Merge remote-tracking branch 'build/master' into class-v6-invention
490
app/igneum-common/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"elliptic-curve",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "igneum-common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom",
|
||||
"k256",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "k256"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
19
app/igneum-common/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "igneum-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "What Igneum Miner and Igneum Wallet share: platform helpers, the payout key code, the signed update manifest, the local dashboard server primitives and the packaged configuration"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
k256 = { version = "0.13", default-features = false, features = ["arithmetic", "std"] }
|
||||
sha3 = { version = "0.10", default-features = false }
|
||||
getrandom = "0.2"
|
||||
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
352
app/igneum-common/src/biometric.rs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
//! The biometric gate, the part that needs no Touch ID and no Windows Hello: challenges the engine issues, the host
|
||||
//! confirms after the prompt, and the action consumes once. The window host (macOS: app/mac/Biometric.swift;
|
||||
//! Windows: the WebView2 host) holds the secret; this module only decides whether a confirmation is fresh.
|
||||
//!
|
||||
//! The flow for a gated action:
|
||||
//! 1. the window asks the engine for a challenge: `Gate::issue(purpose, bound, reason)` gives a nonce; the reason
|
||||
//! (at most 80 characters) is what the prompt shows, built by the engine so the window cannot word it;
|
||||
//! 2. the window hands the nonce to the host; the host reads the reason from the engine (with the host token, which
|
||||
//! only the host knows: the engine printed it on its stdout), shows the prompt, and on success posts
|
||||
//! `Gate::confirm(nonce)`;
|
||||
//! 3. the window calls the action with the nonce; the engine runs `Gate::take(nonce, purpose, bound)`: confirmed
|
||||
//! within CHALLENGE_TTL_S, the same purpose and the same binding (the quote for a send, the new address for an
|
||||
//! address change), never used before. One nonce, one action.
|
||||
//!
|
||||
//! Enrolment (the wallet): the window posts the password to the engine, which checks it and keeps it under a one-time
|
||||
//! token for ENROL_TTL_S; the host takes it with the token after the prompt, seals it and writes the file. The
|
||||
//! password reaches the host over 127.0.0.1 only, never through the page.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A confirmation is fresh for this long after the prompt succeeded.
|
||||
pub const CHALLENGE_TTL_S: u64 = 30;
|
||||
/// An unconfirmed challenge waits for the prompt this long (the confirm screen can sit open).
|
||||
pub const CHALLENGE_WAIT_S: u64 = 180;
|
||||
/// The enrolment token's life.
|
||||
pub const ENROL_TTL_S: u64 = 120;
|
||||
/// The prompt's reason string: at most this many characters (the hard clip); the strings the engines build stay
|
||||
/// under 60, in our voice, no trailing full stop ("Unlock your wallet", "Send 1.5 IGN to 0x7F45…C126").
|
||||
pub const REASON_MAX: usize = 80;
|
||||
/// The idle lock (the wallet): minutes without the window reporting activity before the key is zeroed.
|
||||
pub const IDLE_LOCK_MIN: u64 = 5;
|
||||
|
||||
/// What `/api/state` carries under `biometric`.
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct BiometricState {
|
||||
/// the host said the prompt can be shown (Touch ID set up, Windows Hello configured)
|
||||
pub available: bool,
|
||||
/// touchid | hello | "" (no host yet)
|
||||
pub kind: String,
|
||||
/// the sealed file exists: the gated actions need the prompt
|
||||
pub enrolled: bool,
|
||||
/// the host's word for why it is unavailable, in the window's wording
|
||||
pub message: String,
|
||||
/// the last prompt's outcome: ok | cancelled | failed | locked | invalidated | unavailable | ""
|
||||
pub last_result: String,
|
||||
pub last_op: String,
|
||||
pub last_at: f64,
|
||||
/// the wallet: lock after IDLE_LOCK_MIN minutes idle (setting, on by default once enrolled)
|
||||
pub idle_lock: bool,
|
||||
pub idle_lock_min: u64,
|
||||
/// set when the password changed under an enrolment: the sealed password is stale and was removed
|
||||
pub needs_enrol: bool,
|
||||
}
|
||||
|
||||
pub struct Challenge {
|
||||
pub nonce: String,
|
||||
pub purpose: String,
|
||||
pub bound: String,
|
||||
pub reason: String,
|
||||
issued: Instant,
|
||||
confirmed: Option<Instant>,
|
||||
used: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Gate {
|
||||
challenges: Vec<Challenge>,
|
||||
enrol: Option<(String, String, Instant)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum GateError {
|
||||
Unknown,
|
||||
Expired,
|
||||
NotConfirmed,
|
||||
Stale,
|
||||
Used,
|
||||
Mismatch,
|
||||
}
|
||||
|
||||
impl GateError {
|
||||
/// The window's wording. `what` is "Touch ID" or "Windows Hello".
|
||||
pub fn text(&self, what: &str) -> String {
|
||||
match self {
|
||||
GateError::Unknown => format!("confirm with {what} first"),
|
||||
GateError::Expired => format!("the {what} request timed out; try again"),
|
||||
GateError::NotConfirmed => format!("{what} did not confirm this; try again"),
|
||||
GateError::Stale => format!("the {what} confirmation is older than {CHALLENGE_TTL_S} s; confirm again"),
|
||||
GateError::Used => format!("that {what} confirmation was already used; confirm again"),
|
||||
GateError::Mismatch => format!("the {what} confirmation was for something else; confirm again"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random_hex(n: usize) -> String {
|
||||
let mut raw = vec![0u8; n];
|
||||
getrandom::getrandom(&mut raw).expect("os randomness");
|
||||
crate::keys::hex(&raw)
|
||||
}
|
||||
|
||||
/// Cuts a reason to REASON_MAX characters (not bytes), with a trailing ellipsis when it was longer.
|
||||
pub fn clip_reason(s: &str) -> String {
|
||||
let count = s.chars().count();
|
||||
if count <= REASON_MAX {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut out: String = s.chars().take(REASON_MAX - 1).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
/// The prompt's line for a send: the amount (the caller gives it to 4 decimals) and the checksum address as its
|
||||
/// first 6 and last 4 characters: "Send 1.5 IGN to 0x7F45…C126".
|
||||
pub fn send_reason(amount_ign: &str, display_to: &str) -> String {
|
||||
let short = if display_to.len() > 10 { format!("{}…{}", &display_to[..6], &display_to[display_to.len() - 4..]) } else { display_to.to_string() };
|
||||
clip_reason(&format!("Send {amount_ign} IGN to {short}"))
|
||||
}
|
||||
|
||||
impl Gate {
|
||||
pub fn new() -> Gate {
|
||||
Gate::default()
|
||||
}
|
||||
|
||||
fn prune(&mut self, now: Instant) {
|
||||
// used nonces stay until their window ends, so a replay is answered "used", not "unknown"
|
||||
self.challenges.retain(|c| now.duration_since(c.issued) < Duration::from_secs(CHALLENGE_WAIT_S + CHALLENGE_TTL_S));
|
||||
if let Some((_, _, t)) = &self.enrol {
|
||||
if now.duration_since(*t) >= Duration::from_secs(ENROL_TTL_S) {
|
||||
self.enrol = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn issue(&mut self, purpose: &str, bound: &str, reason: &str, now: Instant) -> String {
|
||||
self.prune(now);
|
||||
let nonce = random_hex(16);
|
||||
self.challenges.push(Challenge { nonce: nonce.clone(), purpose: purpose.into(), bound: bound.into(), reason: clip_reason(reason), issued: now, confirmed: None, used: false });
|
||||
nonce
|
||||
}
|
||||
|
||||
/// For the host: what the prompt says for this nonce.
|
||||
pub fn reason_of(&self, nonce: &str) -> Option<(String, String)> {
|
||||
self.challenges.iter().find(|c| c.nonce == nonce && !c.used).map(|c| (c.purpose.clone(), c.reason.clone()))
|
||||
}
|
||||
|
||||
/// The host says the prompt succeeded for this nonce.
|
||||
pub fn confirm(&mut self, nonce: &str, now: Instant) -> Result<(), GateError> {
|
||||
self.prune(now);
|
||||
let c = self.challenges.iter_mut().find(|c| c.nonce == nonce).ok_or(GateError::Unknown)?;
|
||||
if c.used {
|
||||
return Err(GateError::Used);
|
||||
}
|
||||
if now.duration_since(c.issued) >= Duration::from_secs(CHALLENGE_WAIT_S) {
|
||||
return Err(GateError::Expired);
|
||||
}
|
||||
c.confirmed = Some(now);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The action runs: fresh, the same purpose and binding, once.
|
||||
pub fn take(&mut self, nonce: &str, purpose: &str, bound: &str, now: Instant) -> Result<(), GateError> {
|
||||
let c = self.challenges.iter_mut().find(|c| c.nonce == nonce).ok_or(GateError::Unknown)?;
|
||||
if c.used {
|
||||
return Err(GateError::Used);
|
||||
}
|
||||
let Some(t) = c.confirmed else {
|
||||
return Err(if now.duration_since(c.issued) >= Duration::from_secs(CHALLENGE_WAIT_S) { GateError::Expired } else { GateError::NotConfirmed });
|
||||
};
|
||||
if c.purpose != purpose || c.bound != bound {
|
||||
return Err(GateError::Mismatch);
|
||||
}
|
||||
if now.duration_since(t) >= Duration::from_secs(CHALLENGE_TTL_S) {
|
||||
c.used = true;
|
||||
return Err(GateError::Stale);
|
||||
}
|
||||
c.used = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enrolment: the engine keeps the checked secret under a one-time token.
|
||||
pub fn enrol_begin(&mut self, secret: &str, now: Instant) -> String {
|
||||
let token = random_hex(16);
|
||||
self.enrol = Some((token.clone(), secret.to_string(), now));
|
||||
token
|
||||
}
|
||||
|
||||
/// The host takes the secret with the token, once.
|
||||
pub fn enrol_take(&mut self, token: &str, now: Instant) -> Option<String> {
|
||||
self.prune(now);
|
||||
match self.enrol.take() {
|
||||
Some((t, s, _)) if constant_eq(&t, token) => Some(s),
|
||||
Some(other) => {
|
||||
// a wrong token does not burn the pending enrolment
|
||||
self.enrol = Some(other);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enrol_pending(&self) -> bool {
|
||||
self.enrol.is_some()
|
||||
}
|
||||
|
||||
pub fn enrol_cancel(&mut self) {
|
||||
self.enrol = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn len(&self) -> usize {
|
||||
self.challenges.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal strings, compared in constant time over the longer length.
|
||||
pub fn constant_eq(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
let mut diff = (a.len() ^ b.len()) as u8;
|
||||
for i in 0..a.len().max(b.len()) {
|
||||
diff |= a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0);
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// A fingerprint of a send quote, so the confirmation binds to what the window showed.
|
||||
pub fn send_binding(to: &str, value: &str, tx_nonce: u64, chain_id: u64) -> String {
|
||||
format!("send:{}:{}:{}:{}", to.to_ascii_lowercase(), value, tx_nonce, chain_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t(base: Instant, s: u64) -> Instant {
|
||||
base + Duration::from_secs(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_then_take_once() {
|
||||
let mut g = Gate::new();
|
||||
let now = Instant::now();
|
||||
let n = g.issue("send", "send:0xab:1:0:7", "Send 1 IGN to 0xab", now);
|
||||
assert_eq!(g.reason_of(&n), Some(("send".into(), "Send 1 IGN to 0xab".into())));
|
||||
// not confirmed yet
|
||||
assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 1)), Err(GateError::NotConfirmed));
|
||||
g.confirm(&n, t(now, 2)).unwrap();
|
||||
// wrong binding, wrong purpose
|
||||
assert_eq!(g.take(&n, "send", "send:0xcd:1:0:7", t(now, 3)), Err(GateError::Mismatch));
|
||||
assert_eq!(g.take(&n, "reveal", "send:0xab:1:0:7", t(now, 3)), Err(GateError::Mismatch));
|
||||
// the right one, once
|
||||
assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 3)), Ok(()));
|
||||
assert_eq!(g.take(&n, "send", "send:0xab:1:0:7", t(now, 4)), Err(GateError::Used));
|
||||
assert_eq!(g.confirm(&n, t(now, 4)), Err(GateError::Used));
|
||||
assert!(g.reason_of(&n).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_and_expiry() {
|
||||
let mut g = Gate::new();
|
||||
let now = Instant::now();
|
||||
assert_eq!(g.take("nope", "send", "", now), Err(GateError::Unknown));
|
||||
assert_eq!(g.confirm("nope", now), Err(GateError::Unknown));
|
||||
// a confirmation older than the TTL is stale and burns the nonce
|
||||
let n = g.issue("reveal", "", "Show the words", now);
|
||||
g.confirm(&n, t(now, 1)).unwrap();
|
||||
assert_eq!(g.take(&n, "reveal", "", t(now, 1 + CHALLENGE_TTL_S)), Err(GateError::Stale));
|
||||
assert_eq!(g.take(&n, "reveal", "", t(now, 2)), Err(GateError::Used));
|
||||
// a challenge nobody confirmed within the wait expires
|
||||
let n2 = g.issue("reveal", "", "Show the words", now);
|
||||
assert_eq!(g.confirm(&n2, t(now, CHALLENGE_WAIT_S)), Err(GateError::Expired));
|
||||
assert_eq!(g.take(&n2, "reveal", "", t(now, CHALLENGE_WAIT_S)), Err(GateError::Expired));
|
||||
// pruned away after wait + ttl
|
||||
let _ = g.issue("reveal", "", "x", t(now, CHALLENGE_WAIT_S + CHALLENGE_TTL_S + 1));
|
||||
assert_eq!(g.len(), 1);
|
||||
// and a used nonce still answers "used" inside its window
|
||||
let n4 = g.issue("send", "b", "r", t(now, 1000));
|
||||
g.confirm(&n4, t(now, 1001)).unwrap();
|
||||
assert_eq!(g.take(&n4, "send", "b", t(now, 1002)), Ok(()));
|
||||
assert_eq!(g.confirm(&n4, t(now, 1003)), Err(GateError::Used));
|
||||
// a fresh confirmation at the edge still works
|
||||
let n3 = g.issue("send", "b", "r", now);
|
||||
g.confirm(&n3, t(now, CHALLENGE_WAIT_S - 1)).unwrap();
|
||||
assert_eq!(g.take(&n3, "send", "b", t(now, CHALLENGE_WAIT_S - 1 + CHALLENGE_TTL_S - 1)), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonces_are_distinct_and_hex() {
|
||||
let mut g = Gate::new();
|
||||
let now = Instant::now();
|
||||
let a = g.issue("send", "", "r", now);
|
||||
let b = g.issue("send", "", "r", now);
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(a.len(), 32);
|
||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrol_token_one_time_and_expiry() {
|
||||
let mut g = Gate::new();
|
||||
let now = Instant::now();
|
||||
let tok = g.enrol_begin("correct horse", now);
|
||||
assert!(g.enrol_pending());
|
||||
// a wrong token leaves the pending enrolment in place
|
||||
assert_eq!(g.enrol_take("wrong", t(now, 1)), None);
|
||||
assert!(g.enrol_pending());
|
||||
assert_eq!(g.enrol_take(&tok, t(now, 1)).as_deref(), Some("correct horse"));
|
||||
assert_eq!(g.enrol_take(&tok, t(now, 1)), None);
|
||||
assert!(!g.enrol_pending());
|
||||
// expiry
|
||||
let tok2 = g.enrol_begin("p", now);
|
||||
assert_eq!(g.enrol_take(&tok2, t(now, ENROL_TTL_S)), None);
|
||||
// cancel
|
||||
let tok3 = g.enrol_begin("p", now);
|
||||
g.enrol_cancel();
|
||||
assert_eq!(g.enrol_take(&tok3, t(now, 1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_are_clipped_to_eighty() {
|
||||
let long = "x".repeat(200);
|
||||
assert_eq!(clip_reason(&long).chars().count(), REASON_MAX);
|
||||
assert_eq!(clip_reason("short"), "short");
|
||||
let r = send_reason("1.5", "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
|
||||
assert_eq!(r, "Send 1.5 IGN to 0x7E5F…5Bdf");
|
||||
assert!(r.chars().count() < 60);
|
||||
// a long amount still fits under 60
|
||||
let r2 = send_reason("123456789.1234", "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
|
||||
assert_eq!(r2, "Send 123456789.1234 IGN to 0x7E5F…5Bdf");
|
||||
assert!(r2.chars().count() < 60);
|
||||
// absurd amount: still clipped at 80 characters
|
||||
let r3 = send_reason(&"9".repeat(90), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
|
||||
assert_eq!(r3.chars().count(), REASON_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_eq_and_binding() {
|
||||
assert!(constant_eq("abc", "abc"));
|
||||
assert!(!constant_eq("abc", "abd"));
|
||||
assert!(!constant_eq("abc", "abcd"));
|
||||
assert!(!constant_eq("", "a"));
|
||||
assert_eq!(send_binding("0xAB", "5", 3, 7), "send:0xab:5:3:7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_defaults() {
|
||||
let s = BiometricState::default();
|
||||
assert!(!s.available && !s.enrolled && s.kind.is_empty() && s.last_result.is_empty());
|
||||
let v = serde_json::to_value(&s).unwrap();
|
||||
assert_eq!(v["idle_lock_min"], 0);
|
||||
}
|
||||
}
|
||||
89
app/igneum-common/src/config.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! The packager's configuration next to the binary (`igneum-app.json` for the miner, `igneum-wallet.json` for the
|
||||
//! wallet; macOS: Contents/Resources) and the per-install machine id. From app/igneum-app/src/config.rs.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Written by the packager (build-dmg.sh, make-payload.sh). Missing fields disable the feature.
|
||||
#[derive(Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Packaged {
|
||||
#[serde(default)]
|
||||
pub update_manifest: String,
|
||||
#[serde(default)]
|
||||
pub log_intake_url: String,
|
||||
#[serde(default)]
|
||||
pub log_intake_key: String,
|
||||
#[serde(default)]
|
||||
pub live_page: String,
|
||||
#[serde(default)]
|
||||
pub download_page: String,
|
||||
/// Consensus parameters the packager pins for the bundled node (`--override-params-file`). Absent = none.
|
||||
#[serde(default)]
|
||||
pub node_override_params: Option<serde_json::Value>,
|
||||
/// Wallet: the public Ethereum JSON-RPC of the seed, when one exists (`https://...`); empty = none known.
|
||||
#[serde(default)]
|
||||
pub public_rpc: String,
|
||||
/// Wallet: the page on the site that adds the network to MetaMask (`https://igneum.network/wallet/add`).
|
||||
#[serde(default)]
|
||||
pub add_network_page: String,
|
||||
}
|
||||
|
||||
impl Packaged {
|
||||
pub fn load(candidates: &[PathBuf]) -> Packaged {
|
||||
for c in candidates {
|
||||
if let Some(p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::<Packaged>(&t).ok()) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
Packaged::default()
|
||||
}
|
||||
/// The places the packaged file can be: the binary's folder (Windows), Contents/Resources (macOS), IGNEUM_APP_BIN.
|
||||
pub fn candidates(file_name: &str) -> Vec<PathBuf> {
|
||||
let mut out = vec![];
|
||||
if let Some(d) = std::env::var_os("IGNEUM_APP_BIN") {
|
||||
out.push(PathBuf::from(d).join(file_name));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(d) = exe.parent() {
|
||||
out.push(d.join(file_name));
|
||||
if let Some(c) = d.parent() {
|
||||
out.push(c.join("Resources").join(file_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the machine id, or makes one on the first run (16 hex from the OS) and locks the file to the user.
|
||||
pub fn machine_id(app_dir: &Path) -> String {
|
||||
let path = app_dir.join("machine-id");
|
||||
if let Some(id) = std::fs::read_to_string(&path).ok().map(|s| s.trim().to_ascii_lowercase()) {
|
||||
if id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
let mut raw = [0u8; 8];
|
||||
getrandom::getrandom(&mut raw).expect("os randomness");
|
||||
let id = crate::keys::hex(&raw);
|
||||
let _ = std::fs::create_dir_all(app_dir);
|
||||
crate::platform::lock_permissions(app_dir, true);
|
||||
let _ = std::fs::write(&path, format!("{id}\n"));
|
||||
crate::platform::lock_permissions(&path, false);
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn packaged_carries_the_wallet_fields() {
|
||||
let p: Packaged = serde_json::from_str(r#"{"update_manifest":"","public_rpc":"https://rpc.igneum.network","node_override_params":{"difficulty_v2_activation_daa":123456}}"#).unwrap();
|
||||
assert_eq!(p.public_rpc, "https://rpc.igneum.network");
|
||||
assert_eq!(p.node_override_params.as_ref().unwrap()["difficulty_v2_activation_daa"], 123456);
|
||||
let p: Packaged = serde_json::from_str(r#"{"update_manifest":""}"#).unwrap();
|
||||
assert!(p.node_override_params.is_none());
|
||||
assert!(p.public_rpc.is_empty());
|
||||
}
|
||||
}
|
||||
107
app/igneum-common/src/fetch.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
//! The over-the-air update's network side, as the miner does it (app/igneum-app/src/ota.rs): the manifest and its
|
||||
//! signature fetched with curl (macOS ships it; Windows 10 1803 and later ship curl.exe, so the engine carries no TLS
|
||||
//! stack), verified before parsing; the installer or disk image downloaded next to the manifest with resume (a
|
||||
//! dropped line continues the .part file) and checked against the manifest's sha256 and size.
|
||||
|
||||
use crate::manifest::{self, Manifest, PlatformEntry};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
|
||||
let mut c = Command::new(crate::platform::tool("curl"));
|
||||
c.args(args);
|
||||
let out = crate::run::run_timeout(&mut c, None, limit).ok_or("curl is not available")?;
|
||||
let code = out.lines().last().unwrap_or("").trim().to_string();
|
||||
// 206: a resumed download (-C -) answers partial content
|
||||
if code.starts_with("200") || code.starts_with("206") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("http {}", if code.is_empty() { "no answer".to_string() } else { code }))
|
||||
}
|
||||
}
|
||||
|
||||
/// GET `url` to `dest` with curl; `limit` bounds the whole transfer.
|
||||
pub fn curl_get(url: &str, dest: &Path, limit: Duration) -> Result<(), String> {
|
||||
curl(&["-fsSL", "--max-time", &limit.as_secs().to_string(), "-o", &dest.display().to_string(), "-w", "%{http_code}", url], limit + Duration::from_secs(5))
|
||||
}
|
||||
|
||||
/// Fetches `<url>` and `<url>.sig` into `dir`, verifies the bytes with the embedded OTA public key, parses.
|
||||
/// Writes `manifest.json` to `dir` only after the check.
|
||||
pub fn fetch_manifest(url: &str, dir: &Path) -> Result<Manifest, String> {
|
||||
let m = dir.join("manifest.json.new");
|
||||
let s = dir.join("manifest.json.sig.new");
|
||||
curl_get(url, &m, Duration::from_secs(30)).map_err(|e| format!("manifest: {e}"))?;
|
||||
curl_get(&format!("{url}.sig"), &s, Duration::from_secs(30)).map_err(|e| format!("manifest signature: {e}"))?;
|
||||
let bytes = std::fs::read(&m).map_err(|e| e.to_string())?;
|
||||
let sig = std::fs::read_to_string(&s).map_err(|e| e.to_string())?;
|
||||
let parsed = manifest::verify_and_parse(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?;
|
||||
let _ = std::fs::rename(&m, dir.join("manifest.json"));
|
||||
let _ = std::fs::rename(&s, dir.join("manifest.json.sig"));
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// The file name a download keeps: the last path segment of the url, cleaned to [A-Za-z0-9.-_] (the miner's rule).
|
||||
pub fn file_name(url: &str) -> String {
|
||||
let name = url.rsplit('/').next().unwrap_or("download").split('?').next().unwrap_or("download");
|
||||
let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_').collect();
|
||||
if clean.is_empty() { "download".into() } else { clean }
|
||||
}
|
||||
|
||||
/// The .part file a download in progress writes next to the final name.
|
||||
pub fn part_path(e: &PlatformEntry, dir: &Path) -> PathBuf {
|
||||
dir.join(format!("{}.part", file_name(&e.url)))
|
||||
}
|
||||
|
||||
/// How much of the platform entry is on disk right now, 0..1 (the dashboard's progress bar).
|
||||
pub fn progress(e: &PlatformEntry, dir: &Path) -> f64 {
|
||||
if e.size == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let have = std::fs::metadata(part_path(e, dir)).map(|m| m.len()).unwrap_or(0);
|
||||
(have as f64 / e.size as f64).min(1.0)
|
||||
}
|
||||
|
||||
/// Downloads the platform entry into `dir` (skipped when a file with the right size and sha256 is there already),
|
||||
/// resuming a .part file from an earlier try, and checks size and sha256. Returns the path.
|
||||
pub fn download(e: &PlatformEntry, dir: &Path) -> Result<PathBuf, String> {
|
||||
let dest = dir.join(file_name(&e.url));
|
||||
let ok = |p: &Path| -> bool {
|
||||
std::fs::metadata(p).map(|m| m.len() == e.size).unwrap_or(false) && manifest::sha256_file(p).map(|s| s == e.sha256).unwrap_or(false)
|
||||
};
|
||||
if ok(&dest) {
|
||||
return Ok(dest);
|
||||
}
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
let part = part_path(e, dir);
|
||||
let have = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
|
||||
if have > e.size {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
}
|
||||
if have != e.size {
|
||||
// -C - resumes a partial file; --retry covers a dropped connection; 2 hours for a slow line
|
||||
curl(&["-fsSL", "--retry", "3", "--retry-delay", "5", "-C", "-", "--max-time", "7200", "-o", &part.display().to_string(), "-w", "%{http_code}", &e.url], Duration::from_secs(7260))?;
|
||||
}
|
||||
let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
|
||||
if size != e.size {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(format!("the download is {size} bytes, the manifest says {}", e.size));
|
||||
}
|
||||
let sum = manifest::sha256_file(&part).map_err(|e| e.to_string())?;
|
||||
if sum != e.sha256 {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err("the download's sha256 does not match the manifest".into());
|
||||
}
|
||||
std::fs::rename(&part, &dest).map_err(|e| e.to_string())?;
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn file_names_are_cleaned() {
|
||||
assert_eq!(super::file_name("https://dl.igneum.network/dl/t/Igneum-Wallet-0.1.1.dmg"), "Igneum-Wallet-0.1.1.dmg");
|
||||
assert_eq!(super::file_name("https://x/y/Setup%20.exe?x=1"), "Setup20.exe");
|
||||
assert_eq!(super::file_name("https://x/"), "download");
|
||||
}
|
||||
}
|
||||
258
app/igneum-common/src/http.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
//! The local dashboard server primitives (from app/igneum-app/src/server.rs): plain HTTP/1.1 on 127.0.0.1 with a
|
||||
//! random port, every path under `/t/<token>/`, one thread per connection, Connection: close. And a small JSON client
|
||||
//! for a node's Ethereum JSON-RPC on 127.0.0.1 (no TLS: the wallet reaches a public https RPC through curl instead).
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
pub struct Req {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub query: String,
|
||||
pub body: Vec<u8>,
|
||||
pub origin: Option<String>,
|
||||
pub sec_fetch_site: Option<String>,
|
||||
pub host: Option<String>,
|
||||
/// X-Igneum-Host: the window host's token (the engine printed it on stdout; the page never sees it)
|
||||
pub host_token: Option<String>,
|
||||
/// X-Igneum-Bridge: present on a page's call to the wallet's page bridge (a custom header, so the browser sends a
|
||||
/// CORS preflight first and a plain form post from a stranger never reaches the handler)
|
||||
pub bridge_header: bool,
|
||||
/// Access-Control-Request-Private-Network: the browser asks (Chrome's private network access) before a page on the
|
||||
/// public web reaches 127.0.0.1; the bridge answers the preflight with the allow header
|
||||
pub private_network_ask: bool,
|
||||
}
|
||||
|
||||
pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
|
||||
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
|
||||
let mut reader = BufReader::new(stream.try_clone().ok()?);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).ok()?;
|
||||
let mut parts = line.split_whitespace();
|
||||
let method = parts.next()?.to_string();
|
||||
let target = parts.next()?.to_string();
|
||||
let mut content_length = 0usize;
|
||||
let (mut origin, mut sec_fetch_site, mut host, mut host_token) = (None, None, None, None);
|
||||
let (mut bridge_header, mut private_network_ask) = (false, false);
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
reader.read_line(&mut h).ok()?;
|
||||
let h = h.trim_end();
|
||||
if h.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((k, v)) = h.split_once(':') {
|
||||
let v = v.trim();
|
||||
if k.eq_ignore_ascii_case("content-length") {
|
||||
content_length = v.parse().unwrap_or(0);
|
||||
} else if k.eq_ignore_ascii_case("origin") {
|
||||
origin = Some(v.to_string());
|
||||
} else if k.eq_ignore_ascii_case("sec-fetch-site") {
|
||||
sec_fetch_site = Some(v.to_ascii_lowercase());
|
||||
} else if k.eq_ignore_ascii_case("host") {
|
||||
host = Some(v.to_string());
|
||||
} else if k.eq_ignore_ascii_case("x-igneum-host") {
|
||||
host_token = Some(v.to_string());
|
||||
} else if k.eq_ignore_ascii_case("x-igneum-bridge") {
|
||||
bridge_header = true;
|
||||
} else if k.eq_ignore_ascii_case("access-control-request-private-network") {
|
||||
private_network_ask = v.eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
}
|
||||
if content_length > 1 << 20 {
|
||||
return None;
|
||||
}
|
||||
let mut body = vec![0u8; content_length];
|
||||
if content_length > 0 {
|
||||
reader.read_exact(&mut body).ok()?;
|
||||
}
|
||||
let (path, query) = match target.split_once('?') {
|
||||
Some((p, q)) => (p.to_string(), q.to_string()),
|
||||
None => (target, String::new()),
|
||||
};
|
||||
Some(Req { method, path, query, body, origin, sec_fetch_site, host, host_token, bridge_header, private_network_ask })
|
||||
}
|
||||
|
||||
/// R4.3.7: a mutating request must come from the dashboard itself. A browser sends Sec-Fetch-Site (same-origin for
|
||||
/// the dashboard; cross-site, same-site or none otherwise) and, on POST, an Origin; a cross-site page can reach
|
||||
/// 127.0.0.1 only through the browser, so both are checked. A request without either header (curl, the window host)
|
||||
/// still needs the token in the path.
|
||||
pub fn from_dashboard(req: &Req, port: u16) -> bool {
|
||||
if let Some(s) = &req.sec_fetch_site {
|
||||
if s != "same-origin" && s != "none" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(o) = &req.origin {
|
||||
let ok = o == &format!("http://127.0.0.1:{port}") || o == &format!("http://localhost:{port}");
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(h) = &req.host {
|
||||
if h != &format!("127.0.0.1:{port}") && h != &format!("localhost:{port}") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn respond(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], cache: bool) {
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
204 => "No Content",
|
||||
400 => "Bad Request",
|
||||
403 => "Forbidden",
|
||||
404 => "Not Found",
|
||||
405 => "Method Not Allowed",
|
||||
_ => "Error",
|
||||
};
|
||||
let head = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: {}\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n\r\n",
|
||||
body.len(),
|
||||
if cache { "public, max-age=86400" } else { "no-store" }
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
let _ = stream.write_all(body);
|
||||
let _ = stream.flush();
|
||||
}
|
||||
|
||||
pub fn json_resp(stream: &mut TcpStream, status: u16, v: serde_json::Value) {
|
||||
respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false);
|
||||
}
|
||||
|
||||
pub fn query_param(q: &str, key: &str) -> Option<String> {
|
||||
q.split('&').find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
if k == key { Some(v.to_string()) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
/// Binds 127.0.0.1 on a random port and serves every connection on its own thread through `handle`.
|
||||
pub fn serve<F>(handle: F) -> std::io::Result<u16>
|
||||
where
|
||||
F: Fn(TcpStream) + Send + Sync + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let handle = std::sync::Arc::new(handle);
|
||||
std::thread::spawn(move || {
|
||||
for conn in listener.incoming() {
|
||||
let Ok(stream) = conn else { continue };
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || handle(stream));
|
||||
}
|
||||
});
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Binds 127.0.0.1 on a FIXED port (the wallet's page bridge, 8 October 2026: a page on the web can only find the
|
||||
/// wallet at a port it knows) and serves every connection on its own thread through `handle`. Err when the port is taken.
|
||||
pub fn serve_on<F>(port: u16, handle: F) -> std::io::Result<()>
|
||||
where
|
||||
F: Fn(TcpStream) + Send + Sync + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let handle = std::sync::Arc::new(handle);
|
||||
std::thread::spawn(move || {
|
||||
for conn in listener.incoming() {
|
||||
let Ok(stream) = conn else { continue };
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || handle(stream));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A response with extra headers (the bridge's CORS and private-network answers), Connection: close, no cache.
|
||||
pub fn respond_with(stream: &mut TcpStream, status: u16, ctype: &str, body: &[u8], extra: &[(&str, &str)]) {
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
204 => "No Content",
|
||||
400 => "Bad Request",
|
||||
403 => "Forbidden",
|
||||
404 => "Not Found",
|
||||
405 => "Method Not Allowed",
|
||||
_ => "Error",
|
||||
};
|
||||
let mut head = format!("HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n", body.len());
|
||||
for (k, v) in extra {
|
||||
head.push_str(k);
|
||||
head.push_str(": ");
|
||||
head.push_str(v);
|
||||
head.push_str("\r\n");
|
||||
}
|
||||
head.push_str("\r\n");
|
||||
let _ = stream.write_all(head.as_bytes());
|
||||
let _ = stream.write_all(body);
|
||||
let _ = stream.flush();
|
||||
}
|
||||
|
||||
/// The per-launch dashboard token: 32 hex characters from the OS.
|
||||
pub fn new_token() -> String {
|
||||
let mut raw = [0u8; 16];
|
||||
getrandom::getrandom(&mut raw).expect("os randomness");
|
||||
crate::keys::hex(&raw)
|
||||
}
|
||||
|
||||
// ---- a JSON POST to 127.0.0.1 (the node's Ethereum RPC) -------------------------------------------------------
|
||||
|
||||
/// POSTs `body` as JSON to `http://127.0.0.1:<port><path>` (or any plain-http host:port) and returns the body.
|
||||
pub fn post_json(host_port: &str, path: &str, body: &str, timeout: std::time::Duration) -> Result<String, String> {
|
||||
let mut s = TcpStream::connect(host_port).map_err(|e| format!("connect {host_port}: {e}"))?;
|
||||
let _ = s.set_read_timeout(Some(timeout));
|
||||
let _ = s.set_write_timeout(Some(timeout));
|
||||
let req = format!(
|
||||
"POST {path} HTTP/1.1\r\nHost: {host_port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
s.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
|
||||
let mut reader = BufReader::new(s);
|
||||
let mut status = String::new();
|
||||
reader.read_line(&mut status).map_err(|e| e.to_string())?;
|
||||
let code: u16 = status.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
|
||||
let mut len: Option<usize> = None;
|
||||
let mut chunked = false;
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
reader.read_line(&mut h).map_err(|e| e.to_string())?;
|
||||
let h = h.trim_end();
|
||||
if h.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((k, v)) = h.split_once(':') {
|
||||
if k.eq_ignore_ascii_case("content-length") {
|
||||
len = v.trim().parse().ok();
|
||||
} else if k.eq_ignore_ascii_case("transfer-encoding") && v.trim().eq_ignore_ascii_case("chunked") {
|
||||
chunked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if chunked {
|
||||
loop {
|
||||
let mut l = String::new();
|
||||
reader.read_line(&mut l).map_err(|e| e.to_string())?;
|
||||
let n = usize::from_str_radix(l.trim().split(';').next().unwrap_or("0"), 16).unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let mut buf = vec![0u8; n];
|
||||
reader.read_exact(&mut buf).map_err(|e| e.to_string())?;
|
||||
out.extend_from_slice(&buf);
|
||||
let mut crlf = String::new();
|
||||
let _ = reader.read_line(&mut crlf);
|
||||
}
|
||||
} else if let Some(n) = len {
|
||||
out = vec![0u8; n];
|
||||
reader.read_exact(&mut out).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
reader.read_to_end(&mut out).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out).into_owned();
|
||||
if code != 200 {
|
||||
return Err(format!("http {code}: {}", text.chars().take(200).collect::<String>()));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
109
app/igneum-common/src/keys.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! The payout key: a secp256k1 private key made on this machine, its EVM address, and the miner's wallet file.
|
||||
//! The miner's file lives in its app data directory (`app/wallet.json`), plain JSON with the permissions locked to the
|
||||
//! user: 0600 on macOS and Linux, an icacls grant to the signed-in user alone on Windows. Igneum Wallet imports that
|
||||
//! file and keeps its own key encrypted (app/igneum-wallet/src/vault.rs). From app/igneum-app/src/keys.rs.
|
||||
|
||||
use k256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use k256::SecretKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha3::{Digest, Keccak256};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Wallet {
|
||||
pub address: String, // 0x + 40 lower-case hex
|
||||
pub private_key: String, // 0x + 64 hex, secp256k1
|
||||
pub created: u64, // unix seconds
|
||||
}
|
||||
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Hex (with or without 0x) to bytes; None when not hex or odd length.
|
||||
pub fn unhex(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim().trim_start_matches("0x");
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
|
||||
}
|
||||
|
||||
/// The lower-case 0x address of a raw 32-byte private key, or None when the scalar is not a valid key.
|
||||
pub fn address_of_raw(raw: &[u8; 32]) -> Option<String> {
|
||||
SecretKey::from_slice(raw).ok().map(|sk| address_of(&sk))
|
||||
}
|
||||
|
||||
/// The EVM address of a secp256k1 private key: keccak256 of the uncompressed public key (without the 0x04 prefix), last 20 bytes.
|
||||
pub fn address_of(sk: &SecretKey) -> String {
|
||||
let pk = sk.public_key();
|
||||
let point = pk.to_encoded_point(false);
|
||||
let bytes = point.as_bytes();
|
||||
let h = Keccak256::digest(&bytes[1..]);
|
||||
format!("0x{}", hex(&h[12..]))
|
||||
}
|
||||
|
||||
/// A fresh key from the operating system's randomness.
|
||||
pub fn generate() -> Wallet {
|
||||
loop {
|
||||
let mut raw = [0u8; 32];
|
||||
getrandom::getrandom(&mut raw).expect("os randomness");
|
||||
if let Ok(sk) = SecretKey::from_slice(&raw) {
|
||||
let address = address_of(&sk);
|
||||
return Wallet { address, private_key: format!("0x{}", hex(&raw)), created: crate::platform::unix_now() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// EIP-55 mixed-case form of a lower-case address, for display.
|
||||
pub fn checksum(addr: &str) -> String {
|
||||
let body = addr.trim_start_matches("0x").to_ascii_lowercase();
|
||||
let h = Keccak256::digest(body.as_bytes());
|
||||
let mut out = String::with_capacity(42);
|
||||
out.push_str("0x");
|
||||
for (i, c) in body.chars().enumerate() {
|
||||
let nibble = (h[i / 2] >> (if i % 2 == 0 { 4 } else { 0 })) & 0xf;
|
||||
if c.is_ascii_alphabetic() && nibble >= 8 {
|
||||
out.push(c.to_ascii_uppercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True for 0x followed by 40 hex characters.
|
||||
pub fn valid_address(s: &str) -> bool {
|
||||
let s = s.trim();
|
||||
s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
pub fn save(path: &Path, w: &Wallet) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
crate::platform::lock_permissions(dir, true);
|
||||
}
|
||||
let text = serde_json::to_string_pretty(w).expect("wallet json");
|
||||
std::fs::write(path, text)?;
|
||||
crate::platform::lock_permissions(path, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Option<Wallet> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_vector() {
|
||||
// The well-known test key 0x01: address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
|
||||
let mut raw = [0u8; 32];
|
||||
raw[31] = 1;
|
||||
let sk = SecretKey::from_slice(&raw).unwrap();
|
||||
assert_eq!(checksum(&address_of(&sk)), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
|
||||
}
|
||||
}
|
||||
46
app/igneum-common/src/lib.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! igneum-common: the parts of the Igneum Miner engine (app/igneum-app) that Igneum Wallet (app/igneum-wallet) ships
|
||||
//! on too. Extracted 4 October 2026 as a copy with the app identity made a parameter; the miner keeps its own copies
|
||||
//! until its concurrent branches land and can switch to this crate behind a feature flag (nothing in app/igneum-app
|
||||
//! was changed for the wallet).
|
||||
//!
|
||||
//! - `platform`: directories, file permissions, opening a URL, start at login, keep awake, terminate, quarantine
|
||||
//! - `keys`: secp256k1 key, EVM address, EIP-55 checksum, the miner's plain `wallet.json` format
|
||||
//! - `manifest`: the signed over-the-air update manifest, byte-identical to app/igneum-app/src/manifest.rs
|
||||
//! - `fetch`: the manifest fetch, the download (resumed, as the miner's) and its sha256 check (through curl)
|
||||
//! - `ota`: the apply side of an over-the-air update: the staged bundle, the detached helper that swaps and relaunches,
|
||||
//! the pending/result files; from app/igneum-app/src/ota.rs with the app's names from `AppId`
|
||||
//! - `http`: the 127.0.0.1 dashboard server primitives (request parsing, the token path, the same-origin guard) and a
|
||||
//! small JSON-over-HTTP client for a node's Ethereum RPC on 127.0.0.1
|
||||
//! - `run`: a command with a time limit
|
||||
//! - `config`: the packager's `igneum-app.json` and the per-install machine id
|
||||
//! - `biometric`: the Touch ID / Windows Hello gate logic (nonces, one-time enrolment token, state); the prompt and
|
||||
//! the sealed secret live in the window hosts
|
||||
|
||||
pub mod biometric;
|
||||
pub mod config;
|
||||
pub mod fetch;
|
||||
pub mod http;
|
||||
pub mod keys;
|
||||
pub mod manifest;
|
||||
pub mod ota;
|
||||
pub mod platform;
|
||||
pub mod run;
|
||||
|
||||
/// What differs between the two apps when the platform code names them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AppId {
|
||||
/// "Igneum Miner" or "Igneum Wallet": the window title, the login item name, the Windows Run key value.
|
||||
pub name: &'static str,
|
||||
/// "network.igneum.miner" or "network.igneum.wallet": the macOS bundle id and launch agent label.
|
||||
pub bundle: &'static str,
|
||||
/// The Windows window host next to the engine: "Igneum Miner.exe" or "Igneum Wallet.exe".
|
||||
pub host_exe: &'static str,
|
||||
/// The sub-folder of the shared data root this app writes under: "app" (the miner, as before) or "wallet".
|
||||
pub data_sub: &'static str,
|
||||
/// The engine binary next to the window host: "igneum-app" or "igneum-wallet" (".exe" on Windows). The update
|
||||
/// helper names it when it checks that the new app started.
|
||||
pub engine_exe: &'static str,
|
||||
}
|
||||
|
||||
pub const MINER: AppId = AppId { name: "Igneum Miner", bundle: "network.igneum.miner", host_exe: "Igneum Miner.exe", data_sub: "app", engine_exe: "igneum-app" };
|
||||
pub const WALLET: AppId = AppId { name: "Igneum Wallet", bundle: "network.igneum.wallet", host_exe: "Igneum Wallet.exe", data_sub: "wallet", engine_exe: "igneum-wallet" };
|
||||
527
app/igneum-common/src/manifest.rs
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
//! The over-the-air update manifest: what the downloads host publishes as `igneum-app-latest.json` with a detached
|
||||
//! Ed25519 signature next to it (`igneum-app-latest.json.sig`, 64 bytes as 128 hex characters). The signature is over
|
||||
//! the exact bytes of the manifest file; the publisher (packaging/ota/publish-manifest.sh) writes the file in canonical
|
||||
//! form (sorted keys, no whitespace) and the app verifies the bytes before it parses them.
|
||||
//!
|
||||
//! This module is self-contained (serde_json, ed25519-dalek, sha2 only), so the signer binary
|
||||
//! (src/bin/ota-sign.rs) includes it with `#[path]` and signs with the very code that verifies.
|
||||
//!
|
||||
//! Manifest shape:
|
||||
//! {
|
||||
//! "version": "0.3.1", "published_at": "2026-10-04T13:00:00Z", "channel": "devnet",
|
||||
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
|
||||
//! "min_supported_version": "0.3.0", "notes": "one line",
|
||||
//! "consensus": { "activation_height": null|number, "deadline_note": "" }
|
||||
//! }
|
||||
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// The public half of ~/.config/igneum/ota-signing-key (generated once on the Mac with `igneum-ota-sign keygen`;
|
||||
/// the private key never enters the repo or CI). Fingerprint: SHA-256 of these 32 bytes, see `fingerprint()`.
|
||||
pub const OTA_PUBLIC_KEY_HEX: &str = "b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd";
|
||||
|
||||
/// How many DAA blocks before a consensus activation height the app stops waiting for a safe moment.
|
||||
pub const FORK_URGENT_BLOCKS: u64 = 1_800;
|
||||
/// No apply within this many seconds of an hourly program boundary.
|
||||
pub const BOUNDARY_GUARD_S: i64 = 180;
|
||||
/// A ready update that found no safe moment for this long is applied anyway (an unsynced node mines nothing).
|
||||
pub const SAFE_MOMENT_PATIENCE_S: u64 = 6 * 3600;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct PlatformEntry {
|
||||
pub url: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub kind: String, // dmg | zip | inno-setup
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Manifest {
|
||||
pub version: String,
|
||||
pub published_at: String,
|
||||
pub channel: String,
|
||||
pub mac: Option<PlatformEntry>,
|
||||
pub windows: Option<PlatformEntry>,
|
||||
pub min_supported_version: String,
|
||||
pub notes: String,
|
||||
pub activation_height: Option<u64>,
|
||||
pub deadline_note: String,
|
||||
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
|
||||
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest.
|
||||
pub override_params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn platform(&self, name: &str) -> Option<&PlatformEntry> {
|
||||
match name {
|
||||
"mac" => self.mac.as_ref(),
|
||||
"windows" => self.windows.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn this_platform(&self) -> Option<&PlatformEntry> {
|
||||
self.platform(platform_name())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn platform_name() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"mac"
|
||||
} else if cfg!(windows) {
|
||||
"windows"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
|
||||
}
|
||||
|
||||
pub fn hex_encode(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn public_key(hex: &str) -> Result<VerifyingKey, String> {
|
||||
let bytes = hex_decode(hex).ok_or("public key is not hex")?;
|
||||
let arr: [u8; 32] = bytes.try_into().map_err(|_| "public key is not 32 bytes")?;
|
||||
VerifyingKey::from_bytes(&arr).map_err(|e| format!("public key invalid: {e}"))
|
||||
}
|
||||
|
||||
/// SHA-256 of the raw public key bytes, as hex: what the report and the docs quote.
|
||||
pub fn fingerprint(pub_hex: &str) -> String {
|
||||
match hex_decode(pub_hex) {
|
||||
Some(b) => hex_encode(&Sha256::digest(&b)),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the detached signature (hex) over the manifest bytes with the given public key (hex).
|
||||
pub fn verify_signature(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<(), String> {
|
||||
let key = public_key(pub_hex)?;
|
||||
let sig = hex_decode(sig_hex).ok_or("signature is not hex")?;
|
||||
let sig: [u8; 64] = sig.try_into().map_err(|_| "signature is not 64 bytes")?;
|
||||
let sig = Signature::from_bytes(&sig);
|
||||
key.verify(manifest_bytes, &sig).map_err(|_| "manifest signature does not verify".to_string())
|
||||
}
|
||||
|
||||
/// Parses the manifest JSON (after the signature was checked).
|
||||
pub fn parse(text: &str) -> Result<Manifest, String> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("manifest is not JSON: {e}"))?;
|
||||
let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let version = s(&v, "version");
|
||||
if parse_version(&version).is_none() {
|
||||
return Err(format!("manifest version '{version}' is not a version"));
|
||||
}
|
||||
let entry = |name: &str| -> Result<Option<PlatformEntry>, String> {
|
||||
let Some(p) = v.get("platforms").and_then(|p| p.get(name)) else { return Ok(None) };
|
||||
if p.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let e = PlatformEntry { url: s(p, "url"), sha256: s(p, "sha256").to_ascii_lowercase(), size: p.get("size").and_then(|x| x.as_u64()).unwrap_or(0), kind: s(p, "kind") };
|
||||
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
|
||||
return Err(format!("{name}: the url is not https"));
|
||||
}
|
||||
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!("{name}: sha256 is not 64 hex characters"));
|
||||
}
|
||||
if e.size == 0 {
|
||||
return Err(format!("{name}: size is missing"));
|
||||
}
|
||||
if !["dmg", "zip", "inno-setup"].contains(&e.kind.as_str()) {
|
||||
return Err(format!("{name}: kind '{}' is unknown", e.kind));
|
||||
}
|
||||
Ok(Some(e))
|
||||
};
|
||||
let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null);
|
||||
Ok(Manifest {
|
||||
version,
|
||||
published_at: s(&v, "published_at"),
|
||||
channel: s(&v, "channel"),
|
||||
mac: entry("mac")?,
|
||||
windows: entry("windows")?,
|
||||
min_supported_version: s(&v, "min_supported_version"),
|
||||
notes: s(&v, "notes"),
|
||||
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
|
||||
deadline_note: s(&consensus, "deadline_note"),
|
||||
override_params: match consensus.get("override") {
|
||||
Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()),
|
||||
Some(o) if !o.is_null() => return Err("consensus.override must be an object".into()),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies, then parses.
|
||||
pub fn verify_and_parse(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<Manifest, String> {
|
||||
verify_signature(manifest_bytes, sig_hex, pub_hex)?;
|
||||
let text = std::str::from_utf8(manifest_bytes).map_err(|_| "manifest is not UTF-8")?;
|
||||
parse(text)
|
||||
}
|
||||
|
||||
/// A digest of a whole directory (the staged app bundle): sha256 over "relative path\nsha256 of the file\n" for every
|
||||
/// regular file in byte-sorted path order (symlinks skipped). The macOS helper recomputes the same with find, sort
|
||||
/// and shasum right before the swap (R4.3.5).
|
||||
pub fn digest_dir(root: &std::path::Path) -> std::io::Result<String> {
|
||||
fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<String>) -> std::io::Result<()> {
|
||||
for e in std::fs::read_dir(dir)? {
|
||||
let e = e?;
|
||||
let ft = e.file_type()?;
|
||||
let p = e.path();
|
||||
if ft.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if ft.is_dir() {
|
||||
walk(&p, root, out)?;
|
||||
} else if ft.is_file() {
|
||||
out.push(p.strip_prefix(root).unwrap_or(&p).to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk(root, root, &mut files)?;
|
||||
files.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
|
||||
let mut h = Sha256::new();
|
||||
for f in files {
|
||||
let sum = sha256_file(&root.join(&f))?;
|
||||
h.update(f.as_bytes());
|
||||
h.update(b"\n");
|
||||
h.update(sum.as_bytes());
|
||||
h.update(b"\n");
|
||||
}
|
||||
Ok(hex_encode(&h.finalize()))
|
||||
}
|
||||
|
||||
/// SHA-256 of a file, streamed, as hex.
|
||||
pub fn sha256_file(path: &std::path::Path) -> std::io::Result<String> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = f.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(&buf[..n]);
|
||||
}
|
||||
Ok(hex_encode(&h.finalize()))
|
||||
}
|
||||
|
||||
// ---- versions -----------------------------------------------------------------------------------------------
|
||||
|
||||
/// major.minor.patch plus an optional pre-release tag ("0.4.0-rc1" sorts before "0.4.0"). A leading "v" is allowed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Version {
|
||||
pub parts: [u64; 3],
|
||||
pub pre: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_version(s: &str) -> Option<Version> {
|
||||
let s = s.trim().trim_start_matches('v');
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (num, pre) = match s.split_once('-') {
|
||||
Some((n, p)) if !p.is_empty() => (n, Some(p.to_string())),
|
||||
Some(_) => return None,
|
||||
None => (s, None),
|
||||
};
|
||||
let mut parts = [0u64; 3];
|
||||
let mut n = 0;
|
||||
for p in num.split('.') {
|
||||
if n >= 3 || p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
parts[n] = p.parse().ok()?;
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Version { parts, pre })
|
||||
}
|
||||
|
||||
impl PartialOrd for Version {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Version {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match self.parts.cmp(&other.parts) {
|
||||
std::cmp::Ordering::Equal => match (&self.pre, &other.pre) {
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(Some(a), Some(b)) => a.cmp(b),
|
||||
},
|
||||
o => o,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `latest` is a newer version than `current`. Unparseable input is never newer.
|
||||
pub fn newer(latest: &str, current: &str) -> bool {
|
||||
match (parse_version(latest), parse_version(current)) {
|
||||
(Some(a), Some(b)) => a > b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- when to apply --------------------------------------------------------------------------------------------
|
||||
|
||||
/// What the engine knows when it asks whether now is a safe moment.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Moment {
|
||||
pub node_synced: bool,
|
||||
/// DAA blocks (about seconds) to the next hourly program boundary; None when the node has not said.
|
||||
pub boundary_eta_s: Option<i64>,
|
||||
/// A worker is starting, exporting a pack or being built: let it finish.
|
||||
pub miner_busy: bool,
|
||||
/// How long the update has been ready and waiting.
|
||||
pub ready_for_s: u64,
|
||||
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
|
||||
pub urgent: bool,
|
||||
/// This minute is the machine's own slot (slot_minute): machines take turns, two never restart together.
|
||||
pub slot_ok: bool,
|
||||
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
|
||||
pub network_drop_pct: f64,
|
||||
}
|
||||
|
||||
/// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet).
|
||||
pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0;
|
||||
|
||||
/// The minute of the hour in which this machine applies updates: the first 8 hex of the machine id modulo 60.
|
||||
pub fn slot_minute(id8: &str) -> u64 {
|
||||
u64::from_str_radix(id8.trim(), 16).unwrap_or(0) % 60
|
||||
}
|
||||
|
||||
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
|
||||
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
||||
if m.urgent {
|
||||
return Ok(());
|
||||
}
|
||||
if m.network_drop_pct > NETWORK_DROP_HOLD_PCT {
|
||||
return Err(format!("the network lost {:.0}% of its identities in the last 10 minutes; holding the update", m.network_drop_pct));
|
||||
}
|
||||
if !m.slot_ok {
|
||||
return Err("waiting for this machine's own minute of the hour (machines take turns)".into());
|
||||
}
|
||||
if m.ready_for_s >= SAFE_MOMENT_PATIENCE_S {
|
||||
return Ok(());
|
||||
}
|
||||
if !m.node_synced {
|
||||
return Err("waiting for the node to sync".into());
|
||||
}
|
||||
if let Some(eta) = m.boundary_eta_s {
|
||||
if (0..=BOUNDARY_GUARD_S).contains(&eta) {
|
||||
return Err(format!("hourly program boundary in {} s; installing after it", eta.max(1)));
|
||||
}
|
||||
}
|
||||
if m.miner_busy {
|
||||
return Err("a worker is starting; installing once it runs".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score).
|
||||
pub fn fork_is_close(activation_height: Option<u64>, daa: u64) -> bool {
|
||||
match activation_height {
|
||||
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `current` is older than the manifest's min_supported_version.
|
||||
pub fn unsupported(m: &Manifest, current: &str) -> bool {
|
||||
!m.min_supported_version.is_empty() && newer(&m.min_supported_version, current)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
/// The helper's bash recipe (find | sort | shasum per file | shasum) must equal digest_dir.
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn digest_dir_matches_the_helper() {
|
||||
let root = std::env::temp_dir().join(format!("igneum-digest-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("Contents/MacOS")).unwrap();
|
||||
std::fs::write(root.join("Contents/MacOS/igneum-app"), b"engine").unwrap();
|
||||
std::fs::write(root.join("Contents/Info.plist"), b"<plist/>").unwrap();
|
||||
std::fs::write(root.join("Contents/zed.txt"), b"z").unwrap();
|
||||
let ours = digest_dir(&root).unwrap();
|
||||
let recipe = r#"(cd "$1" && /usr/bin/find . -type f -print | LC_ALL=C /usr/bin/sort | while IFS= read -r f; do printf '%s\n%s\n' "${f#./}" "$(/usr/bin/shasum -a 256 "$f" | /usr/bin/cut -d' ' -f1)"; done) | /usr/bin/shasum -a 256 | /usr/bin/cut -d' ' -f1"#;
|
||||
let out = std::process::Command::new("/bin/bash").args(["-c", recipe, "x", &root.display().to_string()]).output().unwrap();
|
||||
let theirs = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(ours, theirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consensus_override_parses() {
|
||||
let m = parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"activation_height":5000,"override":{"difficulty_v2_activation_daa":5000}}}"#).unwrap();
|
||||
assert_eq!(m.activation_height, Some(5000));
|
||||
assert_eq!(m.override_params.unwrap()["difficulty_v2_activation_daa"], 5000);
|
||||
assert!(parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"override":"no"}}"#).is_err());
|
||||
}
|
||||
|
||||
const SAMPLE: &str = r#"{"channel":"devnet","consensus":{"activation_height":120000,"deadline_note":"difficulty v2"},"min_supported_version":"0.3.0","notes":"difficulty v2 at height 120000","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":20588331,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-0.3.1.dmg"},"windows":{"kind":"inno-setup","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":43978429,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-Setup-0.3.1.exe"}},"published_at":"2026-10-04T13:00:00Z","version":"0.3.1"}"#;
|
||||
|
||||
fn key() -> (SigningKey, String) {
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = hex_encode(sk.verifying_key().as_bytes());
|
||||
(sk, pk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_manifest() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert_eq!(m.version, "0.3.1");
|
||||
assert_eq!(m.channel, "devnet");
|
||||
assert_eq!(m.activation_height, Some(120000));
|
||||
assert_eq!(m.deadline_note, "difficulty v2");
|
||||
assert_eq!(m.min_supported_version, "0.3.0");
|
||||
let mac = m.mac.as_ref().unwrap();
|
||||
assert_eq!(mac.kind, "dmg");
|
||||
assert_eq!(mac.size, 20588331);
|
||||
assert_eq!(m.windows.as_ref().unwrap().kind, "inno-setup");
|
||||
assert_eq!(m.platform("linux"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_platform_is_none_and_bad_entries_fail() {
|
||||
let m = parse(r#"{"version":"0.3.1","platforms":{"mac":null},"consensus":{"activation_height":null}}"#).unwrap();
|
||||
assert!(m.mac.is_none() && m.windows.is_none());
|
||||
assert_eq!(m.activation_height, None);
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("https"));
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://127.0.0.1:29790/x.dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"dmg"}}}"#).is_ok());
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("sha256"));
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"tar"}}}"#).unwrap_err().contains("kind"));
|
||||
assert!(parse(r#"{"version":"latest"}"#).is_err());
|
||||
assert!(parse("not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_verifies_and_tampering_fails() {
|
||||
let (sk, pk) = key();
|
||||
let sig = hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, &pk).is_ok());
|
||||
let m = verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).unwrap();
|
||||
assert_eq!(m.version, "0.3.1");
|
||||
// a tampered sha256 inside the manifest: the bytes changed, the signature no longer verifies
|
||||
let tampered = SAMPLE.replace("aaaaaaaa", "aaaaaaab");
|
||||
assert!(verify_and_parse(tampered.as_bytes(), &sig, &pk).is_err());
|
||||
// a bad signature
|
||||
let mut bad = sig.clone();
|
||||
bad.replace_range(0..2, if &sig[0..2] == "00" { "01" } else { "00" });
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &bad, &pk).is_err());
|
||||
// another key
|
||||
let other = hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, &other).is_err());
|
||||
// garbage
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), "zz", &pk).is_err());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, "abcd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_of_file_is_checked_by_the_caller() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-manifest-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("x.bin");
|
||||
std::fs::write(&f, b"abc").unwrap();
|
||||
assert_eq!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
std::fs::write(&f, b"abd").unwrap();
|
||||
assert_ne!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions() {
|
||||
assert!(newer("0.3.1", "0.3.0"));
|
||||
assert!(newer("0.4.0", "0.3.9"));
|
||||
assert!(newer("1.0.0", "0.99.99"));
|
||||
assert!(newer("v0.3.1", "0.3.0"));
|
||||
assert!(!newer("0.3.0", "0.3.0"));
|
||||
assert!(!newer("0.2.9", "0.3.0"));
|
||||
assert!(!newer("0.3", "0.3.0"));
|
||||
assert!(newer("0.3.1", "0.3"));
|
||||
assert!(newer("0.3.1", "0.3.1-rc1"));
|
||||
assert!(!newer("0.3.1-rc1", "0.3.1"));
|
||||
assert!(newer("0.3.1-rc2", "0.3.1-rc1"));
|
||||
assert!(!newer("", "0.3.0"));
|
||||
assert!(!newer("latest", "0.3.0"));
|
||||
assert!(!newer("0.3.1", "garbage"));
|
||||
assert!(!newer("0.3.1-", "0.3.0"));
|
||||
assert!(!newer("0.3.1.2", "0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_moments() {
|
||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
|
||||
assert!(safe_to_apply(&base).is_ok());
|
||||
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(181), ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: None, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker"));
|
||||
// urgent beats every wait
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, boundary_eta_s: Some(5), miner_busy: true, urgent: true, ..base.clone() }).is_ok());
|
||||
// patience: an unsynced node for 6 h applies anyway
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
|
||||
// the machine's slot: outside it nothing applies, not even with patience; urgent ignores it
|
||||
assert!(safe_to_apply(&Moment { slot_ok: false, ..base.clone() }).unwrap_err().contains("own minute"));
|
||||
assert!(safe_to_apply(&Moment { slot_ok: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { slot_ok: false, urgent: true, ..base.clone() }).is_ok());
|
||||
// the network guard: over 30% of identities gone in 10 minutes holds the update (we are the devnet)
|
||||
assert!(safe_to_apply(&Moment { network_drop_pct: 30.0, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { network_drop_pct: 30.1, ..base.clone() }).unwrap_err().contains("lost 30%"));
|
||||
assert!(safe_to_apply(&Moment { network_drop_pct: 80.0, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { network_drop_pct: 80.0, urgent: true, ..base.clone() }).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots() {
|
||||
assert_eq!(slot_minute("1ccfe586"), 58); // PC 2
|
||||
assert_eq!(slot_minute("00000000"), 0);
|
||||
assert_eq!(slot_minute("0000003c"), 0);
|
||||
assert_eq!(slot_minute("0000003b"), 59);
|
||||
assert_eq!(slot_minute("zz"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_closeness_and_support() {
|
||||
assert!(!fork_is_close(None, 100_000));
|
||||
assert!(!fork_is_close(Some(120_000), 0));
|
||||
assert!(!fork_is_close(Some(120_000), 118_199));
|
||||
assert!(fork_is_close(Some(120_000), 118_200));
|
||||
assert!(fork_is_close(Some(120_000), 120_000));
|
||||
assert!(fork_is_close(Some(120_000), 130_000));
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert!(!unsupported(&m, "0.3.0"));
|
||||
assert!(unsupported(&m, "0.2.9"));
|
||||
assert!(!unsupported(&parse(r#"{"version":"0.3.1"}"#).unwrap(), "0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_sha256_of_key_bytes() {
|
||||
let (_, pk) = key();
|
||||
assert_eq!(fingerprint(&pk).len(), 64);
|
||||
assert_eq!(fingerprint("zz"), "");
|
||||
}
|
||||
}
|
||||
524
app/igneum-common/src/ota.rs
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
//! The apply side of an over-the-air update, shared by both apps. Taken from app/igneum-app/src/ota.rs (the miner's
|
||||
//! updater, 4 October 2026; the miner keeps its own copy until it moves to this crate) with the app's names filled in
|
||||
//! from `AppId`: the staged bundle, the detached helper that swaps it in and relaunches, and the pending/result files
|
||||
//! the old engine, the helper and the new engine pass around. The manifest check and the download live in
|
||||
//! `crate::fetch`; when to apply is each app's own business (the miner waits for a safe mining moment, the wallet for
|
||||
//! no send in flight).
|
||||
//!
|
||||
//! macOS: `stage` mounts the disk image (or unpacks the zip), copies the bundle next to the running one as
|
||||
//! ".<App>.app.new" (same volume, so the swap is two renames) and checks the new engine answers --version with the
|
||||
//! manifest's version. `launch_apply` re-hashes the download and the staged bundle, writes update-pending.json and
|
||||
//! ota-apply.sh, starts the helper detached and returns `Launch::QuitNow`: the engine leaves through its quit path.
|
||||
//! The helper waits for the engine, asks the window to quit (by bundle id), moves the old bundle to
|
||||
//! "<App>.app.previous", the staged one in, opens the new app, and puts the previous one back when the new app does
|
||||
//! not start twice. The new engine counts its starts in update-pending.json; on the third start without
|
||||
//! `HEALTHY_AFTER_S` healthy seconds it asks for `launch_rollback`.
|
||||
//! Windows: the staged artefact is the Inno installer. `launch_apply` starts ota-apply.ps1 detached (CREATE_NO_WINDOW,
|
||||
//! commit 0d123b3), which runs the installer /VERYSILENT first while the engine keeps running; the installer stops the
|
||||
//! engine itself (api/quit) and relaunches the app with /IGNOTA=1. An unanswered administrator prompt comes back as
|
||||
//! `deferred` in update-result.json. The wallet has never run this path (5 October 2026): untested there.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::manifest::{self, PlatformEntry};
|
||||
use crate::AppId;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A new version is healthy once it has run this long; the update is then complete and the leftovers go.
|
||||
pub const HEALTHY_AFTER_S: u64 = 90;
|
||||
|
||||
/// What launch_apply started.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Launch {
|
||||
/// macOS: the helper waits for this engine to exit; the engine leaves through its quit path now.
|
||||
QuitNow,
|
||||
/// Windows: the installer runs first while the engine keeps running; the installer stops the engine itself
|
||||
/// once it is allowed to run. The engine stays up and watches update-result.json for a deferral.
|
||||
InstallerRunning,
|
||||
}
|
||||
|
||||
/// What the old engine leaves for the new one (update-pending.json).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Pending {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub at: f64,
|
||||
pub starts: u32,
|
||||
pub previous_installer: String,
|
||||
}
|
||||
|
||||
/// The helper's verdict (update-result.json).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct HelperResult {
|
||||
pub ok: bool,
|
||||
pub version: String,
|
||||
pub error: String,
|
||||
pub rolled_back: bool,
|
||||
pub deferred: bool,
|
||||
}
|
||||
|
||||
pub fn pending_path(app_dir: &Path) -> PathBuf {
|
||||
app_dir.join("update-pending.json")
|
||||
}
|
||||
|
||||
pub fn result_path(app_dir: &Path) -> PathBuf {
|
||||
app_dir.join("update-result.json")
|
||||
}
|
||||
|
||||
pub fn read_pending(app_dir: &Path) -> Option<Pending> {
|
||||
parse_pending(&std::fs::read_to_string(pending_path(app_dir)).ok()?)
|
||||
}
|
||||
|
||||
pub fn parse_pending(text: &str) -> Option<Pending> {
|
||||
let v: Value = serde_json::from_str(text).ok()?;
|
||||
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
Some(Pending { from: s("from"), to: s("to"), at: v.get("at").and_then(|x| x.as_f64()).unwrap_or(0.0), starts: v.get("starts").and_then(|x| x.as_u64()).unwrap_or(0) as u32, previous_installer: s("previous_installer") })
|
||||
}
|
||||
|
||||
pub fn write_pending(app_dir: &Path, p: &Pending) {
|
||||
let v = json!({ "from": p.from, "to": p.to, "at": p.at, "starts": p.starts, "previous_installer": p.previous_installer, "platform": manifest::platform_name() });
|
||||
let _ = std::fs::write(pending_path(app_dir), v.to_string());
|
||||
}
|
||||
|
||||
pub fn read_result(app_dir: &Path) -> Option<HelperResult> {
|
||||
parse_result(&std::fs::read_to_string(result_path(app_dir)).ok()?)
|
||||
}
|
||||
|
||||
pub fn parse_result(text: &str) -> Option<HelperResult> {
|
||||
let v: Value = serde_json::from_str(text).ok()?;
|
||||
let b = |k: &str| v.get(k).and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
Some(HelperResult { ok: b("ok"), version: s("version"), error: s("error"), rolled_back: b("rolled_back"), deferred: b("deferred") })
|
||||
}
|
||||
|
||||
/// "Igneum-Wallet-Setup-0.1.1.exe": the installer name the Windows packaging gives a version.
|
||||
pub fn installer_name_for(app: AppId, version: &str) -> String {
|
||||
format!("{}-Setup-{version}.exe", app.name.replace(' ', "-"))
|
||||
}
|
||||
|
||||
/// The staged bundle's path next to the running one (macOS).
|
||||
pub fn staged_path(app: AppId, bundle: &Path) -> Option<PathBuf> {
|
||||
bundle.parent().map(|p| p.join(format!(".{}.app.new", app.name)))
|
||||
}
|
||||
|
||||
/// Starts a process that outlives the engine (stdio closed, own session on unix, no window on Windows).
|
||||
pub fn spawn_detached(c: &mut Command) -> Result<(), String> {
|
||||
use std::process::Stdio;
|
||||
c.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
c.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.creation_flags(0x0800_0000 | 0x0000_0008); // CREATE_NO_WINDOW | DETACHED_PROCESS
|
||||
}
|
||||
c.spawn().map(|_| ()).map_err(|e| format!("cannot start the helper: {e}"))
|
||||
}
|
||||
|
||||
/// The engine's own environment for the helper's relaunch (a test run on a private devnet or a scratch data folder):
|
||||
/// every variable whose name starts with one of `prefixes`, written to <app dir>/ota-relaunch.env. "" when there is
|
||||
/// none, and the helper opens the bundle through LaunchServices.
|
||||
pub fn write_env_file(app_dir: &Path, prefixes: &[&str]) -> String {
|
||||
let vars: Vec<String> = std::env::vars().filter(|(k, _)| prefixes.iter().any(|p| k.starts_with(p))).map(|(k, v)| format!("{k}={v}")).collect();
|
||||
if vars.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let p = app_dir.join("ota-relaunch.env");
|
||||
if std::fs::write(&p, vars.join("\n") + "\n").is_ok() { p.display().to_string() } else { String::new() }
|
||||
}
|
||||
|
||||
/// macOS: the new bundle next to the running one (same volume, so the swap is two renames); Windows: the installer
|
||||
/// is the staged artefact. Err("manual: ...") when the engine cannot swap itself (not in a bundle, a read-only
|
||||
/// Applications folder): the window then offers the download instead.
|
||||
#[allow(unused_variables)]
|
||||
pub fn stage(app: AppId, e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result<PathBuf, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let bundle_name = format!("{}.app", app.name);
|
||||
let bundle = crate::platform::bundle_path().ok_or(format!("manual: the engine is not running from {bundle_name}; open the downloaded disk image and drag the app to Applications"))?;
|
||||
let parent = bundle.parent().ok_or("no parent folder")?;
|
||||
let staged = staged_path(app, &bundle).ok_or("no parent folder")?;
|
||||
let _ = std::fs::remove_dir_all(&staged);
|
||||
// writable? a user-owned /Applications is; a managed Mac may not be
|
||||
if std::fs::create_dir(&staged).is_err() {
|
||||
return Err(format!("manual: {} is not writable; open the downloaded disk image and drag the app over the old one", parent.display()));
|
||||
}
|
||||
let _ = std::fs::remove_dir(&staged);
|
||||
let work = dir.join("unpack");
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
std::fs::create_dir_all(&work).map_err(|e| e.to_string())?;
|
||||
let source: PathBuf;
|
||||
let mut mounted: Option<PathBuf> = None;
|
||||
if e.kind == "dmg" {
|
||||
let mnt = work.join("mnt");
|
||||
std::fs::create_dir_all(&mnt).map_err(|e| e.to_string())?;
|
||||
let out = crate::run::run_timeout(Command::new(crate::platform::tool("hdiutil")).args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", &mnt.display().to_string(), &file.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default();
|
||||
if !mnt.join(&bundle_name).is_dir() {
|
||||
return Err(format!("the disk image has no {bundle_name} ({})", out.lines().last().unwrap_or("hdiutil said nothing")));
|
||||
}
|
||||
mounted = Some(mnt.clone());
|
||||
source = mnt.join(&bundle_name);
|
||||
} else {
|
||||
let out = crate::run::run_timeout(Command::new(crate::platform::tool("ditto")).args(["-x", "-k", &file.display().to_string(), &work.display().to_string()]), None, Duration::from_secs(300)).unwrap_or_default();
|
||||
source = find_app(&work, &bundle_name).ok_or(format!("the zip has no {bundle_name} ({})", out.lines().last().unwrap_or("")))?;
|
||||
}
|
||||
let engine = staged.join("Contents/MacOS").join(app.engine_exe);
|
||||
let r = (|| -> Result<(), String> {
|
||||
let out = crate::run::run_timeout(Command::new(crate::platform::tool("ditto")).arg(&source).arg(&staged), None, Duration::from_secs(300)).unwrap_or_default();
|
||||
if !engine.is_file() {
|
||||
return Err(format!("copy failed: {}", out.lines().last().unwrap_or("")));
|
||||
}
|
||||
// the quarantine flag comes off only after the file this bundle came from verified again, now
|
||||
let again = manifest::sha256_file(file).map_err(|e| e.to_string())?;
|
||||
if again != e.sha256 {
|
||||
return Err("the download changed while it was being unpacked; discarded".into());
|
||||
}
|
||||
let _ = Command::new(crate::platform::tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(&staged).output();
|
||||
let v = crate::run::run_timeout(Command::new(&engine).arg("--version"), None, Duration::from_secs(20)).unwrap_or_default();
|
||||
let want = format!("{} {version}", app.engine_exe);
|
||||
if v.trim() != want {
|
||||
return Err(format!("the new engine answers '{}' to --version, the manifest says {version}", v.trim()));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Some(m) = mounted {
|
||||
let _ = Command::new(crate::platform::tool("hdiutil")).args(["detach", "-force", &m.display().to_string()]).output();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
if let Err(err) = r {
|
||||
let _ = std::fs::remove_dir_all(&staged);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(staged)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if e.kind != "inno-setup" {
|
||||
return Err(format!("kind '{}' is not an installer", e.kind));
|
||||
}
|
||||
Ok(file.to_path_buf())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
Err("manual: no automatic install on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn find_app(dir: &Path, bundle_name: &str) -> Option<PathBuf> {
|
||||
let rd = std::fs::read_dir(dir).ok()?;
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.file_name().map(|n| n == bundle_name).unwrap_or(false) && p.is_dir() {
|
||||
return Some(p);
|
||||
}
|
||||
if p.is_dir() {
|
||||
if let Some(f) = find_app(&p, bundle_name) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Windows: an install under Program Files was made by an administrator installer.
|
||||
pub fn under_program_files(dir: &Path) -> bool {
|
||||
let d = dir.to_string_lossy().to_ascii_lowercase();
|
||||
["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"].iter().filter_map(|k| std::env::var(k).ok()).any(|pf| !pf.is_empty() && d.starts_with(&pf.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// Everything launch_apply needs. `staged_digest` is manifest::digest_dir of the staged bundle at stage time (macOS);
|
||||
/// `sha256` the manifest's for the installer (Windows); `env_file` from write_env_file or "".
|
||||
pub struct Apply<'a> {
|
||||
pub app: AppId,
|
||||
pub app_dir: &'a Path,
|
||||
pub current: &'a str,
|
||||
pub version: &'a str,
|
||||
pub staged: &'a Path,
|
||||
pub staged_digest: &'a str,
|
||||
pub sha256: &'a str,
|
||||
pub host_pid: u32,
|
||||
pub env_file: String,
|
||||
/// Windows: the installer of the version now running, kept in updates/ as the rollback target ("" when none)
|
||||
pub previous_installer: String,
|
||||
}
|
||||
|
||||
/// Writes update-pending.json and the helper, starts the helper detached. The caller verified the download and the
|
||||
/// staged bundle a moment ago (sha256 and digest_dir); the helper checks the digest once more before the swap.
|
||||
pub fn launch_apply(a: &Apply) -> Result<Launch, String> {
|
||||
write_pending(a.app_dir, &Pending { from: a.current.to_string(), to: a.version.to_string(), at: crate::platform::unix_now_f(), starts: 0, previous_installer: a.previous_installer.clone() });
|
||||
let _ = std::fs::remove_file(result_path(a.app_dir));
|
||||
let result = result_path(a.app_dir);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let bundle = crate::platform::bundle_path().ok_or(format!("not running from {}.app", a.app.name))?;
|
||||
if a.staged_digest.is_empty() {
|
||||
return Err("no digest for the staged app".into());
|
||||
}
|
||||
let script = a.app_dir.join("ota-apply.sh");
|
||||
std::fs::write(&script, mac_helper(a.app)).map_err(|e| format!("cannot write the helper: {e}"))?;
|
||||
let args = ["apply".to_string(), std::process::id().to_string(), a.host_pid.to_string(), bundle.display().to_string(), a.staged.display().to_string(), a.version.to_string(), result.display().to_string(), a.env_file.clone(), a.staged_digest.to_string()];
|
||||
spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?;
|
||||
Ok(Launch::QuitNow)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?;
|
||||
let script = a.app_dir.join("ota-apply.ps1");
|
||||
std::fs::write(&script, win_helper(a.app)).map_err(|e| format!("cannot write the helper: {e}"))?;
|
||||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
|
||||
"-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &a.staged.display().to_string(), "-Version", a.version, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), "-Sha256", a.sha256,
|
||||
]);
|
||||
spawn_detached(&mut c)?;
|
||||
Ok(Launch::InstallerRunning)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = result;
|
||||
Err("automatic apply is not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The new version failed to start twice: the helper restores the previous one (macOS: the .previous bundle;
|
||||
/// Windows: the previous installer kept in updates/). The caller exits afterwards.
|
||||
pub fn launch_rollback(app: AppId, app_dir: &Path, p: &Pending, host_pid: u32, env_file: String) -> Result<(), String> {
|
||||
let result = result_path(app_dir);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let bundle = crate::platform::bundle_path().ok_or(format!("not running from {}.app", app.name))?;
|
||||
let script = app_dir.join("ota-apply.sh");
|
||||
std::fs::write(&script, mac_helper(app)).map_err(|e| format!("cannot write the helper: {e}"))?;
|
||||
let args = ["rollback".to_string(), std::process::id().to_string(), host_pid.to_string(), bundle.display().to_string(), String::new(), p.to.clone(), result.display().to_string(), env_file, String::new()];
|
||||
spawn_detached(Command::new(crate::platform::tool("nohup")).arg(crate::platform::tool("bash")).arg(&script).args(&args))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = (host_pid, env_file);
|
||||
if p.previous_installer.is_empty() || !Path::new(&p.previous_installer).is_file() {
|
||||
return Err("no previous installer kept; reinstall from igneum.network".into());
|
||||
}
|
||||
let install_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?;
|
||||
let script = app_dir.join("ota-apply.ps1");
|
||||
std::fs::write(&script, win_helper(app)).map_err(|e| format!("cannot write the helper: {e}"))?;
|
||||
let sha = manifest::sha256_file(Path::new(&p.previous_installer)).unwrap_or_default();
|
||||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
|
||||
"-Sha256", &sha, "-Mode", "rollback", "-EnginePid", &std::process::id().to_string(), "-Installer", &p.previous_installer, "-Version", &p.to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(),
|
||||
]);
|
||||
spawn_detached(&mut c)?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = (app, p, host_pid, env_file, result);
|
||||
Err("rollback is not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS helper with this app's names filled in.
|
||||
pub fn mac_helper(app: AppId) -> String {
|
||||
fill(MAC_HELPER, app)
|
||||
}
|
||||
|
||||
/// The Windows helper with this app's names filled in.
|
||||
pub fn win_helper(app: AppId) -> String {
|
||||
fill(WIN_HELPER, app)
|
||||
}
|
||||
|
||||
fn fill(template: &str, app: AppId) -> String {
|
||||
template.replace("@APP_NAME@", app.name).replace("@ENGINE@", app.engine_exe).replace("@BUNDLE@", app.bundle)
|
||||
}
|
||||
|
||||
const MAC_HELPER: &str = r#"#!/bin/bash
|
||||
# @APP_NAME@ update helper, written by the engine (igneum-common/src/ota.rs). Not for running by hand.
|
||||
# bash ota-apply.sh apply|rollback <engine pid> <host pid|0> <app bundle> <staged bundle> <version> <result json> [env file] [digest]
|
||||
# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running
|
||||
# bundle to "<app>.previous" and the staged one in, opens the new app; if the new app does not start twice, puts the
|
||||
# previous one back. rollback: the previous bundle back, the failed one aside. Writes <result json> for the engine.
|
||||
MODE="$1"; EPID="$2"; HPID="$3"; APP="$4"; NEW="$5"; VER="$6"; RESULT="$7"; ENVF="${8:-}"; DIGEST="${9:-}"
|
||||
LOG="$(dirname "$RESULT")/ota-apply.log"
|
||||
exec >>"$LOG" 2>&1
|
||||
echo "$(date -u +%FT%TZ) $MODE: engine $EPID host $HPID app '$APP' new '$NEW' version $VER"
|
||||
gone() { ! kill -0 "$1" 2>/dev/null; }
|
||||
wait_gone() { local p="$1" n="$2"; while [ "$n" -gt 0 ] && ! gone "$p"; do sleep 0.5; n=$((n-1)); done; gone "$p"; }
|
||||
result() { printf '{"ok":%s,"version":"%s","error":"%s","rolled_back":%s,"at":%s}\n' "$1" "$VER" "$2" "$3" "$(date +%s)" > "$RESULT.tmp" && mv "$RESULT.tmp" "$RESULT"; }
|
||||
PREV="$APP.previous"
|
||||
FAILED="$APP.failed"
|
||||
ENGINE="$APP/Contents/MacOS/@ENGINE@"
|
||||
# the same digest the engine computed when it staged the bundle (manifest.rs digest_dir): every regular file,
|
||||
# byte-sorted relative path, "path\nsha256\n" per file, sha256 of the whole
|
||||
digest_dir() { (cd "$1" && /usr/bin/find . -type f -print | LC_ALL=C /usr/bin/sort | while IFS= read -r f; do printf '%s\n%s\n' "${f#./}" "$(/usr/bin/shasum -a 256 "$f" | /usr/bin/cut -d' ' -f1)"; done) | /usr/bin/shasum -a 256 | /usr/bin/cut -d' ' -f1; }
|
||||
started_ok() { local n=60; while [ "$n" -gt 0 ]; do pgrep -f "$ENGINE" >/dev/null 2>&1 && return 0; sleep 0.5; n=$((n-1)); done; return 1; }
|
||||
# a test run carries its environment to the relaunch (open -n cannot); IGNEUM_OTA_RELAUNCH_ENGINE=1 in that file runs
|
||||
# the engine alone (no window, a scratch test); a normal run goes through LaunchServices
|
||||
launch() {
|
||||
if [ -n "$ENVF" ] && [ -f "$ENVF" ]; then
|
||||
(set -a; . "$ENVF"; set +a; if [ "${IGNEUM_OTA_RELAUNCH_ENGINE:-}" = 1 ]; then /usr/bin/nohup "$ENGINE" --no-open >/dev/null 2>&1 & else /usr/bin/nohup "$APP/Contents/MacOS/@APP_NAME@" >/dev/null 2>&1 & fi)
|
||||
else
|
||||
/usr/bin/open -n "$APP"
|
||||
fi
|
||||
}
|
||||
wait_gone "$EPID" 240 || { echo "engine $EPID still running after 120 s; ending it"; kill -9 "$EPID" 2>/dev/null; sleep 1; }
|
||||
if [ -n "$HPID" ] && [ "$HPID" != 0 ] && ! gone "$HPID"; then
|
||||
/usr/bin/osascript -e 'tell application id "@BUNDLE@" to quit' >/dev/null 2>&1 || kill -TERM "$HPID" 2>/dev/null
|
||||
wait_gone "$HPID" 80 || { echo "window $HPID still running after 40 s; ending it"; kill -9 "$HPID" 2>/dev/null; sleep 1; }
|
||||
fi
|
||||
# anything else from this bundle (a stray engine of an older run)
|
||||
pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 0.5
|
||||
case "$MODE" in
|
||||
apply)
|
||||
[ -d "$NEW" ] || { result false "the staged app is missing" false; launch; exit 1; }
|
||||
if [ -n "$DIGEST" ]; then
|
||||
have="$(digest_dir "$NEW")"
|
||||
if [ "$have" != "$DIGEST" ]; then echo "digest mismatch: staged $have, verified $DIGEST"; rm -rf "$NEW"; result false "the staged app changed since it was verified; not installed" false; launch; exit 1; fi
|
||||
echo "staged bundle digest verified"
|
||||
else
|
||||
echo "no digest given; not installing an unverified bundle"; result false "no digest for the staged app" false; launch; exit 1
|
||||
fi
|
||||
rm -rf "$PREV"
|
||||
mv "$APP" "$PREV" || { result false "could not move the old app aside" false; launch; exit 1; }
|
||||
mv "$NEW" "$APP" || { mv "$PREV" "$APP"; result false "could not move the new app in" false; launch; exit 1; }
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null # only a bundle whose digest just verified
|
||||
echo "swapped; opening $APP"
|
||||
launch || echo "open failed"
|
||||
if started_ok; then result true "" false; echo "$VER is running"; exit 0; fi
|
||||
echo "the new app did not start within 30 s; opening it once more"
|
||||
launch || true
|
||||
if started_ok; then result true "" false; echo "$VER is running (second try)"; exit 0; fi
|
||||
echo "the new app did not start twice; restoring the previous version"
|
||||
pkill -f "$APP/Contents/MacOS" 2>/dev/null; sleep 1
|
||||
rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP"
|
||||
launch
|
||||
result false "@APP_NAME@ $VER did not start twice; the previous version was restored" true
|
||||
;;
|
||||
rollback)
|
||||
[ -d "$PREV" ] || { result false "no previous version kept to restore" false; launch; exit 1; }
|
||||
rm -rf "$FAILED"; mv "$APP" "$FAILED" && mv "$PREV" "$APP"
|
||||
launch
|
||||
result false "@APP_NAME@ $VER did not stay up twice; the previous version was restored" true
|
||||
;;
|
||||
*) echo "unknown mode $MODE"; exit 2 ;;
|
||||
esac
|
||||
"#;
|
||||
|
||||
const WIN_HELPER: &str = r#"# @APP_NAME@ update helper, written by the engine (igneum-common/src/ota.rs). Not for running by hand.
|
||||
# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder> -Sha256 <hex>
|
||||
# The installer runs FIRST, while the engine keeps running (4 October 2026: two unattended PCs sat stopped at an
|
||||
# administrator prompt nobody could click). A per-user installer (PrivilegesRequired=lowest) needs no prompt; an older
|
||||
# administrator installer raises one through ShellExecute. Only when the installer actually runs does its
|
||||
# PrepareToInstall step stop the engine (api/quit), replace the files and relaunch the app (/IGNOTA=1). A declined,
|
||||
# timed-out or unanswered prompt leaves the engine running: the result says deferred:true. The old app is relaunched
|
||||
# only when the engine is gone and the install did not happen.
|
||||
param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '')
|
||||
$log = Join-Path (Split-Path -Parent $Result) 'ota-apply.log'
|
||||
function Log([string]$t) { Add-Content -Path $log -Value ("{0} {1}" -f (Get-Date -Format s), $t) }
|
||||
function Done([bool]$ok, [string]$err, [bool]$rb, [bool]$deferred) {
|
||||
$o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; deferred = $deferred; at = [int][double](Get-Date -UFormat %s) }
|
||||
($o | ConvertTo-Json -Compress) | Set-Content -Path $Result -Encoding ASCII
|
||||
}
|
||||
function EngineAlive() { return [bool](Get-Process -Id $EnginePid -ErrorAction SilentlyContinue) }
|
||||
function Relaunch() {
|
||||
if (EngineAlive) { return }
|
||||
$exe = Join-Path $InstallDir '@ENGINE@.exe'
|
||||
if (Test-Path $exe) { Log 'engine gone and nothing installed: starting the old app again'; Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null }
|
||||
}
|
||||
Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps running until the installer runs)"
|
||||
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false; exit 1 }
|
||||
# the installer is hashed again right before it runs
|
||||
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false; exit 1 }
|
||||
$have = (Get-FileHash -Path $Installer -Algorithm SHA256).Hash.ToLower()
|
||||
if ($have -ne $Sha256.ToLower()) { Log "sha256 mismatch: $have"; Remove-Item -Path $Installer -Force -ErrorAction SilentlyContinue; Done $false 'the installer changed since it was verified; not run' $false $false; exit 1 }
|
||||
Log 'installer sha256 verified'
|
||||
$setupLog = Join-Path (Split-Path -Parent $Result) 'ota-setup.log'
|
||||
$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"'))
|
||||
try {
|
||||
# no -Verb RunAs: a per-user installer just runs; an administrator installer makes Windows ask, and a declined or
|
||||
# timed-out prompt comes back here as an exception with the engine still running
|
||||
$p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru
|
||||
if ($p.ExitCode -eq 0) {
|
||||
if ($Mode -eq 'rollback') { Done $false "@APP_NAME@ $Version did not stay up twice; the previous version was reinstalled" $true $false }
|
||||
else { Done $true '' $false $false }
|
||||
Log 'installer exit 0'
|
||||
exit 0
|
||||
}
|
||||
Log ("installer exit " + $p.ExitCode)
|
||||
Done $false ("the installer exited with code " + $p.ExitCode + " (see ota-setup.log)") $false $false
|
||||
Relaunch
|
||||
exit 1
|
||||
} catch {
|
||||
$msg = $_.Exception.Message
|
||||
Log ("installer did not run: " + $msg)
|
||||
Log 'OTA: waiting for administrator approval; the engine keeps running; the update waits for the next time someone is at this PC'
|
||||
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true
|
||||
Relaunch
|
||||
exit 1
|
||||
}
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn helpers_carry_the_apps_names_and_no_placeholder() {
|
||||
for app in [crate::MINER, crate::WALLET] {
|
||||
for text in [mac_helper(app), win_helper(app)] {
|
||||
for ph in ["@APP_NAME@", "@ENGINE@", "@BUNDLE@"] {
|
||||
assert!(!text.contains(ph), "{ph} was left in the helper for {}", app.name);
|
||||
}
|
||||
assert!(text.contains(app.name));
|
||||
}
|
||||
let m = mac_helper(app);
|
||||
assert!(m.contains(&format!("ENGINE=\"$APP/Contents/MacOS/{}\"", app.engine_exe)));
|
||||
assert!(m.contains(&format!("tell application id \"{}\" to quit", app.bundle)));
|
||||
assert!(m.contains(&format!("\"$APP/Contents/MacOS/{}\"", app.name)));
|
||||
assert!(win_helper(app).contains(&format!("'{}.exe'", app.engine_exe)));
|
||||
}
|
||||
assert!(mac_helper(crate::WALLET).contains("Igneum Wallet $VER did not start twice"));
|
||||
assert!(!mac_helper(crate::WALLET).contains("Miner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_and_result_round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-ota-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = Pending { from: "0.1.0".into(), to: "0.1.1".into(), at: 1.5, starts: 2, previous_installer: String::new() };
|
||||
write_pending(&dir, &p);
|
||||
assert_eq!(read_pending(&dir), Some(p));
|
||||
assert!(std::fs::read_to_string(pending_path(&dir)).unwrap().contains(&format!("\"platform\":\"{}\"", manifest::platform_name())));
|
||||
assert_eq!(parse_pending("nope"), None);
|
||||
let r = parse_result(r#"{"ok":false,"version":"0.1.1","error":"did not start","rolled_back":true,"at":1}"#).unwrap();
|
||||
assert_eq!(r, HelperResult { ok: false, version: "0.1.1".into(), error: "did not start".into(), rolled_back: true, deferred: false });
|
||||
assert!(parse_result(r#"{"ok":true,"version":"0.1.1","error":"","rolled_back":false,"deferred":true}"#).unwrap().deferred);
|
||||
assert_eq!(read_result(&dir), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert_eq!(installer_name_for(crate::WALLET, "0.1.1"), "Igneum-Wallet-Setup-0.1.1.exe");
|
||||
assert_eq!(installer_name_for(crate::MINER, "0.3.5"), "Igneum-Miner-Setup-0.3.5.exe");
|
||||
assert_eq!(staged_path(crate::WALLET, Path::new("/Applications/Igneum Wallet.app")).unwrap(), PathBuf::from("/Applications/.Igneum Wallet.app.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_file_takes_only_the_prefixes() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-ota-env-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::env::set_var("IGNEUM_OTA_TEST_X", "1");
|
||||
let p = write_env_file(&dir, &["IGNEUM_OTA_TEST_"]);
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(text.contains("IGNEUM_OTA_TEST_X=1"));
|
||||
assert!(!text.contains("PATH="));
|
||||
assert_eq!(write_env_file(&dir, &["NO_SUCH_PREFIX_ZZ_"]), "");
|
||||
std::env::remove_var("IGNEUM_OTA_TEST_X");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
480
app/igneum-common/src/platform.rs
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
//! What differs per operating system: directories, file permissions, keeping the machine awake, opening a URL,
|
||||
//! starting at login, and stopping a child process gracefully. From app/igneum-app/src/platform.rs; the login item
|
||||
//! takes the app identity (`crate::AppId`) instead of naming Igneum Miner.
|
||||
|
||||
use crate::AppId;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// The absolute path of a system helper (R4.3.3: never a bare name on PATH). Windows: System32 (and NVIDIA's own
|
||||
/// folder for nvidia-smi); macOS: /usr/bin, /usr/sbin, /bin. Unknown names fall back to the bare name.
|
||||
pub fn tool(name: &str) -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
let sys = format!("{root}\\System32");
|
||||
let p = match name {
|
||||
"powershell" => format!("{sys}\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" => format!("{sys}\\{name}.exe"),
|
||||
"nvidia-smi" => {
|
||||
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into());
|
||||
let a = format!("{pf}\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe");
|
||||
let b = format!("{sys}\\nvidia-smi.exe");
|
||||
if Path::new(&a).is_file() { a } else { b }
|
||||
}
|
||||
_ => name.to_string(),
|
||||
};
|
||||
PathBuf::from(p)
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let p = match name {
|
||||
"curl" | "osascript" | "xattr" | "open" | "caffeinate" | "hdiutil" | "ditto" | "nohup" | "pgrep" | "pkill" | "shasum" | "xcrun" => format!("/usr/bin/{name}"),
|
||||
"sntp" | "scutil" | "system_profiler" | "sysctl" => format!("/usr/sbin/{name}"),
|
||||
"bash" | "sh" => format!("/bin/{name}"),
|
||||
_ => name.to_string(),
|
||||
};
|
||||
PathBuf::from(p)
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
for dir in ["/usr/bin", "/bin", "/usr/sbin", "/usr/local/bin"] {
|
||||
let p = Path::new(dir).join(name);
|
||||
if p.is_file() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
PathBuf::from(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strips the dashboard token from a line: "/t/<32 hex>/" becomes "/t/<token>/" (R4.3.8: the token is never logged
|
||||
/// and the logs are uploaded).
|
||||
pub fn redact(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("/t/") {
|
||||
out.push_str(&rest[..i + 3]);
|
||||
let after = &rest[i + 3..];
|
||||
let hex_len = after.chars().take_while(|c| c.is_ascii_hexdigit()).count();
|
||||
if hex_len >= 16 {
|
||||
out.push_str("<token>");
|
||||
rest = &after[hex_len..];
|
||||
} else {
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// True when a line still carries something that looks like the dashboard token (the upload guard).
|
||||
pub fn carries_token(s: &str) -> bool {
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("/t/") {
|
||||
let after = &rest[i + 3..];
|
||||
if after.chars().take_while(|c| c.is_ascii_hexdigit()).count() >= 16 {
|
||||
return true;
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn unix_now_f() -> f64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(p) = std::env::var_os("USERPROFILE") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// The root both apps write under (the miner under `app/`, the wallet under `wallet/`, see `AppId::data_sub`).
|
||||
/// macOS: ~/Library/Application Support/Igneum. Windows: %LOCALAPPDATA%\igneum (the same folder today's launchers
|
||||
/// use, so an existing devnet-v4 database is reused).
|
||||
pub fn data_root() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("IGNEUM_APP_DATA") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
home().join("Library").join("Application Support").join("Igneum")
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var_os("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|| home().join("AppData").join("Local")).join("igneum")
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
home().join(".igneum")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_root() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("IGNEUM_APP_LOGS") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
home().join("Library").join("Logs").join("Igneum")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
data_root().join("logs")
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's short name, cleaned to [A-Za-z0-9-], for the miner labels (mac-<host>, nvidia-<pc>).
|
||||
pub fn host_label() -> String {
|
||||
let raw = {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new(tool("scutil")).args(["--get", "LocalHostName"]).output().ok().and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("COMPUTERNAME").ok()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
std::fs::read_to_string("/etc/hostname").ok()
|
||||
}
|
||||
};
|
||||
let raw = raw.unwrap_or_default();
|
||||
let cleaned: String = raw.trim().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect();
|
||||
let cleaned = cleaned.trim_matches('-').to_string();
|
||||
if cleaned.is_empty() {
|
||||
if cfg!(windows) { "pc".into() } else { "mac".into() }
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/// Restricts a file (or directory) to the current user.
|
||||
pub fn lock_permissions(path: &Path, dir: bool) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(if dir { 0o700 } else { 0o600 }));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = dir;
|
||||
let user = std::env::var("USERNAME").unwrap_or_default();
|
||||
if !user.is_empty() {
|
||||
let _ = Command::new(tool("icacls"))
|
||||
.arg(path)
|
||||
.args(["/inheritance:r", "/grant:r", &format!("{user}:F")])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a URL in the default browser (the fallback when no window host runs).
|
||||
pub fn open_url(url: &str) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = Command::new(tool("open")).arg(url).spawn();
|
||||
#[cfg(windows)]
|
||||
let _ = quiet(&mut Command::new(tool("cmd"))).args(["/c", "start", "", url]).spawn();
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let _ = Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
|
||||
/// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state,
|
||||
/// which must be refreshed (call `keep_awake_tick` every minute).
|
||||
pub struct KeepAwake {
|
||||
#[cfg(target_os = "macos")]
|
||||
child: Option<std::process::Child>,
|
||||
}
|
||||
|
||||
impl KeepAwake {
|
||||
pub fn start() -> KeepAwake {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let child = Command::new(tool("caffeinate")).args(["-dims", "-w", &std::process::id().to_string()]).spawn().ok();
|
||||
KeepAwake { child }
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
keep_awake_tick();
|
||||
KeepAwake {}
|
||||
}
|
||||
}
|
||||
pub fn stop(&mut self) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(c) = self.child.as_mut() {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
const ES_CONTINUOUS: u32 = 0x8000_0000;
|
||||
#[cfg(windows)]
|
||||
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
|
||||
#[cfg(windows)]
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn SetThreadExecutionState(flags: u32) -> u32;
|
||||
}
|
||||
|
||||
pub fn keep_awake_tick() {
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a child to stop. Unix: SIGTERM (the node closes its database cleanly). Windows: TerminateProcess through
|
||||
/// std (what today's launcher does with taskkill /F).
|
||||
pub fn terminate(child: &mut std::process::Child) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// The app bundle on macOS (Igneum Miner.app) when the engine runs from inside one.
|
||||
pub fn bundle_path() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let macos = exe.parent()?;
|
||||
let contents = macos.parent()?;
|
||||
if macos.file_name()? == "MacOS" && contents.file_name()? == "Contents" {
|
||||
contents.parent().map(|p| p.to_path_buf())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What a login item should run: the bundle (macOS) or the window host / the engine (Windows).
|
||||
fn login_command(app: AppId) -> Vec<String> {
|
||||
let _ = app; // macOS runs the bundle by path; Windows names the host executable
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(b) = bundle_path() {
|
||||
return vec!["/usr/bin/open".into(), "-a".into(), b.to_string_lossy().into_owned()];
|
||||
}
|
||||
}
|
||||
let exe = std::env::current_exe().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
let host = dir.join(app.host_exe);
|
||||
if host.exists() {
|
||||
return vec![host.to_string_lossy().into_owned()];
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![exe]
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_agent_path(app: AppId) -> PathBuf {
|
||||
home().join("Library").join("LaunchAgents").join(format!("{}.plist", app.bundle))
|
||||
}
|
||||
|
||||
pub fn set_start_at_login(app: AppId, on: bool) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let path = launch_agent_path(app);
|
||||
if on {
|
||||
let args: String = login_command(app)
|
||||
.iter()
|
||||
.map(|a| format!(" <string>{}</string>\n", a.replace('&', "&").replace('<', "<")))
|
||||
.collect();
|
||||
let plist = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>{}</string>\n <key>ProgramArguments</key>\n <array>\n{args} </array>\n <key>RunAtLoad</key>\n <true/>\n</dict>\n</plist>\n",
|
||||
app.bundle
|
||||
);
|
||||
if let Some(d) = path.parent() {
|
||||
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&path, plist).map_err(|e| e.to_string())?;
|
||||
} else if path.exists() {
|
||||
std::fs::remove_file(&path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
let out = if on {
|
||||
let cmd = login_command(app).iter().map(|a| format!("\"{a}\"")).collect::<Vec<_>>().join(" ");
|
||||
Command::new(tool("reg")).args(["add", key, "/v", app.name, "/t", "REG_SZ", "/d", &cmd, "/f"]).output()
|
||||
} else {
|
||||
Command::new(tool("reg")).args(["delete", key, "/v", app.name, "/f"]).output()
|
||||
};
|
||||
match out {
|
||||
Ok(o) if o.status.success() || !on => Ok(()),
|
||||
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = (app, on);
|
||||
Err("start at login is not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_at_login_is_on(app: AppId) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_agent_path(app).exists()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Command::new(tool("reg"))
|
||||
.args(["query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", app.name])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = app;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the quarantine flag from the app's own files (macOS): after Gatekeeper lets the app through, each binary
|
||||
/// inside would still be checked on its first exec. Best effort; only works on a writable volume.
|
||||
pub fn clear_quarantine() {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(b) = bundle_path() {
|
||||
let _ = Command::new(tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(b.join("Contents")).output();
|
||||
}
|
||||
}
|
||||
|
||||
/// The manual instruction for fixing the clock on this platform.
|
||||
pub fn clock_hint() -> &'static str {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"System Settings > General > Date & Time: turn on \"Set time and date automatically\", or run: sudo sntp -sS time.apple.com"
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"Settings > Time & language > Date & time: turn on \"Set time automatically\" and click \"Sync now\", or run as administrator: w32tm /resync"
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
"run: sudo chronyc makestep, or sudo timedatectl set-ntp true"
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the operating system to set the clock from a time server (an administrator prompt appears). Returns what
|
||||
/// happened, for the window. Blocking; call from a thread.
|
||||
pub fn sync_clock() -> Result<String, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let out = Command::new(tool("osascript"))
|
||||
.args(["-e", "do shell script \"/usr/bin/sntp -sS time.apple.com 2>&1\" with administrator privileges"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
if out.status.success() {
|
||||
Ok(if text.trim().is_empty() { "clock set from time.apple.com".into() } else { text.trim().to_string() })
|
||||
} else if text.contains("canceled") || text.contains("cancelled") {
|
||||
Err("the administrator prompt was cancelled".into())
|
||||
} else {
|
||||
Err(text.trim().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let cmd = tool("cmd").display().to_string();
|
||||
let script = format!("Start-Process -FilePath '{cmd}' -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden");
|
||||
let mut c = Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
|
||||
quiet(&mut c);
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok("asked Windows Time to resync (w32tm /resync)".into())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
for args in [vec!["chronyc", "makestep"], vec!["timedatectl", "set-ntp", "true"]] {
|
||||
if let Ok(out) = Command::new("pkexec").args(&args).output() {
|
||||
if out.status.success() {
|
||||
return Ok(format!("ran {}", args.join(" ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("neither chronyc nor timedatectl could set the clock".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a command line with administrator rights (one prompt): the NVIDIA power cap needs it on Windows.
|
||||
/// Blocking; call from a thread.
|
||||
pub fn run_elevated(cmdline: &str) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let escaped = cmdline.replace('\'', "''");
|
||||
let cmd = tool("cmd").display().to_string();
|
||||
let script = format!("$p = Start-Process -FilePath '{cmd}' -ArgumentList '/c {escaped}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode");
|
||||
let mut c = Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
|
||||
quiet(&mut c);
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.contains("canceled") || err.contains("cancelled") || err.is_empty() { "the administrator prompt was cancelled".into() } else { err })
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let out = Command::new("pkexec").args(["sh", "-c", cmdline]).output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) }
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
let _ = cmdline;
|
||||
Err("not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a command that runs without a console window on Windows.
|
||||
pub fn quiet(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn token_redaction() {
|
||||
let l = "dashboard at http://127.0.0.1:58776/t/a3a01c537130bceeaa1f6118ba48d63e/ (log x)";
|
||||
assert_eq!(super::redact(l), "dashboard at http://127.0.0.1:58776/t/<token>/ (log x)");
|
||||
assert!(super::carries_token(l));
|
||||
assert!(!super::carries_token("GET /t/short/ nothing"));
|
||||
assert_eq!(super::redact("no token here"), "no token here");
|
||||
}
|
||||
}
|
||||
40
app/igneum-common/src/run.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! A command with a time limit (app/igneum-app/src/detect.rs `run_timeout`).
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Runs a command with a time limit; returns stdout (and stderr appended) or None.
|
||||
pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option<String> {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.stdin(if stdin_text.is_some() { Stdio::piped() } else { Stdio::null() });
|
||||
crate::platform::quiet(cmd);
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
if let (Some(text), Some(mut stdin)) = (stdin_text, child.stdin.take()) {
|
||||
let _ = stdin.write_all(text.as_bytes());
|
||||
drop(stdin);
|
||||
}
|
||||
let out = child.stdout.take()?;
|
||||
let err = child.stderr.take()?;
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(out), &mut s);
|
||||
let mut e = String::new();
|
||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(err), &mut e);
|
||||
(s, e)
|
||||
});
|
||||
let deadline = Instant::now() + limit;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (s, e) = reader.join().ok()?;
|
||||
Some(if e.is_empty() { s } else { format!("{s}\n{e}") })
|
||||
}
|
||||
5207
app/igneum-wallet/Cargo.lock
generated
Normal file
41
app/igneum-wallet/Cargo.toml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
[package]
|
||||
name = "igneum-wallet"
|
||||
version = "0.1.6"
|
||||
edition = "2021"
|
||||
description = "Igneum Wallet engine: keeps the key encrypted, signs in Rust, reads a node, verifies finality certificates itself, and serves the window on 127.0.0.1"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "igneum-wallet"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
igneum-common = { path = "../igneum-common" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
k256 = { version = "0.13", features = ["ecdsa", "std"] }
|
||||
sha3 = { version = "0.10", default-features = false }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
getrandom = "0.2"
|
||||
bip39 = { version = "2.1", features = ["rand"] }
|
||||
bip32 = "0.5"
|
||||
argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
qrcodegen = "1.8"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||
# the node's own code: its gRPC client, the RPC model and the finality certificate verification (vendor/igneum-node)
|
||||
kaspa-grpc-client = { path = "../../vendor/igneum-node/rpc/grpc/client" }
|
||||
kaspa-rpc-core = { path = "../../vendor/igneum-node/rpc/core" }
|
||||
kaspa-consensus-core = { path = "../../vendor/igneum-node/consensus/core" }
|
||||
kaspa-hashes = { path = "../../vendor/igneum-node/crypto/hashes" }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
lto = "thin"
|
||||
codegen-units = 4
|
||||
strip = true
|
||||
227
app/igneum-wallet/README.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# Igneum Wallet
|
||||
|
||||
Desktop wallet on the miner's bones: a Rust engine (`src/`) that keeps the key encrypted, signs, reads a node and
|
||||
verifies finality certificates, plus a window served on 127.0.0.1 (`ui/`). macOS host: `app/mac/IgneumWallet.swift`;
|
||||
packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet.iss`. Shared code with the miner:
|
||||
`app/igneum-common`.
|
||||
|
||||
## Versions
|
||||
|
||||
| Version | Date | What |
|
||||
|---|---|---|
|
||||
| 0.1.0 | 4 Oct 2026 | first DMG; built before `/coin.png` existed, so the coin on the home screen is blank; updates download and offer "Open the download" only |
|
||||
| 0.1.1 | 5 Oct 2026 | coin served from `brand/igneum-coin-1024.png`; over-the-air updates v2 (unattended install) |
|
||||
| 0.1.2 | 5 Oct 2026 | Touch ID (macOS) and Windows Hello (Windows, untested): unlock, confirm sends, show the backup, idle lock; the coin and the "reading the chain" line on the balance card; the version in the header and in Settings |
|
||||
| 0.1.3 | 5 Oct 2026 | the update card: one centred card over the window when a new version is ready, with what changed and one tap to install (`ui/update-card.js`) |
|
||||
| 0.1.6 | 8 Oct 2026 | the page bridge (`src/bridge.rs`, `src/eip712.rs`, `site/wallet-provider.js`): websites connect through 127.0.0.1:26811 after your approval in the window; eth_requestAccounts, eth_chainId, eth_sendTransaction, personal_sign and typed data, each shown and confirmed here (Touch ID or Windows Hello when enrolled), the finality line after a transaction; Settings lists the connected sites; a page without approval gets nothing |
|
||||
| 0.1.4 | 5 Oct 2026 | the lock screen (`ui/lock-screen.js`): the coin on the ember glow, the name, the short address, one control; the Touch ID sheet on a tap, once by itself when the wallet opens (setting), never on an idle lock; locking is a 250 ms transition; the idle lock's minutes in Settings (1, 5, 15, 60, never) |
|
||||
|
||||
## The lock screen (ui/lock-screen.js, index.html #screen-unlock, app.js onLockScreen; 0.1.4)
|
||||
|
||||
The coin large and centred on the obsidian ground with the ember glow (it breathes over 6 s; reduced motion stops
|
||||
it), "Igneum Wallet" in Unbounded, the wallet's short address in mono, one primary control. With Touch ID (or Windows
|
||||
Hello) on and the app window as the host: the fingerprint button "Unlock with Touch ID" in ember, its line under it,
|
||||
and a quiet "Use password" that reveals the password field in place (the control area keeps one height, so nothing
|
||||
above it moves). Otherwise the password field is the control and the button is absent. Locking (the Lock button,
|
||||
the menu bar, the idle lock) is a 250 ms transition from the home screen to the lock screen, not a cut.
|
||||
|
||||
When the system sheet appears (`LockScreen.autoPrompt`, the rules in `ui/lock-screen.test.mjs`):
|
||||
|
||||
| Moment | The sheet |
|
||||
|---|---|
|
||||
| a tap on the button, or Return or Space on it (it has the focus on arrival, and again when the window comes back) | yes |
|
||||
| the first arrival after the person opened the app, with "Ask for Touch ID when the wallet opens" on (Settings, default on) | once, 600 ms after the lock screen is fully drawn |
|
||||
| the idle lock, the Lock button or menu item, the window coming back from the menu bar or the Dock | never |
|
||||
| after a cancelled or unmatched sheet | never; the line says "Touch ID cancelled, tap to try again" |
|
||||
| the first run after an over-the-air update (the updater opened the app, not the person) | never |
|
||||
| the window not visible at arrival (opened at login behind another app) | never |
|
||||
|
||||
After the sheet the line under the button, in our words and never a modal: "Touch ID confirmed, unlocking", "Touch
|
||||
ID cancelled, tap to try again", "Touch ID did not match, tap to try again", "Enter your password" (the sheet's
|
||||
Use password button reveals the field), "Touch ID is locked, use your password". A sheet that cannot help (locked,
|
||||
invalidated, not set up, a browser tab) reveals the password field and focuses it. Escape in the password field
|
||||
returns to the button. `?bio=touch` on the page shows the Touch ID layout without a host (screenshots).
|
||||
|
||||
## Touch ID and Windows Hello (src/engine.rs, igneum-common/src/biometric.rs, app/mac/Biometric.swift, app/windows/biometric.h)
|
||||
|
||||
What it gates once "Use Touch ID" is on (Settings): unlocking at start and after the idle lock, every send, and
|
||||
showing the words or the key. The password still works for all three. Nothing else asks for a finger.
|
||||
|
||||
| Piece | Where | What it does |
|
||||
|---|---|---|
|
||||
| the gate | `igneum-common/src/biometric.rs` | nonces the engine issues, the host confirms and the action consumes once; the one-time enrolment token; the state in `/api/state` |
|
||||
| the engine | `src/engine.rs`, `src/server.rs` | `/api/biometric/*`; `/api/send` refuses without a confirmed nonce for that quote while enrolled; `/api/reveal` with a nonce reads the unlocked key in memory; the idle lock; the `HOST {...}` line on stdout |
|
||||
| the Mac host | `app/mac/Biometric.swift` | the `biometric` script message handler; `LAPolicy.deviceOwnerAuthenticationWithBiometrics`; the Secure Enclave seal |
|
||||
| the Windows host | `app/windows/biometric.h` | the `window.chrome.webview` bridge; `UserConsentVerifier` through `IUserConsentVerifierInterop`; DPAPI |
|
||||
| the page | `ui/app.js` | the fingerprint controls on the unlock, send and Settings screens; the activity ping for the idle lock |
|
||||
|
||||
The prompt's lines, worded by the engine or the host, never by the page, in our voice, under 60 characters, no
|
||||
trailing full stop:
|
||||
|
||||
| Prompt | Line |
|
||||
|---|---|
|
||||
| unlock | Unlock your wallet |
|
||||
| send | Send 1.5 IGN to 0x7E5F…5Bdf (the amount to 4 decimals; the address as its first 6 and last 4 characters) |
|
||||
| backup and export | Show your recovery words |
|
||||
| turn on | Turn on Touch ID for your wallet |
|
||||
|
||||
The prompt's buttons: "Use password" (the fallback button; the policy is biometrics only, so it never reaches the
|
||||
device password: the window asks for the wallet's own password) and "Cancel". After the system prompt the page shows
|
||||
its own line with the fingerprint glyph in ember: "Touch ID confirmed, unlocking", "Touch ID cancelled, enter your
|
||||
password", "Touch ID did not match, try again or enter your password", and so on (`bioLine` in `ui/app.js`). The
|
||||
prompt's title and icon are the bundled app's ("Igneum Wallet", the mark): the window host is the bundle's own
|
||||
executable, so the system attributes the prompt to it; a bare engine or a test binary shows its own name instead.
|
||||
macOS composes the sentence itself ("Igneum Wallet is trying to unlock your wallet."), so the Mac host lowercases
|
||||
the line's first letter at display time; Windows Hello shows the line on its own, with its capital.
|
||||
|
||||
The flow for a send: the quote (`/api/send/quote`) comes with `confirm_nonce` and `confirm_reason`. The page hands the nonce to the host; the host
|
||||
reads the reason from the engine with its host token, shows the prompt with that line, and on success posts
|
||||
`/api/biometric/confirm`. The page then calls `/api/send` with the quote and the nonce; the engine checks the nonce
|
||||
was confirmed within 30 s, for this exact quote (address, value, transaction nonce, chain id), and not used before.
|
||||
Unlock: the host shows the prompt, unseals the password and posts it to `/api/unlock` itself. The backup: a
|
||||
`reveal` challenge, the prompt, then `/api/reveal` with the nonce; the words come from the key already unlocked in
|
||||
memory, so the password is neither typed nor stored for it.
|
||||
|
||||
The host token: the engine prints `HOST {"token": ..., "biometric_file": ...}` on its stdout, which only the window
|
||||
host reads. The host sends it as `X-Igneum-Host` on the calls only it may make (status, report, confirm, taking the
|
||||
parked password at enrolment, recording the enrolment). The page cannot confirm its own challenges.
|
||||
|
||||
Enrolment: the page posts the password to `/api/biometric/enrol/begin`; the engine checks it against the vault and
|
||||
parks it under a one-time token for 120 s; the host shows the prompt ("Turn on Touch ID for Igneum Wallet"), takes
|
||||
the password with the token (once), seals it and writes the file, then posts `/api/biometric/enrolled`. A password
|
||||
change deletes the sealed file and Settings asks to turn Touch ID on again. Removing the wallet deletes it too.
|
||||
|
||||
The idle lock: with Touch ID on, Settings > "Lock when idle" chooses 1, 5 (the default), 15 or 60 minutes, or never
|
||||
(`settings.json: idle_lock_min`, 0 for never; `idle_lock` mirrors on/off for 0.1.2 and 0.1.3). The engine zeroes
|
||||
the key after that long without the window reporting input (mouse, keys; `/api/activity` every 20 s while there is
|
||||
some), never during a send or with a confirm screen open. The next unlock is a tap on the button, or the password;
|
||||
the sheet is never raised by itself after an idle lock.
|
||||
|
||||
### What is stored, and where (macOS)
|
||||
|
||||
The packaging signs the app ad hoc. Under an ad hoc signature macOS refuses every Keychain item that carries a
|
||||
biometric access control, on the login keychain and the data-protection keychain alike (`errSecMissingEntitlement`,
|
||||
-34018: `keychain-access-groups` needs a team signature; measured 5 October 2026 on this Mac with a 40-line probe).
|
||||
A Secure Enclave key with the same control is allowed, so the password is sealed to one instead:
|
||||
|
||||
- enrol: a P-256 key is made in the Secure Enclave with `SecAccessControl(.privateKeyUsage, .biometryCurrentSet)`;
|
||||
an ephemeral P-256 key agrees a shared secret with its public half (no prompt), HKDF-SHA256 derives an AES-GCM key
|
||||
and the password is sealed. `~/Library/Application Support/Igneum/wallet/biometric.json` (0600) holds the Secure
|
||||
Enclave key's wrapped form, the ephemeral public key and the box.
|
||||
- unlock or confirm: the host evaluates `LAPolicy.deviceOwnerAuthenticationWithBiometrics` (fallback button "Use
|
||||
password", which only closes the prompt with `LAError.userFallback`; the device password is never offered), then
|
||||
uses the Secure Enclave key under that context. The key agreement runs on every confirm
|
||||
too, so a changed fingerprint set is caught on a send, not only on an unlock.
|
||||
- `.biometryCurrentSet`: a fingerprint added or removed in System Settings makes the Secure Enclave refuse the key.
|
||||
The host reports "invalidated"; the window says to use the password and turn Touch ID on again.
|
||||
|
||||
Windows: the password is sealed with DPAPI (`CryptProtectData`, current user, `CRYPTPROTECT_UI_FORBIDDEN`) only after
|
||||
a `UserConsentVerifier` success and written to `%LOCALAPPDATA%\igneum\wallet\biometric.json`. DPAPI has no
|
||||
biometric binding of its own: the host unseals only after Hello verified.
|
||||
|
||||
### Threat model
|
||||
|
||||
| | Touch ID protects | Touch ID does not protect |
|
||||
|---|---|---|
|
||||
| Casual access at an unlocked Mac (someone at the keyboard while the wallet is locked) | yes: no finger, no unlock, no send, no words; the idle lock closes the window within 5 minutes of nobody being there | |
|
||||
| A copy of `vault.json` taken off the machine | yes, as before: Argon2id + XChaCha20-Poly1305 under the password; the sealed file is useless off this Mac's Secure Enclave | |
|
||||
| A copy of `biometric.json` by another user on this Mac | yes: 0600, and the Secure Enclave key is bound to this device and the current fingerprint set | |
|
||||
| A root attacker, or malware running as the signed-in user | | no: it can read the wallet's memory while unlocked, patch the app, or drive the window; the sealed file is as strong as the user's macOS login and Secure Enclave, not stronger |
|
||||
| Someone who knows the password | | no: the password works everywhere Touch ID does, by design |
|
||||
| A fingerprint added to this Mac by someone with the macOS password | | the Secure Enclave key dies (`.biometryCurrentSet`), so the new finger cannot unlock; the person with the macOS password could still enrol again, which needs the wallet password |
|
||||
| The page (ui/) or a cross-site page in the browser | yes: the host token is never in the page; confirmations are host-only; `/api/send` binds the nonce to the quote; the same-origin guard stays | |
|
||||
|
||||
Windows (untested): DPAPI protects against other accounts and offline copies, not against code running as the user;
|
||||
the same table applies with "Windows Hello" and "the Windows account".
|
||||
|
||||
### Verified (5 October 2026)
|
||||
|
||||
- Unit tests: `cargo test biometric` in `app/igneum-common` (confirm/take once, replay, expiry, stale, mismatch,
|
||||
the enrolment token, reason clipping, the constant-time compare, the binding).
|
||||
- The Swift host compiles with `Biometric.swift` and links LocalAuthentication; the DMG builds.
|
||||
- The probe: `SecItemAdd` with `.biometryCurrentSet` fails with -34018 under the ad hoc signature; a non-permanent
|
||||
Secure Enclave key with the same control is created, exported (`dataRepresentation`, 569 bytes), re-imported, and
|
||||
the ephemeral key agreement seals a box without a prompt.
|
||||
|
||||
### Untested
|
||||
|
||||
- The Windows path: `biometric.h` was written on a Mac; the first compile is BUILD-WALLET-APP.bat on the runner.
|
||||
- See the report for whether the Touch ID prompt was exercised end to end on this Mac (a finger is needed).
|
||||
|
||||
## Over-the-air updates (src/updater.rs, igneum-common/src/{fetch,ota}.rs)
|
||||
|
||||
The signed manifest `igneum-wallet-latest.json` (+ `.sig`, the miner's Ed25519 key) is published with
|
||||
`packaging/ota/publish-manifest.sh --product wallet --version <v> --mac packaging/mac/dist/Igneum-Wallet-<v>.dmg --notes "..." --deploy`.
|
||||
The engine checks it 25 s after start, then hourly (10 minutes after an error), and from Settings > Check for updates.
|
||||
|
||||
States (`state.update.status`, what the banner says):
|
||||
|
||||
| State | Meaning |
|
||||
|---|---|
|
||||
| off | the build has no manifest URL |
|
||||
| unknown | not checked yet |
|
||||
| checking | fetching and verifying the manifest |
|
||||
| current | this is the latest version |
|
||||
| available | a newer version has a build for this platform; the download starts at once |
|
||||
| downloading | curl with resume into `<wallet data>/updates/`; size and sha256 checked against the manifest |
|
||||
| staging | macOS: the DMG mounted, the bundle copied next to the running one as `.Igneum Wallet.app.new`, its engine asked `--version`, the bundle digested; Windows: the installer is the staged artefact |
|
||||
| ready | waits for the safe moment (below); "Install now" applies at once; "Later" hides the banner for that version only |
|
||||
| applying | the download and the staged bundle re-verified, `update-pending.json` written, the helper started; macOS: the engine quits and the helper swaps the bundle and opens the new app |
|
||||
| deferred | Windows only: the installer's administrator prompt was not answered; retried in 6 hours or on Install now |
|
||||
| manual | the engine cannot swap itself (not in a bundle, Applications not writable): "Open the download" |
|
||||
| error | what failed, in `state.update.error`; a version whose apply failed is never re-applied by itself |
|
||||
|
||||
The window shows an update twice over. The card (`ui/update-card.js`, `renderUpdateCard` in `ui/app.js`) is the
|
||||
first sight: one centred card with the mark and a progress ring, "Igneum Wallet 0.1.3", one line, up to three lines of
|
||||
the manifest's notes (the rest behind "What changed"), the size, Install now and Later. It never opens while the send
|
||||
screen is open, while a Touch ID or Windows Hello line is on screen, or while a wallet is being created or imported;
|
||||
it waits and comes once that is over. Later (also Escape and the backdrop) hides that version at that stage and
|
||||
leaves the banner; the card comes back for a newer version, or when the download is ready and automatic updates are
|
||||
off. An open card follows its update through downloading, ready, installing ("Installing. The app restarts itself.")
|
||||
and failed (the one-line cause, Try again). The banner is the small strip that stays. `?update=<state>[&auto=0][&card=1]`
|
||||
on the page shows each state without a manifest; `ui/update-card.test.mjs` checks the words and the rules.
|
||||
|
||||
The setting "Install updates by itself when nothing is being sent" (`settings.json: auto_update`) defaults to on.
|
||||
The safe moment is no send in flight: no `/api/send` running, no quote given in the last 180 s (a confirm screen may
|
||||
be open), no sent transaction still waiting for its block, no create flow half way. A version below the manifest's
|
||||
`min_supported_version` installs at once.
|
||||
|
||||
Rollback: the macOS helper puts `Igneum Wallet.app.previous` back when the new app does not start twice. The new
|
||||
engine counts its starts in `update-pending.json`; on the third start without 90 healthy seconds it restores the
|
||||
previous version (never below `min_supported_version`). The window shows "Updated from X" on the first run after an
|
||||
update and "Rolled back: ..." after a restore.
|
||||
|
||||
Files in `~/Library/Application Support/Igneum/wallet/` (Windows: `%LOCALAPPDATA%\igneum\wallet\`): `updates/`
|
||||
(manifest, download), `update-pending.json`, `update-result.json`, `ota-apply.sh` or `ota-apply.ps1`,
|
||||
`ota-apply.log`, `failed-versions.json`, `ota-relaunch.env` (test runs only).
|
||||
|
||||
### Verified (5 October 2026)
|
||||
|
||||
- Unit tests: manifest parse, version comparison, plan, safe moment (`cargo test` in `app/igneum-wallet`); helper
|
||||
templates, pending/result files, env file, digest recipe (`cargo test` in `app/igneum-common`).
|
||||
- End to end on this Mac with a scratch copy of the 0.1.1 bundle against a 0.1.2 test manifest served from
|
||||
127.0.0.1 (`publish-manifest.sh --dest <folder> --base-url http://127.0.0.1:<port>`; the engine run with
|
||||
`IGNEUM_APP_DATA`, `IGNEUM_WALLET_UPDATE_MANIFEST`, `IGNEUM_WALLET_UPDATE_FIRST_SECS=3`, `IGNEUM_OTA_RELAUNCH_ENGINE=1`
|
||||
so the helper relaunches the engine alone): check, download, stage, apply, swap, relaunch as 0.1.2,
|
||||
"updated to Igneum Wallet 0.1.2 from 0.1.1".
|
||||
- For real on the founder's Mac, through LaunchServices with the real window host: 0.1.1 -> 0.1.2 (5 Oct 2026, 09:14Z)
|
||||
and 0.1.2 -> 0.1.3 (5 Oct 2026: check posted 13:23:18Z, staged 13:23:31Z, applied 13:23:40Z, 0.1.3 up 13:23:46Z,
|
||||
28 s end to end; `/api/state` then showed `updated_from` 0.1.2 and `current`).
|
||||
- 0.1.3 -> 0.1.4 (5 Oct 2026): check posted 15:48:50Z, downloaded and verified 15:48:52Z (`staging` in `/api/state`),
|
||||
staged bundle digested 15:49:15Z, helper started 15:49:16Z (the 0.1.3 engine gone), 0.1.4 up 15:49:19Z with
|
||||
`updated_from` 0.1.3 and `unknown`, `current` at 15:49:45Z after its own check. 0.1.4 raised no Touch ID sheet on
|
||||
that first run (the updater's relaunch, not the person's: `last_op` stayed empty).
|
||||
- The download token rotated on 5 Oct 2026 (docs/plans/rotation-phase-2.md): `publish-manifest.sh` writes the NEW
|
||||
folder (it reads `~/.config/igneum/dl-token`), and a 0.1.4 bundle points there. The installed 0.1.3 polls the OLD
|
||||
folder's `igneum-wallet-latest.json`, which phase 2 had removed (the plan's section 8b kept only the miner's bridge),
|
||||
so the 0.1.4 manifest, its signature and the DMG were copied into the OLD folder too, as a bridge, until the 0.1.3
|
||||
wallets have moved; the old folder goes on 7 October (section 8f).
|
||||
|
||||
### Untested
|
||||
|
||||
- The Windows path (installer first, `/IGNOTA=1`, deferral on an unanswered prompt): copied from the miner's, never
|
||||
run for the wallet. Needs the wallet installer on the GitHub runner and a PC.
|
||||
- The rollback paths (the helper's "did not start twice", the engine's third-start restore) and `deferred`.
|
||||
- A version below `min_supported_version` (no wallet manifest has set one).
|
||||
- Code signatures: bundles are signed ad hoc by the packaging script; the updater verifies the manifest's sha256 and
|
||||
the staged bundle's digest, not a Developer ID signature (the miner does the same).
|
||||
491
app/igneum-wallet/src/bridge.rs
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
//! The page bridge (0.1.6, 8 October 2026, main's order: igneum.network/swap connects any injected wallet, the Igneum
|
||||
//! Wallet signed only inside its own window). An EIP-1193 provider for pages: the engine listens on a FIXED loopback port
|
||||
//! (BRIDGE_PORT, igneum_common::http::serve_on) beside its token-guarded window server; a page's shim
|
||||
//! (site/wallet-provider.js) POSTs JSON-RPC to /bridge/rpc with an Origin the browser sets and the X-Igneum-Bridge
|
||||
//! header (so a preflight runs first). Nothing leaves without the person's word in the wallet's own window:
|
||||
//!
|
||||
//! - a site is approved once (eth_requestAccounts raises a Connect request; Approve in the window stores the origin
|
||||
//! in the settings; Decline or 5 minutes of silence ends it with 4001); every other method from an origin that is
|
||||
//! not approved answers 4100 and creates nothing (the known-failed test: a page without approval gets nothing);
|
||||
//! - eth_sendTransaction, personal_sign and eth_signTypedData(_v4) each raise a request the window shows with the
|
||||
//! origin and the facts (to, value, the call's bytes, the fee; the message; the domain and the primary type);
|
||||
//! Confirm signs (through Touch ID or Windows Hello when enrolled, the same gate as a send) and the page's poll
|
||||
//! reads the hash or the signature; after a transaction the window keeps the finality certificate's line;
|
||||
//! - reads (eth_call, eth_estimateGas, receipts, balances, blocks, logs) go to the wallet's node for an approved
|
||||
//! origin, the chain id and net version with them; wallet_switchEthereumChain accepts the wallet's own chain
|
||||
//! and refuses any other with 4902.
|
||||
//!
|
||||
//! This module holds the pure part (what a request means, what a decision does, what the page may read); the engine
|
||||
//! wires it to the vault, the node and the biometric gate (engine.rs, the "page bridge" section) and the server routes
|
||||
//! it (server.rs). Tests here run without a vault.
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The bridge's port: fixed, so a page can find the wallet (IGNEUM_WALLET_BRIDGE_PORT overrides it for tests).
|
||||
pub const BRIDGE_PORT: u16 = 26811;
|
||||
/// A request the window has not answered expires after this.
|
||||
pub const PENDING_TTL: Duration = Duration::from_secs(300);
|
||||
/// A result the page has not read is kept this long after the decision.
|
||||
pub const RESULT_TTL: Duration = Duration::from_secs(120);
|
||||
pub const MAX_PENDING: usize = 8;
|
||||
|
||||
// EIP-1193 provider error codes
|
||||
pub const E_REJECTED: i64 = 4001;
|
||||
pub const E_UNAUTHORIZED: i64 = 4100;
|
||||
pub const E_UNSUPPORTED: i64 = 4200;
|
||||
pub const E_DISCONNECTED: i64 = 4900;
|
||||
pub const E_CHAIN: i64 = 4902;
|
||||
pub const E_PARAMS: i64 = -32602;
|
||||
pub const E_INTERNAL: i64 = -32603;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Connect,
|
||||
Send,
|
||||
Sign,
|
||||
Typed,
|
||||
}
|
||||
impl Kind {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Kind::Connect => "connect",
|
||||
Kind::Send => "send",
|
||||
Kind::Sign => "sign",
|
||||
Kind::Typed => "typed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One request waiting for the window, or decided and waiting for the page to read it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Pending {
|
||||
pub id: String,
|
||||
pub origin: String,
|
||||
pub kind: Kind,
|
||||
pub created: Instant,
|
||||
pub created_unix: f64,
|
||||
/// what the window shows (never the key, never the page's whole payload)
|
||||
pub detail: Value,
|
||||
/// what the engine acts on at Confirm (the transfer's fields, the digest to sign)
|
||||
pub request: Value,
|
||||
/// the biometric challenge for this request ("" when nothing is enrolled)
|
||||
pub confirm_nonce: String,
|
||||
pub confirm_reason: String,
|
||||
pub done: Option<Result<Value, (i64, String)>>,
|
||||
pub decided: Option<Instant>,
|
||||
}
|
||||
|
||||
/// What the engine knows at the moment of a request.
|
||||
pub struct Ask<'a> {
|
||||
pub address: &'a str,
|
||||
pub unlocked: bool,
|
||||
pub chain_id: u64,
|
||||
pub approved: bool,
|
||||
pub listening: bool,
|
||||
}
|
||||
|
||||
/// What a request means.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Reply {
|
||||
Result(Value),
|
||||
Error(i64, String),
|
||||
/// the page polls igneum_poll with this id
|
||||
Pending(String),
|
||||
/// a read the engine forwards to its node as it is
|
||||
Proxy,
|
||||
}
|
||||
|
||||
const PROXIED: &[&str] = &[
|
||||
"eth_call", "eth_estimateGas", "eth_blockNumber", "eth_getBalance", "eth_getTransactionReceipt", "eth_getTransactionByHash",
|
||||
"eth_gasPrice", "eth_maxPriorityFeePerGas", "eth_feeHistory", "eth_getCode", "eth_getLogs", "eth_getBlockByNumber", "eth_getBlockByHash",
|
||||
"eth_getTransactionCount", "eth_getStorageAt", "igneum_getTransactionStatus",
|
||||
];
|
||||
|
||||
pub struct Bridge {
|
||||
pub pending: Vec<Pending>,
|
||||
/// the last call from each origin (the Settings card's "last seen")
|
||||
pub last_seen: HashMap<String, f64>,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
fn hex_quantity(v: &Value) -> Option<u128> {
|
||||
match v {
|
||||
Value::String(s) => {
|
||||
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
|
||||
if h.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
u128::from_str_radix(h, 16).ok()
|
||||
}
|
||||
Value::Number(n) => n.as_u64().map(|x| x as u128),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn hex_bytes(v: &Value) -> Option<Vec<u8>> {
|
||||
let s = v.as_str()?;
|
||||
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?;
|
||||
if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
(0..h.len() / 2).map(|i| u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).ok()).collect()
|
||||
}
|
||||
fn is_address(s: &str) -> bool {
|
||||
s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
/// A personal_sign message: hex bytes when it looks like hex, else the text itself.
|
||||
pub fn message_bytes(v: &Value) -> Option<Vec<u8>> {
|
||||
let s = v.as_str()?;
|
||||
if s.starts_with("0x") && s.len() % 2 == 0 && s[2..].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
hex_bytes(v)
|
||||
} else {
|
||||
Some(s.as_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Bridge {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
pub fn new() -> Bridge {
|
||||
Bridge { pending: Vec::new(), last_seen: HashMap::new(), seq: 0 }
|
||||
}
|
||||
|
||||
fn new_id(&mut self) -> String {
|
||||
self.seq += 1;
|
||||
let mut raw = [0u8; 8];
|
||||
getrandom::getrandom(&mut raw).expect("os randomness");
|
||||
format!("{}-{}", self.seq, igneum_common::keys::hex(&raw))
|
||||
}
|
||||
|
||||
/// Requests nobody answered in PENDING_TTL end with 4001; results nobody read in RESULT_TTL are dropped. Returns
|
||||
/// the expired ones (the engine logs them).
|
||||
pub fn expire(&mut self, now: Instant) -> Vec<Pending> {
|
||||
let mut gone = Vec::new();
|
||||
for p in self.pending.iter_mut() {
|
||||
if p.done.is_none() && now.duration_since(p.created) > PENDING_TTL {
|
||||
p.done = Some(Err((E_REJECTED, "the request expired: nobody answered it in the Igneum Wallet window".into())));
|
||||
p.decided = Some(now);
|
||||
gone.push(p.clone());
|
||||
}
|
||||
}
|
||||
self.pending.retain(|p| !(p.done.is_some() && p.decided.map(|d| now.duration_since(d) > RESULT_TTL).unwrap_or(false)));
|
||||
gone
|
||||
}
|
||||
|
||||
pub fn open(&self) -> Vec<&Pending> {
|
||||
self.pending.iter().filter(|p| p.done.is_none()).collect()
|
||||
}
|
||||
|
||||
/// What the page's call means. `approved` is the engine's reading of its settings for this origin.
|
||||
pub fn request(&mut self, origin: &str, method: &str, params: &Value, a: &Ask, now: Instant, now_unix: f64) -> Reply {
|
||||
if origin.is_empty() {
|
||||
return Reply::Error(E_UNAUTHORIZED, "a page bridge call carries its origin".into());
|
||||
}
|
||||
self.last_seen.insert(origin.to_string(), now_unix);
|
||||
let list = params.as_array().cloned().unwrap_or_default();
|
||||
let chain_hex = format!("0x{:x}", a.chain_id);
|
||||
match method {
|
||||
"igneum_poll" => {
|
||||
let id = list.first().and_then(|v| v.as_str()).unwrap_or("");
|
||||
return self.poll(origin, id);
|
||||
}
|
||||
"eth_requestAccounts" => {
|
||||
if a.approved {
|
||||
return if a.unlocked {
|
||||
Reply::Result(json!([a.address]))
|
||||
} else {
|
||||
Reply::Error(E_UNAUTHORIZED, "the Igneum Wallet is locked: unlock it in its window, then try again".into())
|
||||
};
|
||||
}
|
||||
if let Some(p) = self.pending.iter().find(|p| p.done.is_none() && p.kind == Kind::Connect && p.origin == origin) {
|
||||
return Reply::Pending(p.id.clone());
|
||||
}
|
||||
if self.open().len() >= MAX_PENDING {
|
||||
return Reply::Error(E_INTERNAL, "too many requests are waiting in the wallet window".into());
|
||||
}
|
||||
let id = self.new_id();
|
||||
self.pending.push(Pending { id: id.clone(), origin: origin.into(), kind: Kind::Connect, created: now, created_unix: now_unix, detail: json!({ "origin": origin }), request: Value::Null, confirm_nonce: String::new(), confirm_reason: String::new(), done: None, decided: None });
|
||||
return Reply::Pending(id);
|
||||
}
|
||||
"eth_accounts" => {
|
||||
return Reply::Result(if a.approved && a.unlocked { json!([a.address]) } else { json!([]) });
|
||||
}
|
||||
"igneum_disconnect" => return Reply::Result(Value::Null),
|
||||
_ => {}
|
||||
}
|
||||
if !a.approved {
|
||||
return Reply::Error(E_UNAUTHORIZED, "this site is not connected to the Igneum Wallet: call eth_requestAccounts first".into());
|
||||
}
|
||||
match method {
|
||||
"eth_chainId" => Reply::Result(json!(chain_hex)),
|
||||
"net_version" => Reply::Result(json!(a.chain_id.to_string())),
|
||||
"wallet_switchEthereumChain" | "wallet_addEthereumChain" => {
|
||||
let want = list.first().and_then(|v| v.get("chainId")).and_then(hex_quantity);
|
||||
match want {
|
||||
Some(c) if c == a.chain_id as u128 => Reply::Result(Value::Null),
|
||||
Some(_) => Reply::Error(E_CHAIN, format!("the Igneum Wallet runs one chain, id {} ({chain_hex})", a.chain_id)),
|
||||
None => Reply::Error(E_PARAMS, "chainId missing".into()),
|
||||
}
|
||||
}
|
||||
"wallet_requestPermissions" | "wallet_getPermissions" => Reply::Result(json!([{ "parentCapability": "eth_accounts", "caveats": [{ "type": "restrictReturnedAccounts", "value": [a.address] }] }])),
|
||||
"eth_sendTransaction" | "personal_sign" | "eth_sign" | "eth_signTypedData" | "eth_signTypedData_v3" | "eth_signTypedData_v4" => {
|
||||
if !a.unlocked {
|
||||
return Reply::Error(E_UNAUTHORIZED, "the Igneum Wallet is locked: unlock it in its window, then try again".into());
|
||||
}
|
||||
if self.open().len() >= MAX_PENDING {
|
||||
return Reply::Error(E_INTERNAL, "too many requests are waiting in the wallet window".into());
|
||||
}
|
||||
let (kind, request, detail) = match method {
|
||||
"eth_sendTransaction" => {
|
||||
let tx = list.first().cloned().unwrap_or(Value::Null);
|
||||
let from = tx.get("from").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if !from.is_empty() && from != a.address.to_ascii_lowercase() {
|
||||
return Reply::Error(E_UNAUTHORIZED, "from is not this wallet's address".into());
|
||||
}
|
||||
let to = tx.get("to").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if !is_address(&to) {
|
||||
return Reply::Error(E_PARAMS, "to must be an address (contract creation is not offered)".into());
|
||||
}
|
||||
let value = tx.get("value").map(|v| hex_quantity(v).ok_or(())).unwrap_or(Ok(0));
|
||||
let Ok(value) = value else { return Reply::Error(E_PARAMS, "value must be a hex quantity".into()) };
|
||||
let data = match tx.get("data").or_else(|| tx.get("input")) {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(d) => match hex_bytes(d) {
|
||||
Some(b) => b,
|
||||
None => return Reply::Error(E_PARAMS, "data must be 0x hex".into()),
|
||||
},
|
||||
};
|
||||
if data.len() > 128 * 1024 {
|
||||
return Reply::Error(E_PARAMS, "data over 128 KiB".into());
|
||||
}
|
||||
let gas = tx.get("gas").or_else(|| tx.get("gasLimit")).and_then(hex_quantity).map(|g| g as u64);
|
||||
let selector = if data.len() >= 4 { format!("0x{}", igneum_common::keys::hex(&data[..4])) } else { String::new() };
|
||||
(Kind::Send, json!({ "to": to, "value": value.to_string(), "data": format!("0x{}", igneum_common::keys::hex(&data)), "gas": gas }),
|
||||
json!({ "origin": origin, "to": to, "to_display": igneum_common::keys::checksum(&to), "value": value.to_string(), "data_len": data.len(), "selector": selector, "gas": gas }))
|
||||
}
|
||||
"personal_sign" | "eth_sign" => {
|
||||
// personal_sign is [message, address]; eth_sign is [address, message]: take whichever is not the address
|
||||
let (m, addr) = if method == "personal_sign" { (list.first(), list.get(1)) } else { (list.get(1), list.first()) };
|
||||
if let Some(ad) = addr.and_then(|v| v.as_str()) {
|
||||
if !ad.eq_ignore_ascii_case(a.address) {
|
||||
return Reply::Error(E_UNAUTHORIZED, "the address is not this wallet's".into());
|
||||
}
|
||||
}
|
||||
let Some(bytes) = m.and_then(message_bytes) else { return Reply::Error(E_PARAMS, "message missing".into()) };
|
||||
if bytes.len() > 64 * 1024 {
|
||||
return Reply::Error(E_PARAMS, "message over 64 KiB".into());
|
||||
}
|
||||
let text = String::from_utf8(bytes.clone()).ok().filter(|t| !t.chars().any(|c| c.is_control() && c != '\n' && c != '\t'));
|
||||
let digest = crate::tx::personal_digest(&bytes);
|
||||
(Kind::Sign, json!({ "digest": format!("0x{}", igneum_common::keys::hex(&digest)) }),
|
||||
json!({ "origin": origin, "text": text, "bytes": bytes.len(), "hex": if text.is_none() { format!("0x{}", igneum_common::keys::hex(&bytes[..bytes.len().min(64)])) } else { String::new() } }))
|
||||
}
|
||||
_ => {
|
||||
// eth_signTypedData(_v3, _v4): [address, typed data as an object or a JSON string]
|
||||
if let Some(ad) = list.first().and_then(|v| v.as_str()) {
|
||||
if !ad.eq_ignore_ascii_case(a.address) {
|
||||
return Reply::Error(E_UNAUTHORIZED, "the address is not this wallet's".into());
|
||||
}
|
||||
}
|
||||
let typed = match list.get(1) {
|
||||
Some(Value::String(s)) => match serde_json::from_str::<Value>(s) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Reply::Error(E_PARAMS, format!("typed data is not JSON: {e}")),
|
||||
},
|
||||
Some(v) if v.is_object() => v.clone(),
|
||||
_ => return Reply::Error(E_PARAMS, "typed data missing".into()),
|
||||
};
|
||||
let t = match crate::eip712::hash(&typed) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Reply::Error(E_PARAMS, format!("typed data: {e}")),
|
||||
};
|
||||
if let Some(c) = t.chain_id {
|
||||
if c != a.chain_id as u128 {
|
||||
return Reply::Error(E_CHAIN, format!("the typed data names chain {c}; this wallet is on {}", a.chain_id));
|
||||
}
|
||||
}
|
||||
let message = typed.get("message").cloned().unwrap_or(Value::Null);
|
||||
let shown = serde_json::to_string_pretty(&message).unwrap_or_default();
|
||||
(Kind::Typed, json!({ "digest": format!("0x{}", igneum_common::keys::hex(&t.digest)) }),
|
||||
json!({ "origin": origin, "domain": t.domain_name, "primary_type": t.primary_type, "message": if shown.len() > 4000 { format!("{}\n…", &shown[..4000]) } else { shown } }))
|
||||
}
|
||||
};
|
||||
let id = self.new_id();
|
||||
self.pending.push(Pending { id: id.clone(), origin: origin.into(), kind, created: now, created_unix: now_unix, detail, request, confirm_nonce: String::new(), confirm_reason: String::new(), done: None, decided: None });
|
||||
Reply::Pending(id)
|
||||
}
|
||||
m if PROXIED.contains(&m) => Reply::Proxy,
|
||||
_ => Reply::Error(E_UNSUPPORTED, format!("{method} is not offered by the Igneum Wallet")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page reads a request's outcome: pending, the result, or the error. Only the origin that made it may read it.
|
||||
pub fn poll(&mut self, origin: &str, id: &str) -> Reply {
|
||||
let Some(p) = self.pending.iter().find(|p| p.id == id) else { return Reply::Error(E_INTERNAL, "unknown request".into()) };
|
||||
if p.origin != origin {
|
||||
return Reply::Error(E_UNAUTHORIZED, "not your request".into());
|
||||
}
|
||||
match &p.done {
|
||||
None => Reply::Pending(id.into()),
|
||||
Some(Ok(v)) => {
|
||||
let v = v.clone();
|
||||
self.pending.retain(|q| q.id != id);
|
||||
Reply::Result(v)
|
||||
}
|
||||
Some(Err((c, m))) => {
|
||||
let (c, m) = (*c, m.clone());
|
||||
self.pending.retain(|q| q.id != id);
|
||||
Reply::Error(c, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's word. Decline ends the request with 4001; Approve hands the request back to the engine, which
|
||||
/// signs or connects and then `resolve`s it.
|
||||
pub fn decide(&mut self, id: &str, ok: bool, now: Instant) -> Result<Pending, String> {
|
||||
let p = self.pending.iter_mut().find(|p| p.id == id && p.done.is_none()).ok_or("no such request is waiting")?;
|
||||
if !ok {
|
||||
p.done = Some(Err((E_REJECTED, "the person declined in the Igneum Wallet".into())));
|
||||
p.decided = Some(now);
|
||||
}
|
||||
Ok(p.clone())
|
||||
}
|
||||
|
||||
pub fn resolve(&mut self, id: &str, r: Result<Value, (i64, String)>, now: Instant) {
|
||||
if let Some(p) = self.pending.iter_mut().find(|p| p.id == id) {
|
||||
p.done = Some(r);
|
||||
p.decided = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_challenge(&mut self, id: &str, nonce: &str, reason: &str) {
|
||||
if let Some(p) = self.pending.iter_mut().find(|p| p.id == id) {
|
||||
p.confirm_nonce = nonce.into();
|
||||
p.confirm_reason = reason.into();
|
||||
}
|
||||
}
|
||||
|
||||
/// The open requests as the window shows them (the oldest first).
|
||||
pub fn pending_json(&self, enrolled: bool) -> Value {
|
||||
Value::Array(self.pending.iter().filter(|p| p.done.is_none()).map(|p| {
|
||||
let mut d = p.detail.clone();
|
||||
d["id"] = json!(p.id);
|
||||
d["kind"] = json!(p.kind.name());
|
||||
d["created_at"] = json!(p.created_unix);
|
||||
d["confirm_needed"] = json!(enrolled && p.kind != Kind::Connect);
|
||||
d["confirm_nonce"] = json!(p.confirm_nonce);
|
||||
d["confirm_reason"] = json!(p.confirm_reason);
|
||||
d
|
||||
}).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
const ME: &str = "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826";
|
||||
fn ask(approved: bool, unlocked: bool) -> Ask<'static> {
|
||||
Ask { address: ME, unlocked, chain_id: 4463, approved, listening: true }
|
||||
}
|
||||
fn tx() -> Value {
|
||||
json!([{ "from": ME, "to": "0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7", "value": "0xde0b6b3a7640000", "data": "0x38ed17390000000000000000000000000000000000000000000000000000000000000000" }])
|
||||
}
|
||||
|
||||
/// The known-failed shape of 0.1.5: a page could reach nothing at all; with the bridge, a page without approval
|
||||
/// must still get nothing but the connect question.
|
||||
#[test]
|
||||
fn a_page_without_approval_gets_nothing() {
|
||||
let mut b = Bridge::new();
|
||||
let now = Instant::now();
|
||||
for (m, p) in [("eth_chainId", json!([])), ("eth_sendTransaction", tx()), ("personal_sign", json!(["hello", ME])), ("eth_call", json!([{}, "latest"])), ("eth_signTypedData_v4", json!([ME, "{}"]))] {
|
||||
match b.request("https://igneum.network", m, &p, &ask(false, true), now, 1.0) {
|
||||
Reply::Error(c, _) => assert_eq!(c, E_UNAUTHORIZED, "{m}"),
|
||||
other => panic!("{m} answered {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(b.request("https://igneum.network", "eth_accounts", &json!([]), &ask(false, true), now, 1.0), Reply::Result(json!([])));
|
||||
assert!(b.open().is_empty(), "no request was created for an unapproved page");
|
||||
assert_eq!(b.request("", "eth_requestAccounts", &json!([]), &ask(false, true), now, 1.0), Reply::Error(E_UNAUTHORIZED, "a page bridge call carries its origin".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connect_request_waits_for_the_windows_word_and_only_its_origin_reads_it() {
|
||||
let mut b = Bridge::new();
|
||||
let now = Instant::now();
|
||||
let Reply::Pending(id) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), now, 1.0) else { panic!() };
|
||||
assert_eq!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), now, 2.0), Reply::Pending(id.clone()), "the same page asking again gets the same request");
|
||||
assert_eq!(b.poll("https://igneum.network", &id), Reply::Pending(id.clone()));
|
||||
assert_eq!(b.poll("https://evil.example", &id), Reply::Error(E_UNAUTHORIZED, "not your request".into()));
|
||||
let p = b.decide(&id, true, now).unwrap();
|
||||
assert_eq!(p.kind, Kind::Connect);
|
||||
b.resolve(&id, Ok(json!([ME])), now);
|
||||
assert_eq!(b.poll("https://igneum.network", &id), Reply::Result(json!([ME])));
|
||||
assert_eq!(b.poll("https://igneum.network", &id), Reply::Error(E_INTERNAL, "unknown request".into()), "read once");
|
||||
// declined
|
||||
let Reply::Pending(id2) = b.request("https://other.example", "eth_requestAccounts", &json!([]), &ask(false, true), now, 3.0) else { panic!() };
|
||||
b.decide(&id2, false, now).unwrap();
|
||||
assert!(matches!(b.poll("https://other.example", &id2), Reply::Error(E_REJECTED, _)));
|
||||
// once approved: the address, the chain, the reads proxied, another chain refused
|
||||
assert_eq!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(true, true), now, 4.0), Reply::Result(json!([ME])));
|
||||
assert_eq!(b.request("https://igneum.network", "eth_chainId", &json!([]), &ask(true, true), now, 4.0), Reply::Result(json!("0x116f")));
|
||||
assert_eq!(b.request("https://igneum.network", "eth_call", &json!([{}, "latest"]), &ask(true, true), now, 4.0), Reply::Proxy);
|
||||
assert_eq!(b.request("https://igneum.network", "wallet_switchEthereumChain", &json!([{ "chainId": "0x116f" }]), &ask(true, true), now, 4.0), Reply::Result(Value::Null));
|
||||
assert!(matches!(b.request("https://igneum.network", "wallet_switchEthereumChain", &json!([{ "chainId": "0x1" }]), &ask(true, true), now, 4.0), Reply::Error(E_CHAIN, _)));
|
||||
assert!(matches!(b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(true, false), now, 4.0), Reply::Error(E_UNAUTHORIZED, _)), "locked: no address");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_a_message_and_typed_data_each_wait_as_their_own_request_with_the_facts_the_window_shows() {
|
||||
let mut b = Bridge::new();
|
||||
let now = Instant::now();
|
||||
let Reply::Pending(id) = b.request("https://igneum.network", "eth_sendTransaction", &tx(), &ask(true, true), now, 1.0) else { panic!() };
|
||||
let p = b.pending.iter().find(|p| p.id == id).unwrap().clone();
|
||||
assert_eq!(p.kind, Kind::Send);
|
||||
assert_eq!(p.detail["to_display"], json!("0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7"));
|
||||
assert_eq!(p.detail["value"], json!("1000000000000000000"));
|
||||
assert_eq!(p.detail["selector"], json!("0x38ed1739"));
|
||||
assert_eq!(p.detail["data_len"], json!(36));
|
||||
assert_eq!(p.request["gas"], Value::Null);
|
||||
assert!(matches!(b.request("https://igneum.network", "eth_sendTransaction", &json!([{ "from": "0x1111111111111111111111111111111111111111", "to": ME }]), &ask(true, true), now, 1.0), Reply::Error(E_UNAUTHORIZED, _)));
|
||||
assert!(matches!(b.request("https://igneum.network", "eth_sendTransaction", &json!([{ "from": ME }]), &ask(true, true), now, 1.0), Reply::Error(E_PARAMS, _)));
|
||||
let Reply::Pending(id2) = b.request("https://igneum.network", "personal_sign", &json!(["0x68656c6c6f", ME]), &ask(true, true), now, 1.0) else { panic!() };
|
||||
let p2 = b.pending.iter().find(|p| p.id == id2).unwrap().clone();
|
||||
assert_eq!(p2.kind, Kind::Sign);
|
||||
assert_eq!(p2.detail["text"], json!("hello"));
|
||||
assert_eq!(p2.request["digest"].as_str().unwrap().len(), 66);
|
||||
let typed = json!({ "types": { "Person": [{ "name": "name", "type": "string" }] }, "primaryType": "Person", "domain": { "name": "Igneum Swap", "chainId": 4463 }, "message": { "name": "Cow" } });
|
||||
let Reply::Pending(id3) = b.request("https://igneum.network", "eth_signTypedData_v4", &json!([ME, typed.to_string()]), &ask(true, true), now, 1.0) else { panic!() };
|
||||
let p3 = b.pending.iter().find(|p| p.id == id3).unwrap().clone();
|
||||
assert_eq!(p3.kind, Kind::Typed);
|
||||
assert_eq!(p3.detail["domain"], json!("Igneum Swap"));
|
||||
assert_eq!(p3.detail["primary_type"], json!("Person"));
|
||||
let other_chain = json!({ "types": { "Person": [{ "name": "name", "type": "string" }] }, "primaryType": "Person", "domain": { "chainId": 1 }, "message": { "name": "Cow" } });
|
||||
assert!(matches!(b.request("https://igneum.network", "eth_signTypedData_v4", &json!([ME, other_chain]), &ask(true, true), now, 1.0), Reply::Error(E_CHAIN, _)));
|
||||
assert_eq!(b.pending_json(true).as_array().unwrap().len(), 3);
|
||||
assert_eq!(b.pending_json(true)[0]["confirm_needed"], json!(true));
|
||||
// the window's confirm, the engine's resolve, the page's read
|
||||
b.decide(&id2, true, now).unwrap();
|
||||
b.resolve(&id2, Ok(json!("0xsig")), now);
|
||||
assert_eq!(b.poll("https://igneum.network", &id2), Reply::Result(json!("0xsig")));
|
||||
assert_eq!(b.open().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unanswered_request_expires_and_a_read_result_is_dropped() {
|
||||
let mut b = Bridge::new();
|
||||
let t0 = Instant::now();
|
||||
let Reply::Pending(id) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), t0, 1.0) else { panic!() };
|
||||
assert!(b.expire(t0 + PENDING_TTL / 2).is_empty());
|
||||
let gone = b.expire(t0 + PENDING_TTL + Duration::from_secs(1));
|
||||
assert_eq!(gone.len(), 1);
|
||||
assert!(matches!(b.poll("https://igneum.network", &id), Reply::Error(E_REJECTED, m) if m.contains("expired")));
|
||||
let Reply::Pending(id2) = b.request("https://igneum.network", "eth_requestAccounts", &json!([]), &ask(false, true), t0, 2.0) else { panic!() };
|
||||
b.decide(&id2, false, t0).unwrap();
|
||||
b.expire(t0 + RESULT_TTL + Duration::from_secs(1));
|
||||
assert!(matches!(b.poll("https://igneum.network", &id2), Reply::Error(E_INTERNAL, _)), "an unread result is gone after RESULT_TTL");
|
||||
}
|
||||
}
|
||||
334
app/igneum-wallet/src/eip712.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
//! EIP-712 typed data hashing for the page bridge (8 October 2026): the digest a page asks the wallet to sign with
|
||||
//! eth_signTypedData_v4, computed here so the window can show the domain and the primary type and the key never
|
||||
//! leaves the engine. Supports the atomic types (uintN, intN, bytesN, bool, address), the dynamic ones (string,
|
||||
//! bytes), arrays (fixed and dynamic) and nested structs; the domain's fields are the ones present in `domain`
|
||||
//! (name, version, chainId, verifyingContract, salt) when `types` carries no EIP712Domain of its own.
|
||||
use serde_json::Value;
|
||||
use sha3::{Digest, Keccak256};
|
||||
|
||||
pub struct Typed {
|
||||
pub digest: [u8; 32],
|
||||
pub domain_name: String,
|
||||
pub primary_type: String,
|
||||
pub chain_id: Option<u128>,
|
||||
}
|
||||
|
||||
fn keccak(b: &[u8]) -> [u8; 32] {
|
||||
Keccak256::digest(b).into()
|
||||
}
|
||||
|
||||
/// A 256-bit unsigned number from a JSON number, a decimal string or a 0x hex string, big-endian in 32 bytes.
|
||||
pub fn u256_bytes(v: &Value) -> Result<[u8; 32], String> {
|
||||
let mut out = [0u8; 32];
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let x = n.as_u64().ok_or("a number must be a whole non-negative number")?;
|
||||
out[24..].copy_from_slice(&x.to_be_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
Value::String(s) => {
|
||||
let s = s.trim();
|
||||
if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
|
||||
if h.is_empty() || h.len() > 64 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!("not a 256-bit hex number: {s}"));
|
||||
}
|
||||
let padded = format!("{:0>64}", h);
|
||||
for i in 0..32 {
|
||||
out[i] = u8::from_str_radix(&padded[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(out)
|
||||
} else {
|
||||
if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(format!("not a decimal number: {s}"));
|
||||
}
|
||||
// schoolbook base-10 into 32 big-endian bytes
|
||||
for c in s.bytes() {
|
||||
let mut carry = (c - b'0') as u32;
|
||||
for i in (0..32).rev() {
|
||||
let x = out[i] as u32 * 10 + carry;
|
||||
out[i] = (x & 0xff) as u8;
|
||||
carry = x >> 8;
|
||||
}
|
||||
if carry != 0 {
|
||||
return Err("number over 256 bits".into());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
_ => Err("not a number".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn i256_bytes(v: &Value) -> Result<[u8; 32], String> {
|
||||
let neg = match v {
|
||||
Value::Number(n) => n.as_i64().map(|x| x < 0).unwrap_or(false),
|
||||
Value::String(s) => s.trim().starts_with('-'),
|
||||
_ => false,
|
||||
};
|
||||
if !neg {
|
||||
return u256_bytes(v);
|
||||
}
|
||||
let mag = match v {
|
||||
Value::Number(n) => Value::String((n.as_i64().unwrap().unsigned_abs()).to_string()),
|
||||
Value::String(s) => Value::String(s.trim()[1..].to_string()),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let m = u256_bytes(&mag)?;
|
||||
// two's complement: invert and add one
|
||||
let mut out = [0u8; 32];
|
||||
let mut carry = 1u16;
|
||||
for i in (0..32).rev() {
|
||||
let x = (!m[i]) as u16 + carry;
|
||||
out[i] = (x & 0xff) as u8;
|
||||
carry = x >> 8;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn hex_bytes(s: &str) -> Result<Vec<u8>, String> {
|
||||
let h = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).ok_or("bytes must be 0x hex")?;
|
||||
if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("bytes must be even-length hex".into());
|
||||
}
|
||||
(0..h.len() / 2).map(|i| u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())).collect()
|
||||
}
|
||||
|
||||
fn is_struct(types: &Value, t: &str) -> bool {
|
||||
types.get(t).map(|v| v.is_array()).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn base_type(t: &str) -> &str {
|
||||
match t.find('[') {
|
||||
Some(i) => &t[..i],
|
||||
None => t,
|
||||
}
|
||||
}
|
||||
|
||||
/// The struct types `t` depends on, `t` first, the rest sorted by name (EIP-712 encodeType).
|
||||
fn dependencies(types: &Value, t: &str) -> Result<Vec<String>, String> {
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
let mut todo = vec![t.to_string()];
|
||||
while let Some(cur) = todo.pop() {
|
||||
if found.contains(&cur) {
|
||||
continue;
|
||||
}
|
||||
let fields = types.get(&cur).and_then(|v| v.as_array()).ok_or(format!("unknown type {cur}"))?;
|
||||
found.push(cur.clone());
|
||||
for f in fields {
|
||||
let ft = f.get("type").and_then(|v| v.as_str()).ok_or("a field without a type")?;
|
||||
let b = base_type(ft);
|
||||
if is_struct(types, b) && !found.contains(&b.to_string()) {
|
||||
todo.push(b.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rest: Vec<String> = found.iter().skip(1).cloned().collect();
|
||||
rest.sort();
|
||||
let mut out = vec![found[0].clone()];
|
||||
out.extend(rest);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn encode_type(types: &Value, t: &str) -> Result<String, String> {
|
||||
let mut s = String::new();
|
||||
for name in dependencies(types, t)? {
|
||||
let fields = types.get(&name).and_then(|v| v.as_array()).ok_or("type")?;
|
||||
s.push_str(&name);
|
||||
s.push('(');
|
||||
let mut first = true;
|
||||
for f in fields {
|
||||
if !first {
|
||||
s.push(',');
|
||||
}
|
||||
first = false;
|
||||
s.push_str(f.get("type").and_then(|v| v.as_str()).ok_or("field type")?);
|
||||
s.push(' ');
|
||||
s.push_str(f.get("name").and_then(|v| v.as_str()).ok_or("field name")?);
|
||||
}
|
||||
s.push(')');
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn type_hash(types: &Value, t: &str) -> Result<[u8; 32], String> {
|
||||
Ok(keccak(encode_type(types, t)?.as_bytes()))
|
||||
}
|
||||
|
||||
fn encode_value(types: &Value, t: &str, v: &Value) -> Result<[u8; 32], String> {
|
||||
if let Some(i) = t.find('[') {
|
||||
// an array: keccak of the concatenated encodings of its elements
|
||||
let inner = &t[..i];
|
||||
let rest = &t[i + 1..];
|
||||
let items = v.as_array().ok_or(format!("{t}: not an array"))?;
|
||||
if let Some(n) = rest.strip_suffix(']').and_then(|n| if n.is_empty() { None } else { n.parse::<usize>().ok() }) {
|
||||
if items.len() != n {
|
||||
return Err(format!("{t}: {} items, {n} expected", items.len()));
|
||||
}
|
||||
}
|
||||
let tail = &rest[rest.find(']').map(|j| j + 1).unwrap_or(rest.len())..];
|
||||
let elem_type = format!("{inner}{tail}");
|
||||
let mut cat = Vec::new();
|
||||
for it in items {
|
||||
cat.extend_from_slice(&encode_value(types, &elem_type, it)?);
|
||||
}
|
||||
return Ok(keccak(&cat));
|
||||
}
|
||||
if is_struct(types, t) {
|
||||
return hash_struct(types, t, v);
|
||||
}
|
||||
match t {
|
||||
"string" => Ok(keccak(v.as_str().ok_or("string expected")?.as_bytes())),
|
||||
"bytes" => Ok(keccak(&hex_bytes(v.as_str().ok_or("bytes expected")?)?)),
|
||||
"bool" => {
|
||||
let mut out = [0u8; 32];
|
||||
out[31] = if v.as_bool().ok_or("bool expected")? { 1 } else { 0 };
|
||||
Ok(out)
|
||||
}
|
||||
"address" => {
|
||||
let b = hex_bytes(v.as_str().ok_or("address expected")?)?;
|
||||
if b.len() != 20 {
|
||||
return Err("an address is 20 bytes".into());
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
out[12..].copy_from_slice(&b);
|
||||
Ok(out)
|
||||
}
|
||||
_ if t.starts_with("uint") => {
|
||||
let bits: usize = t[4..].parse().map_err(|_| format!("bad type {t}"))?;
|
||||
if bits == 0 || bits > 256 || bits % 8 != 0 {
|
||||
return Err(format!("bad type {t}"));
|
||||
}
|
||||
u256_bytes(v)
|
||||
}
|
||||
_ if t.starts_with("int") => {
|
||||
let bits: usize = t[3..].parse().map_err(|_| format!("bad type {t}"))?;
|
||||
if bits == 0 || bits > 256 || bits % 8 != 0 {
|
||||
return Err(format!("bad type {t}"));
|
||||
}
|
||||
i256_bytes(v)
|
||||
}
|
||||
_ if t.starts_with("bytes") => {
|
||||
let n: usize = t[5..].parse().map_err(|_| format!("bad type {t}"))?;
|
||||
if n == 0 || n > 32 {
|
||||
return Err(format!("bad type {t}"));
|
||||
}
|
||||
let b = hex_bytes(v.as_str().ok_or("bytes expected")?)?;
|
||||
if b.len() != n {
|
||||
return Err(format!("{t}: {} bytes given", b.len()));
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
out[..n].copy_from_slice(&b);
|
||||
Ok(out)
|
||||
}
|
||||
_ => Err(format!("unsupported type {t}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hash_struct(types: &Value, t: &str, v: &Value) -> Result<[u8; 32], String> {
|
||||
let fields = types.get(t).and_then(|x| x.as_array()).ok_or(format!("unknown type {t}"))?;
|
||||
let obj = v.as_object().ok_or(format!("{t}: an object expected"))?;
|
||||
let mut enc = Vec::new();
|
||||
enc.extend_from_slice(&type_hash(types, t)?);
|
||||
for f in fields {
|
||||
let name = f.get("name").and_then(|x| x.as_str()).ok_or("field name")?;
|
||||
let ft = f.get("type").and_then(|x| x.as_str()).ok_or("field type")?;
|
||||
let fv = obj.get(name).ok_or(format!("{t}.{name} is missing"))?;
|
||||
enc.extend_from_slice(&encode_value(types, ft, fv)?);
|
||||
}
|
||||
Ok(keccak(&enc))
|
||||
}
|
||||
|
||||
/// The domain type when `types` has none: the fields present in `domain`, in the canonical order.
|
||||
fn domain_types(domain: &Value) -> Value {
|
||||
let order = [("name", "string"), ("version", "string"), ("chainId", "uint256"), ("verifyingContract", "address"), ("salt", "bytes32")];
|
||||
let mut v = Vec::new();
|
||||
for (n, t) in order {
|
||||
if domain.get(n).is_some() {
|
||||
v.push(serde_json::json!({ "name": n, "type": t }));
|
||||
}
|
||||
}
|
||||
Value::Array(v)
|
||||
}
|
||||
|
||||
/// The EIP-712 digest of a typed-data object ({types, primaryType, domain, message}).
|
||||
pub fn hash(typed: &Value) -> Result<Typed, String> {
|
||||
let mut types = typed.get("types").cloned().ok_or("typed data without types")?;
|
||||
let domain = typed.get("domain").cloned().unwrap_or(Value::Object(Default::default()));
|
||||
if !types.get("EIP712Domain").map(|v| v.is_array()).unwrap_or(false) {
|
||||
types["EIP712Domain"] = domain_types(&domain);
|
||||
}
|
||||
let primary = typed.get("primaryType").and_then(|v| v.as_str()).ok_or("typed data without primaryType")?.to_string();
|
||||
let message = typed.get("message").cloned().ok_or("typed data without message")?;
|
||||
let ds = hash_struct(&types, "EIP712Domain", &domain)?;
|
||||
let ms = if primary == "EIP712Domain" { None } else { Some(hash_struct(&types, &primary, &message)?) };
|
||||
let mut pre = vec![0x19, 0x01];
|
||||
pre.extend_from_slice(&ds);
|
||||
if let Some(m) = ms {
|
||||
pre.extend_from_slice(&m);
|
||||
}
|
||||
let chain_id = domain.get("chainId").and_then(|v| u256_bytes(v).ok()).map(|b| {
|
||||
let mut x = 0u128;
|
||||
for byte in &b[16..] {
|
||||
x = (x << 8) | *byte as u128;
|
||||
}
|
||||
x
|
||||
});
|
||||
Ok(Typed { digest: keccak(&pre), domain_name: domain.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(), primary_type: primary, chain_id })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// The specification's own example (EIP-712, "Mail" from Cow to Bob): the three known hashes.
|
||||
fn mail() -> Value {
|
||||
json!({
|
||||
"types": {
|
||||
"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"}, {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}],
|
||||
"Person": [{"name": "name", "type": "string"}, {"name": "wallet", "type": "address"}],
|
||||
"Mail": [{"name": "from", "type": "Person"}, {"name": "to", "type": "Person"}, {"name": "contents", "type": "string"}]
|
||||
},
|
||||
"primaryType": "Mail",
|
||||
"domain": {"name": "Ether Mail", "version": "1", "chainId": 1, "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},
|
||||
"message": {"from": {"name": "Cow", "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"}, "to": {"name": "Bob", "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"}, "contents": "Hello, Bob!"}
|
||||
})
|
||||
}
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_specifications_mail_example_hashes_as_published() {
|
||||
let m = mail();
|
||||
assert_eq!(encode_type(&m["types"], "Mail").unwrap(), "Mail(Person from,Person to,string contents)Person(string name,address wallet)");
|
||||
assert_eq!(hex(&hash_struct(&m["types"], "EIP712Domain", &m["domain"]).unwrap()), "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f"); // no-secrets-ok: the EIP-712 specification's published example hash
|
||||
assert_eq!(hex(&hash_struct(&m["types"], "Mail", &m["message"]).unwrap()), "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e"); // no-secrets-ok: the EIP-712 specification's published example hash
|
||||
let t = hash(&m).unwrap();
|
||||
assert_eq!(hex(&t.digest), "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); // no-secrets-ok: the EIP-712 specification's published example digest
|
||||
assert_eq!(t.domain_name, "Ether Mail");
|
||||
assert_eq!(t.primary_type, "Mail");
|
||||
assert_eq!(t.chain_id, Some(1));
|
||||
}
|
||||
#[test]
|
||||
fn a_domain_without_declared_types_takes_the_fields_present_in_order() {
|
||||
let mut m = mail();
|
||||
m["types"].as_object_mut().unwrap().remove("EIP712Domain");
|
||||
assert_eq!(hex(&hash(&m).unwrap().digest), "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); // no-secrets-ok: the EIP-712 specification's published example digest
|
||||
}
|
||||
#[test]
|
||||
fn numbers_arrays_and_bytes_encode() {
|
||||
assert_eq!(hex(&u256_bytes(&json!("115792089237316195423570985008687907853269984665640564039457584007913129639935")).unwrap()), "f".repeat(64));
|
||||
assert_eq!(hex(&u256_bytes(&json!("0x1f")).unwrap()), format!("{:0>64}", "1f"));
|
||||
assert_eq!(hex(&u256_bytes(&json!(31)).unwrap()), format!("{:0>64}", "1f"));
|
||||
assert_eq!(hex(&i256_bytes(&json!(-1)).unwrap()), "f".repeat(64));
|
||||
assert!(u256_bytes(&json!("1157920892373161954235709850086879078532699846656405640394575840079131296399350")).is_err());
|
||||
let types = json!({ "T": [{"name": "xs", "type": "uint8[]"}, {"name": "b", "type": "bytes4"}, {"name": "ok", "type": "bool"}] });
|
||||
let one = hash_struct(&types, "T", &json!({ "xs": [1, 2], "b": "0x01020304", "ok": true })).unwrap();
|
||||
let two = hash_struct(&types, "T", &json!({ "xs": [2, 1], "b": "0x01020304", "ok": true })).unwrap();
|
||||
assert_ne!(one, two);
|
||||
assert!(hash_struct(&types, "T", &json!({ "xs": [1], "b": "0x0102", "ok": true })).is_err(), "bytes4 with two bytes is refused");
|
||||
assert!(hash_struct(&types, "T", &json!({ "b": "0x01020304", "ok": true })).is_err(), "a missing field is refused");
|
||||
}
|
||||
}
|
||||
1603
app/igneum-wallet/src/engine.rs
Normal file
156
app/igneum-wallet/src/evm.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! The node's Ethereum JSON-RPC: what the wallet reads and the one thing it writes (eth_sendRawTransaction). Plain
|
||||
//! HTTP to 127.0.0.1 through igneum-common; a public https RPC (no local node) goes through curl.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Evm {
|
||||
/// "127.0.0.1:26790" (plain http) or "https://..." (curl)
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
pub fn q(v: &Value) -> Option<u128> {
|
||||
let s = v.as_str()?;
|
||||
u128::from_str_radix(s.trim_start_matches("0x"), 16).ok()
|
||||
}
|
||||
|
||||
pub fn hexq(v: u128) -> String {
|
||||
format!("0x{v:x}")
|
||||
}
|
||||
|
||||
impl Evm {
|
||||
pub fn local(port: u16) -> Evm {
|
||||
Evm { endpoint: format!("127.0.0.1:{port}") }
|
||||
}
|
||||
|
||||
pub fn call(&self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||
let text = if self.endpoint.starts_with("https://") || self.endpoint.starts_with("http://") {
|
||||
let mut c = std::process::Command::new(igneum_common::platform::tool("curl"));
|
||||
c.args(["-sS", "--max-time", "20", "-X", "POST", &self.endpoint, "-H", "Content-Type: application/json", "-d", &body]);
|
||||
igneum_common::run::run_timeout(&mut c, None, Duration::from_secs(25)).ok_or("curl is not available")?
|
||||
} else {
|
||||
igneum_common::http::post_json(&self.endpoint, "/", &body, Duration::from_secs(20))?
|
||||
};
|
||||
let v: Value = serde_json::from_str(text.trim()).map_err(|_| format!("{method}: not JSON: {}", text.chars().take(120).collect::<String>()))?;
|
||||
if let Some(e) = v.get("error") {
|
||||
let msg = e.get("message").and_then(|m| m.as_str()).unwrap_or("error");
|
||||
return Err(format!("{method}: {msg}"));
|
||||
}
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn chain_id(&self) -> Result<u64, String> {
|
||||
q(&self.call("eth_chainId", json!([]))?).map(|v| v as u64).ok_or("eth_chainId: no number".into())
|
||||
}
|
||||
pub fn block_number(&self) -> Result<u64, String> {
|
||||
q(&self.call("eth_blockNumber", json!([]))?).map(|v| v as u64).ok_or("eth_blockNumber: no number".into())
|
||||
}
|
||||
pub fn balance(&self, address: &str) -> Result<u128, String> {
|
||||
q(&self.call("eth_getBalance", json!([address, "latest"]))?).ok_or("eth_getBalance: no number".into())
|
||||
}
|
||||
pub fn nonce(&self, address: &str) -> Result<u64, String> {
|
||||
q(&self.call("eth_getTransactionCount", json!([address, "pending"]))?).map(|v| v as u64).ok_or("nonce: no number".into())
|
||||
}
|
||||
/// (base fee per gas of the latest block, the node's suggested priority fee)
|
||||
pub fn fees(&self) -> Result<(u128, u128), String> {
|
||||
let b = self.call("eth_getBlockByNumber", json!(["latest", false]))?;
|
||||
let base = b.get("baseFeePerGas").and_then(q).unwrap_or(0);
|
||||
let tip = self.call("eth_maxPriorityFeePerGas", json!([])).ok().and_then(|v| q(&v)).unwrap_or(1_000_000_000);
|
||||
Ok((base, tip))
|
||||
}
|
||||
pub fn estimate_gas(&self, from: &str, to: &str, value: u128) -> Result<u64, String> {
|
||||
q(&self.call("eth_estimateGas", json!([{ "from": from, "to": to, "value": hexq(value) }]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into())
|
||||
}
|
||||
/// The gas a call needs, with its data (the page bridge's contract calls; the node prices proving gas inside
|
||||
/// execution gas on Devnet 3, so the node's own estimate is the one to use).
|
||||
pub fn estimate_gas_call(&self, from: &str, to: &str, value: u128, data: &str) -> Result<u64, String> {
|
||||
let mut call = json!({ "from": from, "to": to, "value": hexq(value) });
|
||||
if data.len() > 2 {
|
||||
call["data"] = json!(data);
|
||||
}
|
||||
q(&self.call("eth_estimateGas", json!([call]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into())
|
||||
}
|
||||
pub fn send_raw(&self, raw: &[u8]) -> Result<String, String> {
|
||||
let v = self.call("eth_sendRawTransaction", json!([crate::tx::hex0x(raw)]))?;
|
||||
v.as_str().map(|s| s.to_string()).ok_or("eth_sendRawTransaction: no hash".into())
|
||||
}
|
||||
pub fn receipt(&self, hash: &str) -> Result<Option<Value>, String> {
|
||||
let v = self.call("eth_getTransactionReceipt", json!([hash]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
pub fn tx(&self, hash: &str) -> Result<Option<Value>, String> {
|
||||
let v = self.call("eth_getTransactionByHash", json!([hash]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
pub fn block(&self, number: u64, full: bool) -> Result<Option<Value>, String> {
|
||||
let v = self.call("eth_getBlockByNumber", json!([hexq(number as u128), full]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
pub fn block_by_hash(&self, hash: &str) -> Result<Option<Value>, String> {
|
||||
let v = self.call("eth_getBlockByHash", json!([hash, false]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
/// igneum_getSegment: the chain block's execution record (rewards per blue block's miner, proving pool credit).
|
||||
pub fn segment(&self, number: u64) -> Result<Option<Value>, String> {
|
||||
let v = self.call("igneum_getSegment", json!([hexq(number as u128)]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
pub fn tx_status(&self, hash: &str) -> Result<Value, String> {
|
||||
self.call("igneum_getTransactionStatus", json!([hash]))
|
||||
}
|
||||
}
|
||||
|
||||
/// wei to a decimal IGN string with up to `places` decimals, trailing zeros trimmed (never scientific, never rounded up).
|
||||
pub fn ign(wei: u128, places: usize) -> String {
|
||||
let whole = wei / 1_000_000_000_000_000_000;
|
||||
let frac = wei % 1_000_000_000_000_000_000;
|
||||
let mut f = format!("{frac:018}");
|
||||
f.truncate(places);
|
||||
let f = f.trim_end_matches('0');
|
||||
if f.is_empty() { format!("{whole}") } else { format!("{whole}.{f}") }
|
||||
}
|
||||
|
||||
/// A typed amount ("1.5", "0.001", "12") to wei; refuses more than 18 decimals and anything that is not a number.
|
||||
pub fn parse_ign(text: &str) -> Result<u128, String> {
|
||||
let t = text.trim().replace(',', "");
|
||||
if t.is_empty() {
|
||||
return Err("type an amount".into());
|
||||
}
|
||||
let (w, f) = match t.split_once('.') {
|
||||
Some((w, f)) => (w, f),
|
||||
None => (t.as_str(), ""),
|
||||
};
|
||||
if !w.chars().all(|c| c.is_ascii_digit()) || !f.chars().all(|c| c.is_ascii_digit()) || (w.is_empty() && f.is_empty()) {
|
||||
return Err("an amount is digits with one dot".into());
|
||||
}
|
||||
if f.len() > 18 {
|
||||
return Err("at most 18 decimals".into());
|
||||
}
|
||||
let whole: u128 = if w.is_empty() { 0 } else { w.parse().map_err(|_| "amount too large")? };
|
||||
let mut frac = f.to_string();
|
||||
while frac.len() < 18 {
|
||||
frac.push('0');
|
||||
}
|
||||
let frac: u128 = if frac.is_empty() { 0 } else { frac.parse().map_err(|_| "amount")? };
|
||||
whole.checked_mul(1_000_000_000_000_000_000).and_then(|v| v.checked_add(frac)).ok_or("amount too large".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn amounts() {
|
||||
assert_eq!(parse_ign("1.5").unwrap(), 1_500_000_000_000_000_000);
|
||||
assert_eq!(parse_ign("0.000000000000000001").unwrap(), 1);
|
||||
assert_eq!(parse_ign("12").unwrap(), 12_000_000_000_000_000_000);
|
||||
assert!(parse_ign("1e3").is_err());
|
||||
assert!(parse_ign("0.0000000000000000001").is_err());
|
||||
assert_eq!(ign(1_500_000_000_000_000_000, 6), "1.5");
|
||||
assert_eq!(ign(1, 18), "0.000000000000000001");
|
||||
assert_eq!(ign(1, 6), "0");
|
||||
assert_eq!(ign(42_000_000_000_000_000_000, 6), "42");
|
||||
assert_eq!(q(&json!("0x116f")), Some(4463));
|
||||
}
|
||||
}
|
||||
227
app/igneum-wallet/src/finality.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
//! Finality the wallet checks itself. A transaction is "final" only when its block sits under a certified checkpoint
|
||||
//! whose BLS certificate this wallet verified with the node's own code (kaspa_consensus_core::finality), never from a
|
||||
//! confirmation count and never on the node's word alone. The steps are the browser verifier's (site/verify/core.js):
|
||||
//! 1. the certificate as a block carried it (the coinbase finality section of the blocks after the checkpoint)
|
||||
//! 2. the canonical voter list: every key above dust and not stripped, sorted by key hash, 48-byte G1 keys that
|
||||
//! hash (BLAKE2b-256 keyed "IgneumVoteKeyHash") to the key hashes the node names, as many as the certificate says
|
||||
//! 3. the aggregate G2 signature over `igneum-vote-v1/<chain id> 0x00 index_le64 checkpoint` by the bitmap's keys
|
||||
//! 4. the rule: signed weight at least 2/3 of the active weight and at least 2/3 of the total weight (the floor
|
||||
//! decided 4 October 2026, O-3.15; stricter than the 17/30 this node line still carries)
|
||||
//! Then "under": the checkpoint block's selected-chain height from the Ethereum RPC; a transaction's block is under
|
||||
//! it when its number is at most that height and its hash is still the chain's hash at that number.
|
||||
|
||||
use kaspa_consensus_core::finality::{block_finality_content, verify_aggregate, vote_key_hash, Certificate, FinalityItem, PUBKEY_LEN};
|
||||
use kaspa_hashes::Hash;
|
||||
use kaspa_rpc_core::model::{GetFinalityWeightsResponse, RpcCheckpoint, RpcKeyWeight};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct VerifiedCheckpoint {
|
||||
pub index: u64,
|
||||
pub hash: String,
|
||||
/// the checkpoint block's selected-chain height on the execution side (None until the Ethereum RPC names it)
|
||||
pub chain_number: Option<u64>,
|
||||
pub signers: usize,
|
||||
pub voters: usize,
|
||||
pub signed_weight: u64,
|
||||
pub total_weight: u64,
|
||||
pub active_weight: f64,
|
||||
pub fraction_total: f64,
|
||||
pub fraction_active: f64,
|
||||
/// the checkpoint index the node's weight table was taken at (equal to `index` when exact)
|
||||
pub weights_at_index: u64,
|
||||
pub carrier: String,
|
||||
pub verified_at: f64,
|
||||
}
|
||||
|
||||
/// What the window shows for one transaction.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
/// not in any block the node knows
|
||||
Pending,
|
||||
/// in chain block `number`; no verified checkpoint covers it yet
|
||||
InBlock { number: u64 },
|
||||
/// under verified checkpoint `index`
|
||||
Final { number: u64, index: u64 },
|
||||
/// the receipt says the execution failed; still subject to the same finality
|
||||
Failed { number: u64, reason: String },
|
||||
}
|
||||
|
||||
/// The state machine, pure: receipt (block number, block hash), the chain's hash at that number now, the newest
|
||||
/// verified checkpoint. Tested below.
|
||||
pub fn status(receipt: Option<(u64, &str, bool)>, canonical_hash: Option<&str>, verified: Option<&VerifiedCheckpoint>) -> Status {
|
||||
let Some((number, hash, ok)) = receipt else { return Status::Pending };
|
||||
// the block the receipt names must still be the chain's block at that height
|
||||
match canonical_hash {
|
||||
Some(h) if h.eq_ignore_ascii_case(hash) => {}
|
||||
_ => return Status::Pending,
|
||||
}
|
||||
if !ok {
|
||||
return Status::Failed { number, reason: "the execution failed (reverted or out of gas)".into() };
|
||||
}
|
||||
match verified.and_then(|v| v.chain_number.map(|n| (n, v.index))) {
|
||||
Some((cp_number, index)) if number <= cp_number => Status::Final { number, index },
|
||||
_ => Status::InBlock { number },
|
||||
}
|
||||
}
|
||||
|
||||
/// The certificate for `cp` as a block after it carried it, scanning up to `pages` pages of getBlocks.
|
||||
pub fn find_certificate(grpc: &crate::node::Grpc, cp: &RpcCheckpoint, pages: usize) -> Result<(Certificate, String), String> {
|
||||
let mut low: Hash = cp.hash;
|
||||
let mut seen = 0usize;
|
||||
for _ in 0..pages {
|
||||
let blocks = grpc.blocks_after(low)?;
|
||||
if blocks.is_empty() {
|
||||
break;
|
||||
}
|
||||
for b in &blocks {
|
||||
seen += 1;
|
||||
if let Some(tx) = b.transactions.first() {
|
||||
let (_, items) = block_finality_content(&tx.payload);
|
||||
for it in items {
|
||||
if let FinalityItem::Certificate(c) = it {
|
||||
if c.index == cp.index && c.checkpoint == cp.hash {
|
||||
return Ok((c, b.header.hash.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let last = blocks.last().unwrap().header.hash;
|
||||
if last == low {
|
||||
break;
|
||||
}
|
||||
low = last;
|
||||
}
|
||||
Err(format!("no block within {seen} of checkpoint {} carries its certificate yet", cp.index))
|
||||
}
|
||||
|
||||
/// Steps 2 to 4 over a certificate and the node's weight table.
|
||||
pub fn verify(chain_id: &str, cp: &RpcCheckpoint, cert: &Certificate, carrier: &str, w: &GetFinalityWeightsResponse) -> Result<VerifiedCheckpoint, String> {
|
||||
let mut voters: Vec<&RpcKeyWeight> = w.keys.iter().filter(|k| k.voter).collect();
|
||||
voters.sort_by(|a, b| a.key_hash.cmp(&b.key_hash));
|
||||
if voters.len() != cert.voter_count as usize {
|
||||
return Err(format!("certificate names {} voters, the node's table at checkpoint {} has {}", cert.voter_count, w.checkpoint_index, voters.len()));
|
||||
}
|
||||
let mut pubkeys: Vec<[u8; PUBKEY_LEN]> = Vec::with_capacity(voters.len());
|
||||
for (i, v) in voters.iter().enumerate() {
|
||||
let raw = igneum_common::keys::unhex(&v.pubkey).ok_or(format!("voter {i} key is not hex"))?;
|
||||
let pk: [u8; PUBKEY_LEN] = raw.try_into().map_err(|_| format!("voter {i} key is not 48 bytes"))?;
|
||||
if vote_key_hash(&pk) != v.key_hash {
|
||||
return Err(format!("voter {i} key does not hash to its key hash"));
|
||||
}
|
||||
pubkeys.push(pk);
|
||||
}
|
||||
let positions = cert.signer_positions();
|
||||
if positions.is_empty() {
|
||||
return Err("the certificate has no signers".into());
|
||||
}
|
||||
let signing: Vec<[u8; PUBKEY_LEN]> = positions.iter().map(|&p| pubkeys[p]).collect();
|
||||
if !verify_aggregate(chain_id, cert.index, cert.checkpoint, &signing, &cert.signature) {
|
||||
return Err("the aggregate signature does not verify".into());
|
||||
}
|
||||
let total: u64 = voters.iter().map(|v| v.blocks).sum();
|
||||
let active: f64 = voters.iter().map(|v| v.blocks as f64 * v.participation).sum();
|
||||
let signed: u64 = positions.iter().map(|&p| voters[p].blocks).sum();
|
||||
if total == 0 {
|
||||
return Err("total weight is zero".into());
|
||||
}
|
||||
let fraction_total = signed as f64 / total as f64;
|
||||
let fraction_active = if active > 0.0 { signed as f64 / active } else { 0.0 };
|
||||
if (3 * signed as u128) < (2 * active.round() as u128) {
|
||||
return Err(format!("signed weight is {:.1}% of active, below 2/3", fraction_active * 100.0));
|
||||
}
|
||||
if 3 * (signed as u128) < 2 * (total as u128) {
|
||||
return Err(format!("signed weight is {:.1}% of total, below 2/3", fraction_total * 100.0));
|
||||
}
|
||||
Ok(VerifiedCheckpoint {
|
||||
index: cp.index,
|
||||
hash: cp.hash.to_string(),
|
||||
chain_number: None,
|
||||
signers: positions.len(),
|
||||
voters: voters.len(),
|
||||
signed_weight: signed,
|
||||
total_weight: total,
|
||||
active_weight: active,
|
||||
fraction_total,
|
||||
fraction_active,
|
||||
weights_at_index: w.checkpoint_index,
|
||||
carrier: carrier.to_string(),
|
||||
verified_at: igneum_common::platform::unix_now_f(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cp(chain_number: Option<u64>) -> VerifiedCheckpoint {
|
||||
VerifiedCheckpoint { index: 536, hash: "ac08".into(), chain_number, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine() {
|
||||
// no receipt: pending, whatever the checkpoint says
|
||||
assert_eq!(status(None, None, Some(&cp(Some(100)))), Status::Pending);
|
||||
// in a block, no verified checkpoint
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), None), Status::InBlock { number: 10 });
|
||||
// the checkpoint is below the block: still in a block
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(Some(9)))), Status::InBlock { number: 10 });
|
||||
// the checkpoint's chain height is unknown yet
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(None))), Status::InBlock { number: 10 });
|
||||
// under the checkpoint: final, with the index
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(Some(10)))), Status::Final { number: 10, index: 536 });
|
||||
assert_eq!(status(Some((3, "0xaa", true)), Some("0xAA"), Some(&cp(Some(10)))), Status::Final { number: 3, index: 536 });
|
||||
// the chain moved away from the receipt's block: back to pending
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xbb"), Some(&cp(Some(10)))), Status::Pending);
|
||||
assert_eq!(status(Some((10, "0xaa", true)), None, Some(&cp(Some(10)))), Status::Pending);
|
||||
// a failed execution is reported as failed, never final
|
||||
assert!(matches!(status(Some((10, "0xaa", false)), Some("0xaa"), Some(&cp(Some(10)))), Status::Failed { number: 10, .. }));
|
||||
}
|
||||
|
||||
/// A certificate made with real BLS keys verifies; a flipped bit, a missing voter or a thin quorum does not.
|
||||
#[test]
|
||||
fn certificate_rule() {
|
||||
use kaspa_consensus_core::finality::{aggregate_signatures, VoteSecretKey};
|
||||
use kaspa_rpc_core::model::RpcFinalityParams;
|
||||
let chain = "igneum-devnet-955";
|
||||
let checkpoint = Hash::from_slice(&[7u8; 32]);
|
||||
let keys: Vec<VoteSecretKey> = (0..3).map(|i| VoteSecretKey::from_label(&format!("wallet-test-{i}"))).collect();
|
||||
let mut table: Vec<RpcKeyWeight> = keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, k)| RpcKeyWeight { key_hash: k.key_hash(), pubkey: igneum_common::keys::hex(&k.public_key()), blocks: [50, 30, 20][i], voter: true, participation: 1.0, stripped_until_daa: 0 })
|
||||
.collect();
|
||||
table.sort_by(|a, b| a.key_hash.cmp(&b.key_hash));
|
||||
let by_hash = |h: Hash| keys.iter().find(|k| k.key_hash() == h).unwrap();
|
||||
// the two heaviest keys sign (80 of 100)
|
||||
let mut signers: Vec<usize> = (0..3).filter(|&p| table[p].blocks >= 30).collect();
|
||||
signers.sort();
|
||||
let sigs: Vec<[u8; 96]> = signers.iter().map(|&p| by_hash(table[p].key_hash).sign_vote(chain, 536, checkpoint)).collect();
|
||||
let agg = aggregate_signatures(&sigs).unwrap();
|
||||
let cert = Certificate { index: 536, checkpoint, voter_count: 3, bitmap: Certificate::bitmap_from_positions(3, &signers), signature: agg, aggregator: Hash::from_slice(&[0u8; 32]), aggregator_proof: [0u8; 96] };
|
||||
let rcp = RpcCheckpoint { index: 536, hash: checkpoint, blue_score: 0, daa_score: 0, state: "locked".into(), signed_weight: 80, active_weight: 100, total_weight: 100, fraction_active: 0.8, fraction_total: 0.8, votes_seen: 2, voters: 3, aggregators: vec![], locked_at_daa: 1, certificate_aggregator: Hash::from_slice(&[0u8; 32]) };
|
||||
let params = RpcFinalityParams { checkpoint_interval: 30, checkpoint_depth: 20, weight_window: 120, dust: 5, presence_window: 1, aggregators: 8, equivocation_ban: 120, min_daa: 120 };
|
||||
let w = GetFinalityWeightsResponse { params, checkpoint_index: 536, checkpoint_hash: checkpoint, daa_score: 0, total_weight: 100, active_weight: 100.0, voters: 3, keys: table.clone() };
|
||||
let v = verify(chain, &rcp, &cert, "carrier", &w).unwrap();
|
||||
assert_eq!(v.signers, 2);
|
||||
assert_eq!(v.signed_weight, 80);
|
||||
assert!((v.fraction_total - 0.8).abs() < 1e-9);
|
||||
// wrong chain id: the message differs
|
||||
assert!(verify("igneum-devnet-1", &rcp, &cert, "c", &w).is_err());
|
||||
// a flipped signature byte
|
||||
let mut bad = cert.clone();
|
||||
bad.signature[5] ^= 1;
|
||||
assert!(verify(chain, &rcp, &bad, "c", &w).unwrap_err().contains("aggregate signature"));
|
||||
// only the lightest key signs: 20 of 100, below 2/3
|
||||
let p = (0..3).find(|&p| table[p].blocks == 20).unwrap();
|
||||
let s = by_hash(table[p].key_hash).sign_vote(chain, 536, checkpoint);
|
||||
let thin = Certificate { bitmap: Certificate::bitmap_from_positions(3, &[p]), signature: aggregate_signatures(&[s]).unwrap(), ..cert.clone() };
|
||||
assert!(verify(chain, &rcp, &thin, "c", &w).unwrap_err().contains("below 2/3"));
|
||||
// a voter list that does not match the certificate's count
|
||||
let mut w2 = w.clone();
|
||||
w2.keys.pop();
|
||||
assert!(verify(chain, &rcp, &cert, "c", &w2).unwrap_err().contains("voters"));
|
||||
}
|
||||
}
|
||||
99
app/igneum-wallet/src/hd.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! Keys from words: BIP-39 (24 English words, 256 bits of entropy from the OS) and BIP-32/44 at m/44'/60'/0'/0/0, the
|
||||
//! Ethereum path, so the same words open the same account in MetaMask. A raw private key import skips the words.
|
||||
|
||||
use bip32::{DerivationPath, XPrv};
|
||||
use bip39::{Language, Mnemonic};
|
||||
use igneum_common::keys;
|
||||
|
||||
pub const PATH: &str = "m/44'/60'/0'/0/0";
|
||||
|
||||
pub struct Derived {
|
||||
pub private_key: String, // 0x + 64 hex
|
||||
pub address: String, // 0x + 40 lower-case hex
|
||||
}
|
||||
|
||||
/// 24 new words from 256 bits of OS randomness.
|
||||
pub fn new_words() -> Result<String, String> {
|
||||
let mut entropy = [0u8; 32];
|
||||
getrandom::getrandom(&mut entropy).map_err(|e| e.to_string())?;
|
||||
let m = Mnemonic::from_entropy_in(Language::English, &entropy).map_err(|e| e.to_string())?;
|
||||
Ok(m.words().collect::<Vec<_>>().join(" "))
|
||||
}
|
||||
|
||||
/// Normalises a typed phrase: lower case, single spaces. Accepts 12, 15, 18, 21 or 24 words; checks the checksum.
|
||||
pub fn parse_words(text: &str) -> Result<String, String> {
|
||||
let words: Vec<String> = text.split_whitespace().map(|w| w.to_lowercase()).collect();
|
||||
if ![12, 15, 18, 21, 24].contains(&words.len()) {
|
||||
return Err(format!("{} words: a phrase has 12 or 24 words", words.len()));
|
||||
}
|
||||
let joined = words.join(" ");
|
||||
Mnemonic::parse_in_normalized(Language::English, &joined).map_err(|e| match e {
|
||||
bip39::Error::UnknownWord(i) => format!("word {} is not in the word list: {}", i + 1, words[i]),
|
||||
bip39::Error::InvalidChecksum => "the words do not check out (one is wrong or out of order)".to_string(),
|
||||
other => other.to_string(),
|
||||
})?;
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
/// The Ethereum account at m/44'/60'/0'/0/0 of a phrase (no BIP-39 passphrase).
|
||||
pub fn derive(words: &str) -> Result<Derived, String> {
|
||||
let m = Mnemonic::parse_in_normalized(Language::English, words).map_err(|e| e.to_string())?;
|
||||
let seed = m.to_seed("");
|
||||
let path: DerivationPath = PATH.parse().map_err(|_| "bad path".to_string())?;
|
||||
let xprv = XPrv::derive_from_path(seed, &path).map_err(|e| e.to_string())?;
|
||||
let raw: [u8; 32] = xprv.private_key().to_bytes().into();
|
||||
let address = keys::address_of_raw(&raw).ok_or("the derived key is invalid")?;
|
||||
Ok(Derived { private_key: format!("0x{}", keys::hex(&raw)), address })
|
||||
}
|
||||
|
||||
/// A raw private key, typed or pasted: 64 hex with or without 0x.
|
||||
pub fn parse_private_key(text: &str) -> Result<Derived, String> {
|
||||
let raw = keys::unhex(text).ok_or("a private key is 64 hex characters")?;
|
||||
if raw.len() != 32 {
|
||||
return Err(format!("a private key is 32 bytes, this is {}", raw.len()));
|
||||
}
|
||||
let arr: [u8; 32] = raw.try_into().unwrap();
|
||||
let address = keys::address_of_raw(&arr).ok_or("this is not a valid secp256k1 key")?;
|
||||
Ok(Derived { private_key: format!("0x{}", keys::hex(&arr)), address })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The reference phrase every Ethereum tool ships with (Hardhat account 0).
|
||||
#[test]
|
||||
fn hardhat_vector() {
|
||||
let d = derive("test test test test test test test test test test test junk").unwrap();
|
||||
assert_eq!(d.private_key, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
|
||||
assert_eq!(keys::checksum(&d.address), "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
|
||||
}
|
||||
|
||||
/// BIP-39 vector 1 (entropy all zero): the words and, with the TREZOR passphrase, the published seed.
|
||||
#[test]
|
||||
fn bip39_vector_one() {
|
||||
let m = Mnemonic::from_entropy_in(Language::English, &[0u8; 16]).unwrap();
|
||||
assert_eq!(m.to_string(), "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about");
|
||||
let seed = m.to_seed("TREZOR");
|
||||
assert_eq!(keys::hex(&seed), "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_are_24_and_parse() {
|
||||
let w = new_words().unwrap();
|
||||
assert_eq!(w.split(' ').count(), 24);
|
||||
assert_eq!(parse_words(&w.to_uppercase()).unwrap(), w);
|
||||
let mut broken: Vec<&str> = w.split(' ').collect();
|
||||
broken[0] = "zzzz";
|
||||
assert!(parse_words(&broken.join(" ")).unwrap_err().contains("word 1"));
|
||||
assert!(parse_words("abandon abandon").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_key_import() {
|
||||
let d = parse_private_key("0000000000000000000000000000000000000000000000000000000000000001").unwrap();
|
||||
assert_eq!(keys::checksum(&d.address), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
|
||||
assert!(parse_private_key("0x01").is_err());
|
||||
assert!(parse_private_key(&"ff".repeat(32)).is_err()); // above the curve order
|
||||
}
|
||||
}
|
||||
137
app/igneum-wallet/src/history.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! What happened to this address: transfers in and out from the chain's blocks, block rewards from the execution
|
||||
//! records (igneum_getSegment: one reward per blue block, paid to the miner the block named), proving payouts when a
|
||||
//! segment carries a proof record (none on this node line yet; the label is ready). Scanned forward from the last
|
||||
//! block seen and kept in `<data>/wallet/history-<chain id>-<address>.json`.
|
||||
|
||||
use crate::evm::{q, Evm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub hash: String,
|
||||
/// sent | received | self | reward | proving
|
||||
pub kind: String,
|
||||
pub block: u64,
|
||||
pub block_hash: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// wei, as a decimal string (u128 is wider than JSON numbers)
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub fee: String,
|
||||
pub ok: bool,
|
||||
pub time: u64,
|
||||
/// pending | in_block | final | failed, with the checkpoint index when final
|
||||
#[serde(default)]
|
||||
pub finality: String,
|
||||
#[serde(default)]
|
||||
pub checkpoint: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct History {
|
||||
pub chain_id: u64,
|
||||
pub address: String,
|
||||
/// every block up to and including this one was read
|
||||
pub scanned_to: Option<u64>,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
pub fn load(path: &Path, chain_id: u64, address: &str) -> History {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<History>(&t).ok())
|
||||
.filter(|h| h.chain_id == chain_id && h.address.eq_ignore_ascii_case(address))
|
||||
.unwrap_or(History { chain_id, address: address.to_ascii_lowercase(), scanned_to: None, entries: vec![] })
|
||||
}
|
||||
pub fn save(&self, path: &Path) {
|
||||
if let Some(d) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
if let Ok(t) = serde_json::to_string(self) {
|
||||
let _ = std::fs::write(path, t);
|
||||
igneum_common::platform::lock_permissions(path, false);
|
||||
}
|
||||
}
|
||||
pub fn has(&self, hash: &str) -> bool {
|
||||
self.entries.iter().any(|e| e.hash.eq_ignore_ascii_case(hash))
|
||||
}
|
||||
/// Adds or replaces by hash, newest block first.
|
||||
pub fn put(&mut self, e: Entry) {
|
||||
self.entries.retain(|x| !x.hash.eq_ignore_ascii_case(&e.hash));
|
||||
self.entries.push(e);
|
||||
self.entries.sort_by(|a, b| b.block.cmp(&a.block).then(b.time.cmp(&a.time)));
|
||||
if self.entries.len() > 2000 {
|
||||
self.entries.truncate(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn s(v: &Value, k: &str) -> String {
|
||||
v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// Reads blocks `from..=to` and returns the entries that touch `me` (lower-case 0x address).
|
||||
pub fn scan(evm: &Evm, me: &str, from: u64, to: u64) -> Result<Vec<Entry>, String> {
|
||||
let mut out = Vec::new();
|
||||
for n in from..=to {
|
||||
let Some(b) = evm.block(n, true)? else { break };
|
||||
let bh = s(&b, "hash");
|
||||
let time = b.get("timestamp").and_then(q).unwrap_or(0) as u64;
|
||||
if let Some(txs) = b.get("transactions").and_then(|t| t.as_array()) {
|
||||
for t in txs {
|
||||
let from = s(t, "from").to_ascii_lowercase();
|
||||
let to = s(t, "to").to_ascii_lowercase();
|
||||
if from != me && to != me {
|
||||
continue;
|
||||
}
|
||||
let hash = s(t, "hash");
|
||||
let value = t.get("value").and_then(q).unwrap_or(0);
|
||||
let kind = if from == me && to == me { "self" } else if from == me { "sent" } else { "received" };
|
||||
// the receipt: status and the fee actually paid
|
||||
let (ok, fee) = match evm.receipt(&hash)? {
|
||||
Some(r) => {
|
||||
let ok = r.get("status").and_then(q).unwrap_or(1) == 1;
|
||||
let gas = r.get("gasUsed").and_then(q).unwrap_or(0);
|
||||
let price = r.get("effectiveGasPrice").and_then(q).unwrap_or(0);
|
||||
(ok, gas * price)
|
||||
}
|
||||
None => (true, 0),
|
||||
};
|
||||
out.push(Entry { hash, kind: kind.into(), block: n, block_hash: bh.clone(), from, to, value: value.to_string(), fee: fee.to_string(), ok, time, finality: "in_block".into(), checkpoint: None, note: String::new() });
|
||||
}
|
||||
}
|
||||
// rewards: the segment names every blue block's miner and what it was paid
|
||||
if let Some(seg) = evm.segment(n)? {
|
||||
if let Some(rs) = seg.get("rewards").and_then(|r| r.as_array()) {
|
||||
for (i, r) in rs.iter().enumerate() {
|
||||
let miner = s(r, "miner").to_ascii_lowercase();
|
||||
if miner != me {
|
||||
continue;
|
||||
}
|
||||
let wei = r.get("wei").and_then(q).unwrap_or(0);
|
||||
if wei == 0 {
|
||||
continue;
|
||||
}
|
||||
out.push(Entry { hash: format!("reward-{n}-{i}"), kind: "reward".into(), block: n, block_hash: bh.clone(), from: String::new(), to: me.to_string(), value: wei.to_string(), fee: "0".into(), ok: true, time, finality: "in_block".into(), checkpoint: None, note: "block reward".into() });
|
||||
}
|
||||
}
|
||||
if let Some(pr) = seg.get("proofRecord").filter(|v| !v.is_null()) {
|
||||
if let Some(ps) = pr.get("payouts").and_then(|p| p.as_array()) {
|
||||
for (i, p) in ps.iter().enumerate() {
|
||||
if s(p, "address").eq_ignore_ascii_case(me) {
|
||||
let wei = p.get("wei").and_then(q).unwrap_or(0);
|
||||
out.push(Entry { hash: format!("proving-{n}-{i}"), kind: "proving".into(), block: n, block_hash: bh.clone(), from: String::new(), to: me.to_string(), value: wei.to_string(), fee: "0".into(), ok: true, time, finality: "in_block".into(), checkpoint: None, note: "shard payout".into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
130
app/igneum-wallet/src/main.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//! Igneum Wallet engine. Keeps the key, signs, reads a node, verifies finality certificates, and serves the window on
|
||||
//! 127.0.0.1:<random port>/t/<token>/. The window host (macOS: app/mac/IgneumWallet.swift, Windows: the WebView2
|
||||
//! host) starts it with --wrapper, reads `URL ...` and `STATE {...}` lines from its stdout and writes `quit` on its
|
||||
//! stdin. Without a host (--open) the window opens in the default browser. A host also reads one `HOST {...}` line:
|
||||
//! its own token (sent as X-Igneum-Host on the calls only it may make: the biometric confirmations) and the path of
|
||||
//! the sealed-password file it writes for Touch ID or Windows Hello.
|
||||
//!
|
||||
//! igneum-wallet [--wrapper | --open | --no-open | --launch] [--print-url]
|
||||
//!
|
||||
//! Environment (tests): IGNEUM_APP_DATA, IGNEUM_APP_LOGS, IGNEUM_APP_BIN, IGNEUM_WALLET_GRPC_PORT, IGNEUM_WALLET_EVM_PORT,
|
||||
//! IGNEUM_WALLET_NO_NODE, IGNEUM_WALLET_NETWORK, IGNEUM_WALLET_DEVNET_SUFFIX, IGNEUM_WALLET_PEERS,
|
||||
//! IGNEUM_WALLET_NODE_DIR, IGNEUM_WALLET_OVERRIDE_PARAMS, IGNEUM_WALLET_UPDATE_MANIFEST, IGNEUM_WALLET_IDLE_LOCK_S.
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod bridge;
|
||||
mod eip712;
|
||||
mod engine;
|
||||
mod evm;
|
||||
mod finality;
|
||||
mod hd;
|
||||
mod history;
|
||||
mod node;
|
||||
mod qr;
|
||||
mod server;
|
||||
mod state;
|
||||
mod tx;
|
||||
mod updater;
|
||||
mod vault;
|
||||
|
||||
use igneum_common::platform;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::mpsc::channel;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let wrapper = args.iter().any(|a| a == "--wrapper");
|
||||
let no_open = wrapper || args.iter().any(|a| a == "--no-open");
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("igneum-wallet {}", engine::VERSION);
|
||||
return;
|
||||
}
|
||||
if args.iter().any(|a| a == "--launch") {
|
||||
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
let host = dir.join(engine::APP.host_exe);
|
||||
if host.exists() {
|
||||
let mut c = std::process::Command::new(&host);
|
||||
c.current_dir(&dir);
|
||||
if c.spawn().is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
platform::clear_quarantine();
|
||||
let root = platform::data_root();
|
||||
let app_dir = root.join(engine::APP.data_sub);
|
||||
let log_dir = platform::log_root();
|
||||
let _ = std::fs::create_dir_all(&app_dir);
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
platform::lock_permissions(&app_dir, true);
|
||||
let paths = engine::Paths {
|
||||
vault: app_dir.join("vault.json"),
|
||||
settings: app_dir.join("settings.json"),
|
||||
miner_wallet: root.join(igneum_common::MINER.data_sub).join("wallet.json"),
|
||||
biometric: app_dir.join("biometric.json"),
|
||||
app_dir: app_dir.clone(),
|
||||
log_dir: log_dir.clone(),
|
||||
};
|
||||
let packaged = igneum_common::config::Packaged::load(&igneum_common::config::Packaged::candidates("igneum-wallet.json"));
|
||||
let settings = engine::Settings::load(&paths.settings);
|
||||
let machine_id = igneum_common::config::machine_id(&app_dir);
|
||||
let token = igneum_common::http::new_token();
|
||||
let host_token = igneum_common::http::new_token();
|
||||
let stamp_file = log_dir.join(format!("wallet-{}.log", stamp_now()));
|
||||
let engine_log = std::fs::File::create(&stamp_file).ok();
|
||||
let (tx, rx) = channel();
|
||||
let biometric_file = paths.biometric.clone();
|
||||
let shared = Arc::new(engine::Shared::new(token.clone(), host_token.clone(), paths, packaged, settings, machine_id, tx, engine_log, stamp_file.clone()));
|
||||
let port = match server::start(shared.clone()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cannot listen on 127.0.0.1: {e}");
|
||||
if wrapper {
|
||||
println!("FATAL cannot listen on 127.0.0.1: {e}");
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
server::start_bridge(shared.clone());
|
||||
let url = format!("http://127.0.0.1:{port}/t/{token}/");
|
||||
let url_file = shared.paths.app_dir.join("wallet.url");
|
||||
let _ = std::fs::write(&url_file, &url);
|
||||
platform::lock_permissions(&url_file, false);
|
||||
shared.log(&format!("window listening on 127.0.0.1:{port} (the URL with its token is in wallet.url; log {})", stamp_file.display()));
|
||||
if wrapper || args.iter().any(|a| a == "--print-url") {
|
||||
println!("URL {url}");
|
||||
if wrapper {
|
||||
// the host alone reads stdout: its token and where the sealed password goes
|
||||
println!("HOST {}", serde_json::json!({ "token": host_token, "biometric_file": biometric_file.display().to_string() }));
|
||||
}
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
if !no_open {
|
||||
platform::open_url(&url);
|
||||
}
|
||||
{
|
||||
let shared = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(l) = line else { break };
|
||||
match l.trim() {
|
||||
"quit" => shared.send(engine::Cmd::Quit),
|
||||
"lock" => shared.lock(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if wrapper {
|
||||
shared.send(engine::Cmd::Quit);
|
||||
}
|
||||
});
|
||||
}
|
||||
engine::Engine::new(shared, rx, wrapper).run();
|
||||
}
|
||||
|
||||
fn stamp_now() -> String {
|
||||
let t = platform::unix_now();
|
||||
format!("{}-{:02}{:02}{:02}", t / 86400, t % 86400 / 3600, t % 3600 / 60, t % 60)
|
||||
}
|
||||
223
app/igneum-wallet/src/node.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! Where the chain comes from, in this order:
|
||||
//! 1. the miner app's node on this machine (gRPC 127.0.0.1:26610, Ethereum RPC 26790): used as it is, nothing started
|
||||
//! 2. the seed's public Ethereum RPC when the package names one (`public_rpc` in igneum-wallet.json): balances,
|
||||
//! sending and history work; finality verification needs a node's gRPC and is reported as "no node", so
|
||||
//! transactions stay "in a block" until a node is there
|
||||
//! 3. the bundled igneumd next to the app, started by the wallet on its own ports (gRPC 26620, Ethereum 26800, p2p
|
||||
//! 26621) with the same arguments the miner uses, no mining, stopped when the wallet quits
|
||||
//! The choice is made at start and whenever the source goes away; `State.node.source` says which.
|
||||
//! Environment (tests): IGNEUM_WALLET_GRPC_PORT, IGNEUM_WALLET_EVM_PORT (an external node to use, no probe of the
|
||||
//! miner's ports), IGNEUM_WALLET_NO_NODE=1 (never start one), IGNEUM_WALLET_NETWORK, IGNEUM_WALLET_DEVNET_SUFFIX,
|
||||
//! IGNEUM_WALLET_PEERS, IGNEUM_WALLET_NODE_DIR, IGNEUM_WALLET_OVERRIDE_PARAMS (a file for --override-params-file).
|
||||
|
||||
use kaspa_grpc_client::GrpcClient;
|
||||
use kaspa_rpc_core::api::rpc::RpcApi;
|
||||
use kaspa_rpc_core::error::RpcError;
|
||||
use kaspa_rpc_core::model::{GetBlockDagInfoResponse, GetFinalityCheckpointsResponse, GetFinalityWeightsResponse, RpcBlock, RpcHash};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
pub const MINER_GRPC: u16 = 26610;
|
||||
pub const MINER_EVM: u16 = 26790;
|
||||
pub const OWN_GRPC: u16 = 26620;
|
||||
pub const OWN_EVM: u16 = 26800;
|
||||
pub const OWN_P2P: u16 = 26621;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// the miner app's node on this machine
|
||||
Miner { grpc: u16, evm: u16 },
|
||||
/// a node named by the environment (tests)
|
||||
External { grpc: u16, evm: u16 },
|
||||
/// the seed's public Ethereum RPC; no gRPC, so no certificate verification
|
||||
Public { url: String },
|
||||
/// the bundled node, started by the wallet
|
||||
Own { grpc: u16, evm: u16 },
|
||||
None,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Source::Miner { .. } => "miner",
|
||||
Source::External { .. } => "external",
|
||||
Source::Public { .. } => "public",
|
||||
Source::Own { .. } => "own",
|
||||
Source::None => "none",
|
||||
}
|
||||
}
|
||||
pub fn grpc_port(&self) -> Option<u16> {
|
||||
match self {
|
||||
Source::Miner { grpc, .. } | Source::External { grpc, .. } | Source::Own { grpc, .. } => Some(*grpc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn evm(&self) -> Option<crate::evm::Evm> {
|
||||
match self {
|
||||
Source::Miner { evm, .. } | Source::External { evm, .. } | Source::Own { evm, .. } => Some(crate::evm::Evm::local(*evm)),
|
||||
Source::Public { url } => Some(crate::evm::Evm { endpoint: url.clone() }),
|
||||
Source::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's Ethereum RPC answers eth_chainId on this port.
|
||||
pub fn evm_alive(port: u16) -> bool {
|
||||
crate::evm::Evm::local(port).chain_id().is_ok()
|
||||
}
|
||||
|
||||
/// The environment's external node, when set.
|
||||
pub fn external_from_env() -> Option<Source> {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let grpc = env("IGNEUM_WALLET_GRPC_PORT")?.parse().ok()?;
|
||||
let evm = env("IGNEUM_WALLET_EVM_PORT")?.parse().ok()?;
|
||||
Some(Source::External { grpc, evm })
|
||||
}
|
||||
|
||||
// ---- gRPC, the node's own client, on a private tokio runtime -----------------------------------------------------
|
||||
|
||||
pub struct Grpc {
|
||||
rt: Runtime,
|
||||
client: Arc<GrpcClient>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl Grpc {
|
||||
pub fn connect(port: u16) -> Result<Grpc, String> {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().map_err(|e| e.to_string())?;
|
||||
let url = format!("grpc://127.0.0.1:{port}");
|
||||
let client = rt.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(8), GrpcClient::connect(url.clone())).await.map_err(|_| "gRPC connect timed out".to_string())?.map_err(|e| e.to_string())
|
||||
})?;
|
||||
Ok(Grpc { rt, client: Arc::new(client), url })
|
||||
}
|
||||
fn run<F, T>(&self, f: F) -> Result<T, String>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, RpcError>>,
|
||||
{
|
||||
self.rt.block_on(async { tokio::time::timeout(Duration::from_secs(20), f).await.map_err(|_| "rpc timed out".to_string())?.map_err(|e| e.to_string()) })
|
||||
}
|
||||
pub fn checkpoints(&self, last: u32) -> Result<GetFinalityCheckpointsResponse, String> {
|
||||
self.run(self.client.get_finality_checkpoints(last))
|
||||
}
|
||||
pub fn weights(&self) -> Result<GetFinalityWeightsResponse, String> {
|
||||
self.run(self.client.get_finality_weights())
|
||||
}
|
||||
/// Blocks from `low` onward (the node's own page size), with transactions.
|
||||
pub fn blocks_after(&self, low: RpcHash) -> Result<Vec<RpcBlock>, String> {
|
||||
let r = self.run(self.client.get_blocks(Some(low), true, true))?;
|
||||
Ok(r.blocks)
|
||||
}
|
||||
pub fn dag_info(&self) -> Result<GetBlockDagInfoResponse, String> {
|
||||
self.run(self.client.get_block_dag_info())
|
||||
}
|
||||
pub fn disconnect(&self) {
|
||||
let c = self.client.clone();
|
||||
let _ = self.rt.block_on(async { c.disconnect().await });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the bundled node ---------------------------------------------------------------------------------------------
|
||||
|
||||
pub struct OwnNode {
|
||||
pub child: Child,
|
||||
}
|
||||
|
||||
pub struct OwnNodePlan {
|
||||
pub bin: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub dir: PathBuf,
|
||||
pub log: PathBuf,
|
||||
}
|
||||
|
||||
/// Finds igneumd next to the engine (Windows), in bin/, or in Contents/Resources/bin (macOS bundle).
|
||||
pub fn find_igneumd() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let here = exe.parent()?.to_path_buf();
|
||||
let mut candidates = vec![];
|
||||
if let Some(d) = std::env::var_os("IGNEUM_APP_BIN") {
|
||||
candidates.push(PathBuf::from(d));
|
||||
}
|
||||
candidates.push(here.clone());
|
||||
candidates.push(here.join("bin"));
|
||||
if let Some(c) = here.parent() {
|
||||
candidates.push(c.join("Resources").join("bin"));
|
||||
}
|
||||
let name = if cfg!(windows) { "igneumd.exe" } else { "igneumd" };
|
||||
candidates.into_iter().map(|d| d.join(name)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
pub fn plan_own_node(app_dir: &std::path::Path, log_dir: &std::path::Path, packaged: &igneum_common::config::Packaged) -> Result<OwnNodePlan, String> {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let bin = find_igneumd().ok_or("igneumd is not next to the wallet (the package is incomplete)")?;
|
||||
let network = env("IGNEUM_WALLET_NETWORK").unwrap_or_else(|| "devnet".into());
|
||||
let suffix: Option<u32> = env("IGNEUM_WALLET_DEVNET_SUFFIX").and_then(|v| v.parse().ok());
|
||||
let root = igneum_common::platform::data_root();
|
||||
let dir = match env("IGNEUM_WALLET_NODE_DIR") {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => root.join(match suffix {
|
||||
Some(n) => format!("wallet-node-devnet-{n}"),
|
||||
None => format!("wallet-node-{network}"),
|
||||
}),
|
||||
};
|
||||
let peers: Vec<String> = match std::env::var("IGNEUM_WALLET_PEERS") {
|
||||
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
||||
Err(_) if network == "devnet" && suffix.is_none() => vec!["188.245.5.161:26611".into()],
|
||||
Err(_) => vec![],
|
||||
};
|
||||
let mut args = vec![
|
||||
format!("--{network}"),
|
||||
format!("--appdir={}", dir.display()),
|
||||
format!("--rpclisten=127.0.0.1:{OWN_GRPC}"),
|
||||
format!("--evm-rpclisten=127.0.0.1:{OWN_EVM}"),
|
||||
format!("--listen=0.0.0.0:{OWN_P2P}"),
|
||||
];
|
||||
if let Some(n) = suffix {
|
||||
args.push(format!("--devnet-suffix={n}"));
|
||||
}
|
||||
for p in &peers {
|
||||
args.push(format!("--addpeer={p}"));
|
||||
}
|
||||
// the packager's consensus pin, as the miner does it (igneum-wallet.json node_override_params)
|
||||
if let Some(file) = env("IGNEUM_WALLET_OVERRIDE_PARAMS") {
|
||||
args.push(format!("--override-params-file={file}"));
|
||||
} else if let Some(v) = packaged.node_override_params.as_ref().filter(|v| v.as_object().map(|o| !o.is_empty()).unwrap_or(false)) {
|
||||
let path = app_dir.join("override-params.json");
|
||||
std::fs::write(&path, v.to_string()).map_err(|e| e.to_string())?;
|
||||
args.push(format!("--override-params-file={}", path.display()));
|
||||
}
|
||||
args.extend(["--nodnsseed", "--disable-upnp", "--nologfiles", "--yes"].iter().map(|s| s.to_string()));
|
||||
let log = log_dir.join("wallet-node.log");
|
||||
Ok(OwnNodePlan { bin, args, dir, log })
|
||||
}
|
||||
|
||||
pub fn start_own_node(plan: &OwnNodePlan) -> Result<OwnNode, String> {
|
||||
let _ = std::fs::create_dir_all(&plan.dir);
|
||||
let out = std::fs::OpenOptions::new().create(true).append(true).open(&plan.log).map_err(|e| e.to_string())?;
|
||||
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
let mut c = Command::new(&plan.bin);
|
||||
c.args(&plan.args).stdin(Stdio::null()).stdout(Stdio::from(out)).stderr(Stdio::from(err));
|
||||
igneum_common::platform::quiet(&mut c);
|
||||
let child = c.spawn().map_err(|e| format!("igneumd did not start: {e}"))?;
|
||||
Ok(OwnNode { child })
|
||||
}
|
||||
|
||||
impl OwnNode {
|
||||
pub fn stop(&mut self) {
|
||||
igneum_common::platform::terminate(&mut self.child);
|
||||
for _ in 0..300 {
|
||||
if let Ok(Some(_)) = self.child.try_wait() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
pub fn alive(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(None))
|
||||
}
|
||||
}
|
||||
31
app/igneum-wallet/src/qr.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! The receive QR code, drawn here (no image service, no library call across the network): qrcodegen to an SVG.
|
||||
|
||||
use qrcodegen::{QrCode, QrCodeEcc};
|
||||
|
||||
pub fn svg(text: &str) -> Result<String, String> {
|
||||
let qr = QrCode::encode_text(text, QrCodeEcc::Medium).map_err(|e| format!("{e:?}"))?;
|
||||
let n = qr.size();
|
||||
let border = 2;
|
||||
let dim = n + 2 * border;
|
||||
let mut path = String::new();
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
if qr.get_module(x, y) {
|
||||
path.push_str(&format!("M{} {}h1v1h-1z", x + border, y + border));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {dim} {dim}\" shape-rendering=\"crispEdges\" role=\"img\" aria-label=\"QR code\"><rect width=\"{dim}\" height=\"{dim}\" fill=\"#F4F1EC\"/><path d=\"{path}\" fill=\"#0C0C0E\"/></svg>"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn draws() {
|
||||
let s = super::svg("ethereum:0x7e5f4552091a69125d5dfcb7b8c2659029395bdf").unwrap();
|
||||
assert!(s.starts_with("<svg"));
|
||||
assert!(s.contains("<path d=\"M"));
|
||||
}
|
||||
}
|
||||
288
app/igneum-wallet/src/server.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
//! The local web server: the window's files from the binary and the JSON API, on 127.0.0.1 with a random port, every
|
||||
//! path under `/t/<token>/` (igneum_common::http). Mutating calls must come from the window itself (same origin).
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use igneum_common::http::{from_dashboard, json_resp, query_param, read_request, respond, respond_with};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
|
||||
const INDEX: &str = include_str!("../ui/index.html");
|
||||
const CSS: &str = include_str!("../ui/app.css");
|
||||
const JS: &str = include_str!("../ui/app.js");
|
||||
const CARD_JS: &str = include_str!("../ui/update-card.js");
|
||||
const LOCK_JS: &str = include_str!("../ui/lock-screen.js");
|
||||
const MARK: &str = include_str!("../ui/mark.svg");
|
||||
const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
|
||||
const FONT_MONO_400: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexMono-400.woff2");
|
||||
const FONT_MONO_500: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexMono-500.woff2");
|
||||
const FONT_SANS_400: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexSans-400.woff2");
|
||||
const FONT_SANS_500: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexSans-500.woff2");
|
||||
const FONT_SANS_600: &[u8] = include_bytes!("../../igneum-app/ui/fonts/IBMPlexSans-600.woff2");
|
||||
const FONT_UNB_500: &[u8] = include_bytes!("../../igneum-app/ui/fonts/Unbounded-500.woff2");
|
||||
const FONT_UNB_700: &[u8] = include_bytes!("../../igneum-app/ui/fonts/Unbounded-700.woff2");
|
||||
const FONT_UNB_900: &[u8] = include_bytes!("../../igneum-app/ui/fonts/Unbounded-900.woff2");
|
||||
|
||||
pub fn start(shared: Arc<Shared>) -> std::io::Result<u16> {
|
||||
let s = shared.clone();
|
||||
let port = igneum_common::http::serve(move |stream| handle(stream, s.clone()))?;
|
||||
shared.set_port(port);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// The page bridge's listener on its fixed port (src/bridge.rs): GET /bridge/hello (the page's probe), POST /bridge/rpc
|
||||
/// (JSON-RPC with the page's Origin and the X-Igneum-Bridge header), and the CORS preflight that must come first.
|
||||
/// Nothing under /t/<token>/ is served here, and nothing here reads the window's token.
|
||||
pub fn start_bridge(shared: Arc<Shared>) {
|
||||
let port = std::env::var("IGNEUM_WALLET_BRIDGE_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(crate::bridge::BRIDGE_PORT);
|
||||
let s = shared.clone();
|
||||
match igneum_common::http::serve_on(port, move |stream| handle_bridge(stream, s.clone())) {
|
||||
Ok(()) => {
|
||||
*shared.bridge_listening.lock().unwrap() = (true, port, String::new());
|
||||
shared.log(&format!("page bridge listening on 127.0.0.1:{port} (websites connect here after your approval in the window)"));
|
||||
}
|
||||
Err(e) => {
|
||||
*shared.bridge_listening.lock().unwrap() = (false, port, format!("port {port} is not free: {e}"));
|
||||
shared.log(&format!("page bridge not listening: port {port} is not free ({e}); websites cannot connect until the wallet restarts with the port free"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_origin_ok(o: &str) -> bool {
|
||||
o.starts_with("https://") || o.starts_with("http://localhost") || o.starts_with("http://127.0.0.1")
|
||||
}
|
||||
|
||||
fn handle_bridge(mut stream: TcpStream, shared: Arc<Shared>) {
|
||||
let Some(req) = read_request(&mut stream) else { return };
|
||||
let origin = req.origin.clone().unwrap_or_default();
|
||||
let cors: Vec<(&str, &str)> = if bridge_origin_ok(&origin) {
|
||||
vec![("Access-Control-Allow-Origin", origin.as_str()), ("Vary", "Origin"), ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), ("Access-Control-Allow-Headers", "content-type, x-igneum-bridge"), ("Access-Control-Allow-Private-Network", "true"), ("Access-Control-Max-Age", "600")]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
match (req.method.as_str(), req.path.as_str()) {
|
||||
("OPTIONS", _) => respond_with(&mut stream, 204, "text/plain", b"", &cors),
|
||||
("GET", "/bridge/hello") => {
|
||||
// the probe: the wallet is here, its version and chain; never an address
|
||||
let v = json!({ "ok": true, "wallet": "igneum", "version": crate::engine::VERSION, "chain_id": shared.network_json()["chain_id"], "chain_id_hex": shared.network_json()["chain_id_hex"], "bridge": 1 });
|
||||
respond_with(&mut stream, 200, "application/json; charset=utf-8", v.to_string().as_bytes(), &cors)
|
||||
}
|
||||
("POST", "/bridge/rpc") => {
|
||||
if origin.is_empty() || !bridge_origin_ok(&origin) || !req.bridge_header {
|
||||
respond_with(&mut stream, 403, "application/json; charset=utf-8", json!({ "error": { "code": crate::bridge::E_UNAUTHORIZED, "message": "a page bridge call carries its origin and the X-Igneum-Bridge header" } }).to_string().as_bytes(), &cors);
|
||||
return;
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&req.body).unwrap_or(json!({}));
|
||||
let id = body.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = body.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let params = body.get("params").cloned().unwrap_or(json!([]));
|
||||
let mut out = shared.bridge_request(&origin, &method, ¶ms);
|
||||
out["id"] = id;
|
||||
out["jsonrpc"] = json!("2.0");
|
||||
respond_with(&mut stream, 200, "application/json; charset=utf-8", out.to_string().as_bytes(), &cors)
|
||||
}
|
||||
_ => respond_with(&mut stream, 404, "text/plain", b"Igneum Wallet page bridge", &cors),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
||||
let Some(req) = read_request(&mut stream) else { return };
|
||||
if req.path == "/" {
|
||||
respond(&mut stream, 404, "text/plain", b"Igneum Wallet: open the app window.", false);
|
||||
return;
|
||||
}
|
||||
let prefix = format!("/t/{}", shared.token);
|
||||
let Some(rest) = req.path.strip_prefix(&prefix) else {
|
||||
respond(&mut stream, 404, "text/plain", b"not found", false);
|
||||
return;
|
||||
};
|
||||
let rest = if rest.is_empty() { "/" } else { rest };
|
||||
match (req.method.as_str(), rest) {
|
||||
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
|
||||
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
|
||||
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
|
||||
("GET", "/update-card.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", CARD_JS.as_bytes(), false),
|
||||
("GET", "/lock-screen.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", LOCK_JS.as_bytes(), false),
|
||||
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
|
||||
("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true),
|
||||
("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true),
|
||||
("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true),
|
||||
("GET", "/fonts/IBMPlexSans-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_400, true),
|
||||
("GET", "/fonts/IBMPlexSans-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_500, true),
|
||||
("GET", "/fonts/IBMPlexSans-600.woff2") => respond(&mut stream, 200, "font/woff2", FONT_SANS_600, true),
|
||||
("GET", "/fonts/Unbounded-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_500, true),
|
||||
("GET", "/fonts/Unbounded-700.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_700, true),
|
||||
("GET", "/fonts/Unbounded-900.woff2") => respond(&mut stream, 200, "font/woff2", FONT_UNB_900, true),
|
||||
("GET", "/api/state") => json_resp(&mut stream, 200, shared.state_json()),
|
||||
("GET", "/api/network") => json_resp(&mut stream, 200, shared.network_json()),
|
||||
("GET", "/api/log") => {
|
||||
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);
|
||||
let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000);
|
||||
let lines = shared.rings.lock().unwrap().since(after, limit);
|
||||
json_resp(&mut stream, 200, json!({ "lines": lines }));
|
||||
}
|
||||
// the host reads a challenge's reason with its token (the page never needs this: it has the reason already)
|
||||
("GET", "/api/biometric/challenge") => {
|
||||
if !shared.host_ok(req.host_token.as_deref()) {
|
||||
json_resp(&mut stream, 403, json!({ "ok": false, "error": "host token" }));
|
||||
return;
|
||||
}
|
||||
let nonce = query_param(&req.query, "nonce").unwrap_or_default();
|
||||
match shared.challenge_reason(&nonce) {
|
||||
Ok(v) => json_resp(&mut stream, 200, v),
|
||||
Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })),
|
||||
}
|
||||
}
|
||||
("GET", p) if p.starts_with("/api/tx/") => {
|
||||
let hash = p.trim_start_matches("/api/tx/").to_string();
|
||||
match shared.tx_detail(&hash) {
|
||||
Ok(v) => json_resp(&mut stream, 200, v),
|
||||
Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })),
|
||||
}
|
||||
}
|
||||
("POST", p) => {
|
||||
if !from_dashboard(&req, shared.port()) {
|
||||
json_resp(&mut stream, 403, json!({ "ok": false, "error": "not from the window" }));
|
||||
return;
|
||||
}
|
||||
let body: Value = if req.body.is_empty() { json!({}) } else { serde_json::from_slice(&req.body).unwrap_or(json!({})) };
|
||||
let from_host = shared.host_ok(req.host_token.as_deref());
|
||||
if HOST_ONLY.contains(&p) && !from_host {
|
||||
json_resp(&mut stream, 403, json!({ "ok": false, "error": "only the window host may call this" }));
|
||||
return;
|
||||
}
|
||||
match api_post(&shared, p, body, from_host) {
|
||||
Ok(v) => json_resp(&mut stream, 200, v),
|
||||
Err(e) => json_resp(&mut stream, 400, json!({ "ok": false, "error": e })),
|
||||
}
|
||||
}
|
||||
_ => respond(&mut stream, 404, "text/plain", b"not found", false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls that carry a biometric result or take the parked password: the host's token (X-Igneum-Host) is required,
|
||||
/// so the page cannot confirm its own challenges.
|
||||
const HOST_ONLY: &[&str] = &["/api/biometric/status", "/api/biometric/report", "/api/biometric/confirm", "/api/biometric/enrol/take", "/api/biometric/enrolled"];
|
||||
|
||||
fn api_post(shared: &Arc<Shared>, path: &str, body: Value, from_host: bool) -> Result<Value, String> {
|
||||
let s = |k: &str| body.get(k).and_then(|v| v.as_str()).map(|v| v.to_string());
|
||||
let b = |k: &str| body.get(k).and_then(|v| v.as_bool());
|
||||
match path {
|
||||
"/api/create" => shared.create_begin(&s("password").ok_or("password missing")?),
|
||||
"/api/create/confirm" => {
|
||||
let list = body.get("checks").and_then(|v| v.as_array()).ok_or("checks missing")?;
|
||||
let checks: Vec<(usize, String)> = list.iter().filter_map(|c| Some((c.get("position")?.as_u64()? as usize, c.get("word")?.as_str()?.to_string()))).collect();
|
||||
shared.create_confirm(&checks)
|
||||
}
|
||||
"/api/import" => shared.import(&s("mode").unwrap_or_default(), &s("data").unwrap_or_default(), &s("password").ok_or("password missing")?),
|
||||
"/api/unlock" => shared.unlock(&s("password").ok_or("password missing")?, from_host),
|
||||
"/api/lock" => {
|
||||
shared.lock();
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/reveal" => match s("nonce") {
|
||||
Some(n) => shared.reveal_confirmed(&n),
|
||||
None => shared.reveal(&s("password").ok_or("password missing")?),
|
||||
},
|
||||
"/api/backed-up" => shared.mark_backed_up(),
|
||||
"/api/password" => shared.change_password(&s("old").ok_or("old missing")?, &s("new").ok_or("new missing")?),
|
||||
"/api/remove" => shared.remove(&s("password").ok_or("password missing")?),
|
||||
"/api/receive" => {
|
||||
let address = shared.address();
|
||||
if address.is_empty() {
|
||||
return Err("no wallet".into());
|
||||
}
|
||||
let svg = crate::qr::svg(&format!("ethereum:{}", igneum_common::keys::checksum(&address)))?;
|
||||
Ok(json!({ "ok": true, "address": address, "display": igneum_common::keys::checksum(&address), "svg": svg }))
|
||||
}
|
||||
"/api/send/quote" => {
|
||||
let q = shared.quote(&s("to").unwrap_or_default(), &s("amount").unwrap_or_default())?;
|
||||
let mut v = serde_json::to_value(&q).map_err(|e| e.to_string())?;
|
||||
v["ok"] = json!(true);
|
||||
v["value_ign"] = json!(crate::evm::ign(q.value.parse().unwrap_or(0), 18));
|
||||
v["fee_max_ign"] = json!(crate::evm::ign(q.fee_max.parse().unwrap_or(0), 9));
|
||||
v["total_max_ign"] = json!(crate::evm::ign(q.total_max.parse().unwrap_or(0), 9));
|
||||
v["base_fee_gwei"] = json!(crate::evm::ign(q.base_fee.parse::<u128>().unwrap_or(0) * 1_000_000_000, 3));
|
||||
v["tip_gwei"] = json!(crate::evm::ign(q.tip.parse::<u128>().unwrap_or(0) * 1_000_000_000, 3));
|
||||
v["display_to"] = json!(igneum_common::keys::checksum(&q.to));
|
||||
// the biometric challenge for this quote: the prompt's line and the nonce the host confirms
|
||||
let (nonce, reason) = shared.challenge_for_send(&q, &crate::evm::ign(q.value.parse().unwrap_or(0), 4));
|
||||
v["confirm_nonce"] = json!(nonce);
|
||||
v["confirm_reason"] = json!(reason);
|
||||
v["confirm_needed"] = json!(shared.enrolled());
|
||||
Ok(v)
|
||||
}
|
||||
"/api/send" => {
|
||||
let q: crate::engine::Quote = serde_json::from_value(body.get("quote").cloned().ok_or("quote missing")?).map_err(|e| format!("quote: {e}"))?;
|
||||
shared.send_tx(&q, s("nonce").as_deref())
|
||||
}
|
||||
"/api/settings" => {
|
||||
if let Some(on) = b("start_at_login") {
|
||||
shared.set_start_at_login(on)?;
|
||||
}
|
||||
if let Some(on) = b("idle_lock") {
|
||||
shared.set_idle_lock(on)?;
|
||||
}
|
||||
if let Some(min) = body.get("idle_lock_min").and_then(|v| v.as_u64()) {
|
||||
shared.set_idle_lock_min(min)?;
|
||||
}
|
||||
if let Some(on) = b("ask_on_open") {
|
||||
shared.set_ask_on_open(on)?;
|
||||
}
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
// ---- Touch ID / Windows Hello: the page's side and the host's side (HOST_ONLY) ----
|
||||
"/api/activity" => {
|
||||
shared.touch_activity();
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/biometric/challenge" => shared.challenge(&s("purpose").unwrap_or_default()),
|
||||
"/api/biometric/enrol/begin" => shared.enrol_begin(&s("password").ok_or("password missing")?),
|
||||
"/api/biometric/enrol/cancel" => shared.enrol_cancel(),
|
||||
"/api/biometric/remove" => shared.biometric_remove(),
|
||||
"/api/biometric/status" => shared.biometric_status(b("available").unwrap_or(false), &s("kind").unwrap_or_default(), &s("message").unwrap_or_default()),
|
||||
"/api/biometric/report" => shared.biometric_report(&s("op").unwrap_or_default(), b("ok").unwrap_or(false), &s("code").unwrap_or_default(), &s("message").unwrap_or_default()),
|
||||
"/api/biometric/confirm" => shared.confirm(&s("nonce").ok_or("nonce missing")?),
|
||||
"/api/biometric/enrol/take" => shared.enrol_take(&s("token").ok_or("token missing")?),
|
||||
"/api/biometric/enrolled" => shared.enrolled_set(&s("kind").unwrap_or_default()),
|
||||
// ---- the page bridge: the window's decisions (src/bridge.rs) ----
|
||||
"/api/bridge/decide" => shared.bridge_decide(&s("id").ok_or("id missing")?, b("ok").unwrap_or(false), s("nonce").as_deref()),
|
||||
"/api/bridge/sites/remove" => shared.bridge_sites_remove(&s("origin").ok_or("origin missing")?),
|
||||
"/api/bridge/on" => shared.set_bridge_on(b("on").ok_or("on missing")?),
|
||||
"/api/refresh" => {
|
||||
shared.send(Cmd::Refresh);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/update/check" => {
|
||||
shared.send(Cmd::CheckUpdate);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/update/open" => {
|
||||
shared.send(Cmd::OpenUpdate);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/update/install" => {
|
||||
shared.send(Cmd::InstallUpdate);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/update/auto" => {
|
||||
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
|
||||
shared.send(Cmd::AutoUpdate(on));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/open" => {
|
||||
let url = s("url").ok_or("url missing")?;
|
||||
if url.starts_with("https://") || url.starts_with("http://") {
|
||||
igneum_common::platform::open_url(&url);
|
||||
Ok(json!({ "ok": true }))
|
||||
} else {
|
||||
Err("only http(s) links open".into())
|
||||
}
|
||||
}
|
||||
"/api/quit" => {
|
||||
shared.send(Cmd::Quit);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
_ => Err("unknown api".into()),
|
||||
}
|
||||
}
|
||||
156
app/igneum-wallet/src/state.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! The engine's state as the window reads it (`GET /api/state`), plus the event and log rings (as the miner's).
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct NodeState {
|
||||
/// miner | external | public | own | none
|
||||
pub source: String,
|
||||
pub state: String, // off | starting | connecting | ok | lost
|
||||
pub chain_id: u64,
|
||||
pub chain: String, // the consensus chain id (igneum-devnet-20) when known
|
||||
pub block: u64,
|
||||
pub evm: String, // the Ethereum RPC endpoint in use
|
||||
pub grpc: String,
|
||||
pub synced: bool,
|
||||
pub message: String,
|
||||
pub finality_active: bool,
|
||||
pub latest_locked: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct FinalityView {
|
||||
pub verified_index: u64,
|
||||
pub verified_hash: String,
|
||||
pub chain_number: Option<u64>,
|
||||
pub signers: usize,
|
||||
pub voters: usize,
|
||||
pub fraction_total: f64,
|
||||
pub fraction_active: f64,
|
||||
pub weights_exact: bool,
|
||||
pub verified_at: f64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// The over-the-air updater (src/updater.rs), as the miner's.
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct UpdateState {
|
||||
pub status: String, // off | unknown | checking | current | available | downloading | staging | ready | applying | deferred | manual | error
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
pub notes: String,
|
||||
pub file: String, // the downloaded disk image or installer (the manual path opens it)
|
||||
pub error: String,
|
||||
pub checked_at: f64,
|
||||
pub available: bool,
|
||||
pub downloaded: bool,
|
||||
pub ready: bool,
|
||||
pub applying: bool,
|
||||
pub progress: f64, // 0..1 of the download
|
||||
pub size: u64,
|
||||
pub auto: bool, // settings: install by itself when nothing is being sent
|
||||
pub wait: String, // why it has not applied yet, in the window's words
|
||||
pub urgent: bool, // this version is below min_supported_version: no waiting
|
||||
pub urgent_text: String,
|
||||
pub unsupported: bool,
|
||||
pub min_supported: String,
|
||||
pub updated_from: String, // set on the first run after an update
|
||||
pub rolled_back: String, // set when the helper restored the previous version
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct SettingsState {
|
||||
pub start_at_login: bool,
|
||||
pub network: String,
|
||||
pub auto_update: bool,
|
||||
/// the idle lock in minutes (1, 5, 15, 60), 0 for never; acts only while Touch ID or Windows Hello is enrolled
|
||||
pub idle_lock_min: u64,
|
||||
/// the first arrival after the person opened the app may raise the Touch ID sheet once, by itself
|
||||
pub ask_on_open: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct Event {
|
||||
pub t: f64,
|
||||
pub kind: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct LogLine {
|
||||
pub seq: u64,
|
||||
pub t: f64,
|
||||
pub src: String,
|
||||
pub err: bool,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct State {
|
||||
pub version: String,
|
||||
/// welcome | unlock | home
|
||||
pub phase: String,
|
||||
pub has_vault: bool,
|
||||
pub unlocked: bool,
|
||||
pub backed_up: bool,
|
||||
pub source: String, // created | seed | key | miner
|
||||
pub address: String,
|
||||
pub display: String,
|
||||
pub balance_wei: String,
|
||||
pub balance: String,
|
||||
pub balance_known: bool,
|
||||
pub node: NodeState,
|
||||
pub finality: FinalityView,
|
||||
pub history: Vec<crate::history::Entry>,
|
||||
pub scanning: bool,
|
||||
pub scanned_to: Option<u64>,
|
||||
pub update: UpdateState,
|
||||
pub settings: SettingsState,
|
||||
/// Touch ID / Windows Hello (igneum_common::biometric; the prompt lives in the window host)
|
||||
pub biometric: igneum_common::biometric::BiometricState,
|
||||
pub events: Vec<Event>,
|
||||
pub miner_wallet_file: String,
|
||||
pub miner_wallet_present: bool,
|
||||
pub add_network_page: String,
|
||||
pub public_rpc: String,
|
||||
pub machine_id: String,
|
||||
pub host: String,
|
||||
pub log_dir: String,
|
||||
pub app_dir: String,
|
||||
pub uptime_s: u64,
|
||||
pub now: f64,
|
||||
pub quitting: bool,
|
||||
}
|
||||
|
||||
pub struct Rings {
|
||||
pub events: VecDeque<Event>,
|
||||
pub log: VecDeque<LogLine>,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
impl Rings {
|
||||
pub fn new() -> Rings {
|
||||
Rings { events: VecDeque::new(), log: VecDeque::new(), seq: 0 }
|
||||
}
|
||||
pub fn event(&mut self, kind: &str, text: &str) {
|
||||
self.events.push_front(Event { t: igneum_common::platform::unix_now_f(), kind: kind.into(), text: text.into() });
|
||||
while self.events.len() > 40 {
|
||||
self.events.pop_back();
|
||||
}
|
||||
}
|
||||
pub fn log(&mut self, src: &str, err: bool, text: &str) {
|
||||
self.seq += 1;
|
||||
self.log.push_back(LogLine { seq: self.seq, t: igneum_common::platform::unix_now_f(), src: src.into(), err, text: text.into() });
|
||||
while self.log.len() > 3000 {
|
||||
self.log.pop_front();
|
||||
}
|
||||
}
|
||||
pub fn since(&self, after: u64, limit: usize) -> Vec<LogLine> {
|
||||
let mut out: Vec<LogLine> = self.log.iter().filter(|l| l.seq > after).cloned().collect();
|
||||
if out.len() > limit {
|
||||
out = out.split_off(out.len() - limit);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
319
app/igneum-wallet/src/tx.rs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
//! EIP-1559 transfers: RLP, the signing hash, the secp256k1 signature (low-s, with the recovery bit), the raw bytes
|
||||
//! for eth_sendRawTransaction. Written here so the key never leaves the engine; nothing on the window side signs.
|
||||
|
||||
use k256::ecdsa::{RecoveryId, Signature, SigningKey, VerifyingKey};
|
||||
use sha3::{Digest, Keccak256};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Transfer {
|
||||
pub chain_id: u64,
|
||||
pub nonce: u64,
|
||||
pub max_priority_fee: u128,
|
||||
pub max_fee: u128,
|
||||
pub gas_limit: u64,
|
||||
pub to: [u8; 20],
|
||||
pub value: u128,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
// ---- RLP --------------------------------------------------------------------------------------------------
|
||||
|
||||
fn rlp_len(len: usize, offset: u8, out: &mut Vec<u8>) {
|
||||
if len < 56 {
|
||||
out.push(offset + len as u8);
|
||||
} else {
|
||||
let be = (len as u64).to_be_bytes();
|
||||
let first = be.iter().position(|b| *b != 0).unwrap_or(7);
|
||||
out.push(offset + 55 + (8 - first) as u8);
|
||||
out.extend_from_slice(&be[first..]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rlp_bytes(b: &[u8], out: &mut Vec<u8>) {
|
||||
if b.len() == 1 && b[0] < 0x80 {
|
||||
out.push(b[0]);
|
||||
} else {
|
||||
rlp_len(b.len(), 0x80, out);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rlp_uint(v: u128, out: &mut Vec<u8>) {
|
||||
if v == 0 {
|
||||
out.push(0x80);
|
||||
return;
|
||||
}
|
||||
let be = v.to_be_bytes();
|
||||
let first = be.iter().position(|b| *b != 0).unwrap();
|
||||
rlp_bytes(&be[first..], out);
|
||||
}
|
||||
|
||||
pub fn rlp_list(items: &[u8], out: &mut Vec<u8>) {
|
||||
rlp_len(items.len(), 0xc0, out);
|
||||
out.extend_from_slice(items);
|
||||
}
|
||||
|
||||
fn fields(t: &Transfer, out: &mut Vec<u8>) {
|
||||
rlp_uint(t.chain_id as u128, out);
|
||||
rlp_uint(t.nonce as u128, out);
|
||||
rlp_uint(t.max_priority_fee, out);
|
||||
rlp_uint(t.max_fee, out);
|
||||
rlp_uint(t.gas_limit as u128, out);
|
||||
rlp_bytes(&t.to, out);
|
||||
rlp_uint(t.value, out);
|
||||
rlp_bytes(&t.data, out);
|
||||
out.push(0xc0); // empty access list
|
||||
}
|
||||
|
||||
/// 0x02 || rlp([chainId, nonce, maxPriorityFee, maxFee, gasLimit, to, value, data, accessList])
|
||||
pub fn unsigned_bytes(t: &Transfer) -> Vec<u8> {
|
||||
let mut items = Vec::new();
|
||||
fields(t, &mut items);
|
||||
let mut out = vec![0x02];
|
||||
rlp_list(&items, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn signing_hash(t: &Transfer) -> [u8; 32] {
|
||||
Keccak256::digest(unsigned_bytes(t)).into()
|
||||
}
|
||||
|
||||
pub struct Signed {
|
||||
pub raw: Vec<u8>,
|
||||
pub hash: [u8; 32],
|
||||
}
|
||||
|
||||
/// Signs with the raw private key. The signature is normalised to low s (the EVM rejects high s) and the recovery bit
|
||||
/// flipped with it; the signer's address is recovered from the result and checked before anything is returned.
|
||||
pub fn sign(t: &Transfer, private_key: &[u8; 32]) -> Result<Signed, String> {
|
||||
let sk = SigningKey::from_bytes(private_key.into()).map_err(|_| "invalid private key")?;
|
||||
let h = signing_hash(t);
|
||||
let (sig, rec): (Signature, RecoveryId) = sk.sign_prehash_recoverable(&h).map_err(|e| e.to_string())?;
|
||||
let (sig, rec) = match sig.normalize_s() {
|
||||
Some(low) => (low, RecoveryId::from_byte(rec.to_byte() ^ 1).ok_or("recovery id")?),
|
||||
None => (sig, rec),
|
||||
};
|
||||
// the recovered key must be ours
|
||||
let vk = VerifyingKey::recover_from_prehash(&h, &sig, rec).map_err(|e| format!("recovery check: {e}"))?;
|
||||
if vk != *sk.verifying_key() {
|
||||
return Err("the signature does not recover to the signing key".into());
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
fields(t, &mut items);
|
||||
rlp_uint(rec.to_byte() as u128, &mut items);
|
||||
rlp_scalar(&sig.r().to_bytes(), &mut items);
|
||||
rlp_scalar(&sig.s().to_bytes(), &mut items);
|
||||
let mut raw = vec![0x02];
|
||||
rlp_list(&items, &mut raw);
|
||||
let hash: [u8; 32] = Keccak256::digest(&raw).into();
|
||||
Ok(Signed { raw, hash })
|
||||
}
|
||||
|
||||
/// Signs a 32-byte digest (EIP-191 personal messages, EIP-712 typed data) with the raw private key: the 65-byte
|
||||
/// r || s || v signature pages expect (v = 27 + recovery bit), low s, the recovered key checked before anything is
|
||||
/// returned. The page bridge (8 October 2026).
|
||||
pub fn sign_digest(h: &[u8; 32], private_key: &[u8; 32]) -> Result<[u8; 65], String> {
|
||||
let sk = SigningKey::from_bytes(private_key.into()).map_err(|_| "invalid private key")?;
|
||||
let (sig, rec): (Signature, RecoveryId) = sk.sign_prehash_recoverable(h).map_err(|e| e.to_string())?;
|
||||
let (sig, rec) = match sig.normalize_s() {
|
||||
Some(low) => (low, RecoveryId::from_byte(rec.to_byte() ^ 1).ok_or("recovery id")?),
|
||||
None => (sig, rec),
|
||||
};
|
||||
let vk = VerifyingKey::recover_from_prehash(h, &sig, rec).map_err(|e| format!("recovery check: {e}"))?;
|
||||
if vk != *sk.verifying_key() {
|
||||
return Err("the signature does not recover to the signing key".into());
|
||||
}
|
||||
let mut out = [0u8; 65];
|
||||
out[..32].copy_from_slice(&sig.r().to_bytes());
|
||||
out[32..64].copy_from_slice(&sig.s().to_bytes());
|
||||
out[64] = 27 + rec.to_byte();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The EIP-191 digest of a personal message: keccak("\x19Ethereum Signed Message:\n" + len + message).
|
||||
pub fn personal_digest(message: &[u8]) -> [u8; 32] {
|
||||
let mut pre = format!("\x19Ethereum Signed Message:\n{}", message.len()).into_bytes();
|
||||
pre.extend_from_slice(message);
|
||||
Keccak256::digest(&pre).into()
|
||||
}
|
||||
|
||||
/// The address a 65-byte signature over `h` recovers to, lower-case 0x hex.
|
||||
pub fn recover_digest(h: &[u8; 32], sig65: &[u8; 65]) -> Option<String> {
|
||||
let v = sig65[64];
|
||||
let rec = RecoveryId::from_byte(if v >= 27 { v - 27 } else { v })?;
|
||||
let sig = Signature::from_slice(&sig65[..64]).ok()?;
|
||||
let vk = VerifyingKey::recover_from_prehash(h, &sig, rec).ok()?;
|
||||
let pk = vk.to_encoded_point(false);
|
||||
let h2: [u8; 32] = Keccak256::digest(&pk.as_bytes()[1..]).into();
|
||||
Some(format!("0x{}", h2[12..].iter().map(|b| format!("{b:02x}")).collect::<String>()))
|
||||
}
|
||||
|
||||
/// A big-endian scalar (r, s: 32 bytes) as an RLP integer: leading zeros stripped, zero is the empty string.
|
||||
pub fn rlp_scalar(b: &[u8], out: &mut Vec<u8>) {
|
||||
let first = b.iter().position(|x| *x != 0);
|
||||
match first {
|
||||
None => out.push(0x80),
|
||||
Some(i) => rlp_bytes(&b[i..], out),
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed transaction's `from`, recovered from its raw bytes: what the node will see.
|
||||
pub fn recover_from(raw: &[u8]) -> Option<String> {
|
||||
// decode the outer list, pull the last three items (v, r, s), rebuild the signing payload
|
||||
let (items, _) = rlp_decode_list(&raw[1..])?;
|
||||
if items.len() != 12 {
|
||||
return None;
|
||||
}
|
||||
let mut payload = Vec::new();
|
||||
for it in &items[..9] {
|
||||
payload.extend_from_slice(it);
|
||||
}
|
||||
let mut unsigned = vec![0x02];
|
||||
rlp_list(&payload, &mut unsigned);
|
||||
let h: [u8; 32] = Keccak256::digest(&unsigned).into();
|
||||
let v = rlp_item_bytes(&items[9])?;
|
||||
let r = rlp_item_bytes(&items[10])?;
|
||||
let s = rlp_item_bytes(&items[11])?;
|
||||
let rec = RecoveryId::from_byte(if v.is_empty() { 0 } else { v[0] })?;
|
||||
let mut rs = [0u8; 64];
|
||||
rs[32 - r.len()..32].copy_from_slice(r);
|
||||
rs[64 - s.len()..].copy_from_slice(s);
|
||||
let sig = Signature::from_slice(&rs).ok()?;
|
||||
let vk = VerifyingKey::recover_from_prehash(&h, &sig, rec).ok()?;
|
||||
let point = vk.to_encoded_point(false);
|
||||
let hh = Keccak256::digest(&point.as_bytes()[1..]);
|
||||
Some(format!("0x{}", igneum_common::keys::hex(&hh[12..])))
|
||||
}
|
||||
|
||||
/// Minimal RLP decoding for the recovery check: returns the encoded items (bytes of each item, prefix included).
|
||||
fn rlp_decode_list(b: &[u8]) -> Option<(Vec<&[u8]>, usize)> {
|
||||
let (payload_start, payload_len) = rlp_head(b)?;
|
||||
if b[0] < 0xc0 {
|
||||
return None;
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut o = payload_start;
|
||||
let end = payload_start + payload_len;
|
||||
while o < end {
|
||||
let (ps, pl) = rlp_head(&b[o..])?;
|
||||
items.push(&b[o..o + ps + pl]);
|
||||
o += ps + pl;
|
||||
}
|
||||
Some((items, end))
|
||||
}
|
||||
|
||||
fn rlp_head(b: &[u8]) -> Option<(usize, usize)> {
|
||||
let f = *b.first()?;
|
||||
Some(match f {
|
||||
0..=0x7f => (0, 1),
|
||||
0x80..=0xb7 => (1, (f - 0x80) as usize),
|
||||
0xb8..=0xbf => {
|
||||
let n = (f - 0xb7) as usize;
|
||||
(1 + n, be_len(b.get(1..1 + n)?))
|
||||
}
|
||||
0xc0..=0xf7 => (1, (f - 0xc0) as usize),
|
||||
_ => {
|
||||
let n = (f - 0xf7) as usize;
|
||||
(1 + n, be_len(b.get(1..1 + n)?))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn be_len(b: &[u8]) -> usize {
|
||||
b.iter().fold(0usize, |a, x| (a << 8) | *x as usize)
|
||||
}
|
||||
|
||||
fn rlp_item_bytes(it: &[u8]) -> Option<&[u8]> {
|
||||
let (ps, pl) = rlp_head(it)?;
|
||||
if it[0] < 0x80 {
|
||||
return Some(&it[..1]);
|
||||
}
|
||||
it.get(ps..ps + pl)
|
||||
}
|
||||
|
||||
pub fn hex0x(b: &[u8]) -> String {
|
||||
format!("0x{}", igneum_common::keys::hex(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_personal_message_signature_recovers_to_the_signer_and_carries_v_27_or_28() {
|
||||
let key = [7u8; 32];
|
||||
let sk = SigningKey::from_bytes((&key).into()).unwrap();
|
||||
let pk = sk.verifying_key().to_encoded_point(false);
|
||||
let addr_h: [u8; 32] = Keccak256::digest(&pk.as_bytes()[1..]).into();
|
||||
let me = format!("0x{}", addr_h[12..].iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
let h = personal_digest(b"hello world");
|
||||
// the prefix is the EIP-191 one: the same digest as keccak of the literal prefixed bytes
|
||||
let mut lit = b"\x19Ethereum Signed Message:\n11hello world".to_vec();
|
||||
let direct: [u8; 32] = Keccak256::digest(&lit).into();
|
||||
lit.clear();
|
||||
assert_eq!(h, direct);
|
||||
let sig = sign_digest(&h, &key).unwrap();
|
||||
assert!(sig[64] == 27 || sig[64] == 28);
|
||||
assert_eq!(recover_digest(&h, &sig).as_deref(), Some(me.as_str()));
|
||||
assert!(sig[32] < 0x80, "low s");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn to20() -> [u8; 20] {
|
||||
let mut t = [0u8; 20];
|
||||
t[19] = 0xaa;
|
||||
t
|
||||
}
|
||||
|
||||
/// The unsigned bytes computed by hand for small values: 0x02 then a 31-byte list.
|
||||
#[test]
|
||||
fn rlp_vector() {
|
||||
let t = Transfer { chain_id: 1, nonce: 0, max_priority_fee: 1, max_fee: 1, gas_limit: 21000, to: to20(), value: 0, data: vec![], };
|
||||
let b = unsigned_bytes(&t);
|
||||
let want = format!("02df0180010182520894{}aa8080c0", "00".repeat(19));
|
||||
assert_eq!(igneum_common::keys::hex(&b), want);
|
||||
let mut out = Vec::new();
|
||||
rlp_scalar(&[0u8; 32], &mut out);
|
||||
assert_eq!(out, vec![0x80]);
|
||||
let mut out = Vec::new();
|
||||
let mut one = [0u8; 32];
|
||||
one[31] = 0x7f;
|
||||
rlp_scalar(&one, &mut out);
|
||||
assert_eq!(out, vec![0x7f]);
|
||||
// keccak256 of the empty string, the classic constant
|
||||
assert_eq!(igneum_common::keys::hex(&Keccak256::digest(b"")), "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rlp_long_values() {
|
||||
let mut out = Vec::new();
|
||||
rlp_uint(1_000_000_000_000_000_000u128, &mut out); // 1 IGN in wei = 0x0de0b6b3a7640000
|
||||
assert_eq!(igneum_common::keys::hex(&out), "880de0b6b3a7640000");
|
||||
let mut out = Vec::new();
|
||||
rlp_bytes(&[0u8; 60], &mut out);
|
||||
assert_eq!(out[0], 0xb8);
|
||||
assert_eq!(out[1], 60);
|
||||
let mut out = Vec::new();
|
||||
rlp_uint(4463, &mut out);
|
||||
assert_eq!(igneum_common::keys::hex(&out), "82116f");
|
||||
}
|
||||
|
||||
/// Signing recovers to the signing address, is low-s, and the raw bytes decode back to the same from.
|
||||
#[test]
|
||||
fn sign_recovers() {
|
||||
let mut key = [0u8; 32];
|
||||
key[31] = 1;
|
||||
let t = Transfer { chain_id: 4463, nonce: 7, max_priority_fee: 1_000_000_000, max_fee: 3_000_000_000, gas_limit: 21000, to: to20(), value: 5_000_000_000_000_000_000, data: vec![] };
|
||||
let s = sign(&t, &key).unwrap();
|
||||
assert_eq!(s.raw[0], 0x02);
|
||||
assert_eq!(recover_from(&s.raw).unwrap(), "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf");
|
||||
// deterministic (RFC 6979): the same input signs to the same bytes
|
||||
let s2 = sign(&t, &key).unwrap();
|
||||
assert_eq!(s.raw, s2.raw);
|
||||
assert_eq!(s.hash, s2.hash);
|
||||
// the Hardhat key signs to its known address too
|
||||
let hh = igneum_common::keys::unhex("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80").unwrap();
|
||||
let hk: [u8; 32] = hh.try_into().unwrap();
|
||||
let s3 = sign(&t, &hk).unwrap();
|
||||
assert_eq!(recover_from(&s3.raw).unwrap(), "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266");
|
||||
}
|
||||
}
|
||||
751
app/igneum-wallet/src/updater.rs
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
//! Over-the-air updates for the wallet, v2 (5 October 2026): unattended, on the miner's bones (app/igneum-app/src/ota.rs)
|
||||
//! with the shared parts in igneum_common::{fetch, ota}. The founder's rule: every app updates itself and downloads the
|
||||
//! update without being asked.
|
||||
//!
|
||||
//! The loop, driven from the engine's tick:
|
||||
//! check (25 s after start, then hourly; 10 minutes after an error): fetch igneum-wallet-latest.json and its .sig,
|
||||
//! verify the Ed25519 signature with the key compiled into igneum_common::manifest, parse, compare versions
|
||||
//! -> download (curl with resume into <wallet data>/updates/, then size and sha256 against the manifest)
|
||||
//! -> stage (macOS: mount the DMG, copy the bundle next to the running one, check its engine answers --version with
|
||||
//! the manifest's version, digest the staged bundle; Windows: the installer is the staged artefact)
|
||||
//! -> ready: with the setting "install updates by itself" (default on) the engine applies at the next safe moment,
|
||||
//! which for a wallet is simply no send in flight (no /api/send running, no quote shown in the last 3 minutes, no
|
||||
//! sent transaction still waiting for its block, no create flow half way); at once when the user clicks Install
|
||||
//! now or the version is below min_supported_version
|
||||
//! -> apply: the engine re-hashes the download and the staged bundle, writes update-pending.json, starts the
|
||||
//! detached helper and exits (macOS: the helper swaps /Applications/Igneum Wallet.app and opens the new one;
|
||||
//! Windows: the installer runs first and stops the engine itself; untested for the wallet)
|
||||
//! -> rollback: the helper restores the previous bundle when the new app does not start twice; the new engine
|
||||
//! counts its starts and, on the third start without 90 healthy seconds, restores the previous version.
|
||||
//! "Later" is the window's: it hides the banner for that version; the engine still installs at the safe moment.
|
||||
//!
|
||||
//! Environment (tests): IGNEUM_WALLET_UPDATE_MANIFEST overrides the manifest URL from igneum-wallet.json,
|
||||
//! IGNEUM_WALLET_UPDATE_CHECK_SECS the hourly interval, IGNEUM_WALLET_UPDATE_FIRST_SECS the delay of the first check.
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use igneum_common::fetch;
|
||||
use igneum_common::manifest::{self, Manifest, PlatformEntry};
|
||||
use igneum_common::ota::{self, Launch, Pending};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const CHECK_EVERY_S: u64 = 3600;
|
||||
const FIRST_CHECK_S: u64 = 25;
|
||||
const RETRY_AFTER_ERROR_S: u64 = 600;
|
||||
/// A quote shown on the confirm screen counts as a send in flight for this long.
|
||||
pub const QUOTE_HOLDS_S: u64 = 180;
|
||||
/// The environment the helper carries to a relaunch (test runs); a normal run has none of these set.
|
||||
const ENV_PREFIXES: &[&str] = &["IGNEUM_APP_", "IGNEUM_WALLET_", "IGNEUM_OTA_"];
|
||||
|
||||
pub enum Event {
|
||||
/// The manifest fetched, verified and parsed (or why not).
|
||||
Checked(Result<Manifest, String>),
|
||||
/// The disk image or installer on disk, size and sha256 checked.
|
||||
Downloaded(Result<PathBuf, String>),
|
||||
/// macOS: the new bundle staged next to the running one. Windows: the installer path again.
|
||||
Staged(Result<PathBuf, String>),
|
||||
}
|
||||
|
||||
/// What the engine must do now.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Apply,
|
||||
}
|
||||
|
||||
/// What the engine knows when it asks whether now is a safe moment.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Ctx {
|
||||
/// A send is in flight: /api/send running, a quote on the confirm screen, or a sent transaction not yet in a block.
|
||||
pub send_in_flight: bool,
|
||||
/// The create flow is half way (the 24 words are on the screen, waiting for the confirmation).
|
||||
pub creating: bool,
|
||||
}
|
||||
|
||||
/// What the manifest means for this install.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Plan {
|
||||
/// This version is the latest (or newer than the manifest).
|
||||
Current,
|
||||
/// A newer version is published without a build for this platform yet.
|
||||
NoBuild(String),
|
||||
/// A newer version with a build for this platform.
|
||||
Update(PlatformEntry),
|
||||
}
|
||||
|
||||
pub fn plan(m: &Manifest, current: &str) -> Plan {
|
||||
if !manifest::newer(&m.version, current) {
|
||||
return Plan::Current;
|
||||
}
|
||||
match m.this_platform() {
|
||||
Some(e) => Plan::Update(e.clone()),
|
||||
None => Plan::NoBuild(m.version.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ok when a ready update may be applied now; Err carries the reason to wait, in the words the window shows.
|
||||
pub fn safe_to_apply(ctx: &Ctx, auto: bool, install_asked: bool, urgent: bool, failed_before: bool) -> Result<(), String> {
|
||||
if urgent || install_asked {
|
||||
return Ok(());
|
||||
}
|
||||
if !auto {
|
||||
return Err("waiting for Install now (automatic updates are off)".into());
|
||||
}
|
||||
if failed_before {
|
||||
return Err("this version failed to install before; it waits for Install now".into());
|
||||
}
|
||||
if ctx.send_in_flight {
|
||||
return Err("a send is in flight; installing after it".into());
|
||||
}
|
||||
if ctx.creating {
|
||||
return Err("a wallet is being created; installing after it".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Updater {
|
||||
pub url: String,
|
||||
pub current: String,
|
||||
app_dir: PathBuf,
|
||||
dir: PathBuf,
|
||||
auto: bool,
|
||||
manifest: Option<Manifest>,
|
||||
entry: Option<PlatformEntry>,
|
||||
file: Option<PathBuf>,
|
||||
staged: Option<PathBuf>,
|
||||
staged_digest: String,
|
||||
busy: bool,
|
||||
next_check: Instant,
|
||||
ready_since: Option<Instant>,
|
||||
install_asked: bool,
|
||||
pending: Option<Pending>,
|
||||
started: Instant,
|
||||
healthy_marked: bool,
|
||||
/// versions whose apply failed or that were rolled back: never re-applied by themselves
|
||||
failed_versions: Vec<String>,
|
||||
/// the last manifest's min_supported_version, kept across restarts (updates/manifest.json): the rollback floor
|
||||
min_supported: String,
|
||||
/// Windows: the installer was started and the engine is still up (it stops us when it may run)
|
||||
apply_launched: Option<Instant>,
|
||||
/// Windows: the administrator prompt was not answered; no automatic retry before this
|
||||
deferred_until: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Updater {
|
||||
pub fn new(shared: &Arc<Shared>) -> Updater {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let url = env("IGNEUM_WALLET_UPDATE_MANIFEST").unwrap_or_else(|| shared.packaged.update_manifest.clone());
|
||||
let app_dir = shared.paths.app_dir.clone();
|
||||
let dir = app_dir.join("updates");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let first = env("IGNEUM_WALLET_UPDATE_FIRST_SECS").and_then(|v| v.parse().ok()).unwrap_or(FIRST_CHECK_S);
|
||||
let auto = shared.settings.lock().unwrap().auto_update;
|
||||
let now = Instant::now();
|
||||
let mut u = Updater {
|
||||
url,
|
||||
current: crate::engine::VERSION.to_string(),
|
||||
app_dir,
|
||||
dir,
|
||||
auto,
|
||||
manifest: None,
|
||||
entry: None,
|
||||
file: None,
|
||||
staged: None,
|
||||
staged_digest: String::new(),
|
||||
busy: false,
|
||||
next_check: now + Duration::from_secs(first),
|
||||
ready_since: None,
|
||||
install_asked: false,
|
||||
pending: None,
|
||||
started: now,
|
||||
healthy_marked: false,
|
||||
failed_versions: Vec::new(),
|
||||
min_supported: String::new(),
|
||||
apply_launched: None,
|
||||
deferred_until: None,
|
||||
};
|
||||
u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::<Vec<String>>(&t).ok()).unwrap_or_default();
|
||||
if let Ok(text) = std::fs::read_to_string(u.dir.join("manifest.json")) {
|
||||
if let Ok(m) = manifest::parse(&text) {
|
||||
u.min_supported = m.min_supported_version.clone();
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.status = if u.url.is_empty() { "off".into() } else { "unknown".into() };
|
||||
st.settings.auto_update = auto;
|
||||
}
|
||||
u.settle_previous(shared);
|
||||
u.publish(shared);
|
||||
u
|
||||
}
|
||||
|
||||
fn failed_path(&self) -> PathBuf {
|
||||
self.app_dir.join("failed-versions.json")
|
||||
}
|
||||
|
||||
fn remember_failed(&mut self, shared: &Arc<Shared>, ver: &str) {
|
||||
if ver.is_empty() || self.failed_versions.iter().any(|v| v == ver) {
|
||||
return;
|
||||
}
|
||||
self.failed_versions.push(ver.to_string());
|
||||
let _ = std::fs::write(self.failed_path(), serde_json::to_string(&self.failed_versions).unwrap_or_default());
|
||||
shared.log(&format!("update: {ver} is marked failed; it will not be applied by itself again (Install now still can)"));
|
||||
}
|
||||
|
||||
/// On start: did we just update (or fail to)? Reports it, counts this start, and asks for a rollback when the
|
||||
/// new version keeps dying before it is healthy.
|
||||
fn settle_previous(&mut self, shared: &Arc<Shared>) {
|
||||
let pending = ota::read_pending(&self.app_dir);
|
||||
if let Some(r) = ota::read_result(&self.app_dir) {
|
||||
let _ = std::fs::remove_file(ota::result_path(&self.app_dir));
|
||||
if !r.ok && r.deferred {
|
||||
shared.log(&format!("OTA: the update to {} was deferred before this start ({}); it tries again", r.version, r.error));
|
||||
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
|
||||
} else if !r.ok {
|
||||
{
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.error = r.error.clone();
|
||||
st.update.status = "error".into();
|
||||
if r.rolled_back {
|
||||
st.update.rolled_back = format!("{}: {}", r.version, r.error);
|
||||
}
|
||||
}
|
||||
shared.event("error", &format!("update to {} failed: {}", r.version, r.error));
|
||||
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
|
||||
self.remember_failed(shared, &r.version);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(mut p) = pending else { return };
|
||||
if p.to == self.current {
|
||||
// we are the new version
|
||||
p.starts += 1;
|
||||
ota::write_pending(&self.app_dir, &p);
|
||||
if p.starts == 1 {
|
||||
shared.event("ok", &format!("updated to Igneum Wallet {} from {}", p.to, p.from));
|
||||
shared.state.lock().unwrap().update.updated_from = p.from.clone();
|
||||
} else {
|
||||
shared.log(&format!("start {} of {} since the update from {}; healthy after {} s", p.starts, p.to, p.from, ota::HEALTHY_AFTER_S));
|
||||
}
|
||||
self.pending = Some(p);
|
||||
} else if p.from == self.current {
|
||||
// the old version runs again: the helper restored it, or the installer never ran
|
||||
shared.event("error", &format!("the update to {} did not take; still on {}", p.to, p.from));
|
||||
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
|
||||
self.remember_failed(shared, &p.to);
|
||||
} else {
|
||||
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this (new) version has died twice before reaching HEALTHY_AFTER_S: the engine rolls back and exits.
|
||||
pub fn needs_rollback(&self) -> bool {
|
||||
self.pending.as_ref().map(|p| p.starts >= 3).unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---- state for the window ------------------------------------------------------------------------------------
|
||||
|
||||
fn publish(&self, shared: &Arc<Shared>) {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
let u = &mut st.update;
|
||||
u.auto = self.auto;
|
||||
if let Some(m) = &self.manifest {
|
||||
u.version = m.version.clone();
|
||||
u.notes = m.notes.clone();
|
||||
u.unsupported = manifest::unsupported(m, &self.current);
|
||||
u.min_supported = m.min_supported_version.clone();
|
||||
}
|
||||
if let Some(e) = &self.entry {
|
||||
u.url = e.url.clone();
|
||||
u.size = e.size;
|
||||
}
|
||||
u.available = self.entry.is_some();
|
||||
u.downloaded = self.file.is_some();
|
||||
u.ready = self.staged.is_some();
|
||||
u.file = self.file.as_ref().map(|p| p.display().to_string()).unwrap_or_default();
|
||||
if u.status != "applying" && u.status != "manual" && u.status != "error" && u.status != "deferred" && u.status != "off" {
|
||||
u.status = if self.staged.is_some() {
|
||||
"ready".into()
|
||||
} else if self.file.is_some() {
|
||||
"staging".into()
|
||||
} else if self.entry.is_some() {
|
||||
if self.busy { "downloading".into() } else { "available".into() }
|
||||
} else if self.manifest.is_some() {
|
||||
"current".into()
|
||||
} else if u.status.is_empty() {
|
||||
"unknown".into()
|
||||
} else {
|
||||
u.status.clone()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn set_error(&mut self, shared: &Arc<Shared>, e: &str) {
|
||||
shared.log(&format!("update: {e}"));
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.error = e.to_string();
|
||||
st.update.status = "error".into();
|
||||
st.update.applying = false;
|
||||
st.update.wait = String::new();
|
||||
}
|
||||
|
||||
fn clear_error(&self, shared: &Arc<Shared>) {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.error = String::new();
|
||||
if st.update.status == "error" {
|
||||
st.update.status = "unknown".into();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_auto(&mut self, shared: &Arc<Shared>, on: bool) {
|
||||
self.auto = on;
|
||||
{
|
||||
let mut s = shared.settings.lock().unwrap();
|
||||
s.auto_update = on;
|
||||
s.save(&shared.paths.settings);
|
||||
}
|
||||
shared.state.lock().unwrap().settings.auto_update = on;
|
||||
shared.event("info", if on { "updates install by themselves when nothing is being sent" } else { "updates download but wait for Install now" });
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
// ---- the tick ------------------------------------------------------------------------------------------------
|
||||
|
||||
pub fn tick(&mut self, shared: &Arc<Shared>, ctx: &Ctx) -> Option<Action> {
|
||||
let now = Instant::now();
|
||||
// the new version is healthy once it has run this long: the update is complete, the leftovers can go
|
||||
if !self.healthy_marked && self.pending.is_some() && now.duration_since(self.started) >= Duration::from_secs(ota::HEALTHY_AFTER_S) {
|
||||
self.healthy_marked = true;
|
||||
let p = self.pending.take().unwrap();
|
||||
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
|
||||
let _ = std::fs::remove_file(ota::result_path(&self.app_dir)); // the helper's "ok" lands after this engine started
|
||||
shared.log(&format!("update to {} complete (from {}); keeping the previous version for a rollback", p.to, p.from));
|
||||
self.tidy();
|
||||
}
|
||||
if now >= self.next_check && !self.busy && self.staged.is_none() {
|
||||
self.start_check(shared);
|
||||
}
|
||||
if self.busy {
|
||||
if let (Some(e), None) = (&self.entry, &self.file) {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
if st.update.status == "downloading" {
|
||||
st.update.progress = fetch::progress(e, &self.dir);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let urgent = self.urgent();
|
||||
{
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.urgent = urgent;
|
||||
st.update.urgent_text = if urgent {
|
||||
format!("Igneum Wallet {} is no longer supported; the network needs {} or newer.", self.current, self.min_supported)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
}
|
||||
if self.staged.is_none() {
|
||||
return None;
|
||||
}
|
||||
// Windows: the installer was started with the engine still running; it stops us when it may run. Until then
|
||||
// watch for the helper's verdict (an unanswered administrator prompt).
|
||||
if let Some(t) = self.apply_launched {
|
||||
match ota::read_result(&self.app_dir) {
|
||||
Some(r) if !r.ok => {
|
||||
let _ = std::fs::remove_file(ota::result_path(&self.app_dir));
|
||||
self.defer(shared, &r.error);
|
||||
}
|
||||
Some(_) => {} // the installer is in: it stops this engine any moment now
|
||||
None if now.duration_since(t) >= Duration::from_secs(15 * 60) => self.defer(shared, "no answer from the installer in 15 minutes"),
|
||||
None => {}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(u) = self.deferred_until {
|
||||
if now < u && !self.install_asked {
|
||||
return None;
|
||||
}
|
||||
self.deferred_until = None;
|
||||
shared.state.lock().unwrap().update.status = "ready".into();
|
||||
}
|
||||
let v = self.version();
|
||||
let failed_before = self.failed_versions.iter().any(|f| f == &v);
|
||||
match safe_to_apply(ctx, self.auto, self.install_asked, urgent, failed_before) {
|
||||
Ok(()) => Some(Action::Apply),
|
||||
Err(why) => {
|
||||
shared.state.lock().unwrap().update.wait = why;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: the installer could not run (the administrator prompt was declined, timed out, or nobody was there).
|
||||
fn defer(&mut self, shared: &Arc<Shared>, err: &str) {
|
||||
self.apply_launched = None;
|
||||
self.install_asked = false;
|
||||
self.deferred_until = Some(Instant::now() + Duration::from_secs(6 * 3600));
|
||||
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
|
||||
let v = self.version();
|
||||
{
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.applying = false;
|
||||
st.update.status = "deferred".into();
|
||||
st.update.wait = "waits for the next time someone is at this PC (Windows asks for permission)".into();
|
||||
}
|
||||
shared.event("info", &format!("OTA: administrator approval not given for Igneum Wallet {v} ({err}); the update waits for the next time someone is at this PC"));
|
||||
}
|
||||
|
||||
fn urgent(&self) -> bool {
|
||||
match &self.manifest {
|
||||
Some(m) => self.entry.is_some() && manifest::unsupported(m, &self.current),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// After a completed update: the downloads of older versions go; the installer of the version now running stays
|
||||
/// on Windows (the rollback target of the next update); a failed bundle from an earlier rollback goes on macOS.
|
||||
fn tidy(&self) {
|
||||
if let Ok(rd) = std::fs::read_dir(&self.dir) {
|
||||
for e in rd.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
let keep = cfg!(windows) && name.contains(&self.current) && name.ends_with(".exe");
|
||||
if !keep && name != "manifest.json" && name != "manifest.json.sig" {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(b) = igneum_common::platform::bundle_path() {
|
||||
let failed = PathBuf::from(format!("{}.failed", b.display()));
|
||||
if failed.exists() {
|
||||
let _ = std::fs::remove_dir_all(&failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- check ---------------------------------------------------------------------------------------------------
|
||||
|
||||
pub fn check_now(&mut self, shared: &Arc<Shared>) {
|
||||
if self.busy {
|
||||
return;
|
||||
}
|
||||
self.clear_error(shared);
|
||||
self.start_check(shared);
|
||||
}
|
||||
|
||||
fn start_check(&mut self, shared: &Arc<Shared>) {
|
||||
let every = std::env::var("IGNEUM_WALLET_UPDATE_CHECK_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(CHECK_EVERY_S);
|
||||
self.next_check = Instant::now() + Duration::from_secs(every);
|
||||
if self.url.is_empty() {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.status = "off".into();
|
||||
st.update.checked_at = igneum_common::platform::unix_now_f();
|
||||
return;
|
||||
}
|
||||
self.busy = true;
|
||||
shared.state.lock().unwrap().update.status = "checking".into();
|
||||
let url = self.url.clone();
|
||||
let dir = self.dir.clone();
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = fetch::fetch_manifest(&url, &dir);
|
||||
shared2.send(Cmd::Ota(Event::Checked(r)));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn event(&mut self, shared: &Arc<Shared>, ev: Event) {
|
||||
self.busy = false;
|
||||
match ev {
|
||||
Event::Checked(r) => {
|
||||
shared.state.lock().unwrap().update.checked_at = igneum_common::platform::unix_now_f();
|
||||
match r {
|
||||
Err(e) => {
|
||||
self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S);
|
||||
if self.manifest.is_none() {
|
||||
self.set_error(shared, &e);
|
||||
} else {
|
||||
shared.log(&format!("update check: {e}; keeping the last manifest"));
|
||||
}
|
||||
}
|
||||
Ok(m) => {
|
||||
self.clear_error(shared);
|
||||
let entry = match plan(&m, &self.current) {
|
||||
Plan::Update(e) => Some(e),
|
||||
Plan::NoBuild(v) => {
|
||||
shared.log(&format!("update check: {v} is published but has no {} build yet", manifest::platform_name()));
|
||||
None
|
||||
}
|
||||
Plan::Current => {
|
||||
shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version));
|
||||
None
|
||||
}
|
||||
};
|
||||
let changed = self.entry != entry;
|
||||
if entry.is_none() {
|
||||
self.entry = None;
|
||||
self.file = None;
|
||||
self.staged = None;
|
||||
self.ready_since = None;
|
||||
}
|
||||
self.manifest = Some(m.clone());
|
||||
self.min_supported = m.min_supported_version.clone();
|
||||
if let Some(e) = entry {
|
||||
if changed {
|
||||
self.file = None;
|
||||
self.staged = None;
|
||||
self.ready_since = None;
|
||||
shared.event("info", &format!("Igneum Wallet {} is available: downloading ({} MB){}", m.version, e.size / 1_000_000, if m.notes.is_empty() { String::new() } else { format!(". {}", m.notes) }));
|
||||
}
|
||||
self.entry = Some(e);
|
||||
if self.staged.is_none() {
|
||||
self.start_download(shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Downloaded(r) => match r {
|
||||
Err(e) => {
|
||||
self.set_error(shared, &format!("download failed: {e}"));
|
||||
self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S);
|
||||
}
|
||||
Ok(p) => {
|
||||
self.clear_error(shared);
|
||||
shared.log(&format!("update: {} downloaded and verified", p.display()));
|
||||
self.file = Some(p.clone());
|
||||
self.publish(shared);
|
||||
self.start_stage(shared, p);
|
||||
}
|
||||
},
|
||||
Event::Staged(r) => match r {
|
||||
Err(e) if e.starts_with("manual:") => {
|
||||
let why = e.trim_start_matches("manual:").trim().to_string();
|
||||
{
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.status = "manual".into();
|
||||
st.update.wait = why.clone();
|
||||
}
|
||||
shared.event("info", &format!("update downloaded; {why}"));
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_error(shared, &format!("could not prepare the update: {e}"));
|
||||
self.file = None;
|
||||
self.next_check = Instant::now() + Duration::from_secs(RETRY_AFTER_ERROR_S);
|
||||
}
|
||||
Ok(p) => {
|
||||
self.clear_error(shared);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.staged_digest = manifest::digest_dir(&p).unwrap_or_default();
|
||||
shared.log(&format!("update: staged bundle digest {}", self.staged_digest));
|
||||
}
|
||||
self.staged = Some(p);
|
||||
self.ready_since = Some(Instant::now());
|
||||
let v = self.version();
|
||||
{
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.wait = if self.auto { "installs as soon as nothing is being sent".into() } else { "waiting for Install now".into() };
|
||||
st.update.progress = 1.0;
|
||||
}
|
||||
shared.event("ok", &format!("Igneum Wallet {v} is ready; {}", if self.auto { "it installs as soon as nothing is being sent" } else { "automatic updates are off, so it waits for Install now" }));
|
||||
}
|
||||
},
|
||||
}
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
fn start_download(&mut self, shared: &Arc<Shared>) {
|
||||
let Some(e) = self.entry.clone() else { return };
|
||||
self.busy = true;
|
||||
{
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.update.status = "downloading".into();
|
||||
st.update.progress = 0.0;
|
||||
}
|
||||
let dir = self.dir.clone();
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = fetch::download(&e, &dir);
|
||||
shared2.send(Cmd::Ota(Event::Downloaded(r)));
|
||||
});
|
||||
}
|
||||
|
||||
fn start_stage(&mut self, shared: &Arc<Shared>, file: PathBuf) {
|
||||
let Some(e) = self.entry.clone() else { return };
|
||||
let version = self.version();
|
||||
self.busy = true;
|
||||
shared.state.lock().unwrap().update.status = "staging".into();
|
||||
let dir = self.dir.clone();
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = ota::stage(crate::engine::APP, &e, &file, &dir, &version);
|
||||
shared2.send(Cmd::Ota(Event::Staged(r)));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the user's buttons ----------------------------------------------------------------------------------------
|
||||
|
||||
/// Install now: a ready update applies at once; a downloaded one as soon as it is staged; else a check runs.
|
||||
pub fn install_now(&mut self, shared: &Arc<Shared>) {
|
||||
self.install_asked = true;
|
||||
self.deferred_until = None;
|
||||
if self.apply_launched.is_some() {
|
||||
return; // the installer is already up (its prompt may be waiting on the screen)
|
||||
}
|
||||
if self.staged.is_some() {
|
||||
shared.state.lock().unwrap().update.wait = "installing now".into();
|
||||
return;
|
||||
}
|
||||
if self.busy {
|
||||
return;
|
||||
}
|
||||
self.clear_error(shared);
|
||||
if let Some(f) = self.file.clone() {
|
||||
self.start_stage(shared, f);
|
||||
} else if self.entry.is_some() {
|
||||
self.start_download(shared);
|
||||
} else {
|
||||
self.start_check(shared);
|
||||
}
|
||||
}
|
||||
|
||||
/// The manual path: open the downloaded disk image or installer for the user.
|
||||
pub fn open_file(&self) -> Result<(), String> {
|
||||
let f = self.file.as_ref().ok_or("nothing downloaded yet")?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let r = std::process::Command::new(igneum_common::platform::tool("open")).arg(f).spawn();
|
||||
#[cfg(windows)]
|
||||
let r = igneum_common::platform::quiet(&mut std::process::Command::new(igneum_common::platform::tool("cmd"))).args(["/c", "start", "", &f.display().to_string()]).spawn();
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let r = std::process::Command::new("xdg-open").arg(f).spawn();
|
||||
r.map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ---- apply ---------------------------------------------------------------------------------------------------
|
||||
|
||||
pub fn version(&self) -> String {
|
||||
self.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Re-verifies the download and the staged bundle, writes update-pending.json, starts the helper. macOS: the
|
||||
/// engine exits right after (Launch::QuitNow). Windows: the installer runs first (Launch::InstallerRunning).
|
||||
pub fn launch_apply(&mut self, shared: &Arc<Shared>, host_pid: u32) -> Result<Launch, String> {
|
||||
let staged = self.staged.clone().ok_or("no update is ready")?;
|
||||
let to = self.version();
|
||||
let entry = self.entry.clone().ok_or("no manifest entry")?;
|
||||
if let Some(f) = &self.file {
|
||||
let sum = manifest::sha256_file(f).map_err(|e| format!("cannot hash the download: {e}"))?;
|
||||
if sum != entry.sha256 {
|
||||
self.staged = None;
|
||||
self.file = None;
|
||||
return Err("the downloaded file no longer matches the manifest's sha256; it is discarded".into());
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let d = manifest::digest_dir(&staged).map_err(|e| format!("cannot digest the staged app: {e}"))?;
|
||||
if d != self.staged_digest || d.is_empty() {
|
||||
let _ = std::fs::remove_dir_all(&staged);
|
||||
self.staged = None;
|
||||
return Err("the staged app changed since it was verified; it is discarded".into());
|
||||
}
|
||||
}
|
||||
let previous_installer = if cfg!(windows) { self.dir.join(ota::installer_name_for(crate::engine::APP, &self.current)).to_string_lossy().into_owned() } else { String::new() };
|
||||
let previous_installer = if Path::new(&previous_installer).is_file() { previous_installer } else { String::new() };
|
||||
let env_file = ota::write_env_file(&self.app_dir, ENV_PREFIXES);
|
||||
let a = ota::Apply { app: crate::engine::APP, app_dir: &self.app_dir, current: &self.current, version: &to, staged: &staged, staged_digest: &self.staged_digest, sha256: &entry.sha256, host_pid, env_file, previous_installer };
|
||||
shared.log(&format!("update: starting the helper for {to} (host pid {host_pid}, staged {})", staged.display()));
|
||||
let launched = ota::launch_apply(&a)?;
|
||||
if launched == Launch::InstallerRunning {
|
||||
self.apply_launched = Some(Instant::now());
|
||||
}
|
||||
Ok(launched)
|
||||
}
|
||||
|
||||
/// The new version failed to start twice: restore the previous one through the helper and exit.
|
||||
pub fn launch_rollback(&mut self, shared: &Arc<Shared>, host_pid: u32) -> Result<(), String> {
|
||||
let p = self.pending.clone().ok_or("no update pending")?;
|
||||
// never below the network's minimum; this version stays and is marked failed so it is not re-applied
|
||||
if !self.min_supported.is_empty() && manifest::newer(&self.min_supported, &p.from) {
|
||||
let _ = std::fs::remove_file(ota::pending_path(&self.app_dir));
|
||||
self.pending = None;
|
||||
return Err(format!("not rolling back to {}: the network needs {} or newer; staying on {}", p.from, self.min_supported, p.to));
|
||||
}
|
||||
self.remember_failed(shared, &p.to);
|
||||
shared.event("error", &format!("Igneum Wallet {} did not stay up twice; restoring {}", p.to, p.from));
|
||||
let env_file = ota::write_env_file(&self.app_dir, ENV_PREFIXES);
|
||||
ota::launch_rollback(crate::engine::APP, &self.app_dir, &p, host_pid, env_file)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What packaging/ota/publish-manifest.sh --product wallet writes (canonical JSON, sorted keys, no whitespace).
|
||||
const WALLET_MANIFEST: &str = r#"{"channel":"devnet","consensus":{"activation_height":null,"deadline_note":""},"min_supported_version":"","notes":"coin on the home screen, updates install by themselves","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":19479807,"url":"https://dl.igneum.network/dl/t/Igneum-Wallet-0.1.1.dmg"}},"published_at":"2026-10-05T09:00:00Z","version":"0.1.1"}"#;
|
||||
|
||||
#[test]
|
||||
fn the_wallet_manifest_parses() {
|
||||
let m = manifest::parse(WALLET_MANIFEST).unwrap();
|
||||
assert_eq!(m.version, "0.1.1");
|
||||
assert_eq!(m.channel, "devnet");
|
||||
assert_eq!(m.activation_height, None);
|
||||
assert!(m.min_supported_version.is_empty());
|
||||
let mac = m.mac.as_ref().unwrap();
|
||||
assert_eq!(mac.kind, "dmg");
|
||||
assert_eq!(mac.size, 19479807);
|
||||
assert!(mac.url.ends_with("/Igneum-Wallet-0.1.1.dmg"));
|
||||
assert!(m.windows.is_none());
|
||||
assert!(m.notes.contains("coin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_the_wallet_will_see() {
|
||||
assert!(manifest::newer("0.1.1", "0.1.0"));
|
||||
assert!(manifest::newer("0.1.10", "0.1.9"));
|
||||
assert!(manifest::newer("0.2.0", "0.1.11"));
|
||||
assert!(!manifest::newer("0.1.0", "0.1.0"));
|
||||
assert!(!manifest::newer("0.1.0", "0.1.1"));
|
||||
assert!(manifest::newer("0.1.1", "0.1.1-rc1"));
|
||||
assert!(!manifest::newer("latest", "0.1.0"));
|
||||
assert!(!manifest::newer("", "0.1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plan_follows_the_version_and_the_platform() {
|
||||
let m = manifest::parse(WALLET_MANIFEST).unwrap();
|
||||
match plan(&m, "0.1.0") {
|
||||
Plan::Update(e) if manifest::platform_name() == "mac" => assert_eq!(e.size, 19479807),
|
||||
Plan::NoBuild(v) if manifest::platform_name() != "mac" => assert_eq!(v, "0.1.1"),
|
||||
other => panic!("unexpected plan {other:?}"),
|
||||
}
|
||||
assert_eq!(plan(&m, "0.1.1"), Plan::Current);
|
||||
assert_eq!(plan(&m, "0.2.0"), Plan::Current);
|
||||
// a newer version without any platform entry: nothing to download
|
||||
let none = manifest::parse(r#"{"version":"0.1.2","platforms":{}}"#).unwrap();
|
||||
assert_eq!(plan(&none, "0.1.1"), Plan::NoBuild("0.1.2".into()));
|
||||
// a tampered manifest fails before any plan is made
|
||||
assert!(manifest::verify_and_parse(WALLET_MANIFEST.as_bytes(), &"00".repeat(64), manifest::OTA_PUBLIC_KEY_HEX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_moments() {
|
||||
let quiet = Ctx { send_in_flight: false, creating: false };
|
||||
let sending = Ctx { send_in_flight: true, creating: false };
|
||||
let creating = Ctx { send_in_flight: false, creating: true };
|
||||
assert!(safe_to_apply(&quiet, true, false, false, false).is_ok());
|
||||
assert_eq!(safe_to_apply(&sending, true, false, false, false).unwrap_err(), "a send is in flight; installing after it");
|
||||
assert!(safe_to_apply(&creating, true, false, false, false).unwrap_err().contains("being created"));
|
||||
// automatic updates off: only Install now (or an unsupported version) applies
|
||||
assert!(safe_to_apply(&quiet, false, false, false, false).unwrap_err().contains("Install now"));
|
||||
assert!(safe_to_apply(&quiet, false, true, false, false).is_ok());
|
||||
assert!(safe_to_apply(&quiet, false, false, true, false).is_ok());
|
||||
// Install now and an unsupported version beat a send in flight
|
||||
assert!(safe_to_apply(&sending, true, true, false, false).is_ok());
|
||||
assert!(safe_to_apply(&sending, true, false, true, false).is_ok());
|
||||
// a version that failed before waits for Install now
|
||||
assert!(safe_to_apply(&quiet, true, false, false, true).unwrap_err().contains("failed"));
|
||||
assert!(safe_to_apply(&quiet, true, true, false, true).is_ok());
|
||||
}
|
||||
}
|
||||
153
app/igneum-wallet/src/vault.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! The encrypted key file: `<data root>/wallet/vault.json`. The secret (the 32-byte private key and, when the wallet
|
||||
//! was made or imported from words, the 24-word phrase) is sealed with XChaCha20-Poly1305 under a key derived from
|
||||
//! the password with Argon2id (64 MiB, 3 passes). The password is never written anywhere; the plaintext only ever
|
||||
//! exists in the engine's memory and is zeroed when the wallet locks.
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
pub const M_KIB: u32 = 65_536;
|
||||
pub const T_COST: u32 = 3;
|
||||
pub const P_COST: u32 = 1;
|
||||
|
||||
/// What the vault seals. Zeroed on drop.
|
||||
#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct Secret {
|
||||
/// 0x + 64 hex, secp256k1
|
||||
pub private_key: String,
|
||||
/// the 24 words, space separated; None for a raw key import
|
||||
pub mnemonic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Kdf {
|
||||
pub algo: String,
|
||||
pub m_kib: u32,
|
||||
pub t: u32,
|
||||
pub p: u32,
|
||||
pub salt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Vault {
|
||||
pub version: u32,
|
||||
pub address: String, // 0x + 40 lower-case hex, in the clear: the window shows it while locked
|
||||
pub source: String, // created | seed | key | miner
|
||||
pub created: u64,
|
||||
pub kdf: Kdf,
|
||||
pub cipher: String,
|
||||
pub nonce: String,
|
||||
pub ciphertext: String,
|
||||
/// The one-time backup sheet (the words or the key) was confirmed.
|
||||
#[serde(default)]
|
||||
pub backed_up: bool,
|
||||
}
|
||||
|
||||
fn derive(password: &str, salt: &[u8], kdf: &Kdf) -> Result<[u8; 32], String> {
|
||||
let params = Params::new(kdf.m_kib, kdf.t, kdf.p, Some(32)).map_err(|e| e.to_string())?;
|
||||
let a = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut key = [0u8; 32];
|
||||
a.hash_password_into(password.as_bytes(), salt, &mut key).map_err(|e| e.to_string())?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub fn seal(secret: &Secret, password: &str, address: &str, source: &str) -> Result<Vault, String> {
|
||||
if password.len() < 8 {
|
||||
return Err("the password needs at least 8 characters".into());
|
||||
}
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce = [0u8; 24];
|
||||
getrandom::getrandom(&mut salt).map_err(|e| e.to_string())?;
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| e.to_string())?;
|
||||
let kdf = Kdf { algo: "argon2id".into(), m_kib: M_KIB, t: T_COST, p: P_COST, salt: igneum_common::keys::hex(&salt) };
|
||||
let mut key = derive(password, &salt, &kdf)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let mut plain = serde_json::to_vec(secret).map_err(|e| e.to_string())?;
|
||||
let ct = cipher.encrypt(XNonce::from_slice(&nonce), plain.as_ref()).map_err(|_| "encryption failed".to_string());
|
||||
plain.zeroize();
|
||||
key.zeroize();
|
||||
let ct = ct?;
|
||||
Ok(Vault {
|
||||
version: 1,
|
||||
address: address.to_ascii_lowercase(),
|
||||
source: source.into(),
|
||||
created: igneum_common::platform::unix_now(),
|
||||
kdf,
|
||||
cipher: "xchacha20poly1305".into(),
|
||||
nonce: igneum_common::keys::hex(&nonce),
|
||||
ciphertext: igneum_common::keys::hex(&ct),
|
||||
backed_up: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open(v: &Vault, password: &str) -> Result<Secret, String> {
|
||||
if v.kdf.algo != "argon2id" || v.cipher != "xchacha20poly1305" {
|
||||
return Err("this vault was written by a newer wallet".into());
|
||||
}
|
||||
let salt = igneum_common::keys::unhex(&v.kdf.salt).ok_or("vault salt is not hex")?;
|
||||
let nonce = igneum_common::keys::unhex(&v.nonce).ok_or("vault nonce is not hex")?;
|
||||
let ct = igneum_common::keys::unhex(&v.ciphertext).ok_or("vault ciphertext is not hex")?;
|
||||
let mut key = derive(password, &salt, &v.kdf)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let plain = cipher.decrypt(XNonce::from_slice(&nonce), ct.as_ref());
|
||||
key.zeroize();
|
||||
let mut plain = plain.map_err(|_| "wrong password".to_string())?;
|
||||
let s: Result<Secret, _> = serde_json::from_slice(&plain);
|
||||
plain.zeroize();
|
||||
s.map_err(|_| "the vault did not decode".to_string())
|
||||
}
|
||||
|
||||
pub fn save(path: &Path, v: &Vault) -> Result<(), String> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
igneum_common::platform::lock_permissions(dir, true);
|
||||
}
|
||||
let text = serde_json::to_string_pretty(v).map_err(|e| e.to_string())?;
|
||||
let tmp = path.with_extension("json.new");
|
||||
std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
|
||||
igneum_common::platform::lock_permissions(&tmp, false);
|
||||
std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
|
||||
igneum_common::platform::lock_permissions(path, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Option<Vault> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_wrong_password() {
|
||||
let s = Secret { private_key: "0x0000000000000000000000000000000000000000000000000000000000000001".into(), mnemonic: Some("abandon abandon about".into()) }; // no-secrets-ok: the test's throwaway key (0x..01), not a secret
|
||||
let v = seal(&s, "correct horse", "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf", "created").unwrap();
|
||||
assert_eq!(v.kdf.algo, "argon2id");
|
||||
let back = open(&v, "correct horse").unwrap();
|
||||
assert_eq!(back.private_key, s.private_key);
|
||||
assert_eq!(back.mnemonic, s.mnemonic);
|
||||
assert_eq!(open(&v, "correct horsf").err().unwrap(), "wrong password");
|
||||
// a flipped ciphertext byte fails the tag
|
||||
let mut bad = v.clone();
|
||||
let mut ct = igneum_common::keys::unhex(&bad.ciphertext).unwrap();
|
||||
ct[3] ^= 1;
|
||||
bad.ciphertext = igneum_common::keys::hex(&ct);
|
||||
assert!(open(&bad, "correct horse").is_err());
|
||||
// the JSON round trip keeps the fields
|
||||
let text = serde_json::to_string(&v).unwrap();
|
||||
let v2: Vault = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(open(&v2, "correct horse").unwrap().private_key, s.private_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_password_refused() {
|
||||
let s = Secret { private_key: "0x01".into(), mnemonic: None };
|
||||
assert!(seal(&s, "short", "0x", "key").is_err());
|
||||
}
|
||||
}
|
||||
489
app/igneum-wallet/ui/app.css
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
/* Igneum Wallet window (wallet-ui-3, on the miner's system: app/igneum-app/ui/app.css, miner-ui-3). The site's tokens:
|
||||
obsidian, graphite, ember, molten, bone, ash; Unbounded for the page title, the balance and the row's amount; IBM Plex
|
||||
Sans for words; IBM Plex Mono for labels, units, addresses and small numbers. Fonts ship in the binary. One type scale
|
||||
(--t-*), one spacing scale (--s-*), one accent (ember). Dark by default, light under prefers-color-scheme or
|
||||
[data-theme=light]. Layout: a rail on the left (five sections), a thin top bar, the status strip under it, the page in
|
||||
the middle. The window lays out from 900 x 620 (the hosts' minimum); under 720 px the rail becomes a bottom tab bar.
|
||||
Nothing here is newer than 2022 CSS (the WebView2 host). */
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexSans-400.woff2) format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexSans-500.woff2) format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(fonts/IBMPlexSans-600.woff2) format('woff2')}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/Unbounded-500.woff2) format('woff2')}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')}
|
||||
|
||||
:root{
|
||||
/* colour, dark */
|
||||
--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
|
||||
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--qr-bg:#F4F1EC;
|
||||
/* type scale */
|
||||
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:17px;--t-num:24px;--t-hero:34px;--t-h2:28px;--t-h1:44px;
|
||||
/* spacing scale */
|
||||
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px;
|
||||
--gutter:var(--s-6);--card-pad:var(--s-5);--card-r:16px;--row-r:12px;--gap:var(--s-4);
|
||||
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
|
||||
--top:60px;--bottom:0px;--rail:196px}
|
||||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){
|
||||
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
|
||||
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--qr-bg:#FFFFFF}}
|
||||
:root[data-theme="light"]{
|
||||
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
|
||||
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--qr-bg:#FFFFFF}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
|
||||
.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
||||
.dim{color:var(--ash)}
|
||||
h1,h2,h3{font-family:var(--head);margin:0;line-height:1.1;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em}
|
||||
h2{font-weight:700;font-size:var(--t-h2)}
|
||||
h3{font-weight:700;font-size:var(--t-xl)}
|
||||
p{margin:0}
|
||||
a{color:inherit}
|
||||
button{font:inherit;color:inherit}
|
||||
input,textarea,select{font-variant-numeric:tabular-nums}
|
||||
.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.16em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap}
|
||||
.eyebrow.ember{color:var(--ember)}
|
||||
[hidden]{display:none !important}
|
||||
::selection{background:rgba(242,84,27,.45)}
|
||||
|
||||
/* buttons */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:40px;padding:8px 18px;border-radius:10px;font-weight:600;font-size:var(--t-md);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap}
|
||||
.btn:hover{transform:translateY(-1px);border-color:var(--ash)}
|
||||
.btn:active{transform:none}
|
||||
.btn:disabled{opacity:.4;cursor:default;transform:none}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:var(--ember-hi);border-color:var(--ember-hi)}
|
||||
.btn.big{min-height:52px;padding:12px 28px;font-size:var(--t-xl)}
|
||||
.btn.small{min-height:34px;padding:6px 14px;font-size:var(--t-base);border-radius:8px}
|
||||
.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500}
|
||||
.btn.ghost{border-color:transparent;color:var(--ink-2)}
|
||||
.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)}
|
||||
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
|
||||
.btn.fp svg{flex:0 0 auto}
|
||||
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
|
||||
|
||||
/* the rail */
|
||||
.rail{position:fixed;top:0;left:0;bottom:0;width:var(--rail);background:var(--rail-bg);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:22;padding:14px 12px 14px;-webkit-app-region:drag}
|
||||
.rail button{-webkit-app-region:no-drag}
|
||||
.rail-brand{display:flex;align-items:center;gap:10px;padding:6px 10px 18px;min-width:0}
|
||||
body.mac .rail-brand{padding-top:30px}
|
||||
.rail-brand .word{font-family:var(--head);font-weight:900;font-size:17px;letter-spacing:.06em}
|
||||
.rail-nav{display:flex;flex-direction:column;gap:3px}
|
||||
.nav{position:relative;display:flex;align-items:center;gap:12px;width:100%;min-height:42px;padding:8px 12px;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--ink-2);font-weight:500;font-size:var(--t-md);text-align:left;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease}
|
||||
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
|
||||
.nav:hover{background:var(--hover);color:var(--bone)}
|
||||
.nav:hover svg{color:var(--ink-2)}
|
||||
.nav.on{background:var(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600}
|
||||
.nav.on svg{color:var(--ember)}
|
||||
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
|
||||
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
|
||||
.nav.small svg{width:16px;height:16px;flex-basis:16px}
|
||||
.nav.danger:hover{color:var(--ember)}
|
||||
.nav.danger:hover svg{color:var(--ember)}
|
||||
.nav-dot{position:absolute;right:12px;top:50%;width:7px;height:7px;margin-top:-3px;border-radius:50%;background:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
|
||||
.rail-foot{margin-top:auto;display:flex;flex-direction:column;gap:2px;padding-top:12px;border-top:1px solid var(--line)}
|
||||
.rail-status{font-size:var(--t-xs);color:var(--ash);padding:0 12px 10px;line-height:1.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rail-status b{color:var(--ink-2);font-weight:500}
|
||||
.rail-version{font-size:var(--t-xs);color:var(--ash);padding:8px 12px 0;letter-spacing:.06em}
|
||||
|
||||
/* top bar */
|
||||
.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:var(--top-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line);z-index:20;-webkit-app-region:drag}
|
||||
.top button,.top .pill{-webkit-app-region:no-drag}
|
||||
body.mac:not(.has-rail) .top{padding-left:92px}
|
||||
body.has-rail .top{left:var(--rail)}
|
||||
.brand{display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
|
||||
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
|
||||
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
|
||||
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
|
||||
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
|
||||
.narrow-only{display:none}
|
||||
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
|
||||
.pill.on{color:var(--molten);border-color:var(--molten-40)}
|
||||
.pill.warn{color:var(--ember)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
|
||||
.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite}
|
||||
.warn .dot,.dot.bad{background:var(--ember);animation:none}
|
||||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}}
|
||||
|
||||
/* the status strip under the top bar (the update line): takes no room while empty */
|
||||
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
|
||||
body.has-rail .notices{left:var(--rail)}
|
||||
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)}
|
||||
.notice.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
|
||||
.notice-text{flex:1 1 320px;min-width:0}
|
||||
.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
|
||||
.notice-actions:empty{display:none}
|
||||
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
|
||||
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
|
||||
.notice.urgent .notice-close{color:#fff}
|
||||
.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px}
|
||||
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
|
||||
|
||||
/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */
|
||||
.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none}
|
||||
body.has-rail .ask-wrap{left:var(--rail)}
|
||||
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--graphite);border:1px solid var(--ember-40);border-radius:14px;padding:12px 16px;box-shadow:0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
|
||||
.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0;line-height:1.45}
|
||||
.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none;max-width:none}
|
||||
.ask-foot{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);min-height:0}
|
||||
.ask-foot:empty{display:none}
|
||||
.ask .short-pw{width:200px;flex:0 0 200px;margin-top:0}
|
||||
|
||||
/* screens and pages */
|
||||
main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
|
||||
@media (prefers-reduced-motion:reduce){main{transition:none}}
|
||||
body.has-rail main{left:var(--rail)}
|
||||
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
|
||||
body[data-phase="welcome"] #screen-welcome,body[data-phase="create"] #screen-create,body[data-phase="import"] #screen-import,body[data-phase="unlock"] #screen-unlock,body[data-phase="home"] #screen-home{display:block}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}}
|
||||
#screen-home{padding:22px 0 32px}
|
||||
.page{display:flex;flex-direction:column;gap:var(--gap);animation:rise .3s ease}
|
||||
|
||||
/* welcome */
|
||||
.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-6) 0 48px}
|
||||
.mark-wrap{position:relative;width:120px;height:120px;border-radius:26px;background:#0C0C0E;display:flex;align-items:center;justify-content:center;margin-bottom:6px;box-shadow:0 20px 50px rgba(242,84,27,.25)}
|
||||
.mark-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite;z-index:-1}
|
||||
@keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
|
||||
@media (prefers-reduced-motion:reduce){.mark-wrap::before{animation:none}}
|
||||
.lead{font-size:var(--t-xl);color:var(--ink-2);max-width:54ch}
|
||||
.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-3);width:100%;max-width:860px;margin-top:10px;text-align:left}
|
||||
.tile{background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.tile .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;color:var(--ember)}
|
||||
.tile .t{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.25}
|
||||
.tile .s{font-size:var(--t-base);color:var(--ash);line-height:1.45}
|
||||
.cta{display:flex;flex-wrap:wrap;gap:var(--s-3);align-items:center;justify-content:center;margin-top:10px}
|
||||
.seedline{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em}
|
||||
|
||||
/* steps (create, import) */
|
||||
.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:var(--s-4)}
|
||||
.step .sub{font-size:var(--t-xl);color:var(--ink-2);max-width:60ch}
|
||||
.step .cta{justify-content:flex-start;margin-top:var(--s-2)}
|
||||
.note{font-size:var(--t-base);color:var(--ash);line-height:1.5}
|
||||
.note.small{font-size:var(--t-sm);word-break:break-all}
|
||||
.help{font-size:var(--t-base);color:var(--ash);line-height:1.5;max-width:64ch}
|
||||
.line-text{font-size:var(--t-md);color:var(--ink-2);line-height:1.5}
|
||||
.line-text.ok{color:var(--molten)}
|
||||
.line-text.bad{color:var(--ember)}
|
||||
.err{font-size:var(--t-base);color:var(--ember);line-height:1.5}
|
||||
.err:empty{display:none}
|
||||
.top-gap{margin-top:var(--s-4)}
|
||||
.indent{margin-left:52px}
|
||||
.field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px}
|
||||
.field .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.field input,.field textarea,.addr-input{font:inherit;color:var(--bone);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;min-height:42px;font-size:var(--t-md);user-select:text;-webkit-user-select:text}
|
||||
.field textarea{font-family:var(--mono);font-size:var(--t-md);line-height:1.5;resize:vertical}
|
||||
.field input.mono,.addr-input.mono{font-family:var(--mono)}
|
||||
.field input:focus,.field textarea:focus,.addr-input:focus,.select:focus{outline:none;border-color:var(--ember)}
|
||||
.addr-input{width:100%;min-width:0}
|
||||
.row .addr-input{flex:1 1 180px;width:auto}
|
||||
.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:var(--t-base);word-break:break-all;user-select:text;-webkit-user-select:text}
|
||||
.box span{flex:1;min-width:0}
|
||||
.box.key{color:var(--molten)}
|
||||
.words{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:8px 0 0;padding:0;list-style:none;counter-reset:w}
|
||||
.words li{counter-increment:w;background:var(--graphite);border:1px solid var(--line);border-radius:10px;padding:8px 10px;font-family:var(--mono);font-size:var(--t-md);display:flex;gap:8px;align-items:baseline;user-select:text;-webkit-user-select:text}
|
||||
.words li::before{content:counter(w);color:var(--ash);font-size:var(--t-xs);min-width:18px;text-align:right}
|
||||
.words.small{grid-template-columns:repeat(6,minmax(0,1fr))}
|
||||
.words.small li{font-size:var(--t-sm);padding:5px 8px;background:var(--obsidian)}
|
||||
.checks{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
|
||||
.checks label{display:flex;flex-direction:column;gap:6px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
|
||||
.checks input{font:inherit;font-family:var(--mono);font-size:var(--t-lg);color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;user-select:text;-webkit-user-select:text}
|
||||
.checks input:focus{outline:none;border-color:var(--ember)}
|
||||
|
||||
/* cards, rows, disclosures, switches, the segmented control, the kv */
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0}
|
||||
.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap}
|
||||
.row{display:flex;gap:10px;align-items:center;min-width:0}
|
||||
.row.wrap{flex-wrap:wrap}
|
||||
.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)}
|
||||
.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px}
|
||||
.lead-text h3{font-size:var(--t-xl)}
|
||||
.disclose{display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);width:100%;border:0;background:transparent;padding:0;cursor:pointer;text-align:left;color:var(--bone);font-size:var(--t-md);font-weight:500;min-height:32px}
|
||||
.disclose .chev{color:var(--ash);flex:0 0 auto;margin-right:4px}
|
||||
.disclose-right{display:flex;align-items:center;gap:var(--s-3);min-width:0}
|
||||
.disclose-right .dim{font-size:var(--t-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;max-width:52ch}
|
||||
.chev{width:9px;height:9px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg) translateY(-2px);transition:transform .15s ease;display:inline-block}
|
||||
.open .chev,[aria-expanded="true"] .chev{transform:rotate(225deg) translateY(-2px)}
|
||||
.card .disclose+.disclose,.card .details+.disclose,.card .srow+.disclose,.card .switch+.disclose,.card .err+.disclose{margin-top:var(--s-3);padding-top:var(--s-3);border-top:1px solid var(--line)}
|
||||
.details{padding-top:var(--s-3);display:flex;flex-direction:column;gap:var(--s-3)}
|
||||
.srow{display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:9px 0;min-width:0}
|
||||
.srow+.srow,.switch+.srow,.srow+.switch{border-top:1px solid var(--line)}
|
||||
.sw-text{min-width:0}
|
||||
.sw-text b{font-weight:600}
|
||||
.sw-text .dim{font-size:var(--t-base)}
|
||||
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4}
|
||||
.switch+.switch{border-top:1px solid var(--line)}
|
||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||||
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease;margin-top:1px}
|
||||
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#F4F1EC;transition:transform .15s ease}
|
||||
.switch input:checked+.track{background:var(--ember)}
|
||||
.switch input:checked+.track::after{transform:translateX(18px)}
|
||||
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
|
||||
.switch input:disabled+.track{opacity:.4}
|
||||
.seg{display:inline-flex;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:3px;gap:2px}
|
||||
.seg-b{border:0;background:transparent;color:var(--ash);font-size:var(--t-base);font-weight:500;padding:6px 14px;border-radius:8px;cursor:pointer;transition:background .15s ease,color .15s ease;white-space:nowrap}
|
||||
.seg-b:hover{color:var(--bone)}
|
||||
.seg-b.on{background:var(--graphite);color:var(--bone);box-shadow:0 1px 3px rgba(0,0,0,.25);font-weight:600}
|
||||
.seg-b:disabled{opacity:.4;cursor:default}
|
||||
.select{font:inherit;font-size:var(--t-md);color:var(--bone);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:7px 12px;min-height:38px;cursor:pointer}
|
||||
.kv{display:flex;flex-direction:column}
|
||||
.kv>div{display:grid;grid-template-columns:140px auto 1fr;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
|
||||
.kv>div:last-child{border-bottom:0}
|
||||
.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
|
||||
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;user-select:text;-webkit-user-select:text}
|
||||
.kv .v.ok{color:var(--molten)}
|
||||
.kv .v.warn{color:var(--ember)}
|
||||
.kv .v.dim{color:var(--ash)}
|
||||
.kv .m{font-size:var(--t-sm);color:var(--ash);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.card.adv{padding:0}
|
||||
.card.adv summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:var(--card-pad)}
|
||||
.card.adv summary::-webkit-details-marker{display:none}
|
||||
.card.adv summary::after{content:"+";font-family:var(--mono);color:var(--ash);font-size:18px;margin-left:auto}
|
||||
.card.adv[open] summary::after{content:"\2212"}
|
||||
.card.adv summary .eyebrow{margin-left:0}
|
||||
.card.adv>:not(summary){margin-left:var(--card-pad);margin-right:var(--card-pad)}
|
||||
.card.adv>:last-child{margin-bottom:var(--card-pad)}
|
||||
.card.adv>.note+.note{margin-top:6px}
|
||||
.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0}
|
||||
|
||||
/* Home: the balance first, its money line, the address, the two buttons */
|
||||
.hero-card{display:flex;flex-direction:column;gap:var(--s-3)}
|
||||
.bal-row{display:flex;align-items:baseline;gap:var(--s-4);flex-wrap:wrap;min-width:0}
|
||||
.bal{display:flex;align-items:baseline;gap:12px;min-width:0}
|
||||
.bal .v{font-family:var(--head);font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em;line-height:1;white-space:nowrap;user-select:text;-webkit-user-select:text}
|
||||
.bal .unit{font-size:var(--t-md);color:var(--ash);letter-spacing:.12em}
|
||||
.bal-line{color:var(--ash);font-size:var(--t-md)}
|
||||
.bal-line:empty{display:none}
|
||||
.money-line{font-size:var(--t-md);color:var(--ash);margin-top:-4px}
|
||||
.money-line b{color:var(--bone);font-family:var(--head);font-weight:700;font-size:var(--t-num);margin-right:6px}
|
||||
.addr-big{display:flex;align-items:center;gap:12px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:12px;padding:14px 16px;font-size:var(--t-lg);word-break:break-all;user-select:text;-webkit-user-select:text}
|
||||
.addr-big span{flex:1;min-width:0;color:var(--molten)}
|
||||
.actions{margin-top:var(--s-1)}
|
||||
.actions .note{margin-left:var(--s-2)}
|
||||
.node-line{display:inline-flex;align-items:center;gap:10px;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.node-card.bad .node-line{color:var(--ember)}
|
||||
|
||||
/* the history row: kind and who, the amount, the state word with its reason, the chevron; the details under */
|
||||
.hist{display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.hrow{border:1px solid var(--line);border-radius:var(--row-r);background:var(--row);min-width:0}
|
||||
.hrow.open{border-color:var(--line-2)}
|
||||
.hr-main{display:grid;grid-template-columns:26px minmax(160px,1.3fr) auto minmax(180px,1fr) 30px;align-items:center;gap:var(--s-4);padding:11px 14px;cursor:pointer}
|
||||
.hr-main .glyph{width:26px;height:26px;border-radius:8px;border:1px solid var(--line-2);display:flex;align-items:center;justify-content:center;color:var(--ash);font-family:var(--mono);font-size:var(--t-sm)}
|
||||
.hrow.in .glyph{color:var(--molten);border-color:var(--molten-40)}
|
||||
.hr-who{min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||
.hr-who .kind{font-weight:600;font-size:var(--t-md);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.hr-who .sub{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.hr-amt{font-family:var(--head);font-weight:700;font-size:var(--t-lg);white-space:nowrap;text-align:right;justify-self:end}
|
||||
.hrow.in .hr-amt{color:var(--molten)}
|
||||
.hr-amt .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em;margin-left:4px}
|
||||
.hr-state{min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||
.hr-state .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap}
|
||||
.hr-state .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block;flex:0 0 7px}
|
||||
.hr-state .st.ink{color:var(--ink-2)}
|
||||
.hr-state .st.ok{color:var(--molten)}
|
||||
.hr-state .st.bad{color:var(--ember)}
|
||||
.hr-state .why{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.chev-btn{width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;color:var(--ash);justify-self:end}
|
||||
.chev-btn:hover{border-color:var(--line-2);color:var(--bone)}
|
||||
.hr-details{padding:12px 14px 14px 56px;border-top:1px solid var(--line);user-select:text;-webkit-user-select:text}
|
||||
.hr-details .kv>div{grid-template-columns:110px 1fr}
|
||||
.hr-details .kv .v{white-space:normal;word-break:break-all}
|
||||
.hrow.sent-row .hr-main{cursor:default;grid-template-columns:26px minmax(160px,1.3fr) auto minmax(180px,1fr)}
|
||||
|
||||
/* Send: the fee row and the pending row */
|
||||
.send-form{display:flex;flex-direction:column;gap:var(--s-3)}
|
||||
.unit-word{font-size:var(--t-sm);color:var(--ash);letter-spacing:.1em}
|
||||
.fee-row{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 0 0}
|
||||
.fee-row .k{font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.send-form[data-locked="1"] input{opacity:.6;pointer-events:none}
|
||||
.sent{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-3)}
|
||||
.sent .cta{justify-content:flex-start;margin-top:0}
|
||||
|
||||
/* Receive: the QR beside its line */
|
||||
.qr-row{display:flex;gap:var(--s-5);align-items:flex-start;flex-wrap:wrap;margin-top:var(--s-4)}
|
||||
.qr{width:240px;height:240px;background:var(--qr-bg);border-radius:14px;padding:10px;flex:0 0 240px}
|
||||
.qr svg{width:100%;height:100%;display:block}
|
||||
.qr-row .help{flex:1 1 240px;padding-top:var(--s-2)}
|
||||
|
||||
/* the update card (update-card.js, app.js renderUpdateCard; the miner carries the same block) */
|
||||
.upd-wrap{position:fixed;inset:0;z-index:35;background:var(--scrim);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease}
|
||||
@keyframes fade{from{opacity:0}to{opacity:1}}
|
||||
.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px var(--shadow),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none}
|
||||
.upd-card::before{content:"";position:absolute;left:50%;top:-80px;width:360px;height:260px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.22) 0,rgba(242,84,27,0) 62%);pointer-events:none}
|
||||
.upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:var(--s-3)}
|
||||
.upd-mark img{position:relative;display:block;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))}
|
||||
.upd-ring{position:absolute;inset:0;transform:rotate(-90deg)}
|
||||
.upd-ring .track{fill:none;stroke:var(--line-2);stroke-width:3}
|
||||
.upd-ring .arc{fill:none;stroke:var(--ember);stroke-width:3;stroke-linecap:round;stroke-dasharray:276.5;stroke-dashoffset:276.5;transition:stroke-dashoffset .4s linear,stroke .3s ease}
|
||||
.upd-mark.none .track{stroke:var(--line)}
|
||||
.upd-mark.full .arc{stroke:var(--molten);stroke-dashoffset:0}
|
||||
.upd-mark.busy .arc{stroke-dasharray:69 207.5;stroke-dashoffset:0;animation:spin 1.1s linear infinite;transform-origin:50% 50%}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.upd-mark.failed .arc{stroke:var(--ember);stroke-dashoffset:0;opacity:.55}
|
||||
.upd-pct{position:absolute;left:50%;bottom:-11px;transform:translateX(-50%);font-size:var(--t-xs);font-weight:500;letter-spacing:.06em;color:var(--molten);background:var(--obsidian);border:1px solid var(--line-2);border-radius:999px;padding:2px 8px;font-variant-numeric:tabular-nums}
|
||||
.upd-name{font-size:24px;font-weight:700;letter-spacing:-.01em;position:relative}
|
||||
.upd-line{font-size:var(--t-xl);color:var(--ink-2);line-height:1.4;position:relative}
|
||||
.upd-cause{font-size:var(--t-sm);color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word}
|
||||
.upd-notes{list-style:none;margin:var(--s-2) auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:var(--t-md);color:var(--bone);line-height:1.4}
|
||||
.upd-notes:empty{display:none}
|
||||
.upd-notes li{display:flex;align-items:baseline;gap:10px;text-align:left}
|
||||
.upd-notes li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ember);flex:0 0 6px;position:relative;top:-2px}
|
||||
.upd-more{font:inherit;font-size:var(--t-sm);font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);background:transparent;border:0;padding:4px 8px;cursor:pointer;border-radius:6px;margin-top:2px}
|
||||
.upd-more:hover{color:var(--bone)}
|
||||
.upd-all{list-style:none;margin:0;padding:10px 14px;width:100%;max-height:150px;overflow:auto;text-align:left;font-size:var(--t-base);color:var(--ink-2);line-height:1.5;background:var(--obsidian);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;gap:4px;user-select:text;-webkit-user-select:text}
|
||||
.upd-meta{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em;margin-top:var(--s-2);min-height:18px;line-height:1.5;max-width:44ch}
|
||||
.upd-meta:empty{display:none}
|
||||
.upd-actions{display:flex;gap:var(--s-3);align-items:center;justify-content:center;flex-wrap:wrap;margin-top:var(--s-4);position:relative}
|
||||
.upd-actions:empty{display:none}
|
||||
.upd-actions .btn.primary{min-width:160px}
|
||||
@media (prefers-reduced-motion:reduce){.upd-wrap,.upd-card{animation:none}.upd-ring .arc{transition:none}.upd-mark.busy .arc{animation:none;stroke-dasharray:207.5 69}}
|
||||
@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}}
|
||||
|
||||
/* the lock screen (0.1.4, kept; ui/lock-screen.js, index.html #screen-unlock): the mark on the ember glow, the name,
|
||||
the short address, one primary control. It lies over the main area (the header stays), enters in ENTER_MS (250 ms)
|
||||
and the home screen leaves beneath it in the same 250 ms. The glow breathes slowly; reduced motion stops it. */
|
||||
#screen-unlock{position:absolute;inset:0;z-index:5;max-width:none;margin:0;padding:0 var(--gutter);background:var(--obsidian);overflow:auto;animation:lockin .25s ease both}
|
||||
body.locking #screen-unlock{display:block}
|
||||
body.locking #screen-home{animation:lockout .25s ease both;pointer-events:none}
|
||||
@keyframes lockin{from{opacity:0;transform:scale(1.015)}to{opacity:1;transform:none}}
|
||||
@keyframes lockout{to{opacity:0;transform:scale(.985);filter:blur(4px)}}
|
||||
.lock-body{min-height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:10px;padding:28px 0 36px}
|
||||
.lock-coin{position:relative;width:180px;height:180px;margin-bottom:22px;flex:0 0 auto;display:flex;align-items:center;justify-content:center}
|
||||
.lock-coin::before{content:"";position:absolute;inset:-190px;border-radius:50%;background:radial-gradient(circle,rgba(255,179,92,.10) 0,rgba(242,84,27,.05) 40%,rgba(242,84,27,0) 68%);pointer-events:none}
|
||||
.lock-glow{position:absolute;inset:-96px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.38) 0,rgba(242,84,27,.14) 36%,rgba(242,84,27,0) 66%);animation:lockbreathe 6s ease-in-out infinite;pointer-events:none}
|
||||
@keyframes lockbreathe{0%,100%{opacity:.75;transform:scale(1)}50%{opacity:1;transform:scale(1.06)}}
|
||||
.lock-mark{width:148px;height:148px;border-radius:32px;margin:0;box-shadow:0 18px 48px rgba(242,84,27,.45)}
|
||||
.lock-mark::before{display:none}
|
||||
.lock-title{font-family:var(--head);font-weight:700;font-size:30px;letter-spacing:-.01em;line-height:1.1;margin-top:4px}
|
||||
.lock-address{font-size:var(--t-md);color:var(--ash);letter-spacing:.06em;margin-top:2px}
|
||||
.lock-controls{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:22px;min-height:120px}
|
||||
.lock-controls.touch{min-height:200px;justify-content:flex-start}
|
||||
.lock-touch{display:flex;flex-direction:column;align-items:center;gap:10px}
|
||||
.lock-btn{min-width:272px;gap:10px}
|
||||
.lock-line{min-height:20px;font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.04em;color:var(--ash);text-align:center;max-width:60ch;line-height:1.5;text-wrap:balance}
|
||||
.lock-line .bio-state{display:inline}
|
||||
.lock-line .fp-glyph{display:inline-block;vertical-align:-3px;margin-right:6px}
|
||||
.lock-link{font:inherit;font-size:var(--t-base);color:var(--ash);background:transparent;border:0;cursor:pointer;padding:6px 10px;border-radius:6px;text-decoration:underline;text-underline-offset:4px;text-decoration-color:var(--line-2);transition:color .15s ease}
|
||||
.lock-link:hover{color:var(--bone);text-decoration-color:var(--ash)}
|
||||
.unlock{display:flex;gap:10px;align-items:center;margin-top:6px}
|
||||
.unlock input{font:inherit;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;min-width:280px;min-height:52px;user-select:text;-webkit-user-select:text}
|
||||
.unlock input:focus{outline:none;border-color:var(--ember)}
|
||||
.lock-form{margin-top:0;animation:rise .2s ease}
|
||||
.lock-form input{min-width:260px}
|
||||
.lock-err{min-height:0}
|
||||
.lock-foot{margin-top:18px;opacity:.85}
|
||||
@media (max-height:720px){.lock-coin{width:140px;height:140px;margin-bottom:14px}.lock-mark{width:112px;height:112px;border-radius:26px}.lock-glow{inset:-70px}.lock-coin::before{inset:-150px}.lock-title{font-size:26px}.lock-controls{margin-top:14px}.lock-body{padding:16px 0 24px}}
|
||||
@media (prefers-reduced-motion:reduce){#screen-unlock,body.locking #screen-home,.lock-form{animation:none}.lock-glow{animation:none}}
|
||||
|
||||
/* Touch ID lines */
|
||||
.bio-state{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.04em;color:var(--ember);min-height:18px}
|
||||
.bio-state.ok{color:var(--molten)}
|
||||
.bio-state.wait{color:var(--ash)}
|
||||
.fp-glyph{flex:0 0 auto;color:var(--ember)}
|
||||
#send-go .fp-ico[hidden]{display:none}
|
||||
|
||||
.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px var(--shadow);max-width:min(90vw,520px);text-align:center}
|
||||
body.has-rail .toast{left:calc(50% + var(--rail) / 2)}
|
||||
|
||||
/* narrow windows: 1000 the rail folds to icons; 900 the row's reason goes under the word; 720 a bottom tab bar */
|
||||
@media (max-width:1000px){
|
||||
:root{--rail:76px;--gutter:var(--s-5)}
|
||||
.rail{padding:12px 8px}
|
||||
.rail-brand{justify-content:center;padding:6px 0 14px}
|
||||
.rail-brand .word{display:none}
|
||||
.nav{flex-direction:column;gap:4px;padding:8px 4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;font-family:var(--mono);font-weight:500;text-align:center;min-height:52px;justify-content:center}
|
||||
.nav.on{font-weight:500}
|
||||
.nav.on::before{left:-9px}
|
||||
.nav.small{min-height:44px}
|
||||
.nav-dot{right:8px;top:8px;margin:0}
|
||||
.rail-status{display:none}
|
||||
.rail-version{text-align:center;padding:8px 0 0;font-size:10px;white-space:nowrap}
|
||||
.page-sub{display:none}
|
||||
.kv>div{grid-template-columns:120px auto 1fr}
|
||||
.hr-main{grid-template-columns:26px minmax(140px,1fr) auto 30px}
|
||||
.hr-state{grid-column:2 / 4;flex-direction:row;align-items:baseline;gap:var(--s-3)}
|
||||
}
|
||||
@media (max-width:860px){
|
||||
.three{grid-template-columns:1fr}
|
||||
h1{font-size:40px}
|
||||
.bal .v{font-size:var(--t-hero)}
|
||||
.words{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.words.small{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
.disclose-right .dim{max-width:30ch}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
:root{--rail:0px;--gutter:var(--s-4);--bottom:64px}
|
||||
body.has-rail .rail{top:auto;bottom:0;left:0;right:0;width:auto;height:64px;flex-direction:row;align-items:center;border-right:0;border-top:1px solid var(--line);padding:0 var(--s-2)}
|
||||
.rail-brand{display:none}
|
||||
.rail-nav{flex-direction:row;flex:1;gap:0;justify-content:space-around}
|
||||
.nav{min-height:56px;padding:6px 2px;font-size:10px;border-radius:10px;flex:1;max-width:120px}
|
||||
.nav.on::before{display:none}
|
||||
.rail-foot{display:none}
|
||||
body.has-rail .narrow-only{display:inline-flex}
|
||||
body.has-rail .top,body.has-rail .notices,body.has-rail main,body.has-rail .ask-wrap{left:0}
|
||||
body.has-rail main{bottom:var(--bottom)}
|
||||
body.has-rail .toast{left:50%;bottom:calc(var(--bottom) + 12px)}
|
||||
.ask-wrap{bottom:var(--bottom)}
|
||||
.top{padding:0 var(--s-4)}
|
||||
.page-title h1{font-size:17px}
|
||||
.pill{min-width:0}
|
||||
.bal-row{flex-direction:column;gap:4px;align-items:flex-start}
|
||||
.bal .v{font-size:var(--t-h2)}
|
||||
.actions .btn.big{flex:1 1 40%}
|
||||
.actions .note{flex-basis:100%;margin-left:0}
|
||||
.addr-big{font-size:var(--t-base)}
|
||||
.hr-main{grid-template-columns:26px 1fr 30px;gap:var(--s-2) var(--s-3);padding:12px}
|
||||
.hr-amt{grid-column:1 / 3;grid-row:2;justify-self:start;text-align:left}
|
||||
.hr-state{grid-column:1 / -1;grid-row:3;flex-direction:column;align-items:flex-start;gap:2px}
|
||||
.hr-state .why{white-space:normal}
|
||||
.hr-details{padding-left:12px;padding-right:12px}
|
||||
.hrow.sent-row .hr-main{grid-template-columns:26px 1fr}
|
||||
.kv>div{grid-template-columns:96px 1fr;gap:4px var(--s-3)}
|
||||
.kv .m{grid-column:1 / -1;white-space:normal}
|
||||
.disclose-right .dim{display:none}
|
||||
.node-line{white-space:normal;line-height:1.4}
|
||||
.lead-row{flex-direction:column}
|
||||
.srow{flex-direction:column;align-items:flex-start;gap:var(--s-2)}
|
||||
.indent{margin-left:0}
|
||||
.seg{width:100%}
|
||||
.seg-b{flex:1;padding:6px 8px}
|
||||
.step{padding:32px 0}
|
||||
.words{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.words.small{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.checks{grid-template-columns:1fr}
|
||||
.qr{width:200px;height:200px;flex-basis:200px}
|
||||
.ask .short-pw{flex:1 1 160px;width:auto}
|
||||
.lock-btn{min-width:0;width:100%}
|
||||
.unlock input,.lock-form input{min-width:0;flex:1}
|
||||
.unlock{width:100%}
|
||||
}
|
||||
/* short windows (the 620 px floor): tighter paddings, so the hero and the rows fit */
|
||||
@media (max-height:700px){
|
||||
:root{--card-pad:18px;--gap:var(--s-3)}
|
||||
#screen-home{padding:16px 0 20px}
|
||||
.hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)}
|
||||
.mark-wrap{width:88px;height:88px}
|
||||
.mark-wrap img{width:56px;height:56px}
|
||||
h1{font-size:40px}
|
||||
.lead{font-size:var(--t-lg)}
|
||||
.step{padding:32px 0 32px}
|
||||
}
|
||||
|
||||
/* the page bridge (0.1.6): a website's request as a card over the window; the Settings card's site rows */
|
||||
.bridge-card{max-width:540px}
|
||||
.bridge-amount{font-family:var(--head);font-weight:700;font-size:28px;color:var(--ember);margin:4px 0 8px;letter-spacing:-.01em}
|
||||
.bridge-message{white-space:pre-wrap;word-break:break-word;max-height:220px;overflow:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.5;color:var(--ink-2);margin:6px 0 10px;text-align:left}
|
||||
.bridge-done{margin-top:12px;text-align:left}
|
||||
.bridge-done .kv{margin-top:8px}
|
||||
#bridge-origin{margin-top:6px}
|
||||
.sites{display:flex;flex-direction:column}
|
||||
.sites .srow .sw-text b{font-weight:600}
|
||||
.sites .srow .sw-text .dim{display:block;margin-top:2px}
|
||||
.lock-line.bridge-waiting{color:var(--molten);margin-top:4px}
|
||||
1057
app/igneum-wallet/ui/app.js
Normal file
433
app/igneum-wallet/ui/index.html
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Igneum Wallet</title>
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body data-phase="welcome" data-page="home">
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><symbol id="i-fp" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4.8 7.7A9.2 9.2 0 0 1 12 4.1c1.5 0 2.9.3 4.1.9"/><path d="M5.2 16.6A12.5 12.5 0 0 1 4.6 12a7.4 7.4 0 0 1 14.8 0v4"/><path d="M8.5 20a9 9 0 0 1-1.3-4.7V12a4.8 4.8 0 0 1 9.6 0v1.5"/><path d="M12 11a1 1 0 0 1 1 1v2a6 6 0 0 1-1 3.3"/><path d="M15.6 20a10 10 0 0 0 .9-2.6"/></g></symbol></svg>
|
||||
|
||||
<!-- the rail: five sections (wallet-ui-3: Home, Send, Receive, History, Settings), Lock and Quit in the foot. Under
|
||||
720 px it is a bottom tab bar. The welcome, create, import and lock screens have no rail. -->
|
||||
<aside class="rail" id="rail" hidden>
|
||||
<div class="rail-brand">
|
||||
<img src="mark.svg" width="28" height="28" alt="">
|
||||
<span class="word">IGNEUM</span>
|
||||
</div>
|
||||
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
|
||||
<button class="nav on" data-page="home" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 11 12 3l9 8"/><path d="M5 10v10h14V10"/></svg><span>Home</span></button>
|
||||
<button class="nav" data-page="send"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg><span>Send</span></button>
|
||||
<button class="nav" data-page="receive"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg><span>Receive</span></button>
|
||||
<button class="nav" data-page="history"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>History</span></button>
|
||||
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
|
||||
</nav>
|
||||
<div class="rail-foot">
|
||||
<div class="rail-status mono" id="rail-status"></div>
|
||||
<button class="nav small" id="btn-lock"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg><span>Lock</span></button>
|
||||
<button class="nav small danger" id="btn-quit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg><span>Quit</span></button>
|
||||
<div class="rail-version mono" id="foot-version"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<header class="top">
|
||||
<div class="brand" id="top-brand">
|
||||
<img src="mark.svg" width="30" height="30" alt="">
|
||||
<span class="word">IGNEUM</span><span class="miner">WALLET</span>
|
||||
</div>
|
||||
<div class="page-title" id="page-title" hidden>
|
||||
<h1 id="page-title-text">Home</h1>
|
||||
<span class="page-sub" id="page-sub"></span>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
|
||||
<button class="btn small ghost narrow-only" id="btn-lock-top" hidden>Lock</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- the status strip: the update line, one notice at a time -->
|
||||
<div class="notices" id="notices" hidden>
|
||||
<div class="notice" id="notice" role="status" aria-live="polite">
|
||||
<span class="notice-text" id="notice-text"></span>
|
||||
<span class="notice-actions" id="notice-actions"></span>
|
||||
<button class="notice-close" id="notice-close" title="Close" aria-label="Close">×</button>
|
||||
<span class="prog" id="notice-prog" hidden><i></i></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- the quit question, in place of a dialog -->
|
||||
<div class="ask-wrap" id="ask-quit" hidden>
|
||||
<div class="ask">
|
||||
<span class="ask-text">Quit Igneum Wallet? The bundled node stops with it.</span>
|
||||
<button class="btn small primary" id="ask-quit-yes">Quit</button>
|
||||
<button class="btn small ghost" id="ask-quit-no">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<!-- welcome -->
|
||||
<section class="screen" id="screen-welcome">
|
||||
<div class="hero">
|
||||
<div class="mark-wrap"><img src="mark.svg" width="72" height="72" alt=""></div>
|
||||
<div class="eyebrow ember" id="welcome-eyebrow">devnet v4 · nothing is bought or sold</div>
|
||||
<h1>Igneum Wallet</h1>
|
||||
<p class="lead">Your IGN and your key, on this machine. The key is made here and never leaves this app.</p>
|
||||
<div class="three">
|
||||
<div class="tile"><div class="k">01</div><div class="t">Your key stays here</div><div class="s">Sealed with a password you choose. Signing happens inside the app.</div></div>
|
||||
<div class="tile"><div class="k">02</div><div class="t">Final means verified</div><div class="s">A payment is final when this wallet has checked the network's certificate itself.</div></div>
|
||||
<div class="tile"><div class="k">03</div><div class="t">Works with the miner</div><div class="s">Uses the miner app's node when it runs here. Imports the miner's key in one tap.</div></div>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<button class="btn primary big" id="go-create">Create a wallet</button>
|
||||
<button class="btn big" id="go-import">Import</button>
|
||||
</div>
|
||||
<p class="seedline mono">Nobody from Igneum will ever ask for your words or your key.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- create -->
|
||||
<section class="screen" id="screen-create">
|
||||
<div class="step" id="create-1">
|
||||
<div class="eyebrow">step 1 of 3</div>
|
||||
<h2>Choose a password</h2>
|
||||
<p class="sub">It locks the key on this machine. Nobody can reset it for you. At least 8 characters.</p>
|
||||
<label class="field"><span class="k">Password</span><input type="password" id="create-pw" autocomplete="new-password"></label>
|
||||
<label class="field"><span class="k">Again</span><input type="password" id="create-pw2" autocomplete="new-password"></label>
|
||||
<div class="err" id="create-err"></div>
|
||||
<div class="cta"><button class="btn primary big" id="create-next">Make my words</button><button class="btn ghost" id="create-back">Back</button></div>
|
||||
</div>
|
||||
<div class="step" id="create-2" hidden>
|
||||
<div class="eyebrow">step 2 of 3</div>
|
||||
<h2>Write these 24 words down</h2>
|
||||
<p class="sub">They are the wallet. Anyone with them has your IGN. They are shown once.</p>
|
||||
<ol class="words" id="words"></ol>
|
||||
<div class="field"><div class="k">your address</div><div class="box mono"><span id="create-address"></span><button class="btn tiny" data-copy="create-address">Copy</button></div></div>
|
||||
<div class="cta"><button class="btn primary big" id="create-written">I wrote them down</button></div>
|
||||
</div>
|
||||
<div class="step" id="create-3" hidden>
|
||||
<div class="eyebrow">step 3 of 3</div>
|
||||
<h2>Type three of them</h2>
|
||||
<p class="sub">So the paper is right before the words are gone from the screen.</p>
|
||||
<div class="checks" id="checks"></div>
|
||||
<div class="err" id="confirm-err"></div>
|
||||
<div class="cta"><button class="btn primary big" id="create-confirm">Open my wallet</button><button class="btn ghost" id="create-again">Show the words again</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- import -->
|
||||
<section class="screen" id="screen-import">
|
||||
<div class="step">
|
||||
<div class="eyebrow">import</div>
|
||||
<h2>Bring a key here</h2>
|
||||
<p class="sub">Words from any wallet, a raw private key, or the key the miner app made on this machine.</p>
|
||||
<div class="seg" id="import-mode" role="radiogroup" aria-label="What to import">
|
||||
<button class="seg-b on" data-mode="seed" role="radio" aria-checked="true">24 words</button>
|
||||
<button class="seg-b" data-mode="key" role="radio" aria-checked="false">Private key</button>
|
||||
<button class="seg-b" data-mode="miner" id="seg-miner" role="radio" aria-checked="false">Miner's key</button>
|
||||
</div>
|
||||
<label class="field" id="import-data-field"><span class="k" id="import-data-label">The words, in order</span><textarea id="import-data" rows="4" spellcheck="false" autocomplete="off"></textarea></label>
|
||||
<p class="note" id="import-miner-note" hidden>The miner app's key file on this machine will be read. Rewards keep going to the same address.</p>
|
||||
<label class="field"><span class="k">New password for this machine</span><input type="password" id="import-pw" autocomplete="new-password"></label>
|
||||
<label class="field"><span class="k">Again</span><input type="password" id="import-pw2" autocomplete="new-password"></label>
|
||||
<div class="err" id="import-err"></div>
|
||||
<div class="cta"><button class="btn primary big" id="import-go">Import</button><button class="btn ghost" id="import-back">Back</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- the lock screen (lock-screen.js, app.js onLockScreen; 0.1.4, kept): the mark on the ember glow, the name, the
|
||||
short address, one primary control. -->
|
||||
<section class="screen lock" id="screen-unlock">
|
||||
<div class="lock-body">
|
||||
<div class="lock-coin"><span class="lock-glow" aria-hidden="true"></span><div class="mark-wrap lock-mark"><img src="mark.svg" width="88" height="88" alt=""></div></div>
|
||||
<h1 class="lock-title">Igneum Wallet</h1>
|
||||
<p class="lock-address mono" id="unlock-address"></p>
|
||||
<p class="lock-line bridge-waiting" id="unlock-bridge" hidden></p>
|
||||
<div class="lock-controls">
|
||||
<div class="lock-touch" id="unlock-bio" hidden>
|
||||
<button class="btn primary big fp lock-btn" id="unlock-touch" type="button"><svg width="22" height="22" aria-hidden="true"><use href="#i-fp"></use></svg><span id="unlock-touch-text">Unlock with Touch ID</span></button>
|
||||
<div class="lock-line" id="unlock-bio-note" aria-live="polite"></div>
|
||||
<button class="lock-link" id="unlock-use-pw" type="button">Use password</button>
|
||||
</div>
|
||||
<form id="unlock-form" class="unlock lock-form" hidden>
|
||||
<input type="password" id="unlock-pw" placeholder="Password" autocomplete="current-password" aria-label="Password">
|
||||
<button class="btn primary big" type="submit" id="unlock-submit">Unlock</button>
|
||||
</form>
|
||||
<div class="err lock-err" id="unlock-err"></div>
|
||||
</div>
|
||||
<p class="seedline lock-foot">Forgot it? Only the 24 words or the key open this wallet again.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- home: five pages behind the rail -->
|
||||
<section class="screen" id="screen-home">
|
||||
|
||||
<!-- Home -->
|
||||
<section class="page" id="page-home" data-page="home">
|
||||
<div class="card hero-card">
|
||||
<div class="bal-row">
|
||||
<div class="bal" id="h-bal"><span class="v" id="h-balance">0</span><span class="unit mono">IGN</span></div>
|
||||
<p class="line-text bal-line" id="h-balance-line"></p>
|
||||
</div>
|
||||
<p class="money-line" id="h-money"></p>
|
||||
<div class="addr-big mono"><span id="home-address">not set</span><button class="btn small" data-copy="home-address">Copy</button></div>
|
||||
<div class="row wrap actions">
|
||||
<button class="btn primary big" id="go-send">Send</button>
|
||||
<button class="btn big" id="go-receive">Receive</button>
|
||||
<span class="note" id="backup-note" hidden>Your words are not written down yet. <a href="#" id="backup-link">Back up now</a>.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card node-card" id="node-card">
|
||||
<button class="disclose" id="node-toggle" aria-expanded="false" aria-controls="node-details">
|
||||
<span class="node-line"><i class="dot" id="node-dot"></i><span id="node-line-text">Node starting</span></span>
|
||||
<span class="disclose-right"><span class="dim" id="node-sub"></span><span class="chev" aria-hidden="true"></span></span>
|
||||
</button>
|
||||
<div class="details" id="node-details" hidden>
|
||||
<div class="kv" id="node-kv"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Recent</h3><button class="btn small ghost" id="recent-all">All history</button></div>
|
||||
<div class="hist" id="recent"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Send -->
|
||||
<section class="page" id="page-send" data-page="send" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Send IGN</h3><span class="eyebrow" id="send-eyebrow"></span></div>
|
||||
<div class="send-form" id="send-form">
|
||||
<label class="field"><span class="k">To</span><input type="text" id="send-to" class="mono" placeholder="0x" spellcheck="false" autocomplete="off"></label>
|
||||
<label class="field"><span class="k">Amount</span><div class="row"><input type="text" id="send-amount" class="mono" placeholder="0.0" inputmode="decimal" autocomplete="off"><span class="unit-word mono">IGN</span><span class="note" id="send-balance"></span></div></label>
|
||||
<div class="fee-row">
|
||||
<span class="k mono">fee</span>
|
||||
<span class="line-text" id="fee-line">shown when you review</span>
|
||||
<button class="btn tiny ghost" id="fee-toggle" aria-expanded="false" aria-controls="fee-details" hidden>Details</button>
|
||||
</div>
|
||||
<p class="help" id="fee-details" hidden></p>
|
||||
<div class="err" id="send-err"></div>
|
||||
<div class="cta" id="send-cta"><button class="btn primary big" id="send-quote">Review</button></div>
|
||||
</div>
|
||||
<div class="ask inline" id="ask-send" hidden>
|
||||
<span class="ask-text" id="ask-send-text"></span>
|
||||
<button class="btn small primary" id="send-go"><svg class="fp-ico" width="16" height="16" hidden><use href="#i-fp"></use></svg><span id="send-go-text">Send now</span></button>
|
||||
<button class="btn small ghost" id="ask-send-no">Cancel</button>
|
||||
<span class="ask-foot" id="send-bio-line"></span>
|
||||
<span class="ask-foot err" id="confirm-send-err"></span>
|
||||
</div>
|
||||
<div class="sent" id="send-done" hidden>
|
||||
<div class="hrow sent-row" id="sent-row"></div>
|
||||
<div class="box mono"><span id="sent-hash"></span><button class="btn tiny" data-copy="sent-hash">Copy</button></div>
|
||||
<div class="cta"><button class="btn small" id="sent-view">View in History</button><button class="btn small ghost" id="sent-again">Send another</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Receive -->
|
||||
<section class="page" id="page-receive" data-page="receive" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your address</h3></div>
|
||||
<div class="addr-big mono"><span id="receive-address">not set</span><button class="btn small" data-copy="receive-address">Copy</button></div>
|
||||
<div class="qr-row">
|
||||
<div class="qr" id="qr"></div>
|
||||
<p class="help">Only IGN on the Igneum network. Rewards from the miner app land here when it pays this address. The code is drawn on this machine.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- History -->
|
||||
<section class="page" id="page-history" data-page="history" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>History</h3><span class="eyebrow" id="hist-eyebrow"></span></div>
|
||||
<div class="hist" id="history"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings -->
|
||||
<section class="page" id="page-settings" data-page="settings" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Security</h3></div>
|
||||
<div class="srow" id="bio-row">
|
||||
<div class="sw-text"><b id="bio-title">Touch ID</b> <span class="dim" id="bio-sentence"></span></div>
|
||||
<div class="row"><button class="btn small" id="bio-toggle-btn">Turn on</button></div>
|
||||
</div>
|
||||
<div class="details indent" id="bio-enrol" hidden>
|
||||
<div class="row wrap"><input type="password" class="addr-input" id="bio-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary fp" id="bio-enrol-go"><svg width="16" height="16"><use href="#i-fp"></use></svg><span id="bio-enrol-text">Turn on Touch ID</span></button><button class="btn small ghost" id="bio-enrol-cancel">Cancel</button></div>
|
||||
</div>
|
||||
<p class="note indent" id="bio-off-note" hidden></p>
|
||||
<div class="err indent" id="bio-err"></div>
|
||||
<label class="switch" id="ask-on-open-row"><input type="checkbox" id="ask-on-open"><span class="track"></span><span class="sw-text"><b id="ask-on-open-text">Ask for Touch ID when the wallet opens</b> <span class="dim">Once, when you open the app. Never after an idle lock.</span></span></label>
|
||||
<div class="srow" id="idle-row">
|
||||
<div class="sw-text"><b>Lock when idle</b> <span class="dim" id="idle-lock-note"></span></div>
|
||||
<select class="select" id="idle-lock" aria-label="Lock when idle">
|
||||
<option value="1">after 1 minute</option>
|
||||
<option value="5">after 5 minutes</option>
|
||||
<option value="15">after 15 minutes</option>
|
||||
<option value="60">after 1 hour</option>
|
||||
<option value="0">never</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="disclose" id="pw-toggle" aria-expanded="false" aria-controls="pw-details"><span>Change password</span><span class="chev" aria-hidden="true"></span></button>
|
||||
<div class="details" id="pw-details" hidden>
|
||||
<div class="row wrap"><input type="password" class="addr-input" id="pw-old" placeholder="Current" autocomplete="current-password"><input type="password" class="addr-input" id="pw-new" placeholder="New, 8 or more" autocomplete="new-password"><button class="btn small" id="pw-change">Change</button></div>
|
||||
<p class="help">Touch ID is turned off by a password change; turn it on again above.</p>
|
||||
<div class="err" id="pw-err"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Websites</h3><span class="eyebrow" id="sites-eyebrow">page bridge</span></div>
|
||||
<label class="switch" id="bridge-on-row"><input type="checkbox" id="bridge-on"><span class="track"></span><span class="sw-text"><b>Let websites connect</b> <span class="dim" id="bridge-status"></span></span></label>
|
||||
<div class="sites" id="sites"></div>
|
||||
<p class="note" id="sites-note">A site asks once; you answer in this window. Each site sees your address and can ask you to sign or send; every request is shown here first. Disconnect a site any time.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Backup</h3><span class="eyebrow" id="backup-eyebrow"></span></div>
|
||||
<button class="disclose" id="backup-toggle" aria-expanded="false" aria-controls="backup-details"><span>Show my words</span><span class="chev" aria-hidden="true"></span></button>
|
||||
<div class="details" id="backup-details" hidden>
|
||||
<div class="ask inline" id="ask-backup">
|
||||
<span class="ask-text">Show the 24 words and the key on screen? Anyone who sees them has your IGN.</span>
|
||||
<button class="btn small primary fp" id="backup-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button>
|
||||
<button class="btn small" id="backup-use-pw">Use password</button>
|
||||
<button class="btn small ghost" id="backup-cancel">Cancel</button>
|
||||
</div>
|
||||
<div class="row wrap" id="backup-pw-row" hidden><input type="password" class="addr-input" id="backup-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary" id="backup-show">Show</button></div>
|
||||
<div class="err" id="backup-err"></div>
|
||||
<div id="backup-out" hidden>
|
||||
<ol class="words small" id="backup-words"></ol>
|
||||
<div class="field"><div class="k">private key</div><div class="box mono key"><span id="backup-key"></span><button class="btn tiny" data-copy="backup-key">Copy</button><button class="btn tiny ghost" id="backup-hide">Hide</button></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="disclose" id="export-toggle" aria-expanded="false" aria-controls="export-details"><span>Export the key for MetaMask</span><span class="chev" aria-hidden="true"></span></button>
|
||||
<div class="details" id="export-details" hidden>
|
||||
<p class="help">A copied key can be stolen from the clipboard, a screenshot or a notes app. Paste it straight into MetaMask and clear the clipboard.</p>
|
||||
<div class="ask inline" id="ask-export">
|
||||
<span class="ask-text">Show the private key on screen? Anyone who sees it has your IGN.</span>
|
||||
<button class="btn small primary fp" id="export-touch" hidden><svg width="16" height="16"><use href="#i-fp"></use></svg><span>Show with Touch ID</span></button>
|
||||
<button class="btn small" id="export-use-pw">Use password</button>
|
||||
<button class="btn small ghost" id="export-cancel">Cancel</button>
|
||||
</div>
|
||||
<div class="row wrap" id="export-pw-row" hidden><input type="password" class="addr-input" id="export-pw" placeholder="Your password" autocomplete="current-password"><button class="btn small primary" id="export-show">Show</button></div>
|
||||
<div class="err" id="export-err"></div>
|
||||
<div class="box mono key" id="export-out" hidden><span id="export-key"></span><button class="btn tiny" data-copy="export-key">Copy</button><button class="btn tiny ghost" id="export-hide">Hide</button></div>
|
||||
</div>
|
||||
|
||||
<div class="srow top-gap">
|
||||
<div class="sw-text"><b>MetaMask network</b> <span class="dim mono" id="net-line"></span></div>
|
||||
<div class="row"><button class="btn small" id="net-add">Add to MetaMask</button><button class="btn small ghost" id="net-copy">Copy settings</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>This machine</h3></div>
|
||||
<label class="switch"><input type="checkbox" id="start-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in. The wallet locks itself when idle.</span></span></label>
|
||||
<div class="field">
|
||||
<div class="k">appearance</div>
|
||||
<div class="seg" id="theme-seg" role="radiogroup" aria-label="Appearance">
|
||||
<button class="seg-b" data-theme="system" role="radio" aria-checked="true">System</button>
|
||||
<button class="seg-b" data-theme="light" role="radio" aria-checked="false">Light</button>
|
||||
<button class="seg-b" data-theme="dark" role="radio" aria-checked="false">Dark</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3 id="s-version">Igneum Wallet</h3>
|
||||
<p class="help" id="s-update-note">Not checked yet.</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn small primary" id="s-install" hidden>Install now</button>
|
||||
<button class="btn small" id="s-update">Check</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="auto-update"><span class="track"></span><span class="sw-text"><b>Install updates by itself</b> <span class="dim">Downloads in the background and installs when nothing is being sent.</span></span></label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Node</h3><span class="eyebrow" id="node-eyebrow"></span></div>
|
||||
<div class="field">
|
||||
<div class="k">endpoint in use</div>
|
||||
<div class="box mono"><span id="node-endpoint">none yet</span><button class="btn tiny" data-copy="node-endpoint">Copy</button></div>
|
||||
</div>
|
||||
<p class="line-text" id="node-source-line"></p>
|
||||
<p class="help">The wallet picks the miner's node when it runs on this machine, else its own bundled node, else the public RPC. Finality is verified only with a node here.</p>
|
||||
<p class="note mono small" id="node-ids"></p>
|
||||
</div>
|
||||
|
||||
<details class="card adv" id="s-advanced">
|
||||
<summary><h3>Advanced</h3><span class="eyebrow">this machine</span></summary>
|
||||
<p class="note mono small" id="s-mid"></p>
|
||||
<p class="note mono small" id="s-dirs"></p>
|
||||
<p class="note" id="s-miner-file"></p>
|
||||
<div class="row wrap top-gap"><button class="btn small danger" id="remove-start">Remove this wallet from this machine</button></div>
|
||||
<div class="ask inline" id="ask-remove" hidden>
|
||||
<span class="ask-text">Remove this wallet from this machine? The vault file is deleted. Only your 24 words or the key bring it back.</span>
|
||||
<input type="password" class="addr-input short-pw" id="remove-pw" placeholder="Your password" autocomplete="current-password" aria-label="Password">
|
||||
<button class="btn small primary" id="remove-go">Remove</button>
|
||||
<button class="btn small ghost" id="ask-remove-no">Cancel</button>
|
||||
<span class="ask-foot err" id="remove-err"></span>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- the update card (update-card.js, app.js renderUpdateCard): one update, centred; Later, Escape or the backdrop
|
||||
leaves the strip above -->
|
||||
<!-- the page bridge (0.1.6): a website's request, answered here and nowhere else -->
|
||||
<div class="upd-wrap bridge-wrap" id="bridge" hidden>
|
||||
<div class="upd-card bridge-card" id="bridge-card" role="dialog" aria-modal="true" aria-labelledby="bridge-title" tabindex="-1">
|
||||
<div class="eyebrow ember" id="bridge-eyebrow">a website asks</div>
|
||||
<h2 class="upd-name" id="bridge-title"></h2>
|
||||
<p class="bridge-amount mono" id="bridge-amount" hidden></p>
|
||||
<pre class="bridge-message mono" id="bridge-message" hidden></pre>
|
||||
<ul class="upd-notes" id="bridge-lines"></ul>
|
||||
<p class="upd-meta mono" id="bridge-origin"></p>
|
||||
<div class="upd-actions" id="bridge-actions">
|
||||
<button class="btn primary big" id="bridge-yes"><svg class="fp-ico" width="16" height="16" hidden><use href="#i-fp"></use></svg><span id="bridge-yes-text">Connect</span></button>
|
||||
<button class="btn ghost" id="bridge-no">Decline</button>
|
||||
</div>
|
||||
<p class="ask-foot" id="bridge-bio-line"></p>
|
||||
<p class="err" id="bridge-err"></p>
|
||||
<div class="bridge-done" id="bridge-done" hidden>
|
||||
<p class="upd-line" id="bridge-done-line"></p>
|
||||
<div class="box mono" id="bridge-hash-box" hidden><span id="bridge-hash"></span><button class="btn tiny" data-copy="bridge-hash">Copy</button></div>
|
||||
<div class="kv" id="bridge-final"></div>
|
||||
<div class="upd-actions"><button class="btn ghost" id="bridge-close">Done</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upd-wrap" id="upd" hidden>
|
||||
<div class="upd-card" id="upd-card" role="dialog" aria-modal="true" aria-labelledby="upd-name" aria-describedby="upd-line" tabindex="-1">
|
||||
<div class="upd-mark" id="upd-mark">
|
||||
<svg class="upd-ring" viewBox="0 0 96 96" aria-hidden="true"><circle class="track" cx="48" cy="48" r="44"></circle><circle class="arc" id="upd-arc" cx="48" cy="48" r="44"></circle></svg>
|
||||
<img src="mark.svg" width="40" height="40" alt="">
|
||||
<span class="upd-pct mono" id="upd-pct" hidden></span>
|
||||
</div>
|
||||
<div class="eyebrow ember">update</div>
|
||||
<h2 class="upd-name" id="upd-name"></h2>
|
||||
<p class="upd-line" id="upd-line"></p>
|
||||
<p class="upd-cause mono" id="upd-cause" hidden></p>
|
||||
<ul class="upd-notes" id="upd-notes"></ul>
|
||||
<button class="upd-more" id="upd-more" type="button" aria-expanded="false" hidden>What changed</button>
|
||||
<ul class="upd-all" id="upd-all" hidden></ul>
|
||||
<p class="upd-meta mono" id="upd-meta"></p>
|
||||
<div class="upd-actions" id="upd-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="update-card.js"></script>
|
||||
<script src="lock-screen.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
78
app/igneum-wallet/ui/lock-screen.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/* The lock screen (pure; lock-screen.test.mjs loads this file): what the screen shows for a wallet state, the line
|
||||
under the button after the host answered, and when the system Touch ID (or Windows Hello) sheet may be raised
|
||||
without a tap. 5 October 2026, for 0.1.4.
|
||||
|
||||
The sheet appears when the person taps the button or presses Return or Space on it. One exception: the first
|
||||
arrival after the person opened the app may raise it once, AUTO_DELAY_MS after the lock screen is fully drawn,
|
||||
when the setting "Ask for Touch ID when the wallet opens" is on. Never on an idle lock, on a hand lock, when the
|
||||
window comes back, after a cancelled sheet, or when the updater relaunched the app. */
|
||||
const LockScreen = (function () {
|
||||
'use strict';
|
||||
/** after the lock screen is fully drawn */
|
||||
const AUTO_DELAY_MS = 600;
|
||||
/** the transition from the home screen to the lock screen, and the lock screen's own entry */
|
||||
const ENTER_MS = 250;
|
||||
/** the idle lock's choices in Settings, minutes; 0 is never */
|
||||
const IDLE_CHOICES = [1, 5, 15, 60, 0];
|
||||
const IDLE_DEFAULT_MIN = 5;
|
||||
|
||||
function idleLabel(min) {
|
||||
const m = Number(min) || 0;
|
||||
if (m <= 0) return 'never';
|
||||
if (m === 60) return '1 hour';
|
||||
if (m % 60 === 0) return (m / 60) + ' hours';
|
||||
return m === 1 ? '1 minute' : m + ' minutes';
|
||||
}
|
||||
|
||||
function shortAddress(a) {
|
||||
return a ? a.slice(0, 8) + '…' + a.slice(-6) : '';
|
||||
}
|
||||
|
||||
/** What the lock screen shows: the fingerprint button when Touch ID or Windows Hello is enrolled and the app
|
||||
window is the host (a browser tab cannot raise the sheet); else the password field is the primary control. */
|
||||
function layout(s, hostHere) {
|
||||
const b = (s && s.biometric) || {};
|
||||
const touch = !!(b.enrolled && hostHere);
|
||||
return { touch: touch, passwordPrimary: !touch, address: shortAddress(s && s.display) };
|
||||
}
|
||||
|
||||
/** The line under the button after the host answered: {text, cls, password}. cls is '' (ember), 'ok' or
|
||||
'wait'; password says the password field should be revealed (the sheet cannot unlock this time). */
|
||||
function line(r, name) {
|
||||
if (!r) return { text: '', cls: '', password: false };
|
||||
if (r.ok) return { text: name + ' confirmed, unlocking', cls: 'ok', password: false };
|
||||
const c = r.code;
|
||||
if (c === 'cancelled') return { text: name + ' cancelled, tap to try again', cls: '', password: false };
|
||||
if (c === 'failed') return { text: name + ' did not match, tap to try again', cls: '', password: false };
|
||||
if (c === 'timeout') return { text: name + ' did not answer, tap to try again', cls: '', password: false };
|
||||
if (c === 'fallback') return { text: 'Enter your password', cls: 'wait', password: true };
|
||||
if (c === 'locked') return { text: name + ' is locked, use your password', cls: '', password: true };
|
||||
if (c === 'invalidated') return { text: name + ' was turned off (a fingerprint changed): use your password, then turn it on again in Settings', cls: '', password: true };
|
||||
if (c === 'not_set_up' || c === 'unavailable' || c === 'not_enrolled') return { text: r.message || (name + ' is not set up on this Mac'), cls: '', password: true };
|
||||
if (c === 'nohost') return { text: name + ' needs the Igneum Wallet app window', cls: '', password: true };
|
||||
if (c === 'engine') return { text: r.message || ('the wallet did not unlock'), cls: '', password: true };
|
||||
return { text: r.message || (name + ' did not succeed, tap to try again'), cls: '', password: false };
|
||||
}
|
||||
|
||||
/** Whether the sheet may be raised now without a tap. ctx: reason (arrival | lock | focus | cancel), phase,
|
||||
touch (the button is on screen), askOnOpen (the setting), hidden (the window is not visible), busy (a sheet is
|
||||
up), firstPhase (the first phase this page saw: 'unlock' means the app opened locked), updatedFrom (set on
|
||||
the first run after an update: the updater opened the app, not the person). mem.used: the one chance went. */
|
||||
function autoPrompt(ctx, mem) {
|
||||
const c = ctx || {}, m = mem || {};
|
||||
const no = function (why) { return { prompt: false, why: why, delay: 0 }; };
|
||||
if (c.phase !== 'unlock') return no('not-locked');
|
||||
if (!c.touch) return no('no-touch');
|
||||
if (m.used) return no('once');
|
||||
if (c.reason !== 'arrival') return no(c.reason || 'not-arrival');
|
||||
if (c.firstPhase !== 'unlock') return no('not-arrival');
|
||||
if (c.updatedFrom) return no('updater');
|
||||
if (!c.askOnOpen) return no('setting-off');
|
||||
if (c.hidden) return no('hidden');
|
||||
if (c.busy) return no('busy');
|
||||
return { prompt: true, why: 'arrival', delay: AUTO_DELAY_MS };
|
||||
}
|
||||
|
||||
return { AUTO_DELAY_MS: AUTO_DELAY_MS, ENTER_MS: ENTER_MS, IDLE_CHOICES: IDLE_CHOICES, IDLE_DEFAULT_MIN: IDLE_DEFAULT_MIN, idleLabel: idleLabel, shortAddress: shortAddress, layout: layout, line: line, autoPrompt: autoPrompt };
|
||||
})();
|
||||
if (typeof module === 'object' && module && module.exports) module.exports = LockScreen;
|
||||
93
app/igneum-wallet/ui/lock-screen.test.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// node --test app/igneum-wallet/ui/lock-screen.test.mjs (no dependencies)
|
||||
// Loads ui/lock-screen.js (plain browser JS, run with `module` defined and no `document`) and checks what the lock
|
||||
// screen shows, the line under the button after the host answered, and when the system sheet may be raised
|
||||
// without a tap: once, on the first arrival after the person opened the app, with the setting on; never on an
|
||||
// idle lock, a hand lock, the window coming back, a cancelled sheet, or the updater's relaunch.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'lock-screen.js'), 'utf8');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const L = mod.exports;
|
||||
const { layout, line, autoPrompt, idleLabel, shortAddress } = L;
|
||||
|
||||
const ADDR = '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf';
|
||||
const st = (over) => ({ phase: 'unlock', display: ADDR, biometric: { enrolled: true, available: true, kind: 'touchid' }, settings: { ask_on_open: true, idle_lock_min: 5 }, update: { updated_from: '' }, ...over });
|
||||
const arrival = (over) => ({ reason: 'arrival', phase: 'unlock', touch: true, askOnOpen: true, hidden: false, busy: false, firstPhase: 'unlock', updatedFrom: '', ...over });
|
||||
|
||||
test('what the screen shows: the button when enrolled in the app window, else the password field first', () => {
|
||||
const a = layout(st(), true);
|
||||
assert.equal(a.touch, true); assert.equal(a.passwordPrimary, false); assert.equal(a.address, '0x7E5F45…395Bdf');
|
||||
// enrolled, but the page is open in a browser tab: no host to raise the sheet
|
||||
const b = layout(st(), false);
|
||||
assert.equal(b.touch, false); assert.equal(b.passwordPrimary, true);
|
||||
// not enrolled in the app window
|
||||
const c = layout(st({ biometric: { enrolled: false, available: true } }), true);
|
||||
assert.equal(c.touch, false); assert.equal(c.passwordPrimary, true);
|
||||
assert.deepEqual(layout(null, true), { touch: false, passwordPrimary: true, address: '' });
|
||||
assert.equal(shortAddress(''), '');
|
||||
});
|
||||
|
||||
test('the line under the button: cancel and no-match keep the button, fallback and lockout reveal the password', () => {
|
||||
assert.deepEqual(line({ ok: true }, 'Touch ID'), { text: 'Touch ID confirmed, unlocking', cls: 'ok', password: false });
|
||||
assert.deepEqual(line({ ok: false, code: 'cancelled' }, 'Touch ID'), { text: 'Touch ID cancelled, tap to try again', cls: '', password: false });
|
||||
assert.deepEqual(line({ ok: false, code: 'failed' }, 'Windows Hello'), { text: 'Windows Hello did not match, tap to try again', cls: '', password: false });
|
||||
assert.deepEqual(line({ ok: false, code: 'timeout' }, 'Touch ID'), { text: 'Touch ID did not answer, tap to try again', cls: '', password: false });
|
||||
assert.deepEqual(line({ ok: false, code: 'fallback' }, 'Touch ID'), { text: 'Enter your password', cls: 'wait', password: true });
|
||||
assert.equal(line({ ok: false, code: 'locked' }, 'Touch ID').password, true);
|
||||
assert.equal(line({ ok: false, code: 'invalidated' }, 'Touch ID').password, true);
|
||||
assert.equal(line({ ok: false, code: 'nohost' }, 'Touch ID').text, 'Touch ID needs the Igneum Wallet app window');
|
||||
assert.equal(line({ ok: false, code: 'not_set_up', message: 'Touch ID is not set up on this Mac. Add a fingerprint in System Settings > Touch ID & Password.' }, 'Touch ID').password, true);
|
||||
assert.equal(line({ ok: false, code: 'engine', message: 'the engine did not unlock' }, 'Touch ID').text, 'the engine did not unlock');
|
||||
assert.deepEqual(line({ ok: false, code: 'weird' }, 'Touch ID'), { text: 'Touch ID did not succeed, tap to try again', cls: '', password: false });
|
||||
assert.deepEqual(line(null, 'Touch ID'), { text: '', cls: '', password: false });
|
||||
// never a full stop at the end, never a modal: one line in our words
|
||||
for (const c of ['cancelled', 'failed', 'timeout', 'fallback', 'locked', 'nohost']) assert.ok(!line({ ok: false, code: c }, 'Touch ID').text.endsWith('.'), c);
|
||||
});
|
||||
|
||||
test('the one automatic prompt: first arrival after the person opened the app, setting on, 600 ms after drawn', () => {
|
||||
const mem = { used: false };
|
||||
assert.deepEqual(autoPrompt(arrival(), mem), { prompt: true, why: 'arrival', delay: 600 });
|
||||
assert.equal(L.AUTO_DELAY_MS, 600); assert.equal(L.ENTER_MS, 250);
|
||||
// once per launch
|
||||
assert.equal(autoPrompt(arrival(), { used: true }).why, 'once');
|
||||
// the setting off
|
||||
assert.equal(autoPrompt(arrival({ askOnOpen: false }), mem).why, 'setting-off');
|
||||
// the window is not visible at arrival (start at login, the window behind): nothing
|
||||
assert.equal(autoPrompt(arrival({ hidden: true }), mem).why, 'hidden');
|
||||
// a sheet is already up
|
||||
assert.equal(autoPrompt(arrival({ busy: true }), mem).why, 'busy');
|
||||
// not enrolled, or a browser tab: no button, no sheet
|
||||
assert.equal(autoPrompt(arrival({ touch: false }), mem).why, 'no-touch');
|
||||
// not locked
|
||||
assert.equal(autoPrompt(arrival({ phase: 'home' }), mem).why, 'not-locked');
|
||||
});
|
||||
|
||||
test('never on an idle lock, a hand lock, the window coming back, a cancelled sheet, or the updater relaunch', () => {
|
||||
const mem = { used: false };
|
||||
// the page opened on the home screen (the wallet was unlocked at arrival) and locked later: idle or by hand
|
||||
assert.equal(autoPrompt(arrival({ reason: 'lock', firstPhase: 'home' }), mem).why, 'lock');
|
||||
assert.equal(autoPrompt(arrival({ reason: 'lock' }), mem).why, 'lock');
|
||||
// the window came back from the menu bar or the Dock
|
||||
assert.equal(autoPrompt(arrival({ reason: 'focus' }), mem).why, 'focus');
|
||||
// after a cancelled sheet the page waits for a tap
|
||||
assert.equal(autoPrompt(arrival({ reason: 'cancel' }), mem).why, 'cancel');
|
||||
// the first phase the page saw was not the lock screen (welcome, home): a later lock is not an arrival
|
||||
assert.equal(autoPrompt(arrival({ firstPhase: 'home' }), mem).why, 'not-arrival');
|
||||
assert.equal(autoPrompt(arrival({ firstPhase: 'welcome' }), mem).why, 'not-arrival');
|
||||
// the updater opened the app (first run after an update), not the person
|
||||
assert.equal(autoPrompt(arrival({ updatedFrom: '0.1.3' }), mem).why, 'updater');
|
||||
// nothing in the context is read as a prompt
|
||||
assert.equal(autoPrompt(null, null).prompt, false);
|
||||
});
|
||||
|
||||
test('the idle lock choices and their labels', () => {
|
||||
assert.deepEqual(L.IDLE_CHOICES, [1, 5, 15, 60, 0]);
|
||||
assert.equal(L.IDLE_DEFAULT_MIN, 5);
|
||||
assert.deepEqual(L.IDLE_CHOICES.map(idleLabel), ['1 minute', '5 minutes', '15 minutes', '1 hour', 'never']);
|
||||
assert.equal(idleLabel('15'), '15 minutes'); assert.equal(idleLabel(undefined), 'never'); assert.equal(idleLabel(120), '2 hours');
|
||||
});
|
||||
1
app/igneum-wallet/ui/mark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"/><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"/></svg>
|
||||
|
After Width: | Height: | Size: 214 B |
104
app/igneum-wallet/ui/update-card.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/* The update card (pure; update-card.test.mjs loads this file): the same card, rules and words as the miner's
|
||||
(app/igneum-app/ui/app.js, UpdateCard), with the wallet's name and the wallet's reasons to wait.
|
||||
A centred card over the window for one update: the mark with a progress ring, "Igneum Wallet 0.1.3", one line
|
||||
(is available, is downloading, is ready to install, Installing, did not install), up to three lines of release
|
||||
notes with the rest behind "What changed", the size, Install now and Later.
|
||||
model() turns state.update into what the card says. decide() says whether it shows now, given the window
|
||||
(ctx) and what the card already did (mem: open, later, seen):
|
||||
deferred while the send screen is open, while Touch ID (or Windows Hello) is on screen, while a wallet is
|
||||
being created or imported, while the app quits
|
||||
later Later, Escape or the backdrop hides this version at this stage; the banner keeps it
|
||||
back a newer version, or the download ready while automatic updates are off (with them on it installs
|
||||
by itself, so a dismissed download stays dismissed); an open card follows its update to the end
|
||||
installing and failed show only on an open card (Install now was pressed here); the banner carries the rest */
|
||||
var UpdateCard = (function () {
|
||||
'use strict';
|
||||
var NAME = 'Igneum Wallet', LINE_MAX = 70, LINES = 3;
|
||||
var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'later', label: 'Later' };
|
||||
var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true };
|
||||
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
|
||||
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '…' : t; }
|
||||
// the manifest's notes as sentences: split on line breaks, then after . ! ? ; (no lookbehind: the Mac WebView)
|
||||
function sentences(text) {
|
||||
var out = [], parts = String(text || '').split(/\n+/);
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var p = parts[i], at = 0;
|
||||
for (var j = 0; j < p.length; j++) {
|
||||
var ch = p.charAt(j), next = p.charAt(j + 1);
|
||||
if ((ch === '.' || ch === '!' || ch === '?' || ch === ';') && (next === ' ' || next === '')) { push(p.slice(at, j + 1)); at = j + 1; }
|
||||
}
|
||||
push(p.slice(at));
|
||||
}
|
||||
function push(s) { s = s.trim().replace(/[;:,]+$/, ''); if (s) out.push(cap(s)); }
|
||||
return out;
|
||||
}
|
||||
// at most LINES lines, each under LINE_MAX characters (cut at a word); more = something is left for "What changed"
|
||||
function splitNotes(text) {
|
||||
var all = sentences(text), lines = [], more = all.length > LINES;
|
||||
for (var i = 0; i < all.length && lines.length < LINES; i++) {
|
||||
var s = all[i].replace(/\.$/, '');
|
||||
if (s.length >= LINE_MAX) { var cut = s.lastIndexOf(' ', LINE_MAX - 2); s = s.slice(0, cut > 20 ? cut : LINE_MAX - 2).replace(/[,;:]$/, '') + '…'; more = true; }
|
||||
lines.push(s);
|
||||
}
|
||||
return { lines: lines, more: more, all: all };
|
||||
}
|
||||
function size(bytes) { if (!(bytes > 0)) return ''; return bytes < 1e6 ? (bytes / 1e6).toFixed(1) + ' MB' : Math.round(bytes / 1e6) + ' MB'; }
|
||||
function card(stage, u, over) {
|
||||
var ver = u.version || '';
|
||||
var m = { stage: stage, version: ver, key: 'update:' + ver + ':' + (stage === 'available' || stage === 'downloading' || stage === 'staging' ? 'pending' : stage), name: NAME + ' ' + ver, line: '', note: '', cause: '', size: size(u.size), pct: -1, ring: 'none', actions: [], dismissable: true, auto: !!u.auto };
|
||||
var n = splitNotes(u.notes); m.lines = n.lines; m.more = n.more; m.all = n.all;
|
||||
if (over) for (var k in over) m[k] = over[k];
|
||||
return m;
|
||||
}
|
||||
// u = state.update; s = { version, quitting }
|
||||
function model(u, s) {
|
||||
if (!u || !u.version) return null;
|
||||
s = s || {};
|
||||
var pct = u.progress > 0 ? Math.min(100, Math.round(u.progress * 100)) : 0;
|
||||
if (u.status === 'error') {
|
||||
if (/no update manifest configured/.test(u.error || '')) return null;
|
||||
return card('failed', u, { line: u.rolled_back ? 'did not stay up and was rolled back.' : 'did not install.', cause: firstLine(u.error, 120), ring: 'failed', actions: [RETRY, LATER], lines: [], more: false });
|
||||
}
|
||||
if (u.applying || u.status === 'applying' || (u.status === 'ready' && u.wait === 'installing now')) {
|
||||
return card('installing', u, { line: 'Installing. The app restarts itself.', note: s.quitting ? 'A moment.' : 'Your key stays where it is.', ring: 'busy', dismissable: false, lines: [], more: false });
|
||||
}
|
||||
var m = null;
|
||||
switch (u.status) {
|
||||
case 'available': m = card('available', u, { line: 'is available.', actions: [INSTALL, LATER] }); break;
|
||||
case 'downloading': m = card('downloading', u, { line: 'is downloading.', pct: pct, ring: 'progress', actions: [INSTALL, LATER] }); break;
|
||||
case 'staging': m = card('staging', u, { line: 'is being checked.', pct: 100, ring: 'progress', actions: [INSTALL, LATER] }); break;
|
||||
case 'ready':
|
||||
var why = /failed to install before/.test(u.wait || '') ? 'It failed to install before.' : u.auto ? 'It installs by itself when nothing is being sent.' : '';
|
||||
m = card('ready', u, { line: 'is ready to install.', note: why, ring: 'full', actions: [INSTALL, LATER] }); break;
|
||||
case 'deferred': m = card('deferred', u, { line: 'is waiting for permission.', note: 'It installs the next time someone is at this PC.', ring: 'full', actions: [INSTALL, LATER] }); break;
|
||||
case 'manual': m = card('manual', u, { line: 'is downloaded.', note: 'Open the disk image and drag the app over the old one.', ring: 'full', actions: [OPEN, LATER] }); break;
|
||||
}
|
||||
if (!m) return null;
|
||||
if (u.urgent && u.urgent_text) { m.note = u.urgent_text; m.dismissable = false; m.actions = m.actions.filter(function (a) { return a.act !== 'later'; }); }
|
||||
return m;
|
||||
}
|
||||
// why the card may not open now: ctx = { phase, view, bioBusy, bioLine, bioName, quitting }
|
||||
function blocked(ctx) {
|
||||
ctx = ctx || {};
|
||||
if (ctx.quitting) return 'the app is quitting';
|
||||
if (ctx.phase === 'create' || ctx.phase === 'import') return 'a wallet is being ' + (ctx.phase === 'create' ? 'created' : 'imported');
|
||||
if (ctx.view === 'send') return 'the send screen is open';
|
||||
if (ctx.bioBusy || ctx.bioLine) return (ctx.bioName || 'Touch ID') + ' is on screen';
|
||||
return '';
|
||||
}
|
||||
// mem = { open: the card is up, later: the key Later was pressed on, seen: the version the card was shown for }
|
||||
function decide(m, ctx, mem) {
|
||||
mem = mem || {};
|
||||
if (!m) return { show: false, why: 'none' };
|
||||
var b = blocked(ctx);
|
||||
if (b && m.stage !== 'installing') return { show: false, why: 'deferred', reason: b };
|
||||
if (m.stage === 'installing' || m.stage === 'failed') return mem.open ? { show: true, why: 'open' } : { show: false, why: 'strip' };
|
||||
if (!m.dismissable) return { show: true, why: 'urgent' };
|
||||
if (mem.later === m.key) return { show: false, why: 'later' };
|
||||
if (mem.open) return { show: true, why: 'open' };
|
||||
if (m.stage === 'ready' && m.auto && mem.seen === m.version) return { show: false, why: 'auto' };
|
||||
return { show: true, why: m.stage === 'ready' ? 'ready' : 'new' };
|
||||
}
|
||||
return { NAME: NAME, LINE_MAX: LINE_MAX, LINES: LINES, sentences: sentences, splitNotes: splitNotes, size: size, model: model, blocked: blocked, decide: decide };
|
||||
})();
|
||||
if (typeof module === 'object' && module && module.exports) module.exports = UpdateCard;
|
||||
97
app/igneum-wallet/ui/update-card.test.mjs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// node --test app/igneum-wallet/ui/update-card.test.mjs (no dependencies)
|
||||
// Loads ui/update-card.js (plain browser JS, run with `module` defined and no `document`) and checks what the card
|
||||
// says for each update state, the release-note lines, and when it shows or waits (a send, Touch ID, a wallet flow).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'update-card.js'), 'utf8');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const C = mod.exports;
|
||||
const { model, decide, blocked, splitNotes, sentences, size } = C;
|
||||
|
||||
const NOTES = 'The update card: one centred card with Install now and Later. Touch ID confirms every send on the Mac; Windows Hello is written but untested. The coin and the chain line sit on the balance card. The version shows in the header and in Settings.';
|
||||
const upd = (over) => ({ status: 'ready', version: '0.1.3', url: '', notes: NOTES, file: '', error: '', checked_at: 0, available: true, downloaded: true, ready: true, applying: false, progress: 1, size: 19_479_807, auto: true, wait: '', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '', ...over });
|
||||
const quiet = { phase: 'home', view: 'overview', bioBusy: false, bioLine: false, bioName: 'Touch ID', quitting: false };
|
||||
|
||||
test('release notes: sentences, three lines under 70 characters, the rest behind What changed', () => {
|
||||
assert.deepEqual(sentences('one. two! three? four; five'), ['One.', 'Two!', 'Three?', 'Four', 'Five']);
|
||||
assert.deepEqual(sentences('v0.1.3 ships. 24 words.'), ['V0.1.3 ships.', '24 words.']);
|
||||
const n = splitNotes(NOTES);
|
||||
assert.equal(n.lines.length, 3);
|
||||
for (const l of n.lines) assert.ok(l.length < C.LINE_MAX, `${l.length}: ${l}`);
|
||||
assert.deepEqual(n.lines, ['The update card: one centred card with Install now and Later', 'Touch ID confirms every send on the Mac', 'Windows Hello is written but untested']);
|
||||
assert.equal(n.more, true); assert.equal(n.all.length, 5);
|
||||
assert.deepEqual(splitNotes('coin on the home screen, updates install by themselves'), { lines: ['Coin on the home screen, updates install by themselves'], more: false, all: ['Coin on the home screen, updates install by themselves'] });
|
||||
assert.deepEqual(splitNotes('').lines, []);
|
||||
assert.equal(size(19_479_807), '19 MB'); assert.equal(size(0), '');
|
||||
});
|
||||
|
||||
test('what the card says: available, downloading with the ring, ready, installing, failed, manual, waiting', () => {
|
||||
const a = model(upd({ status: 'available', downloaded: false, ready: false, progress: 0 }), {});
|
||||
assert.equal(a.name, 'Igneum Wallet 0.1.3'); assert.equal(a.line, 'is available.'); assert.equal(a.key, 'update:0.1.3:pending');
|
||||
assert.equal(a.size, '19 MB'); assert.deepEqual(a.actions.map((x) => x.label), ['Install now', 'Later']);
|
||||
const d = model(upd({ status: 'downloading', downloaded: false, ready: false, progress: 0.43 }), {});
|
||||
assert.equal(d.line, 'is downloading.'); assert.equal(d.pct, 43); assert.equal(d.ring, 'progress'); assert.equal(d.key, a.key);
|
||||
const r = model(upd(), {});
|
||||
assert.equal(r.line, 'is ready to install.'); assert.equal(r.note, 'It installs by itself when nothing is being sent.'); assert.equal(r.key, 'update:0.1.3:ready');
|
||||
assert.equal(model(upd({ auto: false, wait: 'waiting for Install now (automatic updates are off)' }), {}).note, '');
|
||||
assert.equal(model(upd({ wait: 'a send is in flight; installing after it' }), {}).note, 'It installs by itself when nothing is being sent.');
|
||||
const i = model(upd({ status: 'applying', applying: true }), {});
|
||||
assert.equal(i.stage, 'installing'); assert.equal(i.line, 'Installing. The app restarts itself.'); assert.deepEqual(i.actions, []); assert.equal(i.dismissable, false);
|
||||
assert.equal(model(upd({ wait: 'installing now' }), {}).stage, 'installing');
|
||||
const f = model(upd({ status: 'error', error: 'sha256 mismatch: the file is not what the manifest signed\nsecond line', ready: false }), {});
|
||||
assert.equal(f.line, 'did not install.'); assert.equal(f.cause, 'sha256 mismatch: the file is not what the manifest signed');
|
||||
assert.deepEqual(f.actions.map((x) => x.label), ['Try again', 'Later']);
|
||||
assert.equal(model(upd({ status: 'error', error: 'did not stay up', rolled_back: '0.1.3: did not stay up' }), {}).line, 'did not stay up and was rolled back.');
|
||||
assert.equal(model(upd({ status: 'manual', ready: false }), {}).actions[0].label, 'Open the download');
|
||||
assert.equal(model(upd({ status: 'deferred' }), {}).line, 'is waiting for permission.');
|
||||
const u = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'This version is no longer supported. Installing 0.1.3 now.' }), {});
|
||||
assert.equal(u.dismissable, false); assert.deepEqual(u.actions.map((x) => x.label), ['Install now']);
|
||||
assert.equal(model(upd({ status: 'current', available: false }), {}), null);
|
||||
assert.equal(model(upd({ status: 'off', version: '' }), {}), null);
|
||||
assert.equal(model(upd({ status: 'error', error: 'no update manifest configured in this build' }), {}), null);
|
||||
assert.equal(model(null, {}), null);
|
||||
});
|
||||
|
||||
test('deferred: the send screen, Touch ID on screen, a wallet being created or imported, quitting', () => {
|
||||
assert.equal(blocked(quiet), '');
|
||||
assert.equal(blocked({ ...quiet, view: 'send' }), 'the send screen is open');
|
||||
assert.equal(blocked({ ...quiet, bioBusy: true }), 'Touch ID is on screen');
|
||||
assert.equal(blocked({ ...quiet, bioLine: true, bioName: 'Windows Hello' }), 'Windows Hello is on screen');
|
||||
assert.equal(blocked({ ...quiet, phase: 'create' }), 'a wallet is being created');
|
||||
assert.equal(blocked({ ...quiet, phase: 'import' }), 'a wallet is being imported');
|
||||
assert.equal(blocked({ ...quiet, phase: 'unlock' }), '');
|
||||
assert.equal(blocked({ ...quiet, quitting: true }), 'the app is quitting');
|
||||
const a = model(upd({ status: 'available' }), {});
|
||||
assert.deepEqual(decide(a, { ...quiet, view: 'send' }, {}), { show: false, why: 'deferred', reason: 'the send screen is open' });
|
||||
assert.deepEqual(decide(a, { ...quiet, bioBusy: true }, {}), { show: false, why: 'deferred', reason: 'Touch ID is on screen' });
|
||||
// the block lifts: the card comes (it was queued, not dismissed)
|
||||
assert.equal(decide(a, quiet, { open: false, later: '', seen: '' }).show, true);
|
||||
assert.equal(decide(model(upd({ status: 'applying', applying: true }), {}), { ...quiet, view: 'send' }, { open: true }).show, true);
|
||||
});
|
||||
|
||||
test('Later: hides this version at this stage; back for a newer version or a ready download with auto off', () => {
|
||||
const pend = model(upd({ status: 'downloading', progress: 0.5, ready: false }), {});
|
||||
const ready = model(upd(), {});
|
||||
const readyOff = model(upd({ auto: false }), {});
|
||||
assert.deepEqual(decide(pend, quiet, {}), { show: true, why: 'new' });
|
||||
const later = { open: false, later: pend.key, seen: '0.1.3' };
|
||||
assert.deepEqual(decide(pend, quiet, later), { show: false, why: 'later' });
|
||||
assert.deepEqual(decide(ready, quiet, later), { show: false, why: 'auto' });
|
||||
assert.deepEqual(decide(readyOff, quiet, later), { show: true, why: 'ready' });
|
||||
assert.deepEqual(decide(readyOff, quiet, { open: false, later: readyOff.key, seen: '0.1.3' }), { show: false, why: 'later' });
|
||||
assert.deepEqual(decide(model(upd({ status: 'available', version: '0.1.4' }), {}), quiet, later), { show: true, why: 'new' });
|
||||
assert.deepEqual(decide(ready, quiet, { open: false, later: '', seen: '' }), { show: true, why: 'ready' });
|
||||
const open = { open: true, later: '', seen: '0.1.3' };
|
||||
assert.equal(decide(pend, quiet, open).why, 'open'); assert.equal(decide(ready, quiet, open).why, 'open');
|
||||
assert.equal(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, open).show, true);
|
||||
assert.equal(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, open).show, true);
|
||||
assert.deepEqual(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, {}), { show: false, why: 'strip' });
|
||||
assert.deepEqual(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, {}), { show: false, why: 'strip' });
|
||||
const urgent = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'Unsupported.' }), {});
|
||||
assert.deepEqual(decide(urgent, quiet, { later: urgent.key }), { show: true, why: 'urgent' });
|
||||
});
|
||||
203
app/igneum-wallet/ui/view.test.mjs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
// node --test app/igneum-wallet/ui/view.test.mjs (no dependencies)
|
||||
// Loads the View block of app.js (plain browser JS: the file is run with `module` defined and no `document`, so only
|
||||
// the pure block executes) and checks the words each page shows for a given state (wallet-ui-3).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const V = mod.exports.View;
|
||||
|
||||
const ME = '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf', B = '0x2b5ad5c4795c026514f8317c7a215e218dccd6cf';
|
||||
const NOW = 1_791_000_000;
|
||||
const node = (over) => ({ source: 'miner', state: 'ok', chain_id: 7762959, chain: 'igneum-devnet-20', block: 140286, evm: 'http://127.0.0.1:26790', grpc: '127.0.0.1:26610', synced: true, message: "using the miner app's node on this machine", finality_active: true, latest_locked: 4674, ...over });
|
||||
const fin = (over) => ({ verified_index: 4674, verified_hash: '0xf1', chain_number: 140246, signers: 11, voters: 12, fraction_total: 0.912, fraction_active: 0.98, weights_exact: true, verified_at: NOW - 14, message: 'checkpoint 4674 verified here', ...over });
|
||||
const st = (over) => ({ version: '0.1.5', phase: 'home', balance: '31.6321', balance_known: true, scanning: false, scanned_to: 140286, node: node(), finality: fin(), settings: { network: 'devnet', start_at_login: false, auto_update: true, idle_lock_min: 5, ask_on_open: true }, update: { status: 'current', version: '0.1.5', checked_at: NOW - 780 }, now: NOW, ...over });
|
||||
const entry = (over) => ({ hash: '0x' + 'c3d4'.repeat(16), kind: 'received', block: 140262, block_hash: '0xabcd', from: B, to: ME, value: '250000000000000000', fee: '25380000000000', ok: true, time: NOW - 140, finality: 'in_block', checkpoint: null, note: '', ...over });
|
||||
|
||||
test('the five pages and their order', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['home', 'send', 'receive', 'history', 'settings']);
|
||||
assert.equal(V.page('history').title, 'History');
|
||||
assert.equal(V.page('nonsense').id, 'home');
|
||||
});
|
||||
|
||||
test('numbers: IGN from wei, short addresses, times', () => {
|
||||
assert.equal(V.ign('1500000000000000000'), '1.5');
|
||||
assert.equal(V.ign('250000000000000000', 6), '0.25');
|
||||
assert.equal(V.ign('10140000000000000000', 4), '10.14');
|
||||
assert.equal(V.ign('0'), '0');
|
||||
assert.equal(V.ign('nonsense'), '0');
|
||||
assert.equal(V.ign('76140000000000', 9), '0.00007614');
|
||||
assert.equal(V.shortHex('0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf'), '0x7E5F45\u20265Bdf');
|
||||
assert.equal(V.shortHex(''), '');
|
||||
assert.equal(V.withCommas(140286), '140,286');
|
||||
assert.equal(V.ago(14), '14 s ago'); assert.equal(V.ago(780), '13 min ago'); assert.equal(V.ago(7200), '2 h ago');
|
||||
assert.match(V.timeWord(NOW - 140, NOW), /^\d\d:\d\d$/);
|
||||
assert.match(V.timeWord(NOW - 86400 * 3, NOW), /^\d+ \w+ \d\d:\d\d$/);
|
||||
});
|
||||
|
||||
test('the balance: the number only when known, else one line that says why (never a 0)', () => {
|
||||
assert.deepEqual(V.balanceLine(st()), { known: true, text: '' });
|
||||
assert.deepEqual(V.balanceLine(st({ balance_known: false })), { known: false, text: 'reading the balance' });
|
||||
assert.deepEqual(V.balanceLine(st({ balance_known: false, scanning: true, scanned_to: 61200, node: node({ block: 140270 }) })), { known: false, text: 'reading the chain, 61,200 of 140,270 blocks' });
|
||||
assert.deepEqual(V.balanceLine(st({ balance_known: false, node: node({ state: 'starting' }) })), { known: false, text: 'waiting for the node to start' });
|
||||
assert.deepEqual(V.balanceLine(st({ balance_known: false, node: node({ state: 'off', source: 'none' }) })), { known: false, text: 'waiting for a node' });
|
||||
});
|
||||
|
||||
test('money first when a price exists; else the one grey line, devnet or not', () => {
|
||||
assert.deepEqual(V.moneyLine(st()), { money: '', text: 'no market price on devnet: coins have no value' });
|
||||
assert.deepEqual(V.moneyLine(st({ settings: { network: 'testnet' } })), { money: '', text: 'no market price yet' });
|
||||
assert.deepEqual(V.moneyLine(st({ price_gbp_per_ign: 0.5 })), { money: '\u00a315.82', text: "at today's price" });
|
||||
// a price with an unknown balance is no money line
|
||||
assert.equal(V.moneyLine(st({ price_gbp_per_ign: 0.5, balance_known: false })).money, '');
|
||||
});
|
||||
|
||||
test('the node line: the state word, the source in plain words, the block, the verification', () => {
|
||||
assert.deepEqual(V.nodeLine(st()), { text: "Node synced \u00b7 the miner app's node \u00b7 block 140,286 \u00b7 verified to checkpoint 4,674", sub: '', tone: 'ok' });
|
||||
const pub = st({ node: node({ source: 'public', grpc: '', finality_active: false, latest_locked: 0, message: 'using the public RPC; no node here, so finality cannot be verified' }), finality: fin({ verified_index: 0, chain_number: null, message: 'no node here' }) });
|
||||
assert.equal(V.nodeLine(pub).text, 'Node synced \u00b7 the public RPC \u00b7 block 140,286 \u00b7 certificates cannot be verified without a node');
|
||||
const own = st({ node: node({ source: 'own', state: 'starting', block: 0, message: 'the bundled node is starting; the chain syncs from the seed' }), finality: fin({ verified_index: 0 }) });
|
||||
assert.deepEqual(V.nodeLine(own), { text: 'Node starting \u00b7 the bundled node', sub: 'the bundled node is starting; the chain syncs from the seed', tone: '' });
|
||||
const none = st({ node: node({ source: 'none', state: 'off', block: 0, message: 'no node: install Igneum Miner, or wait for the bundled node' }), finality: fin({ verified_index: 0 }) });
|
||||
assert.deepEqual(V.nodeLine(none), { text: 'Node not found', sub: 'no node: install Igneum Miner, or wait for the bundled node', tone: 'bad' });
|
||||
const lost = V.nodeLine(st({ node: node({ state: 'lost', message: 'history: connect 127.0.0.1:26790: Connection refused (os error 61)' }) }));
|
||||
assert.equal(lost.tone, 'bad'); assert.equal(lost.sub, 'the node stopped answering; trying again');
|
||||
assert.equal(V.nodeLine(st({ finality: fin({ verified_index: 0 }) })).text, "Node synced \u00b7 the miner app's node \u00b7 block 140,286 \u00b7 waiting for the first certificate");
|
||||
assert.equal(V.sourceWords('external'), 'the node named by the environment');
|
||||
});
|
||||
|
||||
test('the node details: every old card row with a one-line meaning, no n/a', () => {
|
||||
const rows = V.nodeDetails(st(), NOW);
|
||||
const keys = rows.map((r) => r[0]);
|
||||
assert.deepEqual(keys, ['source', 'chain', 'block', 'endpoint', 'finality on the node', 'verified here', 'signed by', 'checkpoint height', 'weights', 'checked']);
|
||||
assert.equal(rows[5][1], 'checkpoint 4,674'); assert.equal(rows[5][3], 'ok');
|
||||
assert.equal(rows[6][1], '11 of 12 voters'); assert.match(rows[6][2], /^91\.2% of all weight/);
|
||||
assert.equal(rows[9][1], '14 s ago');
|
||||
for (const r of rows) assert.ok(!/n\/a/.test(r[1]) && r[1] !== '', r[0]);
|
||||
const none = V.nodeDetails(st({ node: node({ source: 'public', evm: 'https://rpc.example', finality_active: false }), finality: fin({ verified_index: 0, message: 'no node here' }) }), NOW);
|
||||
assert.equal(none.find((r) => r[0] === 'finality on the node')[1], 'not checkable without a node');
|
||||
assert.deepEqual(none.find((r) => r[0] === 'verified here').slice(1), ['nothing yet', 'no node here', 'dim']);
|
||||
});
|
||||
|
||||
test('the pill: the wallet\'s own words', () => {
|
||||
assert.deepEqual(V.pillWords(st()), { text: 'synced', cls: 'on' });
|
||||
assert.deepEqual(V.pillWords(st({ node: node({ source: 'public' }) })), { text: 'public rpc', cls: 'on' });
|
||||
assert.deepEqual(V.pillWords(st({ node: node({ state: 'starting' }) })), { text: 'starting', cls: '' });
|
||||
assert.deepEqual(V.pillWords(st({ node: node({ state: 'off', source: 'none' }) })), { text: 'no node', cls: 'warn' });
|
||||
assert.deepEqual(V.pillWords(st({ node: node({ state: 'lost' }) })), { text: 'node lost', cls: 'warn' });
|
||||
assert.deepEqual(V.pillWords(st({ phase: 'unlock' })), { text: 'locked', cls: '' });
|
||||
assert.deepEqual(V.pillWords(st({ quitting: true })), { text: 'stopping', cls: '' });
|
||||
});
|
||||
|
||||
test('one state word per row: the wallet\'s verified final wins, failed from the receipt, else the node\'s word, else the label', () => {
|
||||
const e = entry();
|
||||
assert.deepEqual(V.stateWord(entry({ finality: 'final', checkpoint: 4673, block: 139900 }), 'executed'), { word: 'finalised', tone: 'ok', why: 'under checkpoint 4,673, verified here' });
|
||||
assert.deepEqual(V.stateWord(entry({ finality: 'failed', ok: false }), 'executed'), { word: 'failed', tone: 'bad', why: 'the execution failed; the fee was still paid' });
|
||||
assert.deepEqual(V.stateWord(e, 'pending'), { word: 'pending', tone: '', why: 'waiting for a block' });
|
||||
assert.deepEqual(V.stateWord(e, 'included'), { word: 'included', tone: '', why: 'in block 140,262, not executed yet' });
|
||||
assert.deepEqual(V.stateWord(e, 'executed'), { word: 'executed', tone: 'ink', why: 'in block 140,262' });
|
||||
assert.deepEqual(V.stateWord(e, 'proven'), { word: 'proven', tone: 'ink', why: 'in block 140,262, proof paid' });
|
||||
// the node says finalised but this wallet has not verified the certificate: the word, not the molten tone
|
||||
assert.deepEqual(V.stateWord(e, 'finalised'), { word: 'finalised', tone: 'ink', why: 'under a locked checkpoint, not yet verified here' });
|
||||
assert.deepEqual(V.stateWord(e, 'finality not active'), { word: 'finality not active', tone: 'ink', why: 'under a locked checkpoint; finality is paused on the network' });
|
||||
assert.equal(V.stateWord(e, 'unknown').word, 'pending');
|
||||
// no node word yet (the public RPC, or not asked): the wallet's label
|
||||
assert.deepEqual(V.stateWord(e, ''), { word: 'included', tone: '', why: 'in block 140,262' });
|
||||
assert.deepEqual(V.stateWord(entry({ finality: 'pending', block: 0 }), ''), { word: 'pending', tone: '', why: 'waiting for a block' });
|
||||
assert.equal(V.stateWord(entry({ finality: 'pending', block: 0 }), 'included').why, 'in a block, not executed yet');
|
||||
// a reward has no transaction hash: executed in its block, finalised under a verified checkpoint
|
||||
const r = entry({ hash: 'reward-140201-0', kind: 'reward', from: '', block: 140201 });
|
||||
assert.deepEqual(V.stateWord(r, ''), { word: 'executed', tone: 'ink', why: 'in block 140,201' });
|
||||
assert.equal(V.stateWord(entry({ hash: 'proving-1-0', kind: 'proving', finality: 'final', checkpoint: 9 }), '').word, 'finalised');
|
||||
// never a stronger word than the chain's: the words the row can show are exactly the node's plus failed
|
||||
const words = new Set(['pending', 'included', 'executed', 'proven', 'finalised', 'finality not active', 'failed']);
|
||||
for (const w of ['pending', 'included', 'executed', 'proven', 'finalised', 'finality not active', 'unknown', '']) assert.ok(words.has(V.stateWord(e, w).word), w);
|
||||
});
|
||||
|
||||
test('the row model: kind word, who, sign, amount, the rows that need the node\'s word', () => {
|
||||
const m = V.rowModel(entry(), 'included', NOW);
|
||||
assert.equal(m.kind, 'Received'); assert.equal(m.who, 'from 0x2b5ad5\u2026d6cf'); assert.equal(m.amount, '+0.25'); assert.equal(m.in, true); assert.equal(m.synthetic, false); assert.equal(m.nodeWord, 'included');
|
||||
const s = V.rowModel(entry({ kind: 'sent', from: ME, to: B, value: '1500000000000000000' }), '', NOW);
|
||||
assert.equal(s.kind, 'Sent'); assert.equal(s.who, 'to 0x2b5ad5\u2026d6cf'); assert.equal(s.amount, '\u22121.5'); assert.equal(s.in, false);
|
||||
assert.equal(V.rowModel(entry({ kind: 'self', from: ME, to: ME }), '', NOW).who, 'to yourself');
|
||||
assert.equal(V.rowModel(entry({ hash: 'reward-1-0', kind: 'reward' }), '', NOW).who, 'block reward');
|
||||
assert.equal(V.rowModel(entry({ hash: 'proving-1-0', kind: 'proving' }), '', NOW).kind, 'Proving payout');
|
||||
const list = [entry({ hash: '0x01', finality: 'pending' }), entry({ hash: '0x02' }), entry({ hash: 'reward-1-0', kind: 'reward' }), entry({ hash: '0x03', finality: 'final', checkpoint: 1 }), entry({ hash: '0x04', finality: 'failed' }), entry({ hash: '0x05' })];
|
||||
assert.deepEqual(V.needsNodeWord(list, 12), ['0x01', '0x02', '0x05']);
|
||||
assert.deepEqual(V.needsNodeWord(list, 2), ['0x01', '0x02']);
|
||||
});
|
||||
|
||||
test('send: the fee line, the gas words behind Details, the question in place', () => {
|
||||
const q = { to: B, display_to: '0x2B5AD5c4795c026514f8317c7a215E218DCCD6cF', value_ign: '1.5', fee_max_ign: '0.00007614', total_max_ign: '1.50007614', gas: 25380, base_fee_gwei: '1', tip_gwei: '1', max_fee: '3000000000' };
|
||||
assert.deepEqual(V.feeWords(null), { line: 'shown when you review', detail: '' });
|
||||
const f = V.feeWords(q);
|
||||
assert.equal(f.line, 'at most 0.00007614 IGN');
|
||||
assert.equal(f.detail, 'Gas 25,380 at a base fee of 1 gwei (burned) plus a 1 gwei tip (to the miner), capped at 3 gwei; what is not used comes back.');
|
||||
assert.equal(V.gwei('1500000000'), '1.5 gwei'); assert.equal(V.gwei('25000000000'), '25 gwei'); assert.equal(V.gwei('250000'), '0.00025 gwei'.replace('0.00025', '0'));
|
||||
assert.deepEqual(V.sendAsk(q, true, 'Touch ID'), { text: 'Send 1.5 IGN to 0x2B5AD5\u2026D6cF? Fee at most 0.00007614 IGN; 1.50007614 IGN leaves the wallet at most.', button: 'Confirm with Touch ID' });
|
||||
assert.equal(V.sendAsk(q, false).button, 'Send now');
|
||||
assert.equal(V.sendAsk(q, true, 'Windows Hello').button, 'Confirm with Windows Hello');
|
||||
});
|
||||
|
||||
test('settings: the Touch ID sentence, the idle sentence, the update card note', () => {
|
||||
assert.equal(V.bioSentence({ enrolled: true }, 'Touch ID', true), 'Touch ID is on. It unlocks the wallet, confirms each send and shows the backup; the password still works everywhere.');
|
||||
assert.equal(V.bioSentence({ enrolled: false, available: true }, 'Touch ID', false), 'Touch ID needs the Igneum Wallet app window; this page is open in a browser.');
|
||||
assert.equal(V.bioSentence({ enrolled: false, available: false }, 'Touch ID', true), 'Touch ID is not set up on this Mac.');
|
||||
assert.equal(V.bioSentence({ enrolled: false, available: false, message: 'Windows Hello is not configured.' }, 'Windows Hello', true), 'Windows Hello is not configured.');
|
||||
assert.equal(V.bioSentence({ enrolled: false, available: true }, 'Windows Hello', true), 'Unlock, confirm each send and show the backup with Windows Hello. The password still works everywhere.');
|
||||
assert.equal(V.idleSentence(5, 'Touch ID'), 'The key is cleared after 5 minutes without you; Touch ID or the password opens it again.');
|
||||
assert.equal(V.idleSentence(0, 'Touch ID'), 'The wallet stays open until you lock it.');
|
||||
assert.equal(V.updateNote({ status: 'current', version: '0.1.5', checked_at: NOW - 780 }, NOW), 'Up to date, checked 13 min ago.');
|
||||
assert.equal(V.updateNote({ status: 'unknown', version: '' }, NOW), 'Not checked yet.');
|
||||
assert.equal(V.updateNote({ status: 'ready', version: '0.1.6', wait: 'installs as soon as nothing is being sent' }, NOW), 'Igneum Wallet 0.1.6 is ready. Installs as soon as nothing is being sent.');
|
||||
assert.equal(V.updateNote({ status: 'current', version: '0.1.5', updated_from: '0.1.4', checked_at: NOW - 60 }, NOW), 'Updated from 0.1.4. Up to date, checked 1 min ago.');
|
||||
assert.equal(V.updateNote({ status: 'off', version: '' }, NOW), 'Updates are off in this build.');
|
||||
assert.equal(V.updateLine({ status: 'downloading', version: '0.1.6', size: 20080717, progress: 0.43 }).text, 'Downloading Igneum Wallet 0.1.6 (20 MB): 43%');
|
||||
assert.equal(V.updateLine({ status: 'current', version: '0.1.5' }), null);
|
||||
});
|
||||
|
||||
// the page bridge (0.1.6, 8 October 2026): what the window says for a page's request (connect, a transaction, a message,
|
||||
// typed data), the Settings card's site rows, the lock-screen line while a page waits. Known-failed first on 0.1.5:
|
||||
// View.bridgeWords is not a function.
|
||||
test('the page bridge: the words for each request kind, the site rows, the waiting line', () => {
|
||||
const connect = V.bridgeWords({ id: '1-ab', kind: 'connect', origin: 'https://igneum.network', created_at: 1 });
|
||||
assert.equal(connect.title, 'igneum.network wants to connect');
|
||||
assert.equal(connect.lines[0], 'It will see your address and may ask you to sign or send. Nothing leaves without your word here.');
|
||||
assert.equal(connect.yes, 'Connect'); assert.equal(connect.no, 'Decline');
|
||||
const send = V.bridgeWords({ id: '2-cd', kind: 'send', origin: 'https://igneum.network', to: '0x9a6fa842c4e58a87aef1f3ad15233d99283002b7', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value: '1000000000000000000', value_ign: '1', data_len: 36, selector: '0x38ed1739', gas: 184000, fee_max_ign: '0.000368', total_max_ign: '1.000368', confirm_needed: false });
|
||||
assert.equal(send.title, 'igneum.network asks you to send');
|
||||
assert.equal(send.amount, '1 IGN');
|
||||
assert.equal(send.lines[0], 'To 0x9a6fA8…02B7, a contract call (36 bytes, 0x38ed1739).');
|
||||
assert.equal(send.lines[1], 'Fee up to 0.000368 IGN (184,000 gas). Total up to 1.000368 IGN.');
|
||||
assert.equal(send.yes, 'Send'); assert.equal(send.no, 'Decline');
|
||||
const plain = V.bridgeWords({ id: '3', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value_ign: '0.5', data_len: 0, selector: '', gas: 21000, fee_max_ign: '0.000042', total_max_ign: '0.500042' });
|
||||
assert.equal(plain.lines[0], 'To 0x9a6fA8…02B7, a plain transfer.');
|
||||
const unpriced = V.bridgeWords({ id: '4', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value: '0', data_len: 4, selector: '0xd0e30db0' });
|
||||
assert.equal(unpriced.amount, '0 IGN'); assert.equal(unpriced.lines[1], 'Pricing the fee with the node.');
|
||||
const touch = V.bridgeWords({ id: '5', kind: 'send', origin: 'https://igneum.network', to_display: '0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7', value_ign: '1', data_len: 0, selector: '', gas: 21000, fee_max_ign: '0.00004', total_max_ign: '1.00004', confirm_needed: true }, 'Touch ID');
|
||||
assert.equal(touch.yes, 'Send with Touch ID');
|
||||
const sign = V.bridgeWords({ id: '6', kind: 'sign', origin: 'https://igneum.network', text: 'Sign in to Igneum Swap\nnonce: 42', bytes: 31 });
|
||||
assert.equal(sign.title, 'igneum.network asks you to sign a message');
|
||||
assert.equal(sign.message, 'Sign in to Igneum Swap\nnonce: 42');
|
||||
assert.equal(sign.lines[0], 'Signing proves this wallet is yours. It moves no coins.');
|
||||
assert.equal(sign.yes, 'Sign');
|
||||
const bin = V.bridgeWords({ id: '7', kind: 'sign', origin: 'https://igneum.network', text: null, bytes: 32, hex: '0x0102' });
|
||||
assert.equal(bin.message, '32 bytes: 0x0102');
|
||||
const typed = V.bridgeWords({ id: '8', kind: 'typed', origin: 'https://igneum.network', domain: 'Igneum Swap', primary_type: 'Permit', message: '{\n "owner": "0x1"\n}' });
|
||||
assert.equal(typed.title, 'igneum.network asks you to sign typed data');
|
||||
assert.equal(typed.lines[0], 'Permit for Igneum Swap. Read it before you sign: a permit can let a contract spend tokens.');
|
||||
assert.equal(typed.message, '{\n "owner": "0x1"\n}');
|
||||
// the lock-screen line and the site rows
|
||||
assert.equal(V.bridgeWaiting([connect, send].map((x) => ({ origin: 'https://igneum.network' }))), 'igneum.network is waiting: unlock to answer it');
|
||||
assert.equal(V.bridgeWaiting([]), '');
|
||||
const rows = V.siteRows([{ origin: 'https://igneum.network', approved_at: 1_800_000_000 - 3600, last_seen: 1_800_000_000 - 30 }], 1_800_000_000);
|
||||
assert.equal(rows[0].host, 'igneum.network'); assert.equal(rows[0].line, 'connected 1 h ago · last call 30 s ago');
|
||||
assert.equal(V.siteRows([], 1).length, 0);
|
||||
assert.equal(V.bridgeStatus({ on: true, listening: true, port: 26811, sites: [] }), 'On. Websites you approve can connect through port 26811 on this machine only.');
|
||||
assert.equal(V.bridgeStatus({ on: true, listening: false, port: 26811, reason: 'port 26811 is not free: in use', sites: [] }), 'Not listening: port 26811 is not free: in use. Quit whatever holds the port and open the wallet again.');
|
||||
assert.equal(V.bridgeStatus({ on: false, listening: true, port: 26811, sites: [] }), 'Off. No website can connect; the switch turns it on.');
|
||||
});
|
||||
328
app/mac/Biometric.swift
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
// Touch ID for the Igneum window hosts (IgneumWallet.swift, IgneumMiner.swift), compiled into each with
|
||||
// swiftc ... IgneumWallet.swift Biometric.swift -framework Cocoa -framework WebKit -framework LocalAuthentication
|
||||
// 5 October 2026.
|
||||
//
|
||||
// The page in the WKWebView posts {id, op, ...} to the "biometric" script message handler; the host answers with
|
||||
// window.__igneumBiometric(id, {ok, code, message, ...}). Ops: status, enrol, unlock (wallet), confirm.
|
||||
//
|
||||
// What is stored, and where. The packaging signs the apps ad hoc, and under an ad hoc signature macOS refuses any
|
||||
// Keychain item with a biometric access control (errSecMissingEntitlement, -34018, on the login keychain and the
|
||||
// data-protection keychain alike: keychain-access-groups needs a team signature). A Secure Enclave key with the same
|
||||
// control is allowed, so the secret is sealed to one instead:
|
||||
// - enrol: a P-256 key is made in the Secure Enclave with SecAccessControl(.privateKeyUsage, .biometryCurrentSet);
|
||||
// an ephemeral P-256 key agrees a shared secret with its public half (no prompt), HKDF-SHA256 makes an AES-GCM key
|
||||
// and the secret (the wallet's password; a fixed marker for the miner) is sealed. The file the engine named
|
||||
// (<data root>/<app>/biometric.json, user-only permissions) holds the Secure Enclave key's wrapped form, the
|
||||
// ephemeral public key and the box. The wrapped key is useless off this Mac's Secure Enclave.
|
||||
// - unlock or confirm: the host evaluates LAPolicy.deviceOwnerAuthenticationWithBiometrics (no fallback button: the
|
||||
// wallet's own password is the fallback, in the window), then uses the Secure Enclave key under that context.
|
||||
// .biometryCurrentSet means a fingerprint added or removed in System Settings makes the key unusable: the host
|
||||
// reports "invalidated" and the window asks for the password and a fresh enrolment.
|
||||
// The secret never goes through the page: on unlock the host posts it to the engine on 127.0.0.1 with the engine's
|
||||
// URL token; the engine's host token (X-Igneum-Host, read from the engine's stdout) marks the calls only the host
|
||||
// may make (the confirmations, taking the parked password at enrolment).
|
||||
|
||||
import Foundation
|
||||
import LocalAuthentication
|
||||
import CryptoKit
|
||||
import Security
|
||||
|
||||
final class Biometric {
|
||||
let product: String
|
||||
/// the prompt's fallback button: "Use password" for the wallet (the window then asks for it), "" for the miner
|
||||
let fallbackTitle: String
|
||||
/// the enrolment prompt's line: "Turn on Touch ID for your wallet" / "... for the miner"
|
||||
let enrolReason: String
|
||||
private(set) var engineURL = ""
|
||||
private(set) var hostToken = ""
|
||||
private(set) var file: URL?
|
||||
private let queue = DispatchQueue(label: "network.igneum.biometric")
|
||||
private let salt = Data("igneum-biometric-v1".utf8)
|
||||
private let minerMarker = "igneum-biometric-marker-v1"
|
||||
|
||||
init(product: String, fallbackTitle: String, enrolReason: String) {
|
||||
self.product = product
|
||||
self.fallbackTitle = fallbackTitle
|
||||
self.enrolReason = enrolReason
|
||||
}
|
||||
|
||||
/// From the engine's HOST line. Reports availability to the engine at once.
|
||||
func configure(engineURL: String, hostToken: String, file: String) {
|
||||
self.engineURL = engineURL
|
||||
self.hostToken = hostToken
|
||||
self.file = file.isEmpty ? nil : URL(fileURLWithPath: file)
|
||||
let s = status()
|
||||
queue.async {
|
||||
_ = self.post("api/biometric/status", ["available": s.available, "kind": "touchid", "message": s.message])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- availability ----
|
||||
|
||||
func status() -> (available: Bool, message: String) {
|
||||
let ctx = LAContext()
|
||||
var err: NSError?
|
||||
if ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) {
|
||||
if ctx.biometryType != .touchID { return (false, "This Mac has no Touch ID sensor.") }
|
||||
return (true, "")
|
||||
}
|
||||
return (false, Biometric.text(for: err, op: "status").message)
|
||||
}
|
||||
|
||||
// ---- the ops, one at a time on the queue ----
|
||||
|
||||
func handle(_ msg: [String: Any], reply: @escaping ([String: Any]) -> Void) {
|
||||
let op = msg["op"] as? String ?? ""
|
||||
queue.async {
|
||||
let r: [String: Any]
|
||||
switch op {
|
||||
case "status":
|
||||
let s = self.status()
|
||||
r = ["ok": true, "available": s.available, "kind": "touchid", "message": s.message, "enrolled": self.sealedFileExists()]
|
||||
case "enrol": r = self.enrol(token: msg["token"] as? String)
|
||||
case "unlock": r = self.unlock()
|
||||
case "confirm": r = self.confirm(nonce: msg["nonce"] as? String ?? "")
|
||||
default: r = ["ok": false, "code": "bad_op", "message": "unknown op \(op)"]
|
||||
}
|
||||
if op != "status" {
|
||||
_ = self.post("api/biometric/report", ["op": op, "ok": r["ok"] as? Bool ?? false, "code": r["code"] as? String ?? "", "message": r["message"] as? String ?? ""])
|
||||
}
|
||||
DispatchQueue.main.async { reply(r) }
|
||||
}
|
||||
}
|
||||
|
||||
private func enrol(token: String?) -> [String: Any] {
|
||||
guard let file = file else { return fail("unavailable", "The engine has not named the sealed file yet.") }
|
||||
let s = status()
|
||||
if !s.available { return fail("unavailable", s.message) }
|
||||
let ctx = context()
|
||||
if let e = evaluate(ctx, reason: enrolReason) { return e }
|
||||
// the secret: the wallet's password from the engine (one-time token), or the miner's marker
|
||||
var secret: Data
|
||||
if let t = token, !t.isEmpty {
|
||||
let r = post("api/biometric/enrol/take", ["token": t])
|
||||
guard r.ok, let p = r.json["secret"] as? String else { return fail("engine", r.json["error"] as? String ?? "the engine did not hand over the password") }
|
||||
secret = Data(p.utf8)
|
||||
} else {
|
||||
secret = Data(minerMarker.utf8)
|
||||
}
|
||||
defer { secret.resetBytes(in: 0..<secret.count) }
|
||||
do {
|
||||
var acErr: Unmanaged<CFError>?
|
||||
guard let ac = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .biometryCurrentSet], &acErr) else {
|
||||
return fail("secure_enclave", "The access control could not be made: \(acErr?.takeRetainedValue().localizedDescription ?? "unknown")")
|
||||
}
|
||||
let key = try SecureEnclave.P256.KeyAgreement.PrivateKey(accessControl: ac, authenticationContext: ctx)
|
||||
let eph = P256.KeyAgreement.PrivateKey()
|
||||
let shared = try eph.sharedSecretFromKeyAgreement(with: key.publicKey)
|
||||
let sym = shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32)
|
||||
let box = try AES.GCM.seal(secret, using: sym)
|
||||
guard let combined = box.combined else { return fail("seal", "The box has no combined form.") }
|
||||
let doc: [String: Any] = ["version": 1, "kind": "touchid", "product": product, "created": Int(Date().timeIntervalSince1970),
|
||||
"key": key.dataRepresentation.base64EncodedString(), "eph": eph.publicKey.rawRepresentation.base64EncodedString(), "box": combined.base64EncodedString()]
|
||||
let data = try JSONSerialization.data(withJSONObject: doc, options: [.sortedKeys])
|
||||
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: file, options: [.atomic])
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path)
|
||||
} catch {
|
||||
return fail("secure_enclave", "The Secure Enclave refused: \(error.localizedDescription)")
|
||||
}
|
||||
let r = post("api/biometric/enrolled", ["kind": "touchid"])
|
||||
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine did not record the enrolment") }
|
||||
return ["ok": true]
|
||||
}
|
||||
|
||||
/// Wallet: Touch ID, then the password out of the box and into the engine; never to the page.
|
||||
private func unlock() -> [String: Any] {
|
||||
guard sealedFileExists() else { return fail("not_enrolled", "Touch ID is not turned on for this wallet.") }
|
||||
let ctx = context()
|
||||
if let e = evaluate(ctx, reason: "Unlock your wallet") { return e }
|
||||
switch open(ctx) {
|
||||
case .failure(let e): return e.dict
|
||||
case .success(var secret):
|
||||
defer { secret.resetBytes(in: 0..<secret.count) }
|
||||
guard let p = String(data: secret, encoding: .utf8) else { return fail("seal", "The sealed password did not decode.") }
|
||||
let r = post("api/unlock", ["password": p])
|
||||
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine did not unlock") }
|
||||
return ["ok": true]
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's challenge: its reason on the prompt, then the confirmation with the host token. The Secure
|
||||
/// Enclave key is exercised too, so a changed fingerprint set is caught here as well.
|
||||
private func confirm(nonce: String) -> [String: Any] {
|
||||
guard !nonce.isEmpty else { return fail("bad_nonce", "No challenge.") }
|
||||
let c = get("api/biometric/challenge?nonce=\(nonce)")
|
||||
guard c.ok, let reason = c.json["reason"] as? String else { return fail("engine", c.json["error"] as? String ?? "the engine does not know this challenge") }
|
||||
let ctx = context()
|
||||
if let e = evaluate(ctx, reason: reason) { return e }
|
||||
if sealedFileExists() {
|
||||
if case .failure(let e) = agree(ctx) { return e.dict }
|
||||
}
|
||||
let r = post("api/biometric/confirm", ["nonce": nonce])
|
||||
if !r.ok { return fail("engine", r.json["error"] as? String ?? "the engine refused the confirmation") }
|
||||
return ["ok": true]
|
||||
}
|
||||
|
||||
// ---- Touch ID ----
|
||||
|
||||
func context() -> LAContext {
|
||||
let ctx = LAContext()
|
||||
// the policy is biometrics only, so the fallback button never reaches the device password: "Use password"
|
||||
// (the wallet) returns LAError.userFallback and the window asks for the wallet's own password; the miner
|
||||
// has no password, so no button
|
||||
ctx.localizedFallbackTitle = fallbackTitle
|
||||
ctx.localizedCancelTitle = "Cancel"
|
||||
return ctx
|
||||
}
|
||||
|
||||
/// nil on success, else the reply for the page.
|
||||
private func evaluate(_ ctx: LAContext, reason: String) -> [String: Any]? {
|
||||
var err: NSError?
|
||||
guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) else {
|
||||
let t = Biometric.text(for: err, op: "evaluate")
|
||||
return fail(t.code, t.message)
|
||||
}
|
||||
// macOS composes the sentence itself: "Igneum Wallet is trying to <reason>." (the bundled app's name and icon
|
||||
// are the title), so the line starts lowercase here; the engine's canonical lines keep their capital for
|
||||
// Windows Hello, which shows the line on its own
|
||||
let line = String(reason.prefix(80))
|
||||
let shown = line.prefix(1).lowercased() + line.dropFirst()
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var ok = false
|
||||
var failure: Error?
|
||||
ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: shown) { success, error in
|
||||
ok = success
|
||||
failure = error
|
||||
sem.signal()
|
||||
}
|
||||
sem.wait()
|
||||
if ok { return nil }
|
||||
let t = Biometric.text(for: failure as NSError?, op: "evaluate")
|
||||
return fail(t.code, t.message)
|
||||
}
|
||||
|
||||
static func text(for err: NSError?, op: String) -> (code: String, message: String) {
|
||||
guard let e = err else { return ("failed", "Touch ID did not succeed.") }
|
||||
if e.domain == LAErrorDomain, let la = LAError.Code(rawValue: e.code) {
|
||||
switch la {
|
||||
case .userCancel, .systemCancel, .appCancel: return ("cancelled", "Touch ID was cancelled.")
|
||||
case .authenticationFailed: return ("failed", "Touch ID did not match.")
|
||||
case .biometryLockout: return ("locked", "Touch ID is locked after too many tries. Use the password; Touch ID comes back after the Mac is unlocked with its password.")
|
||||
case .biometryNotEnrolled: return ("not_set_up", "Touch ID is not set up on this Mac. Add a fingerprint in System Settings > Touch ID & Password.")
|
||||
case .biometryNotAvailable, .passcodeNotSet: return ("unavailable", "Touch ID is not available on this Mac.")
|
||||
case .userFallback: return ("fallback", "Enter your password.")
|
||||
default: break
|
||||
}
|
||||
}
|
||||
return ("failed", "Touch ID did not succeed: \(e.localizedDescription)")
|
||||
}
|
||||
|
||||
// ---- the sealed file ----
|
||||
|
||||
func sealedFileExists() -> Bool {
|
||||
guard let f = file else { return false }
|
||||
return FileManager.default.fileExists(atPath: f.path)
|
||||
}
|
||||
|
||||
private struct Sealed {
|
||||
let key: SecureEnclave.P256.KeyAgreement.PrivateKey
|
||||
let eph: P256.KeyAgreement.PublicKey
|
||||
let box: AES.GCM.SealedBox
|
||||
}
|
||||
|
||||
private func load(_ ctx: LAContext) -> Result<Sealed, Reply> {
|
||||
guard let f = file, let data = try? Data(contentsOf: f), let doc = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let k = doc["key"] as? String, let e = doc["eph"] as? String, let b = doc["box"] as? String,
|
||||
let kd = Data(base64Encoded: k), let ed = Data(base64Encoded: e), let bd = Data(base64Encoded: b) else {
|
||||
return .failure(failure("not_enrolled", "The sealed file is missing or unreadable. Turn Touch ID on again in Settings."))
|
||||
}
|
||||
do {
|
||||
let key = try SecureEnclave.P256.KeyAgreement.PrivateKey(dataRepresentation: kd, authenticationContext: ctx)
|
||||
let eph = try P256.KeyAgreement.PublicKey(rawRepresentation: ed)
|
||||
let box = try AES.GCM.SealedBox(combined: bd)
|
||||
return .success(Sealed(key: key, eph: eph, box: box))
|
||||
} catch {
|
||||
return .failure(failure("invalidated", "Touch ID no longer opens this: the sealed key is not usable (\(error.localizedDescription)). Use the password, then turn Touch ID on again in Settings."))
|
||||
}
|
||||
}
|
||||
|
||||
/// The key agreement under the authenticated context: the Secure Enclave refuses it when the fingerprint set
|
||||
/// changed since enrolment (.biometryCurrentSet).
|
||||
private func agree(_ ctx: LAContext) -> Result<SymmetricKey, Reply> {
|
||||
switch load(ctx) {
|
||||
case .failure(let e): return .failure(e)
|
||||
case .success(let s):
|
||||
do {
|
||||
let shared = try s.key.sharedSecretFromKeyAgreement(with: s.eph)
|
||||
return .success(shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32))
|
||||
} catch {
|
||||
return .failure(failure("invalidated", "Touch ID no longer opens this: a fingerprint was added or removed since it was turned on. Use the password, then turn Touch ID on again in Settings."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ ctx: LAContext) -> Result<Data, Reply> {
|
||||
switch load(ctx) {
|
||||
case .failure(let e): return .failure(e)
|
||||
case .success(let s):
|
||||
do {
|
||||
let shared = try s.key.sharedSecretFromKeyAgreement(with: s.eph)
|
||||
let sym = shared.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(product.utf8), outputByteCount: 32)
|
||||
return .success(try AES.GCM.open(s.box, using: sym))
|
||||
} catch {
|
||||
return .failure(failure("invalidated", "Touch ID no longer opens this: a fingerprint was added or removed since it was turned on. Use the password, then turn Touch ID on again in Settings."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the engine on 127.0.0.1 ----
|
||||
|
||||
/// A reply for the page that is also an Error, so Result can carry it.
|
||||
struct Reply: Error {
|
||||
let dict: [String: Any]
|
||||
}
|
||||
|
||||
private func fail(_ code: String, _ message: String) -> [String: Any] {
|
||||
["ok": false, "code": code, "message": message]
|
||||
}
|
||||
private func failure(_ code: String, _ message: String) -> Reply {
|
||||
Reply(dict: fail(code, message))
|
||||
}
|
||||
|
||||
private struct Answer {
|
||||
let ok: Bool
|
||||
let json: [String: Any]
|
||||
}
|
||||
|
||||
private func request(_ path: String, method: String, body: [String: Any]?) -> Answer {
|
||||
guard !engineURL.isEmpty, let url = URL(string: engineURL + path) else { return Answer(ok: false, json: ["error": "no engine URL"]) }
|
||||
var req = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
req.httpMethod = method
|
||||
req.setValue(hostToken, forHTTPHeaderField: "X-Igneum-Host")
|
||||
if let b = body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: b)
|
||||
}
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var out = Answer(ok: false, json: ["error": "no answer from the engine"])
|
||||
let task = URLSession.shared.dataTask(with: req) { data, resp, error in
|
||||
defer { sem.signal() }
|
||||
if let e = error { out = Answer(ok: false, json: ["error": e.localizedDescription]); return }
|
||||
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let j = data.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] } ?? [:]
|
||||
out = Answer(ok: code == 200 && (j["ok"] as? Bool ?? true), json: j)
|
||||
}
|
||||
task.resume()
|
||||
sem.wait()
|
||||
return out
|
||||
}
|
||||
|
||||
private func post(_ path: String, _ body: [String: Any]) -> Answer {
|
||||
request(path, method: "POST", body: body)
|
||||
}
|
||||
|
||||
private func get(_ path: String) -> Answer {
|
||||
request(path, method: "GET", body: nil)
|
||||
}
|
||||
}
|
||||
399
app/mac/IgneumWallet.swift
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
// Igneum Wallet for macOS: the window around the wallet engine. A copy of IgneumMiner.swift with the names, the
|
||||
// bundle and the menu changed (no pause; a Lock item instead). Compiled by packaging/mac/build-wallet-dmg.sh with
|
||||
// swiftc -O -target arm64-apple-macos11 -o "Igneum Wallet" IgneumWallet.swift Biometric.swift -framework Cocoa -framework WebKit -framework LocalAuthentication
|
||||
// It starts Contents/MacOS/igneum-wallet --wrapper, reads "URL ...", "HOST {...}" and "STATE {...}" lines from its
|
||||
// stdout, shows the URL in a WKWebView, keeps a menu-bar item with the state, and on quit writes "quit" to the engine's
|
||||
// stdin and waits for its "EXIT" line (the bundled node stops first when one runs). Closing the window hides it; the
|
||||
// app lives on in the menu bar and the Dock. 4 October 2026. Touch ID (Biometric.swift, the "biometric" script
|
||||
// message handler): 5 October 2026.
|
||||
//
|
||||
// Snapshot mode, used to make the design screenshots without a browser:
|
||||
// "Igneum Wallet" --snapshot <out.png> --url <window url> [--size 1120x780]
|
||||
|
||||
import Cocoa
|
||||
import WebKit
|
||||
|
||||
let obsidian = NSColor(srgbRed: 12 / 255, green: 12 / 255, blue: 14 / 255, alpha: 1)
|
||||
|
||||
func flameImage(size: CGFloat) -> NSImage {
|
||||
// the brand mark's flame as a template image for the menu bar
|
||||
let img = NSImage(size: NSSize(width: size, height: size), flipped: true) { rect in
|
||||
let s = rect.width / 100
|
||||
let outer = NSBezierPath()
|
||||
let pts: [(CGFloat, CGFloat)] = [(50, 4), (74, 34), (67, 58), (80, 54), (61, 96), (39, 96), (20, 54), (33, 58), (26, 34)]
|
||||
outer.move(to: NSPoint(x: pts[0].0 * s, y: pts[0].1 * s))
|
||||
for p in pts.dropFirst() { outer.line(to: NSPoint(x: p.0 * s, y: p.1 * s)) }
|
||||
outer.close()
|
||||
let inner = NSBezierPath()
|
||||
let ip: [(CGFloat, CGFloat)] = [(50, 42), (59, 58), (50, 82), (41, 58)]
|
||||
inner.move(to: NSPoint(x: ip[0].0 * s, y: ip[0].1 * s))
|
||||
for p in ip.dropFirst() { inner.line(to: NSPoint(x: p.0 * s, y: p.1 * s)) }
|
||||
inner.close()
|
||||
outer.append(inner)
|
||||
outer.windingRule = .evenOdd
|
||||
NSColor.black.setFill()
|
||||
outer.fill()
|
||||
return true
|
||||
}
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}
|
||||
|
||||
/// A clear strip over the top band of the web view: WKWebView swallows window drags, this view lets the window move.
|
||||
final class DragStrip: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { true }
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil }
|
||||
}
|
||||
|
||||
final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate, WKScriptMessageHandler {
|
||||
let bio = Biometric(product: "Igneum Wallet", fallbackTitle: "Use password", enrolReason: "Turn on Touch ID for your wallet")
|
||||
var enginePort = ""
|
||||
var window: NSWindow!
|
||||
var web: WKWebView!
|
||||
var engine: Process?
|
||||
var engineIn: FileHandle?
|
||||
var status: NSStatusItem!
|
||||
var menuOpen = NSMenuItem(title: "Open Igneum Wallet", action: #selector(showWindow), keyEquivalent: "")
|
||||
var menuPause = NSMenuItem(title: "Lock", action: #selector(lockWallet), keyEquivalent: "")
|
||||
var menuNode = NSMenuItem(title: "Node: looking", action: nil, keyEquivalent: "")
|
||||
var menuBlocks = NSMenuItem(title: "Balance: locked", action: nil, keyEquivalent: "")
|
||||
var url: URL?
|
||||
var paused = false
|
||||
var exited = false
|
||||
var quitting = false
|
||||
var buffer = Data()
|
||||
var placeholder: NSTextField!
|
||||
|
||||
// snapshot mode
|
||||
var snapshotPath: String?
|
||||
var snapshotURL: String?
|
||||
var snapshotSize = NSSize(width: 1120, height: 780)
|
||||
|
||||
func applicationDidFinishLaunching(_ note: Notification) {
|
||||
NSApp.setActivationPolicy(snapshotPath == nil ? .regular : .accessory)
|
||||
buildMenu()
|
||||
buildWindow()
|
||||
if let p = snapshotPath, let u = snapshotURL, let url = URL(string: u) {
|
||||
self.url = url
|
||||
enginePort = url.port.map(String.init) ?? ""
|
||||
// design screenshots and tests: IGNEUM_HOST_LINE (the engine's HOST json) wires the Touch ID bridge to a
|
||||
// scratch engine, IGNEUM_PROBE runs JS in the page, IGNEUM_SNAPSHOT_DELAY (s) waits before the capture
|
||||
if let h = ProcessInfo.processInfo.environment["IGNEUM_HOST_LINE"], let d = h.data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
|
||||
bio.configure(engineURL: u.replacingOccurrences(of: "?host=mac", with: ""), hostToken: o["token"] as? String ?? "", file: o["biometric_file"] as? String ?? "")
|
||||
}
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
web.load(URLRequest(url: url))
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { self.snapshot(to: p) }
|
||||
return
|
||||
}
|
||||
buildStatusItem()
|
||||
startEngine()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func buildMenu() {
|
||||
let main = NSMenu()
|
||||
let appItem = NSMenuItem(); main.addItem(appItem)
|
||||
let appMenu = NSMenu()
|
||||
appMenu.addItem(withTitle: "About Igneum Wallet", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
|
||||
appMenu.addItem(.separator())
|
||||
appMenu.addItem(withTitle: "Hide Igneum Wallet", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
|
||||
appMenu.addItem(.separator())
|
||||
appMenu.addItem(withTitle: "Quit Igneum Wallet", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
appItem.submenu = appMenu
|
||||
let editItem = NSMenuItem(); main.addItem(editItem)
|
||||
let edit = NSMenu(title: "Edit")
|
||||
edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
|
||||
edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
|
||||
edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||
editItem.submenu = edit
|
||||
let winItem = NSMenuItem(); main.addItem(winItem)
|
||||
let win = NSMenu(title: "Window")
|
||||
win.addItem(withTitle: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m")
|
||||
win.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
|
||||
winItem.submenu = win
|
||||
NSApp.mainMenu = main
|
||||
}
|
||||
|
||||
func buildWindow() {
|
||||
let size = snapshotPath == nil ? NSSize(width: 1120, height: 780) : snapshotSize
|
||||
window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
|
||||
window.title = "Igneum Wallet"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.isMovableByWindowBackground = true
|
||||
window.backgroundColor = obsidian
|
||||
window.minSize = NSSize(width: 900, height: 620)
|
||||
window.center()
|
||||
window.delegate = self
|
||||
window.isReleasedWhenClosed = false
|
||||
let conf = WKWebViewConfiguration()
|
||||
conf.userContentController.add(self, name: "biometric") // the page's Touch ID bridge (Biometric.swift)
|
||||
web = WKWebView(frame: window.contentView!.bounds, configuration: conf)
|
||||
web.autoresizingMask = [.width, .height]
|
||||
web.navigationDelegate = self
|
||||
web.uiDelegate = self
|
||||
web.setValue(false, forKey: "drawsBackground")
|
||||
web.customUserAgent = "IgneumWallet/0.1.0 (Macintosh)"
|
||||
window.contentView?.addSubview(web)
|
||||
// the brand band is draggable; the pill and the settings button on the right stay clickable
|
||||
let strip = DragStrip(frame: NSRect(x: 0, y: size.height - 60, width: size.width - 300, height: 60))
|
||||
strip.autoresizingMask = [.width, .minYMargin]
|
||||
window.contentView?.addSubview(strip)
|
||||
placeholder = NSTextField(labelWithString: "Starting the engine")
|
||||
placeholder.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .medium)
|
||||
placeholder.textColor = NSColor(srgbRed: 154 / 255, green: 154 / 255, blue: 158 / 255, alpha: 1)
|
||||
placeholder.alignment = .center
|
||||
placeholder.frame = NSRect(x: 0, y: 18, width: size.width, height: 20)
|
||||
placeholder.autoresizingMask = [.width, .maxYMargin]
|
||||
placeholder.isHidden = snapshotPath != nil
|
||||
window.contentView?.addSubview(placeholder)
|
||||
}
|
||||
|
||||
func buildStatusItem() {
|
||||
status = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
status.button?.image = flameImage(size: 18)
|
||||
status.button?.imagePosition = .imageLeading
|
||||
status.button?.title = ""
|
||||
let menu = NSMenu()
|
||||
menu.addItem(menuOpen)
|
||||
menu.addItem(menuPause)
|
||||
menu.addItem(.separator())
|
||||
menuNode.isEnabled = false
|
||||
menuBlocks.isEnabled = false
|
||||
menu.addItem(menuNode)
|
||||
menu.addItem(menuBlocks)
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(withTitle: "Quit Igneum Wallet", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "")
|
||||
menu.autoenablesItems = false
|
||||
menuOpen.isEnabled = true
|
||||
menuPause.isEnabled = true
|
||||
status.menu = menu
|
||||
}
|
||||
|
||||
// ---- the engine ----
|
||||
func startEngine() {
|
||||
let exe = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/igneum-wallet")
|
||||
guard FileManager.default.isExecutableFile(atPath: exe.path) else {
|
||||
fail("igneum-wallet is missing from the app bundle. Copy Igneum Wallet from the disk image again.")
|
||||
return
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = exe
|
||||
p.arguments = ["--wrapper"]
|
||||
let out = Pipe(), inp = Pipe()
|
||||
p.standardOutput = out
|
||||
p.standardInput = inp
|
||||
p.standardError = FileHandle.standardError
|
||||
engineIn = inp.fileHandleForWriting
|
||||
out.fileHandleForReading.readabilityHandler = { h in
|
||||
let d = h.availableData
|
||||
if d.isEmpty { return }
|
||||
DispatchQueue.main.async { self.feed(d) }
|
||||
}
|
||||
p.terminationHandler = { _ in
|
||||
DispatchQueue.main.async { self.engineEnded() }
|
||||
}
|
||||
do { try p.run() } catch {
|
||||
fail("The engine could not start: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
engine = p
|
||||
}
|
||||
|
||||
func feed(_ d: Data) {
|
||||
buffer.append(d)
|
||||
while let nl = buffer.firstIndex(of: 10) {
|
||||
let lineData = buffer.subdata(in: 0..<nl)
|
||||
buffer.removeSubrange(0...nl)
|
||||
guard let line = String(data: lineData, encoding: .utf8) else { continue }
|
||||
if line.hasPrefix("URL ") {
|
||||
let u = String(line.dropFirst(4)).trimmingCharacters(in: .whitespaces)
|
||||
if let url = URL(string: u + "?host=mac") {
|
||||
self.url = url
|
||||
enginePort = url.port.map(String.init) ?? ""
|
||||
placeholder.isHidden = true
|
||||
web.load(URLRequest(url: url))
|
||||
}
|
||||
} else if line.hasPrefix("HOST ") {
|
||||
// the host token and the sealed file's path: the page never sees this line
|
||||
if let d = String(line.dropFirst(5)).data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
|
||||
bio.configure(engineURL: self.url?.absoluteString.replacingOccurrences(of: "?host=mac", with: "") ?? "", hostToken: o["token"] as? String ?? "", file: o["biometric_file"] as? String ?? "")
|
||||
}
|
||||
} else if line.hasPrefix("STATE ") {
|
||||
applyState(String(line.dropFirst(6)))
|
||||
} else if line.hasPrefix("FATAL ") {
|
||||
fail(String(line.dropFirst(6)))
|
||||
} else if line == "EXIT" {
|
||||
exited = true
|
||||
if quitting { NSApp.reply(toApplicationShouldTerminate: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyState(_ json: String) {
|
||||
guard let data = json.data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||||
let node = o["node"] as? String ?? ""
|
||||
let source = o["source"] as? String ?? ""
|
||||
let block = o["block"] as? Double ?? 0
|
||||
let unlocked = o["unlocked"] as? Bool ?? false
|
||||
let balance = o["balance"] as? String ?? ""
|
||||
let phase = o["phase"] as? String ?? ""
|
||||
var title = ""
|
||||
if o["quitting"] as? Bool == true { title = "stopping" }
|
||||
else if phase != "home" { title = "" }
|
||||
else if unlocked && !balance.isEmpty { title = "\(balance) IGN" }
|
||||
else if !unlocked { title = "locked" }
|
||||
status.button?.title = title.isEmpty ? "" : " " + title
|
||||
menuPause.title = unlocked ? "Lock" : "Locked"
|
||||
menuPause.isEnabled = unlocked
|
||||
let nf = NumberFormatter(); nf.numberStyle = .decimal
|
||||
menuNode.title = "Node: \(source) \(node), block \(nf.string(from: NSNumber(value: block)) ?? "0")"
|
||||
menuBlocks.title = unlocked ? "Balance: \(balance) IGN" : "Balance: locked"
|
||||
}
|
||||
|
||||
func engineEnded() {
|
||||
exited = true
|
||||
if quitting { NSApp.reply(toApplicationShouldTerminate: true); return }
|
||||
placeholder.stringValue = "The engine stopped. Quit and open Igneum Wallet again."
|
||||
placeholder.isHidden = false
|
||||
status?.button?.title = " stopped"
|
||||
}
|
||||
|
||||
func fail(_ text: String) {
|
||||
placeholder.stringValue = text
|
||||
placeholder.isHidden = false
|
||||
let a = NSAlert()
|
||||
a.messageText = "Igneum Wallet"
|
||||
a.informativeText = text
|
||||
a.runModal()
|
||||
}
|
||||
|
||||
@objc func showWindow() {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc func lockWallet() {
|
||||
send("lock")
|
||||
}
|
||||
|
||||
func send(_ cmd: String) {
|
||||
if let d = (cmd + "\n").data(using: .utf8) { engineIn?.write(d) }
|
||||
}
|
||||
|
||||
// ---- quit: miners first, then the node ----
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
if snapshotPath != nil { return .terminateNow }
|
||||
guard let e = engine, e.isRunning, !exited else { return .terminateNow }
|
||||
quitting = true
|
||||
status?.button?.title = " stopping"
|
||||
web.evaluateJavaScript("document.getElementById('pill-text').textContent='stopping'", completionHandler: nil)
|
||||
send("quit")
|
||||
// 45 s is the engine's own worst case (30 s for the bundled node to close its database); then force
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 45) {
|
||||
if self.engine?.isRunning == true { self.engine?.terminate() }
|
||||
NSApp.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
return .terminateLater
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
showWindow()
|
||||
return true
|
||||
}
|
||||
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
// the window hides; the miner keeps running in the menu bar
|
||||
window.orderOut(nil)
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- Touch ID: the page posts {id, op, ...}; the answer goes back as window.__igneumBiometric(id, {...}) ----
|
||||
func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard message.name == "biometric", let body = message.body as? [String: Any], let id = body["id"] as? Int else { return }
|
||||
// only the engine's own page, same origin as the URL the engine printed
|
||||
let origin = message.frameInfo.securityOrigin
|
||||
guard origin.host == "127.0.0.1", String(origin.port) == enginePort else { return }
|
||||
bio.handle(body) { result in
|
||||
guard let d = try? JSONSerialization.data(withJSONObject: result), let j = String(data: d, encoding: .utf8) else { return }
|
||||
self.web.evaluateJavaScript("window.__igneumBiometric && window.__igneumBiometric(\(id), \(j))", completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- links: anything off 127.0.0.1 opens in the default browser ----
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
if let u = action.request.url, let scheme = u.scheme, scheme.hasPrefix("http"), u.host != "127.0.0.1" {
|
||||
NSWorkspace.shared.open(u)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
let a = NSAlert()
|
||||
a.messageText = "Igneum Wallet"
|
||||
a.informativeText = message
|
||||
a.addButton(withTitle: "Quit")
|
||||
a.addButton(withTitle: "Cancel")
|
||||
completionHandler(a.runModal() == .alertFirstButtonReturn)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
||||
let a = NSAlert(); a.messageText = "Igneum Wallet"; a.informativeText = message; a.runModal(); completionHandler()
|
||||
}
|
||||
|
||||
// ---- snapshot ----
|
||||
func snapshot(to path: String) {
|
||||
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {
|
||||
web.evaluateJavaScript(js) { r, e in print("probe:", r ?? "nil", e?.localizedDescription ?? "") }
|
||||
}
|
||||
let delay = Double(ProcessInfo.processInfo.environment["IGNEUM_SNAPSHOT_DELAY"] ?? "") ?? 0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { self.capture(to: path) }
|
||||
}
|
||||
|
||||
func capture(to path: String) {
|
||||
let conf = WKSnapshotConfiguration()
|
||||
conf.rect = web.bounds
|
||||
web.takeSnapshot(with: conf) { image, error in
|
||||
defer { NSApp.terminate(nil) }
|
||||
guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("snapshot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!)
|
||||
return
|
||||
}
|
||||
do { try png.write(to: URL(fileURLWithPath: path)); print("wrote \(path)") } catch { print("could not write \(path): \(error)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The entry point. With Biometric.swift compiled alongside, top-level statements are not allowed here (only in a
|
||||
// main.swift), so the launch lives in a @main type.
|
||||
@main
|
||||
struct Main {
|
||||
static func main() {
|
||||
let app = NSApplication.shared
|
||||
let delegate = App()
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
switch args[i] {
|
||||
case "--snapshot": if i + 1 < args.count { delegate.snapshotPath = args[i + 1]; i += 1 }
|
||||
case "--url": if i + 1 < args.count { delegate.snapshotURL = args[i + 1]; i += 1 }
|
||||
case "--size":
|
||||
if i + 1 < args.count {
|
||||
let parts = args[i + 1].split(separator: "x").compactMap { Double($0) }
|
||||
if parts.count == 2 { delegate.snapshotSize = NSSize(width: parts[0], height: parts[1]) }
|
||||
i += 1
|
||||
}
|
||||
default: break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
}
|
||||
}
|
||||
61
app/windows/BUILD-WALLET-APP.bat
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
@echo off
|
||||
rem Builds "Igneum Wallet.exe" (the WebView2 window host) on a Windows PC. Double-click this file.
|
||||
rem Needs Visual Studio with the MSVC v143 x64 component (cl.exe, rc.exe), the Windows SDK's C++/WinRT headers
|
||||
rem (Windows Hello, biometric.h) and the internet on the first run (the WebView2 SDK comes from NuGet). The output lands in dist\ and, when the extracted payload folder
|
||||
rem (igneum-windows-app, with igneum-wallet.exe) is next to this folder or its parent, is copied into it, so
|
||||
rem packaging\windows\BUILD-INSTALLER.bat ships it. Without this exe the installer still works: the Start Menu entry
|
||||
rem runs igneum-wallet.exe --launch, which opens the dashboard in the default browser.
|
||||
setlocal EnableDelayedExpansion
|
||||
cd /d "%~dp0"
|
||||
set "SDKVER=1.0.2903.40"
|
||||
set "SDKURL=https://www.nuget.org/api/v2/package/Microsoft.Web.WebView2/%SDKVER%"
|
||||
if not exist build mkdir build
|
||||
if not exist dist mkdir dist
|
||||
|
||||
rem ---- 1. the MSVC environment ----
|
||||
set "VCVARS="
|
||||
for %%d in ("C:\Program Files\Microsoft Visual Studio" "C:\Program Files (x86)\Microsoft Visual Studio") do (
|
||||
for /f "delims=" %%f in ('dir /s /b "%%~d\vcvarsall.bat" 2^>nul') do set "VCVARS=%%f"
|
||||
)
|
||||
if "%VCVARS%"=="" (
|
||||
echo Visual Studio with the MSVC v143 x64 component was not found ^(no vcvarsall.bat under Program Files\Microsoft Visual Studio^).
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [build] MSVC: "%VCVARS%"
|
||||
call "%VCVARS%" x64 >nul 2>&1
|
||||
where cl.exe >nul 2>nul || (echo cl.exe is not on PATH after vcvarsall; open a "x64 Native Tools" prompt and run this file from there. & pause & exit /b 1)
|
||||
|
||||
rem ---- 2. the WebView2 SDK (NuGet package, a zip) ----
|
||||
if not exist "build\webview2\build\native\include\WebView2.h" (
|
||||
echo [build] downloading the WebView2 SDK %SDKVER%
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ProgressPreference='SilentlyContinue'; [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri '%SDKURL%' -OutFile 'build\webview2.zip' -UseBasicParsing; if (Test-Path 'build\webview2') { Remove-Item -Recurse -Force 'build\webview2' }; Expand-Archive -Path 'build\webview2.zip' -DestinationPath 'build\webview2' -Force"
|
||||
if not exist "build\webview2\build\native\include\WebView2.h" (echo the SDK did not unpack; see build\webview2 & pause & exit /b 1)
|
||||
)
|
||||
|
||||
rem ---- 3. the art: brand\icons in the repo layout, or an art\ folder next to this file ----
|
||||
set "ART="
|
||||
if exist "..\..\brand\icons\igneum.ico" set "ART=..\..\brand\icons"
|
||||
if exist "art\igneum.ico" set "ART=art"
|
||||
if exist "..\brand\icons\igneum.ico" set "ART=..\brand\icons"
|
||||
if "%ART%"=="" (echo igneum.ico was not found ^(brand\icons or an art\ folder^). & pause & exit /b 1)
|
||||
|
||||
rem ---- 4. compile ----
|
||||
echo [build] rc
|
||||
rc.exe /nologo /i "%ART%" /fo build\host.res wallet-host.rc || (pause & exit /b 1)
|
||||
echo [build] cl
|
||||
cl.exe /nologo /O2 /MT /EHsc /W3 /std:c++17 /DUNICODE /D_UNICODE /I "build\webview2\build\native\include" /Fo"build\\" wallet-host.cpp build\host.res ^
|
||||
/link /SUBSYSTEM:WINDOWS /OUT:"dist\Igneum Wallet.exe" "build\webview2\build\native\x64\WebView2LoaderStatic.lib" ^
|
||||
user32.lib shell32.lib ole32.lib advapi32.lib gdi32.lib version.lib shlwapi.lib windowsapp.lib crypt32.lib winhttp.lib || (pause & exit /b 1)
|
||||
echo [build] done: dist\Igneum Wallet.exe
|
||||
|
||||
rem ---- 5. into the payload, when it is here ----
|
||||
for %%p in ("igneum-windows-app" "..\igneum-windows-app" "..\..\igneum-windows-app" "..\..\packaging\windows\igneum-windows-app") do (
|
||||
if exist "%%~p\igneum-wallet.exe" (
|
||||
copy /y "dist\Igneum Wallet.exe" "%%~p\" >nul
|
||||
echo [build] copied into %%~p
|
||||
)
|
||||
)
|
||||
echo.
|
||||
echo Next: packaging\windows\BUILD-INSTALLER.bat builds the Setup exe with this host inside.
|
||||
pause
|
||||
377
app/windows/biometric.h
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
// Windows Hello for the Igneum window hosts (wallet-host.cpp, host.cpp): the WebView2 side of what
|
||||
// app/mac/Biometric.swift does with Touch ID. 5 October 2026. UNTESTED on a PC at the time of writing (written on a
|
||||
// Mac): BUILD-WALLET-APP.bat / BUILD-APP.bat on the GitHub runner are the first compile.
|
||||
//
|
||||
// The page posts a JSON string {id, op, nonce?, token?} with window.chrome.webview.postMessage; the host answers with
|
||||
// PostWebMessageAsJson({id, ok, code, message, ...}) and the page's listener routes it to window.__igneumBiometric.
|
||||
// Ops: status, enrol, unlock (wallet), confirm.
|
||||
//
|
||||
// The prompt is Windows.Security.Credentials.UI.UserConsentVerifier, through IUserConsentVerifierInterop so a Win32
|
||||
// window can own it (RequestVerificationForWindowAsync). What is stored: the wallet's password, sealed with DPAPI
|
||||
// (CryptProtectData, current user, CRYPTPROTECT_UI_FORBIDDEN) only after a Hello success, in the file the engine
|
||||
// named on its HOST line (%LOCALAPPDATA%\igneum\wallet\biometric.json); the miner seals a fixed marker. DPAPI is as
|
||||
// strong as the Windows account: anything running as this user can unseal it, which is why the host only unseals
|
||||
// after Hello verified, and why the threat model in app/igneum-wallet/README.md says what this does and does not
|
||||
// protect. The secret never goes through the page: the host posts it to the engine on 127.0.0.1 with the engine's
|
||||
// URL token; X-Igneum-Host (the token from the HOST line) marks the calls only the host may make.
|
||||
//
|
||||
// Needs: C++/WinRT headers (the Windows SDK's cppwinrt include, on INCLUDE after vcvarsall), windowsapp.lib,
|
||||
// crypt32.lib, winhttp.lib. C++17.
|
||||
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <wincrypt.h>
|
||||
#include <winhttp.h>
|
||||
#include <winrt/base.h>
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
#include <winrt/Windows.Security.Credentials.UI.h>
|
||||
#include <UserConsentVerifierInterop.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <thread>
|
||||
#include <functional>
|
||||
|
||||
namespace igbio {
|
||||
|
||||
using winrt::Windows::Security::Credentials::UI::UserConsentVerifier;
|
||||
using winrt::Windows::Security::Credentials::UI::UserConsentVerifierAvailability;
|
||||
using winrt::Windows::Security::Credentials::UI::UserConsentVerificationResult;
|
||||
|
||||
struct Config {
|
||||
std::wstring product; // L"Igneum Wallet" | L"Igneum Miner"
|
||||
std::wstring enrolReason; // L"Turn on Windows Hello for your wallet" | L"... for the miner"
|
||||
std::wstring engineURL; // http://127.0.0.1:<port>/t/<token>/
|
||||
std::string hostToken; // from the HOST line
|
||||
std::wstring file; // the sealed file's path from the HOST line
|
||||
HWND hwnd = nullptr; // the window that owns the prompt
|
||||
};
|
||||
|
||||
static Config g_cfg;
|
||||
static const char* MARKER = "igneum-biometric-marker-v1";
|
||||
|
||||
// ---- small JSON helpers (flat objects, string values) ----
|
||||
|
||||
static std::string jsonEscape(const std::string& s) {
|
||||
std::string o;
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': o += "\\\""; break;
|
||||
case '\\': o += "\\\\"; break;
|
||||
case '\n': o += "\\n"; break;
|
||||
case '\r': o += "\\r"; break;
|
||||
case '\t': o += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) { char b[8]; sprintf_s(b, "\\u%04x", c); o += b; } else o += (char)c;
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// The value of "key" in a flat JSON object: a string (escapes decoded), a number, or a bool as text.
|
||||
static std::string jsonGet(const std::string& j, const char* key) {
|
||||
std::string k = std::string("\"") + key + "\"";
|
||||
size_t i = j.find(k);
|
||||
if (i == std::string::npos) return "";
|
||||
i = j.find(':', i + k.size());
|
||||
if (i == std::string::npos) return "";
|
||||
i++;
|
||||
while (i < j.size() && (j[i] == ' ' || j[i] == '\t')) i++;
|
||||
if (i < j.size() && j[i] == '"') {
|
||||
std::string o;
|
||||
for (i++; i < j.size() && j[i] != '"'; i++) {
|
||||
if (j[i] == '\\' && i + 1 < j.size()) {
|
||||
char e = j[++i];
|
||||
if (e == 'n') o += '\n';
|
||||
else if (e == 'r') o += '\r';
|
||||
else if (e == 't') o += '\t';
|
||||
else if (e == 'u' && i + 4 < j.size()) {
|
||||
unsigned v = strtoul(j.substr(i + 1, 4).c_str(), nullptr, 16);
|
||||
i += 4;
|
||||
if (v < 0x80) o += (char)v;
|
||||
else if (v < 0x800) { o += (char)(0xC0 | (v >> 6)); o += (char)(0x80 | (v & 0x3F)); }
|
||||
else { o += (char)(0xE0 | (v >> 12)); o += (char)(0x80 | ((v >> 6) & 0x3F)); o += (char)(0x80 | (v & 0x3F)); }
|
||||
} else o += e;
|
||||
} else o += j[i];
|
||||
}
|
||||
return o;
|
||||
}
|
||||
size_t e = i;
|
||||
while (e < j.size() && j[e] != ',' && j[e] != '}') e++;
|
||||
std::string v = j.substr(i, e - i);
|
||||
while (!v.empty() && (v.back() == ' ' || v.back() == '\r' || v.back() == '\n')) v.pop_back();
|
||||
return v;
|
||||
}
|
||||
|
||||
static std::wstring widen8(const std::string& s) {
|
||||
if (s.empty()) return L"";
|
||||
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0);
|
||||
std::wstring w(n, 0);
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), &w[0], n);
|
||||
return w;
|
||||
}
|
||||
static std::string narrow8(const std::wstring& w) {
|
||||
if (w.empty()) return "";
|
||||
int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), nullptr, 0, nullptr, nullptr);
|
||||
std::string s(n, 0);
|
||||
WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), &s[0], n, nullptr, nullptr);
|
||||
return s;
|
||||
}
|
||||
|
||||
static std::string b64(const std::vector<BYTE>& d) {
|
||||
DWORD n = 0;
|
||||
CryptBinaryToStringA(d.data(), (DWORD)d.size(), CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr, &n);
|
||||
std::string o(n, 0);
|
||||
CryptBinaryToStringA(d.data(), (DWORD)d.size(), CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, &o[0], &n);
|
||||
while (!o.empty() && o.back() == 0) o.pop_back();
|
||||
return o;
|
||||
}
|
||||
static std::vector<BYTE> unb64(const std::string& s) {
|
||||
DWORD n = 0;
|
||||
if (!CryptStringToBinaryA(s.c_str(), (DWORD)s.size(), CRYPT_STRING_BASE64, nullptr, &n, nullptr, nullptr)) return {};
|
||||
std::vector<BYTE> o(n);
|
||||
if (!CryptStringToBinaryA(s.c_str(), (DWORD)s.size(), CRYPT_STRING_BASE64, o.data(), &n, nullptr, nullptr)) return {};
|
||||
o.resize(n);
|
||||
return o;
|
||||
}
|
||||
|
||||
static std::string reply(bool ok, const std::string& code, const std::string& message) {
|
||||
return std::string("{\"ok\":") + (ok ? "true" : "false") + ",\"code\":\"" + jsonEscape(code) + "\",\"message\":\"" + jsonEscape(message) + "\"}";
|
||||
}
|
||||
|
||||
// ---- the engine on 127.0.0.1 (WinHTTP) ----
|
||||
|
||||
struct Answer { bool ok; int status; std::string body; };
|
||||
|
||||
static Answer call(const std::wstring& method, const std::string& path, const std::string& body) {
|
||||
Answer a = { false, 0, "" };
|
||||
URL_COMPONENTS uc = { sizeof(uc) };
|
||||
wchar_t host[64] = {}, upath[1024] = {};
|
||||
uc.lpszHostName = host; uc.dwHostNameLength = 64;
|
||||
uc.lpszUrlPath = upath; uc.dwUrlPathLength = 1024;
|
||||
std::wstring full = g_cfg.engineURL + widen8(path);
|
||||
if (!WinHttpCrackUrl(full.c_str(), 0, 0, &uc)) { a.body = "{\"error\":\"bad engine url\"}"; return a; }
|
||||
HINTERNET s = WinHttpOpen(L"IgneumHost/1", WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
|
||||
if (!s) { a.body = "{\"error\":\"winhttp\"}"; return a; }
|
||||
WinHttpSetTimeouts(s, 5000, 5000, 15000, 15000);
|
||||
HINTERNET c = WinHttpConnect(s, host, uc.nPort, 0);
|
||||
HINTERNET r = c ? WinHttpOpenRequest(c, method.c_str(), upath, nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, 0) : nullptr;
|
||||
if (r) {
|
||||
std::wstring hdr = L"X-Igneum-Host: " + widen8(g_cfg.hostToken) + L"\r\nContent-Type: application/json\r\n";
|
||||
if (WinHttpSendRequest(r, hdr.c_str(), (DWORD)-1, body.empty() ? WINHTTP_NO_REQUEST_DATA : (LPVOID)body.data(), (DWORD)body.size(), (DWORD)body.size(), 0) && WinHttpReceiveResponse(r, nullptr)) {
|
||||
DWORD st = 0, n = sizeof(st);
|
||||
WinHttpQueryHeaders(r, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &st, &n, WINHTTP_NO_HEADER_INDEX);
|
||||
a.status = (int)st;
|
||||
DWORD avail = 0;
|
||||
while (WinHttpQueryDataAvailable(r, &avail) && avail > 0) {
|
||||
std::string chunk(avail, 0);
|
||||
DWORD got = 0;
|
||||
if (!WinHttpReadData(r, &chunk[0], avail, &got)) break;
|
||||
a.body.append(chunk, 0, got);
|
||||
}
|
||||
a.ok = st == 200 && jsonGet(a.body, "ok") != "false";
|
||||
} else a.body = "{\"error\":\"no answer from the engine\"}";
|
||||
}
|
||||
if (r) WinHttpCloseHandle(r);
|
||||
if (c) WinHttpCloseHandle(c);
|
||||
WinHttpCloseHandle(s);
|
||||
return a;
|
||||
}
|
||||
static Answer post(const std::string& path, const std::string& body) { return call(L"POST", path, body); }
|
||||
static Answer get(const std::string& path) { return call(L"GET", path, ""); }
|
||||
|
||||
// ---- Windows Hello ----
|
||||
|
||||
static std::string availabilityText(UserConsentVerifierAvailability a, bool* available) {
|
||||
*available = false;
|
||||
switch (a) {
|
||||
case UserConsentVerifierAvailability::Available: *available = true; return "";
|
||||
case UserConsentVerifierAvailability::DeviceNotPresent: return "Windows Hello has no fingerprint or face sensor on this PC.";
|
||||
case UserConsentVerifierAvailability::NotConfiguredForUser: return "Windows Hello is not set up on this PC. Set it up in Settings > Accounts > Sign-in options.";
|
||||
case UserConsentVerifierAvailability::DisabledByPolicy: return "Windows Hello is disabled by policy on this PC.";
|
||||
case UserConsentVerifierAvailability::DeviceBusy: return "The Windows Hello sensor is busy.";
|
||||
default: return "Windows Hello is not available on this PC.";
|
||||
}
|
||||
}
|
||||
|
||||
static std::string status(bool* available) {
|
||||
try {
|
||||
auto a = UserConsentVerifier::CheckAvailabilityAsync().get();
|
||||
return availabilityText(a, available);
|
||||
} catch (...) {
|
||||
*available = false;
|
||||
return "Windows Hello could not be checked on this PC.";
|
||||
}
|
||||
}
|
||||
|
||||
// The prompt. Returns "" on success, else the reply JSON for the page.
|
||||
static std::string verify(const std::wstring& reason) {
|
||||
try {
|
||||
auto factory = winrt::get_activation_factory<UserConsentVerifier, IUserConsentVerifierInterop>();
|
||||
winrt::Windows::Foundation::IAsyncOperation<UserConsentVerificationResult> op{ nullptr };
|
||||
winrt::hstring msg(reason.substr(0, 80));
|
||||
winrt::check_hresult(factory->RequestVerificationForWindowAsync(g_cfg.hwnd, static_cast<HSTRING>(winrt::get_abi(msg)),
|
||||
winrt::guid_of<winrt::Windows::Foundation::IAsyncOperation<UserConsentVerificationResult>>(), winrt::put_abi(op)));
|
||||
auto r = op.get();
|
||||
switch (r) {
|
||||
case UserConsentVerificationResult::Verified: return "";
|
||||
case UserConsentVerificationResult::Canceled: return reply(false, "cancelled", "Windows Hello was cancelled.");
|
||||
case UserConsentVerificationResult::RetriesExhausted: return reply(false, "locked", "Windows Hello is locked after too many tries. Use the password.");
|
||||
case UserConsentVerificationResult::NotConfiguredForUser: return reply(false, "not_set_up", "Windows Hello is not set up on this PC. Set it up in Settings > Accounts > Sign-in options.");
|
||||
case UserConsentVerificationResult::DeviceNotPresent: return reply(false, "unavailable", "Windows Hello has no sensor on this PC.");
|
||||
case UserConsentVerificationResult::DisabledByPolicy: return reply(false, "unavailable", "Windows Hello is disabled by policy on this PC.");
|
||||
case UserConsentVerificationResult::DeviceBusy: return reply(false, "failed", "The Windows Hello sensor is busy; try again.");
|
||||
default: return reply(false, "failed", "Windows Hello did not succeed.");
|
||||
}
|
||||
} catch (const winrt::hresult_error& e) {
|
||||
return reply(false, "failed", "Windows Hello failed: " + narrow8(std::wstring(e.message())));
|
||||
} catch (...) {
|
||||
return reply(false, "failed", "Windows Hello failed.");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the sealed file (DPAPI, current user) ----
|
||||
|
||||
static bool readFile(std::string* out) {
|
||||
HANDLE h = CreateFileW(g_cfg.file.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (h == INVALID_HANDLE_VALUE) return false;
|
||||
char buf[8192]; DWORD n = 0;
|
||||
out->clear();
|
||||
while (ReadFile(h, buf, sizeof(buf), &n, nullptr) && n > 0) out->append(buf, n);
|
||||
CloseHandle(h);
|
||||
return true;
|
||||
}
|
||||
static bool writeFile(const std::string& text) {
|
||||
size_t i = g_cfg.file.find_last_of(L"\\/");
|
||||
if (i != std::wstring::npos) CreateDirectoryW(g_cfg.file.substr(0, i).c_str(), nullptr);
|
||||
HANDLE h = CreateFileW(g_cfg.file.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (h == INVALID_HANDLE_VALUE) return false;
|
||||
DWORD w = 0;
|
||||
BOOL ok = WriteFile(h, text.data(), (DWORD)text.size(), &w, nullptr);
|
||||
CloseHandle(h);
|
||||
return ok && w == text.size();
|
||||
}
|
||||
static bool sealedExists() {
|
||||
DWORD a = GetFileAttributesW(g_cfg.file.c_str());
|
||||
return a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY);
|
||||
}
|
||||
|
||||
static bool seal(const std::string& secret, std::string* boxB64) {
|
||||
DATA_BLOB in = { (DWORD)secret.size(), (BYTE*)secret.data() }, out = {};
|
||||
std::wstring desc = g_cfg.product + L" biometric";
|
||||
if (!CryptProtectData(&in, desc.c_str(), nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return false;
|
||||
std::vector<BYTE> v(out.pbData, out.pbData + out.cbData);
|
||||
LocalFree(out.pbData);
|
||||
*boxB64 = b64(v);
|
||||
return true;
|
||||
}
|
||||
static bool unseal(const std::string& boxB64, std::string* secret) {
|
||||
std::vector<BYTE> v = unb64(boxB64);
|
||||
if (v.empty()) return false;
|
||||
DATA_BLOB in = { (DWORD)v.size(), v.data() }, out = {};
|
||||
if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, &out)) return false;
|
||||
secret->assign((char*)out.pbData, out.cbData);
|
||||
SecureZeroMemory(out.pbData, out.cbData);
|
||||
LocalFree(out.pbData);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- the ops ----
|
||||
|
||||
static std::string opStatus() {
|
||||
bool avail = false;
|
||||
std::string msg = status(&avail);
|
||||
return std::string("{\"ok\":true,\"available\":") + (avail ? "true" : "false") + ",\"kind\":\"hello\",\"message\":\"" + jsonEscape(msg) + "\",\"enrolled\":" + (sealedExists() ? "true" : "false") + "}";
|
||||
}
|
||||
|
||||
static std::string opEnrol(const std::string& token) {
|
||||
if (g_cfg.file.empty()) return reply(false, "unavailable", "The engine has not named the sealed file yet.");
|
||||
bool avail = false;
|
||||
std::string msg = status(&avail);
|
||||
if (!avail) return reply(false, "unavailable", msg);
|
||||
std::string e = verify(g_cfg.enrolReason);
|
||||
if (!e.empty()) return e;
|
||||
std::string secret;
|
||||
if (!token.empty()) {
|
||||
Answer a = post("api/biometric/enrol/take", "{\"token\":\"" + jsonEscape(token) + "\"}");
|
||||
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine did not hand over the password" : jsonGet(a.body, "error"));
|
||||
secret = jsonGet(a.body, "secret");
|
||||
} else secret = MARKER;
|
||||
std::string box;
|
||||
bool ok = seal(secret, &box);
|
||||
SecureZeroMemory(&secret[0], secret.size());
|
||||
if (!ok) return reply(false, "seal", "DPAPI could not seal the secret.");
|
||||
std::string doc = "{\"version\":1,\"kind\":\"hello\",\"product\":\"" + jsonEscape(narrow8(g_cfg.product)) + "\",\"created\":" + std::to_string((long long)(GetTickCount64() / 1000)) + ",\"box\":\"" + box + "\"}";
|
||||
if (!writeFile(doc)) return reply(false, "file", "The sealed file could not be written.");
|
||||
Answer a = post("api/biometric/enrolled", "{\"kind\":\"hello\"}");
|
||||
if (!a.ok) return reply(false, "engine", "the engine did not record the enrolment");
|
||||
return reply(true, "", "");
|
||||
}
|
||||
|
||||
static std::string opUnlock() {
|
||||
std::string text;
|
||||
if (!sealedExists() || !readFile(&text)) return reply(false, "not_enrolled", "Windows Hello is not turned on for this wallet.");
|
||||
std::string e = verify(L"Unlock your wallet");
|
||||
if (!e.empty()) return e;
|
||||
std::string secret;
|
||||
if (!unseal(jsonGet(text, "box"), &secret)) return reply(false, "invalidated", "Windows Hello no longer opens this wallet: the sealed password could not be read. Use the password, then turn Windows Hello on again in Settings.");
|
||||
Answer a = post("api/unlock", "{\"password\":\"" + jsonEscape(secret) + "\"}");
|
||||
SecureZeroMemory(&secret[0], secret.size());
|
||||
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine did not unlock" : jsonGet(a.body, "error"));
|
||||
return reply(true, "", "");
|
||||
}
|
||||
|
||||
static std::string opConfirm(const std::string& nonce) {
|
||||
if (nonce.empty()) return reply(false, "bad_nonce", "No challenge.");
|
||||
Answer c = get("api/biometric/challenge?nonce=" + nonce);
|
||||
if (!c.ok) return reply(false, "engine", jsonGet(c.body, "error").empty() ? "the engine does not know this challenge" : jsonGet(c.body, "error"));
|
||||
std::string e = verify(widen8(jsonGet(c.body, "reason")));
|
||||
if (!e.empty()) return e;
|
||||
if (sealedExists()) {
|
||||
// the sealed file must still open under this account (DPAPI; a reset account leaves it unreadable)
|
||||
std::string text, secret;
|
||||
if (!readFile(&text) || !unseal(jsonGet(text, "box"), &secret)) return reply(false, "invalidated", "The sealed file could not be read. Turn Windows Hello on again in Settings.");
|
||||
SecureZeroMemory(&secret[0], secret.size());
|
||||
}
|
||||
Answer a = post("api/biometric/confirm", "{\"nonce\":\"" + jsonEscape(nonce) + "\"}");
|
||||
if (!a.ok) return reply(false, "engine", jsonGet(a.body, "error").empty() ? "the engine refused the confirmation" : jsonGet(a.body, "error"));
|
||||
return reply(true, "", "");
|
||||
}
|
||||
|
||||
/// The HOST line from the engine: {"token": "...", "biometric_file": "..."}. Posts the availability at once.
|
||||
static void configure(const std::string& hostLineJson, const std::wstring& engineURL, const std::wstring& product, const std::wstring& enrolReason, HWND hwnd) {
|
||||
g_cfg.product = product;
|
||||
g_cfg.enrolReason = enrolReason;
|
||||
g_cfg.engineURL = engineURL;
|
||||
g_cfg.hostToken = jsonGet(hostLineJson, "token");
|
||||
g_cfg.file = widen8(jsonGet(hostLineJson, "biometric_file"));
|
||||
g_cfg.hwnd = hwnd;
|
||||
std::thread([] {
|
||||
winrt::init_apartment(winrt::apartment_type::multi_threaded);
|
||||
bool avail = false;
|
||||
std::string msg = status(&avail);
|
||||
post("api/biometric/status", std::string("{\"available\":") + (avail ? "true" : "false") + ",\"kind\":\"hello\",\"message\":\"" + jsonEscape(msg) + "\"}");
|
||||
}).detach();
|
||||
}
|
||||
|
||||
/// A message from the page (the JSON string it posted). `answer` receives the reply JSON with the id put back; it is
|
||||
/// called on a worker thread, so the host marshals it to the UI thread for PostWebMessageAsJson.
|
||||
static void handle(const std::string& msg, std::function<void(const std::string&)> answer) {
|
||||
std::string id = jsonGet(msg, "id"), op = jsonGet(msg, "op"), nonce = jsonGet(msg, "nonce"), token = jsonGet(msg, "token");
|
||||
std::thread([id, op, nonce, token, answer] {
|
||||
winrt::init_apartment(winrt::apartment_type::multi_threaded);
|
||||
std::string r;
|
||||
if (op == "status") r = opStatus();
|
||||
else if (op == "enrol") r = opEnrol(token);
|
||||
else if (op == "unlock") r = opUnlock();
|
||||
else if (op == "confirm") r = opConfirm(nonce);
|
||||
else r = reply(false, "bad_op", "unknown op " + op);
|
||||
if (op != "status") {
|
||||
post("api/biometric/report", "{\"op\":\"" + jsonEscape(op) + "\",\"ok\":" + (jsonGet(r, "ok") == "true" ? "true" : "false") + ",\"code\":\"" + jsonEscape(jsonGet(r, "code")) + "\",\"message\":\"" + jsonEscape(jsonGet(r, "message")) + "\"}");
|
||||
}
|
||||
// put the id in front: {"id":N, ...rest}
|
||||
std::string withId = "{\"id\":" + (id.empty() ? "0" : id) + "," + r.substr(1);
|
||||
answer(withId);
|
||||
}).detach();
|
||||
}
|
||||
|
||||
} // namespace igbio
|
||||
486
app/windows/wallet-host.cpp
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
// Igneum Wallet for Windows: the window around the wallet engine. A copy of host.cpp (the miner's window) with the
|
||||
// names and the tray menu changed: "Lock" instead of "Pause". Built on the PC by BUILD-WALLET-APP.bat (MSVC, the
|
||||
// WebView2 SDK from NuGet, static loader). It starts igneum-wallet.exe --wrapper next to it, reads "URL ..." /
|
||||
// "STATE {...}" / "EXIT" from its stdout, shows the URL in a WebView2 control, keeps a tray icon with the state, and on
|
||||
// quit writes "quit" to the engine's stdin and waits for EXIT. Closing the window hides it to the tray. 4 October 2026.
|
||||
// Windows Hello (biometric.h, the page's window.chrome.webview.postMessage bridge, the HOST line): 5 October 2026.
|
||||
//
|
||||
// Untested on a real PC at the time of writing (written on a Mac): BUILD-WALLET-APP.bat is the first run.
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#ifndef UNICODE
|
||||
#define UNICODE
|
||||
#endif
|
||||
#ifndef _UNICODE
|
||||
#define _UNICODE
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <wrl.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include "WebView2.h"
|
||||
#include "wallet-version.h"
|
||||
#include "biometric.h"
|
||||
|
||||
using namespace Microsoft::WRL;
|
||||
|
||||
#define WM_ENGINE_LINE (WM_APP + 1)
|
||||
#define WM_TRAY (WM_APP + 2)
|
||||
#define WM_BIO_REPLY (WM_APP + 3)
|
||||
#define ID_TRAY_OPEN 1001
|
||||
#define ID_TRAY_PAUSE 1002
|
||||
#define ID_TRAY_QUIT 1003
|
||||
#define ID_QUIT_TIMER 7
|
||||
#define IDI_APP 1
|
||||
|
||||
static HWND g_hwnd = nullptr;
|
||||
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
|
||||
static ComPtr<ICoreWebView2Controller> g_controller;
|
||||
static ComPtr<ICoreWebView2> g_webview;
|
||||
static std::wstring g_url, g_status = L"starting the engine";
|
||||
static std::string g_hostLine; // the engine's HOST {...} line, kept until the window and the URL exist
|
||||
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
|
||||
static NOTIFYICONDATAW g_nid = {};
|
||||
static std::wstring g_trayTitle = L"Igneum Wallet";
|
||||
static ULONGLONG g_quitStarted = 0;
|
||||
|
||||
static std::wstring widen(const std::string& s) {
|
||||
if (s.empty()) return L"";
|
||||
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0);
|
||||
std::wstring w(n, 0);
|
||||
MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), &w[0], n);
|
||||
return w;
|
||||
}
|
||||
|
||||
static std::wstring exeDir() {
|
||||
wchar_t buf[MAX_PATH];
|
||||
GetModuleFileNameW(nullptr, buf, MAX_PATH);
|
||||
std::wstring p(buf);
|
||||
size_t i = p.find_last_of(L"\\/");
|
||||
return i == std::wstring::npos ? L"." : p.substr(0, i);
|
||||
}
|
||||
|
||||
static void sendEngine(const char* line) {
|
||||
if (!g_engineIn) return;
|
||||
DWORD w = 0;
|
||||
WriteFile(g_engineIn, line, (DWORD)strlen(line), &w, nullptr);
|
||||
WriteFile(g_engineIn, "\n", 1, &w, nullptr);
|
||||
}
|
||||
|
||||
static void setTray(const std::wstring& tip) {
|
||||
g_trayTitle = tip;
|
||||
wcsncpy_s(g_nid.szTip, tip.c_str(), _TRUNCATE);
|
||||
Shell_NotifyIconW(NIM_MODIFY, &g_nid);
|
||||
}
|
||||
|
||||
static void repaintStatus() {
|
||||
InvalidateRect(g_hwnd, nullptr, TRUE);
|
||||
}
|
||||
|
||||
// A tiny JSON number/string/bool reader for the STATE line (flat object, known keys).
|
||||
static std::string jsonField(const std::string& j, const char* key) {
|
||||
std::string k = std::string("\"") + key + "\":";
|
||||
size_t i = j.find(k);
|
||||
if (i == std::string::npos) return "";
|
||||
i += k.size();
|
||||
while (i < j.size() && j[i] == ' ') i++;
|
||||
if (i < j.size() && j[i] == '"') {
|
||||
size_t e = j.find('"', i + 1);
|
||||
return e == std::string::npos ? "" : j.substr(i + 1, e - i - 1);
|
||||
}
|
||||
size_t e = i;
|
||||
while (e < j.size() && j[e] != ',' && j[e] != '}') e++;
|
||||
return j.substr(i, e - i);
|
||||
}
|
||||
|
||||
static void applyState(const std::string& j) {
|
||||
std::string mining = jsonField(j, "mining"), node = jsonField(j, "node"), phase = jsonField(j, "phase");
|
||||
g_paused = jsonField(j, "paused") == "true";
|
||||
double hash = atof(jsonField(j, "hash_total").c_str());
|
||||
std::string blocks = jsonField(j, "blocks"), accepted = jsonField(j, "accepted_total");
|
||||
wchar_t tip[128];
|
||||
if (jsonField(j, "quitting") == "true") swprintf_s(tip, L"Igneum Wallet: stopping");
|
||||
else if (phase != "dashboard") swprintf_s(tip, L"Igneum Wallet: set up");
|
||||
else if (mining == "mining") swprintf_s(tip, L"Igneum Wallet: %.1f MH/s, %S blocks found, node %S", hash, accepted.c_str(), node.c_str());
|
||||
else if (mining == "paused") swprintf_s(tip, L"Igneum Wallet: paused, node %S", node.c_str());
|
||||
else swprintf_s(tip, L"Igneum Wallet: %S, node %S (%S blocks)", mining.c_str(), node.c_str(), blocks.c_str());
|
||||
setTray(tip);
|
||||
}
|
||||
|
||||
// The engine asks for an elevated step (the NVIDIA power cap): this process has a UI context, so the UAC prompt shows.
|
||||
// Runs cmd /c <line> as administrator, waits, and answers on the engine's stdin.
|
||||
static void runElevated(std::wstring line) {
|
||||
std::thread([line] {
|
||||
std::wstring params = L"/c " + line;
|
||||
wchar_t sysdir[MAX_PATH];
|
||||
GetSystemDirectoryW(sysdir, MAX_PATH);
|
||||
std::wstring cmdExe = std::wstring(sysdir) + L"\\cmd.exe"; // the absolute path, never a bare name (R4.3.3)
|
||||
SHELLEXECUTEINFOW sei = { sizeof(sei) };
|
||||
sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI;
|
||||
sei.lpVerb = L"runas";
|
||||
sei.lpFile = cmdExe.c_str();
|
||||
sei.lpParameters = params.c_str();
|
||||
sei.nShow = SW_HIDE;
|
||||
if (!ShellExecuteExW(&sei) || !sei.hProcess) {
|
||||
DWORD err = GetLastError();
|
||||
sendEngine(err == ERROR_CANCELLED ? "elevated fail: the administrator prompt was cancelled" : "elevated fail: could not start the elevated step");
|
||||
return;
|
||||
}
|
||||
WaitForSingleObject(sei.hProcess, 120000);
|
||||
DWORD code = 1;
|
||||
GetExitCodeProcess(sei.hProcess, &code);
|
||||
CloseHandle(sei.hProcess);
|
||||
if (code == 0) sendEngine("elevated ok");
|
||||
else { char buf[64]; sprintf_s(buf, "elevated fail: exit code %lu", code); sendEngine(buf); }
|
||||
}).detach();
|
||||
}
|
||||
|
||||
static void openInBrowser(const std::wstring& url) {
|
||||
ShellExecuteW(nullptr, L"open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
}
|
||||
|
||||
static void navigate() {
|
||||
if (g_webview && !g_url.empty()) g_webview->Navigate((g_url + L"?host=windows").c_str());
|
||||
}
|
||||
|
||||
static void initWebView() {
|
||||
std::wstring data = L"";
|
||||
wchar_t* local = nullptr;
|
||||
size_t len = 0;
|
||||
if (_wdupenv_s(&local, &len, L"LOCALAPPDATA") == 0 && local) { data = std::wstring(local) + L"\\igneum\\webview2"; free(local); }
|
||||
HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(nullptr, data.empty() ? nullptr : data.c_str(), nullptr,
|
||||
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>([](HRESULT result, ICoreWebView2Environment* env) -> HRESULT {
|
||||
if (FAILED(result) || !env) {
|
||||
g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window.";
|
||||
repaintStatus();
|
||||
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
|
||||
return S_OK;
|
||||
}
|
||||
env->CreateCoreWebView2Controller(g_hwnd, Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>([](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT {
|
||||
if (FAILED(result) || !controller) {
|
||||
g_status = L"The app window could not start WebView2. The dashboard opens in your browser.";
|
||||
repaintStatus();
|
||||
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
|
||||
return S_OK;
|
||||
}
|
||||
g_controller = controller;
|
||||
g_controller->get_CoreWebView2(&g_webview);
|
||||
g_webviewOk = true;
|
||||
ComPtr<ICoreWebView2Settings> settings;
|
||||
if (g_webview && SUCCEEDED(g_webview->get_Settings(&settings)) && settings) {
|
||||
settings->put_AreDefaultContextMenusEnabled(FALSE);
|
||||
settings->put_IsStatusBarEnabled(FALSE);
|
||||
settings->put_AreDevToolsEnabled(FALSE);
|
||||
}
|
||||
// links off 127.0.0.1 open in the default browser
|
||||
g_webview->add_NewWindowRequested(Callback<ICoreWebView2NewWindowRequestedEventHandler>([](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {
|
||||
LPWSTR uri = nullptr;
|
||||
if (SUCCEEDED(args->get_Uri(&uri)) && uri) { openInBrowser(uri); CoTaskMemFree(uri); }
|
||||
args->put_Handled(TRUE);
|
||||
return S_OK;
|
||||
}).Get(), nullptr);
|
||||
g_webview->add_NavigationStarting(Callback<ICoreWebView2NavigationStartingEventHandler>([](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT {
|
||||
LPWSTR uri = nullptr;
|
||||
if (SUCCEEDED(args->get_Uri(&uri)) && uri) {
|
||||
std::wstring u(uri);
|
||||
CoTaskMemFree(uri);
|
||||
if (u.rfind(L"http", 0) == 0 && u.find(L"127.0.0.1") == std::wstring::npos) { args->put_Cancel(TRUE); openInBrowser(u); }
|
||||
}
|
||||
return S_OK;
|
||||
}).Get(), nullptr);
|
||||
// the page's Windows Hello bridge (biometric.h): a JSON string in, a JSON object back on the UI thread
|
||||
g_webview->add_WebMessageReceived(Callback<ICoreWebView2WebMessageReceivedEventHandler>([](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT {
|
||||
LPWSTR src = nullptr;
|
||||
if (FAILED(args->get_Source(&src)) || !src) return S_OK;
|
||||
std::wstring origin(src);
|
||||
CoTaskMemFree(src);
|
||||
if (origin.rfind(L"http://127.0.0.1:", 0) != 0) return S_OK; // only the engine's own page
|
||||
LPWSTR text = nullptr;
|
||||
if (SUCCEEDED(args->TryGetWebMessageAsString(&text)) && text) {
|
||||
std::string msg = igbio::narrow8(text);
|
||||
CoTaskMemFree(text);
|
||||
igbio::handle(msg, [](const std::string& json) { PostMessageW(g_hwnd, WM_BIO_REPLY, 0, (LPARAM) new std::string(json)); });
|
||||
}
|
||||
return S_OK;
|
||||
}).Get(), nullptr);
|
||||
RECT rc; GetClientRect(g_hwnd, &rc);
|
||||
g_controller->put_Bounds(rc);
|
||||
COREWEBVIEW2_COLOR dark = { 255, 12, 12, 14 };
|
||||
ComPtr<ICoreWebView2Controller2> c2;
|
||||
if (SUCCEEDED(g_controller.As(&c2)) && c2) c2->put_DefaultBackgroundColor(dark);
|
||||
g_status = L"";
|
||||
repaintStatus();
|
||||
navigate();
|
||||
return S_OK;
|
||||
}).Get());
|
||||
return S_OK;
|
||||
}).Get());
|
||||
if (FAILED(hr)) {
|
||||
g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window.";
|
||||
repaintStatus();
|
||||
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
|
||||
}
|
||||
}
|
||||
|
||||
static bool startEngine() {
|
||||
SECURITY_ATTRIBUTES sa = { sizeof(sa), nullptr, TRUE };
|
||||
HANDLE outR, outW, inR, inW;
|
||||
if (!CreatePipe(&outR, &outW, &sa, 0) || !CreatePipe(&inR, &inW, &sa, 0)) return false;
|
||||
SetHandleInformation(outR, HANDLE_FLAG_INHERIT, 0);
|
||||
SetHandleInformation(inW, HANDLE_FLAG_INHERIT, 0);
|
||||
STARTUPINFOW si = { sizeof(si) };
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdOutput = outW;
|
||||
si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
|
||||
si.hStdInput = inR;
|
||||
PROCESS_INFORMATION pi = {};
|
||||
std::wstring exe = exeDir() + L"\\igneum-wallet.exe";
|
||||
std::wstring cmd = L"\"" + exe + L"\" --wrapper";
|
||||
std::vector<wchar_t> buf(cmd.begin(), cmd.end());
|
||||
buf.push_back(0);
|
||||
BOOL ok = CreateProcessW(exe.c_str(), buf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, exeDir().c_str(), &si, &pi);
|
||||
CloseHandle(outW);
|
||||
CloseHandle(inR);
|
||||
if (!ok) { CloseHandle(outR); CloseHandle(inW); return false; }
|
||||
CloseHandle(pi.hThread);
|
||||
g_engine = pi.hProcess;
|
||||
g_engineIn = inW;
|
||||
g_engineOut = outR;
|
||||
std::thread([] {
|
||||
std::string acc;
|
||||
char chunk[4096];
|
||||
DWORD n;
|
||||
while (ReadFile(g_engineOut, chunk, sizeof(chunk), &n, nullptr) && n > 0) {
|
||||
acc.append(chunk, n);
|
||||
size_t nl;
|
||||
while ((nl = acc.find('\n')) != std::string::npos) {
|
||||
std::string line = acc.substr(0, nl);
|
||||
acc.erase(0, nl + 1);
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
PostMessageW(g_hwnd, WM_ENGINE_LINE, 0, (LPARAM) new std::string(line));
|
||||
}
|
||||
}
|
||||
PostMessageW(g_hwnd, WM_ENGINE_LINE, 1, 0);
|
||||
}).detach();
|
||||
return true;
|
||||
}
|
||||
|
||||
static void showTrayMenu() {
|
||||
HMENU m = CreatePopupMenu();
|
||||
AppendMenuW(m, MF_STRING, ID_TRAY_OPEN, L"Open Igneum Wallet");
|
||||
AppendMenuW(m, MF_STRING, ID_TRAY_PAUSE, g_paused ? L"Lock" : L"Lock");
|
||||
AppendMenuW(m, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(m, MF_STRING | MF_GRAYED, 0, g_trayTitle.c_str());
|
||||
AppendMenuW(m, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(m, MF_STRING, ID_TRAY_QUIT, L"Quit Igneum Wallet");
|
||||
POINT pt; GetCursorPos(&pt);
|
||||
SetForegroundWindow(g_hwnd);
|
||||
TrackPopupMenu(m, TPM_RIGHTBUTTON | TPM_BOTTOMALIGN, pt.x, pt.y, 0, g_hwnd, nullptr);
|
||||
DestroyMenu(m);
|
||||
}
|
||||
|
||||
static void beginQuit() {
|
||||
if (g_quitting) return;
|
||||
g_quitting = true;
|
||||
g_quitStarted = GetTickCount64();
|
||||
g_status = L"Stopping: the miner first, then the node";
|
||||
setTray(L"Igneum Wallet: stopping");
|
||||
if (g_webview) g_webview->ExecuteScript(L"document.getElementById('pill-text').textContent='stopping'", nullptr);
|
||||
if (g_engine && !g_exited) {
|
||||
sendEngine("quit");
|
||||
SetTimer(g_hwnd, ID_QUIT_TIMER, 500, nullptr);
|
||||
} else {
|
||||
DestroyWindow(g_hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
switch (msg) {
|
||||
case WM_SIZE:
|
||||
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
|
||||
return 0;
|
||||
case WM_ENGINE_LINE: {
|
||||
if (wp == 1) {
|
||||
g_exited = true;
|
||||
if (g_quitting) { DestroyWindow(hwnd); return 0; }
|
||||
g_status = L"The engine stopped. Close this window and open Igneum Wallet again.";
|
||||
setTray(L"Igneum Wallet: stopped");
|
||||
repaintStatus();
|
||||
return 0;
|
||||
}
|
||||
std::string* line = (std::string*)lp;
|
||||
if (line->rfind("URL ", 0) == 0) {
|
||||
g_url = widen(line->substr(4));
|
||||
if (g_webviewOk) navigate();
|
||||
else if (!g_webview && g_status.find(L"WebView2") != std::wstring::npos && !g_hintShown) { openInBrowser(g_url); g_hintShown = true; }
|
||||
} else if (line->rfind("HOST ", 0) == 0) {
|
||||
// the host token and the sealed file's path; the page never sees this line
|
||||
g_hostLine = line->substr(5);
|
||||
igbio::configure(g_hostLine, g_url, L"Igneum Wallet", L"Turn on Windows Hello for your wallet", hwnd);
|
||||
} else if (line->rfind("STATE ", 0) == 0) {
|
||||
applyState(line->substr(6));
|
||||
} else if (line->rfind("ELEVATE ", 0) == 0) {
|
||||
runElevated(widen(line->substr(8)));
|
||||
} else if (line->rfind("FATAL ", 0) == 0) {
|
||||
g_status = widen(line->substr(6));
|
||||
repaintStatus();
|
||||
MessageBoxW(hwnd, g_status.c_str(), L"Igneum Wallet", MB_OK | MB_ICONERROR);
|
||||
} else if (*line == "EXIT") {
|
||||
g_exited = true;
|
||||
if (g_quitting) DestroyWindow(hwnd);
|
||||
}
|
||||
delete line;
|
||||
return 0;
|
||||
}
|
||||
case WM_BIO_REPLY: {
|
||||
std::string* json = (std::string*)lp;
|
||||
if (g_webview) g_webview->PostWebMessageAsJson(igbio::widen8(*json).c_str());
|
||||
delete json;
|
||||
return 0;
|
||||
}
|
||||
case WM_TIMER:
|
||||
if (wp == ID_QUIT_TIMER) {
|
||||
DWORD code = 0;
|
||||
bool gone = !g_engine || (GetExitCodeProcess(g_engine, &code) && code != STILL_ACTIVE);
|
||||
if (gone || g_exited || GetTickCount64() - g_quitStarted > 45000) {
|
||||
if (!gone && g_engine) TerminateProcess(g_engine, 1);
|
||||
KillTimer(hwnd, ID_QUIT_TIMER);
|
||||
DestroyWindow(hwnd);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_TRAY:
|
||||
if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); }
|
||||
else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu();
|
||||
return 0;
|
||||
case WM_COMMAND:
|
||||
switch (LOWORD(wp)) {
|
||||
case ID_TRAY_OPEN: ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); return 0;
|
||||
case ID_TRAY_PAUSE: sendEngine(g_paused ? "lock" : "lock"); return 0;
|
||||
case ID_TRAY_QUIT: beginQuit(); return 0;
|
||||
}
|
||||
return 0;
|
||||
case WM_CLOSE:
|
||||
// hide to the tray; the miner keeps running
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
if (!g_hintShown) {
|
||||
g_nid.uFlags |= NIF_INFO;
|
||||
wcscpy_s(g_nid.szInfoTitle, L"Igneum Wallet keeps mining");
|
||||
wcscpy_s(g_nid.szInfo, L"The window is in the tray. Right-click the icon to pause or quit.");
|
||||
g_nid.dwInfoFlags = NIIF_INFO;
|
||||
Shell_NotifyIconW(NIM_MODIFY, &g_nid);
|
||||
g_nid.uFlags &= ~NIF_INFO;
|
||||
g_hintShown = true;
|
||||
}
|
||||
return 0;
|
||||
case WM_PAINT: {
|
||||
PAINTSTRUCT ps;
|
||||
HDC dc = BeginPaint(hwnd, &ps);
|
||||
RECT rc; GetClientRect(hwnd, &rc);
|
||||
HBRUSH bg = CreateSolidBrush(RGB(12, 12, 14));
|
||||
FillRect(dc, &rc, bg);
|
||||
DeleteObject(bg);
|
||||
if (!g_status.empty()) {
|
||||
SetBkMode(dc, TRANSPARENT);
|
||||
SetTextColor(dc, RGB(154, 154, 158));
|
||||
HFONT f = CreateFontW(-15, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI");
|
||||
HFONT old = (HFONT)SelectObject(dc, f);
|
||||
RECT tr = rc; tr.left += 40; tr.right -= 40;
|
||||
DrawTextW(dc, g_status.c_str(), -1, &tr, DT_CENTER | DT_VCENTER | DT_WORDBREAK | DT_NOPREFIX | (g_webviewOk ? DT_BOTTOM : DT_VCENTER));
|
||||
SelectObject(dc, old);
|
||||
DeleteObject(f);
|
||||
}
|
||||
EndPaint(hwnd, &ps);
|
||||
return 0;
|
||||
}
|
||||
case WM_DESTROY:
|
||||
Shell_NotifyIconW(NIM_DELETE, &g_nid);
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcW(hwnd, msg, wp, lp);
|
||||
}
|
||||
|
||||
// --version and --help print one line and exit, for the CI smoke run and support scripts. A windows-subsystem exe has
|
||||
// no console of its own: the text goes to the inherited stdout when there is one (a pipe or a file), else to the
|
||||
// parent's console.
|
||||
static bool handleCliFlags() {
|
||||
int argc = 0;
|
||||
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
|
||||
if (!argv) return false;
|
||||
std::wstring text;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
std::wstring a = argv[i];
|
||||
if (a == L"--version" || a == L"-V") text = L"Igneum Wallet " IGNEUM_HOST_VERSION_STR L" (window host)\r\n";
|
||||
else if (a == L"--help" || a == L"-h" || a == L"/?")
|
||||
text = L"Igneum Wallet " IGNEUM_HOST_VERSION_STR L" (window host)\r\n"
|
||||
L"Usage: \"Igneum Wallet.exe\" [--version | --help]\r\n"
|
||||
L"Without flags it starts igneum-wallet.exe --wrapper next to it and shows the dashboard in a WebView2 window.\r\n";
|
||||
}
|
||||
LocalFree(argv);
|
||||
if (text.empty()) return false;
|
||||
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
if (out == nullptr || out == INVALID_HANDLE_VALUE) {
|
||||
if (AttachConsole(ATTACH_PARENT_PROCESS)) out = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
}
|
||||
if (out && out != INVALID_HANDLE_VALUE) {
|
||||
int n = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0, nullptr, nullptr);
|
||||
std::string utf8(n, 0);
|
||||
WideCharToMultiByte(CP_UTF8, 0, text.c_str(), (int)text.size(), &utf8[0], n, nullptr, nullptr);
|
||||
DWORD written = 0;
|
||||
WriteFile(out, utf8.data(), (DWORD)utf8.size(), &written, nullptr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
|
||||
if (handleCliFlags()) return 0;
|
||||
HANDLE once = CreateMutexW(nullptr, TRUE, L"Local\\IgneumWalletWindow");
|
||||
if (GetLastError() == ERROR_ALREADY_EXISTS) {
|
||||
HWND other = FindWindowW(L"IgneumWalletWindow", nullptr);
|
||||
if (other) { ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
|
||||
return 0;
|
||||
}
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
WNDCLASSW wc = {};
|
||||
wc.lpfnWndProc = WndProc;
|
||||
wc.hInstance = hInst;
|
||||
wc.lpszClassName = L"IgneumWalletWindow";
|
||||
wc.hIcon = LoadIconW(hInst, MAKEINTRESOURCEW(IDI_APP));
|
||||
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
wc.hbrBackground = CreateSolidBrush(RGB(12, 12, 14));
|
||||
RegisterClassW(&wc);
|
||||
int w = 1120, h = 820;
|
||||
int sx = (GetSystemMetrics(SM_CXSCREEN) - w) / 2, sy = (GetSystemMetrics(SM_CYSCREEN) - h) / 2;
|
||||
g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"Igneum Wallet", WS_OVERLAPPEDWINDOW, sx, sy, w, h, nullptr, nullptr, hInst, nullptr);
|
||||
ShowWindow(g_hwnd, SW_SHOW);
|
||||
|
||||
g_nid.cbSize = sizeof(g_nid);
|
||||
g_nid.hWnd = g_hwnd;
|
||||
g_nid.uID = 1;
|
||||
g_nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
|
||||
g_nid.uCallbackMessage = WM_TRAY;
|
||||
g_nid.hIcon = (HICON)LoadImageW(hInst, MAKEINTRESOURCEW(IDI_APP), IMAGE_ICON, 16, 16, LR_DEFAULTCOLOR);
|
||||
wcscpy_s(g_nid.szTip, L"Igneum Wallet: starting");
|
||||
Shell_NotifyIconW(NIM_ADD, &g_nid);
|
||||
|
||||
if (!startEngine()) {
|
||||
g_status = L"igneum-wallet.exe is missing next to this program. Run the installer again.";
|
||||
repaintStatus();
|
||||
MessageBoxW(g_hwnd, g_status.c_str(), L"Igneum Wallet", MB_OK | MB_ICONERROR);
|
||||
}
|
||||
initWebView();
|
||||
|
||||
MSG msg;
|
||||
while (GetMessageW(&msg, nullptr, 0, 0)) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
if (g_engine) { CloseHandle(g_engine); }
|
||||
CloseHandle(once);
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
36
app/windows/wallet-host.rc
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Resources for Igneum Wallet.exe (the window host): the coin icon and the version block.
|
||||
// Compiled by BUILD-WALLET-APP.bat with rc.exe; the icon path is relative to brand\icons (passed with /i). The version comes
|
||||
// from wallet-version.h, shared with wallet-host.cpp.
|
||||
#include <winver.h>
|
||||
#include "wallet-version.h"
|
||||
|
||||
1 ICON "igneum.ico"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION IGNEUM_HOST_VERSION_RC
|
||||
PRODUCTVERSION IGNEUM_HOST_VERSION_RC
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
FILESUBTYPE VFT2_UNKNOWN
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904B0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Igneum"
|
||||
VALUE "FileDescription", "Igneum Wallet"
|
||||
VALUE "FileVersion", IGNEUM_HOST_VERSION_STR
|
||||
VALUE "InternalName", "Igneum Wallet"
|
||||
VALUE "LegalCopyright", "Igneum. Nothing is bought or sold."
|
||||
VALUE "OriginalFilename", "Igneum Wallet.exe"
|
||||
VALUE "ProductName", "Igneum Wallet"
|
||||
VALUE "ProductVersion", IGNEUM_HOST_VERSION_STR
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
7
app/windows/wallet-version.h
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// The version of "Igneum Wallet.exe" (the window host). One place for wallet-host.rc and wallet-host.cpp.
|
||||
// Keep it equal to app/igneum-wallet/Cargo.toml and the AppVersion default in packaging/windows/Igneum-Wallet.iss.
|
||||
#ifndef IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_STR "0.1.6"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,1,6,0
|
||||
#endif
|
||||
|
|
@ -365,7 +365,18 @@ The k column. k is the chip core's energy per op over the GPU's at the same oper
|
|||
|
||||
Correction, 8 October 2026 (the research lane's microbench on the 5090, counter-asic-4-research.md 15.1a and the corrected 20.3 and 20.4 at 71fd465b): the per-MAC figures above are wrong by a factor of 32. A `mma.m8n8k16` tile is 1,024 multiply-adds per warp, 32 per lane, so a hash does 32 MACs per tile, not 1,024; the 4090's "0.056 pJ per MAC" is 1.8 pJ, and the 5090 at the ALU shadow's premium reads 2.9 pJ per MAC unlocked and 1.5 pJ at the 1,300 MHz lock (the packs job, 366,080 MACs per hash; the microbench's dependent u8 tile 4.1 and 2.2, the wide s8 m16n8k32 tile 1.36 and 0.83). Against the same 5 nm MAC array figures (0.04 to 0.4 pJ per INT8-class MAC, claimed) a chip's k on tile work is therefore 0.03 to 0.3, below the ALU shadow's 0.3 to 0.8, not near 1: at the same premium a tensor-shaped shadow leaves the chip 3.5x to 6.7x where the ALU shadow leaves it 2.1x to 3.5x. The tensor-tile column (2.1x at k = 1, 1.6x at k = 1.5) is withdrawn as a candidate; its premise, that a chip's MAC is no cheaper than the GPU's, is false by 4x to 30x on the public figures. The shipped row is unchanged: 2.1x at k = 1 and 3.4x at k about 0.33, the ALU shadow at the operating point's knee, measured four times at 82 to 90 W.
|
||||
|
||||
The capex column. The `f = 1` GDDR7 chip of 5.5 is USD 2.8 per MH/s of silicon and memory, which is USD 0.00016 per MH/s-hour of capex over two years against USD 0.000023 of electricity: capex-dominated 7x, as the 5090 is (USD 14.7 per MH/s at MSRP, 10x). A 64 MiB hot table adds about USD 15 of N5 die, the shadow core USD 25 to 40, an interposer USD 200, so the chip's capex reaches at most about USD 4.3 per MH/s: the per-unit capex wall is unreachable by 3x to 7x, and the break-even market cap moves only through the project cost (the mission lane's model: about USD 100 M with the N5 shadow core, about 200 M if the shadow runs per load and forces one die or an interposer; the per-load form behind that figure, the 16 x 27 placement, was closed on 7 October 2026 at night when it failed the value-level acceptance test across drawn eras, so the 200 M row rests on no construction shown to exist until a sound per-load class, one pass of a 432-instruction sub-block per load, is drawn, accepted and measured). Every figure here is modelled on cited or claimed parts; the research lane's microbench (20 probes, the mma_u8 and l2 rows the ones this model would take) is on PC 1's queue after the hot-table job.
|
||||
The capex column. The `f = 1` GDDR7 chip of 5.5 is USD 2.8 per MH/s of silicon and memory, which is USD 0.00016 per MH/s-hour of capex over two years against USD 0.000023 of electricity: capex-dominated 7x, as the 5090 is (USD 14.7 per MH/s at MSRP, 10x). A 64 MiB hot table adds about USD 15 of N5 die, the shadow core USD 25 to 40, an interposer USD 200, so the chip's capex reaches at most about USD 4.3 per MH/s: the per-unit capex wall is unreachable by 3x to 7x, and the break-even market cap moves only through the project cost (the mission lane's model: about USD 100 M with the N5 shadow core, about 200 M if the shadow runs per load and forces one die or an interposer; the per-load form behind that figure is REOPENED (8 October 2026, 11:0x UTC): the 16 x 27 iterated placement is dead on the no-era census (0.986 to 0.990 rejection per candidate on both instruments), the 7 October night's drawn-era death was an instrument artefact (the per-load acceptance test counted the era window's fixed top index bits as biased, fixed on counter-asic-4 at 10:5x UTC), and the sound form, one pass of a 256-instruction sub-block per load (`mx8+shl4096x1`), accepts 234 of 256 seeds on the no-era census with its drawn-era verdict the invention lane's 15:00 BST read; the 200 M row rests on that form, class v6's layer 5). Every figure here is modelled on cited or claimed parts; the research lane's microbench (20 probes, the mma_u8 and l2 rows the ones this model would take) is on PC 1's queue after the hot-table job.
|
||||
|
||||
### 5.12 Two chips from the hardware-future lane, and the honest denominator (8 October 2026, 11:3x UK, main's order; the figures lane B's, `docs/analysis/class-v6/hardware-future.md` at master 34f63b3c, modelled on this file's method unless marked claimed; the research lane carries them here as the model's own rows)
|
||||
|
||||
| Chip | When | Energy per random read | Rate per die | Energy per hash at zero shadow | Edge per joule against the 5090 at 2.40 microjoules | Against the M5 Max at 0.78 | With the class v4 shadow (F = 1.10 microjoules) at k = 0.5 / k = 1 | Silicon and project | What the four layers of class v6 do to it | Labels |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| **A 2 GiB SRAM full store on one N2 reticle** (TSMC N2 38 Mb/mm^2 HD SRAM, +11 percent over N3E, claimed, IEEE Spectrum, 12 December 2024; 2 GiB is about 452 mm^2 of macro under the 858 mm^2 reticle) | 2027 to 2028 (an N2 project; wafers about USD 30,000 and booked to 2028, claimed) | 1.0 nJ for a 64-byte read across the array (0.5 to 2.0: the wire term dominates, this file's 5.1 convention) | power-bound: about 2,100 MH/s at 300 W (0.14 microjoules per hash) | 0.14 | **17x (8x to 30x)** | 5.6x | **4.8x / 2.7x**; at the 5090's whole latency shadow (F about 2.0) 3.7x / 2.0x | about USD 400 to 600 of silicon per die, USD 0.25 to 0.4 per MH/s; an N2 project USD 100 M to 500 M and 18 to 24 months (claimed, the 2.5 economics; the mission lane's break-even model then reads a cap of about USD 330 M to 1.7 B in years 1 to 2 at s = 0.30) | layers 1, 3 and 4: nothing (the dataset is the SRAM's content, every draw firmware; the core an N2 core, k 0.3 to 0.5); layer 2 moves its CAPEX, not its joules: two dies at 4 GiB 15x and about USD 1,000, four at 8 GiB 13x and USD 2,000 to 2,500 | modelled; the N2 density and price claimed |
|
||||
| **A custom HBM4E base die** (the controller and PHY in the stack on N3P at 0.75 V, "2x the power efficiency", claimed, TrendForce, 1 December 2025; Micron 2027, SK hynix 2026; a non-hyperscaler customer from about 2028) | 2028 or later | 0.9 to 1.0 nJ | 166 MH/s per stack at the model's activate ceiling (36 at the JEDEC tFAW: the ceiling is unmeasured, 5.3) | 0.18 (0.37) | **14x (6.5x)** | 4.4x (2.1x) | 4.4x / 2.6x | about USD 5 per MH/s (approximate); the stack at a premium: Samsung asking about USD 4 to 5 per Gbit for HBM4 against 1.5 for HBM3E (claimed, 2 October 2026) | untouched by all four layers; the brake until about 2028 is HBM allocation and price, not the hash | modelled; the die and price claimed |
|
||||
| Per-bank processing in memory (Samsung HBM-PIM, SK hynix GDDR6-AiM and AiMX, Samsung LPDDR5X-PIM at Hot Chips 25 August 2026, LPDDR6-PIM in JEDEC work) | now to 2027 | the host memory's own | | | **under 1x: structurally blind to the hash's dependent random reads** | | | | a per-bank unit sees its own bank only: 1.6 percent of a hash's reads land in-bank at a 2 GiB dataset on a 32 MB bank, 0.4 percent at 8 GiB; every other read crosses the channel as it does for a controller chip; layer 2's growth lowers the share further | modelled (arithmetic on the bank size) |
|
||||
| The honest denominator | | | | the M5 Max at 0.78 microjoules per hash (the GPU and DRAM channels, measured 6 October), 3.1x the 5090 per joule; the 5090 at its knee 1.67 (measured 7 October) | | | | | against the best honest joule every chip edge is 2x to 3x smaller than against the 5090's stock point: the SRAM die 5.6x and the base die 3.6x to 4.4x at zero shadow, about 3x with the shadow at k = 0.5 | measured cards |
|
||||
|
||||
Reading, as the research lane reads it for the class v6 synthesis: the number this file's 5.6 verdict carried ("over 2x", the GDDR7 board at 5.1x) is no longer the strongest chip in the five-year window. The SRAM full store on merchant N2 reaches 17x at zero shadow and 2.7x to 4.8x with the class v4 shadow, and none of the rotating layers reaches it; what holds it is money and time (an N2 project at USD 100 M to 500 M, 18 to 24 months, the break-even cap in the hundreds of millions to about USD 1.7 B) and the one lever the chain has on it is capex through the dataset's size (layer 2's floor and its schedule, priced in the class v6 synthesis). The served chip line is reviewed against these rows at 20:00 UK today in the synthesis's last section; nothing served moves on this section.
|
||||
|
||||
## 6. The per-day derivation (item 2)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Class v6, the family gate: how a parameter family is cryptanalysed as a family
|
||||
|
||||
Research lane D of the class v6 rotating-family design, under the Counter ASIC coordinator. First cut 8 October 2026, 11:0x to 17:00 BST (the deliverable clock); the full report by 09:00 BST on 9 October. Branch `class-v6-family-gate` (this document) and the harness branch `family-gate-v5` (the class v5 crate at 8f481459 plus the census harness, never a chain path). Internal research, not an independent review; nothing here touches a served number, the devnet or the testnet object; no consensus code this week. Every figure carries its source: a lane report on the mirror, a log on a box, or an arithmetic step shown in the text. Figures from memory are labelled approximate.
|
||||
Research lane D of the class v6 rotating-family design, under the Counter ASIC coordinator. First cut landed on the mirror's master at 11:40 BST, 8 October 2026 (fbe62a8b); this cut, with the measured coverage, is the 17:00 BST deliverable; the full report by 09:00 BST on 9 October. Branch `class-v6-family-gate` (this document) and the harness branch `family-gate-v5` (the class v5 crate at 8f481459 plus the census harness, never a chain path). Internal research, not an independent review; nothing here touches a served number, the devnet or the testnet object; no consensus code this week. Every figure carries its source: a lane report on the mirror, a log on a box, or an arithmetic step shown in the text. Figures from memory are labelled approximate.
|
||||
|
||||
The question. Class v6 (the founder's word at 11:1x BST, 8 October 2026) draws per era the parameters a release used to fix (layer 1), lets the dataset track the chain state (layer 2), rotates instruction families by height (layer 3) and generalises the acceptance floor and the uniformity test to each era's draw with a redraw on failure (layer 4). A chip is then built against a FAMILY of hashes, not one hash. The in-house pass of 7 October and the attack board F1 to F10 bounded ONE member of the family (class v4 sub-version 3 at 017e7037 and class v5 at 1c420786). This document says how the family is bounded: what the space is, how many eras must be drawn to say anything about the worst era, which tests the chain runs itself on every era and epoch, what the offline board looks like over the testnet period, what a published proof of testing contains, and what the whole apparatus cannot see.
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ The question. Class v6 (the founder's word at 11:1x BST, 8 October 2026) draws p
|
|||
| The per-era tests (section 3) | Three rings. Ring A, the chain runs per era at the cut, microseconds: the structural redraw rules (the band membership of every draw, the day-key cost rule, the product-bias rotation class recorded). Ring B, the chain runs per epoch, seconds: the acceptance rule generalised to the family's shape ((a) (b) (a') (c) (c') (c'') (c''') plus the two next-class tests, the per-site largest-256-item-bucket bound and the index-bit bias read, on the same 2^20 pass). Ring C, the gate runs offline per drawn era, box-hours: the F8-form census at 2^24 on 64 seeds, the attempts census, the exhaustion count. Each row carries its known-failed shape and its clean-seed cost so the redraw is priced |
|
||||
| The board (section 4) | F1 to F10 and the nine lanes classified per-era, per-family or chain-run, with the harness taking the era draw as an input; the proof of testing is one JSON record per drawn era (the draw, the object, the binary, the seeds, every verdict with its statistic, threshold and log sha256, the core-seconds) and one family summary (eras per stratum, the worst era per test, the refuse rate per band, the bound's arithmetic) |
|
||||
| The honest line (section 5) | The gate sees what its tests see. It does not see the diffuse era-stride class below the bit-bias read's resolution (a bias under 0.3 percent at 2^20), a mechanism that lives in a joint corner no stratum names, a mixer weakness below adv-mixer-3's bands, or an era whose draw sits in the untested 1 in 64. A GPU-like chip loses nothing on any era; what a corner buys it is a per-era hot set bounded at 1.002x (the floor) or 1.0024x (the diffuse class), or, at `m = 4`, a mixer with 2 applications of measured margin instead of 6 |
|
||||
| Running now (section 6) | The harness `family_gate_era_census` on build-1 from 11:16 BST: one drawn era per seed, every family parameter from the era's own stream, the chain draw through the real rule keyed on the family's shape, one TSV row per era with the (c'') ratio, the bucket ratio, the bit bias and the attempts; the base control (`v5_attempts_census`, the shipped draw) beside it. The coverage table in section 6 is filled from the logs at 16:xx BST |
|
||||
| Measured (section 6) | 24,000 drawn eras through the ring-B tests in 54 core-hours (1.7 box-hours) on build-1 and build-3, 11:16 to 12:13 BST, the harness as a diff in `logs/`. Four measured rows move the design: the lossy corner of B = 4 exhausts the 256 cap in 1.2 percent of eras (61 of 5,000) against 0 of 19,000 elsewhere, so the band is B = 4 on the injecting families with or/mul/mulhi never raised (0 of 3,000 under it); the (c''') floor at 0.995 costs 4.5 to 6.5 percent of candidates at width 4 against 0.6 to 1.0 at width 1 (a per-width calibration); the shape axis moves the draw from 1.8 attempts (64 x 108) to 3.8 (256 x 27); the era-stride bias sits at over 6 sigma on one address bit (bit R in 73 percent of cases) in about half the eras of every stratum and at over 100 sigma in a third, falling to 7.5 percent under R in 28..31, a structural fact for the research lane's layer 4 rather than a per-epoch refusal |
|
||||
|
||||
## 1. The parameter space
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ The question. Class v6 (the founder's word at 11:1x BST, 8 October 2026) draws p
|
|||
| Axis | Band proposed for the draw | What fixed the band (the measured rows) | The tested margin inside the band | Known-failed corner, named |
|
||||
|---|---|---|---|---|
|
||||
| Mixer multiplier `m` (applications per round, `LoadClass::mixer_mult`; 72 per item at `m = 8`, 8 between dependent reads) | {4, 8, 16}; the research lane's outline; this lane's reading: {8} plus {4} as a corner, {16} only after the verifier row | x4 and x8 measured (`docs/plans/mixer-x4.md` section 6.4: 1.92 and 2.79 ms per unit on a loaded M5 Max core; the daily build 42 ms at x8 on the 5090, measured); x16 estimated at about 3.7 ms per unit on the M5 Max core and 9 ms on a 2019-class core (chip-model-v3 section 3 item 1), against the 10 ms verifier gate | adv-mixer-3 (`report-mixer-3.md`, "The round margin, stated"): no statistic survives 2 of the 8 keyed applications between reads at bands 0.0015 (Q2, 2^24 states), 0.00026 (2^27), 0.00018 (Q2b, 2^28); SAT solved at k = 1 in 137 s and timed out at k = 2, 3, 4; two real days and eight random days agree. So at `m = 8` 6 of 8 applications are margin; at `m = 4` the same rows leave 2 of 4; at `m = 16` 14 of 16 | None measured. The corner is `m = 4` by margin and `m = 16` by the verifier; the all-ROT-equal day key is the day-level corner (rejected by the AP-F4-1 rule) |
|
||||
| Op-mix weights (the ten non-load families, sum 75) | Each weight within B points of table 1.4.2 (add 12, xor 10, mul 8, mad 8, shfl 8, rotl 7, sub 6, mulhi 6, rotr 6, or 4), renormalised by largest remainder; shfl and mulhi never raised; B = 2 (spec 1.13.1, proposed) or B = 4 (the research lane) | The microbench (`counter-asic-4-research.md` 15.1a, the 5090 alone, 8 October): pJ per counted op at the stock clock and the 1,300 MHz lock: arx 11.3 / 6.2, mul 13.9 / 8.3, mulhi 39.6 / 21.0, prmt 22.3 / 11.5, lop3 24.1 / 13.0, shfl 55.8 / 29.4; the shuffle is 4.9x the add, so a draw that raises shfl raises the card's premium per instruction with no better `k` for a chip (15.1b). F7's era census: the op-weight corners (15 to 31 of 75) read 1.0x against the GPU, 0 memory effect | The acceptance's rejection rate moves with the lossy share: at the table 0.68 per candidate ((a') 83.5 percent of rejections; adv-accept row 90, 20,000 seeds); the F7 census at B = 2 read every corner clean for the chip; nothing was measured at B = 4 before today (section 6) | The lossy corner: or, mul and mulhi all at +B. It raises (a') rejections and so the attempts per seed; the exhaustion bound `r^256` is the number to read there (section 3, ring C) |
|
||||
| Op-mix weights (the ten non-load families, sum 75) | Each weight within B points of table 1.4.2 (add 12, xor 10, mul 8, mad 8, shfl 8, rotl 7, sub 6, mulhi 6, rotr 6, or 4), renormalised by largest remainder; shfl and mulhi never raised; B = 2 (spec 1.13.1, proposed) or B = 4 (the research lane) | The microbench (`counter-asic-4-research.md` 15.1a, the 5090 alone, 8 October): pJ per counted op at the stock clock and the 1,300 MHz lock: arx 11.3 / 6.2, mul 13.9 / 8.3, mulhi 39.6 / 21.0, prmt 22.3 / 11.5, lop3 24.1 / 13.0, shfl 55.8 / 29.4; the shuffle is 4.9x the add, so a draw that raises shfl raises the card's premium per instruction with no better `k` for a chip (15.1b). F7's era census: the op-weight corners (15 to 31 of 75) read 1.0x against the GPU, 0 memory effect | The acceptance's rejection rate moves with the lossy share: at the table 0.68 per candidate ((a') 83.5 percent of rejections; adv-accept row 90, 20,000 seeds); the F7 census at B = 2 read every corner clean for the chip. MEASURED today (section 6): at B = 4 with the lossy families free to rise the random stratum reads r = 0.72 and a maximum attempt of 107 (the shipped stream's maximum is 28); at the lossy corner r = 0.956 and 1.2 percent of eras EXHAUST the 256 cap | The lossy corner: or, mul and mulhi all at +B (30 of 75 against the table's 18). It is OUTSIDE the band: 8 of 663 drawn eras reached the last resort, which fails rule (a) in 9 percent of seeds (adv-accept-3 finding 1). The band this lane carries: B = 4 on the seven injecting families, or/mul/mulhi never raised above their base (the `lossybase` stratum of section 6 is its measurement), with ring A's lossy-share rule as the check |
|
||||
| Read width `W` (words per load) | {1, 4} | `docs/plans/read-width.md` (5 October): w16 within the rules (5090 139.8 against 136.1 MH/s, 9070 XT 17.90 against 18.15, M5 Max within 1 percent); w64 bandwidth-bound (5090 share 0.58, 71.9 MH/s): out; the per-load mixes out on the 5 percent spread rule | The acceptance runs unchanged on aligned addresses (read-width.md section 2), but (c'') and (c''') read a site's distinct ALIGNED indices against `E_s = N - N^2 / 2W_s`: at `W = 4` the index space is `W_s / 4`, so the expectation must follow or every width-4 program reads 0.976 at a quarter window and is refused by the 0.98 floor. The harness divides the window by the width (`site_window_words`); the spec line is owed | `W = 16` (w64): excluded by measurement, never in the band |
|
||||
| Shadow block shape (instructions per block, passes per iteration) | {64 x 108, 128 x 54, 256 x 27}: 6,912 shadow instructions per iteration at rung 0 | 64-instruction blocks ran 2.5 to 3.5 percent faster than 256 on the 5090 and the M5 Max (6 October, measured); 1,024 cost the M5 Max 17 percent (out) | F1's shadow redundancy bound (3.0 percent of peephole-removable instructions, the v5 generator's `SHADOW_REMOVABLE_MAX_PERMILLE`) was set from F1's census at 256 instructions; the removable fraction per block is a property of the block's length, so the fraction must be re-read at 64 and 128 (a per-family row). The acceptance's (a') fixpoint and the shadow's execution in (c) take any length | The per-load placement (16 sub-blocks of 16 after each load): dead as a chain class (`counter-asic-4-research.md` 20.2a-close): 22 of 1,621 candidates accepted, 42 of 64 seeds exhaust 32 attempts, candidate 0 carries index bit 0 set in 40 of 1,024 addresses at site 0 (z 29.5). The placement axis therefore has ONE value in the band |
|
||||
| Program length `N` (counted ops per hash) | Not drawn: the ladder's signal (rungs 0 to 2); the research lane's outline keeps it so | F6: the worst of 10^5 class v4 programs 8.708 ms on the half-core proxy, so N about 300,000 fits under 10 ms (rung 2 admissible, rung 3 not); the M5 Max loses 3.3 to 10 points across the band (measured ladder) | The verifier's worst case was censused at rung 0 only; a draw inside the ladder needs the 10^5-program worst case at the top rung (per-family row) | Rung 3 (inadmissible by F6) |
|
||||
|
|
@ -65,6 +65,8 @@ Let a per-era test have a fixed verdict per era and let `p` be the fraction of t
|
|||
| 2^-10 | 505 years | 3,067 | 4,713 |
|
||||
| 2^-20 | the F4 and F7 gate's fraction | 3.1 million | 4.8 million |
|
||||
|
||||
Measured today (section 6.3): 10,000 random eras of the family and 3,000 of the proposed band pass the ring-B exhaustion and floor tests, so the failing fraction of eras on those tests is under 3.0e-4 and 1.0e-3 at 95 percent; the lossy corner fails at 1.2 percent and is out of the band.
|
||||
|
||||
What this buys and does not. The F4 and F7 gates ask "no class with gain over 1.1x at a fraction over 2^-20" and reach it by censusing 2^24 draws of a CHEAP statistic (microseconds per day key or era seed). A per-era test that costs box-hours (the F8 census at 2^24) can be run on hundreds of eras, so it bounds the failing fraction at the 1/64 to 1/256 class, never 2^-20. That is why layer 4 puts the tests on the chain: a per-era test the chain runs on EVERY era (ring A) or every epoch (ring B) needs no sampling bound at all; the offline census (ring C) is then a check on the chain-run tests' calibration (their clean refuse rate and their known-failed firing per stratum), and a reading of the one statistic the chain cannot afford, the live-dataset census.
|
||||
|
||||
### 2.2 The union bound over the per-era tests
|
||||
|
|
@ -103,7 +105,7 @@ The three rings. A test's ring says who runs it and what a failure costs.
|
|||
| Band membership: `m` in the band, `W` in the allowed set, the shape in the set, every weight within B of the table with shfl and mulhi at or under their base, the sum 75 | the layer-1 draws | a draw outside the band (a unit test) | 0 by construction (the draw is bounded) | spec 1.13.1's form |
|
||||
| The day-key cost rule (AP-F4-1, class v5) | ROT, MUL | mul1all, mulnaf, day 29,337 | 6.1e-4 per day | f4-weakday.md 6.7 to 8; 8ca66afa |
|
||||
| The rotation class recorded: whether R leaves a product's low bits inside the index at the era's D | R, D | none (a record, not a refusal: restricting R to {29, 30} would cut the interleave's entropy from 31 to 2 values; the value-level test of ring B is the catch) | 0 | adv-cache-2 2.3 |
|
||||
| The weights' lossy share bound: or + mul + mulhi at most the table's 18 plus B, so the exhaustion bound `r^256` stays under 1e-18 (r under 0.85) | weights | the lossy corner of section 6 if its `r` reads over 0.85 | 0 by construction | row 90 (r = 0.681 at the table) and section 6 |
|
||||
| The weights' lossy share bound: or + mul + mulhi at most the table's 18 (never raised), so the exhaustion bound `r^256` stays under 1e-18 (r under 0.85) | weights | the lossy corner of section 6: r = 0.956, 8 of 663 eras at the cap (FIRED) | 0 by construction | row 90 (r = 0.681 at the table) and section 6 |
|
||||
|
||||
### 3.2 Ring B: the acceptance rule generalised to the family, per epoch
|
||||
|
||||
|
|
@ -117,7 +119,7 @@ The three rings. A test's ring says who runs it and what a failure costs.
|
|||
| (c''') the hot-item floor at 0.995 | the same `E_s`; the floor's clean spread re-read per stratum (section 6): a fixed 0.995 under a spread that moves with the shape or the width is either a liveness cost or a miss | seed 100767 (0.9919) and the nine live hot sets | 2.435 percent of accepted class v4 programs, +0.045 on the mean attempt | in the (c'') pass |
|
||||
| The per-site largest-256-item-bucket bound (NEW, the F8 tail's catch) | the largest count over a site's 4,096-word buckets in its window against `N / (W_s / 4096)`, in SIGMA of the bucket's expectation (`(max - E) / sqrt(E)`), refused above a band set from the clean spread. The ratio alone cannot carry the bound: a clean full-window site's largest of 65,536 Poisson(16) buckets reads 2.1x at +4.4 sigma, a quarter-window site's largest of 16,384 Poisson(64) buckets 1.5x at +4 sigma (the 16-era smoke run read ratios 2.2 to 2.4 on clean sites); the four tail seeds' 3.1x to 5.6x at narrow windows are +17 to +37 sigma | p4, p8, p10, p34 | measured in section 6 (the columns `bucket_z_max`, `bucket_win`) | one linear pass over the (c'') indices |
|
||||
| The index-bit bias read (NEW, adv-cache-2's value-level test) | per site, the one-count of each free index bit (bits above the width's alignment and below the window's cut) over the 2^20 evaluations, in sigma (`sigma = sqrt(N) / 2 = 512`); a 6-sigma band is a bias of 0.3 percent at 2^20 (the product law's bit 0 at 25 percent reads z about 512, bit 1 at 3/8 z 256, bit 3 at 3.1 percent z 64, bit 6 at 0.4 percent z 8, bit 7 at 0.2 percent z 4: passes). MEASURED on the first 16 drawn eras (11:21 BST, section 6): 7 of 16 accepted programs carry one site with a bias of 130 to 511 sigma at address bit R or R + 1 (the era's rotation), the era-stride class exactly, on programs (c''') passes at 0.9954 to 1.0000; the 9 others read under 3.8 sigma. So as a REFUSAL the read would redraw about 40 percent of epochs; its right use is as a record per era plus the structural lever (fold the product's low bits in `load_index` before the rotation, a next-class item), and the chip price is stated in section 5 | Devnet 3 site 0, era-drawn-28 site 15, the per-load candidate 0; and now the 7 of 16 | 7 of 16 at 6 sigma (a refusal is not the form); 0 of 16 at a 600-sigma band | one pass over the indices, 28 bit tests each |
|
||||
| The cap and the last resort | unchanged at 256; the last resort's own verdict is adv-accept-3's finding 1 (9 percent of last-resort programs fail (a)); the family keeps the open fix (continue past 256 until one passes) | the reject-everything mirror | 0 reached in 1.02 x 10^6 seeds | n/a |
|
||||
| The cap and the last resort | unchanged at 256; class v5 carries the verified construction (`last_resort_v5`: a scan of 256 more candidates past the cap, each rewritten and its stale loads re-sourced, the first that passes the whole rule; the unchecked fallback behind it), so adv-accept-3's finding 1 (sub-version 3's unchecked last resort failing (a) in 9 percent of seeds) is closed on the family's crate. What the family changes: the scan's stated odds (under 1e-300) assume independent attempts, and section 6 measures a per-era exhaustion rate 1,000x the independent estimate at the lossy corner, so the fallback's odds are the corner's, not the arithmetic's; ring A keeps the corner out of the band | the reject-everything mirror; the 61 exhausted eras of section 6 (the scan's verdict on each is the full report's item 5) | 0 reached in 1.02 x 10^6 seeds of the shipped draw; 0 of 19,000 family eras outside the lossy corner | n/a |
|
||||
|
||||
### 3.3 Ring C: the offline tests per drawn era
|
||||
|
||||
|
|
@ -182,40 +184,78 @@ What the family gate cannot see, and how a chip would use it.
|
|||
| The verifier at `m = 16` and rung 2 on a 2019-class core | unmeasured (an estimate of 9 ms against the 10 ms gate) | nothing for a chip; a node tier retired if the corner is drawn | the row is owed before 16 enters the band |
|
||||
| The acceptance's stand-in at D = 28 against a live D = 29 | the windows cap `min(k_off, D - 26)` and the rotation's cut set move with D; the gap is measured at D = 28 only | a per-site concentration the closed form does not reproduce | 0.0004 at D = 28; unmeasured at 29 (ring C row) |
|
||||
| The union bound's own looseness | the per-test miss rates rest on 3 to 11 known-failed cases each (section 2.2), so the family's false-pass rate is not yet a number under 1 | nothing new; the bound is on what the gate claims, not on the hash | fixed by cases, not eras: 60 fired cases per test for `delta` under 0.05 |
|
||||
| The last-resort program | 9 percent of last-resort programs fail (a) and are handed out unchecked; a lossy-corner era that raised `r` toward 0.95 would reach it at 2e-6 per epoch | a program that re-reads one address in two loads of the same hash | ring A's lossy-share bound keeps `r` under 0.85 (`r^256` under 1e-18); the fix (continue past 256) is owed to the rule's owner |
|
||||
| The last-resort path at a corner | class v5's scan hands out a rule-checked program, and its unchecked fallback is priced at under 1e-300 on INDEPENDENT attempts; the lossy corner measured 1.2 percent of eras exhausting against a 1.3e-5 independent estimate, so at a corner the attempts of one era are correlated through the era's own weights and the fallback's odds are unknown there | a program that re-reads one address in two loads of the same hash, if the fallback were ever reached | ring A keeps the corner out of the band (0 of 19,000 eras exhaust elsewhere); the scan's verdict on the 61 exhausted eras is owed (section 7 item 5) |
|
||||
| The era redraw's own grindability | a ring-A redraw consumes the stream's next block, so an adversary who could steer `E_n` could steer which block is used; the era VDF of F7 (517 s on the fastest prover, 259x the window) closes the steering of `E_n` itself | nothing beyond F7's bound | F7 (a) PASS with the VDF in the node |
|
||||
|
||||
A GPU-like chip (the `f = 1` stored-dataset chip with a programmable core, chip-model-v3 section 5) loses nothing on any era of the family: every drawn parameter is firmware to it, and what the family costs it is the core sized for the band's top (the research lane's USD 30 to 60 per chip, modelled). The family gate's job is therefore not to defeat that chip (the identity of the research file's section 2 says the per-joule edge is the card's own idle, 2.1x at `k = 1`) but to make sure no drawn era hands ANY chip more than the one hash did: a per-era hot set, a per-era mixer shortcut, a per-era verifier miss. Everything in this document is a bound on that increment, and the increments found so far are 1.002x and 1.0024x.
|
||||
|
||||
## 6. Running now, and the coverage measured
|
||||
## 6. The coverage measured (24,000 drawn eras, 11:16 to 12:13 BST, 8 October 2026)
|
||||
|
||||
Started 11:1x BST, 8 October 2026, on igneum-build-1 under `lease pool` (class measure, owner class-v6-family-gate), the scripts under `/srv/builds/_adv-family-gate/` (fg-setup.sh, fg-build.sh, fg-census.sh), every binary run from a pinned copy under `bin/<tag>/` with its sha256 (AP-H2's rule), logs and TSV rows under `logs/`, copies of the finished logs land under `docs/analysis/class-v6/logs/` on this branch with the full report.
|
||||
The harness: branch `family-gate-v5` at 3dc3117c (the class v5 crate 8f481459 plus `accept::tests::family_gate_era_census`; the diff is `logs/harness-family-gate-v5-3dc3117c.diff` (a plain diff of igneum-pow/src: the gate refuses the patch form's author lines) in this directory, since the branch's push to the mirror was refused by the class v5 tree's own pre-push hook and is held). What it does per era `k` of a label space: the era bytes from `igneum-family-gate[/stratum]/era/k` and the epoch bytes from `.../program/k` (the F8 label space's form); from the era's own stream, in a fixed order and consumed whether pinned or not, the shadow block shape in {64 x 108, 128 x 54, 256 x 27}, the mixer multiplier in {4, 8, 16} (recorded: the acceptance never runs the mixer), the ten non-load weights perturbed by `below(2B + 1) - B` with shfl and mulhi never raised and renormalised to 75 by largest remainder (B = 4), the read width through the era draw over {1, 4} words (the mix one-hot on the drawn width); then the chain draw of that era's epoch through the real rule keyed on the family's shape (`is_family_shape`, the acceptance's `is_class_v4_shape` generalised behind `IGNEUM_FAMILY_GATE`; the draw's source rule reads the same predicate), every candidate's first failing part recorded, the cap 256 and the last resort as shipped; on the accepted program, on the rule's own 2^20 sample (4,096 units), per site: the (c'') and (c''') ratio with the window divided by the width, the largest 4,096-word (256-item) bucket in sigma of its expectation, and the largest index-bit one-count excess in sigma over the free bits. One TSV row per era (`logs/<stratum>-family_gate_era_census-<from>-<n>.tsv`, the run's log beside it with the binary's sha256 and the lease line); every binary a pinned copy under `/srv/builds/_adv-family-gate/bin/<tag>/` (fg2 9b7f764a for the first corners, fg5 e7a50f8d for the width-4 and lossy-base strata). Cost: about 10 core-seconds per era; 54.4 core-hours in all (1.7 box-hours of 32-core slots) on build-1 (random, lossy cap, width 4) and build-3 (shape 64, lossy base, width 4 plus lossy cap), every run under `lease pool` at class measure. The control: `v5_attempts_census` on the shipped class v5 draw over 10,000 seeds of the F8 label space (build-1, 24 cores, 1,534 s, binary 4bec799c, `logs/base-v5_attempts_census-1000-10000.log`): r = 0.6854, mean attempt 2.179, 0 exhaustions, (a') 82.5 percent of rejections, (c''') 0.9 percent, the row-90 reading reproduced.
|
||||
|
||||
| Run | What | Where | State |
|
||||
|---|---|---|---|
|
||||
| base | `v5_attempts_census` (the shipped class v5 draw: shape 256 x 27, `m = 8`, `W = 1`, the table weights) over f8-label seeds 1,000 to 11,000, each seed its own drawn era (M, R, pos, windows) | build-1, 24 cores, binary 4bec799c (8f481459 unmodified) | running from 11:16 BST |
|
||||
| fg1 random | `family_gate_era_census` with every axis drawn (shape in {64, 128, 256}, `m` in {4, 8, 16} recorded, `W` over {1, 4}, weights at B = 4 with shfl and mulhi never raised) | build-1 | the harness built at 11:2x BST; the smoke run and the chunks follow |
|
||||
| fg1 corners | the same harness pinned per cell: `IGNEUM_FG_LOSSY_CAP`, `IGNEUM_FG_WIDTH=4`, `IGNEUM_FG_SHAPE=64`, the combinations | build-1, build-3, build-4 as the pool frees | queued |
|
||||
Voided and stated: the first width-4 stratum (3,000 eras, 11:2x to 11:4x BST) ran at width 1, because `LoadClass::era` with a one-entry allowed set is the pinned path and redraws to the base's widest width; the fix builds the pinned class by hand (the era drawn over the one-entry set, the mix one-hot); the void rows are not in this directory and the stratum was re-run under its own label space. The first corner strata (lossy cap, shape 64) share the random stratum's label space, so where the random draw happened to land on the pinned value the era is the same program in both; the later strata use per-stratum labels.
|
||||
|
||||
The coverage table (filled from the TSV rows at 16:xx BST for the 17:00 cut; the full read at 09:00 BST tomorrow):
|
||||
### 6.1 Per stratum
|
||||
|
||||
| Stratum | Eras drawn | `r` per candidate | Mean attempt, max | Exhausted | min (c'') ratio (the worst era) | Under 0.995 (the (c''') refuse rate on the accepted draw) | Largest bucket ratio (worst era, site) | Largest bit bias in sigma (worst era, site, bit) |
|
||||
| Stratum | Eras | r per candidate | Mean attempt, max | Exhausted at the 256 cap | min (c'') ratio of the accepted draw, worst era | The floors' refuse rates over all candidates | Largest bucket excess, worst era | Largest index-bit bias, worst era; the share of eras over 6 and over 100 sigma |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| base (shipped draw) | pending | | | | | | | |
|
||||
| random (every axis drawn) | pending | | | | | | | |
|
||||
| lossy cap | pending | | | | | | | |
|
||||
| W = 4 | pending | | | | | | | |
|
||||
| shape 64 | pending | | | | | | | |
|
||||
| random (every axis drawn, B = 4 on every family) | 10,000 | 0.724 | 2.63, 236 | 0 | 0.9950 at site 0 of era 1150 (R 14, shape 64, W 1) | (c''') 988 of 36,285 candidates (2.72 percent), (c'') 368 (1.01) | +512 sigma at site 4 (full window) of era 2794 (R 11) | z = -1,024 at site 13 bit 18 of era 3310 (R 18); over 6 sigma 5,172 (51.7 percent), over 100 sigma 3,390 (33.9) |
|
||||
| lossy base, THE PROPOSED BAND (B = 4 on the seven injecting families, or/mul/mulhi never raised) | 3,000 | 0.595 | 1.47, 59 | 0 | 0.9951 at site 0 of era 114 (R 25, shape 64, W 4) | (c''') 290 of 7,410 (3.91 percent), (c'') 110 (1.48) | +369 sigma at site 12 of era 2099 (R 10) | z = -1,024 at site 14 bit 26 of era 499 (R 26); over 6 sigma 1,431 (47.7 percent), over 100 sigma 933 (31.1) |
|
||||
| lossy cap (or, mul, mulhi all at +4: 30 of 75) | 3,000 | 0.957 | 19.21, 254 | 37 (1.23 percent of eras; the first eight 22, 123, 125, 170, 293, 305, 348, 584) | 0.9950 at site 13 of era 2359 | (c''') 282 of 69,363 (0.41 percent), (c'') 131 (0.19) | +184 sigma at site 0 of era 2085 (R 14) | z = -896 at site 3 bit 26 of era 2141 (R 26); over 6 sigma 57.7 percent, over 100 sigma 37.0 |
|
||||
| width 4 words (the pinned width, every other axis drawn) | 3,000 | 0.738 | 2.82, 114 | 0 | 0.9950 at site 4 of era 865 (R 2) | (c''') 497 of 11,454 (4.34 percent), (c'') 134 (1.17) | +44 sigma at site 5 (half window) of era 270 (R 21) | z = -768 at site 15 bit 25 of era 183 (R 25); over 6 sigma 47.5 percent, over 100 sigma 28.6 |
|
||||
| width 4 plus lossy cap | 2,000 | 0.959 | 20.15, 253 | 24 (1.20 percent) | 0.9950 at site 1 of era 1869 | (c''') 352 of 47,934 (0.73 percent), (c'') 124 (0.26) | +70 sigma at site 0 (quarter window) of era 1440 (R 10) | z = -736 at site 11 bit 27 of era 1239 (R 27); over 6 sigma 52.3 percent, over 100 sigma 31.2 |
|
||||
| shape 64 x 108 (the pinned shape) | 3,000 | 0.643 | 1.80, 31 | 0 | 0.9950 at site 0 of era 1150 | (c''') 301 of 8,399 (3.58 percent), (c'') 104 (1.24) | +512 sigma at site 4 of era 2794 (R 11) | z = -1,024 at site 10 bit 1 of era 2431 (R 1); over 6 sigma 52.1 percent, over 100 sigma 34.0 |
|
||||
| the shipped draw (control) | 10,000 | 0.685 | 2.18, 28 | 0 | not read (the census test reports parts only) | (c''') 0.6 percent of candidates, (c'') 0.8 | | |
|
||||
|
||||
Rejections by first failing part, the random stratum (26,285 rejected candidates): (a') 76.4 percent, (a) 13.4, (b) 3.8, (c''') 3.8, (c'') 1.4, (c) constant bit 0.6, (c) saturated 0.5, (c') 0.1, (c) distinct addresses 0.1, (c) output bias 0.0. At the lossy cap (66,400): (a') 74.6, (a) 20.8, (b) 3.7, the dynamic parts under 1 together. The shape of the rule's cost does not change across the family; its level does.
|
||||
|
||||
### 6.2 Per axis, inside the random stratum (10,000 eras) and the proposed band (3,000)
|
||||
|
||||
| Axis | Value | Eras (random / band) | Mean attempt (random / band) | Max attempt | r (random / band) | (c''') refusals per candidate (random / band) | Eras with a site over 6 sigma | Over 100 sigma | Bucket sigma p99 | min ratio p1 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| width (words) | 1 | 4,978 / 1,508 | 2.35 / 1.30 | 158 / 59 | 0.701 / 0.565 | 0.61 / 0.98 percent | 55.7 / 52.1 percent | 39.1 / 36.4 | +51.5 | 0.9959 |
|
||||
| width (words) | 4 | 5,022 / 1,492 | 2.91 / 1.64 | 236 / 23 | 0.744 / 0.621 | 4.52 / 6.50 percent | 47.8 / 43.3 | 28.8 / 25.7 | +28.2 | 0.9956 |
|
||||
| shape | 64 x 108 | 3,371 / 980 | 1.81 / 1.22 | 40 / 14 | 0.645 / 0.550 | 3.86 / 4.31 | 50.5 / 48.7 | 33.0 / 31.3 | +40.7 | 0.9958 |
|
||||
| shape | 128 x 54 | 3,302 / 963 | 2.26 / 1.28 | 62 / 12 | 0.694 / 0.562 | 2.76 / 4.32 | 51.8 / 48.7 | 34.1 / 32.9 | +46.0 | 0.9958 |
|
||||
| shape | 256 x 27 | 3,327 / 1,057 | 3.82 / 1.87 | 236 / 59 | 0.792 / 0.651 | 2.02 / 3.33 | 52.8 / 45.9 | 34.6 / 29.2 | +41.7 | 0.9957 |
|
||||
| mixer m (recorded) | 4 | 3,305 / 990 | 2.72 / 1.64 | 236 / 59 | 0.731 / 0.621 | 2.74 / 3.94 | 51.5 / 47.8 | 34.0 / 31.5 | +41.5 | 0.9958 |
|
||||
| mixer m | 8 | 3,382 / 1,011 | 2.67 / 1.38 | 158 / 21 | 0.728 / 0.580 | 2.76 / 3.86 | 51.2 / 47.2 | 33.9 / 31.3 | +42.5 | 0.9957 |
|
||||
| mixer m | 16 | 3,313 / 999 | 2.49 / 1.39 | 113 / 36 | 0.714 / 0.581 | 2.66 / 3.94 | 52.4 / 48.1 | 33.8 / 30.5 | +42.4 | 0.9959 |
|
||||
| rotation class | R 1..27 | 8,732 / 2,587 | 2.64 / 1.51 | 236 / 59 | 0.725 / 0.602 | 3.07 / 4.40 | 53.9 / 49.2 | 37.7 / 35.0 | +44.5 | 0.9957 |
|
||||
| rotation class | R 28..31 (bit 0 cut at D = 28) | 1,268 / 413 | 2.57 / 1.21 | 231 / 12 | 0.720 / 0.548 | 0.29 / 0.44 | 36.5 / 38.5 | 7.5 / 6.8 | +6.5 | 0.9985 |
|
||||
|
||||
Readings, each a measured row above:
|
||||
|
||||
1. The mixer axis is invisible to every per-era statistic, as section 1.3 said it must be: the three `m` values read the same to the noise (r 0.714 to 0.731, the biased share 51 to 52 percent). `m` is a per-family axis, closed by the mixer rows of section 4, not by drawing eras.
|
||||
2. The shape axis moves the draw's cost: 256 x 27 costs 3.82 attempts against 1.81 at 64 x 108 (r 0.792 against 0.645), because the freshness fixpoint (a') walks the whole block and a 256-instruction block holds more lossy last writers per iteration than a 64-instruction one. The band's cheapest shape is the fastest card shape too (64, measured 2.5 to 3.5 percent faster on 6 October).
|
||||
3. The width axis moves the (c''') floor's calibration: at width 4 the floor at 0.995 refuses 4.5 to 6.5 percent of candidates against 0.6 to 1.0 at width 1, with the expectation already divided by the width. The clean spread of the ratio at width 4 is not the width-1 spread the floor was set from (the aligned index space is a quarter the size, so the birthday collisions on the closed form are 4x denser, and the `N - N^2 / 2W` form is 0.0007 low at `N / W = 1/16`). A floor fixed at one number across the family is therefore a per-width liveness cost of 4x the (c''') attempts; the family's floor is set per width from the clean spread (the next measurement, section 7 item 1), or the statistic is re-expressed as a sigma over its own expectation as the bucket statistic now is.
|
||||
4. The lossy corner breaks the exhaustion bound: r = 0.957 to 0.959 and 1.2 percent of eras at the 256 cap in both lossy strata (61 of 5,000), against 0 of 19,000 in every other stratum and 0 of 10,000 on the shipped draw. The arithmetic agrees: 0.957^256 = 1.3e-5 per seed is the geometric estimate, but the attempts are not independent across one seed's candidates (the lossy weight table is shared by every attempt of the era, so the rejection rate per era is itself drawn from a spread), and the measured per-era rate is 1.2e-2, three orders above the independent-attempt figure. The ring-A rule is the one the data forces: or + mul + mulhi never raised above the table's 18 of 75. Under it (the lossy-base stratum) r = 0.595, the mean attempt 1.47, the maximum 59, 0 exhaustions in 3,000.
|
||||
5. The index-bit bias is the family's standing fact: 48 to 58 percent of accepted programs in every stratum carry one site with a bias over 6 sigma at 2^20, and 73 percent of those are at bit R exactly, 12 percent at R + 1, 5 percent at R + 2 (the product law's bits 0, 1, 2 at P = 1/4, 3/8, 7/16 through `rotl(x * M, R)`); 74 percent read P(bit) under 1/2 (a product's low bit), 26 percent over 1/2 (an or-shaped source, 5/8 and up). Under R in 28..31 (bit 0 cut by the 28-bit mask) the share over 100 sigma falls from 37.7 to 7.5 percent and the bucket excess's p99 from +44 to +6.5 sigma, which is adv-cache-2's R-class reading at 10,000 eras. The shipped devnet's R = 29 is one of the four clean rotations; the chain's eras are drawn from all 31. The bucket statistic and the bit statistic see the same mechanism (the bucket's p99 on the bit-clean eras is +6.8 sigma, on the biased ones +44), so one next-class test covers both: the value-level read, with the structural fix in `load_index` as the remedy rather than a refusal (a 6-sigma refusal redraws half the epochs; a 100-sigma one a third).
|
||||
6. The (c''') floor holds everywhere by construction (0 accepted programs under 0.995) and its clean spread at width 1 is the shipped one (p1 of the minimum ratio 0.9958 to 0.9961, median 0.9999 across strata); the floor's refuse rate moves from 2.7 percent (random) to 3.9 (the band) and 0.4 (the lossy corner, where (a') refuses first).
|
||||
|
||||
### 6.3 What the counts bound (the arithmetic of section 2 on these rows)
|
||||
|
||||
| Claim | n | Bound at 95 percent | Bound at 99 percent |
|
||||
|---|---|---|---|
|
||||
| An era of the proposed band whose chain draw exhausts the 256 cap | 3,000 of 3,000 passed (lossy base) | under 1.0e-3 of eras (one in 1,000: one per 490 years at 180-day eras) | under 1.5e-3 |
|
||||
| The same on the random stratum at B = 4 on every family | 10,000 of 10,000 | under 3.0e-4 | under 4.6e-4 |
|
||||
| An era of the band whose accepted draw sits under the (c''') floor | 3,000 of 3,000 (and 19,000 of 19,000 outside the lossy corner) | under 1.0e-3 | under 1.5e-3 |
|
||||
| An era of the lossy corner that exhausts | 61 of 5,000 FAILED | the rate is 1.2 percent, a measurement, not a bound: the corner is out of the band; class v5's last-resort scan then hands each such era a rule-checked program (the scan's own verdicts on the 61 are owed) | |
|
||||
| The (c''') floor's miss rate on the few-item hot-set class | 9 of 9 fired (unchanged: this census reads the acceptance's sample, not the live dataset) | under 0.33 | under 0.40 |
|
||||
| The bit-bias read's firing on the product class | 5,172 of the random stratum's eras read over 6 sigma; the three named cases (Devnet 3 site 0, era-drawn-28 site 15, the per-load candidate 0) are of that class | a record, not a refusal: no miss rate is claimed | |
|
||||
|
||||
The union bound stays loose where section 2.2 said it would: the two floors' miss rates on the live-dataset classes rest on nine and five cases, and this census adds cases to the exhaustion and bias rows only. The live-dataset census at 2^24 on 64 seeds of two eras of the band (one at width 4, one at R in 1..27 with a 1,000-sigma site) is the next ring-C row (section 7 item 6); it is what turns the bit read's z into a per-era hot-set price on the real item map.
|
||||
|
||||
## 7. Owed by 09:00 BST on 9 October (the full report)
|
||||
|
||||
1. The coverage table of section 6 filled from the logs, with the per-stratum refuse rates and the union-bound arithmetic on the measured numbers, and the TSV rows and logs under `docs/analysis/class-v6/logs/`.
|
||||
2. The clean spread of the bucket ratio and the bit bias per stratum, so the two new ring-B tests get a bound and a clean refuse rate (not a guess).
|
||||
3. The lossy-corner `r` against the 0.85 line, and the exhaustion count at 10^4 seeds on that corner.
|
||||
4. The width-4 reading of (c'') and (c''') with the divided expectation, and the spec line for 1.4.6.5.
|
||||
5. The mixer rows at `m = 4` and 16 (adv-mixer-3's index and sac at k = 2 on two days; the SAT at k = 2) as the per-family corner rows, if the boxes have the cores after the other lanes' jobs arrive; else the plan with its hours.
|
||||
6. The F8-form census at 2^24 on 64 seeds of one lossy-cap era and one width-4 era, the first ring-C rows on the family (about 2 box-hours each).
|
||||
7. The gate-record JSON written for every era of the census from the TSV (the format of 4.3), and the summary.
|
||||
1. The (c''') floor's clean spread per width (and per shape) from the accepted draws of section 6, with the per-width floor or the sigma form that keeps the clean refuse rate near the shipped 2.4 percent; the spec line for 1.4.6.5's expectation under a drawn width.
|
||||
2. The bucket bound stated in sigma from the bit-clean spread (p99 +6.8, max +28 over 4,828 eras): a band at +8 sigma refuses under 1 percent of bit-clean programs and every F8 tail seed (+17 to +37); its cost on the biased eras is the bit read's cost, so the two tests are one rule.
|
||||
3. The lossy-corner reading taken one step further: the exhaustion rate per era against the lossy share in points (18 to 30 of 75), so the band's edge is a measured curve, not one corner; 10^4 seeds at each of three shares.
|
||||
4. The mixer rows at `m = 4` and 16 (adv-mixer-3's index and sac at k = 2 on two days; the SAT at k = 2) as the per-family corner rows, on build-4 when its pool frees; else the plan with its hours.
|
||||
5. The class v5 last-resort scan on the 61 exhausted eras (the first passing k past the cap, or the fallback), and the lossy-share curve at +1, +2, +3 and +4 points (3,000 eras each, running on build-1 from 12:5x BST with the fg6 harness).
|
||||
6. The F8-form census at 2^24 on 64 seeds of two eras of the band (one at width 4, one with a 1,000-sigma site under R in 1..27), the first ring-C rows on the family (about 2 box-hours each): the live-dataset hot-set price of the bit-level bias.
|
||||
7. The gate-record JSON per era (the format of 4.3) generated from the TSV rows by `fg-records.py` (in `logs/`), and the family summary.
|
||||
8. The two build-4 strata (shape 64 plus lossy cap, shape 256) when its pool frees; nothing in the cut depends on them.
|
||||
|
||||
## 8. Sources
|
||||
|
||||
|
|
|
|||
281
docs/analysis/class-v6/floor/invention.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# Class v6 floor lane 5, invention: what none of the four floor lanes covers, checked against the identity
|
||||
|
||||
8 October 2026, 12:48 to 19:xx UK, branch `class-v6-floor-invention` from `counter-asic-4` (base 171618c5 after the fast-forward; first cut at 13:0x UK; corrected 13:1x UK for the read-width naming: `w16` is 16 bytes, `w64` is the 64-byte item; the rented-card rows of 13:2x to 13:4x UK carried in section 3.1, which reverse rank 1 at stock; the 5090 row at 13:4x UK). The open-ended lane of the floor research: four other lanes work the known levers (SM-sparse gating, the shadow's `k` from RTL synthesis, the SRAM full-store chip and the dataset floor, the honest denominator per tier); this file finds what they do not cover. Every candidate is checked against the identity first and killed or kept with a number. Every number carries a label: **measured** (a card on a named job, the file named), **modelled** (arithmetic on the chip model's cited inputs, `docs/analysis/chip-model-v3.md` section 5), **claimed** (a vendor's or an author's figure, URL given), **approximate** (from memory or a scaling). Nothing here changes a consensus object, a pack or a served number. Research and reasoning ran on this Mac; no build, no benchmark and no miner ran anywhere for this file.
|
||||
|
||||
## 0. One page
|
||||
|
||||
**The identity, as the record states it** (`docs/analysis/counter-asic-4-research.md` section 2): against the chip that stores the dataset, `edge = (E_card + F) / (E_mem + k F)`; at zero premium the edge is `E_card / E_mem` (3.6x on a 5090 at its 1,300 MHz knee against the GDDR7 board, measured card, modelled chip), and a shadow op with `k` under 1 never fully closes it. The one thing with `k = 1` by construction is the memory itself: the same devices on both sides. The record forced one DRAM operation per read (the activate and one 32-byte sector) and called every wider read "nothing to force: the chip already pays the sector". That is true up to 32 bytes; at 64 bytes the chip pays a second sector, and this lane's question was whether the honest card pays it at the DRAM's price or at its own.
|
||||
|
||||
**The candidate this lane found, measured today, and where it stands.** The dataset item is 64 bytes (16 words; spec 1.5). Reading the whole item (W = 16 words, the read-width branch's class `w64`, bit-exact on four runtimes, fingerprint `836e56e7d496e980`, the verifier +4 percent) forces a second 32-byte sector per read in the same open row: on the chip model's own inputs (909 pJ of activation plus 1,150 pJ of movement per sector, Micron's 4.5 pJ per bit, claimed) the GDDR7 chip's energy per hash rises from 0.466 to 0.62 microjoules (+33 percent, its rate unchanged), one HBM3 stack's from 0.321 to 0.40 (+24), and on floor lane 3's wire figure the N2 SRAM die's from 0.036 to 0.126 (66x to 19x). The design's "never 16 words" rested on one row, the 5090's 71.9 MH/s (-47 percent) in the exported kernel at full occupancy, while the same card's own 64-byte probe read -13 percent; so the lane rented cards and measured the card side at stock (section 3.1): **the rate question is settled per architecture, and the energy question kills the lever at stock.** A one-request form of the same kernel text (PTX `ld.global.L2::64B` on the first load of each item; bit-exact, the same fingerprint) holds the 4-byte rate on the RTX 4090 at full occupancy (63.65 against 64.73 MH/s, 98 percent, PASS) and on the RTX 5090 at a sparse shape (145.7 against 142.6 MH/s, sp170-w4, PASS); on the H100 the best form is -21 percent and the hint does nothing (FAIL); on the RTX 3090 the best form is -13 percent (FAIL). But the card pays the second sector at its own fabric's price, not the DRAM's 1.15 nJ: 8.6 nJ on the 4090 (+71 W at 8.3 G reads per second for no rate) and 4.7 nJ on the 5090 (+88 W at 18.6 G), so the energy per hash rises 34 percent on the 4090, 25 on the 5090, 33 on the H100's best form and 25 on the 3090, against the modelled chip's 33 (GDDR7) and 50 (HBM3). The sector's `k` is about 0.13 (4090) to 0.24 (5090) on GDDR7, under the ALU shadow's 0.3 to 0.8; the edge at zero shadow moves 0 on Ada, 7 percent on Blackwell (4.7x to 4.4x at stock) and 0.9x in the chip's favour on Hopper, and 0 with the shadow on (3.3x at `k` 0.5 either way). **KILL at stock on measured rows.** The one row that could revive it is the honest card's knee (a smaller fixed share and a lower fabric voltage; a rented pod refuses `-lgc`), owed as a single PC 1 row and not blocking anything: W = 16 stays out of class v6.
|
||||
|
||||
**Everything else on the brief is killed, with the number.** The tensor core: the record's `k` 0.03 to 0.3 is not defensible at its floor (the 0.04 pJ figure is INT4 at 0.46 V on a test chip with no memory system); shipping merchant silicon at nominal voltage reads 0.30 to 0.56 pJ per INT8 MAC (Meta MTIA v2 0.51, Qualcomm AI 100 Ultra 0.34, AMD MI355X 0.56, NVIDIA B200 0.44, Apple M4 ANE 0.30 measured; all claimed unless marked), and a Hopper tensor core under a pure MMA loop measured 0.34, so the honest band against the tile the class runs (1.5 pJ per MAC at the knee, measured) is `k` 0.2 to 0.4 and against the dense wide tile (0.83 at the knee) 0.4 to 0.7: equal to or under the ALU shadow's 0.3 to 0.8, never above it, and the Apple cost (-35 percent of rate at 1,024 tiles per hash, measured) kills it as content regardless. The RT core: traversal results are implementation-defined on every API (Vulkan "no ordering guarantee", DXR "no defined order", NVIDIA "could change depending on which driver, which GPU"), the acceleration structure is opaque, and a dedicated unit reads 4 to 33 nJ per ray against 290 to 750 nJ measured board-level on an RTX 2080 (`k` 0.01 to 0.2): dead on bit-exactness and on `k`. Memory-level parallelism per dollar: no asymmetry per channel, but a correction to the chip model: no 28 nm GDDR7 PHY exists (the shipped ones are N3 and FinFET; the oldest GDDR6 PHYs are 12 nm), so the record's cheapest chip (a USD 5 M project, a USD 17 M break-even cap) is not buildable; the floor is a 12 nm GDDR6 part at about USD 20 M to 30 M (cap USD 70 M to 100 M) or an N5-class GDDR7 part at USD 50 M to 75 M (cap 170 M to 250 M), modelled. Proof of latency: the chain checks values, never time; the block is 1 s and a DRAM round trip 0.1 to 0.4 microseconds; the farm's node is on its own LAN; a sequential per-block prefix favours the lower-latency side, which is the chip (about 4x). The time dimension on the address map: a bit permutation is firmware (a few hundred gates), no prefetch exists for a dependent chain, and the literature has no such scheme. The literature since 2023 holds nothing outside the identity; what it adds is a higher Ethash precedent (iPollo V2H, 0.14 J per MH, about 13x a 5090, vendor claim), the measured PAM3 I/O energies (SK hynix, ISSCC 2024) and the PHY-node fact above. Of this lane's own ideas, four more die on the identity (video decode, row-straddling, scratch in DRAM, independent chains per hash), and two items per read (W = 32) dies with W = 16.
|
||||
|
||||
### The KEEP list, ranked
|
||||
|
||||
| Rank | Item | What it moves | Cost to the honest tiers | Where | Label |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **The chip model's project floor corrected: no 28 nm GDDR7 PHY** | the cheapest buildable chip's project USD 20 M to 75 M (cap USD 70 M to 250 M), not USD 5 M (cap 17 M); the N5-core row's cap 170 M to 250 M, not 100 M | none | chip-model-v3 5.4 and 5.6 and the record's 16.2 | claimed (vendor IP pages), modelled (the mission lane's cap) |
|
||||
| 2 | **The tensor `k` band corrected** (0.2 to 0.4 on the class's tile, 0.4 to 0.7 on the dense tile; not 0.03 to 0.3) | no served row (the tile stays a worse-or-equal lever) | none | the record's 15.1a and 20.4 and chip-model 5.11 | claimed and measured |
|
||||
| 3 | **The card's cost of a second sector, measured: about 8.6 nJ on a 4090 at stock** | closes the read-width question on the identity's own terms (`k` about 0.13 on the sector); the hinted one-request load form is kept as a miner-side kernel fact for Ada (98 percent of the 4-byte rate at 64 bytes) | none (nothing ships) | the read-width plan's 4.1 and lane 3's width table; the knee row owed on PC 1 | measured |
|
||||
| 4 | **The Ethash precedent's top row** (iPollo V2H, 3.4 GH/s at 475 W, about 13x a 5090 per joule) | the precedent band 2.1x to 13x, not 2.1x to 6.8x | none | `asic-resistance-history.md` and chip-model 5.1 | claimed |
|
||||
|
||||
No item on this list is a class v6 change; nothing here reads `k` at or above 1 on a measured GPU figure.
|
||||
|
||||
### The five-sentence reading for the 15:45 close
|
||||
|
||||
The identity leaves one piece of work a chip pays at the card's own price, the memory's data movement, and this lane's candidate was the second sector of the 64-byte item (W = 16 words); measured today on rented cards at stock, a one-request form holds the 4-byte rate on Ada (98 percent on a 4090) and fails on Hopper and Ampere (-21 and -13 percent at best), but the card pays that sector at about 8.6 nJ through its own fabric against the DRAM's 1.15, so its energy per hash rises 25 to 34 percent, the same as the modelled chip's 33, and the lever reads `k` about 0.13: dead at stock, W = 16 stays out of class v6, with the knee row owed on PC 1 as a formality. The tensor core, the RT core, proof of latency and the drawn address map are dead on the identity or on bit-exactness, with the tensor `k` band corrected upward to 0.2 to 0.7 (still never above the ALU shadow's) and the RT core's traversal implementation-defined on every vendor. The chip model's cheapest chip does not exist as priced: a GDDR7 PHY needs a FinFET node (N3 shipped; 12 nm is the oldest GDDR6 PHY), so the floor project is USD 20 M to 75 M and the floor break-even cap USD 70 M to 250 M instead of 17 M, which goes into the model now. Nothing in the literature since 2023 offers a lever outside the identity, and this lane's own four other ideas die on it with numbers. The floor section's default for this lane therefore stands as written: no new lever; what the lane adds is two corrections to the model, one measured card-side figure, and the measured closing of the read-width question.
|
||||
|
||||
## 1. The frame
|
||||
|
||||
| Term | Value at the 5090's 1,300 MHz knee | Label | Source |
|
||||
|---|---|---|---|
|
||||
| `E_card`, class v3 | 1.67 microjoules (127.3 MH/s at 213.0 W; 134.6 at 223.3 on the efficiency pass) | measured | the record 20.3; `docs/plans/counter-asic-3-status.md` |
|
||||
| `E_mem`, the `f = 1` GDDR7 chip | 0.466 microjoules: 166 MH/s at 77.6 W (42.6 W of reads at 2.0 nJ, 20 W static, 15 W controller) | modelled | chip-model-v3 5.3 and 5.4 |
|
||||
| `E_mem`, one HBM3 stack | 0.321 microjoules: 83.6 MH/s at 26.8 W (12.8 W of reads at 1.2 nJ, 14 W static and controller) | modelled | the same |
|
||||
| `E_mem`, the N2 SRAM full store at the hash's width | 0.036 microjoules at W = 1 (0.25 nJ per read: 1.3 pJ per bit of wire over 80 bits plus 0.1 nJ of macro and 0.05 of controller), 66x the 5090's stock point; 0.126 at W = 16 (0.88 nJ, 560 bits), 19x | modelled (floor lane 3, section 10.3 of the class v6 design) | `docs/analysis/class-v6/floor/sram-and-floor.md` |
|
||||
| The read's energy, split | GDDR7: 909 pJ activation plus 4.5 pJ per bit x 256 bits = 1,150 pJ of movement and I/O (44 / 56 percent); HBM3: 909 plus 3.48 x 256 = 890 scaled by 4.12 / 6.25 | modelled on O'Connor et al. 2017 Tables 2 and 3 and Micron's and Samsung's pJ per bit (claimed) | chip-model-v3 5.3 |
|
||||
| `F`, the class v4 premium | 0.652 microjoules (82.8 W over 126.9 M x 102,100 ops: 6.4 pJ per counted op) | measured | the record 20.3 |
|
||||
| `k`, the ALU shadow | 0.3 to 0.8 (the 5090's 6.2 to 11.3 pJ per op measured against a 5 nm SIMD array's 2 to 5, approximate) | GPU measured, chip approximate | the record 15.1a |
|
||||
| The dataset item | 16 words, 64 bytes; `dataset[w] = item(w >> 4)[w AND 15]`; the lottery hash reads one word per load | design | spec 1.5 and the item table; `docs/plans/read-width.md` |
|
||||
| The read-width classes, by name | `w4` = 4 bytes (W = 1 word, the lottery hash); `w16` = 16 bytes (W = 4, one sector); `w64` = 64 bytes (W = 16, the whole item, two sectors on the 5090); `w64x4` = 64 bytes at 32 loads | design | `docs/plans/read-width.md` 1.5; the bench-log entry of 5 October |
|
||||
| The 5090's dependent-read probe, best over lanes in flight | 17.5 to 18.2 G reads per second at 4 B; 18.0 to 19.9 at 16 B; 9.1 to 15.7 at 64 B (9.1 at 4 M lanes) | measured, 5 October | `docs/bench-log.md`, the read-width entry |
|
||||
| The hash rates at the widths, unlocked, no power sampling | 5090: w4 136.1, w16 139.8, w64 71.9 MH/s; 9070 XT: 18.15, 17.90, 17.59; M5 Max: 27.74, 28.26, 28.27 | measured, 5 October | the same |
|
||||
| The card's whole-card marginal per dependent DRAM read | 10.9 nJ unlocked, 8.7 nJ at the lock | measured, 8 October | the record 15.1a |
|
||||
|
||||
The four doors a candidate can open (the invention lane's section 1, kept): raise `k`; raise the chip's capex or project; shorten the chip's useful life; lower the honest card's cost at the same `F`. This file adds the fifth door the identity itself names: **force work whose `k` is 1 by construction, which is only the memory's own.**
|
||||
|
||||
## 2. The candidates on the brief
|
||||
|
||||
### 2.1 Work the GPU already does at near-ASIC efficiency
|
||||
|
||||
**The tensor core, re-read.** The question: is "a merchant N2 project beats Blackwell's tensor core per joule by 3x to 30x" defensible, or is the tensor shadow at full rate closer to `k` 0.7 to 1?
|
||||
|
||||
| Chip | Node | Year | Dense INT8 TOPS (FP8 where marked) | W | pJ per MAC (2 / (TOPS per W)) | Label | URL (read 8 October 2026) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| RTX 5090 | 4N | 2025 | 838 | 575 | 1.37 at spec; **1.36 measured** on the dense s8 m16n8k32 tile at 80 percent of peak unlocked, 0.83 at the 1,300 lock; the dependent u8 m8n8k16 tile the class runs 2.9 unlocked, 1.5 at the lock (the packs job), 4.1 / 2.2 (the microbench) | claimed spec; measured rows the record's 15.1a and 20.3 | https://www.nvidia.com/en-us/geforce/graphics-cards/50-series/rtx-5090/ |
|
||||
| RTX 4090 | 4N | 2022 | 661 | 450 | 1.36 | claimed | NVIDIA Ada whitepaper |
|
||||
| H100 SXM | 4N | 2022 | 1,979 | 700 | 0.71 | claimed | https://www.nvidia.com/en-us/data-center/h100/ |
|
||||
| H800, mma and wgmma INT8 dense under a pure MMA loop | 4N | 2025 | | sub-TDP loop | **0.34 / 0.54** | measured | https://arxiv.org/html/2501.12084v2 Tables 10 and 11 |
|
||||
| B200 (FP8 / INT8) | N4P | 2024 | 4,500 | 1,000 | 0.44 | claimed | https://lenovopress.lenovo.com/LP2226 |
|
||||
| AMD MI300X | N5 | 2023 | 2,615 | 750 | 0.57 | claimed | AMD data sheet |
|
||||
| AMD MI355X | N3P | 2025 | 5,033 | 1,400 | 0.56 | claimed | https://glennklockwood.com/garden/processors/mi355x |
|
||||
| RX 9070 XT (RDNA 4 WMMA) | N4P | 2025 | 389 | 304 | 1.56 | claimed | https://www.amd.com/en/products/graphics/desktops/radeon/9000-series/amd-radeon-rx-9070xt.html |
|
||||
| Qualcomm Cloud AI 100 Ultra | 7 nm | 2023 | 870 | 150 | 0.34 | claimed | Qualcomm product brief |
|
||||
| Meta MTIA v2 | 5 nm | 2024 | 354 | 90 | 0.51 | claimed | https://www.eenewseurope.com/en/meta-launches-second-generation-custom-ai-chip |
|
||||
| Intel Gaudi 3 (FP8) | 5 nm | 2024 | 1,835 | 900 | 0.98 | claimed | https://www.anandtech.com/show/21342 |
|
||||
| Google TPU Ironwood (FP8) | n/d | 2025 | 4,614 | about 1,100 inferred | about 0.48 | inferred from the Hot Chips 2025 slides | https://hc2025.hotchips.org/ |
|
||||
| AWS Trainium2 (FP8) | 5 nm | 2024 | 1,299 | about 500 (third party) | 0.77 | inferred | https://arxiv.org/pdf/2608.28048 |
|
||||
| Tenstorrent Blackhole p150 (FP8) | N6 | 2025 | 774 | 300 | 0.78 | claimed | https://docs.tenstorrent.com/aibs/blackhole/specifications.html |
|
||||
| Apple M4 ANE (FP16 rate, INT8 runs at it) | N3E | 2024 | 19 | 2.8 | **0.30** | measured | https://maderix.substack.com/p/inside-the-m4-apple-neural-engine-615 |
|
||||
| Hailo-8 | 16 nm | 2019 | 26 | 2.5 | 0.19 at spec, 0.71 measured on ResNet-50 | claimed / measured | https://www.cnx-software.com/2020/10/07/ |
|
||||
| Untether speedAI 240 (FP8, at-memory) | 7 nm | 2022 | 2,000 | 66 | 0.067 | claimed, never shipped at volume | https://www.design-reuse.com/news/52539/ |
|
||||
| Axelera Metis (digital in-memory) | 12 nm | 2023 | 214 | about 14 | 0.13 | claimed | https://www.hackster.io/news/ |
|
||||
| NVIDIA VSQ test chip (INT4) | 5 nm | 2023 | | | 0.021 at 0.46 V on a benchmarking layer; **0.052 at the nominal 0.67 V on full networks**; no INT8 figure | measured, a test chip without HBM or a NoC | https://research.nvidia.com/publication/2023-01_956-topsw-deep-learning-inference-accelerator-vector-scaled-4-bit-quantization |
|
||||
|
||||
The reading. Every shipping systolic chip with a real memory system and fabric lands at 0.3 to 0.6 pJ per INT8 MAC at nominal voltage; the figures under 0.15 are in-memory-compute vendor claims and an INT4 test chip at low voltage. Dally's own energy pie for that test chip (Hot Chips 2023, slide 24) has the datapath at 47 percent and the buffers, collector and movement at 53 percent, so a whole chip is about 2x its datapath before any NoC or HBM. The record's `k` floor of 0.03 took the 0.46 V INT4 figure against the 5090's 1.5 to 4 pJ; at nominal and INT8 the chip side is 0.3 to 0.6. The honest band, labelled:
|
||||
|
||||
| Against | GPU pJ per MAC (measured) | Chip pJ per MAC at nominal (claimed, the cluster above) | `k` | Reading |
|
||||
|---|---|---|---|---|
|
||||
| The tile the class runs (dependent u8 m8n8k16, the `mm1430` pack) at the knee | 1.5 | 0.3 to 0.6 | **0.2 to 0.4** | the forcing lever's `k`; equal to or under the ALU shadow's 0.3 to 0.8 |
|
||||
| The same unlocked | 2.9 | 0.3 to 0.6 | 0.1 to 0.2 | |
|
||||
| The dense wide tile (s8 m16n8k32) at the knee | 0.83 | 0.3 to 0.6 | **0.4 to 0.7** | a tensor shadow built of wide tiles would sit at the ALU band's centre; the H800's 0.34 under a pure loop says a GPU tensor core at its best is inside the merchant cluster |
|
||||
| The dense wide tile unlocked | 1.36 | 0.3 to 0.6 | 0.25 to 0.45 | |
|
||||
|
||||
So "3x to 30x" is not defensible: at nominal voltage merchant silicon beats the 5090's dense tile by 2.3x to 4.5x and its dependent tile by 2.5x to 10x, and the 30x needs an INT4 layer at 0.46 V. And "`k` 0.7 to 1 at full rate" is not reached either: the top of the dense band is 0.7. The identity check: the tile's `k` is at best the ALU shadow's and the joules it forces are the same by construction (the record 20.3: 71.5 W against 82.8 at the knee for the same tile count); the chip's downside bet (its `k` floor) is 0.2 against the ALU's 0.3, so the tile remains the worse-or-equal lever, not the 4x to 30x worse lever the record wrote. The cost to the honest tiers is what kills it as content: the M5 Max at -35 percent of rate at 1,024 tiles per hash and -78 percent at 4,096 (measured, the record 20.2a), with no integer matrix path in Metal. Bit-exact on NVIDIA and the CPU (measured); the AMD WMMA layout unverified. **KILL as v6 content; KEEP the band correction (rank 3).** FP8, FP16 and BF16 tiles: the accumulation order inside a tile is unspecified by PTX, so not bit-exact across vendors or generations: KILL.
|
||||
|
||||
**The texture unit's filtered gather.** Measured 0.19 nJ per filtered fetch unlocked and 0.10 at the lock (the microbench `tex_linear_f32_256k`); a 9-bit fixed-point interpolator on NVIDIA (CUDA Programming Guide, claimed), vendor-specific fraction widths elsewhere: not bit-exact across the three vendors; a chip's interpolator is one 9-bit multiply-add, `k` far under 1. KILL. The point-sampled fetch is the L2 chase under another name (the same checksum, measured): the L2 lever, dead at `k` 0.1 to 0.3.
|
||||
|
||||
**The rasteriser and ROPs.** Unreachable from CUDA, OpenCL and Metal compute; fill conventions, sample positions and depth precision differ by vendor by design. KILL.
|
||||
|
||||
**The hardware video codec.** A conformant decoder of a standard (H.264, HEVC, AV1) is normatively bit-exact, which is the one fixed-function block on a card whose output a CPU verifier could reproduce, and a chip would license the same decoder block at the same node (`k` about 0.5 to 1, the same circuit, approximate). It dies on throughput and on the verifier: a card carries a fixed count of decode engines (two to four on current cards, approximate) against any count on a chip, so the honest card's hash rate would be bound by its decoder, not its memory, at a ratio that differs per vendor (the 5090 against the M5 Max's media engine); and a software decode on the verifier runs at milliseconds per frame on one core (approximate), against a 10 ms gate that holds the whole warp. KILL. (This lane's own candidate, recorded here because it is the only fixed-function block that passes the bit-exactness test.)
|
||||
|
||||
### 2.2 The RT core
|
||||
|
||||
| Unit (node) | Throughput | Power or area | Energy per ray | Label | URL (read 8 October 2026) |
|
||||
|---|---|---|---|---|---|
|
||||
| RTX 5090, 170 fourth-generation RT cores | 317.5 RT TFLOPS; "double the throughput for ray-triangle intersection" over Ada; no per-ray or per-clock rate published, no RT-core area or power share | 575 W | not published | claimed | https://images.nvidia.com/aem-dam/Solutions/geforce/blackwell/nvidia-rtx-blackwell-gpu-architecture.pdf |
|
||||
| RTX 2080 running DXR (Mach-RT, Table 3) | 288 to 745 Mrays per second over six scenes | 215 W TDP | **290 to 750 nJ per ray, board-level** | measured rays, TDP assumed | https://hwrt.cs.utah.edu/papers/mach-rt_TVCG.pdf |
|
||||
| Turing TU102 | 10 Giga rays per second claimed | about 260 W | about 26 nJ per ray at the claimed peak | claimed | Hot Chips 31 slides |
|
||||
| AMD RDNA 4 | 8 ray-box and 2 ray-triangle tests per clock per CU, BVH8, OBBs | no RT power figure | none | claimed | https://hc2025.hotchips.org/assets/program/conference/day1/8_amd_pomianowski_final.pdf |
|
||||
| SiliconArts RayCore, 6 RTUs, 28 nm | 239 Mrays per second | 1 W, 18 mm^2 | about 4.2 nJ per ray | claimed, FPGA-derived | https://www.embedded.com/?p=4433770 |
|
||||
| Imagination PowerVR GR6500, 28 nm | 300 Mrays per second | 5 to 10 W for the whole GPU | 17 to 33 nJ per ray | claimed | https://www.anandtech.com/show/7870 |
|
||||
| RayFlex RTL, 15 nm PDK, 1 GHz | one ray-box or ray-triangle test per cycle | 60 to 85 mW per datapath | 60 to 85 pJ per intersection test | synthesised | https://arxiv.org/pdf/2409.06000 |
|
||||
| TRaX study, 65 nm | | memory 60 to 95 percent of ray energy, DRAM alone up to 80, compute 1 to 2 | 0.5 to 5 microjoules per ray | simulated | https://hwrt.cs.utah.edu/papers/rt_performance_CGI18.pdf |
|
||||
|
||||
Per node visit (Ylitie et al., NVIDIA, HPG 2017, measured): about 15 ray-node and 7 to 9 ray-triangle tests per ray on a compressed 8-wide BVH, 1.5 to 3.2 KB fetched per ray, "memory traffic is the main limiting factor with incoherent rays"; Chou et al. (MICRO 2023) call it "a latency-bound" pointer chase. The identity check: a traversal step over a dataset-sized tree is one dependent DRAM read (the identity's `E_mem`, which both sides pay) plus a box test the RT core does in hardware; the box test is the only forcing, and the chip's cost to match it is 60 to 85 pJ at 15 nm (synthesised) against a GPU share nobody has measured below 290 nJ per ray board-level, so `k` reads 0.01 to 0.2 on the public figures: the chip builds a cheaper RT unit, not a dearer one.
|
||||
|
||||
Bit-exactness, which is the first question and the one that decides it: the Vulkan specification gives "no ordering guarantee between operations performed on different intersection candidates", makes watertightness a "should", and performs the hit test "in an implementation specific manner" (https://docs.vulkan.org/spec/latest/chapters/raytraversal.html); DXR: "There is no defined order of execution of any hit shaders for the intersections along a ray path" (https://microsoft.github.io/DirectX-Specs/d3d/Raytracing.html); NVIDIA's own answer (forum, 14 October 2024): which edge or vertex wins is "implementation defined and not guaranteed" and "could theoretically change depending on which API you use, which driver, which GPU"; Blender's Cycles records that matching OptiX or MetalRT bit for bit was impossible. The acceleration structure is opaque in DXR, Vulkan and Metal, and Vulkan's serialised form carries a driver UUID that the next driver may refuse. A CPU verifier therefore cannot reproduce a hardware traversal, and a design that defines its own traversal in software and uses the RT core as an untrusted accelerator forces nothing (the honest card then does the box tests in software or re-checks them). **KILL**, on bit-exactness first and on `k` second. Price of the RT unit a chip would build: 18 mm^2 at 28 nm for six units (claimed), about USD 5 of N5 (approximate).
|
||||
|
||||
### 2.3 Memory-level parallelism as the resource: channels per dollar, the controller and PHY
|
||||
|
||||
The claim to check: a card has more channels per dollar than a chip built from the same devices plus a controller, because the chip's controller and PHY are a cost the card amortises over gaming.
|
||||
|
||||
| Item | Finding | Number | Label | URL (read 8 October 2026) |
|
||||
|---|---|---|---|---|
|
||||
| Channels per device | GDDR7: four 8-bit channels and 64 banks per 2 GB device; the 5090's 16 devices are 64 channels and 1,024 banks; a chip built from the same devices has the same count; the next parts (3 GB, 4 GB, 6 GB) carry the same four channels, so channels per GB FALL for the chip and the card alike (Micron ends the 2 GB part) | 64 channels per 32 GB today; 64 per 48 GB on 3 GB parts | claimed (TrendForce, Rambus) | chip-model-v3 5.1; https://www.trendforce.com/news/2026/09/24/ |
|
||||
| Does a chip buy more channels per dollar | No: the channel count is bought with capacity on both sides (16 devices whatever the dataset), and the chip's only path to more activates per second is more devices, which the card cannot add and the chip pays for at the same price per device | 0 asymmetry per channel | modelled | |
|
||||
| The PHY's node | No 28 nm GDDR7 PHY exists: Cadence's GDDR7 PHY is silicon-proven on TSMC N3 at 32 Gbps (September 2024); Innosilicon's is "advanced FinFET"; the oldest GDDR6 PHYs are 12 nm (Cadence 12FFC and Six Semiconductor, 16 Gbps); nothing at 28 nm at any GDDR6 or GDDR7 rate | the chip's cheapest controller is a 12 nm GDDR6 part or an N5-class GDDR7 part | claimed (vendor pages) | https://www.businesswire.com/news/home/20240925780123/en ; https://innosilicon.com/html/ip-solution/gddr7.html ; https://www.businesswire.com/news/home/20221115005674/en |
|
||||
| The PHY's energy | GDDR7 PAM3 I/O measured on silicon: TX 1.103 pJ per bit, RX 0.764 pJ per bit (SK hynix, ISSCC 2024 paper 13.1); at the hash's 17.9 G reads x 256 bits = 4.6 Tb/s the controller-side RX is 3.5 W, inside the model's 15 W controller allowance with its clocking and termination (PAM3 termination is a large share: arXiv 2410.12990, simulated) | `E_mem` moves by under 0.06 microjoules | measured I/O; the allowance approximate | https://www.researchgate.net/publication/378947349 ; https://arxiv.org/abs/2410.12990 |
|
||||
| The GB202 die | 761.56 mm^2 with 16 x 32-bit controllers and PHYs along the edges; no public mm^2 for the PHY blocks | the card's PHY is a real area it amortises; the chip's is the same area at the same node | measured die, no annotation | https://kurnal-insights.com/en/dieshot/nvidia-gb2025090/ |
|
||||
|
||||
The identity check: the chip and the card pay the same per-bit I/O and the same controller physics, so nothing moves `E_mem` beyond the allowance already in the model. What moves is the chip's PROJECT: the record's cheapest row (chip-model-v3 5.6 "a 28 nm-class project at USD 5 M to 30 M", the record's 16.2 "28 nm controller, USD 5 M, break-even cap USD 17 M") prices a PHY that is not made. The corrected floor, on the mission lane's model (cap = C_proj / 0.30 in years 1 to 2):
|
||||
|
||||
| Cheapest buildable chip | Memory | Project | Break-even cap, years 1 to 2 | Label |
|
||||
|---|---|---|---|---|
|
||||
| A 12 nm GDDR6 controller (16 Gbps PHY IP exists): 32 banks per device, so 32 devices of GDDR6 for the same 1,024 banks and 21.3 G activates per second; about USD 10 per 2 GB device (approximate); a 1,024-bit board | USD 320 | USD 20 M to 30 M (a 12 nm project with licensed PHY IP; the history's 2.5 figures scaled, approximate) | **USD 70 M to 100 M** | modelled, approximate |
|
||||
| An N5-class GDDR7 controller (the 5090's own 16 devices) | USD 320 | USD 50 M to 75 M (the history's 7 nm-class startup figure, claimed) | **USD 170 M to 250 M** | modelled |
|
||||
| The same plus the N5 shadow core (class v4 as shipped; the record's 16.2) | | USD 50 M to 75 M (the core joins the controller's die, which is N5 anyway) | USD 170 M to 250 M, not 100 M | modelled |
|
||||
|
||||
**KEEP as a correction to the chip model (rank 2), not a lever.** Consequence: the record's break-even row for the chip that matters (the N5 core on a 28 nm controller, USD 30 M, cap 100 M) understates the floor by about 2x, because the controller cannot sit on 28 nm; the shadow per load's "doubling" (USD 60 M, cap 200 M) therefore adds less than it seemed, since the single-N5-die form is already the only buildable form. The 85 W node per farm and the leaves' bandwidth are unchanged.
|
||||
|
||||
### 2.4 Proof of latency
|
||||
|
||||
A per-block random challenge whose answer window is shorter than one DRAM round trip plus network latency, so a stored copy in a remote farm cannot answer in time but a local card can.
|
||||
|
||||
| Check | Finding | Number | Label |
|
||||
|---|---|---|---|
|
||||
| The identity | Not an energy lever: it changes who may answer, not what a hash costs. If it bound anything it would bind the chip's node placement | 0 on `E_mem`, `F` and `k` | arithmetic |
|
||||
| What the chain can enforce | Values, never time (the record's section 6, new-pow 3.2): a block header is the challenge, blocks arrive over the WAN at tens to hundreds of milliseconds, the interval is 1 s and a timestamp is the miner's own; no node can check that an answer arrived within microseconds of a challenge it saw itself at a different time | the shortest enforceable window is a block interval, 1 s, against a DRAM round trip of 0.4 microseconds on the 5090 (415 ns queued, measured) and about 0.1 on a chip (55 ns of controller plus tRCD and tCL, modelled): 2.5 million round trips per block | measured card latency; the chip's modelled |
|
||||
| The threat model | The stored-dataset chip's node is on its own LAN (class-v5 2a.2: one node serves a farm, the leaves at 16.5 KB/s); there is no "remote chip farm" to lock out; a farm is as local as a card | 0 | design |
|
||||
| The one form with teeth: a sequential per-block prefix (every miner must run a chain of dependent reads from the block hash before any nonce counts) | Favours the side with the lower unloaded latency, which is the chip: a 100 ms prefix is 250,000 reads at 400 ns on the 5090 and 100 ms of its time; the chip at about 100 ns per read finishes in 25 ms and hashes for the rest | the chip's share of the block rises about 8 percent at a 100 ms prefix; the card loses 10 percent of its time | modelled |
|
||||
| Fresh join | Unchanged: a joiner needs the previous block, which it needs anyway | 0 | design |
|
||||
| The verifier | Nothing to verify: time is not in the proof | 0 | design |
|
||||
| The literature | No proof of work with a sub-round-trip window was found 2023 to 2026; the nearest are watchtower-ping location proofs (BFT-PoLoc, arXiv 2403.13230: about 2 percent Byzantine challengers tolerated, 12 percent misclassification, measured on the Internet; Witness Chain) and PoSME (already in the record); Proximity Signatures (eprint 2026/694) refused the fetch and is unread | null | claimed |
|
||||
|
||||
**KILL.** The chain cannot measure time at the scale a DRAM round trip lives on, the farm is local, and the sequential form helps the chip.
|
||||
|
||||
### 2.5 The time dimension: the address map drawn often enough to break a chip's prefetch and banking plan
|
||||
|
||||
| Check | Finding | Number | Label |
|
||||
|---|---|---|---|
|
||||
| The identity | A mapping change is firmware for the stored-dataset chip; no `F`, no `k` | 0 | arithmetic |
|
||||
| Prefetch | A dependent chain has nothing to prefetch on either side: the next address is the previous read's data | 0 | design |
|
||||
| Banking plan | The chip's controller maps address bits to channel, bank and row through a table; a drawn permutation of the index bits costs a few hundred gates (the invention lane's 2.7, the spec's 1.13.1 draws of `M`, `R` and `pos` already); the card pays whatever the map does to its own bank parallelism, measured as a 0.8 to 3.2 percent spread across six eras | 0 to the chip; 0.8 to 3.2 percent to the cards | measured (Counter ASIC 2.0 layers 4 and 8) |
|
||||
| A fixed-function chip | Dead at the first draw, which is layer 1 already; a programmable chip pays USD 25 to 40 of N5 core, already in the record's 16.1 | nothing new | modelled |
|
||||
| The cadence | Drawing per epoch instead of per era changes nothing above: a table reload is microseconds | 0 | design |
|
||||
| The literature | No scheme since 2023 rotates an address map to defeat banking or prefetch; Ethash's 30,000-block reseed and RandomX's dataset rebuild are the only moving layouts, both firmware to every chip that shipped | null | claimed |
|
||||
|
||||
**KILL.** Covered by layer 1 and the invention lane's row; nothing a programmable chip pays beyond the core it already carries.
|
||||
|
||||
### 2.6 The literature since 2023, what the record's section 8 missed
|
||||
|
||||
| Item | What it found | Number | Label | URL (read 8 October 2026) |
|
||||
|---|---|---|---|---|
|
||||
| Choe, Moreshet, Bahar, Herlihy, "Attacking memory-hard scrypt with near-data processing", MEMSYS 2019 | the one paper that runs a memory-hard function on a PIM model: one in-order core per 128 MB vault | 1.5x over the host, no energy figure | simulated | https://par.nsf.gov/servlets/purl/10156963 |
|
||||
| Blocki and Smearsoll, TCC 2025; Blocki et al., CRYPTO 2026 (arXiv 2508.06795) | pebbling bounds on MTP-style and data-dependent memory-hard functions | complexity only, no joules | theory | https://arxiv.org/abs/2508.06795 |
|
||||
| SemiAnalysis, "The Memory Wall", 3 September 2024 | off-chip movement about 20x the cell read; an HBM access about 95 percent interface, 5 percent cell, for streaming | 2 pJ per bit interface against 0.18 pJ per bit activation amortised over a streamed row; for THIS hash's random 32-byte read the activation is 909 pJ against 1,150 of movement (44 / 56 percent), so a shorter link (lane B's base-die and bonded rows) cuts the larger half, which the model already carries | claimed; the split modelled | https://newsletter.semianalysis.com/p/the-memory-wall |
|
||||
| SK hynix GDDR7 at 35.4 Gb/s per pin, ISSCC 2024 paper 13.1 | PAM3 TX 1.103 pJ per bit, RX 0.764 pJ per bit | the I/O share of the 4.5 pJ per bit device figure | measured silicon | https://www.researchgate.net/publication/378947349 |
|
||||
| ETH SAFARI real-chip DRAM studies 2024 to 2026 | all on DDR4; no GDDR6, GDDR7 or HBM3 activate-energy measurement exists in public | the record's 909 pJ stands as the only figure | measured, DDR4 | https://arxiv.org/pdf/2405.06081 |
|
||||
| iPollo V2H, V2, V2X (Ethash, November 2024) | 3.4 GH/s at 475 W; 10 GH/s at 1,500 W; 1.2 GH/s at 165 W | 0.14 J per MH: about 13x an RTX 5090 at 1.8 J per MH on Ethash; the precedent's top row, above the record's X16-P at 6.8x; all of it the memory system | vendor spec, claimed | https://www.asicminervalue.com/en/miners/ipollo/v2h ; https://www.whattomine.com/coins/382-egaz-etchash/gpus/92-nvidia-geforce-rtx-5090 |
|
||||
| Jasminer X16-Q (Ethash, ETC) | 1,950 MH/s at 620 to 630 W | 0.32 J per MH, about 5.7x a 5090 | vendor spec | https://pool.kryptex.com/device/asic/Jasminer/x16-q |
|
||||
| Innosilicon A11 Pro (2021); iPollo V1 (June 2022) | 1.5 GH/s at 2,350 W; 3.6 GH/s at 3,100 W | 1.3x and 2.3x a 4090 | vendor spec | https://www.asicminervalue.com/miners/innosilicon/a11-pro-eth-1500mh |
|
||||
| Pinecone R1X (RandomX, January 2026) | 1.2 MH/s at 2,055 W, listed against the X9's 1 MH/s at 2,472 W | 1.71 J per kH; unverified, no unit seen | vendor spec, unverified | https://github.com/monero-project/monero/issues/10270 |
|
||||
| Qubic, 2026 | 23 to 34 percent of Monero's hash in withholding windows, never a sustained majority; pivot to Dogecoin ASICs 1 April 2026, Monero mining phased out | 27,000 XMR blocks, about USD 3.5 M | measured events | https://arxiv.org/abs/2512.01437v2 ; https://decrypt.co/363018 |
|
||||
| Karlsen, Iron Fish, Pyrin after the FishHash forks | no ASIC since April 2024; a ColEngine P2 FPGA did 32 GH/s Karlsen at 1,484 W before the fork | FPGA only | vendor | https://pool.kryptex.com/articles/ironfish-hardfork-en |
|
||||
| Conflux, Flux, Nexa, Ergo, Ravencoin, Beam, Tari, Zephyr | no dedicated chip 2024 to 2026 (Tari and Zephyr mined by the RandomX X5 class) | null | vendor | |
|
||||
| GDDR7 PHY nodes and GDDR6's oldest | Cadence N3 (2024); Innosilicon FinFET; GDDR6 at 12 nm (Cadence 12FFC, Six Semiconductor); nothing at 28 nm | section 2.3's correction | claimed | as 2.3 |
|
||||
| Proof of latency; era-rotating address maps | no scheme found, 2023 to 2026 | null | | |
|
||||
|
||||
Reading: nothing published since 2023 offers a lever outside the identity; every energy figure is a streaming pJ per bit, and no one has measured a per-random-read joule on GDDR6, GDDR7 or HBM3 at the controller, so the record's 2.0 and 1.2 nJ stand as the only model and are owed a measurement nobody can rent (the AWS F2 hour of chip-model 5.3 is still the one HBM2 instrument). The new rows change two precedents: the Ethash band's top is about 13x (claimed) not 6.8x, and the Monero chip story has a shipped-but-unverified successor to the withdrawn X9.
|
||||
|
||||
## 3. This lane's own ideas
|
||||
|
||||
### 3.1 The second sector: W = 16 words, the whole 64-byte item (KILL at stock on measured rows; the knee row owed)
|
||||
|
||||
**The construction** is the read-width branch's `w64` class (`docs/plans/read-width.md` section 1 and 1.5, the fold of its section 1): a load reads the 64-byte-aligned item and folds its 16 words into the destination register with a rotate-multiply between words (`verify::fold_words`, mirrored in Metal, CUDA and OpenCL: four `uint4` loads from the aligned line, then fifteen rotl-mul-xor steps), so no function of the line alone replaces the dataset and the dependent chain is unchanged. Built 5 October; the pack in `proto-cuda/packs-readwidth/w64/`; three vector units and the cache and dataset checks passed on Metal, Apple OpenCL, the RTX 5090 (NVRTC) and the RX 9070 XT; the 2^24 fingerprint `836e56e7d496e980` equal on all four; the acceptance rule's rejection 0 to 14 of 60 as version 2; the CPU verifier 0.630 ms per 32 hashes against 0.604 (+4 percent). Two things were never priced before today: the chip side, and the card side at any occupancy but the exported one, with watts.
|
||||
|
||||
**The chip side, on the model's own inputs** (chip-model-v3 5.3: a random 32-byte read is 909 pJ of activation plus 256 bits at 4.5 pJ per bit of movement and I/O; HBM3 the same shape at O'Connor's 3.48 pJ per bit scaled by 4.12 / 6.25; the SRAM die on floor lane 3's wire figure):
|
||||
|
||||
| Memory | Read energy at W = 1 (one 32 B sector) | At W = 16 (two sectors in the open row: one activate, two column accesses) | `E_mem` per hash, W = 1 to W = 16 | Rate | Label |
|
||||
|---|---|---|---|---|---|
|
||||
| GDDR7, 16 devices | 2.0 nJ | 3.2 nJ (+56 percent; 2.9 at O'Connor's 3.5 pJ per bit, +45) | 0.466 to **0.62** (+33 percent; 0.58 at the lower movement figure); the 35 W of static and controller unchanged | activate-bound at 166 MH/s, unchanged; 1.36 TB/s of the board's 1.79 | modelled |
|
||||
| HBM3, one stack | 1.2 nJ | 1.8 nJ (+50 percent) | 0.321 to **0.40** (+24 percent) | 83.6 MH/s, unchanged | modelled |
|
||||
| HBM3, eight stacks | 1.2 | 1.8 | 0.262 to 0.33 (+26 percent) | unchanged | modelled |
|
||||
| The custom HBM4E base die (lane B) | 0.9 to 1.0 | about 1.5 | 0.18 to about 0.27 (+50 percent) | unchanged | modelled, approximate |
|
||||
| The N2 SRAM full store (floor lane 3's rows) | 0.25 nJ (80 bits of wire) | 0.88 nJ (560 bits) | 0.036 to 0.126 (+250 percent): 66x to 19x against the 5090's stock point | power-bound: 8.3 to 2.4 GH/s per die | modelled (lane 3) |
|
||||
|
||||
**The card side, measured today** (8 October 2026, 13:2x to 13:4x UK; four cards on five pods; RunPod one-shot pods under the fleet's `oneshot.py`, the image `nvidia/cuda:12.8.1-devel-ubuntu24.04`; the worker built on each pod from this branch's `proto-cuda/nvrtc/worker.cpp` with `g++ -O2 -std=c++17 ... -ldl` through its Linux `dlopen` path, sha256 prefix 30eea48b; stock clocks, no power cap change; the packs `w4` (the lottery hash), `w64` (the item) and `w64-l2` (the `w64` kernel text with the first `uint4` load of each item replaced by inline PTX `ld.global.L2::64B.v4.u32`, so the L2 fetches the 64-byte line as one request; nothing else changed); every row's self-test PASS (96 of 96 vector lanes) and the 2^24 fingerprint `836e56e7d496e980` on every `w64` and `w64-l2` row, `25f96e7dce90bd4e` on every `w4` row, so the hinted text is bit-exact; rows of 2^24 nonces x 60 batches with `nvidia-smi` at 1 Hz over the row, the watts the mean of the upper half of the row's samples; a first pass of 5-batch rows for the shape sweep, a second of 60-batch rows for the watts; the sparse shapes are the worker's `sp<N>-w<W>` persistent grids, N = the card's SM count, W warps per block, so lanes in flight = N x W x 32):
|
||||
|
||||
| Card (architecture, memory) | `w4`: MH/s at W | `w64` exported, full occupancy | `w64` best sparse shape | `w64-l2` best form | On the 95 percent line | Energy per hash, `w4` to the best `w64` form | Label |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| RTX 4090 (Ada, GDDR6X, 128 SMs; driver 595.71) | 64.73 at 225 W (3.48 microjoules); idle 29 W | 33.75 (-48 percent) at 242 W | sp128-w1 (4,096 lanes) 53.98 (-17) at 258 W | **full occupancy 63.65 (-1.7 percent) at 296 W**; sp128-w1 58.5 (-10) at 264 | **PASS on rate** | 3.48 to 4.66: **+34 percent** | measured |
|
||||
| H100 SXM (Hopper, HBM3, 132 SMs; driver 580.126) | 253.4 at 442 W (1.74); idle 70 W | 171.4 (-32) at 469 W | sp132-w8 200.3 (-21) at 463 W | 171.4 at full occupancy, 200.3 at sp132-w8: **the hint does nothing on Hopper** | FAIL (79 percent) | 1.74 to 2.31 (best form): +33 percent; the full-occupancy form 2.74, +57 | measured |
|
||||
| RTX 3090 (Ampere, GDDR6X, 82 SMs; driver 580.159) | 60.00 at 321 W (5.35) | 23.95 (-60) at 330 W | sp82-w2 42.19 (-30) at 334 W | sp82-w2 52.34 (-13) at 349 W; sp82-w4 51.0 (5-batch row) | FAIL (87 percent) | 5.35 to 6.67: +25 percent | measured |
|
||||
| RTX 5090 (Blackwell, GDDR7, 170 SMs; a secure-cloud pod, driver 580.126, bus 02:00.0; the community host 216.249.100.66 answered `cuInit` 999 on three pods, all destroyed) | 142.5 to 142.7 at 311 to 319 W (2.21 microjoules; the record's 2.26 at 311 W); idle not settled on the pod | 75.3 (-47 percent) at 344 W (the 5 October row, 71.9, reproduced) | sp170-w1 (5,440 lanes) 101.5 (-29) at 343 W; sp170-w8 88.2 | full occupancy 110.4 (-22.5) at 392 W; **sp170-w4 (21,760 lanes) 145.7 (+2.3 percent) at 401 W**; sp170-w8 141.9 (-0.4) at 408 | **PASS on rate at the sparse shape** | 2.21 to 2.75: **+25 percent** | measured |
|
||||
| RX 9070 XT, Apple M5 Max | 5 October: -3 percent and 0 at 64 bytes (a 64-byte line per read already) | | | not re-run | free | 0 on the memory side | measured 5 October |
|
||||
|
||||
The rows are the record: the 4090 and H100 pods were destroyed before their raw logs were copied home (the copy step failed quietly on a shell expansion), so their figures above are the extracted rows read off the pods in the session; the 3090's raw logs are at `~/igneum-fleet/fl5-results/3090-*` (result.log, result2.log, power.csv, power2.csv). Spend: about USD 6 of the USD 150 allowed.
|
||||
|
||||
**What the rows say, on the identity.** (1) The rate question: the exported form's -47 to -60 percent is a request-shape and occupancy effect, as the 5090's probe said; one PTX qualifier on the first load makes the 64-byte item one L2 request and holds the 4-byte rate on Ada at full occupancy (98 percent), while on Hopper the hint changes nothing (the H100's loss is elsewhere: its HBM3 pseudo-channel moves a 64-byte access as two bursts and its best shape is 79 percent) and on Ampere the best shape is 87 percent. (2) The energy question, which decides it: the card pays the second sector at the cost of a whole dependent read, not at the DRAM's movement term. On the 4090 the hinted form draws 71 W more at the same 64.7 MH/s: 71 W over 8.3 G reads per second is **8.6 nJ per second sector**; on the 5090 at sp170-w4, 88 W more at 145.7 MH/s: **4.7 nJ**; against the 1.15 nJ the DRAM device charges for it (the 4090's GDDR6X at 6 pJ per bit would be 1.5), so the other 7 nJ is the card's own L2, crossbar, L1 and the SMs' longer wait, the same anatomy as the record's 8.7 nJ whole-card marginal per dependent read (15.1a). In the identity's terms `F` is 128 x 8.6 nJ = 1.1 microjoules per hash on the 4090 (0.6 on the 5090) and the chip's cost for the same work is 128 x 1.15 = 0.15: **`k` about 0.13 on the 4090, 0.24 on the 5090**, under the ALU shadow's 0.3 to 0.8 and no better than the L2 hot table's. The energy per hash rises 34 percent on the 4090 and 33 percent on the H100's best form against the modelled chip's 33 (GDDR7) and 50 (HBM3): the edge at zero shadow moves 0 on Ada against the GDDR7 chip and 0.9x in the chip's favour against HBM3 on Hopper; against the SRAM die the chip's 66x to 19x becomes about 25x against the card's new energy, not 19x. (3) The one row that could change the sign is the knee: at the 1,300 MHz lock the 5090's fixed share is smaller and its fabric runs at a lower voltage, so the sector's 8.6 nJ would fall with the measured 10.9 to 8.7 nJ per read (15.1a) and the card's rise would be a larger fraction of a smaller base; the lever lives only if the card's energy per hash rises under about 20 percent there, and a rented pod cannot read it (`nvidia-smi -lgc` refused: "does not have permission to change clocks"). That row is one PC 1 run (the `w4` and `w64-l2` packs at `--block-warps 1`, 60 batches, at the lock) and is owed to the hash lane as a formality, not a gate.
|
||||
|
||||
**Verdict: KILL at stock on measured rows; W = 16 words stays out of class v6; the design's "never 16" stands, for the measured reason (the card's fabric price of a sector) rather than the plan's (the pins).** What is kept: the hinted one-request load as a kernel fact for Ada (a 64-byte dependent read at the 4-byte rate), which a future width decision can use; the measured 8.6 nJ per sector as the card-side figure the chip model's width rows lacked; and the per-architecture rate table above.
|
||||
|
||||
Cost to the honest tiers and the rule of 5 October: nothing ships, so nothing changes for any tier; had it shipped, the Ada and Blackwell tiers would have paid about a third more energy per hash for no rate, Hopper a third more for a fifth of its rate, Ampere a quarter more for an eighth of its rate, AMD and Apple nothing, the verifier 4 percent, and every DRAM chip the same third.
|
||||
|
||||
### 3.2 Two items per read: W = 32, 128 bytes, one NVIDIA L2 line (KILL with 3.1)
|
||||
|
||||
The chip at W = 32: one activate plus four column accesses, 5.5 nJ per read; 21.3 G activates x 128 B = 2.7 TB/s is over the board's 1.79, so the GDDR7 chip becomes bandwidth-bound at 14 G reads per second, 110 MH/s, `E_mem` 1.02 microjoules (+120 percent); one HBM3 stack 0.66 (+105); the SRAM die about 0.21 (11x). The card: the pins bind the 5090 at 2.3 TB/s before any fabric cost (-20 percent of rate at best), and the measured sector price of 3.1 (8.6 nJ per sector at stock) puts three more sectors at about 3.3 microjoules per hash on the card against the chip's 0.44: `k` about 0.13 again, on a card that has also lost a fifth of its rate. Dead with 3.1; no `w32` pack is needed.
|
||||
|
||||
### 3.3 Independent chains per hash (memory-level parallelism as the card's lever) (KILL, one probe would reopen it)
|
||||
|
||||
The idea: four independent dependent chains of 32 reads per hash, folded at the end, so each resident hash keeps four reads in flight and an occupancy-bound card climbs toward its memory's activate ceiling while the chip (already at the ceiling) gains nothing; an `E_card` lever. The number: the 5090 reads 17.5 to 18.2 G per second at 4 B as "the best over lanes in flight" (the 5 October probe), which means more lanes did not help, so the bind is the memory system, not the in-flight count; the bound is the ceiling's 21.3 G, +17 to +22 percent, and the expectation is 0 to 5 percent (the microbench's `l2_indep4` against `l2_chase` read +2 percent on the L2-bound pattern). Today's rows agree from the other side: on the 4090 the `w4` hash at 64.7 MH/s is 8.3 G reads per second, the probe's own ceiling (8.3 to 8.9), at every occupancy from one warp per block to the sparse grids (64.70 to 64.74). KILL; the probe that would reopen it is one `dram_indep4_1g` row in the microbench.
|
||||
|
||||
### 3.4 Row-straddling items (two activates per read) (KILL, dominated)
|
||||
|
||||
A 64-byte item laid across a row boundary costs the chip two activates: 2 x 0.909 + 2 x 1.15 = 4.1 nJ per read (+105 percent) and halves its activate-bound rate (83 MH/s), so `E_mem` = 0.94 microjoules; the card's activate-bound rate halves too, at the fabric price of 3.1 on top. Dominated by the aligned item, which is itself dead. KILL.
|
||||
|
||||
### 3.5 Scratch state in DRAM (KILL)
|
||||
|
||||
A per-lane scratch line in DRAM, read-modify-written per step, would be `k = 1` work if it reached the DRAM on both sides; it does not: 7,262 hashes in flight x 64 B is 465 KB, inside the 5090's 96 MB L2 (an L2 hit at 1.4 nJ, `k` 0.1 to 0.3 against the chip's lane SRAM), and a scratch large enough to miss the L2 (13 KB per lane) is 15 MB of SRAM on the chip (about USD 5 of N5). The 5 October scratch rows measured the card's side: -12 to -48 percent of rate on the 5090. KILL (`docs/analysis/scratch-soundness.md` and the read-width entry say the same).
|
||||
|
||||
### 3.6 A note on the sunk card (not a candidate)
|
||||
|
||||
Both sides are capex-dominated (the record's 16.1: 7x to 10x their electricity per MH/s-hour), and the chip's economic edge is capex per MH/s (USD 2.8 against 14.7). For a home miner whose card was bought for something else the capex is sunk, so that tier's cost per MH/s-hour is its electricity alone, USD 0.000084 at the knee and USD 0.05 per kWh, against the chip's all-in USD 0.00018 (capex over two years plus electricity): the sunk card is 2.2x CHEAPER per MH/s-hour than the chip until the chip's capex is amortised. The honest-denominator lane owns this reading; it is recorded here because it is the one place the identity's per-joule framing understates the honest tier.
|
||||
|
||||
## 4. The KILL list, with the number
|
||||
|
||||
| Candidate | The identity check | The number | Label |
|
||||
|---|---|---|---|
|
||||
| The second sector, W = 16 words (the whole item) | the card pays the sector at its own fabric's price: `k` about 0.13; energy per hash +34 percent (4090), +33 (H100 best form), +25 (3090) against the chip's +33 | 8.6 nJ per sector on a 4090 at stock; the rate holds on Ada with the hint (98 percent), fails on Hopper (79) and Ampere (87) | measured card, modelled chip |
|
||||
| Two items per read, W = 32 | the same `k` on three more sectors, on a card the pins bind at -20 percent | chip 1.02 microjoules; card about 3.3 of sector work | modelled on the measured sector price |
|
||||
| Tensor tiles as the shadow (int8) | `k` 0.2 to 0.4 on the class's tile, 0.4 to 0.7 on the dense tile: never above the ALU shadow's band | the M5 Max -35 percent at 1,024 tiles, -78 at 4,096 (measured); the same premium as the ALU shadow by construction | measured card, claimed chip |
|
||||
| FP8, FP16, BF16 tiles; the texture interpolator; the rasteriser | not bit-exact across vendors | | spec and vendor documents |
|
||||
| The hardware video decoder | bit-exact by standard, but the honest card is decoder-bound (a fixed engine count) and the verifier decodes at milliseconds per frame | engines per card two to four (approximate) against any count on a chip | approximate |
|
||||
| The RT core | traversal implementation-defined on Vulkan, DXR, OptiX and Metal; the BVH opaque; `k` 0.01 to 0.2 on the public per-ray figures | 4 to 33 nJ per ray on a dedicated unit (claimed) against 290 to 750 measured board-level on an RTX 2080 | claimed and measured |
|
||||
| Memory-level parallelism per dollar | no asymmetry per channel; the correction is the PHY's node (section 2.3, kept as rank 1) | `E_mem` under +0.06 microjoules | claimed, modelled |
|
||||
| Proof of latency | the chain checks values; the block is 1 s against a 0.4 microsecond round trip; the farm's node is local; a sequential prefix favours the chip about 4x | 2.5 million round trips per block | measured card latency, modelled chip |
|
||||
| The drawn address map | firmware; no prefetch exists for a dependent chain | 0 to the chip, 0.8 to 3.2 percent to the cards | measured spread |
|
||||
| Independent chains per hash | the card's read rate sits at its probe's ceiling at every occupancy | the 4090 64.70 to 64.74 MH/s across six shapes; expected 0 to 5 percent on the 5090, bound 22 | measured |
|
||||
| Row-straddling items | dominated by the aligned item, which is dead | | modelled |
|
||||
| Scratch in DRAM | the card's scratch sits in L2; the chip's in SRAM | 465 KB against 96 MB | measured rows |
|
||||
|
||||
## 5. Consequences per tier (the standing rule of 5 October 2026)
|
||||
|
||||
| Tier | What this file means | What is being done |
|
||||
|---|---|---|
|
||||
| Home miner, one 8 GB NVIDIA card (32-byte sectors) | nothing ships; had W = 16 shipped, this tier would have paid about a third more energy per hash for no rate (Ada) and an eighth of its rate too (Ampere) | nothing; the knee row on PC 1 is a formality |
|
||||
| One 12 GB card (4070, 5070) | the same | the same |
|
||||
| One 16 GB card (RX 9070 XT) | nothing (its 64-byte line is free at W = 16, measured 5 October); its row against the chip is unchanged at 22.7x on class v3 | the vendor-share metric |
|
||||
| One 24 or 32 GB card (5090 class) | nothing ships; had W = 16 shipped, the 5090 would have held its rate at a sparse shape and paid +88 W (+25 percent per hash) at stock; the record's 3.6x at the knee and 2.1x at `k = 1` stand | the knee row on PC 1 (the hash lane, one row, a formality) |
|
||||
| Apple (M5 Max and the unified tiers) | nothing (free at 64 bytes, measured 5 October); the honest best per joule is unchanged | nothing |
|
||||
| A rig | nothing ships; the two model corrections raise the chip's project floor (USD 20 M to 75 M), which is the rig's real wall, by 4x to 15x over the record's cheapest row | the chip model's rows corrected (the Counter lane) |
|
||||
| A pool user | nothing changes | |
|
||||
| A node operator (the verifier) | nothing changes (the +4 percent of `w64` never ships) | |
|
||||
| A chip | the cheapest project is a 12 nm GDDR6 part at USD 20 M to 30 M or an N5 GDDR7 part at 50 M to 75 M, not a 28 nm one at 5 M; its `k` on tile work 0.2 to 0.7, not 0.03 to 0.3; its cost of the second sector (+33 percent) was matched by the card's | chip-model-v3 5.4, 5.6 and 5.11 and the record's 16.2 corrected (owed to the Counter lane) |
|
||||
| The public claim | unchanged by this lane on the hash; the tensor `k` sentence loses "4x to 30x"; the Ethash precedent gains the 13x row; the cheapest-chip project line gains a floor | the Counter lane's texts |
|
||||
|
||||
## 6. Unverified and owed
|
||||
|
||||
- The knee row (the 5090 at the 1,300 MHz lock, `w4` against `w64-l2`, 60 batches, watts): a rented pod refuses the lock; one PC 1 row with the hash lane; the lever lives only under a 20 percent energy rise there, which the stock rows make unlikely.
|
||||
- The 4090's and H100's raw logs were not copied before their pods were destroyed; the rows in 3.1 are the figures read off the pods during the session. The 5090's and the 3090's raw logs are at `~/igneum-fleet/fl5-results/5090-*` and `3090-*` (result.log, result2.log, result3.log, power*.csv).
|
||||
- The watts are `nvidia-smi` board power at 1 Hz, the upper half of each row's samples averaged; idle on the rented pods read 29 W (4090) and 70 W (H100); the 3090's idle row was taken straight after a run and is not a settled idle.
|
||||
- The chip side of 3.1 is the model's own data-movement term applied twice (4.5 pJ per bit, Micron, claimed, streaming; O'Connor's 3.48 for HBM2) and lane 3's wire figure for the SRAM die; no one has measured a second-sector column read's energy on GDDR7 (section 2.6: the public record has no random-read joule on any current part).
|
||||
- The tensor chip figures are vendor specifications at TDP (claimed), the M4 ANE and the H800 loop measured; the RT figures are claims and one measured RTX 2080 row; the PHY node facts are vendor IP pages read today.
|
||||
- The project-cost corrections use the history's figures (a 7 nm-class startup project USD 50 M to 75 M, claimed; the 12 nm figure scaled, approximate) and the mission lane's cap model.
|
||||
- Nothing ran on the Mac, on a Hetzner box or on a PC for this file; the measurements ran on four RunPod one-shot pods, all destroyed on their done (the 5090 pod when its rows are in).
|
||||
|
||||
## 7. Sources
|
||||
|
||||
Internal: `docs/analysis/counter-asic-4-research.md` (sections 2, 4, 5, 8, 15.1a, 16, 20), `docs/analysis/chip-model-v3.md` (5.1, 5.3, 5.4, 5.6, 5.10 to 5.12), `docs/design/class-v6-rotating-family.md` (sections 2, 7, 7a, 7c, 10 and 10.3), `docs/analysis/class-v6/hardware-future.md` at 7618e729 (sections 0, 1, 2, 4.9, 5), `docs/analysis/class-v6/history.md` (sections 3 and 5), `docs/analysis/class-v6/invention.md` on `class-v6-invention` (sections 1 and 2), `docs/analysis/class-v6/floor/sram-and-floor.md` on `class-v6-floor-sram` as carried in section 10.3, `docs/plans/read-width.md` (sections 1, 1.5, 4.1), `docs/bench-log.md` (the 5 October read-width entry: the probes and the hash rates), `proto-cuda/packs-readwidth/w64/kernel_bound.cu` (the four `uint4` loads per item), `proto-cuda/nvrtc/worker.cpp` (`variantSource`, the `sp<N>-w<W>` shapes, the Linux `dlopen` path), the fleet's `oneshot.py` and the rented rows' logs under `~/igneum-fleet/fl5-results/`, `docs/analysis/scratch-soundness.md`, spec 01 (1.5, the item table).
|
||||
|
||||
External, all read 8 October 2026 by this lane's three research sub-agents (tensor, RT, literature) and labelled where carried: the NVIDIA RTX 5090 page and the Blackwell architecture whitepaper; the Ada whitepaper; arXiv 2501.12084v2 (Hopper tensor-core microbenchmarks); Lenovo LP2226 (B200); the AMD MI300X data sheet and MI355X notes; the RX 9070 XT page; Qualcomm's AI 100 Ultra brief; eeNews Europe (MTIA v2); AnandTech 21342 (Gaudi 3); the Hot Chips 2025 Ironwood slides; arXiv 2608.28048 (Trainium2); Tenstorrent's Blackhole specification; the M4 ANE measurement (maderix.substack.com); cnx-software (Hailo-8); design-reuse 52539 (Untether); hackster.io (Axelera); NVIDIA Research's VSQ accelerator (JSSC 2023); Dally, Hot Chips 2023 keynote, slides 12 and 24; the Vulkan ray traversal chapter; the DXR specification; the NVIDIA developer forum thread 309730 (14 October 2024); the Blender Cycles source note; the Mach-RT TVCG preprint; Hot Chips 31 (Turing); the AMD Hot Chips 2025 RDNA 4 slides; embedded.com (RayCore); AnandTech 7870 (GR6500); arXiv 2409.06000 (RayFlex); the TRaX CGI 2018 study; Ylitie et al., HPG 2017; Chou et al., MICRO 2023; par.nsf.gov 10156963 (Choe et al., MEMSYS 2019); arXiv 2508.06795; SemiAnalysis "The Memory Wall"; the SK hynix ISSCC 2024 GDDR7 paper (ResearchGate 378947349); arXiv 2410.12990; arXiv 2405.06081; asicminervalue (iPollo V2H, V1, A11 Pro); kryptex (Jasminer X16-Q, FishHash); whattomine (the 5090 on Etchash); monero-project issue 10270; arXiv 2512.01437v2 and decrypt.co 363018 (Qubic); Business Wire 20240925780123 (Cadence GDDR7 on N3) and 20221115005674 (GDDR6 at 12 nm); innosilicon.com GDDR7; kurnal-insights (GB202 die); TrendForce 24 September 2026 (the 2 GB GDDR7 part); arXiv 2403.13230 (BFT-PoLoc); eprint 2026/694 (unread, refused); the PTX ISA (`ld` with the `.L2::64B` prefetch-size qualifier, sm_75 and later).
|
||||
436
docs/analysis/class-v6/floor/sram-and-floor.md
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
# Floor lane 3: the SRAM full-store die and the dataset floor
|
||||
|
||||
8 October 2026, 12:5x to 13:2x UK (the close of the floor section at 15:45 UK by the founder's rule of 13:3x; later rows land as amendments under section 10 with their own time), branch `class-v6-floor-sram` from `counter-asic-4`. One of the five floor lanes of
|
||||
section 10 of `docs/design/class-v6-rotating-family.md`; the rows go to the Counter ASIC 4.0 research lane for the
|
||||
15:45 UK close. Nothing here touches the devnet, the testnet object or any served number. Every figure carries a
|
||||
label: **measured** (a card on a named job in this repository), **claimed** (a vendor's or a paper's figure, with the
|
||||
record's source), **modelled** (arithmetic on claimed or measured figures by the method of `docs/analysis/chip-model-v3.md`
|
||||
section 5), **approximate** (an estimate; the sensitivity is given). No chip has been measured. The arithmetic is
|
||||
`scratchpad/sram_floor.py`, run on build-3 (nothing was run on the Mac).
|
||||
|
||||
The question: the strongest five-year chip in the record is a 2 GiB SRAM full store on one reticle of merchant N2
|
||||
(lane B, `docs/analysis/class-v6/hardware-future.md` 4.9 and 5; chip-model-v3 5.12): 1.0 nJ per 64-byte read (0.5 to
|
||||
2.0), about 2,100 MH/s per die at 300 W, 13x to 17x the RTX 5090 per joule at zero shadow, about 3x with the shadow at
|
||||
`k = 0.5` against the M5 Max, USD 400 to 600 of silicon per 2 GiB. None of the four v6 layers reaches it; layer 2's
|
||||
floor moves its capex, not its joules. This file finds what does, prices it on both sides, and says which lever, which
|
||||
schedule and which served claim.
|
||||
|
||||
## 0. The findings, in one page
|
||||
|
||||
1. **The record's SRAM read energy is the 64-byte figure, and the chip reads what the hash asks for.** The wire term
|
||||
(1.3 pJ per bit across a 24 mm die, approximate) scales with the bits moved, and the macro's own access (about 0.1 nJ,
|
||||
a wordline sensed) does not. At today's read width (`W = 1`, 4 bytes) the die moves about 80 bits per dependent read
|
||||
(address, control, data), not 560: **0.25 nJ per read, 8.3 GH/s per die, 66x the 5090 at zero shadow** (modelled on
|
||||
the record's own wire figure). The record's 17x is the `W = 16` row. With the class v4 shadow at `k = 0.5` the same
|
||||
chip reads 5.7x (the record's 4.8x), at `k = 1` 3.0x. **On the k lane's synthesised core (13:1x UK, section 2.2b:
|
||||
about 0.11 microjoules of shadow per hash at N3, absolute `k` about 0.1, the shuffle row open) the same die reads
|
||||
21x at W = 4 and 18x at W = 8 under class v4, 10x and 9.7x at the honest cards' whole latency shadow, 6x to 7x at
|
||||
the 5090's knee under the full shadow.** On the record's claimed band the shadow is the whole hold and the width
|
||||
moves the shadowed number 0.2x to 0.7x; on the synthesised core the memory is a third to a half of the die's energy
|
||||
and the width is worth 1.3x. The public 2x is not reachable against this die either way.
|
||||
2. **The one lever on the wire is the read width, and it is free on the honest cards up to 16 bytes.** `W = 4` words
|
||||
(16 bytes) is measured within 2.7 percent on the 5090 and the 9070 XT and within 1 percent on the M5 Max (the
|
||||
record, 5 October); it moves the die to 176 bits per read, 0.38 nJ, **44x at zero shadow, 5.6x at k = 0.5**. `W = 8`
|
||||
(32 bytes, exactly the GDDR7 sector the 5090 already fetches) has no row of its own but is free by the measured
|
||||
rows: the 5090's ceiling is about 18 G sectors a second (w16 moved 573 GB/s of sectors at 139.8 MH/s, w64 589 GB/s
|
||||
at 71.9, read-width.md), an aligned 32-byte read is one sector per load exactly as w16 is, AMD moves its 64-byte line
|
||||
either way, and the M5 Max read 1.03 of its v2 rate at both w16 and w64; it reads 0.55 nJ, **31x and 5.4x**, and one
|
||||
PC 1 row confirms it. `W = 16` costs the 5090 47 percent of its rate (two sectors per load, measured) and is dead. The fold is keyed by the destination
|
||||
register through its first word (`verify::fold_words`), so a chip cannot pre-fold a wide read into a narrower table:
|
||||
the raw words must cross the die. A chain of 2, 4 or 8 dependent atoms per step moves nothing: the ratio is per
|
||||
read on both sides. Banking plus a sequencer cannot localise: the chain alternates the 16 load sites and their
|
||||
windows, so no lane placement follows a window, and moving the lane to the data costs about 290 bits, which caps
|
||||
the wire lever at about 300 bits per read (`W = 8` is at that cap). The per-site window shrink (a half or a quarter
|
||||
of the dataset, era-layout 1.4) buys the SRAM die nothing and the DRAM chip nothing; its floor at 256 MiB is the
|
||||
cache's lever and stands.
|
||||
3. **The dataset floor is a ticket lever, not a per-joule or a per-MH/s lever.** SRAM cost per GiB is flat to 2031
|
||||
(about USD 250 per GiB: density gains 6 to 11 percent per node, wafers dearer per node, claimed), so USD 5,000 of
|
||||
silicon per store is 20 GiB in 2026 and about 21 GiB in 2031, which retires every card under 32 GB and every Mac
|
||||
under 64 GB. The hop between dies costs 0.04 to 0.15 nJ at the honest widths (UCIe 0.5 pJ per bit, claimed), so a
|
||||
ten-die store still reads 25x to 58x at zero shadow and 5.2x to 5.7x with the shadow; and because every die is
|
||||
powered, the store's USD per MH/s does not rise with the dataset (USD 0.25 to 0.5 at every size). The floor
|
||||
raises the minimum unit from USD 500 to USD 5,000, which is 2.5 times a 5090's street price. And the floor has a
|
||||
cost on the honest side that the record read as zero: the 5090 at its knee pays 4, 8 and 10 percent more energy
|
||||
per hash at 2, 4 and 8 GiB than at 1 GiB (measured, PC 1, 12:58 UK; 1 to 4 percent unlocked), so each step moves
|
||||
the honest denominator toward the chip by the same share. **The schedule that
|
||||
keeps the most honest cards is lane A's: 5.5 GiB at the v6 epoch (3 reticles, USD 1,500), 8.5 GiB two years on (5
|
||||
reticles, USD 2,500, the last size inside the 12 GB tier's room), 11.5 GiB at four years (6 reticles, USD 3,000,
|
||||
the last inside the 16 GB tier); each step sits just under a card tier's room and just over a reticle multiple.**
|
||||
It never reaches USD 5,000 without retiring the 24 GB tier; this file does not recommend that.
|
||||
4. **The capex wall, recomputed on the spec's emission** (3,168,808,781 base units per DAA second, 10^8 base units
|
||||
per IGN, a 30-day ramp, halving every 63,115,200 s, 80 percent to miners: 0.77 B IGN to miners in year 1, 0.80 in
|
||||
year 2, 0.40 in years 3 and 4, 0.20 in 5 and 6), class rotation at 0 cost to this chip (firmware), the floor as a
|
||||
fleet term that drops out: an N2 project of USD 100 M to 500 M that starts at launch, ships at 24 months and mines
|
||||
years 3 to 6 at a 30 percent share pays only above **USD 0.43 to 2.9 per IGN** (launch-year miner revenue USD 330
|
||||
M to 2.3 B, USD 0.9 M to 6.2 M a day); a maker who takes the whole chain pays above **USD 0.13 to 0.9** (USD 100
|
||||
M to 680 M a year, USD 0.27 M to 1.9 M a day). Below about USD 0.13 per IGN (USD 100 M a year of miner revenue) no
|
||||
rational SRAM project starts at any edge; above about USD 2.9 (USD 2.3 B a year) every one does. At USD 0.01, 0.10
|
||||
and 1.00 per IGN the NPV of a USD 150 M project at 5x and a 30 percent share is -148 M, -130 M and +54 M.
|
||||
5. **Nothing else reaches the die.** A per-era or per-epoch re-fill costs the die 0.27 mW; per block 0.96 W, 0.3
|
||||
percent, against the GPU's 1.3 to 3.2 percent of rate (dead, the invention lane's 2.15 again). A read straddling
|
||||
two atoms costs the die 0.02 nJ at `W = 4` and the verifier 19 percent more items (dead on the verifier). A second
|
||||
hot table is the die's own kind of memory (`k` 0.1 to 0.3 for the GPU's L2 hit, the record) and costs the card 2.4
|
||||
nJ per hit (dead). What is new and kept: the lane-migration bound (keep the per-lane scratch resident so the lane
|
||||
stays pinned and the data must travel), and the note that 3D-stacked SRAM (SoIC-class, a vertical hop at about 0.1
|
||||
pJ per bit, approximate) would make a multi-die store read as one die, which weakens the floor on joules further.
|
||||
|
||||
The honest line: against this die the memory lever is worth a factor of two at zero shadow and 1.3x with the shadow
|
||||
on at a synthesised core (a tenth on the record's claimed band); the floor is a ticket; the project cost against the
|
||||
chain's revenue is the wall; and the number the public text can carry is a range, 6x to 10x at the honest cards'
|
||||
whole latency shadow on the synthesised core (2x to 4x on the record's claimed band, marked), never "under 2x".
|
||||
|
||||
## 1. Inputs
|
||||
|
||||
| Quantity | Value | Label | Source |
|
||||
|---|---|---|---|
|
||||
| The 5090 at class v3 | 136.1 MH/s at 326 W, 2.40 microjoules per hash all-in, 2.26 the identity's `E_card`; 128 dependent reads per hash | measured | chip-model-v3 5.1; the research file section 2 |
|
||||
| The 5090's class v4 premium `F` | 1.10 microjoules unlocked (145 W); the whole latency shadow about 2.0 (about 330,000 ops, 575 W) | measured / approximate | the research file 15.1a; lane B section 7 |
|
||||
| The M5 Max | 0.78 microjoules at class v3, 1.40 at class v4 (GPU and DRAM channels) | measured, 6 October | latency-shadow-2026-10-06 section 3 |
|
||||
| Read width rows | w4 139.8 against 136.1 MH/s on the 5090; 17.90 against 18.15 on the 9070 XT; within 1 percent on the M5 Max; w64 71.9 MH/s on the 5090 (bandwidth-bound) | measured, 5 October | the design document section 2 (read width); read-width.md |
|
||||
| N2 SRAM density | 38 Mb/mm^2 HD, +11 percent over N3E; 2 GiB about 452 mm^2 of macro; a 550 to 650 mm^2 die; USD 400 to 600 of silicon at a USD 30,000 wafer | claimed (IEEE Spectrum, 12 December 2024) / approximate | lane B 4.9 |
|
||||
| The wire | 1.3 pJ per bit across a 24 mm die (0.6 at 128 mm^2 scaled by the side) | approximate; the band 0.5 to 2.0 nJ per 64 B is this term | lane B 4.9, 8 |
|
||||
| The macro access | about 0.1 nJ per access, width-independent above the column mux | approximate | lane B 4.9 (the 64 B figure), this file's reading |
|
||||
| The controller share | 0.05 nJ per read; 30 W static and sequencer per die at 300 W | approximate | lane B 4.9 |
|
||||
| The inter-die hop | UCIe 0.5 pJ per bit (0.25 to 0.5); the record's 0.27 nJ is 544 bits | claimed (UCIe via SNIA) | lane B 4.7 |
|
||||
| The load address | per site a window of the dataset, a half or a quarter (aligned, `k_off` in {0, 1, 2}), never under 2^26 words; `y = rotl(x * M, R)`; uniform on the window | designed | era-layout.md 1.3, 1.4 |
|
||||
| The fold of a wide read | `x = dst ^ w0; x = rotl(x, r) * M ^ w_i` for the rest: keyed by the destination register | code | `igneum-pow/src/verify.rs` `fold_words` |
|
||||
| The card room | 75 percent of card memory, 50 percent of Apple unified; the non-dataset working set 254 to 479 MiB best, 600 to 1,500 worst; the cache 512 MiB from 4 GiB, 1 GiB from 8, 2 GiB from 16, freed after the build in the best case | the standing rule / measured working set | card-lifetime-2026-10-05 section 3; the design document 3.4 |
|
||||
| The card population | by count of the bench table's 32 measured consumer cards: 6 GB 3 percent, 8 GB 22, 10 and 11 GB about 9, 12 GB 22, 16 GB 28, 24 GB and up 16; the fleet's hashrate-weighted census unread | approximate | lane A 4.2a via the design document 3.4 |
|
||||
| The Apple rate cost of the working set | -12 percent at 2 GiB, -20 at 4, -22 at 8 against 1 GiB on the M5 Max | measured, 10:40 UTC | the design document 3.3 |
|
||||
| The 5090's cost of the working set | against the 1 GiB control (137.65 MH/s at 312.2 W unlocked, 127.39 at 212.6 W at the 1,300 MHz lock): 2 GiB -2.8 percent unlocked and -4.9 at the lock, 4 GiB -3.8 and -11.2, 8 GiB -4.3 and -14.1; MH/W at the lock 0.599, 0.576, 0.553, 0.543 (4, 8 and 10 percent per hash in energy at the knee) | measured, 12:49 to 12:58 UK, PC 1, label ca4-v6 | the hash lane's message of 13:0x UK |
|
||||
| The emission | 3,168,808,781 base units per DAA second, 10^8 base units per IGN (1.0 B IGN a year in years 1 and 2), a 30-day ramp (about 37 M IGN never minted), halving every 63,115,200 DAA s, 80 percent to miners | spec | spec 05 (the economics table, section 2.5) |
|
||||
| The mission lane's break-even model | revenue `s x E2 x p`; cap = `C_proj / s` in years 1 to 2 at `s` = 0.30; its E2 of 4.18 B IGN is not this emission constant's figure (1.57 B to miners), so this file recomputes from the constant and carries both | model | mission/future.md 2.2 |
|
||||
| The re-fill | a 1 GiB rebuild 157 G ops, 13.4 ms on a 5090, 0.16 to 0.5 J on a chip core; class v5 refreshes per epoch (3,600 s) | measured / modelled | the invention lane 2.11, 2.15; the research file row 7 |
|
||||
|
||||
## 2. Lever 1: the wire energy
|
||||
|
||||
### 2.1 The model
|
||||
|
||||
A dependent read on the die moves an address (32 bits), control (about 16) and the data (32 `W` bits) across the
|
||||
global wire at 1.3 pJ per bit, pays the macro's access (0.1 nJ) and the sequencer's share (0.05 nJ):
|
||||
`E_read = 0.15 nJ + 1.3 pJ x (48 + 32 W)`. On `n` dies a read crosses a hop with probability `1 - 1/n` at 0.5 pJ per bit
|
||||
on the same bits. The rate is power-bound: 270 W over `128 x E_read`; energy per hash at zero shadow is 300 W over the
|
||||
rate. With the shadow: `edge = (2.26 + F) / (E_hash0 + k F)`. All modelled; the GPU side measured.
|
||||
|
||||
### 2.2 The read width, both sides
|
||||
|
||||
| `W` (words) | Bits per read | `E_read` nJ | GH/s per die | `E_hash` at zero shadow, microjoules | Edge over the 5090 at zero shadow | Over the M5 Max | Class v4 shadow, k 0.5 / 1 | Full shadow (F 2.0), k 0.5 / 1 | M5 Max class v4, k 0.5 | The honest cards' cost | Label |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 (today, 4 B) | 80 | 0.25 | 8.3 | 0.036 | **66x** | 22x | 5.7x / 3.0x | 4.1x / 2.1x | 4.0x | 0 | modelled |
|
||||
| 4 (16 B) | 176 | 0.38 | 5.6 | 0.054 | **44x** | 14x | 5.6x / 2.9x | 4.0x / 2.1x | 3.9x | 5090 +2.7 percent rate, 9070 XT -1.4, M5 Max within 1 (measured) | modelled chip, measured cards |
|
||||
| 8 (32 B, the GDDR7 sector) | 304 | 0.55 | 3.9 | 0.078 | **31x** | 10x | 5.4x / 2.9x | 4.0x / 2.0x | 3.6x | free by the measured rows: one sector per load on NVIDIA as at w16 (the ceiling about 18 G sectors a second: 573 GB/s at w16, 589 at w64), one 64 B line on AMD, and the M5 Max at 1.03 of v2 at both w16 and w64; a PC 1 row confirms (the crate's width set is {1, 4, 16} words, so a pack at 8 needs a generator and emitter line first, the hash lane's caveat) | modelled chip; the card by interpolation of measured rows |
|
||||
| 16 (64 B, the record's atom) | 560 | 0.88 | 2.4 | 0.125 | 19x (the record's 17x at 1.0 nJ) | 6.2x | 5.0x / 2.7x | 3.8x / 2.0x | 3.2x | the 5090 -47 percent of rate (measured): dead | modelled |
|
||||
|
||||
Reading: the record's 1.0 nJ and 17x are the 64-byte row; at the hash's own width the die is 3.5x stronger at zero
|
||||
shadow (66x). With the shadow on, every row sits at 5.0x to 5.7x at `k = 0.5` and 2.7x to 3.0x at `k = 1`: the read
|
||||
energy is 3 to 13 percent of the chip's energy per hash once the shadow is paid, and the width moves the shadowed edge
|
||||
by 0.2x to 0.7x. The one honest width lever is `W = 4` now (measured free) and `W = 8` after one measurement; the
|
||||
public text's number against this die is the shadow's, not the memory's.
|
||||
|
||||
### 2.2a The same rows against the 5090 at its knee, in both conventions (absolute first)
|
||||
|
||||
At the 1,300 MHz lock the card pays 1.67 microjoules at class v3 and 0.65 for the class v4 shadow (measured, 6.5 pJ
|
||||
per counted op). Two conventions price the die's shadow core. **Absolute**: the core costs what it costs, `k` fixed
|
||||
against the stock 11.3 pJ, so the die pays `k x 1.10` microjoules whatever the card does; this is the physics and the
|
||||
headline. **The record's**: `k` against the card's op cost at the same operating point, so the die pays `k x 0.65`
|
||||
at the lock; it flatters the die at the lock by 1.6x and is carried marked. At stock the two coincide (section 2.2).
|
||||
The `k` lane's RTL rows re-fold these in absolute for the close (its sequencer-core row due 15:00).
|
||||
|
||||
| `W` | Absolute, k 0.5 / 1 | The record's convention (marked), k 0.5 / 1 | Label |
|
||||
|---|---|---|---|
|
||||
| 1 | 4.0x / 2.0x | 6.4x / 3.4x | modelled |
|
||||
| 4 | **3.8x / 2.0x** | 6.1x / 3.3x | modelled |
|
||||
| 8 | **3.7x / 2.0x** | 5.8x / 3.2x | modelled |
|
||||
| 16 | 3.4x / 1.9x | 5.2x / 3.0x | modelled |
|
||||
|
||||
### 2.2b The re-fold on the k lane's synthesised core (13:1x UK; floor lane 2, `class-v6-floor-k`)
|
||||
|
||||
Floor lane 2's first chip-side figures (ASAP7 routed RTL, Yosys and OpenROAD, a minimal 8-register lane; scaled to
|
||||
N3 by x0.50, approximate): ARX 1.05 to 1.09 pJ per op, mul and mulhi 0.68, mad 1.66, the index fold 1.19, prmt 0.64,
|
||||
lop3 0.72; the shuffle, the crossbar, the scratch and the int8 tile pending. The class v4 mix costs that core about 1.0
|
||||
to 1.2 pJ per op at N3, **0.11 microjoules per hash of shadow at 102,100 ops, independent of the card's operating
|
||||
point**; absolute `k` about 0.10 at stock and 0.18 at the lock (the record's claimed band was 0.3 to 0.8). An N2 core
|
||||
is one node further (x0.7, approximate): 0.08. At the honest cards' whole latency shadow (330,000 ops) the same core
|
||||
pays 0.36 (N3) or 0.25 (N2). The re-fold, `E_chip = E_hash0 + shadow`, against the 5090 at 3.36 (stock, class v4),
|
||||
2.32 (lock), 4.26 (stock, the full shadow), 2.67 (lock, full) and the M5 Max at 1.40 (class v4):
|
||||
|
||||
| `W` | Class v4 shadow, stock / lock, N3 core | The same, N2 core | Full shadow, stock / lock, N3 | The same, N2 | M5 Max class v4, N3 / N2 | Label |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 23x / 16x | 29x / 20x | 11x / 6.7x | 15x / 9.3x | 9.6x / 12x | modelled on synthesised pJ |
|
||||
| 4 | **21x / 14x** | 25x / 17x | **10x / 6.5x** | 14x / 8.8x | 8.5x / 11x | the same |
|
||||
| 8 | **18x / 12x** | 21x / 15x | **9.7x / 6.1x** | 13x / 8.2x | 7.5x / 8.9x | the same |
|
||||
| 16 | 14x / 9.9x | 16x / 11x | 8.8x / 5.5x | 11x / 7.1x | 6.0x / 6.8x | the same |
|
||||
|
||||
Reading, which replaces "the shadow is the whole hold" of section 0 where the synthesised figure stands: at a
|
||||
measured-class `k` of 0.1 the shadow holds the die to 14x to 23x under class v4 and 6x to 11x at the honest cards'
|
||||
whole latency shadow, not 2x to 6x; the die's memory energy is then a third to a half of its energy per hash, and the
|
||||
read width is worth 1.3x (W = 1 to W = 8) on the shadowed number, not 0.2x. The public 2x is not reachable against
|
||||
this die by any shadow at a synthesised core; what holds it is the project cost against the chain's revenue (section
|
||||
4), which the shadowed edge moves by 1.4x across the whole band. The rows carry the k lane's labels (ASAP7 measured by
|
||||
synthesis, the node scaling approximate, the shuffle open); its own re-fold replaces this table when it lands.
|
||||
|
||||
Sensitivity: the wire at 0.65 pJ per bit halves every wire term (`W = 1` 0.20 nJ, 83x; `W = 8` 0.35 nJ, 49x); at 2.6
|
||||
pJ per bit doubles it (`W = 1` 0.35 nJ, 47x; `W = 8` 0.95 nJ, 18x). The macro at 0.2 nJ adds 0.1 nJ to every row. The
|
||||
shadowed rows move under 0.3x across the whole band.
|
||||
|
||||
Feasibility of the narrow-read rate (approximate): 8.3 GH/s per die is 1.06 T reads per second on the die, 85 Tb/s of
|
||||
on-die traffic (10.6 TB/s; a reticle die's mesh carries about 1/50 of a WSE-3's 21 PB/s), about 260 M reads per
|
||||
second per 4 Mbit macro at 4,096 macros, 21,000 lanes in flight at 20 ns, and 4.2 T sequencer ops per second at 3 to 5
|
||||
pJ (13 to 21 W, inside the 30 W static line). Nothing in it is a ceiling the DRAM rows have; the die is power-bound.
|
||||
|
||||
### 2.2c W = 16 (the whole 64-byte item) across the three chips, for the close (the coordinator's order, 13:2x UK)
|
||||
|
||||
Floor lane 5 ranks W = 16 first if the 5090 passes a PC 1 job today at its best lane count. The row at each chip's own
|
||||
cost: GDDR7 +33 percent on the memory term (two sectors per load), HBM3 +24 percent, the die at this file's 560 bits
|
||||
(0.88 nJ). The card side has two readings: the 5090 passes within the 5 percent rule (its 2.40 microjoules stand), or
|
||||
the measured w64 row stands (71.9 MH/s, 47 percent of rate, 1.89x the energy per hash if the watts hold; read-width.md,
|
||||
5 October). Zero shadow unless named; the shadow rows on the k lane's synthesised core (2.2b).
|
||||
|
||||
| Chip | `E_hash` at W = 1 (edge) | `E_hash` at W = 16 | Edge if the 5090 passes | Edge at the measured w64 row | With the class v4 shadow (synthesised core) | At the full shadow | Label |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| GDDR7 board, 16 devices | 0.466 (5.1x) | 0.550 | 4.4x | 8.3x | 5.1x | 4.7x | modelled |
|
||||
| HBM3, one stack | 0.321 (7.5x) | 0.358 | 6.7x | 12.7x | 7.2x | 5.9x | modelled |
|
||||
| SRAM die, one N2 reticle | 0.036 (66x) | 0.125 | **19x** | 36x | 14x | 8.8x | modelled |
|
||||
|
||||
Reading: W = 16 is the one width that moves the die by more than 2x at zero shadow (66x to 19x) and it costs the DRAM
|
||||
chips 11 to 15 percent; it is worth that only if the 5090 passes, since at the measured w64 row every chip's edge
|
||||
nearly doubles instead. The decision is the PC 1 measurement, not this table; if it passes, W = 16 replaces W = 8 as
|
||||
this file's width recommendation and the die's shadowed rows read 14x (class v4) and 8.8x (full).
|
||||
|
||||
### 2.3 What cannot localise, checked
|
||||
|
||||
| Candidate | What the chip would do | Why it fails | Number |
|
||||
|---|---|---|---|
|
||||
| Banking plus a sequencer near the data | place each lane's sequencer where its next read lands | the next address is uniform on a window of at least 2^26 words (256 MiB; a quarter of the die at the smallest) and the 16 sites alternate every load, so no placement is right twice | 0 |
|
||||
| Lane migration (move the lane to the data instead of the data to the lane) | ship the 8 registers, the pc and the scratch pointer to the target bank | costs about 290 bits per read against 80 to 304 for the data; cheaper than the data only above `W = 8`, which is why the wire lever caps at about 300 bits (0.55 nJ) whatever the width; the per-lane scratch (variant 5) pins the lane if it stays resident | the cap: `E_read` under about 0.6 nJ on one die at any `W` |
|
||||
| The per-site window (a half or a quarter of the dataset) | map each window to one die of a multi-die store | the lane sits on one die and the window changes every load; a lane placed at random crosses with the same `1 - 1/n` whether or not the sites have windows | 0 for the SRAM die and the DRAM chip; the 256 MiB floor is the cache's lever and stands |
|
||||
| The index fold and the stride rotation | permute the address lines in firmware | an SRAM die has no layout to lose; the fold is an xor-rotate on the address path | 0 |
|
||||
| A 2-, 4- or 8-atom dependent chain per step | nothing: it reads more | both sides pay per read (the 5090's marginal 10.9 nJ per dependent read, measured; the die's 0.25 to 0.55): the ratio is unchanged at every chain length and the honest rate halves per doubling | 44.5x at every chain length at `W = 4` (modelled) |
|
||||
| A replicated store (the full dataset on every die) | zero hops | doubles the silicon to save 0.04 to 0.15 nJ per read: USD 500 per die for under 1 percent of the shadowed edge | not worth building |
|
||||
|
||||
### 2.4 The hop, by die count and width
|
||||
|
||||
| `W` | 1 die | 2 | 4 | 8 | 10 | 16 | Label |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1: `E_read` nJ / edge at zero shadow / k 0.5 | 0.25 / 66x / 5.7x | 0.27 / 62x / 5.7x | 0.28 / 59x / 5.7x | 0.29 / 58x / 5.7x | 0.29 / 58x / 5.7x | 0.29 / 58x / 5.7x | modelled |
|
||||
| 4 | 0.38 / 44x / 5.6x | 0.42 / 40x / 5.5x | 0.44 / 38x / 5.5x | 0.46 / 37x / 5.5x | 0.46 / 37x / 5.5x | 0.46 / 37x / 5.5x | modelled |
|
||||
| 8 | 0.55 / 31x / 5.4x | 0.62 / 27x / 5.3x | 0.66 / 26x / 5.2x | 0.68 / 25x / 5.2x | 0.68 / 25x / 5.2x | 0.69 / 25x / 5.2x | modelled |
|
||||
|
||||
The hop saturates at `1 - 1/n` of 0.04 to 0.15 nJ; the floor cannot reach the die's joules at any size. The record's
|
||||
"13x at four dies" is the 64-byte row with the 0.27 nJ hop; at the honest widths the four-die store reads 26x to 59x.
|
||||
|
||||
## 3. Lever 2: the dataset floor, priced per tier
|
||||
|
||||
### 3.1 The SRAM cost curve (claimed inputs, approximate extrapolation)
|
||||
|
||||
| Year | Node | HD SRAM density, Mb/mm^2 | GiB per 452 mm^2 of macro | USD per reticle die | USD per GiB stored | Label |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 2026 to 2027 | N2 | 38 (claimed) | 2.0 | 500 (400 to 600) | 250 | lane B |
|
||||
| 2028 to 2029 | A16 class | about 41 (+8 percent; the record's 6 to 11 per node) | 2.16 | about 550 (wafers dearer per node) | 255 | approximate |
|
||||
| 2030 to 2031 | A14 class | about 44 | 2.33 | about 600 | 258 | approximate |
|
||||
|
||||
SRAM cost per GiB is flat across the window (the density gain and the wafer price cancel within the error). A store's
|
||||
silicon is about USD 250 per GiB of floor, quantised at the maker's die menu: one die design rounds up to the reticle
|
||||
(2.1 GiB costs two dies); a second, smaller die removes the rounding. The rows below carry both.
|
||||
|
||||
### 3.2 The floor against reticles and tiers
|
||||
|
||||
Room per tier: 75 percent of card memory (50 percent of Apple unified) minus the working set (254 MiB best, 479 worst)
|
||||
minus the cache when it is resident (worst). "OUT (worst)" counts the cache resident; "OUT (best)" the cache freed
|
||||
after the daily build. The Apple rate cost of the working set is on top at every step (-12 / -20 / -22 percent at 2 /
|
||||
4 / 8 GiB, measured; the curve past 8 GiB unmeasured and read as flat to -25 percent, approximate).
|
||||
|
||||
| Floor GiB | Reticles 2026 / 2031 | USD of silicon per store, one die design 2026 / 2031 (linear menu) | Tiers OUT, worst case | Tiers OUT, best case | Share of today's measured consumer cards OUT (by count, worst) | State records it assumes (64 B each) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 2.0 (genesis) | 1 / 1 | 500 / 600 (500) | none | none | 0 | 33.6 M |
|
||||
| 2.1 | 2 / 1 | 1,000 / 600 (525) | none | none | 0 | 35 M |
|
||||
| 4.0 | 2 / 2 | 1,000 / 1,200 (1,000) | none (the 6 GB tier) | none | 3 percent | 67 M |
|
||||
| 4.1 | 3 / 2 | 1,500 / 1,200 (1,025) | the same | none | 3 percent | 69 M |
|
||||
| **5.5 (lane A's v6 epoch)** | 3 / 3 | 1,500 / 1,800 (1,375) | the 8 GB tier in the worst case only (72 / 76 percent of the card: one point over the rule) | none | 0 to 22 percent | 92 M |
|
||||
| 6.0 (main's candidate) | 3 / 3 | 1,500 / 1,800 (1,500) | 8 GB | 8 GB | 25 percent | 101 M |
|
||||
| 8.0 | 4 / 4 | 2,000 / 2,400 (2,000) | 8 GB, 12 GB (cache resident), Apple 16 GB | 8 GB, Apple 16 GB | 25 to 47 percent | 134 M |
|
||||
| **8.5 (this file's year 2)** | 5 / 4 | 2,500 / 2,400 (2,125) | 8 GB, 12 GB (cache resident), Apple 16 GB; the 12 GB tier holds at 73 to 75 percent with the cache freed | 8 GB, Apple 16 GB | 25 to 47 percent | 143 M |
|
||||
| 11.0 (lane A's year 4) | 6 / 5 | 3,000 / 3,000 (2,750) | 8, 12, 16 GB (cache resident), Apple 16 | 8, 12 GB, Apple 16 | 47 to 75 percent | 185 M |
|
||||
| **11.5 (this file's year 4)** | 6 / 5 | 3,000 / 3,000 (2,875) | the same; the 16 GB tier holds at 73 to 75 percent with the cache freed | the same | 47 to 75 percent | 193 M |
|
||||
| 16.0 | 8 / 7 | 4,000 / 4,200 (4,000) | 8, 12, 16, 24 GB (cache resident), Apple 16, 32 | 8, 12, 16 GB, Apple 16, 32 | 75 to 84 percent | 268 M |
|
||||
| 17.5 | 9 / 8 | 4,500 / 4,800 (4,375) | the same | the same | 75 to 84 percent | 294 M |
|
||||
| **20.0 (USD 5,000 at N2)** | 10 / 9 | 5,000 / 5,400 (5,000) | everything under 32 GB and every Mac under 64 GB; the 32 GB card at 85 percent with the cache resident | everything under 32 GB except the 32 GB card; Macs under 64 GB | 84 percent (only the 24 GB and up tier's 32 GB half holds) | 336 M |
|
||||
| 22.0 | 11 / 10 | 5,500 / 6,000 (5,500) | the same plus the 32 GB card | the same | 84 to 100 percent of consumer cards | 369 M |
|
||||
| 24.0 | 12 / 11 | 6,000 / 6,600 (6,000) | every consumer card; Apple 64 GB and up | the same | 100 percent | 403 M |
|
||||
|
||||
Reading: **USD 5,000 of silicon per store is 20 GiB at N2 and about 21 GiB at the 2031 node, and it retires every
|
||||
card under 32 GB and every Mac under 64 GB on the day it lands, whichever year that is.** The constraint and "retire
|
||||
the fewest honest cards" cannot both hold; the frontier is the table. The schedule that retires the fewest cards
|
||||
per dollar of chip silicon puts each step just under a tier's room and just over a reticle multiple: 5.5 GiB (3
|
||||
reticles), 8.5 (5 reticles against 8.0's 4, at the cost of nothing to the 12 GB tier with the cache freed), 11.5 (6
|
||||
reticles); it reaches USD 3,000 by four years on and never USD 5,000. Against the record's 2.1x lever (lane A's
|
||||
schedule), this file's two upper steps cost the same tiers and buy one more reticle at the year-2 step.
|
||||
|
||||
If "per card-equivalent" is read per 136 MH/s of a 5090 rather than per store, the number is unreachable: a powered
|
||||
die makes 2,000 to 8,000 MH/s, so the store's silicon is USD 33 to 125 per card-equivalent at every floor, because
|
||||
the maker powers every die and the rate scales with the count (section 2.4). The floor does not move USD per MH/s; it
|
||||
moves the minimum ticket.
|
||||
|
||||
The honest side pays the floor in joules, which the record read as zero for the NVIDIA tiers: the 5090 at the 1,300
|
||||
MHz lock loses 4.9, 11.2 and 14.1 percent of rate at 2, 4 and 8 GiB against 1 GiB (unlocked 2.8, 3.8 and 4.3), which
|
||||
is 4, 8 and 10 percent more energy per hash at the knee (measured, PC 1, 12:49 to 12:58 UK, the hash lane's ca4-v6
|
||||
rows: the page-walk cost the unlocked card hides). So the 5.5 GiB step costs a tuned 5090 about 9 percent per hash and
|
||||
the 8.5 and 11.5 steps 10 to 11 (the curve past 8 GiB unmeasured, read as flattening), against the chip's ticket rising
|
||||
from USD 500 to 1,500, 2,500 and 3,000 and its joules not at all; every chip edge in section 2 rises by the same 4 to 11
|
||||
percent against a card at its knee. The 9070 XT and the smaller NVIDIA tiers have no size rows (approximate: the
|
||||
5090's curve). Per step the floor buys the chain USD 1,000 of chip ticket for about 1 percent of the tuned 5090's
|
||||
energy per hash and 22 percent of today's measured consumer cards by count.
|
||||
|
||||
### 3.3 The growth rule, tied to chain state
|
||||
|
||||
Layer 2's rule stands (the design document 3.1): the day's item count is the larger of the schedule's floor and the
|
||||
state's record count times 64 bytes, with the ceiling at the schedule's step for the year so the state can only bring
|
||||
a step forward. The proposed floor schedule, by height like a class epoch and a consensus field read at genesis:
|
||||
|
||||
| Step | Floor | Height (DAA seconds from the v6 epoch) | The state size that brings it forward instead | Reticles (N2 / 2031) | Silicon per store | Tiers that leave | Apple rate cost on top |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| the v6 epoch | 5.5 GiB | 0 | 92 M records | 3 / 3 | USD 1,500 | the 6 GB tier; the 8 GB tier only under the worst-case working set | about -21 percent against 1 GiB (between the measured 4 and 8 GiB rows) |
|
||||
| two years on | 8.5 GiB | 63,115,200 | 143 M records | 5 / 4 | USD 2,500 | the 8 GB tier (22 percent), the 10 GB 3080, the Apple 16 GB laptop; the 12 GB tier holds with the cache freed | about -22 percent (the measured 8 GiB row) |
|
||||
| four years on | 11.5 GiB | 126,230,400 | 193 M records | 6 / 5 | USD 3,000 | the 12 GB tier (22 percent) and the 11 GB 1080 Ti and 2080 Ti; the 16 GB tier holds with the cache freed | -22 to -25 percent (unmeasured past 8 GiB) |
|
||||
| the 16 GiB step | 16 GiB | whenever the state passes 268 M records, never by the calendar inside the window | 268 M | 8 / 7 | USD 4,000 | the 16 GB tier (28 percent), the 24 GB tier with the cache resident, Apple 32 GB | the same |
|
||||
|
||||
The rule assumes the state stays under 92 M records at the v6 epoch (a used chain's accounts alone are about 10 M,
|
||||
class-v5 section 3; lane A's 61 M row is 10 M accounts plus 50 M slots plus 1 M code chunks), under 143 M two years
|
||||
on and under 193 M at four; an Ethereum-class state (about 250 M, approximate) brings the 16 GiB step forward in the
|
||||
first years, which is the ceiling's reason. The non-power-of-two floors need the multiply-shift mapping of spec
|
||||
1.13.3 option (a) (uniform to 2^-32, branch-free), which changes the vectors once at the v6 epoch; option (b)'s
|
||||
powers of two give the chip the reticle multiple for free (8 GiB is exactly four dies).
|
||||
|
||||
## 4. Lever 3: the capex wall, recomputed
|
||||
|
||||
### 4.1 The model
|
||||
|
||||
Emission from the constant: 0.77 B IGN to miners in year 1 (the ramp), 0.80 in year 2, 0.40 in years 3 and 4, 0.20 in
|
||||
5 and 6, 0.10 in 7 and 8. An N2 project of cost `C` starts at launch, tapes out at 24 months (lane B's 18 to 24), and
|
||||
its fleet mines years 3 to 6 (two halvings): 1.20 B IGN of miner revenue nominal, 0.85 B at a 10 percent discount.
|
||||
The maker takes share `s` and keeps `(1 - 1/e)` of its share's revenue at a per-joule edge `e` (the honest fleet mines
|
||||
at zero margin, so the chip's margin is what its joules save). Class rotation every 180 days costs it nothing
|
||||
(firmware). The dataset floor enters as the fleet term (USD 0.25 to 0.5 per MH/s of silicon, every die powered),
|
||||
which at the rental-equilibrium hash (7.8 M MH/s per USD of price, the mission lane) is under 2 dies for the whole
|
||||
network at any price in the table: it drops out. `NPV = s (1 - 1/e) x 0.85 B x p - C`.
|
||||
|
||||
### 4.2 The thresholds
|
||||
|
||||
| `C` (USD M) | Share `s` | Edge `e` | Break-even price `p*` (USD per IGN) | Launch-year miner revenue at `p*` (USD M a year) | Per day | Cap at the end of year 2 at `p*` | Label |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 100 | 0.30 | 3x | 0.59 | 453 | 1.24 M | 1.15 B | modelled |
|
||||
| 100 | 0.30 | 5x | 0.49 | 378 | 1.03 M | 0.96 B | modelled |
|
||||
| 100 | 0.30 | 13x | 0.43 | 327 | 0.90 M | 0.83 B | modelled |
|
||||
| 100 | 1.00 (takes the chain) | 3x / 5x / 13x | 0.18 / 0.15 / 0.13 | 136 / 113 / 98 | 0.37 / 0.31 / 0.27 M | 0.35 / 0.29 / 0.25 B | modelled |
|
||||
| 250 | 0.30 | 3x / 5x / 13x | 1.47 / 1.23 / 1.06 | 1,132 / 944 / 818 | 3.1 / 2.6 / 2.2 M | 2.9 / 2.4 / 2.1 B | modelled |
|
||||
| 250 | 1.00 | 3x / 5x / 13x | 0.44 / 0.37 / 0.32 | 340 / 283 / 245 | 0.93 / 0.78 / 0.67 M | 0.87 / 0.72 / 0.63 B | modelled |
|
||||
| 500 | 0.30 | 3x / 5x / 13x | 2.94 / 2.45 / 2.12 | 2,265 / 1,887 / 1,636 | 6.2 / 5.2 / 4.5 M | 5.8 / 4.8 / 4.2 B | modelled |
|
||||
| 500 | 1.00 | 3x / 5x / 13x | 0.88 / 0.74 / 0.64 | 680 / 566 / 491 | 1.9 / 1.6 / 1.3 M | 1.7 / 1.4 / 1.3 B | modelled |
|
||||
|
||||
At three assumed prices (the assumption is the price; everything else is the constant), `C` = 150 M, `s` = 0.30, `e` = 5x:
|
||||
|
||||
| IGN price (assumed) | Launch-year miner revenue | Per day | NPV of the project | The fleet for a 30 percent share at the rental equilibrium | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| USD 0.01 | USD 7.7 M | USD 21 K | -148 M | 34 GH/s, 0.02 of a die | no project at any edge or cost |
|
||||
| USD 0.10 | USD 77 M | USD 211 K | -130 M | 335 GH/s, 0.17 of a die | no project at a 30 percent share; a USD 100 M project that takes the whole chain is at its break-even (0.13) |
|
||||
| USD 1.00 | USD 770 M | USD 2.1 M | +54 M | 3.4 TH/s, 1.7 dies | the USD 100 M to 250 M projects pay at every edge; the USD 500 M project does not at 3x |
|
||||
|
||||
Reading: **the revenue below which no rational SRAM project starts is about USD 100 M a year of miner revenue (IGN at
|
||||
about USD 0.13, USD 0.27 M a day), and that only for a USD 100 M project at 13x whose maker intends to take the whole
|
||||
chain; at a 30 percent share the first project starts at about USD 330 M a year (USD 0.43 per IGN, USD 0.9 M a day).
|
||||
The revenue above which every project starts is about USD 2.3 B a year (USD 2.9 per IGN, USD 6.2 M a day), where a
|
||||
USD 500 M project pays at 3x and a 30 percent share.** Between them the edge matters less than the cost: moving `e`
|
||||
from 3x to 13x moves `p*` by 1.4x, moving `C` from 100 M to 500 M moves it 5x. The per-joule levers of this file
|
||||
(section 2) move the chip from 5.7x to 5.4x at `k = 0.5` and change no row here by more than 2 percent; the lever on
|
||||
this table is the project cost, which the hash does not set, and the share-pattern detector, which is what makes the
|
||||
`s = 1` rows a chain the market can see. The mission lane's cruder rule (cap = `C / 0.30` in years 1 to 2 on its E2 of
|
||||
4.18 B) reads USD 330 M to 1.7 B of cap for the same projects, the same band as the `s` = 0.30 rows above.
|
||||
|
||||
The fleet column is the sentence the record already carries: at every price in the table the chip fleet for a third
|
||||
of the network is under two dies. A maker who builds the project builds the chain's whole hashrate in one package;
|
||||
the detector, not the hash, is the instrument.
|
||||
|
||||
### 4.3 The wall re-folded on the corrected project floor (floor lane 5, 13:2x UK: no 28 nm GDDR7 PHY exists, so the cheapest DRAM-board chip project is USD 20 M to 75 M, its cap 70 M to 250 M, not 5 M and 17 M)
|
||||
|
||||
The SRAM project's rows (4.2) stand. The thresholds for ANY rational chip project move to the cheapest one that
|
||||
exists, the GDDR7 board at USD 20 M to 75 M, which reads 5.1x at zero shadow and about 5x with the synthesised
|
||||
shadow (2.2b). The same model (ships at 24 months, mines years 3 to 6 at 10 percent; an 18-month ship lowers every
|
||||
`p*` by 15 percent):
|
||||
|
||||
| `C` (USD M) | Share `s` | Edge `e` | `p*` (USD per IGN) | Launch-year miner revenue at `p*` | Per day | Cap at the end of year 2 | Label |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 20 | 1.00 (takes the chain) | 5x / 3x | 0.029 / 0.035 | USD 23 M / 27 M a year | 62 K / 74 K | 0.06 B / 0.07 B | modelled |
|
||||
| 20 | 0.30 | 5x / 3x | 0.098 / 0.118 | 75 M / 91 M | 207 K / 248 K | 0.19 B / 0.23 B | modelled |
|
||||
| 75 | 1.00 | 5x / 3x | 0.110 / 0.132 | 85 M / 102 M | 233 K / 279 K | 0.22 B / 0.26 B | modelled |
|
||||
| 75 | 0.30 | 5x / 3x | 0.368 / 0.441 | 283 M / 340 M | 776 K / 931 K | 0.72 B / 0.86 B | modelled |
|
||||
|
||||
**The two numbers: below about USD 23 M a year of miner revenue (IGN 0.03, USD 62 K a day, a cap of about USD 60 M
|
||||
at the end of year 2) no rational chip project of any kind starts; above about USD 340 M a year (IGN 0.44, USD 0.93 M
|
||||
a day, a cap of about USD 0.9 B) every DRAM-board project starts at a third of the network, and the SRAM project
|
||||
starts at about the same level (USD 330 M a year, 4.2) at 13x or a whole-chain maker.** The band between is where the
|
||||
detector and the issuance trigger live (the history's USD 20 K to 50 K a day for compute-bound hashes sits at this
|
||||
file's lower threshold). The corrected floor moves the lower number 4x (from USD 100 M a year on the SRAM project to
|
||||
23 M) and the upper number not at all.
|
||||
|
||||
## 5. Lever 4: everything else that reaches the die, checked
|
||||
|
||||
| Candidate | What it costs the SRAM die | What it costs the honest tiers | The fresh-join path | Verdict | Label |
|
||||
|---|---|---|---|---|---|
|
||||
| A per-era (180-day) re-derivation the store must re-fill | 2^25 items x 9,360 ops at 3 pJ plus the writes: 0.96 J per re-fill, once per era: nothing | nothing (the daily build already is this) | unchanged (the dataset is derived from the state and the seed either way) | **dead**: 0 | modelled |
|
||||
| A per-epoch (3,600 s) re-fill (class v5 as designed) | 0.27 mW | the 32 ms build per epoch on every GPU (measured at 1 GiB) | unchanged | the design as it stands; not a lever | measured GPU, modelled chip |
|
||||
| A per-block (1 s) re-fill | 0.96 W, 0.32 percent of 300 W | 1.3 to 3.2 percent of a GPU's hash time (the invention lane 2.15) | the node ships the touched leaves per block (a few KB) | **dead**: the GPU pays 4x to 10x more | modelled |
|
||||
| A read that straddles two 64-byte atoms (`W = 4` at 4-byte grain, unaligned) | two macro accesses on 3/16 of reads: +0.02 nJ on 0.38 (5 percent) | the verifier derives two items for 19 percent of loads (+19 percent of the 8.3 to 8.8 ms loaded row: at the 10 ms gate); the 5090 crosses a 32-byte sector on 3/8 of reads, same DRAM row (bandwidth, not latency; unmeasured) | unchanged | **dead on the verifier** | modelled; the invention lane's 2.13 is the w64 form |
|
||||
| A per-read dependency on a second small hot table in the same die (the uniformity floor held) | the die's own kind of memory: a 64 MiB table is 15 mm^2 of N2 beside 452, read at 0.1 to 0.2 nJ | the 5090's L2 hit 2.4 nJ unlocked, 1.4 at the lock (measured); `k` 0.1 to 0.3 (the research file's design 6, dead) | unchanged | **dead**: it gives the die a cheaper read than the dataset and the card a dearer one | measured GPU, modelled chip |
|
||||
| A wider lane state (keep the per-lane scratch resident; variant 5) | pins the lane so the data must travel: holds the wire lever at the data's bits rather than the lane's 290 | 0 (the scratch lives in registers and shared memory today) | unchanged | **keep** as a dependency of lever 1 | modelled |
|
||||
| 3D-stacked SRAM (SoIC-class, two or four dies face to face; a vertical hop at about 0.1 pJ per bit) | the multi-die store reads as one die (the hop 0.01 to 0.03 nJ); the silicon per GiB unchanged | nothing | unchanged | **noted**: it removes the floor's last effect on joules and does not touch the ticket; the floor's pricing in section 3 stands | approximate |
|
||||
| Replication of the store across dies | USD 500 per die for 0.04 to 0.15 nJ per read | nothing | unchanged | not worth building | modelled |
|
||||
| A floor tied to the reticle (the dataset never under `r` reticles at the current node's density, re-based per family epoch by signal) | the same table as section 3.2 read by reticle count | the same tiers | unchanged | a governance form of lever 2, not a new lever; it needs an oracle for the density and is not recommended | design |
|
||||
|
||||
## 6. Consequences per tier
|
||||
|
||||
| Tier | What this file means for it | What is being done |
|
||||
|---|---|---|
|
||||
| Home miner, one 8 GB card | Lever 1 costs it nothing at `W = 4` (measured on larger cards; its own row unmeasured) and under 5 percent at `W = 8` by the sector argument (unmeasured). Lever 2 retires it at the year-2 step (8.5 GiB) on this file's schedule, as on lane A's; a 6 GiB v6 epoch retires it two years earlier. Lever 3 is not its concern: no chip exists, and the first one needs IGN at USD 0.13 to 2.9 | the schedule is the founder's number; the `W = 8` measurement is a PC 1 job |
|
||||
| One 12 GB card | holds to the year-4 step (11.5 GiB) with the cache freed after the build; out at 11.0 to 11.5 with it resident | the same |
|
||||
| One 16 GB card (the 9070 XT, the 5070 Ti) | holds every step to 11.5 GiB; out at the 16 GiB step | the same |
|
||||
| One 24 or 32 GB card, the 5090 at its knee | holds every step of the schedule; the 24 GB tier leaves at 16 GiB with the cache resident; the 32 GB card at 22 GiB. It pays the floor in joules at the knee: 4, 8 and 10 percent per hash at 2, 4 and 8 GiB (measured), about 9 to 11 percent across the schedule; unlocked 3 to 4 percent. Against the die with the shadow on at the lock (the card 1.67 microjoules plus F 0.65, measured; the die at `W = 4`): under the record's convention (`k` against the GPU's op cost at the same operating point, 6.2 pJ at the lock) 6.1x at `k = 0.5` and 3.3x at `k = 1`; under the absolute convention (the die's core costs what it costs, `k` fixed against the stock 11.3 pJ) 3.8x and 2.0x. The record's convention flatters the die at the lock by 1.6x; the absolute one is the physics and is what this file carries for the served line | the Ember knob carries the lock; the research lane picks the convention for its tables |
|
||||
| The unified-memory SoC | a 16 GB Mac leaves at the year-2 step; 32 GB at the 16 GiB step; every Mac pays -21 to -25 percent of rate from the v6 epoch on (measured to 8 GiB). Against the die at class v4 and `k = 0.5` the M5 Max reads 3.6x to 4.0x | finding 3 of lane B: the reference joule |
|
||||
| A rig | nothing changes until a project pays (section 4); the 180-day rotation and the floor do not move the chip's per-MH/s cost | the issuance trigger and the detector, as the record |
|
||||
| A pool user | the chip fleet that matters is one package; the share-pattern detector is the warning | the detector before the public testnet |
|
||||
| The public claim | "under 2x" is not reachable against the die by any shadow: on the k lane's synthesised core 10x at the honest cards' whole latency shadow at stock, 6x to 7x at the 5090's knee, 18x to 21x under class v4; on the record's claimed band (marked) 2x at `k = 1` and 4x at `k = 0.5`; 20x to 60x without the shadow. The number a miner can act on is the project's price threshold: USD 0.13 to 2.9 per IGN | section 7's served sentence |
|
||||
|
||||
## 7. Recommendation, for the 15:45 close
|
||||
|
||||
The lever is the read width: pin `W = 4` words at genesis on the measured rows (the 5090 +2.7 percent, the 9070 XT
|
||||
-1.4, the M5 Max within 1) and move to `W = 8` once one PC 1 row confirms what the measured rows already say (one
|
||||
sector per load on NVIDIA, one line on AMD, Apple flat to w64), which moves the die from 66x to 31x to 44x at zero
|
||||
shadow and by 0.2x to 0.4x with the shadow on, and keep the per-lane scratch resident so the lane stays pinned. The schedule is 5.5 GiB at the v6 epoch, 8.5 two years on and
|
||||
11.5 at four, each just under a card tier's room and just over a reticle multiple (3, 5 and 6 dies; USD 1,500, 2,500
|
||||
and 3,000 of silicon), and USD 5,000 is not recommended because it is 20 GiB and retires every card under 32 GB and
|
||||
every Mac under 64 GB whenever it lands. The served claim should say that the strongest chip we can model for 2027
|
||||
to 2028, an SRAM store on 2 nm, would reach about 6x to 10x per joule against an RTX 5090 paying its whole latency
|
||||
shadow with a core at the synthesised cost of a 3 nm lane (2x to 4x on the claimed band the record carried, marked),
|
||||
20x to 60x without that shadow, and that such a project pays only above about USD 0.4 per IGN at a third of the
|
||||
network or USD 0.13 for a maker who takes the whole chain, which is the pattern the share detector watches for. The floor is a ticket (USD 500 to 3,000 of silicon per
|
||||
store across the schedule) and not a per-joule or per-MH/s lever, and the text should not claim otherwise. Nothing in
|
||||
this file moves a served number; the `W = 8` row and the Apple rate curve past 8 GiB are the two measurements owed.
|
||||
|
||||
## 8. Unverified and owed
|
||||
|
||||
- Every chip-side figure is modelled; the macro access (0.1 nJ, width-independent) and the wire (1.3 pJ per bit at 24
|
||||
mm) are the record's approximations and move every zero-shadow row by 2x either way; the shadowed rows move under
|
||||
0.3x.
|
||||
- `W = 8` has no row of its own: its card cost is read by interpolation of the measured w16 and w64 rows (one sector
|
||||
per load on NVIDIA, one line on AMD, the M5 Max flat at 1.03 through w64). One PC 1 row at stock and at the lock
|
||||
confirms it; the crate's width set is {1, 4, 16} words, so the pack needs a generator and emitter line first (the
|
||||
hash lane's caveat, 13:0x UK). Not run by this lane (no builds on the Mac; no GPU on build-3 or build-4).
|
||||
- The Apple rate curve past 8 GiB (the 11.5 and 16 GiB steps): unmeasured; read as -22 to -25 percent.
|
||||
- The 5090's size rows at 2, 4 and 8 GiB landed at 12:58 UK (the hash lane, PC 1, label ca4-v6) and are in section 3.2;
|
||||
the 9070 XT and the smaller NVIDIA tiers have no size rows (their page reach is read as the 5090's, approximate).
|
||||
- The SRAM cost curve past N2 (A16 and A14 density and wafer price): approximate; the flat USD 250 per GiB rests on
|
||||
the density gain and the wafer price cancelling, which a 2x move in either changes by 2x.
|
||||
- The emission figures are the spec's constant read by this lane; the mission lane's E2 of 4.18 B IGN differs and is
|
||||
carried beside, not reconciled.
|
||||
- The 10 percent discount and the 24-month tape-out are assumptions; an 18-month tape-out adds about 0.2 B IGN of
|
||||
life and lowers every `p*` by about 15 percent.
|
||||
- The card population shares are by count of the bench table's 32 measured consumer cards; the fleet census is unread.
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
binary 4bec799c42e4c843 commit 8f4814591a4ad543e8cb91a50fa049da80d82b5d test v5_attempts_census from 1000 seeds 10000 threads 24 start 2026-10-08T10:15:44Z
|
||||
lease: holding 24 pool cores (48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71, waited 0 s, class measure): family gate: v5_attempts_census base seeds 1000+10000
|
||||
|
||||
running 1 test
|
||||
test accept::tests::v5_attempts_census ... v5_attempts_census: 10000 seeds 1000..11000 in 1534 s on 24 threads
|
||||
candidates 31788, rejected 21788, per-candidate rejection 0.6854, exhaustions 0, P(256 consecutive) 1.010e-42
|
||||
first failing part (a') unfresh source: 17980 (82.5 percent of rejections, 56.6 percent of candidates)
|
||||
first failing part (a) stale load: 2521 (11.6 percent of rejections, 7.9 percent of candidates)
|
||||
first failing part (b) no injecting write: 670 (3.1 percent of rejections, 2.1 percent of candidates)
|
||||
first failing part (c''') hot-item site: 201 (0.9 percent of rejections, 0.6 percent of candidates)
|
||||
first failing part (c'') low-entropy site: 240 (1.1 percent of rejections, 0.8 percent of candidates)
|
||||
first failing part (c') saturated source: 14 (0.1 percent of rejections, 0.0 percent of candidates)
|
||||
first failing part (c) constant bit: 88 (0.4 percent of rejections, 0.3 percent of candidates)
|
||||
first failing part (c) distinct addresses: 7 (0.0 percent of rejections, 0.0 percent of candidates)
|
||||
first failing part (c) saturated: 67 (0.3 percent of rejections, 0.2 percent of candidates)
|
||||
accepted attempt mean 2.179
|
||||
ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 79 filtered out; finished in 1533.90s
|
||||
|
||||
Terminated
|
||||
lease: released 24 pool cores after 1534 s, exit 0
|
||||
end 2026-10-08T10:41:18Z rc 0
|
||||
27
docs/analysis/class-v6/logs/fg-axes.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env python3
|
||||
"""family-gate lane: the per-axis and per-rotation-class readings of one stratum's TSV (markdown tables)."""
|
||||
import sys, csv, collections, statistics
|
||||
rows = [r for p in sys.argv[1:] for r in csv.DictReader(open(p), delimiter='\t')]
|
||||
acc = [r for r in rows if r['attempt'] not in ('', '-1')]
|
||||
print(f"eras {len(rows)}, accepted {len(acc)}, exhausted {len(rows)-len(acc)}")
|
||||
def z(r): return abs(float(r['bit_z_max']))
|
||||
print("\n| Axis | Value | Eras | Mean attempt | Max attempt | r per candidate | (c''') refusals per candidate | Biased site over 6 sigma | Over 100 sigma | Bucket sigma p99 | min ratio p1 |")
|
||||
print("|---|---|---|---|---|---|---|---|---|---|---|")
|
||||
def rcls(r): R = int(r['R']); return 'R 28..31 (bit 0 cut at D = 28)' if R >= 28 else 'R 1..27'
|
||||
for axis, keyf in [('width (words)', lambda r: r['width']), ('shape', lambda r: r['shape']), ('mixer m', lambda r: r['mixer']), ('rotation class', rcls)]:
|
||||
d = collections.defaultdict(list)
|
||||
for r in rows: d[keyf(r)].append(r)
|
||||
for k in sorted(d, key=lambda x: (len(x), x)):
|
||||
g = d[k]; ga = [r for r in g if r['attempt'] not in ('', '-1')]
|
||||
cands = sum(int(r['candidates']) for r in g); rej = cands - len(ga); c3 = sum(int(r['c3_hot_item']) for r in g)
|
||||
bz = sorted(float(r['bucket_z_max']) for r in ga); mr = sorted(float(r['min_ratio']) for r in ga)
|
||||
print(f"| {axis} | {k} | {len(g)} | {statistics.mean(int(r['attempt']) for r in ga):.2f} | {max(int(r['attempt']) for r in ga)} | {rej/cands:.3f} | {100*c3/cands:.2f} percent | {100*sum(z(r)>6 for r in ga)/len(ga):.1f} percent | {100*sum(z(r)>100 for r in ga)/len(ga):.1f} percent | +{bz[int(len(bz)*.99)]:.1f} | {mr[int(len(mr)*.01)]:.4f} |")
|
||||
rel = collections.Counter((int(r['bit']) - int(r['R'])) % 32 for r in acc if z(r) > 6)
|
||||
tot = sum(rel.values())
|
||||
print("\nBiased bit minus R, mod 32, among the biased eras:", ', '.join(f"{k}: {v} ({100*v/tot:.0f} percent)" for k, v in rel.most_common(5)))
|
||||
neg = sum(1 for r in acc if float(r['bit_z_max']) < -6); pos = sum(1 for r in acc if float(r['bit_z_max']) > 6)
|
||||
print(f"Sign: {neg} with P(bit) under 1/2 (the product law), {pos} over 1/2 (an or-shaped source)")
|
||||
clean = sorted(float(r['bucket_z_max']) for r in acc if z(r) <= 6); n = len(clean)
|
||||
print(f"Bucket excess on the {n} bit-clean eras: median +{clean[n//2]:.1f} sigma, p95 +{clean[int(n*.95)]:.1f}, p99 +{clean[int(n*.99)]:.1f}, max +{clean[-1]:.1f}")
|
||||
cz = sorted(z(r) for r in acc if z(r) <= 6); print(f"Bit |z| on the eras under 6 sigma: median {cz[len(cz)//2]:.2f}, p99 {cz[int(len(cz)*.99)]:.2f} (the maximum of about 26 free bits over 16 sites of a clean program)")
|
||||
mr = sorted(float(r['min_ratio']) for r in acc); print(f"Minimum (c'') ratio of the accepted draw: min {mr[0]:.5f}, p1 {mr[int(len(mr)*.01)]:.5f}, p5 {mr[int(len(mr)*.05)]:.5f}, median {mr[len(mr)//2]:.5f}")
|
||||
9
docs/analysis/class-v6/logs/fg-build.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
# family-gate lane: build the test binary of a box-side source dir under the lease pool and pin a copy. usage: fg-build.sh <tagdir> [threads]
|
||||
set -euo pipefail
|
||||
TAG="$1"; T="${2:-32}"; ROOT=/srv/builds/_adv-family-gate; W="$ROOT/src-$TAG"
|
||||
cd "$W/igneum-pow"; export PATH="$HOME/.cargo/bin:$PATH"; CARGO=$(command -v cargo)
|
||||
export RUSTC_WRAPPER=${RUSTC_WRAPPER:-$(command -v sccache || true)}
|
||||
/srv/builds/_bin/lease pool "$T" --min 8 --class measure --label "family gate: build the harness test binary ($TAG)" --owner class-v6-family-gate -- "$CARGO" test --release --lib --no-run -j "{cores}" > "$ROOT/logs/build-$TAG.log" 2>&1
|
||||
BIN=$(ls -t target/release/deps/igneum_pow-* | grep -v '\.d$' | head -1)
|
||||
mkdir -p "$ROOT/bin/$TAG"; cp "$BIN" "$ROOT/bin/$TAG/igneum_pow_test"; sha256sum "$ROOT/bin/$TAG/igneum_pow_test" | tee "$ROOT/bin/$TAG/sha256"
|
||||
12
docs/analysis/class-v6/logs/fg-census.sh
Executable file
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
# family-gate lane: one chunk of the class v5 attempts census (every candidate of each seed's chain draw through the real rule, the
|
||||
# era of each seed drawn from its own label) from a pinned copy of the test binary, under the lease pool.
|
||||
# usage: fg-census.sh <commit> <test-name> <from> <seeds> <threads> <tag>
|
||||
set -euo pipefail
|
||||
C="$1"; TEST="$2"; FROM="$3"; N="$4"; T="$5"; TAG="$6"
|
||||
ROOT=/srv/builds/_adv-family-gate; BIN="$ROOT/bin/$C/igneum_pow_test"; LOG="$ROOT/logs/$TAG-$TEST-$FROM-$N.log"
|
||||
echo "$$" > "$ROOT/pids/$TAG-$TEST-$FROM-$N.pid"
|
||||
echo "binary $(sha256sum "$BIN" | cut -c1-16) commit $C test $TEST from $FROM seeds $N threads $T start $(date -u +%FT%TZ)" > "$LOG"
|
||||
IGNEUM_V5_CENSUS_FROM="$FROM" IGNEUM_V5_CENSUS_SEEDS="$N" IGNEUM_V5_CENSUS_THREADS="$T" IGNEUM_FG_FROM="$FROM" IGNEUM_FG_SEEDS="$N" IGNEUM_FG_THREADS="$T" IGNEUM_FG_OUT="$ROOT/logs/$TAG-$TEST-$FROM-$N.tsv" \
|
||||
/srv/builds/_bin/lease pool "$T" --min 8 --class measure --label "family gate: $TEST $TAG seeds $FROM+$N" --owner class-v6-family-gate -- "$BIN" --ignored "$TEST" --nocapture --test-threads 1 >> "$LOG" 2>&1
|
||||
echo "end $(date -u +%FT%TZ) rc $?" >> "$LOG"
|
||||
38
docs/analysis/class-v6/logs/fg-records.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env python3
|
||||
"""family-gate lane: one gate-record JSON per drawn era from the census TSV rows (the format of family-gate.md section 4.3).
|
||||
usage: fg-records.py <outdir> <stratum>=<tsv>[,<tsv>] ... ; the harness binary sha and box from the matching .log header."""
|
||||
import sys, csv, json, os, pathlib, hashlib
|
||||
FAMILY = {"id": "class-v6-draft-2026-10-08", "spec": "docs/spec/01-lottery-hash.md 1.4.6, 1.13.1 (the family's bands in docs/analysis/class-v6/family-gate.md section 1)",
|
||||
"object": "igneum-pow class-v5 8f481459 plus the harness (branch family-gate-v5), the acceptance keyed on the family's shapes behind IGNEUM_FAMILY_GATE"}
|
||||
def header(logpath):
|
||||
try:
|
||||
line = open(logpath).readline().split()
|
||||
return {"binary": line[1], "box": None, "started": line[-1]}
|
||||
except Exception:
|
||||
return {}
|
||||
out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)
|
||||
n = 0
|
||||
for arg in sys.argv[2:]:
|
||||
stratum, paths = arg.split('=', 1)
|
||||
for p in paths.split(','):
|
||||
if not os.path.exists(p): continue
|
||||
h = header(p[:-4] + '.log')
|
||||
with open(p) as f:
|
||||
for r in csv.DictReader(f, delimiter='\t'):
|
||||
acc = r['attempt'] not in ('', '-1')
|
||||
rec = {"family": FAMILY, "label": "internal research, not an independent review",
|
||||
"era": {"label": f"igneum-family-gate/era/{r['k']}", "stratum": stratum,
|
||||
"draw": {"m": int(r['mixer']), "weights": [int(r[k]) for k in ['w_add','w_xor','w_mul','w_mad','w_shfl','w_rotl','w_sub','w_mulhi','w_rotr','w_or']],
|
||||
"width_words": int(r['width']), "shape": [int(r['shape']), int(r['reps'])], "R": int(r['R']), "M": "0x" + r['M'], "pos": json.loads(r['pos'])}},
|
||||
"harness": {**h, "source_tsv": os.path.basename(p), "secs": float(r['secs'])},
|
||||
"tests": [
|
||||
{"name": "attempts", "epoch": f"igneum-family-gate/program/{r['k']}", "candidates": int(r['candidates']), "accepted_attempt": int(r['attempt']) if acc else None,
|
||||
"first_failing_part": {k: int(r[k]) for k in ['a_prime','a','b','c_const','c_lane','c_sat','c_bias','c_distinct','c1_sat_source','c2_low_entropy','c3_hot_item']},
|
||||
"exhausted": not acc, "verdict": "PASS" if acc else "EXHAUSTED"},
|
||||
{"name": "c2-c3-ratio", "floor": [0.98, 0.995], "min_ratio": float(r['min_ratio']) if acc else None, "min_site": int(r['min_site']) if acc else None, "verdict": "PASS" if acc else "n/a"},
|
||||
{"name": "bucket-256-items", "max_ratio": float(r['bucket_ratio_max']) if acc else None, "max_sigma": float(r.get('bucket_z_max') or 'nan') if acc else None, "site": int(r['bucket_site']) if acc else None, "window": int(r.get('bucket_win') or -1) if acc else None, "bound": "not yet set (the clean spread is this census)", "verdict": "RECORD"},
|
||||
{"name": "index-bit-bias", "max_sigma": float(r['bit_z_max']) if acc else None, "site": int(r['bit_site']) if acc else None, "bit": int(r['bit']) if acc else None, "window": int(r.get('bit_win') or -1) if acc else None, "band_sigma": 6, "over_band": (abs(float(r['bit_z_max'])) > 6) if acc else None, "verdict": "RECORD"}]}
|
||||
(out / stratum).mkdir(exist_ok=True)
|
||||
(out / stratum / f"era-{r['k']}.json").write_text(json.dumps(rec, indent=1))
|
||||
n += 1
|
||||
print(f"{n} records under {out}")
|
||||
43
docs/analysis/class-v6/logs/fg-summary.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env python3
|
||||
"""family-gate lane: per-stratum summary of family_gate_era_census TSV rows (and the gate-record JSON per era)."""
|
||||
import sys, csv, json, math, os, statistics
|
||||
def load(path):
|
||||
with open(path) as f:
|
||||
r = csv.DictReader(f, delimiter='\t')
|
||||
return [row for row in r]
|
||||
def num(x, d=float('nan')):
|
||||
try: return float(x)
|
||||
except: return d
|
||||
def summarise(name, rows):
|
||||
n = len(rows)
|
||||
if n == 0: return f"| {name} | 0 | | | | | | | | |"
|
||||
acc = [r for r in rows if r['attempt'] not in ('', '-1')]
|
||||
cands = sum(int(r['candidates']) for r in rows); rej = cands - len(acc)
|
||||
r_per = rej / cands if cands else float('nan')
|
||||
atts = [int(r['attempt']) for r in acc]
|
||||
exh = n - len(acc)
|
||||
ratios = [(num(r['min_ratio']), r) for r in acc]
|
||||
worst = min(ratios, key=lambda x: x[0])
|
||||
under995 = sum(1 for x, _ in ratios if x < 0.995)
|
||||
c3 = sum(int(r['c3_hot_item']) for r in rows); c2 = sum(int(r['c2_low_entropy']) for r in rows)
|
||||
exh_eras = [r['k'] for r in rows if r['attempt'] in ('', '-1')]
|
||||
bz = [(num(r.get('bucket_z_max', 'nan')), r) for r in acc]
|
||||
bzw = max(bz, key=lambda x: x[0]) if bz and not math.isnan(bz[0][0]) else (float('nan'), {})
|
||||
bitz = [(abs(num(r['bit_z_max'])), r) for r in acc]
|
||||
bitw = max(bitz, key=lambda x: x[0])
|
||||
over6 = sum(1 for z, _ in bitz if z > 6); over100 = sum(1 for z, _ in bitz if z > 100)
|
||||
parts = {k: sum(int(r[k]) for r in rows) for k in ['a_prime','a','b','c_const','c_lane','c_sat','c_bias','c_distinct','c1_sat_source','c2_low_entropy','c3_hot_item']}
|
||||
pshare = ' '.join(f"{k} {100*v/rej:.1f}" for k, v in parts.items() if rej and v)
|
||||
def era(r): return f"era {r['k']} (R {r['R']}, shape {r['shape']}, W {r['width']})"
|
||||
return (f"| {name} | {n} | {r_per:.3f} | {statistics.mean(atts):.2f}, {max(atts)} | {exh}{(' (eras ' + ','.join(exh_eras[:8]) + ')') if exh else ''} | {worst[0]:.4f} at site {worst[1]['min_site']} of {era(worst[1])} | (c''') refused {c3} of {cands} candidates ({100*c3/cands:.2f} percent), (c'') {c2} ({100*c2/cands:.2f}) "
|
||||
f"| +{bzw[0]:.1f} sigma at site {bzw[1].get('bucket_site','')} (win {bzw[1].get('bucket_win','')}) of {era(bzw[1]) if bzw[1] else ''} | {bitw[1]['bit_z_max']} at site {bitw[1]['bit_site']} bit {bitw[1]['bit']} of {era(bitw[1])}; over 6 sigma {over6} ({100*over6/len(acc):.1f} percent), over 100 sigma {over100} ({100*over100/len(acc):.1f} percent) |\n"
|
||||
f"| rejections by part (percent of {rej}) | {pshare} | | | | | | | |")
|
||||
if __name__ == '__main__':
|
||||
print("| Stratum | Eras | r per candidate | Mean attempt, max | Exhausted at the 256 cap | min (c'') ratio of the accepted draw, worst era | The floors' refuse rates over all candidates | Largest bucket excess (sigma), worst era | Largest index-bit bias (sigma), worst era; the share over 6 and over 100 sigma |")
|
||||
print("|---|---|---|---|---|---|---|---|---|")
|
||||
for arg in sys.argv[1:]:
|
||||
name, path = arg.split('::', 1)
|
||||
rows = []
|
||||
for p in path.split(','):
|
||||
if os.path.exists(p): rows += load(p)
|
||||
print(summarise(name, rows))
|
||||
473
docs/analysis/class-v6/logs/harness-family-gate-v5-3dc3117c.diff
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
diff --git a/igneum-pow/src/accept.rs b/igneum-pow/src/accept.rs
|
||||
index 808451a4..533ec672 100644
|
||||
--- a/igneum-pow/src/accept.rs
|
||||
+++ b/igneum-pow/src/accept.rs
|
||||
@@ -241,7 +241,105 @@ pub struct SiteIndexStats {
|
||||
|
||||
/// The window of a load site in words at the rule's dataset: `2^28 >> min(win, 2)`.
|
||||
pub fn site_window_words(ins: &Instr) -> u64 {
|
||||
- (1u64 << ACCEPT_DATASET_LOG2) >> (ins.win as u64).min(2)
|
||||
+ // a load of `width` words reads an aligned address, so its index space is the window over `width` (1 on every
|
||||
+ // shipped class; the class v6 family-gate harness draws 4 and the expectation must follow, 8 October 2026)
|
||||
+ ((1u64 << ACCEPT_DATASET_LOG2) >> (ins.win as u64).min(2)) / (ins.width.max(1) as u64)
|
||||
+}
|
||||
+
|
||||
+/// Class v6 family-gate harness: what one load site's indices look like beyond [`SiteIndexStats`]: the largest
|
||||
+/// 256-item bucket (4,096 words, the bucket of the F8 tail attribution of 8 October 2026) as a ratio to the window's
|
||||
+/// expectation, and the largest index-bit one-count excess in sigma over the window's free bits (adv-cache-2's
|
||||
+/// value-level read: a product's low bits placed at address bit R and up).
|
||||
+#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
+pub struct FamilySiteStats {
|
||||
+ pub base: SiteIndexStats,
|
||||
+ pub bucket_ratio: f64,
|
||||
+ /// the same excess in sigma of the bucket's own expectation (a clean full-window site's maximum over 65,536
|
||||
+ /// Poisson(16) buckets is about +4.4 sigma, a ratio of 2.1; the quarter-bit tail of 8 October read 3.1x to 5.6x)
|
||||
+ pub bucket_z: f64,
|
||||
+ pub bucket_id: u32,
|
||||
+ /// the site's window draw `k_off` (0 the dataset, 1 a half, 2 a quarter)
|
||||
+ pub win: u8,
|
||||
+ pub bit_z: f64,
|
||||
+ pub bit: u8,
|
||||
+}
|
||||
+
|
||||
+/// The same run as [`site_index_stats`] with the two extra statistics per site.
|
||||
+pub fn family_site_stats(p: &Program, units: usize) -> Result<Vec<FamilySiteStats>, Reject> {
|
||||
+ let loads = p.loads_per_hash();
|
||||
+ let sites = loads / ITERATIONS;
|
||||
+ let mut acc = Acc {
|
||||
+ sources: None,
|
||||
+ indices: Some(vec![Vec::with_capacity(units * LANES * ITERATIONS); sites]),
|
||||
+ sat_source: vec![0; sites],
|
||||
+ and_acc: [u32::MAX; 8],
|
||||
+ or_acc: [0; 8],
|
||||
+ saturated: 0,
|
||||
+ bit_ones: [0; 64],
|
||||
+ distinct_sum: 0,
|
||||
+ };
|
||||
+ let mut lane_addrs = vec![0u32; LANES * loads];
|
||||
+ for (unit, &base) in accept_base_nonces_n(&p.seed, units).iter().enumerate() {
|
||||
+ run_unit(p, unit, base, &mut acc, &mut lane_addrs)?;
|
||||
+ }
|
||||
+ let load_instrs: Vec<&Instr> = p.instrs.iter().filter(|i| i.op.is_load()).collect();
|
||||
+ let mut out = Vec::with_capacity(sites);
|
||||
+ for (site, ix) in acc.indices.take().unwrap().iter_mut().enumerate() {
|
||||
+ let ins = load_instrs[site];
|
||||
+ let n = ix.len() as f64;
|
||||
+ let k = (ins.win as u32).min(2);
|
||||
+ let free_bits = ACCEPT_DATASET_LOG2 - k;
|
||||
+ let mut ones = [0u64; 32];
|
||||
+ let mut buckets = vec![0u32; 1usize << (ACCEPT_DATASET_LOG2 - 12)];
|
||||
+ for &x in ix.iter() {
|
||||
+ buckets[(x >> 12) as usize] += 1;
|
||||
+ let mut v = x;
|
||||
+ let mut b = 0;
|
||||
+ while v != 0 {
|
||||
+ ones[b] += (v & 1) as u64;
|
||||
+ v >>= 1;
|
||||
+ b += 1;
|
||||
+ }
|
||||
+ }
|
||||
+ let width = ins.width.max(1) as u32;
|
||||
+ let align_bits = width.trailing_zeros();
|
||||
+ let (mut bit_z, mut bit) = (0.0f64, 0u8);
|
||||
+ for b in align_bits..free_bits {
|
||||
+ let z = (ones[b as usize] as f64 - n / 2.0) / (n / 4.0).sqrt();
|
||||
+ if z.abs() > bit_z.abs() {
|
||||
+ bit_z = z;
|
||||
+ bit = b as u8;
|
||||
+ }
|
||||
+ }
|
||||
+ let window_buckets = (site_window_words(ins) * width as u64) >> 12;
|
||||
+ let expect = n / window_buckets as f64;
|
||||
+ let (mut bmax, mut bid) = (0u32, 0u32);
|
||||
+ for (i, &c) in buckets.iter().enumerate() {
|
||||
+ if c > bmax {
|
||||
+ bmax = c;
|
||||
+ bid = i as u32;
|
||||
+ }
|
||||
+ }
|
||||
+ ix.sort_unstable();
|
||||
+ let mut st = SiteIndexStats { distinct: 0, pairs: 0, top_index: 0, top_count: 0 };
|
||||
+ let mut i = 0;
|
||||
+ while i < ix.len() {
|
||||
+ let mut j = i + 1;
|
||||
+ while j < ix.len() && ix[j] == ix[i] {
|
||||
+ j += 1;
|
||||
+ }
|
||||
+ let run = (j - i) as u32;
|
||||
+ st.distinct += 1;
|
||||
+ st.pairs += (run as u64) * (run as u64 - 1) / 2;
|
||||
+ if run > st.top_count {
|
||||
+ st.top_count = run;
|
||||
+ st.top_index = ix[i];
|
||||
+ }
|
||||
+ i = j;
|
||||
+ }
|
||||
+ out.push(FamilySiteStats { base: st, bucket_ratio: bmax as f64 / expect, bucket_z: (bmax as f64 - expect) / expect.sqrt(), bucket_id: bid, win: k as u8, bit_z, bit });
|
||||
+ }
|
||||
+ Ok(out)
|
||||
}
|
||||
|
||||
/// One interpreter run over `units` units with every load site's word indices kept, then per site the sorted
|
||||
@@ -353,11 +451,38 @@ pub fn check_indices_v5(p: &Program) -> Result<(), Reject> {
|
||||
/// Whether `class` is the class v4 shape (the 256-instruction shadow block over the class v3 base, the pass count and
|
||||
/// the era set aside): the shape the sub-version 2 rules (a') and (c') apply to, on every draw path.
|
||||
pub fn is_class_v4_shape(class: &LoadClass) -> bool {
|
||||
+ if family_gate_on() {
|
||||
+ return is_family_shape(class);
|
||||
+ }
|
||||
matches!(class.shadow, Some(ShadowClass { instrs: V4_SHADOW_INSTRS, .. }))
|
||||
// class v5 (docs/design/class-v5-stored-state.md) is judged under the same rules: its state flag is set aside
|
||||
&& LoadClass { era: None, shadow: None, state: false, ..*class } == LoadClass { shadow: None, ..V4_CLASS }
|
||||
}
|
||||
|
||||
+/// Class v6 family-gate harness (8 October 2026, `docs/analysis/class-v6/family-gate.md`): the acceptance keyed on the
|
||||
+/// FAMILY's shapes instead of the one class v4 shape, on when `IGNEUM_FAMILY_GATE` is set in the environment of a
|
||||
+/// harness run and never on a chain path. The family: the shadow block in {64, 128, 256} instructions at the same
|
||||
+/// 6,912 instructions per iteration, the mixer multiplier in {4, 8, 16}, the read width the era's draw over {1, 4}
|
||||
+/// words (the mix one-hot on the drawn width), the rest the class v4 base with the state flag set aside.
|
||||
+pub fn is_family_shape(class: &LoadClass) -> bool {
|
||||
+ let sh = match class.shadow {
|
||||
+ Some(s) => s,
|
||||
+ None => return false,
|
||||
+ };
|
||||
+ if !matches!(sh.instrs, 64 | 128 | 256) || sh.instrs as usize * sh.reps as usize != V4_SHADOW_INSTRS as usize * crate::generator::V4_SHADOW_REPS as usize {
|
||||
+ return false;
|
||||
+ }
|
||||
+ if !matches!(class.mixer_mult, 4 | 8 | 16) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ LoadClass { era: None, shadow: None, state: false, mixer_mult: 8, mix: V4_CLASS.mix, ..*class } == LoadClass { shadow: None, ..V4_CLASS }
|
||||
+}
|
||||
+
|
||||
+fn family_gate_on() -> bool {
|
||||
+ static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
+ *ON.get_or_init(|| std::env::var_os("IGNEUM_FAMILY_GATE").is_some())
|
||||
+}
|
||||
+
|
||||
/// One pass of the dataflow freshness over the base program then the shadow block (the order of one iteration),
|
||||
/// from `fresh`; `check` reports the first load that reads a register that is not fresh. The rule (AP-F8-1,
|
||||
/// `docs/analysis/ca3-v4-uniform.md`): a load leaves its destination fresh only if its source was (a saturated
|
||||
@@ -1262,4 +1387,219 @@ mod tests {
|
||||
assert!(check(&generate(s)).is_ok());
|
||||
}
|
||||
}
|
||||
+
|
||||
+ /// Class v6 family-gate harness (8 October 2026, `docs/analysis/class-v6/family-gate.md`): one drawn era per seed of a
|
||||
+ /// label space, every parameter of the family drawn from the era's own stream (the shadow block shape in {64, 128,
|
||||
+ /// 256}, the mixer multiplier in {4, 8, 16}, the read width over {1, 4} words through the era draw, the ten non-load
|
||||
+ /// weights perturbed within B points with shuffle and mulhi never raised), the chain draw of that era's epoch
|
||||
+ /// through the real rule with the first failing part of every candidate, then on the accepted program the per-site
|
||||
+ /// statistics at the rule's own 2^20 sample: the (c'') ratio, the largest 256-item bucket, the index-bit bias.
|
||||
+ /// One TSV row per era to `IGNEUM_FG_OUT`. Pins for the strata: `IGNEUM_FG_SHAPE`, `IGNEUM_FG_MIXER`,
|
||||
+ /// `IGNEUM_FG_WIDTH`, `IGNEUM_FG_B` (default 4), `IGNEUM_FG_LOSSY_CAP` (or, mul and mulhi at +B: the lossy corner),
|
||||
+ /// `IGNEUM_FG_LABEL` (default `igneum-family-gate`). Requires `IGNEUM_FAMILY_GATE=1` in the environment.
|
||||
+ #[test]
|
||||
+ #[ignore]
|
||||
+ fn family_gate_era_census() {
|
||||
+ use crate::generator::{set_family_weights, LoadClass, ShadowClass, NONLOAD_WEIGHTS, V5_CLASS, MAX_ATTEMPTS_V4};
|
||||
+ use crate::seed::seed_words_from_bytes;
|
||||
+ use std::io::Write;
|
||||
+ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
+ use std::sync::Mutex;
|
||||
+ assert!(family_gate_on(), "IGNEUM_FAMILY_GATE=1 is required");
|
||||
+ let env_u = |k: &str, d: u32| std::env::var(k).ok().and_then(|v| v.parse().ok()).unwrap_or(d);
|
||||
+ let seeds = env_u("IGNEUM_FG_SEEDS", 1000);
|
||||
+ let from = env_u("IGNEUM_FG_FROM", 0);
|
||||
+ let threads = env_u("IGNEUM_FG_THREADS", 32) as usize;
|
||||
+ let b_pts = env_u("IGNEUM_FG_B", 4) as i64;
|
||||
+ let label = std::env::var("IGNEUM_FG_LABEL").unwrap_or_else(|_| "igneum-family-gate".to_string());
|
||||
+ let pin_shape = std::env::var("IGNEUM_FG_SHAPE").ok().and_then(|v| v.parse::<u16>().ok());
|
||||
+ let pin_mixer = std::env::var("IGNEUM_FG_MIXER").ok().and_then(|v| v.parse::<u8>().ok());
|
||||
+ let pin_width = std::env::var("IGNEUM_FG_WIDTH").ok().and_then(|v| v.parse::<u8>().ok());
|
||||
+ let lossy_cap = std::env::var_os("IGNEUM_FG_LOSSY_CAP").is_some();
|
||||
+ // the proposed band after the 12:00 BST reading of 8 October: B on the injecting families only, the three lossy
|
||||
+ // families (or, mul, mulhi) never raised above their base
|
||||
+ let lossy_base = std::env::var_os("IGNEUM_FG_LOSSY_BASE").is_some();
|
||||
+ // the lossy-share curve (the full report's item 3): or, mul and mulhi each at exactly +n points
|
||||
+ let lossy_plus: Option<i64> = std::env::var("IGNEUM_FG_LOSSY_PLUS").ok().and_then(|v| v.parse().ok());
|
||||
+ let out_path = std::env::var("IGNEUM_FG_OUT").unwrap_or_else(|_| "family-gate.tsv".to_string());
|
||||
+ let out = Mutex::new(std::fs::File::create(&out_path).expect("IGNEUM_FG_OUT"));
|
||||
+ let wnames: Vec<String> = NONLOAD_WEIGHTS.iter().map(|(o, _)| format!("w_{o:?}").to_lowercase()).collect();
|
||||
+ writeln!(out.lock().unwrap(), "k\tshape\treps\tmixer\twidth\tR\tM\tpos\t{}\tattempt\tcandidates\ta_prime\ta\tb\tc_const\tc_lane\tc_sat\tc_bias\tc_distinct\tc1_sat_source\tc2_low_entropy\tc3_hot_item\tmin_ratio\tmin_site\ttop_count_max\tbucket_ratio_max\tbucket_z_max\tbucket_site\tbucket_win\tbit_z_max\tbit_site\tbit_win\tbit\tlast_resort_k\tsecs", wnames.join("\t")).unwrap();
|
||||
+ let part_ix = |r: &Reject| -> usize {
|
||||
+ match r {
|
||||
+ Reject::UnfreshLoadSource { .. } => 0,
|
||||
+ Reject::StaleLoadSource { .. } => 1,
|
||||
+ Reject::NoInjectingWrite { .. } => 2,
|
||||
+ Reject::ConstantBit { .. } => 3,
|
||||
+ Reject::LaneConstantSite { .. } => 4,
|
||||
+ Reject::Saturated { .. } => 5,
|
||||
+ Reject::OutputBias { .. } => 6,
|
||||
+ Reject::DistinctAddresses { .. } => 7,
|
||||
+ Reject::SaturatedSource { .. } => 8,
|
||||
+ Reject::LowEntropySite { .. } | Reject::RepeatedSource { .. } => 9,
|
||||
+ Reject::HotItemSite { .. } => 10,
|
||||
+ }
|
||||
+ };
|
||||
+ let next = AtomicU32::new(from);
|
||||
+ let t0 = std::time::Instant::now();
|
||||
+ let done = AtomicU32::new(0);
|
||||
+ std::thread::scope(|sc| {
|
||||
+ for _ in 0..threads {
|
||||
+ sc.spawn(|| loop {
|
||||
+ let k = next.fetch_add(1, Ordering::Relaxed);
|
||||
+ if k >= from + seeds {
|
||||
+ break;
|
||||
+ }
|
||||
+ let ts = std::time::Instant::now();
|
||||
+ let w = |s: String| -> Vec<u8> { seed_words_from_bytes(s.as_bytes()).iter().flat_map(|x| x.to_le_bytes()).collect() };
|
||||
+ let epoch = w(format!("{label}/program/{k}"));
|
||||
+ let era = w(format!("{label}/era/{k}"));
|
||||
+ // the family's draws from the era's own stream, in a fixed order, each consumed whether pinned or not
|
||||
+ let dw = seed_words_from_bytes(&[b"igneum-family-gate/draw/".as_slice(), era.as_slice()].concat());
|
||||
+ let mut rng = SplitMix64::new(dw[0] as u64 | ((dw[1] as u64) << 32));
|
||||
+ let shapes = [64u16, 128, 256];
|
||||
+ let shape = pin_shape.unwrap_or(shapes[rng.below(3) as usize]);
|
||||
+ let mixers = [4u8, 8, 16];
|
||||
+ let mixer = pin_mixer.unwrap_or(mixers[rng.below(3) as usize]);
|
||||
+ let mut weights = NONLOAD_WEIGHTS;
|
||||
+ let mut raw = [0i64; 10];
|
||||
+ for (i, (op, wgt)) in NONLOAD_WEIGHTS.iter().enumerate() {
|
||||
+ let mut d = rng.below((2 * b_pts + 1) as u64) as i64 - b_pts;
|
||||
+ if matches!(op, Op::Shfl | Op::MulHi) && d > 0 {
|
||||
+ d = 0;
|
||||
+ }
|
||||
+ if lossy_cap && matches!(op, Op::Or | Op::Mul | Op::MulHi) {
|
||||
+ d = b_pts;
|
||||
+ }
|
||||
+ if lossy_base && matches!(op, Op::Or | Op::Mul | Op::MulHi) && d > 0 {
|
||||
+ d = 0;
|
||||
+ }
|
||||
+ if let Some(n) = lossy_plus {
|
||||
+ if matches!(op, Op::Or | Op::Mul | Op::MulHi) {
|
||||
+ d = n;
|
||||
+ }
|
||||
+ }
|
||||
+ raw[i] = (*wgt as i64 + d).max(1);
|
||||
+ }
|
||||
+ // renormalise to 75 by largest remainder
|
||||
+ let total: i64 = raw.iter().sum();
|
||||
+ let mut floors = [0u64; 10];
|
||||
+ let mut rems: Vec<(i64, usize)> = Vec::new();
|
||||
+ let mut sum = 0u64;
|
||||
+ for i in 0..10 {
|
||||
+ floors[i] = ((raw[i] * 75) / total) as u64;
|
||||
+ rems.push((((raw[i] * 75) % total), i));
|
||||
+ sum += floors[i];
|
||||
+ }
|
||||
+ rems.sort_by(|x, y| y.0.cmp(&x.0).then(x.1.cmp(&y.1)));
|
||||
+ let mut short = 75 - sum;
|
||||
+ for &(_, i) in &rems {
|
||||
+ if short == 0 {
|
||||
+ break;
|
||||
+ }
|
||||
+ floors[i] += 1;
|
||||
+ short -= 1;
|
||||
+ }
|
||||
+ for i in 0..10 {
|
||||
+ weights[i].1 = floors[i];
|
||||
+ }
|
||||
+ set_family_weights(Some(weights));
|
||||
+ let reps = (V4_SHADOW_INSTRS as u32 * crate::generator::V4_SHADOW_REPS as u32 / shape as u32) as u16;
|
||||
+ let base = LoadClass { shadow: Some(ShadowClass { instrs: shape, reps }), mixer_mult: mixer, ..V5_CLASS };
|
||||
+ // a one-entry set is the pinned-width path of LoadClass::era (the mix stays the base's and the draw is
|
||||
+ // redrawn to the base's widest, 1 word) and the set must be strictly ascending, so a pinned width is
|
||||
+ // built by hand: the era drawn over the one-entry set and the mix one-hot on that width, as the
|
||||
+ // drawn-set path does
|
||||
+ let class = match pin_width {
|
||||
+ Some(w) if w == 1 || w == 4 => {
|
||||
+ let mut c = base;
|
||||
+ let i = crate::generator::WIDTH_WORDS.iter().position(|&x| x == w).unwrap();
|
||||
+ c.mix = [0, 0, 0];
|
||||
+ c.mix[i] = 100;
|
||||
+ c.era = Some(crate::generator::era_draw(&era, &[w]));
|
||||
+ c
|
||||
+ }
|
||||
+ _ => LoadClass::era(base, &era, &[1, 4]),
|
||||
+ };
|
||||
+ let e = class.era.unwrap();
|
||||
+ let lbl = f8_label(&epoch);
|
||||
+ let mut parts = [0u32; 11];
|
||||
+ let mut accepted: Option<(u32, Program)> = None;
|
||||
+ for attempt in 0..MAX_ATTEMPTS_V4 {
|
||||
+ let c = candidate_class(&lbl, &epoch, attempt, class);
|
||||
+ match check(&c) {
|
||||
+ Ok(_) => {
|
||||
+ accepted = Some((attempt, c));
|
||||
+ break;
|
||||
+ }
|
||||
+ Err(r) => parts[part_ix(&r)] += 1,
|
||||
+ }
|
||||
+ }
|
||||
+ // an exhausted era takes class v5's last resort: the scan of 256 more candidates past the cap, each
|
||||
+ // rewritten (or, mul, mulhi to xor) and its stale loads re-sourced, the first that passes the whole
|
||||
+ // rule; the column records that k (cap + i), or "fallback" when none of the 256 passed
|
||||
+ let last_resort_k: String = if accepted.is_none() {
|
||||
+ use crate::generator::{last_resort_v4, repair_stale_loads, LAST_RESORT_SCAN};
|
||||
+ let mut found = String::from("fallback");
|
||||
+ for kk in MAX_ATTEMPTS_V4..MAX_ATTEMPTS_V4 + LAST_RESORT_SCAN {
|
||||
+ let q = repair_stale_loads(last_resort_v4(candidate_class(&lbl, &epoch, kk, class)));
|
||||
+ if check(&q).is_ok() {
|
||||
+ found = kk.to_string();
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ found
|
||||
+ } else {
|
||||
+ String::new()
|
||||
+ };
|
||||
+ set_family_weights(None);
|
||||
+ let candidates: u32 = parts.iter().sum::<u32>() + accepted.is_some() as u32;
|
||||
+ let (attempt, stats_row) = match &accepted {
|
||||
+ Some((att, p)) => {
|
||||
+ let st = family_site_stats(p, ACCEPT_UNITS_DISTINCT_V4).expect("the accepted program runs");
|
||||
+ let n = (ACCEPT_UNITS_DISTINCT_V4 * LANES * ITERATIONS) as f64;
|
||||
+ let loads: Vec<&Instr> = p.instrs.iter().filter(|i| i.op.is_load()).collect();
|
||||
+ let (mut min_r, mut min_s) = (f64::MAX, 0usize);
|
||||
+ let (mut bmax, mut bz, mut bsite, mut bwin) = (0.0f64, 0.0f64, 0usize, 0u8);
|
||||
+ let (mut zmax, mut zsite, mut zbit, mut zwin) = (0.0f64, 0usize, 0u8, 0u8);
|
||||
+ let mut top = 0u32;
|
||||
+ for (s, fs) in st.iter().enumerate() {
|
||||
+ let r = site_ratio(fs.base.distinct, n, site_window_words(loads[s]));
|
||||
+ if r < min_r {
|
||||
+ min_r = r;
|
||||
+ min_s = s;
|
||||
+ }
|
||||
+ if fs.bucket_z > bz {
|
||||
+ bmax = fs.bucket_ratio;
|
||||
+ bz = fs.bucket_z;
|
||||
+ bsite = s;
|
||||
+ bwin = fs.win;
|
||||
+ }
|
||||
+ if fs.bit_z.abs() > zmax.abs() {
|
||||
+ zmax = fs.bit_z;
|
||||
+ zsite = s;
|
||||
+ zbit = fs.bit;
|
||||
+ zwin = fs.win;
|
||||
+ }
|
||||
+ top = top.max(fs.base.top_count);
|
||||
+ }
|
||||
+ (*att as i64, format!("{min_r:.5}\t{min_s}\t{top}\t{bmax:.3}\t{bz:.2}\t{bsite}\t{bwin}\t{zmax:.2}\t{zsite}\t{zwin}\t{zbit}"))
|
||||
+ }
|
||||
+ None => (-1, "\t\t\t\t\t\t\t\t\t\t".to_string()),
|
||||
+ };
|
||||
+ let wcols: Vec<String> = weights.iter().map(|(_, x)| x.to_string()).collect();
|
||||
+ let pcols: Vec<String> = parts.iter().map(|x| x.to_string()).collect();
|
||||
+ let row = format!("{k}\t{shape}\t{reps}\t{mixer}\t{}\t{}\t{:08x}\t{:?}\t{}\t{attempt}\t{candidates}\t{}\t{stats_row}\t{last_resort_k}\t{:.1}", e.width_words, e.stride_rot, e.stride_mul, e.pos, wcols.join("\t"), pcols.join("\t"), ts.elapsed().as_secs_f64());
|
||||
+ writeln!(out.lock().unwrap(), "{row}").unwrap();
|
||||
+ let d = done.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
+ if d % 100 == 0 {
|
||||
+ println!("family_gate_era_census: {d} eras in {:.0} s", t0.elapsed().as_secs_f64());
|
||||
+ out.lock().unwrap().flush().unwrap();
|
||||
+ }
|
||||
+ });
|
||||
+ }
|
||||
+ });
|
||||
+ out.lock().unwrap().flush().unwrap();
|
||||
+ println!("family_gate_era_census: {} eras {from}..{} in {:.0} s on {threads} threads -> {out_path}", done.load(Ordering::Relaxed), from + seeds, t0.elapsed().as_secs_f64());
|
||||
+ }
|
||||
}
|
||||
diff --git a/igneum-pow/src/generator.rs b/igneum-pow/src/generator.rs
|
||||
index 3f2d6723..b1db44c9 100644
|
||||
--- a/igneum-pow/src/generator.rs
|
||||
+++ b/igneum-pow/src/generator.rs
|
||||
@@ -943,14 +943,31 @@ pub fn generate_era_generator(seed_string: &str, seed_bytes: &[u8], base: LoadCl
|
||||
p
|
||||
}
|
||||
|
||||
+/// Class v6 family-gate harness: `base` with the shadow block shape from `IGNEUM_FG_SHAPE` (64, 128 or 256 at 6,912 per
|
||||
+/// iteration) under `IGNEUM_FAMILY_GATE`, so an unmodified harness runs the family's shape; `base` otherwise.
|
||||
+fn family_shape_of(base: LoadClass) -> LoadClass {
|
||||
+ if std::env::var_os("IGNEUM_FAMILY_GATE").is_some() {
|
||||
+ if let Some(n) = std::env::var("IGNEUM_FG_SHAPE").ok().and_then(|v| v.parse::<u16>().ok()) {
|
||||
+ assert!(matches!(n, 64 | 128 | 256), "IGNEUM_FG_SHAPE is 64, 128 or 256");
|
||||
+ let reps = (V4_SHADOW_INSTRS as u32 * V4_SHADOW_REPS as u32 / n as u32) as u16;
|
||||
+ return LoadClass { shadow: Some(ShadowClass { instrs: n, reps }), ..base };
|
||||
+ }
|
||||
+ }
|
||||
+ base
|
||||
+}
|
||||
+
|
||||
impl ProgramClass {
|
||||
/// The load class this program class draws from.
|
||||
pub fn load_class(&self) -> LoadClass {
|
||||
match self {
|
||||
ProgramClass::V2 => LoadClass::V2,
|
||||
ProgramClass::V3 => V3_CLASS,
|
||||
- ProgramClass::V4 => V4_CLASS,
|
||||
- ProgramClass::V5 => V5_CLASS,
|
||||
+ ProgramClass::V4 => family_shape_of(V4_CLASS),
|
||||
+ ProgramClass::V5 => {
|
||||
+ // class v6 family-gate harness: the shadow block shape from IGNEUM_FG_SHAPE (64, 128 or 256 at 6,912
|
||||
+ // per iteration) under IGNEUM_FAMILY_GATE, so an unmodified harness runs the family's shape
|
||||
+ family_shape_of(V5_CLASS)
|
||||
+ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,6 +1332,34 @@ pub const NONLOAD_WEIGHTS: [(Op, u64); 10] = [
|
||||
(Op::Or, 4),
|
||||
];
|
||||
|
||||
+thread_local! {
|
||||
+ static FAMILY_WEIGHTS: std::cell::Cell<Option<[(Op, u64); 10]>> = const { std::cell::Cell::new(None) };
|
||||
+}
|
||||
+/// Class v6 family-gate harness (8 October 2026): the ten non-load weights this thread's draws use when set, else
|
||||
+/// [`NONLOAD_WEIGHTS`]. Set only by the harness census (`accept::tests::family_gate_era_census`), never on a chain path.
|
||||
+pub fn set_family_weights(w: Option<[(Op, u64); 10]>) {
|
||||
+ FAMILY_WEIGHTS.with(|c| c.set(w));
|
||||
+}
|
||||
+pub fn family_weights() -> [(Op, u64); 10] {
|
||||
+ FAMILY_WEIGHTS.with(|c| c.get()).unwrap_or_else(|| *FAMILY_WEIGHTS_ENV.get_or_init(family_weights_env))
|
||||
+}
|
||||
+static FAMILY_WEIGHTS_ENV: std::sync::OnceLock<[(Op, u64); 10]> = std::sync::OnceLock::new();
|
||||
+/// The harness's process-wide weight table: `IGNEUM_FG_WEIGHTS=w0,..,w9` (sum 75, the order of [`NONLOAD_WEIGHTS`]),
|
||||
+/// read only with `IGNEUM_FAMILY_GATE` set (an unmodified harness such as attack-f8 then draws the family's weights).
|
||||
+fn family_weights_env() -> [(Op, u64); 10] {
|
||||
+ let mut w = NONLOAD_WEIGHTS;
|
||||
+ if std::env::var_os("IGNEUM_FAMILY_GATE").is_some() {
|
||||
+ if let Ok(s) = std::env::var("IGNEUM_FG_WEIGHTS") {
|
||||
+ let v: Vec<u64> = s.split(',').filter_map(|x| x.trim().parse().ok()).collect();
|
||||
+ assert!(v.len() == 10 && v.iter().sum::<u64>() == 75, "IGNEUM_FG_WEIGHTS: ten weights summing to 75");
|
||||
+ for (i, x) in v.into_iter().enumerate() {
|
||||
+ w[i].1 = x;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ w
|
||||
+}
|
||||
+
|
||||
/// Version 1 weights (retired). Sum 100, load at 25 percent.
|
||||
pub const OP_WEIGHTS: [(Op, u64); 11] = [
|
||||
(Op::Load, 25),
|
||||
@@ -1436,8 +1481,9 @@ pub fn candidate_from_words_class(
|
||||
// era set aside) on EVERY draw path, era or not, so a census through candidate_class reads the same stream as
|
||||
// the chain; v2, v3 and every other class take no part. The draw order and the stream are otherwise the same.
|
||||
// class v5 (docs/design/class-v5-stored-state.md) draws under the same rule: its state flag is set aside here too
|
||||
- let source_rule_v4 = matches!(class.shadow, Some(ShadowClass { instrs: V4_SHADOW_INSTRS, .. }))
|
||||
- && LoadClass { era: None, shadow: None, state: false, ..class } == LoadClass { shadow: None, ..V4_CLASS };
|
||||
+ // class v6 family-gate harness (8 October 2026): the same predicate as the acceptance's, generalised to the
|
||||
+ // family's shapes only when IGNEUM_FAMILY_GATE is set (never on a chain path; see accept::is_family_shape).
|
||||
+ let source_rule_v4 = crate::accept::is_class_v4_shape(&class);
|
||||
let mut fresh = [false; 8];
|
||||
let mut fresh_value = [true; 8];
|
||||
// the shared-operand idiom (AP-F8-1, sub-version 3): after `or d |= s`, a later `xor d ^= s` or `sub d -= s` with
|
||||
@@ -1449,7 +1495,7 @@ pub fn candidate_from_words_class(
|
||||
for k in 0..INSTR_COUNT {
|
||||
let mut roll = rng.below(75);
|
||||
let mut op = Op::Add;
|
||||
- for &(o, w) in &NONLOAD_WEIGHTS {
|
||||
+ for &(o, w) in &family_weights() {
|
||||
if roll < w {
|
||||
op = o;
|
||||
break;
|
||||
@@ -1556,7 +1602,7 @@ pub fn candidate_from_words_class(
|
||||
for _ in 0..sh.instrs {
|
||||
let mut roll = rng.below(75);
|
||||
let mut op = Op::Add;
|
||||
- for &(o, w) in &NONLOAD_WEIGHTS {
|
||||
+ for &(o, w) in &family_weights() {
|
||||
if roll < w {
|
||||
op = o;
|
||||
break;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
binary 9b7f764acb5bf1bc commit fg2 test family_gate_era_census from 0 seeds 3000 threads 16 start 2026-10-08T10:23:28Z
|
||||
lease: holding 16 pool cores (80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95, waited 1 s, class measure): family gate: family_gate_era_census lossy seeds 0+3000
|
||||
|
||||
running 1 test
|
||||
test accept::tests::family_gate_era_census ... family_gate_era_census: 100 eras in 95 s
|
||||
family_gate_era_census: 200 eras in 169 s
|
||||
family_gate_era_census: 300 eras in 248 s
|
||||
family_gate_era_census: 400 eras in 324 s
|
||||
family_gate_era_census: 500 eras in 401 s
|
||||
family_gate_era_census: 600 eras in 475 s
|
||||
family_gate_era_census: 700 eras in 551 s
|
||||
family_gate_era_census: 800 eras in 625 s
|
||||
family_gate_era_census: 900 eras in 705 s
|
||||
family_gate_era_census: 1000 eras in 780 s
|
||||
family_gate_era_census: 1100 eras in 853 s
|
||||
family_gate_era_census: 1200 eras in 934 s
|
||||
family_gate_era_census: 1300 eras in 1008 s
|
||||
family_gate_era_census: 1400 eras in 1080 s
|
||||
family_gate_era_census: 1500 eras in 1142 s
|
||||
family_gate_era_census: 1600 eras in 1200 s
|
||||
family_gate_era_census: 1700 eras in 1261 s
|
||||
family_gate_era_census: 1800 eras in 1324 s
|
||||
family_gate_era_census: 1900 eras in 1392 s
|
||||
family_gate_era_census: 2000 eras in 1457 s
|
||||
family_gate_era_census: 2100 eras in 1524 s
|
||||
family_gate_era_census: 2200 eras in 1589 s
|
||||
family_gate_era_census: 2300 eras in 1649 s
|
||||
family_gate_era_census: 2400 eras in 1717 s
|
||||
family_gate_era_census: 2500 eras in 1781 s
|
||||
family_gate_era_census: 2600 eras in 1846 s
|
||||
family_gate_era_census: 2700 eras in 1920 s
|
||||
family_gate_era_census: 2800 eras in 1995 s
|
||||
family_gate_era_census: 2900 eras in 2066 s
|
||||
family_gate_era_census: 3000 eras in 2136 s
|
||||
family_gate_era_census: 3000 eras 0..3000 in 2136 s on 16 threads -> /srv/builds/_adv-family-gate/logs/lossy-family_gate_era_census-0-3000.tsv
|
||||
ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 80 filtered out; finished in 2136.31s
|
||||
|
||||
lease: released 16 pool cores after 2137 s, exit 0
|
||||
end 2026-10-08T10:59:05Z rc 0
|
||||
3001
docs/analysis/class-v6/logs/lossy-family_gate_era_census-0-3000.tsv
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
binary e7a50f8dcf57a14b commit fg5 test family_gate_era_census from 0 seeds 3000 threads 16 start 2026-10-08T10:45:36Z
|
||||
lease: holding 16 pool cores (16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, waited 0 s, class measure): family gate: family_gate_era_census lossybase seeds 0+3000
|
||||
|
||||
running 1 test
|
||||
test accept::tests::family_gate_era_census ... family_gate_era_census: 100 eras in 34 s
|
||||
family_gate_era_census: 200 eras in 65 s
|
||||
family_gate_era_census: 300 eras in 95 s
|
||||
family_gate_era_census: 400 eras in 128 s
|
||||
family_gate_era_census: 500 eras in 160 s
|
||||
family_gate_era_census: 600 eras in 190 s
|
||||
family_gate_era_census: 700 eras in 222 s
|
||||
family_gate_era_census: 800 eras in 253 s
|
||||
family_gate_era_census: 900 eras in 283 s
|
||||
family_gate_era_census: 1000 eras in 316 s
|
||||
family_gate_era_census: 1100 eras in 347 s
|
||||
family_gate_era_census: 1200 eras in 379 s
|
||||
family_gate_era_census: 1300 eras in 410 s
|
||||
family_gate_era_census: 1400 eras in 441 s
|
||||
family_gate_era_census: 1500 eras in 474 s
|
||||
family_gate_era_census: 1600 eras in 505 s
|
||||
family_gate_era_census: 1700 eras in 536 s
|
||||
family_gate_era_census: 1800 eras in 569 s
|
||||
family_gate_era_census: 1900 eras in 600 s
|
||||
family_gate_era_census: 2000 eras in 632 s
|
||||
family_gate_era_census: 2100 eras in 663 s
|
||||
family_gate_era_census: 2200 eras in 695 s
|
||||
family_gate_era_census: 2300 eras in 725 s
|
||||
family_gate_era_census: 2400 eras in 757 s
|
||||
family_gate_era_census: 2500 eras in 788 s
|
||||
family_gate_era_census: 2600 eras in 820 s
|
||||
family_gate_era_census: 2700 eras in 853 s
|
||||
family_gate_era_census: 2800 eras in 883 s
|
||||
family_gate_era_census: 2900 eras in 914 s
|
||||
family_gate_era_census: 3000 eras in 948 s
|
||||
family_gate_era_census: 3000 eras 0..3000 in 948 s on 16 threads -> /srv/builds/_adv-family-gate/logs/lossybase-family_gate_era_census-0-3000.tsv
|
||||
ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 80 filtered out; finished in 947.88s
|
||||
|
||||
lease: released 16 pool cores after 947 s, exit 0
|
||||
end 2026-10-08T11:01:23Z rc 0
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
binary 9b7f764acb5bf1bc commit fg2 test family_gate_era_census from 0 seeds 10000 threads 32 start 2026-10-08T10:23:28Z
|
||||
lease: holding 32 pool cores (24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,72,73,74,75,76,77,78,79, waited 1 s, class measure): family gate: family_gate_era_census rand seeds 0+10000
|
||||
|
||||
running 1 test
|
||||
test accept::tests::family_gate_era_census ... family_gate_era_census: 100 eras in 45 s
|
||||
family_gate_era_census: 200 eras in 86 s
|
||||
family_gate_era_census: 300 eras in 121 s
|
||||
family_gate_era_census: 400 eras in 156 s
|
||||
family_gate_era_census: 500 eras in 197 s
|
||||
family_gate_era_census: 600 eras in 231 s
|
||||
family_gate_era_census: 700 eras in 264 s
|
||||
family_gate_era_census: 800 eras in 300 s
|
||||
family_gate_era_census: 900 eras in 334 s
|
||||
family_gate_era_census: 1000 eras in 370 s
|
||||
family_gate_era_census: 1100 eras in 404 s
|
||||
family_gate_era_census: 1200 eras in 437 s
|
||||
family_gate_era_census: 1300 eras in 472 s
|
||||
family_gate_era_census: 1400 eras in 507 s
|
||||
family_gate_era_census: 1500 eras in 543 s
|
||||
family_gate_era_census: 1600 eras in 579 s
|
||||
family_gate_era_census: 1700 eras in 615 s
|
||||
family_gate_era_census: 1800 eras in 651 s
|
||||
family_gate_era_census: 1900 eras in 690 s
|
||||
family_gate_era_census: 2000 eras in 726 s
|
||||
family_gate_era_census: 2100 eras in 761 s
|
||||
family_gate_era_census: 2200 eras in 796 s
|
||||
family_gate_era_census: 2300 eras in 832 s
|
||||
family_gate_era_census: 2400 eras in 867 s
|
||||
family_gate_era_census: 2500 eras in 903 s
|
||||
family_gate_era_census: 2600 eras in 940 s
|
||||
family_gate_era_census: 2700 eras in 976 s
|
||||
family_gate_era_census: 2800 eras in 1011 s
|
||||
family_gate_era_census: 2900 eras in 1048 s
|
||||
family_gate_era_census: 3000 eras in 1081 s
|
||||
family_gate_era_census: 3100 eras in 1112 s
|
||||
family_gate_era_census: 3200 eras in 1142 s
|
||||
family_gate_era_census: 3300 eras in 1170 s
|
||||
family_gate_era_census: 3400 eras in 1197 s
|
||||
family_gate_era_census: 3500 eras in 1225 s
|
||||
family_gate_era_census: 3600 eras in 1255 s
|
||||
family_gate_era_census: 3700 eras in 1288 s
|
||||
family_gate_era_census: 3800 eras in 1320 s
|
||||
family_gate_era_census: 3900 eras in 1352 s
|
||||
family_gate_era_census: 4000 eras in 1385 s
|
||||
family_gate_era_census: 4100 eras in 1417 s
|
||||
family_gate_era_census: 4200 eras in 1452 s
|
||||
family_gate_era_census: 4300 eras in 1485 s
|
||||
family_gate_era_census: 4400 eras in 1516 s
|
||||
family_gate_era_census: 4500 eras in 1548 s
|
||||
family_gate_era_census: 4600 eras in 1580 s
|
||||
family_gate_era_census: 4700 eras in 1611 s
|
||||
family_gate_era_census: 4800 eras in 1643 s
|
||||
family_gate_era_census: 4900 eras in 1673 s
|
||||
family_gate_era_census: 5000 eras in 1707 s
|
||||
family_gate_era_census: 5100 eras in 1740 s
|
||||
family_gate_era_census: 5200 eras in 1769 s
|
||||
family_gate_era_census: 5300 eras in 1800 s
|
||||
family_gate_era_census: 5400 eras in 1831 s
|
||||
family_gate_era_census: 5500 eras in 1863 s
|
||||
family_gate_era_census: 5600 eras in 1899 s
|
||||
family_gate_era_census: 5700 eras in 1936 s
|
||||
family_gate_era_census: 5800 eras in 1974 s
|
||||
family_gate_era_census: 5900 eras in 2008 s
|
||||
family_gate_era_census: 6000 eras in 2042 s
|
||||
family_gate_era_census: 6100 eras in 2077 s
|
||||
family_gate_era_census: 6200 eras in 2115 s
|
||||
family_gate_era_census: 6300 eras in 2145 s
|
||||
family_gate_era_census: 6400 eras in 2172 s
|
||||
family_gate_era_census: 6500 eras in 2195 s
|
||||
family_gate_era_census: 6600 eras in 2219 s
|
||||
family_gate_era_census: 6700 eras in 2244 s
|
||||
family_gate_era_census: 6800 eras in 2269 s
|
||||
family_gate_era_census: 6900 eras in 2294 s
|
||||
family_gate_era_census: 7000 eras in 2318 s
|
||||
family_gate_era_census: 7100 eras in 2344 s
|
||||
family_gate_era_census: 7200 eras in 2370 s
|
||||
family_gate_era_census: 7300 eras in 2395 s
|
||||
family_gate_era_census: 7400 eras in 2419 s
|
||||
family_gate_era_census: 7500 eras in 2444 s
|
||||
family_gate_era_census: 7600 eras in 2468 s
|
||||
family_gate_era_census: 7700 eras in 2491 s
|
||||
family_gate_era_census: 7800 eras in 2515 s
|
||||
family_gate_era_census: 7900 eras in 2539 s
|
||||
family_gate_era_census: 8000 eras in 2563 s
|
||||
family_gate_era_census: 8100 eras in 2585 s
|
||||
family_gate_era_census: 8200 eras in 2607 s
|
||||
family_gate_era_census: 8300 eras in 2629 s
|
||||
family_gate_era_census: 8400 eras in 2650 s
|
||||
family_gate_era_census: 8500 eras in 2673 s
|
||||
family_gate_era_census: 8600 eras in 2695 s
|
||||
family_gate_era_census: 8700 eras in 2715 s
|
||||
family_gate_era_census: 8800 eras in 2734 s
|
||||
family_gate_era_census: 8900 eras in 2755 s
|
||||
family_gate_era_census: 9000 eras in 2776 s
|
||||
family_gate_era_census: 9100 eras in 2797 s
|
||||
family_gate_era_census: 9200 eras in 2817 s
|
||||
family_gate_era_census: 9300 eras in 2838 s
|
||||
family_gate_era_census: 9400 eras in 2859 s
|
||||
family_gate_era_census: 9500 eras in 2880 s
|
||||
family_gate_era_census: 9600 eras in 2900 s
|
||||
family_gate_era_census: 9700 eras in 2919 s
|
||||
family_gate_era_census: 9800 eras in 2938 s
|
||||
family_gate_era_census: 9900 eras in 2958 s
|
||||
family_gate_era_census: 10000 eras in 2977 s
|
||||
family_gate_era_census: 10000 eras 0..10000 in 2977 s on 32 threads -> /srv/builds/_adv-family-gate/logs/rand-family_gate_era_census-0-10000.tsv
|
||||
ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 80 filtered out; finished in 2976.75s
|
||||
|
||||
lease: released 32 pool cores after 2977 s, exit 0
|
||||
end 2026-10-08T11:13:05Z rc 0
|
||||
10001
docs/analysis/class-v6/logs/rand-family_gate_era_census-0-10000.tsv
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
binary 9b7f764acb5bf1bc commit fg2 test family_gate_era_census from 0 seeds 3000 threads 24 start 2026-10-08T10:23:52Z
|
||||
lease: holding 24 pool cores (8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31, waited 0 s, class measure): family gate: family_gate_era_census shape64 seeds 0+3000
|
||||
|
||||
running 1 test
|
||||
test accept::tests::family_gate_era_census ... family_gate_era_census: 100 eras in 26 s
|
||||
family_gate_era_census: 200 eras in 48 s
|
||||
family_gate_era_census: 300 eras in 73 s
|
||||
family_gate_era_census: 400 eras in 96 s
|
||||
family_gate_era_census: 500 eras in 120 s
|
||||
family_gate_era_census: 600 eras in 144 s
|
||||
family_gate_era_census: 700 eras in 167 s
|
||||
family_gate_era_census: 800 eras in 188 s
|
||||
family_gate_era_census: 900 eras in 211 s
|
||||
family_gate_era_census: 1000 eras in 234 s
|
||||
family_gate_era_census: 1100 eras in 256 s
|
||||
family_gate_era_census: 1200 eras in 281 s
|
||||
family_gate_era_census: 1300 eras in 304 s
|
||||
family_gate_era_census: 1400 eras in 327 s
|
||||
family_gate_era_census: 1500 eras in 350 s
|
||||
family_gate_era_census: 1600 eras in 372 s
|
||||
family_gate_era_census: 1700 eras in 395 s
|
||||
family_gate_era_census: 1800 eras in 418 s
|
||||
family_gate_era_census: 1900 eras in 442 s
|
||||
family_gate_era_census: 2000 eras in 464 s
|
||||
family_gate_era_census: 2100 eras in 488 s
|
||||
family_gate_era_census: 2200 eras in 510 s
|
||||
family_gate_era_census: 2300 eras in 533 s
|
||||
family_gate_era_census: 2400 eras in 556 s
|
||||
family_gate_era_census: 2500 eras in 578 s
|
||||
family_gate_era_census: 2600 eras in 602 s
|
||||
family_gate_era_census: 2700 eras in 625 s
|
||||
family_gate_era_census: 2800 eras in 649 s
|
||||
family_gate_era_census: 2900 eras in 673 s
|
||||
family_gate_era_census: 3000 eras in 695 s
|
||||
family_gate_era_census: 3000 eras 0..3000 in 695 s on 24 threads -> /srv/builds/_adv-family-gate/logs/shape64-family_gate_era_census-0-3000.tsv
|
||||
ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 80 filtered out; finished in 694.93s
|
||||
|
||||
lease: released 24 pool cores after 695 s, exit 0
|
||||
end 2026-10-08T10:35:27Z rc 0
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
binary 71b25e59d765561c commit fg1 test family_gate_era_census from 0 seeds 16 threads 8 start 2026-10-08T10:20:51Z
|
||||
lease: holding 8 pool cores (8,9,10,11,12,13,14,15, waited 0 s, class measure): family gate: family_gate_era_census smoke seeds 0+16
|
||||
|
||||
running 1 test
|
||||
test accept::tests::family_gate_era_census ... family_gate_era_census: 16 eras 0..16 in 22 s on 8 threads -> /srv/builds/_adv-family-gate/logs/smoke-family_gate_era_census-0-16.tsv
|
||||
ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 80 filtered out; finished in 21.91s
|
||||
|
||||
Terminated
|
||||
lease: released 8 pool cores after 22 s, exit 0
|
||||
end 2026-10-08T10:21:13Z rc 0
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
k shape reps mixer width R M pos w_add w_xor w_mul w_mad w_shfl w_rotl w_sub w_mulhi w_rotr w_or attempt candidates a_prime a b c_const c_lane c_sat c_bias c_distinct c1_sat_source c2_low_entropy c3_hot_item min_ratio min_site top_count_max bucket_ratio_max bucket_site bit_z_max bit_site bit secs
|
||||
6 256 27 8 1 18 f5374043 [4, 7, 11, 14] 17 8 4 10 8 8 6 2 4 8 0 1 0 0 0 0 0 0 0 0 0 0 0 0.99925 7 3 2.344 7 256.38 7 18 8.1
|
||||
1 256 27 16 4 6 50b5995b [0, 1, 7, 10] 11 12 11 7 5 7 10 3 3 6 1 2 1 0 0 0 0 0 0 0 0 0 0 0.99936 0 5 2.312 2 -223.84 0 6 8.9
|
||||
3 64 108 8 1 28 0ea25b7d [5, 6, 7, 13] 8 10 13 6 4 3 10 6 9 6 0 1 0 0 0 0 0 0 0 0 0 0 0 0.99989 13 3 2.312 2 -8.62 14 0 8.9
|
||||
4 256 27 4 1 14 b0d09763 [0, 6, 8, 11] 14 12 5 5 6 10 8 5 7 3 0 1 0 0 0 0 0 0 0 0 0 0 0 0.99969 7 4 2.375 1 -3.10 0 24 9.1
|
||||
2 128 54 8 1 10 8833036b [0, 5, 11, 13] 10 9 9 4 6 4 10 5 11 7 6 7 5 1 0 0 0 0 0 0 0 0 0 0.99994 0 4 2.375 7 -3.21 2 20 9.2
|
||||
5 64 108 16 1 26 7f8a4ac1 [0, 9, 10, 13] 12 15 9 13 9 5 5 3 3 1 1 2 1 0 0 0 0 0 0 0 0 0 0 0.99944 7 3 2.438 13 -440.40 7 26 9.6
|
||||
0 128 54 4 4 22 78e9ab5b [0, 1, 3, 12] 12 9 8 5 9 12 3 7 9 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0.99995 4 4 2.375 4 -2.99 0 6 10.3
|
||||
7 256 27 4 1 17 c8678a37 [0, 6, 13, 15] 14 12 6 5 7 9 7 3 7 5 1 2 0 0 0 0 0 0 0 0 0 1 0 0.99544 4 5 13.938 4 -468.09 3 17 12.7
|
||||
8 64 108 4 4 25 71a1775d [0, 1, 7, 15] 17 10 10 7 5 4 5 7 9 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0.99993 10 5 2.312 10 2.97 12 6 7.9
|
||||
12 256 27 8 1 5 da7c079d [3, 5, 6, 12] 14 9 9 9 7 3 7 6 4 7 1 2 1 0 0 0 0 0 0 0 0 0 0 0.99985 6 4 2.375 15 -129.94 5 6 8.4
|
||||
10 256 27 4 4 5 5aa8d8e5 [0, 1, 12, 15] 13 12 11 11 7 4 3 5 4 5 1 2 1 0 0 0 0 0 0 0 0 0 0 0.99992 10 4 2.312 1 2.86 2 23 8.9
|
||||
11 256 27 16 4 30 43f23edb [0, 1, 10, 14] 12 7 11 7 5 3 7 7 10 6 0 1 0 0 0 0 0 0 0 0 0 0 0 1.00001 8 5 2.250 12 -3.27 1 9 9.0
|
||||
13 128 54 16 4 1 62dcc64d [0, 1, 7, 9] 11 8 10 13 8 5 9 6 2 3 1 2 1 0 0 0 0 0 0 0 0 0 0 0.99809 2 5 2.438 13 -254.71 2 2 8.7
|
||||
14 64 108 16 1 29 d740df07 [1, 3, 9, 10] 17 7 8 7 6 7 4 7 11 1 3 4 2 1 0 0 0 0 0 0 0 0 0 0.99989 10 3 2.438 5 -3.73 2 6 8.0
|
||||
9 64 108 8 4 18 da7e862f [0, 1, 2, 8] 15 9 11 5 4 5 2 7 9 8 23 24 17 4 2 0 0 0 0 0 0 0 0 0.99990 9 4 2.188 1 2.37 7 25 9.6
|
||||
15 128 54 8 4 25 b6a91d0d [0, 1, 5, 12] 14 7 9 4 8 8 8 6 7 4 1 2 1 0 0 0 0 0 0 0 0 0 0 0.99654 8 5 4.188 8 -510.81 8 25 9.2
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
binary e7a50f8dcf57a14b commit fg5 test family_gate_era_census from 0 seeds 3000 threads 16 start 2026-10-08T10:43:53Z
|
||||
lease: holding 16 pool cores (8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23, waited 0 s, class measure): family gate: family_gate_era_census w4c seeds 0+3000
|
||||
|
||||
running 1 test
|
||||
test accept::tests::family_gate_era_census ... family_gate_era_census: 100 eras in 55 s
|
||||
family_gate_era_census: 200 eras in 106 s
|
||||
family_gate_era_census: 300 eras in 158 s
|
||||
family_gate_era_census: 400 eras in 211 s
|
||||
family_gate_era_census: 500 eras in 263 s
|
||||
family_gate_era_census: 600 eras in 315 s
|
||||
family_gate_era_census: 700 eras in 369 s
|
||||
family_gate_era_census: 800 eras in 412 s
|
||||
family_gate_era_census: 900 eras in 457 s
|
||||
family_gate_era_census: 1000 eras in 507 s
|
||||
family_gate_era_census: 1100 eras in 557 s
|
||||
family_gate_era_census: 1200 eras in 606 s
|
||||
family_gate_era_census: 1300 eras in 659 s
|
||||
family_gate_era_census: 1400 eras in 725 s
|
||||
family_gate_era_census: 1500 eras in 788 s
|
||||
family_gate_era_census: 1600 eras in 847 s
|
||||
family_gate_era_census: 1700 eras in 907 s
|
||||
family_gate_era_census: 1800 eras in 958 s
|
||||
family_gate_era_census: 1900 eras in 1002 s
|
||||
family_gate_era_census: 2000 eras in 1046 s
|
||||
family_gate_era_census: 2100 eras in 1086 s
|
||||
family_gate_era_census: 2200 eras in 1134 s
|
||||
family_gate_era_census: 2300 eras in 1182 s
|
||||
family_gate_era_census: 2400 eras in 1227 s
|
||||
family_gate_era_census: 2500 eras in 1271 s
|
||||
family_gate_era_census: 2600 eras in 1316 s
|
||||
family_gate_era_census: 2700 eras in 1357 s
|
||||
family_gate_era_census: 2800 eras in 1394 s
|
||||
family_gate_era_census: 2900 eras in 1433 s
|
||||
family_gate_era_census: 3000 eras in 1472 s
|
||||
family_gate_era_census: 3000 eras 0..3000 in 1472 s on 16 threads -> /srv/builds/_adv-family-gate/logs/w4c-family_gate_era_census-0-3000.tsv
|
||||
ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 80 filtered out; finished in 1472.04s
|
||||
|
||||
Terminated
|
||||
lease: released 16 pool cores after 1472 s, exit 0
|
||||
end 2026-10-08T11:08:25Z rc 0
|
||||
3001
docs/analysis/class-v6/logs/w4c-family_gate_era_census-0-3000.tsv
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
binary e7a50f8dcf57a14b commit fg5 test family_gate_era_census from 0 seeds 2000 threads 8 start 2026-10-08T10:45:36Z
|
||||
lease: holding 8 pool cores (8,9,10,11,12,13,14,15, waited 0 s, class measure): family gate: family_gate_era_census w4lossy seeds 0+2000
|
||||
|
||||
running 1 test
|
||||
test accept::tests::family_gate_era_census ... family_gate_era_census: 100 eras in 98 s
|
||||
family_gate_era_census: 200 eras in 193 s
|
||||
family_gate_era_census: 300 eras in 289 s
|
||||
family_gate_era_census: 400 eras in 378 s
|
||||
family_gate_era_census: 500 eras in 479 s
|
||||
family_gate_era_census: 600 eras in 576 s
|
||||
family_gate_era_census: 700 eras in 672 s
|
||||
family_gate_era_census: 800 eras in 767 s
|
||||
family_gate_era_census: 900 eras in 867 s
|
||||
family_gate_era_census: 1000 eras in 952 s
|
||||
family_gate_era_census: 1100 eras in 1002 s
|
||||
family_gate_era_census: 1200 eras in 1052 s
|
||||
family_gate_era_census: 1300 eras in 1101 s
|
||||
family_gate_era_census: 1400 eras in 1148 s
|
||||
family_gate_era_census: 1500 eras in 1198 s
|
||||
family_gate_era_census: 1600 eras in 1247 s
|
||||
family_gate_era_census: 1700 eras in 1297 s
|
||||
family_gate_era_census: 1800 eras in 1345 s
|
||||
family_gate_era_census: 1900 eras in 1393 s
|
||||
family_gate_era_census: 2000 eras in 1444 s
|
||||
family_gate_era_census: 2000 eras 0..2000 in 1444 s on 8 threads -> /srv/builds/_adv-family-gate/logs/w4lossy-family_gate_era_census-0-2000.tsv
|
||||
ok
|
||||
|
||||
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 80 filtered out; finished in 1443.67s
|
||||
|
||||
lease: released 8 pool cores after 1443 s, exit 0
|
||||
end 2026-10-08T11:09:39Z rc 0
|
||||
495
docs/analysis/counter-asic-4-research.md
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
# Counter ASIC 4.0 research: can the chip's per-joule edge be held near 2x without the GPU paying an energy premium?
|
||||
|
||||
7 October 2026, 20:27 to 21:0x UTC (21:27 to 22:0x UK), branch `counter-asic-4`, the Counter ASIC 4.0 research lane. Written for the founder's question of this evening: class v4 (the latency shadow, about 100,000 counted integer operations per hash hidden in the memory wait) brings the strongest chip's per-joule edge over an RTX 5090 from 5x to 9x down to 2.1x to 3.9x, and costs the 5090 145 W at its stock clock and 88 W at a 1,400 MHz core lock for the same hash rate. The brief: find the design that keeps the chip's edge at or under about 2x per joule WITHOUT the GPU paying an energy premium, or prove that no such design exists and say what the floor is.
|
||||
|
||||
Every number carries a label: **measured** (a card on a named night, the file named), **modelled** (arithmetic on the chip model's cited memory figures, `docs/analysis/chip-model-v3.md` section 5), **claimed** (a vendor's or an author's figure, URL given), **approximate** (from memory or a scaling estimate). Nothing in this file changes a consensus object, a pack or a parameter; the one code change (section 12) is a miner-side kernel variant that is opt-in by name and never raced by default.
|
||||
|
||||
## 0. One page
|
||||
|
||||
**The answer: no hash-side design reaches a chip edge of about 2x at zero premium, and the reason is an identity, not a missing idea.** Against the chip that matters (the stored-dataset chip, "a GPU's memory system without the GPU", chip-model-v3 section 5.5) the per-joule edge is
|
||||
|
||||
edge = (E_card + F) / (E_mem + k F)
|
||||
|
||||
where `E_card` is the honest card's whole-card energy per hash on class v3, `F` the premium per hash the card pays for forced work, `E_mem` the chip's memory-system energy per hash (0.466 microjoules on GDDR7, 0.321 on one HBM3 stack, modelled), and `k` the chip core's energy per forced operation over the card's. A lever with `F = 0` is a lever with `k F = 0`: every joule the chip must spend on forced work is a joule the card spends first. So at zero premium the edge is `E_card / E_mem`, which no hash change touches, and the ONLY things that move it are the card's own watts and the chip's memory choice.
|
||||
|
||||
The floor, on tonight's measured rows (the hash lane's efficiency pass, PC 2, 7 October 2026, `docs/plans/counter-asic-3-status.md` on branch ca3-coord, section "the efficiency pass"):
|
||||
|
||||
| Honest card, class v3 (no shadow) | Energy per hash | Chip edge at zero premium, GDDR7 / one HBM3 stack / eight stacks | Label |
|
||||
|---|---|---|---|
|
||||
| RTX 5090, core unlocked (136.59 MH/s at 330.2 W) | 2.42 microjoules | 5.2x / 7.5x / 9.2x | measured card, modelled chip |
|
||||
| RTX 5090, app stock point, 5 October (124 MH/s at 290 W) | 2.34 | 5.0x / 7.3x / 8.9x | measured, modelled |
|
||||
| **RTX 5090, 1,400 MHz core lock (134.68 MH/s at 228.0 W)** | **1.69** | **3.6x / 5.3x / 6.5x** | measured, modelled |
|
||||
| RTX 5090, the knee (the hash lane's second pass, 20:5x UTC): class v3 best at 1,300 MHz, 134.62 MH/s at 223.3 W; class v4 best at 1,200 MHz, 133.80 MH/s at 305.1 W; the premium at the best points 81.8 W; the floor below 1,100 MHz unmeasured (a third pass runs) | 1.66 (v3); 2.28 (v4) | 3.6x / 5.2x / 6.3x on v3; class v4 at k = 1 (6.1 pJ per counted op) 2.1x | measured, modelled |
|
||||
| RTX 5090, the bound any operating point can reach: idle 74 W (PC 2) plus the memory system's 55 W at 17.5 G reads/s, 135 MH/s | 0.96 | 2.1x / 3.0x / 3.7x | approximate (idle measured; the memory share modelled) |
|
||||
| RTX 4070 at its Ember tune point (30.95 MH/s at 79.5 W) | 2.57 | 5.5x / 8.0x / 9.8x | measured, modelled |
|
||||
| RX 9070 XT (about 18.6 MH/s at about 304 W) | 10.6 | 23x / 33x / 40x | approximate card figure |
|
||||
| Apple M5 Max, GPU plus DRAM channels (27.08 MH/s at 21.0 W) | 0.78 | 1.7x / 2.4x / 3.0x | measured, modelled |
|
||||
|
||||
So the premium-free floor against the GDDR7 chip is 3.6x on a 5090 locked at 1,400 MHz, about 2x at the bound set by that card's own idle and memory power, and 1.7x on an Apple GPU today with no shadow at all. The 2x line at zero premium is a property of the honest card's idle, not of the hash. The shadow buys the rest only with a premium, and only if the chip's core is no better than about half a GPU lane per op: at the 1,400 lock the measured premium (88.3 W, 0.654 microjoules, 6.5 pJ per counted op) gives 2.1x at `k = 1`, 2.9x at a core of 3.3 pJ per op, 4.1x at 1 pJ, and below about 1.8 pJ per op (`k` under 0.28) the shadow turns against us, because it then dilutes the card more than the chip.
|
||||
|
||||
**The ranked designs (section 9 has every column):**
|
||||
|
||||
1. **The operating point as the shipped default** (miner-only; core lock at the knee plus undervolt where the driver allows; AMD through ADLX or nothing; Apple has no lever). Premium: none by construction, it lowers `E_card`. Measured: v3 2.42 to 1.69 microjoules, the v4 premium 145 to 88 W, the class v4 edge at `k = 1` 2.1x. 2 to 4 agent hours (already ordered into Ember Tune for 0.3.24). Breaks: a locked card's free shadow shrinks (about 150,000 ops at 1,400 MHz), so ladder rung 2 is not free on it.
|
||||
2. **The SM-sparse miner kernel** (miner-only; the hash on a fraction of the SMs with 32 warps per block and the rest clock-gated). Reads the unmeasured 100 W between the 5090's idle-plus-memory (about 130 W) and its 228 W at the lock; if half is SM activity the premium-free edge falls toward 2.5x. Built tonight as worker variants `sp<N>-w<W>` (section 12), one PC 1 job owed. Breaks if the 100 W is clock tree and leakage.
|
||||
3. **The shadow kept at rung 0, its op mix re-weighted toward the families where a chip gains least over a GPU lane** (shuffle and multiply heavy). Same premium, the pessimistic-core edge 3.4x to about 2.9x, `k = 1` unchanged at 2.1x. 4 to 6 agent hours plus the six gates (a class change under the 95 percent rule). Held until the knee and the SM-sparse rows are read (the coordinator, 21:5x UK).
|
||||
|
||||
Dead by arithmetic (sections 3 to 7): dropping the shadow after class v5 (v5 leaves the chip at 5.1x to 9.1x), the per-window refresh as a cost (a 1 GiB rebuild is about 0.1 J on a chip core), extra reads or longer chains (both sides scale together), memory-system shaping (the same DRAM on both sides), per-card adaptive classes (a chip picks the class that maximises its own reward per joule, so a menu never beats the card's best single class), proof of useful work (the useful fraction is bounded at 8 percent of the hash budget at 1 GH/s and falls with scale; no ZK ASIC has beaten a GPU per joule on real hardware in the public record; every deployed useful-work scheme was gamed), and the tensor block (`k` near 1 but 0.056 pJ per multiply-add, so there are no joules in it to force without 26x the verifier cost).
|
||||
|
||||
## 1. The measured facts this rests on
|
||||
|
||||
| Fact | Value | Label | Source |
|
||||
|---|---|---|---|
|
||||
| RTX 5090 class v3 and v4 across the core-clock grid, the same hash rate | unlocked: v3 136.59 MH/s at 330.2 W, v4 136.84 at 475.5 (premium 145.3 W); 1,400 MHz lock: v3 134.68 at 228.0 W, v4 134.98 at 316.3 (premium 88.3 W); the rate memory-bound and flat from 2,850 to 1,400 MHz (136.8 to 135.0); the knee below 1,400 being read now | measured, PC 2, 7 October 2026 | `docs/plans/counter-asic-3-status.md` (ca3-coord), the efficiency pass table |
|
||||
| The shadow's marginal energy per counted op on the 5090 | 10.4 pJ unlocked (145.3 W over 136.84 M hashes x 102,100 ops); 6.5 pJ at the 1,400 lock (88.3 W over 134.98 M x 102,100); 10.2 to 13.2 pJ on the 6 October ladder under the 431 W cap | measured (arithmetic on measured rows) | the same; `docs/analysis/latency-shadow-2026-10-06.md` section 5 |
|
||||
| The 5090's ALU budget and where the shadow binds | 45.2 T op/s at 3,050 MHz; about 332,000 ops per hash before compute binds at 136 MH/s; at a 1,400 MHz lock the budget scales to about 20.7 T op/s and the bind point to about 150,000 ops | measured budget; the scaling approximate | `docs/benchmarks/repro.md` 2.2; latency-shadow section 5 item 1 |
|
||||
| RTX 5090 idle | 73.8 W at 862 MHz (PC 2, 6 October); 90.6 W (PC 1, 5 October) | measured | latency-shadow section 5; the Ember readbacks in the same file |
|
||||
| RTX 4070 at its tune point (1,860 MHz lock, 160 W cap) | class v3 30.95 MH/s at 79.5 W (2.57 microjoules); class v4 3.51 microjoules, 109 W: the owner pays 30 W more; the rate holds to about 200,000 ops | measured, PC 1, 6 October | status file item 8, the 4070 rows; `docs/analysis/horizon/algorithm.md` 5.1 |
|
||||
| RX 9070 XT | about 18.6 MH/s at about 304 W (10.6 microjoules); the shadow costs it no rate at any rung (+3.6 percent at rung 3); its watts under class v4 OWED | approximate (watts from memory); rate measured | algorithm.md 5.1; `docs/design/latency-ladder.md` section 8 |
|
||||
| Apple M5 Max, GPU plus DRAM channels | class v3 27.08 MH/s at 21.0 W (0.78 microjoules); class v4 at 100,000 ops 1.40 microjoules (+16 W, -1.5 percent of rate); marginal 6.9 pJ per counted op | measured, 6 October | latency-shadow section 3 |
|
||||
| The stored-dataset chip (f = 1) | GDDR7, the 5090's own 16 devices without the GPU: 166.4 MH/s at 77.6 W, 0.466 microjoules; one HBM3 stack 83.6 MH/s at 26.8 W, 0.321; eight stacks 666 MH/s at 174 W, 0.262; a node and executor beside it about 85 W (approximate) | modelled (activate-bound ceilings, 2.0 and 1.2 nJ per random 32-byte read, static and controller allowances) | chip-model-v3 sections 5.3 to 5.5 and 5.10 (the Counter lane's class v5 rows, relayed 20:5x UTC) |
|
||||
| Class v5 at zero shadow | GDDR7 5.1x per joule against the 5090 at 2.40 microjoules (2.5x with a node per chip); one HBM3 stack 7.2x (1.8x with a node per chip); eight stacks 9.1x (6.2x); the leaves ship at 16.5 KB/s to 10,000 members today, so a farm shares one node | modelled | chip-model-v3 section 5.10; `docs/design/class-v5-stored-state.md` 2a.2 |
|
||||
| The tensor-shaped block (mm8) on an RTX 4090 | free in rate to 4,096 tiles per hash; 2.9 to 14.7 W; 0.70 falling to 0.056 pJ per multiply-add; verifier +1.1 microseconds per step per unit scalar | measured, 6 October | `docs/analysis/horizon/new-pow.md` 5.1 |
|
||||
| Proving on the 5090 | a shard at the adopted v1 budget (4.7 M SP1 cycles) 4.3 s alone, 13.2 s beside the miner; the prover alone peaks at 190.6 W on empty shards; a GPU-proved block's useful fraction bound 8 percent of network hashes at 1 GH/s | measured; the bound is arithmetic | `docs/bench-log.md` "proving v1" and "the S_p curve"; new-pow section 6 |
|
||||
| The verifier gate | class v4 rung 0 8.77 ms cold with the sibling loaded on the reference core (5.14 alone); rungs 0 to 2 admissible, rung 3 out by 0.08 ms; class v5 adds 0.20 to 0.28 ms | measured, igneum-build-1, 6 and 7 October | latency-ladder section 5; class-v5 section 7 |
|
||||
|
||||
## 2. The identity, and what it forbids
|
||||
|
||||
Write the card's energy per hash on class v3 as `E_card = E_idle + E_memsys + E_wait`: the idle floor spread over the hashes (74 W over 135 MH/s is 0.55 microjoules on the 5090), the memory system's own reads and standby (about 55 W, 0.41 microjoules, modelled), and the SMs, fabric and clock tree while they wait on loads (the rest: 228 - 74 - 55 = 99 W, 0.73 microjoules at the lock, unmeasured as a breakdown). The chip pays `E_mem` (its memory, controller and static: the whole 0.466) and nothing else. Add forced work `W` ops per hash at `e_gpu` per op on the card and `e_chip` on the chip, `k = e_chip / e_gpu`, `F = W e_gpu`:
|
||||
|
||||
| Quantity | Formula | 5090 at the 1,400 lock, GDDR7 chip | 5090 unlocked, GDDR7 | 5090 lock, one HBM3 stack |
|
||||
|---|---|---|---|---|
|
||||
| Edge at zero premium | `E_card / E_mem` | 3.63x | 5.19x | 5.27x |
|
||||
| Edge with the premium `F` | `(E_card + F) / (E_mem + k F)` | at F = 0.654 (88 W): 2.07x at k = 1 (e_chip 6.5 pJ); 2.92x at 3.3 pJ; 3.50x at 2.0 pJ; 4.12x at 1.0 pJ | at F = 1.062 (145 W): 2.27x at k = 1 (10.4 pJ); 4.33x at 3.3 pJ; 6.12x at 1.0 pJ | 2.38x at k = 1; 3.56x at 3.3 pJ; 5.54x at 1.0 pJ |
|
||||
| The asymptote at infinite premium | `1 / k` | 1x at k = 1, 2x at k = 0.5 | the same | the same |
|
||||
| The premium that reaches 2x at k = 1 | `E_card - 2 E_mem` | 0.761 microjoules, 103 W | 1.486, 203 W | 1.051, 142 W |
|
||||
| The premium that reaches 2x at k at or under 0.5 | none | | | |
|
||||
| The slope at F = 0 | `(E_mem - k E_card) / E_mem^2` per microjoule | k = 1: -5.65x per microjoule; k = 0.5: -1.75; k = 0.3: -0.19; k = 0.275: 0 | k = 1: -9.0; k = 0.19: 0 | k = 1: -13.3; k = 0.19: 0 |
|
||||
|
||||
Three consequences, each labelled arithmetic on the rows above:
|
||||
|
||||
1. **Zero premium is zero forcing.** `F = 0` gives `k F = 0` for every `k`. A design that costs the GPU nothing extra costs the chip nothing extra. The only exceptions would be levers with an asymmetry the identity does not see: work the card already pays for in `E_idle + E_wait` that the chip does not pay for. Sections 4 and 5 look for one and find none that a consensus rule can check (the GPU's L2 is the nearest, section 4 row 5, and it costs rate).
|
||||
2. **The shadow's worth is a bet on `k`, and the bet gets narrower at the knee.** At the 1,400 lock the shadow buys 1.5x of edge at `k = 1`, 0.7x at a 3.3 pJ core, nothing at 1.8 pJ, and below that it costs us. The reference the project has used for a pessimistic core (Bitmain's withdrawn X9 at about a third of a desktop CPU's energy per RandomX hash, claimed; latency-ladder section 5a) is a ratio against a CPU core, and a CPU core spends about 100 pJ or more per instruction against a GPU lane's 6.5 to 10.4 pJ measured here (Horowitz 2014 gives 70 pJ per in-order instruction at 45 nm, Dally 2023 gives 250 pJ per out-of-order ARM instruction; both claimed). A chip three times better than a CPU core on a random program is a chip WORSE than a GPU lane per op. So the X9 implies nothing about `k` against a GPU; the honest band for a chip maker who builds a SIMD array like a GPU's is `k` 0.5 to 1 (approximate), with the N5 fixed-datapath floor (0.19 pJ of datapath before register file, operand wires and instruction fetch; chip-model-v3 5.1) as the theoretical bound that nobody has shown on a random 32-lane program with shuffles. The honest headline stays 2.1x at `k = 1`, and the project should stop quoting the X9 as a `k` of 0.33 against a GPU.
|
||||
3. **What moves the premium-free floor is `E_card`, and the hash cannot touch it.** The 5090's own idle (74 to 91 W measured) plus its memory system (55 W modelled) bound the floor near 2.0x on GDDR7 at any operating point; the Apple GPU at 21 W shows a GPU designed for low idle sitting at 1.7x with no shadow. The measurements that read how much of the 99 W of `E_wait` is reachable are the knee sweep (running) and the SM-sparse kernel (section 12).
|
||||
|
||||
## 3. Thread 1: class v5 with the shadow at zero
|
||||
|
||||
The question: once class v5 is live (the dataset built from the chain's execution state, refreshed per window), does the shadow still buy anything? Re-run of the chip model with v5 on and the shadow at zero (ladder rung below 0, which is class v3's energy, since no rung below 0 exists and dropping the shadow is the class object moving back to v3 under the 95 percent rule):
|
||||
|
||||
| Chip under class v5, shadow at zero | Rate, watts | Energy per hash | Edge over the 5090 at 2.40 microjoules (the model's denominator) | Edge at the 1,400 lock (1.69) | With a node and executor per chip (85 W, approximate) | Label |
|
||||
|---|---|---|---|---|---|---|
|
||||
| GDDR7, 16 devices | 166 MH/s at 78 W | 0.466 | 5.1x | 3.6x | 0.977 microjoules: 2.5x (1.7x at the lock) | modelled (the Counter lane, chip-model-v3 5.10) |
|
||||
| HBM3, one stack | 84 MH/s at 28 W | 0.321 | 7.2x | 5.3x | 1.34: 1.8x (1.3x) | modelled |
|
||||
| HBM3, eight stacks | 666 MH/s at 175 W | 0.262 | 9.1x | 6.5x | 0.389: 6.2x (4.3x) | modelled |
|
||||
|
||||
What v5 adds to the chip's bill is the window's leaves, 64 bytes per state record, 5,952 bytes at today's devnet state and at most the dataset's size at the sample cap (class-v5 2a.2), delivered over a link: 16.5 KB/s to 10,000 members today; the WAN line is about 700,000 state records (45 MB per member per hour at 1 Gbit/s), a LAN farm is never bounded. So the "node per chip" column is not reachable by construction: one node serves a farm, as the v5 design states itself. The arithmetic the founder asked for: under v5 alone the strongest chip keeps 5.1x (GDDR7) to 9.1x (eight HBM3 stacks) at the stock point and 3.6x to 6.5x at the lock, all over 2x. **The answer is not "drop the shadow after v5."** What v5 does remove is the f = 0 recompute chip as a category and the pool miner who holds nothing but the day key (class-v5 section 1); its merit is there, not against the memory-system chip.
|
||||
|
||||
A construction that WOULD force a node per card does not exist below the state size: everything the lottery derives is derived from a seed and the state, so the only bytes a central node cannot compress away are the state's, and today's state is 6 KB. The refresh cadence does not help either (section 4 row 7).
|
||||
|
||||
## 4. Thread 2: energy-shaped work instead of op-shaped work
|
||||
|
||||
The brief's hypothesis: some operations cost a GPU almost nothing extra in energy because the hardware already pays for them during the memory wait. The identity says what to look for: an operation class with `k` as high as possible (a chip can improve on the GPU least) at a cost `e_gpu` that is REAL joules (so the forcing exists). A class with tiny `e_gpu` forces nothing; a class with tiny `k` forces the card and spares the chip. Per class, the figures:
|
||||
|
||||
| Operation class | Card's marginal energy per op | A chip's cost to match it | k band | Premium per 0.1x of edge bought at the 1,400 lock, GDDR7 (from the slope at F = 0) | Verifier cost | Reading | Labels |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Random 32-bit integer ALU program (the class v4 shadow: add, xor, mul, mad, rotates, sub, mulhi, or) | 5090: 10.4 pJ unlocked, 6.5 pJ at the 1,400 lock; M5 Max 6.9 pJ | a SIMD array at N5: 0.19 pJ of datapath plus register file, operand network and instruction fetch; the model's columns 3.3 pJ (k 0.3 of 11) to 11 pJ | 0.5 to 1 honest band; 0.15 to 0.3 the attacker's claim (unshown) | k = 1: 0.018 microjoules (2.4 W) per 0.1x; k = 0.5: 0.057 (7.7 W); k = 0.3: 0.53 (71 W) | 3.2 microseconds per 1,000 shadow instructions per warp on the M5 Max core; rung 0 fits with 1.2 ms to spare on the loaded reference core | the right lever IF k is near 1; its value collapses below k = 0.5 and reverses at 0.28 | card measured; chip approximate; slope arithmetic |
|
||||
| Shuffle-heavy mix (shfl, shfla through the warp crossbar) | the shfl step 0.86x of the add-xor-rotate chain on the M5 Max, 1.13x for rotr; the 5090's per-family step costs in the item 6 record; AMD shfla 0.75 to 0.84 | a crossbar is the same wire physics on any die; the algorithm lane's k floor rises from 0.32 to 0.46 with a shuffle-heavy weight table (approximate) | 0.46 to 1 | at k = 0.46: 0.075 microjoules (10 W) per 0.1x | the same law; shfl is one op in the interpreter | the content to prefer inside the shadow: the same premium, less of it reaches a chip | measured steps; k floor approximate (algorithm.md 5.3) |
|
||||
| 32-bit multiply heavy | a 32 x 32 multiplier is 0.52 pJ at N5 datapath on both sides (claimed, mlsysbook citing Horowitz and Dally); the GPU's overhead around it is the operand delivery | the same multiplier | about 0.5 to 1 | as the shuffle row | one op | second choice behind shuffles; the saturation and lossy-source rules of sub-version 3 already bound what a multiply can do to the read map | claimed figures |
|
||||
| Tensor-shaped int8 tiles (mm8) | 0.056 pJ per multiply-add at 4,096 tiles per hash on the 4090 (57 pJ per 1,024-MAC tile); 14.7 W in all | a licensable int8 MMA block: the same circuit | 0.5 to 1 | to reach the shadow's 0.654 microjoules the block needs about 11,500 tiles per hash (R about 1,450); the 4090 binds near R 3,000 | scalar 1.1 microseconds per step per unit: about 10.6 ms at R = 1,450 on the M5 Max core, FAILS the gate alone; a byte-dot verifier (AVX-VNNI, NEON udot, AVX2 pmaddubsw) 4x to 16x cheaper, approximate, unmeasured | k is good and the joules are too cheap: no forcing without the verifier paying 26x per joule forced (new-pow 5.1 item 3). Kept as reserve R8 for datapath diversity | measured block; verifier scaling approximate |
|
||||
| L2-resident hot reads (a second table sized to GPU L2, read beside the dataset) | an L2 hit on a 96 MB L2: about 0.1 to 0.3 nJ per 32-byte sector (approximate: Horowitz's 1 MB cache at 100 pJ per 64 bits at 45 nm, scaled to N5 and a 96 MB array with its crossbar); measured in the added form the table cost the 5090 13 to 16 percent of RATE because the dataset stream evicts it (5 October, no cache-policy hints) | a 64 MiB SRAM read on a 128 mm^2-class array: 0.2 to 0.5 nJ (chip-model-v3 5.1, approximate) | 0.7 to 3: the one class where a chip may be WORSE than the GPU per op, because a big SRAM array is not cheaper than a GPU's L2 slice | at k = 1.5: 0.008 microjoules (1 W) per 0.1x, if the hot reads were free in rate | the hot table's items are derived lazily on the CPU (unmeasured per unit) | worth ONE measurement, not a claim: the 5 October rows used no cache hints; CUDA `createpolicy.fractional.L2::evict_last` for the hot table and `ld.global.L2::evict_first` for the dataset stream, AMD's slc bits; nothing on Metal, so Apple pays rate. If the rate holds with hints, this is the only lever with k possibly over 1 | approximate throughout |
|
||||
| Dependent pointer chasing: more reads per hash, a longer chain | the card's incremental energy per read about 3.1 nJ (55 W over 17.5 G reads/s, modelled); its overhead power is per second, so a longer chain lowers both rates together | 2.0 nJ per read | the edge is unchanged: both sides' energy per hash scale by the same factor (E_card and E_mem are both per-second powers over a rate that moves together) | none bought at any premium | +0.13 ms per 128 reads per unit, approximate | not a lever (chip-model-v3 5.7 row "latency") | modelled |
|
||||
| Extra random reads at the same bandwidth (wider loads, w16, w32) | the 5090 fetches a 32-byte sector per 4-byte read already; w16 moved the 5090 2.7 percent and the chip not at all; w64 made the 5090 bandwidth-bound (71.9 MH/s) | the same 32-byte atom per channel access | 1 | nothing to force: the chip already pays the sector | none | not a lever; the 4-byte decision stands | measured (read-width) |
|
||||
| Per-lane live state (a scratch, register-resident permutations across the hash) | a chip keeps 1,172 lanes at 55 ns of controller latency against the 5090's 7,262 at 415 ns; lane state is 64 B to 320 B | SRAM at picojoules per access | not applicable: lane state is not an energy term on either side | none | | `docs/analysis/scratch-soundness.md`: the live state is bounded by the read-modify-write count and sits in a chip's SRAM at under 5 percent of its mirror | measured bound |
|
||||
| The dataset refresh as the cost (class v5's rebuild per window, per epoch or per block) | a 1 GiB rebuild is 2^24 items at about 9,360 ops each, 157 G ops: 32 ms on a 4090, 13.4 ms on the 5090 | 157 G ops at 1 to 3 pJ per op is 0.16 to 0.5 J per rebuild; a rebuild per block is 0.16 to 0.5 W against 78 W of hashing; the write is 1 GiB per second against 1.8 TB/s | the refresh is 0.1 percent of the chip's item traffic | nothing | a rebuild per second costs the honest 4090 3 percent of its hash time (rate, not joules) | dead by arithmetic; the refresh cadence is a liveness tool (proof of following), not an energy lever | arithmetic on measured build times and approximate chip energies |
|
||||
|
||||
What the table says: the founder's instinct is right that the shadow spends energy because it uses ALUs, and the public energy figures agree on why a GPU lane is expensive per op (Dally, Hot Chips 2023, claimed: fetch, decode and operand delivery about 30 pJ per lane instruction at 45 nm against 0.1 pJ for the add itself; a tensor instruction amortises that overhead over 1,024 multiply-adds, which is exactly why the mm8 block is cheap for the card and useless as a forcing lever). The classes where the card's energy is "already paid" during the wait (lane state, in-flight reads, the memory burst) are classes where the chip's cost is zero or equal, so they force nothing. The one class where a chip might pay more than the GPU per op is the GPU's own L2, and the one measurement of it lost rate; a cache-hinted re-run is the open item. Everything else reduces to choosing the shadow's content for the highest `k` at a fixed premium, which is design 3.
|
||||
|
||||
## 5. Thread 3: memory-system binding beyond random reads
|
||||
|
||||
Row-buffer-miss patterns, bank-conflict shaping, refresh-aware access and burst-sized items were each checked against the identity. The hash already opens a row per read (random 4-byte reads over 1 GiB: a row hit rate near zero on both sides); a 32-byte item is already the GDDR7 channel burst (the 5090 measures a 32-byte sector per read; the 9070 XT a 64-byte line, which is AMD's cost, not the chip's); bank parallelism is the same 1,024 banks on the 5090's board and on a chip built from the same 16 devices; refresh is the DRAM's. **There is no asymmetry to shape: the chip and the card have the same DRAM, the same row cycle (tRC about 45 ns across DDR4, GDDR and HBM; Li, Reddy and Jacob, MEMSYS 2018, claimed) and the same activate window.** The two levers a memory system offers are both the chip's: a lower energy per read (HBM's 1.2 nJ against GDDR7's 2.0 through 0.8 pJ per bit of interposer I/O against a PCB, modelled; fine-grained DRAM would cut the 909 pJ activation further, O'Connor et al. MICRO 2017, https://www.cs.utexas.edu/~skeckler/pubs/MICRO_2017_Fine_Grained_DRAM.pdf, claimed) and more activates per second per watt (AMD's "Folded Banks", ISCA 2025, 6.7x the irregular bandwidth from 8x the activate parallelism, https://dl.acm.org/doi/10.1145/3695053.3731111, claimed). Both move `E_mem` down, so the premium-free edge can only rise with memory generations, which is the HBM4 point the Horizon lane made (algorithm.md proposal 5).
|
||||
|
||||
What the Ethash and RandomX record measured (the literature sub-agent's pass tonight, 7 October 2026; every figure claimed by its vendor unless marked):
|
||||
|
||||
| Algorithm, what it relied on | The chip, its date and figures | The GPU at the time | Chip edge per joule | Where the resistance failed | Sources |
|
||||
|---|---|---|---|---|---|
|
||||
| Ethash: 4 GB+ DAG, 64 random 128-byte reads per hash, bandwidth-bound | Antminer E3 (Jul 2018) 190 MH/s at 760 W; Innosilicon A10 Pro (2020) 500 at 950; Linzhi Phoenix (Dec 2020) 2,733 MH/s at about 3,000 W (F2Pool test, measured); Jasminer X4 (Nov 2021) 2,500 at 1,200; Antminer E9 Pro (2023) 3,680 at 2,200; Jasminer X16-P (2024) 5,800 at 1,900 | RTX 3080 about 100 MH/s at 220 W (0.45 MH/W, measured) | E3 0.55x; A10 Pro 1.2x; Phoenix 2.0x; X4 4.6x; E9 Pro 3.7x; X16-P 6.8x | the chips won by moving the same bytes at lower energy per bit (custom controllers, on-package and on-die memory: Linzhi's 72 mixers and 2.8 Tb/s of internal memory); nothing in the hash's logic | https://www.theblock.co/post/88622/questions-new-ethash-asic-ethereum ; https://f2pool.io/mining/pow-round-up/20201228-pow-round-up/ ; https://www.asicminervalue.com/miners/jasminer/x4 ; https://pool.kryptex.com/ms/device/asic/jasminer/x16-p ; https://ethereum-magicians.org/t/progpow-audit-delay-issue/3309?page=4 |
|
||||
| RandomX: 2 GiB dataset, one 64-byte read per iteration, 8 chained random programs, CPU-shaped | Antminer X5 (Sep 2023) 212 kH/s at 1,350 W; Antminer X9 announced 26 Dec 2025 at 1,000 kH/s and 2,472 W, withdrawn May 2026 before any unit shipped; Pinecone R1X 1,200 kH/s at 2,055 W (unverified) | Ryzen 9 3950X 12.9 kH/s at 105 W (measured); GPUs 18x worse per joule than the CPU | X5 1.3x; X9 3.3x (claimed, never shipped); R1X 4.7x (unverified) | against its honest device the latency-bound random program held a chip to 1.3x shipped and 3x to 5x claimed after seven years; RandomX v2 testnet forked 5 October 2026 | https://github.com/tevador/RandomX/blob/master/doc/design.md ; https://pool.kryptex.com/articles/antminer-x5-en ; https://github.com/monero-project/monero/issues/10270 ; https://oneminers.com/blogs/news/whatever-happened-to-the-antminer-x9-bitmain-monero-miner |
|
||||
| ProgPoW and KawPoW: random math per period on the GPU's datapath, 256-byte DAG loads | none shipped as of 2026 | RTX 4090 65 MH/s at 330 W | the authors' claim 1.1x to 1.2x; Rao's audit (Sep 2019): conventional chips gain little, memory-intensive algorithms face an "imminent threat" in general | the resistance held; the energy per hash on GPUs is the price (KawPoW 0.2 MH/W against Ethash 0.45 on the same cards) | https://github.com/ifdefelse/ProgPOW/blob/master/README.md ; https://leastauthority.com/?p=759 |
|
||||
| Equihash 200,9: a 144 MB sort | Antminer Z9 mini (May 2018) 10 kSol/s at 247 to 300 W; Z15 (Jun 2020) 420 kSol/s at 1,510 W; Z15 Pro (2023) 840 kSol/s at 2,780 W | Vega 64 506 Sol/s at about 250 W | 17x to 151x | the working set fitted on-die and the sort pipelines; Zcash voted 45 to 19 against resistance (Jun 2018) | https://overclock3d.net/news/misc/bitmain-launches-their-antminer-z9-mini-zcash-mining-asic/ ; https://support.bitmain.com/hc/en-us/articles/19259431024281 |
|
||||
| Cuckatoo32: a 512 MB SRAM random walk | iPollo G1 (Dec 2020) 36 graphs/s at 2,800 W | RTX 3090 1.0 to 1.43 graphs/s at 285 W | 2.6x to 3.7x | the random walk stayed latency-bound even in SRAM: the smallest edge of any compute-shaped chip | https://pool.kryptex.com/device/asic/ipollo/g1 ; https://forum.grin.mw/t/mining-hardware-comparision/9011 |
|
||||
| kHeavyHash, Blake3, SHA (compute only) | IceRiver KS0 to Antminer KS5 Pro (2023 to 2024); Antminer AL1 (2024) | RTX 4090 | 160x to 700x | nothing memory-bound to hold them | https://asicminervalue.com/miners/bitmain/antminer-ks5-pro-21th ; https://pool.kryptex.com/device/asic/bitmain/antminer-al1 |
|
||||
|
||||
The reading for Igneum: Igneum's hash is in the Ethash and RandomX class (DRAM-bound), and the Ethash chips' 2x to 7x is exactly `E_card / E_mem` with a better memory system; the Cuckatoo and RandomX rows show what latency-bound work does to a chip's logic edge (3x against a CPU, under 1.3x shipped). The identity predicts both.
|
||||
|
||||
## 6. Thread 4: per-card adaptive work
|
||||
|
||||
The question: could the protocol admit per-class programs where the shadow is scaled to the card's own memory-to-compute ratio, without letting a chip pick the cheapest? **No, and the proof is one line.** Let the menu be a set of classes `c`, each with a difficulty weight `w(c)` (reward per hash under that class). An honest card's best reward per joule is `max_c w(c) / E_card(c)`, a chip's is `max_c w(c) / E_chip(c)`. The chip's edge under free choice is the ratio of the two maxima. Since the chip may pick the card's own best class `c*`, `max_c w(c) / E_chip(c) >= w(c*) / E_chip(c*)`, so
|
||||
|
||||
edge(menu) >= w(c*) / E_chip(c*) over w(c*) / E_card(c*) = edge(c*),
|
||||
|
||||
the edge at the card's best single class. A menu can never beat the best single class for the honest card, and whenever any class on the menu is cheaper for the chip than `c*` is, the menu is worse. The chain cannot verify hardware, only values, so no rule can bind a class to a card type (new-pow 3.2 makes the same point about timings and capacities). The ladder (`docs/design/latency-ladder.md`) is the construction that survives this: one network-wide `N`, stepped by miner signal within a verifier-bounded genesis list, which the attack-pass lane's F10 row gated (monotone, 89 percent does not move it, no two-step jump). What a per-card ratio CAN do is sit in the miner, not the protocol: a card picks its own clock, grid and kernel shape (designs 1 and 2) for the one network class.
|
||||
|
||||
## 7. Thread 5: proof of useful work
|
||||
|
||||
Igneum's miners are its provers. Could the proving replace or fund the shadow?
|
||||
|
||||
| Quantity | Value | Label | Source |
|
||||
|---|---|---|---|
|
||||
| Energy per proven shard on the 5090 tonight (the adopted v1 budget, 4.7 M SP1 cycles) | 4.3 s alone at up to 190.6 W: at most about 820 J per shard, at least about 5,700 cycles per joule (174 microjoules per cycle); beside the miner 13.2 s | measured time and the prover's peak watts (the watts for the v1 shard itself unmeasured, the empty-shard peak is the bound) | bench-log "the S_p curve" and "proving v1 step 1" |
|
||||
| The hash for comparison | 590,000 hashes per joule at the 1,400 lock | measured | section 1 |
|
||||
| What the chain needs proven | 1 block per second, 2 shards per block at the v1 budget: about 1.6 kW of 5090 proving network-wide, independent of the hash rate | arithmetic | the same |
|
||||
| The useful fraction of a proving-as-lottery scheme | proving work per segment over network hashes per segment: 8 percent at 1 GH/s, 0.08 percent at 100 GH/s; gas sets the numerator, the security budget the denominator; a verifier that recomputes the piece or checks a 32 to 40 ms proof cannot sit under the 10 ms gate | arithmetic on measured figures | new-pow sections 3.1 and 6 (scheme A, NEVER) |
|
||||
| Public GPU zkVM proving rates | Airbender 21.8 MHz of RISC-V cycles on an H100 and 9.7 MHz on a 4090 (about 16 to 46 J per million cycles at TDP); SP1 Turbo 3.45 MHz on an H100; RISC Zero 1.1 MHz; SP1 Hypercube 16 RTX 5090s for 99.7 percent of Ethereum blocks under 12 s (Nov 2025), "12.5x GPU efficiency in 6 months" | claimed | https://zksync.io/airbender ; https://blog.succinct.xyz/real-time-proving-16-gpus/ |
|
||||
| Proving chips | Cysic ZK Air "comparable to 10 RTX 4090", ZK Pro "50", no watts published, shipping slipped to 2026; Ingonyama's ZPU paper model "about 13x the efficiency of an A40" with no silicon; Fabric Cryptography's VPU "orders of magnitude", no number, no watts, still pre-order; academic ASICs (SZKP, NoCap, UniZK, zkSpeed) 46x to 800x against CPUs or a 2017 V100, all simulation, bandwidth-hungry (2 to 3 TB/s of HBM) | claimed | https://docs.cysic.xyz/hardware-products/zk-asic-products/ ; https://www.ingonyama.com/oldblogs/zpu-the-zero-knowledge-processing-unit ; https://www.fabriccryptography.com/ ; https://arxiv.org/html/2408.05890v1 ; https://arxiv.org/html/2504.06211v1 |
|
||||
| The one measured non-GPU ZK data point | Jane Street's Hardcaml MSM, ZPrize FPGA winner: 2^26 MSM in 5.08 s at 52 W, about 264 J; the GPU winner on an A40 about 0.56 s per MSM, at most about 170 J at TDP: the FPGA lost on energy | measured times; energy approximate | https://blog.janestreet.com/zero-knowledge-fpgas-hardcaml/ ; https://github.com/matter-labs/z-prize-msm-gpu-combined |
|
||||
| What happened to useful-work schemes | Aleo dropped PoSW consensus after "a small number of provers developed specialized hardware" and rewrote the puzzle when miners optimised MSM/NTT (2022 to 2024); Qubic's rented CPUs took 27 to 51 percent of Monero's hash and reorged it (Aug 2025); Boundless PoVW's token fell 96 percent from its high and RISC Zero closed its hosted prover (Dec 2025); Pearl cuPOW's 112 MW did "zero useful AI computation" | measured events | https://equilibrium.co/writing/aleo-mainnet-launch-reflecting-on-the-journey ; https://aleo.org/post/prover-incentives-2-retrospective/ ; https://www.theblock.co/post/364496/qubics-monero-hashrate-controversy ; https://blockeden.xyz/blog/2026/01/14/boundless-risc-zero-decentralized-proof-market-zk/ ; https://arxiv.org/abs/2606.04819 |
|
||||
|
||||
Does it change the question? **No, in two ways.** (1) The useful fraction is bounded by gas, not by the hash budget: at any scale past 1 GH/s the chain cannot spend its security budget on proving because there is not enough proving to spend it on, and the per-hash verifier cannot check a proof's piece under 10 ms. (2) Proving is compute-shaped (NTT, hashing over 31-bit fields, MSM), the shape in which chips reach 46x to 800x in simulation and the shape that Aleo's provers specialised against; making the lottery out of it hands the chip the lottery. Proving chips do not exist as measured products today (no vendor publishes watts; the one measured FPGA lost to the GPU), and the public GPU proving software moved 12.5x in six months, which a fixed pipeline cannot follow; so for 2026 to 2028 a proving chip's credible edge over a 5090 is 5x to 30x per joule (approximate, from the simulation claims against obsolete GPUs discounted by the software trend), which is worse than the hash's 2.1x to 3.6x. The 80/20 split stands; the energy spent on proving is defensible only because paying users demand the proofs.
|
||||
|
||||
## 8. Thread 6: the literature since 2023
|
||||
|
||||
| Item | What it did | Cost to GPUs in energy | What chips did | Date, source |
|
||||
|---|---|---|---|---|
|
||||
| Kaspa's chip turn | kept kHeavyHash; "ASIC resistance only delays the unavoidable" | none (the GPUs left) | IceRiver KS0 162x per joule over a 4090, Antminer KS5 Pro 700x; GPU share negligible within months | 2023 to 2024; https://kaspa.org/?p=45054 ; https://www.asicminervalue.com/miners/iceriver/ks0 |
|
||||
| Karlsen, Iron Fish to FishHash (Ethash-derived, 4.6 to 5 GB DAG) | the Kaspa forks that wanted GPUs back took an Ethash-class DAG | 4090 82.5 MH/s at 260 W (0.32 MH/W, measured) | none has followed; the Ethash chips' 2x to 7x is the precedent waiting | Apr and Sep 2024; https://pool.kryptex.com/articles/ironfish-hardfork-en |
|
||||
| Alephium Blake3 | compute | n/a | Antminer AL1 about 200x per joule | 2024; https://pool.kryptex.com/device/asic/bitmain/antminer-al1 |
|
||||
| Autolykos v2 (Ergo) | a 2 GB table growing 5 percent per 51,200 blocks to about 66 GB | 4090 265 MH/s at 240 W (1.1 MH/W) | none through 2026 | https://docs.ergoplatform.com/mining/asic/ |
|
||||
| NexaPoW (Schnorr per attempt) | "useful ASICs" | 4090 0.84 MH/W | Dragonball A21 1.89 MH/W: 2.2x | Dec 2024; https://pool.kryptex.com/articles/dragonball-a21-en |
|
||||
| XelisHash v2 and v3 | ChaCha8 plus Blake3 scratchpad, sequential | GPU-mined | none; forked v2 for FPGA resistance (Jul 2024), v3 Dec 2025 | https://github.com/xelis-project/xelis-hash |
|
||||
| RandomX v2.0 | released 25 to 30 Mar 2026, testnet fork 5 Oct 2026 | CPU-mined | the response to the X5 and the announced X9 | https://www.coinpro.ch/en/?p=42081 |
|
||||
| PoSME (Condrey, arXiv 2604.15751, 17 Apr 2026; IETF draft May 2026) | latency-bound pointer chasing, 32 MiB to 2 GiB arena; claims an HBM3 bound of 1.6x to 2.0x, wafer-scale SRAM 5x to 28x, verifier about 6 ms | GPUs 14x to 19x slower than a CPU (claimed) | no deployment | https://arxiv.org/abs/2604.15751 |
|
||||
| Bandwidth-hard functions from random permutations | formal bandwidth hardness | theory | | https://arxiv.org/pdf/2207.11519 (2022) |
|
||||
| Qubic uPoW on Monero | rented CPU fleets at 27 to 51 percent of hash | economic, not silicon | a six-block reorg, 12 Aug 2025 | https://coinmetrics.substack.com/p/state-of-the-network-issue-326 |
|
||||
|
||||
Nothing since 2023 found a lever outside the identity. The latency-bound family (RandomX, PoSME, Cuckatoo, Igneum) holds a chip's LOGIC edge to about 1.3x to 3x against the honest device; the memory-side edge is `E_card / E_mem` everywhere, and the one project that has lived with it longest (Monero) answers with re-tunes, not with a trick.
|
||||
|
||||
## 9. The ranked list of designs
|
||||
|
||||
Each row: (a) the chip's per-joule edge over the 5090 with its labels, (b) the 5090's and the 4070's energy premium over class v3, (c) the verifier cost against the 10 ms gate, (d) build cost in agent hours, (e) what breaks it. The chip is the f = 1 GDDR7 chip unless named; HBM3 one stack in brackets.
|
||||
|
||||
| Rank | Design | (a) Chip edge per joule over the 5090 | (b) Premium over class v3: 5090 / 4070 | (c) Verifier | (d) Agent hours | (e) What breaks it |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | **The operating point as the shipped default**: core lock at the knee plus undervolt where the driver allows, per card model, in Ember Tune (miner-only, no consensus) | class v3 at the 1,400 lock 3.6x (5.3x), measured card, modelled chip; at the knee below 1,400 lower (being read); class v4 at the lock 2.1x at k = 1, 2.9x at a 3.3 pJ core (measured premium, modelled chip) | lowers the base: 330 to 228 W on v3; the v4 premium 145 to 88 W (both measured); the 4070 already sits at its tune point (79.5 W; its premium 30 W measured) | none | 2 to 4 (the Ember core-clock knob is ordered for 0.3.24; add the undervolt line, the AMD ADLX line, the Apple "no lever" line) | AMD has no `-lgc` (ADLX or nothing); Apple has no lever; a locked 5090's free shadow is about 150,000 ops, so ladder rung 2 costs it rate; the knee may sit where the driver refuses the lock |
|
||||
| 2 | **The SM-sparse miner kernel**: the hash on about N of the SMs at 32 warps per block, the rest clock-gated (miner-only; worker variants `sp<N>-w<W>`, section 12) | unmeasured: reads the 99 W of `E_wait` at the lock; if half is SM activity, class v3 at the lock about 2.8x (4.0x) and class v4 at k = 1 about 1.7x; if none, unchanged | lowers the base by what it finds; no premium | none | 1 (built tonight) + 1 (one PC 1 job) + 3 to 4 to ship as the default kernel through the tuning file and the Devnet 2 gate | the overhead is clock tree and leakage, not SMs (the knee sweep shows this first); fewer SMs cannot hold 17.5 G reads in flight (MSHR limits), so the rate falls with the watts; AMD and Apple need their own variants |
|
||||
| 3 | **The shadow at rung 0 with a high-k op mix** (shuffle and multiply heavy weight table; a class change under the 95 percent rule) | at the 1,400 lock and the same premium: 2.1x at k = 1 (unchanged), 2.9x at the pessimistic core instead of 3.4x (k floor 0.32 to 0.46, approximate) | unchanged: 88 W locked, 145 W unlocked / 30 W | unchanged (shfl is one op; about 8.8 ms loaded at rung 0) | 4 to 6 (the weight-table knob, packs, three-vendor fingerprints) plus the six gates across lanes | Apple pays shfl at 1.91x per op (under 1 percent of its ALU time at rung 0, argued); held by the coordinator until designs 1 and 2 are read |
|
||||
| 4 | **The L2-resident hot table with cache-policy hints** (a 32 to 64 MiB table beside the dataset; dataset loads evict-first, hot loads evict-last) | if the rate holds: the one lever with k possibly over 1 (a 64 MiB SRAM read on a chip 0.2 to 0.5 nJ against an L2 hit 0.1 to 0.3 nJ, both approximate): 512 hot reads per hash is about 0.1 microjoules on both sides, class v3 at the lock about 3.2x (4.4x) | about 0.1 microjoules (13 W) if the hints hold the table; the 5 October rows without hints cost 13 to 16 percent of RATE on the 5090 and 16 to 20 on the 9070 XT | the hot items derived lazily per unit: unmeasured, likely under 0.5 ms | 4 for the measurement (a hinted variant of the 5 October hot-table packs on PC 2); 10 or more if adopted (a class change) | Metal has no cache hints (Apple pays rate); AMD's slc bits unverified; the chip's SRAM is $12 of die at 64 MiB, so the lever is energy only; it is a measurement, not a result |
|
||||
| 5 | **The tensor block with a byte-dot verifier** (mm8 at R about 1,450, the shadow's premium in tiles) | k 0.5 to 1 (the same circuit): at the shadow's premium 2.1x at k = 1, 2.9x at k = 0.5 (modelled on the 4090's measured block energy) | the same 0.65 microjoules by construction / the 4070 untested | scalar FAILS (about 10.6 ms at R = 1,450 on the M5 Max core); VNNI, udot or pmaddubsw 4x to 16x cheaper (approximate): 1 to 3 ms, inside the gate on a 2019 core only with AVX2 | 8 to 12 (the two-output block exists in `proto-newpow/mma-shadow`; the SIMD verifier, the AMD WMMA layout gate, Apple's emulation measurement) | Pascal, RDNA 2 and every Apple card emulate at 10 to 16 steps per tile; the AMD fragment layout is unverified; buys nothing over design 3 at equal premium except a better-bounded k |
|
||||
| 6 | **Class v5 with the shadow dropped** | 5.1x (7.2x), modelled; 3.6x (5.3x) at the lock | none / none | -0.65 ms (the shadow gone), +0.2 ms (v5) | 0 | fails the brief: the memory-system chip is untouched; v5 is shipped for its own reasons (the recompute chip and the stateless pool miner gone) |
|
||||
| 7 | **The refresh as the cost** (per epoch or per block rebuild) | unchanged (the rebuild is 0.1 to 0.5 J on a chip core) | the 4090 loses 3 percent of hash time at one rebuild per second / the same | none | 0 | dead by arithmetic |
|
||||
| 8 | **Per-card adaptive classes** | at least the edge of the card's best single class (the proof in section 6) | | | 0 | dead by the maximum argument; the ladder is what survives |
|
||||
| 9 | **Proof of useful work** (proving in the lottery) | a proving chip's credible 5x to 30x (approximate) | | a proof piece cannot be checked under 10 ms | 0 | dead by the gas bound and the Aleo precedent |
|
||||
| 10 | **Memory-system shaping** (row, bank, refresh, burst; more reads; wider loads) | unchanged | none | none | 0 | no asymmetry: the same DRAM both sides |
|
||||
|
||||
The three strategic answers that are not designs, for the record: (i) accept chips (the Kaspa turn: the chip edge is then the point, and the honest number to publish is the chip's USD 0.00021 per MH/s-hour against an owned 5090's 0.00113, algorithm.md 5.1); (ii) hold the line with the shadow, the ladder and the share-pattern detector, paying the premium at the knee and saying so; (iii) retire the premium by card-side efficiency (designs 1 and 2) and keep the shadow as the floor-setter. This file recommends (iii) with (ii)'s honesty.
|
||||
|
||||
## 10. The recommendation, in five sentences
|
||||
|
||||
The premium cannot reach zero against a chip that is a GPU's memory system without the GPU, because every joule we force the chip to spend is a joule the card spends first, at a ratio set by silicon; what zero premium buys is the card's own whole-card energy over the chip's memory energy, which is 3.6x on a 5090 locked at 1,400 MHz, about 2x at that card's idle-plus-memory bound, and 1.7x on an Apple GPU today. The shadow is therefore kept at rung 0 as the floor-setter, with its measured price stated at the knee (88 W at 1,400 MHz, lower if the knee is lower) and its measured worth (2.1x at a chip core equal to a GPU lane, 2.9x at a core three times better, nothing below 1.8 pJ per op), and its content re-weighted toward shuffles and multiplies once designs 1 and 2 are read, because that is the only way to raise the chip's `k` at the same premium. The lever that moves every row with no consensus change is the honest card's operating point, so the knee lock and the undervolt ship as the default in Ember Tune for every NVIDIA card model, with the AMD and Apple rows stated honestly as "ADLX or nothing" and "no lever". The SM-sparse kernel built tonight is the one measurement that can lower the premium-free floor further on the same card, and it decides itself in one PC 1 job: if a quarter of the SMs holds the rate at much lower watts it ships as the miner's default kernel, if the watts track the rate it is dead. Class v5 ships for its own reasons and does not retire the shadow; the public text should say the floor and the premium as numbers, and should stop quoting the withdrawn X9 as a chip core a third as costly as a GPU lane, since that ratio was against a CPU.
|
||||
|
||||
## 11. Consequences per tier
|
||||
|
||||
| Tier | What this file means tonight | What is being done |
|
||||
|---|---|---|
|
||||
| Home miner, one 8 GB card (NVIDIA; 4060 Ti class, 3.81 microjoules rented and untuned) | No chip exists; against the modelled GDDR7 chip the untuned card sits at 8.2x per joule on class v3 and about 3.1x on class v4 at k = 1 (algorithm.md 5.1). The two levers that move its row are in the miner, not the hash: the Ember tune (10 to 30 percent per joule measured on the 4070) and, if it reads well, the SM-sparse kernel. Class v4's premium on a small card is about 30 W at the tune point (the 4070's row) | design 1 in 0.3.24; design 2's PC 1 job |
|
||||
| One 12 GB card (4070, 5070) | the 4070 at its tune point is 5.5x (v3) and about 2.2x (v4, k = 1) against the GDDR7 chip, measured card, modelled chip; its v4 premium is 30 W (79.5 to 109 W) for no rate | the same |
|
||||
| One 16 GB card (RX 9070 XT) | 23x per joule behind the GDDR7 chip on class v3 (approximate watts); the shadow costs it no rate (+3.6 percent at rung 3) and its watts under v4 are OWED; AMD has no clock-lock path in the app (ADLX line or nothing) | the AMD watts row; the ADLX line in Ember Tune's text |
|
||||
| One 24 or 32 GB card (5090 class) | the measured rows of this file: 5.2x unlocked, 3.6x at the 1,400 lock on class v3; class v4 2.1x at k = 1 for 88 W at the lock; a 5090 owner who locks the core pays 316 W instead of 476 W for 1.4 percent of rate | the knee sweep (running), the Ember knob, the SM-sparse job |
|
||||
| Apple (M-series) | the honest best per joule the project owns: 1.7x from the GDDR7 chip with no shadow, 0.9x at class v4 and k = 1; no clock lever; a shuffle-heavy mix costs it 1.91x per shfl op (under 1 percent of its ALU time at rung 0, argued) | nothing to do for Apple in designs 1 and 2; design 3 is checked against its 5 percent rule |
|
||||
| A rig | a rig's bill is watts: the lock takes a 5090 rig from 476 to 316 W per card on class v4 (-34 percent of electricity) for 1.4 percent of rate; the shadow's premium at the knee is the rig's price for the 2.1x floor | design 1 as the default |
|
||||
| A pool user | nothing changes in shares or payout from any design here; designs 3 to 5 would be class changes announced by the 95 percent signal | |
|
||||
| A node operator (the verifier) | designs 1 and 2 cost nothing; design 3 nothing; design 4 under 0.5 ms (unmeasured); design 5 needs a SIMD byte-dot path to fit the gate | |
|
||||
| A chip | its edge is `E_card / E_mem` plus whatever the shadow forces at its own `k`: 3.6x to 5.2x on GDDR7 with no shadow depending on the card's operating point, 2.1x with the shadow at k = 1; a core cheaper than 1.8 pJ per counted op would turn the shadow in its favour at the knee, which no public figure shows for a random 32-lane program | the `k` question stays with the external brief (algorithm.md proposal 8) |
|
||||
| The public claim | the honest sentence is the floor and the premium as numbers: "a chip that stores the dataset keeps the card's whole-card energy over its memory energy, 3.6x on a locked 5090 and 1.7x on an Apple GPU with no extra work; the shadow holds it to 2.1x against a core as good as a GPU lane for 88 W on a 5090 at the knee" | to the Counter lane for the chip texts once the knee is a number (the coordinator, 21:5x UK) |
|
||||
|
||||
## 12. What was built tonight: the SM-sparse worker variant
|
||||
|
||||
`proto-cuda/nvrtc/worker.cpp` (this branch) gains opt-in variants `sp<N>` and `sp<N>-w<W>` (N persistent blocks of W warps, W default 32): the pack's bound kernel text is rewritten at compile time with exact anchors (the race's existing mechanism, `variantSource`): `__global__ void igneum_hash_bound(...)` becomes `__device__ __forceinline__ void igneum_hash_bound_unit(..., uint32_t gid)` with its `gid` line removed, and a persistent wrapper of the kernel's own name loops the unit over the dispatch's nonces with stride `gridDim.x * blockDim.x`, taking a trailing `uint32_t nonces` argument. `launchHash` launches it as a grid of N blocks; `raceTime` carries the entry's shape while timing it; the winner installs it. The variants are made on demand by name (`parseSparseVariant`) and are not in the catalogue, so a default race never runs them and no shipped worker changes behaviour. Refused on a persistent (variant-5) pack and when combined with a `__launch_bounds__` minBlocks variant. The rewrite was mirrored in Python on the class v4 devnet pack and reads as intended; the exe was cross-compiled on igneum-build-1 under `lease pool 4 --class measure --label "ca4 research: worker cross-compile (SM-sparse variant)"` (20:48 UTC; `/srv/builds/ca4-research/nvrtc/igneum-worker-cuda-ca4sparse.exe`, sha256 ee8d0e70dd101f125f42c0c7cf07481a794ee18a1317acf68561b37ea18d72be, no icon or version block: a scratch exe for one job, not a shipping one). Not yet run on a card: the first run's self-test (the vector warps through the bound kernel and the 2^24 fingerprint equal to the base kernel's) is its gate, and an NVRTC compile error shows as the variant dropped with "compile:" in the race line. The job is with the hash lane for PC 1 after the knee sweep (rungs sp170, sp85, sp43, sp21, sp11 at the 1,400 lock and unlocked, class v3 and v4 packs; MH/s, watts, SM MHz, fingerprint per row).
|
||||
|
||||
## 13. Unverified and owed
|
||||
|
||||
- The knee: read at 20:5x UTC as 1,300 MHz on class v3 (223.3 W, 1.66 microjoules) and 1,200 MHz on class v4 (305.1 W, 2.28), the premium 81.8 W at the best points; every "at the lock" row in this file is the 1,400 row and moves by under 3 percent to the knee (3.63x to 3.56x on GDDR7; the class v4 edge at k = 1 stays 2.1x). The floor below 1,100 MHz is unmeasured (a third pass runs to it).
|
||||
- The breakdown of the 5090's 228 W at the lock into idle, memory and SM activity is unmeasured; the SM-sparse job reads it.
|
||||
- The RX 9070 XT's watts under class v4; the 2019-class verifier core (O-1.14); the M5 Max package watts.
|
||||
- The chip side is the model (chip-model-v3 section 5): activate-bound ceilings, 2.0 and 1.2 nJ per random read, the static and controller allowances, the 85 W node; no chip has been measured. The `k` band 0.5 to 1 for a SIMD-array core is approximate; the N5 datapath floor is a bound, not a design.
|
||||
- The L2-hit and 64 MiB SRAM energies of design 4 are scalings from Horowitz 2014 and chip-model-v3 5.1 (approximate); the cache-hinted hot table is unmeasured.
|
||||
- The tensor block's SIMD verifier cost is a 4x to 16x scaling, unmeasured.
|
||||
- Every external figure is the vendor's or the author's claim at its URL, read 7 October 2026 by the literature sub-agents of this lane; where a page refused the fetch the figure came through a search summary and is marked claimed.
|
||||
|
||||
## 14. Sources
|
||||
|
||||
Internal: `docs/analysis/chip-model-v3.md` (sections 5 and 6; section 5.10 on ca3-coord), `docs/analysis/latency-shadow-2026-10-06.md`, `docs/plans/counter-asic-3-status.md` (ca3-coord: the efficiency pass, the 4070 and 9070 XT rows), `docs/plans/counter-asic-3.md`, `docs/plans/counter-asic-3-derivation.md`, `docs/plans/counter-asic-2.md`, `docs/design/class-v5-stored-state.md` (class-v5), `docs/design/latency-ladder.md` (ladder and attack-ladder-5a), `docs/analysis/attack-pass/f1-shadow.md` and `f10-ladder.md` (attack-pass), `docs/plans/cryptanalysis/in-house-pass.md` (crypto-engage), `docs/analysis/horizon/new-pow.md` and `algorithm.md`, `docs/analysis/asic-resistance-history.md`, `docs/analysis/scratch-soundness.md`, `docs/bench-log.md`, `docs/evidence.md` row 15.
|
||||
|
||||
External (all read 7 October 2026): O'Connor et al., Fine-Grained DRAM, MICRO 2017, https://www.cs.utexas.edu/users/skeckler/pubs/MICRO_2017_Fine_Grained_DRAM.pdf ; Chatterjee et al., subchannels, HPCA 2017, https://www.cs.utexas.edu/users/skeckler/pubs/HPCA_2017_Subchannels.pdf (GUPS about 9 pJ per bit against STREAM about 3, claimed); Horowitz, ISSCC 2014, https://pages.cs.wisc.edu/~markhill/restricted/isscc2014_horowitz_power_scaling.pdf ; Dally, Hot Chips 2023 keynote, https://www.hc2023.hotchips.org/assets/program/conference/day2/Keynote%202/Keynote-NVIDIA_Hardware-for-Deep-Learning.pdf ; Micron GDDR7 4.5 pJ per bit via https://www.club386.com/micron-gddr7-improves-nvidia-gpus-by-up-to-30-in-gaming/ ; Samsung HBM3E 3.9 pJ per bit via https://www.igorslab.de/en/samsung-ends-afterburner-hbm4e-with-325-tb-s-bandwidth-to-exceed-nvidias-requirements/ ; AccelWattch, MICRO 2021, https://paragon.cs.northwestern.edu/papers/2021-MICRO-AccelWattch-Kandiah.pdf ; tensor cores on memory-bound kernels, https://arxiv.org/html/2502.16851v2 ; Shuhai, FCCM 2020, https://arxiv.org/pdf/2005.04324 ; Folded Banks, ISCA 2025, https://dl.acm.org/doi/10.1145/3695053.3731111 ; Li, Reddy, Jacob, MEMSYS 2018, https://terpconnect.umd.edu/~blj/papers/memsys2018-dramsim.pdf ; the Ethash, RandomX, ProgPoW, Equihash, Cuckatoo, Kaspa, Alephium, FishHash, Autolykos, NexaPoW, Xelis and PoSME URLs in sections 5 and 8; the proving URLs in section 7.
|
||||
|
||||
## 15. The k below 1 hunt (second pass, the founder's question of 22:4x UK: "a better way to shadow, or a new way, that is easier on GPUs and hard as hell on chips")
|
||||
|
||||
Written 21:3x to 21:5x UTC, 7 October 2026. A note on the letter: this file's `k` is the chip core's energy per forced operation over the GPU's, so an operation that is cheaper on the GPU than a chip can match is `k` ABOVE 1 here (the coordinator's brief wrote the same thing as "k below 1" with the ratio the other way up). The identity of section 2 does not change: zero premium is zero forcing at any `k`; what a high `k` buys is the right-hand asymptote `1 / k` and a steep slope at small premium, so a block with `k` near or above 1 makes a GIVEN premium count for more and removes the chip's downside bet. "Hard as hell on chips" is `k` well above 1; "easier on GPUs" is a block whose joules per unit of forcing are low, which is the same thing said twice.
|
||||
|
||||
**The one-sentence answer first: on the public figures nothing reads k above 1 with certainty; every block a GPU has, a chip at the same node builds for the same or less energy per operation, and the only block where k reads near or above 1 rather than 0.3 to 0.5 is the int8 tensor tile, because the GPU's tensor core is already a dense MAC array at the node's density, so the best design is the tensor-shaped shadow with a SIMD verifier (section 17, rank 3), and the microbenchmarks built tonight (section 18) replace the public-figure column with measured picojoules per operation on the 5090 when the PC 1 queue reaches them.**
|
||||
|
||||
### 15.1 Every GPU block gaming and AI already paid for, priced
|
||||
|
||||
The GPU column is the public figure or this project's measurement until the microbench rows land (each probe is named; the row then reads watts minus the sleep floor over counted operations per second, at the unlocked clock and the 1,300 MHz knee). The chip column is the best public figure at a 4 or 5 nm node for the same operation, labelled. `k` is the chip's cost over the GPU's at the same operating point. The verifier column is the CPU's cost to reproduce the operation bit-exactly inside the hash (the x8 verifier does 18 G simple integer ops per second per core and the shadow's law is 0.1 ns per lane-instruction, `latency-shadow-2026-10-06.md` section 4). The last column is the edge if the shadow were made of that operation at the class v4 premium's joules at the 1,400 lock (0.654 microjoules): at ZERO premium every row reads 3.6x (GDDR7) and 5.3x (HBM3), the identity; the row is what the same joules buy against a chip of that `k`.
|
||||
|
||||
| GPU block | The 5090's energy per op: public or measured today | Microbench probe | A chip's cost at 4 to 5 nm, public | k band | Bit-exact on CPU, three vendors; verifier cost | Edge at the measured premium (0.654 microjoules at the lock) | Labels |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Int32 ALU (add, xor, rotate, LOP3, byte permute) | 6.5 pJ per counted op at the 1,400 lock, 10.4 unlocked (the class v4 shadow) | int_arx, lop3, prmt | datapath add 0.06 pJ at 5 nm (mlsysbook citing Horowitz 2014 and Dally 2021, https://mlsysbook.ai/vol1/backmatter/appendix_assumptions.html); the lane's register file and operand delivery dominate on both sides: Dally's 45 nm figures (Hot Chips 2023) are 30 pJ of fetch, decode and operand delivery around a 1.5 pJ HFMA; a SIMD array with a local register file at N5 about 2 to 5 pJ per op | 0.3 to 0.8 | yes; 0.1 ns per lane-op, the shadow's measured law | k 0.5: 2.9x; k 0.8: 2.3x; k 1: 2.1x | GPU measured; chip claimed and approximate |
|
||||
| Int32 multiply, mulhi | unmeasured marginal (the family step costs of item 6 give the multiply at about the add's issue cost on the 5090) | int_mul, int_mulhi | 32-bit multiply 0.52 pJ at 5 nm datapath (claimed, the same source); the multiplier is the same circuit on both sides, so the overhead ratio is the ALU row's | 0.4 to 0.8 | yes; one op | as the ALU row | claimed |
|
||||
| Warp shuffle crossbar | the shfl step 0.86x of the add-xor-rotate step on the M5 Max, measured (item 6); the 5090's step cost in the item 6 record; marginal pJ unmeasured | shfl | a 32-lane crossbar is wires: 0.6 pJ per bit-mm at N5 (approximate, chip-model-v3 5.1), a 32-bit word across a 1 mm array about 20 pJ on either die; the algorithm lane's k floor for a shuffle-heavy mix 0.46 (approximate) | 0.5 to 1 | yes; one op | k 0.5: 2.9x; k 1: 2.1x | measured steps; chip approximate |
|
||||
| Register file bandwidth | not a separable op: the 3 operand reads per lane instruction are most of the 6.5 to 10.4 pJ (Dally: 6 pJ of register file per in-order instruction at 45 nm, claimed) | prmt and lop3 read it (cheap datapath, RF-dominated) | the same banked SRAM at the same node; a chip's lever is operand reuse through a fixed datapath (the 3x factor), which a chained random program denies | about 1 for a random program | the CPU keeps the 32 lanes' registers in L1 (register-major loops) | as the ALU row at k 1: 2.1x | claimed |
|
||||
| FP32 FMA at the GPU's density | 104.8 TFLOPS FP32 on the 5090 (NVIDIA, claimed: 52 T FMA per second); marginal pJ unmeasured | fp32_fma | FP32 FMA at 5 nm about 0.4 to 0.6 pJ datapath (Horowitz 45 nm: 0.9 add, 3.7 multiply, scaled by the 2.5x to 3x process factor Dally gives per node step, approximate); the same overhead ratio as int | 0.3 to 0.6 | fmaf is IEEE-exact on every vendor with fast-math off and denormals NOT flushed (Apple and AMD flush by default in some modes: a vendor-mode risk the integer hash does not have); one op | k 0.5: 2.9x | claimed, approximate |
|
||||
| FP16x2 FMA | 2 per instruction; unmeasured | fp16x2_fma | half the FP32 datapath | as FP32 | fma.rn.f16x2 is exact per IEEE half with round-to-nearest, but Apple's and AMD's half paths differ in denormal handling: excluded from the hash content | | approximate |
|
||||
| **Tensor tile, int8 (u8 m8n8k16, s8 m16n8k32)** | 0.056 pJ per multiply-add marginal at 4,096 tiles per hash on the RTX 4090 (measured 6 October, new-pow 5.1; 0.70 pJ at 64 tiles, the fixed cost amortising); Dally: IMMA 160 pJ per 1,024-MAC instruction at 45 nm (claimed) = 0.16 pJ per MAC, about 0.02 at 5 nm by his process factor | mma_u8_m8n8k16, mma_s8_m16n8k32 | NVIDIA's 5 nm INT4 test chip 95.6 TOPS/W at 0.46 V (JSSC 2023, measured, via Dally's NASEM slides https://www.nationalacademies.org/cdn/materials/9fba0a50-8a84-4dda-8844-e5461844fb28): 0.021 pJ per INT4 MAC at 0.46 V, about 0.1 pJ at 1.05 V (the plot's 5x); an INT8 MAC 2x to 4x an INT4 MAC: 0.04 to 0.08 pJ at the low voltage, 0.2 to 0.4 pJ at nominal; a licensable int8 MMA block is the same circuit as the GPU's | **0.7 to 3 at the same voltage; near 1 is the honest centre** | integer, exact (bit-exact on 2^24 lanes and 1,024 CPU lanes at five rungs, measured); scalar 1.07 microseconds per tile per unit: at the premium's tile count (11,600 tiles per hash, R about 1,450) 12.4 ms, FAILS the gate; a byte-dot verifier (AVX-VNNI vpdpbusd 64 MACs per instruction, NEON udot 16, AVX2 pmaddubsw) 4x to 16x cheaper, 0.8 to 3 ms, approximate and unmeasured | at 0.654 microjoules of tiles: k 0.7: 2.5x; **k 1: 2.1x; k 1.5: 1.6x; k 2: 1.3x**; the chip's downside (k 0.3) is gone because its MAC cannot be 3x cheaper than the GPU's at the same node | GPU measured on a 4090; chip claimed (a test chip at a different voltage); k approximate |
|
||||
| Tensor tile, FP16 and BF16 (m16n8k16, FP32 accumulate) | the same hardware path; unmeasured | mma_f16_m16n8k16, mma_bf16_m16n8k16 | the same array with floating-point accumulate | about 1 | NOT bit-exact: the order of the 16 products' accumulation inside the tile is unspecified by PTX, so two vendors (or two NVIDIA generations) may differ in the last bit; excluded from the hash content; measured here only for the energy picture | | claimed |
|
||||
| Tensor tile, FP8 e4m3 (m16n8k32) | sm_89 and later; unmeasured | mma_e4m3_m16n8k32 | the same | about 1 | the same exclusion (floating accumulate), and no AMD RDNA or Apple path | | claimed |
|
||||
| L2 cache (96 MB on the 5090, 64 MB class on a 4090, 36 on a 4070) | an L2 hit on a GPU is about 0.1 to 0.3 nJ per 32-byte sector (approximate, Horowitz's 1 MB cache at 100 pJ per 64 bits at 45 nm scaled to N5 and a 96 MB array with its crossbar); measured tonight: the l2_chase rows (dependent, the latency) and l2_indep4 (the throughput) | l2_chase_32m, l2_chase_64m, l2_indep4_32m | a 64 MiB SRAM on a 128 mm^2-class die: 0.2 to 0.5 nJ per 64-byte read (chip-model-v3 5.1, approximate); a 3 nm 563 kbit macro reads at 3.89 pJ per access (JSSC 2026, measured, https://sscs.ieee.org/tag/one-cycle-latency-low-leakage-access-mode-1-clm/), so the array's wires, not the cell, set the cost on both sides | **0.7 to 3; the one block where the chip may be WORSE** | the hot items derived lazily on the CPU (unmeasured; the 5 October hot-table packs); the measured rate cost without cache hints 13 to 16 percent on the 5090 (the ldcs job in the hash lane's queue reads it with the hint) | 512 hot reads per hash at 0.2 nJ = 0.1 microjoules: too few joules to force by themselves; at k 1.5 that 0.1 buys 0.1x; the lever is capex-free (section 16) and energy-small | approximate throughout |
|
||||
| Texture sampler, point sampled | a fetch through the texture cache's address unit; unmeasured | tex_point_u32_32m | an address unit is a few adders; a chip reads SRAM or DRAM directly | about 1 or under (the chip skips the unit) | a point fetch is a load; exact | as the L2 row | approximate |
|
||||
| Texture sampler, linear interpolation | the interpolation weights are 9-bit fixed point with 8 fractional bits on NVIDIA (CUDA Programming Guide, "Linear Filtering", claimed); unmeasured | tex_linear_f32_256k | a chip's interpolator is one 9-bit multiply-add per fetch, cheaper than the GPU's full sampler | under 1 | NVIDIA's rule is reproducible on a CPU; AMD's and Apple's samplers use their own fraction widths and rounding (vendor-specific, approximate): NOT bit-exact across the three vendors, excluded from the hash content; measured for the energy picture only | | claimed, approximate |
|
||||
| The rasteriser | not reachable from CUDA, OpenCL or Metal compute; only through a graphics pipeline, whose rasterisation rules (fill convention, sample positions, depth precision) differ by vendor | none | | | NOT bit-exact across vendors by construction; excluded | | |
|
||||
| DRAM random read (the control) | the hash's own pattern: 17.5 G dependent 4-byte reads per second at about 3.1 nJ incremental per read (modelled, 55 W of memory system) | dram_chase_1g | 2.0 nJ per random 32-byte read on GDDR7, 1.2 on HBM3 (modelled from O'Connor et al. and Samsung's pJ per bit) | 0.4 to 0.65 (the chip's whole case) | exact; the verifier's item derivation | this IS E_mem: 3.6x at zero premium | modelled |
|
||||
|
||||
The reading: a GPU lane's cost per operation is overhead (fetch, decode, operand delivery, the banked register file) around a datapath that is a few percent of it, and a chip at the same node builds the same datapath and may drop part of the overhead, which is why every ALU-shaped row reads `k` 0.3 to 0.8. The two rows that read `k` near or above 1 are the ones where the GPU's block is itself a dense array with no per-op overhead to drop (the int8 tensor tile) or a large SRAM whose cost is wires that a chip's SRAM has too (the L2). The tensor tile is the only one of the two with joules to force at a verifiable cost, and only with a SIMD verifier.
|
||||
|
||||
### 15.1a The microbench rows (PC 1, the RTX 5090 alone, 05:14 to 05:56 UTC, 8 October 2026 (06:14 to 06:56 UK); the hash lane's job `run-ca4-pc1-microbench-5090-20261008-c`, the ca4mb exe, 20 probes at 60 s each at full residency, "20 probes ran, 0 skipped or failed" at both states; the sampler's mean from 8 s in, the three power fields within 0.2 W; idle 74.7 W unlocked and 60.2 W at the lock; every checksum equal at both states)
|
||||
|
||||
The reading per row is (watts minus the `sleep` row) over counted operations per second: picojoules per counted op on this card. The `sleep` row is NOT idle: 120.2 W unlocked and 66.2 at the lock against 74.7 and 60.2 idle, so full residency with every warp spinning on `__nanosleep` costs 45 W at the stock clock before any instruction issues; that residency cost is subtracted from every row below, which makes each figure the op's own marginal.
|
||||
|
||||
| Probe (what one counted op is) | Unlocked: W / SM MHz / G ops per s / pJ per op | At the 1,300 lock: W / G ops per s / pJ per op | Label |
|
||||
|---|---|---|---|
|
||||
| sleep (full residency, no issue) | 120.2 / 2,872 / 0 / the floor | 66.2 / 0 / the floor | measured |
|
||||
| int_arx (int32 add, xor, rotate; 4 chains) | 533.9 / 2,816 / 36,600 / **11.3** | 170.9 / 16,819 / **6.2** | measured; the class v4 shadow read 10.8 and 6.4 pJ per counted op on the packs job: the two instruments agree |
|
||||
| int_mul (int32 multiply-add; 4 chains) | 558.2 / 2,712 / 31,419 / 13.9 | 194.5 / 15,412 / 8.3 | measured |
|
||||
| int_mulhi (4 chains) | 538.5 / 2,805 / 10,573 / 39.6 | 168.6 / 4,871 / 21.0 | measured (a multi-instruction sequence per op on this card) |
|
||||
| prmt (byte permute; 4 chains) | 498.4 / 2,812 / 16,933 / 22.3 | 156.6 / 7,882 / 11.5 | measured |
|
||||
| lop3 (three-input logic; 4 chains) | 454.6 / 2,820 / 13,896 / 24.1 | 149.4 / 6,391 / 13.0 | measured (counted as one op per chain-step; the compiler's fusion is not verified) |
|
||||
| **shfl (warp shuffle, one xor beside it; 4 chains)** | 512.6 / 2,811 / 7,036 / **55.8** | 161.3 / 3,240 / **29.4** | measured: the most expensive operation on the card per op, 5x the add |
|
||||
| fp32_fma (4 chains) | 522.7 / 2,714 / 43,671 / 9.2 | 180.7 / 21,863 / 5.2 | measured |
|
||||
| fp16x2_fma (two half FMAs per instruction) | 365.1 / 2,829 / 48,067 / 5.1 per half FMA | 125.6 / 22,696 / 2.6 | measured |
|
||||
| **mma_u8_m8n8k16 (int8 MAC inside the tile; 32 per lane per tile, two tiles per step)** | 537.4 / 2,776 / 101,732 / **4.1 per MAC** | 169.7 / 47,555 / **2.2** | measured |
|
||||
| **mma_s8_m16n8k32 (int8 MAC; 128 per lane per tile)** | 575.0 / 2,629 / 334,227 (80 percent of the dense INT8 peak, approximate) / **1.36 per MAC** | 205.9 / 168,252 / **0.83** | measured: the cheapest counted op on the card, 8x under the ARX path |
|
||||
| mma_f16_m16n8k16 (FP16 MAC, FP32 accumulate) | 454.5 / 2,819 / 104,798 / 3.2 | 149.7 / 48,264 / 1.7 | measured (not bit-exact across vendors: energy only) |
|
||||
| mma_bf16_m16n8k16 | 425.3 / 2,820 / 104,953 / 2.9 | 137.8 / 48,320 / 1.5 | measured (energy only) |
|
||||
| mma_e4m3_m16n8k32 (FP8) | 439.3 / 2,820 / 211,028 / 1.5 | 143.9 / 97,101 / 0.8 | measured (energy only) |
|
||||
| l2_chase_32m (dependent 4-byte read, L2-resident; per read) | 378.7 / 2,842 / 107.55 G reads per s / **2.4 nJ per read** | 138.3 / 52.13 / 1.4 nJ | measured |
|
||||
| l2_chase_64m | 381.3 / 2,842 / 107.64 / 2.4 nJ | 140.5 / 52.14 / 1.4 nJ | measured (64 MiB still inside the 5090's L2) |
|
||||
| l2_indep4_32m (4 independent chains) | 377.0 / 2,842 / 109.42 / 2.3 nJ | 138.6 / 52.96 / 1.4 nJ | measured |
|
||||
| dram_chase_1g (the hash's own pattern; per read) | 318.4 / 2,850 / 18.17 G reads per s / **10.9 nJ per read** | 200.0 / 15.38 / 8.7 nJ | measured: the whole card's marginal per dependent DRAM read, against the memory system's modelled 2.0 nJ |
|
||||
| tex_point_u32_32m (per fetch) | 373.0 / 2,842 / 107.65 / 2.3 nJ | 138.6 / 52.20 / 1.4 nJ | measured (the same checksum as the L2 chase: the sampler's address path is the L2 chase) |
|
||||
| tex_linear_f32_256k (per filtered fetch, 4 chains) | 298.6 / 2,850 / 923.8 / 0.19 nJ | 101.0 / 413.3 / 0.10 nJ | measured (energy only; not bit-exact across vendors) |
|
||||
|
||||
**A correction these rows force on sections 15.2, 20.3 and 20.4, and on the 6 October tensor figure.** A `mma.m8n8k16` tile is one warp instruction over 32 lanes: 1,024 multiply-adds per WARP, 32 per lane, so a hash (one lane) does 32 MACs per tile, not 1,024. Section 15.2 and 20.3 multiplied the per-hash tile count by 1,024, and the 6 October figure for the 4090 (new-pow 5.1: "0.056 pJ per multiply-add at 4,096 tiles per hash") carries the same factor of 32: at 63.08 MH/s and 4,096 tiles per hash the 4090 did 8.3 x 10^12 MACs per second, not 2.6 x 10^14, and its 14.7 W is 1.8 pJ per MAC, not 0.056. Re-read with the right count, the packs job's tile rows (20.3) are: `mm1430` 11,440 tiles x 32 = 366,080 MACs per hash, 5.03 x 10^13 MACs per second at 137.45 MH/s, 146.7 W = **2.9 pJ per MAC unlocked** and 71.5 W over 4.64 x 10^13 = **1.5 pJ per MAC at the lock**; the microbench's dependent u8 tile reads 4.1 and 2.2, its s8 m16n8k32 at 80 percent of peak 1.36 and 0.83. The int8 MAC on the 5090 therefore costs 1 to 4 pJ, between a tenth and a third of a 32-bit ALU op (6 to 11 pJ), not a hundredth; and NVIDIA's own 5 nm MAC array (0.04 to 0.1 pJ per INT8-class MAC at 0.46 V, about 0.2 to 0.4 at nominal, claimed) is then 4x to 30x cheaper than the 5090's measured tile, not equal to it. **The chip's `k` on tile work reads about 0.03 to 0.3, below the ALU shadow's 0.3 to 0.8, so the tensor shadow is the WORSE forcing lever, and the k-above-1 candidate of section 15 does not exist on measured rows.** The corrected 20.4 is below; the 6 October 4090 figure is corrected in this file and owed to `docs/analysis/horizon/new-pow.md` 5.1 and `chip-model-v3.md` 5.11 (the Counter lane).
|
||||
|
||||
What the other rows say, in the identity's terms (the chip's cost per op from the public figures of 15.1, the GPU's now measured):
|
||||
|
||||
| Block | GPU, measured (unlocked / lock) | Chip at 4 to 5 nm, public | k band, corrected | Reading |
|
||||
|---|---|---|---|---|
|
||||
| int32 ALU (the class v4 shadow's mix) | 11.3 / 6.2 pJ | 2 to 5 pJ for a SIMD array with its register file (approximate) | 0.3 to 0.8 | the best forcing lever the card has; unchanged |
|
||||
| warp shuffle | 55.8 / 29.4 pJ | a 32-lane crossbar of a 32-bit word across about 1 mm: about 20 pJ (approximate, the wire figure of 15.1) | 0.4 to 0.7, with the GPU paying 5x the add per op | a shuffle-heavy re-weight raises the GPU's premium per instruction 5x for a k no better than the add's: the re-weight's case is now AGAINST it on the measured GPU side (section 17 rank 5 and the held item) |
|
||||
| int32 multiply | 13.9 / 8.3 pJ | the same multiplier, 0.5 pJ datapath (claimed) | 0.3 to 0.8 | as the ALU row |
|
||||
| int8 tile | 2.9 to 4.1 / 1.5 to 2.2 pJ per MAC (u8); 1.36 / 0.83 (s8 m16n8k32) | 0.04 to 0.4 pJ per MAC (claimed) | **0.03 to 0.3** | the worse lever: the chip's array undercuts the GPU's tensor core by 4x to 30x per MAC |
|
||||
| L2 hit | 2.4 / 1.4 nJ per read | an on-die SRAM read 0.2 to 0.5 nJ (approximate) | **0.1 to 0.3** | the hot-table lever (design 4, the ldcs measurement) is dead on the GPU side: an L2 hit costs the card 5x to 10x what a chip's SRAM costs |
|
||||
| texture interpolation | 0.19 / 0.10 nJ per fetch | a 9-bit interpolator: picojoules | far under 1 | excluded anyway (not bit-exact across vendors) |
|
||||
| DRAM dependent read | 10.9 / 8.7 nJ per read (the whole card's marginal) | 2.0 (GDDR7) to 1.2 (HBM3) nJ per read (modelled) | 0.1 to 0.2 on the marginal; this is the `E_card / E_mem` of section 2 seen per read | the premium-free floor, measured per read: at the lock the card pays 8.7 nJ for a read the chip's memory pays 2.0 for |
|
||||
|
||||
**The one-sentence answer of section 15, corrected on measured rows: nothing on the 5090 reads k above 1; the int8 tile, the one block the public figures put near parity, is 1 to 4 pJ per MAC measured against a 5 nm array's claimed 0.04 to 0.4, so it is the worst lever of all, and the ALU shadow (k 0.3 to 0.8 on 6 to 11 pJ per op) stays the best forcing work the card has.**
|
||||
|
||||
### 15.1b The op-mix re-weight's hold, restated on measured rows (06:0x UTC, 8 October 2026)
|
||||
|
||||
The re-opening condition set at 05:2x UTC (the 5090's shuffle and multiply at or under the add's picojoules per op) is not met and is now a measurement: shfl 55.8 pJ against the ARX op's 11.3 (4.9x), mul 13.9 (1.2x), mulhi 39.6 (3.5x). A shuffle-heavy weight table (the algorithm lane's shfl 14 and shfla 8 of 75 against class v4's shfl 8) therefore costs the GPU MORE per instruction at the same count, so the served 3.4x stands on a measured basis and the re-weight stays held. What the chip's `k` on shuffle-heavy work would have to be for 2.9x to be the honest pessimistic column, on the identity at the 1,300 knee (card 2.33 microjoules, `E_mem` 0.466): if the premium stayed at the ALU shadow's 0.652 microjoules (the same watts, fewer instructions), 2.9x needs `E_mem + k F = 0.80`, `k = 0.52`; if the instruction count stayed and the premium rose with the mix (about 29 percent of the counted ops at 4.9x the cost: the premium about 2.1x, 1.4 microjoules, the card 3.1), 2.9x needs `k = 0.42`. So the re-weight's pessimistic column is honest only if a chip pays 0.4 to 0.5 of the GPU's 55.8 pJ per shuffle, 22 to 28 pJ for a 32-lane crossbar move of a 32-bit word, which is above the wire figure of 15.1 (about 20 pJ across 1 mm, approximate) and unmeasured; and the GPU side of that bargain is a premium up to 2x higher per instruction. On measured rows the re-weight is not a candidate; it would re-price only against a measured chip crossbar.
|
||||
|
||||
### 15.2 The tensor shadow, re-read on the identity
|
||||
|
||||
The 6 October verdict on scheme B ("never as class v5 content") was right for the question it answered: at the 4090's free band (R = 512, 0.23 microjoules) the block forced too few joules and the verifier paid 26x the ALU shadow's cost per joule. The founder's question is a different one: not "is there a cheaper lever" but "is there a lever whose joules the chip cannot undercut". On that question the tensor tile is the best block on the card, because the GPU's marginal 0.056 pJ per MAC is within a factor of about 2 of what a 5 nm MAC array costs anyone (the test chip's 0.04 to 0.1 pJ, claimed), while the ALU shadow's 6.5 to 10.4 pJ per op is 10x to 50x what a fixed SIMD datapath costs. What it would take to make the tensor block carry the SAME premium as the ALU shadow (0.654 microjoules at the lock), on the 5090:
|
||||
|
||||
| Quantity | Value | Label |
|
||||
|---|---|---|
|
||||
| Tiles per hash for 0.654 microjoules at 0.056 pJ per MAC | 11.7 M MACs per hash, 11,400 u8 m8n8k16 tiles (R about 1,430 per iteration) | arithmetic on the 4090's measured marginal; the 5090's marginal is tonight's mma_u8 row |
|
||||
| Where the 5090's free band ends | the 4090 held its rate to R = 512 at 40 percent of its dense int8 peak (approximate); the 5090's tensor peak is about 1.7x the 4090's (NVIDIA's dense INT8 figures, claimed), so the free band on a 5090 reaches about R 2,000 and 11,400 tiles per hash sits inside it; on a 4070 (about 0.45x the 4090's tensor peak) the same R is tensor-bound and costs rate | approximate |
|
||||
| The verifier at R 1,430 | scalar 12.4 ms per unit on the M5 Max core (FAILS the 10 ms gate); with AVX-VNNI (64 byte-MACs per instruction) 0.8 ms, NEON udot (16 per instruction) 3 ms, AVX2 pmaddubsw (16 per instruction, every x86 since 2013) 3 ms, plus the x8 base 2.06: 2.9 to 5.1 ms on the M5 Max core, 7 to 13 ms on a 2019-class core by the 2.5x rule; the 2019 core passes only with the AVX2 path and a small R | approximate, unmeasured; the item that decides it |
|
||||
| The chip's edge at that premium (1,400 lock, GDDR7) | k 0.7: 2.5x; k 1: 2.1x; k 1.5: 1.6x; k 2: 1.3x; the same numbers as the ALU shadow at the same k, with the chip's k 0.3 column (4.1x) removed from the table because an int8 MAC array at 5 nm cannot be 3x cheaper than the GPU's | modelled |
|
||||
| What it costs the honest cards | the same watts as the ALU shadow by construction (the premium is the design variable); Turing and later NVIDIA and RDNA 3 and later AMD native; Pascal, RDNA 2 and Apple emulate at 10 to 16 ALU steps per tile, which at 11,400 tiles per hash is 110,000 to 180,000 counted ops, over the M5 Max's 130,000 bind point: the Apple tier would lose 5 to 10 percent of rate (approximate) | measured bind point; emulation cost approximate |
|
||||
| What breaks it | the AMD WMMA fragment layout is unverified (status item 6); Apple has no integer matrix path reachable from the toolchain; the SIMD verifier is unwritten; the 2019-class core may not fit; the chip's k near 1 is the centre of a claimed band, not a measurement | |
|
||||
|
||||
So the tensor shadow does not lower the GPU's premium (the founder's "easier on GPUs" is not available: a premium is a premium), it removes the chip's downside bet at the same premium, which is worth about 1x of edge at the pessimistic end (4.1x to about 2.1x at the ALU shadow's k 0.3 against the tile's k near 1). It is ranked below the two miner-only levers because it is a class change with an unwritten verifier and a vendor split, and above the ALU op-mix re-weight because its k floor is better bounded.
|
||||
|
||||
## 16. The inverse lever: capex as the wall
|
||||
|
||||
The brief: make the chip's capital cost, not its energy, the wall (the state-derived dataset plus an L2-resident hot table plus the era changes), with the break-even row recomputed. The model is the mission lane's (`docs/analysis/mission/future.md` section 2.2, 7 October 2026, model): the maker takes share `s` of the hash; two-year revenue `s x E2 x p` (E2 the two-year emission, 4.18 B IGN in years 1 to 2; p the price); the project pays when `p` is over `p* = C_proj / (s x E2)`, and the market cap at which it pays is `p* x supply` (4.18 B at the end of year 2), so in years 1 to 2 the break-even cap is `C_proj / s`, 3.3x the project cost at s = 0.30. The fleet's own silicon was 2 percent of revenue there and dropped out; the capex column below says whether anything in the hash can make it not drop out.
|
||||
|
||||
### 16.1 The capex column
|
||||
|
||||
| Chip or card | Silicon and memory per unit | MH/s | USD per MH/s | Capex per MH/s-hour over two years | Electricity per MH/s-hour at USD 0.05 per kWh | Capex over electricity | Label |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| f = 1 GDDR7 chip (chip-model-v3 5.4) | USD 470 (16 devices USD 320, controller USD 50, board USD 100) | 166 | 2.8 | 0.00016 | 0.000023 (0.466 microjoules) | 7x | modelled |
|
||||
| the same plus a 64 MiB SRAM hot table | +32 mm^2 of N5 at 0.49 mm^2 and USD 0.23 per MB (chip-model-v3 section 2): +USD 15 of die; if it forces the controller onto an N5 die or a second die, +USD 50 to 100 of die and package (approximate) | 166 | 2.9 to 3.5 | 0.00017 to 0.00020 | 0.000028 (the table's reads) | 6x to 7x | modelled, approximate |
|
||||
| the same plus the class v4 shadow core (30 mm^2 of N5 at N = 100,000) | +USD 25 to 40 | 166 | 3.0 to 3.1 | 0.00017 | 0.000080 (1.59 microjoules at k = 1) | 2x | modelled |
|
||||
| the same with the shadow per load (section 16.2: the ALU core must sit on the controller's die or across an interposer) | +USD 200 of interposer and package (a one-stack CoWoS class package, chip-model-v3 5.1, approximate) | 166 | 4.3 | 0.00024 | 0.000080 | 3x | approximate |
|
||||
| RTX 5090 at MSRP | USD 1,999 | 136 | 14.7 | 0.00084 | 0.000084 (1.69 microjoules at the lock); 0.00012 unlocked | 10x (7x) | measured card price; the street price in 2026 is about 2x MSRP, which doubles the capex row |
|
||||
| RTX 4070 at its tune point | about USD 550 (approximate street) | 31 | 17.7 | 0.00101 | 0.000128 | 8x | approximate |
|
||||
|
||||
What the column says: BOTH sides are capex-dominated (7x to 10x their electricity per MH/s-hour), so the chip's economic edge is its capex per MH/s (5x over a 5090 at MSRP), not its energy, and a design that moved the chip's capex would move the number a miner actually computes. But nothing in the hash can move it far: the work per hash is small (512 instructions plus the shadow), so the silicon a chip needs beside its memory is 30 to 100 mm^2 of N5 (USD 25 to 60), and the memory is the same 16 devices the card carries (USD 320). A hot table forces USD 15 to 100 more; the shadow core USD 25 to 40; an interposer USD 200. The chip's capex per MH/s moves from 2.8 to at most about 4.3, against the card's 14.7 to 29. The per-unit capex wall is unreachable by a factor of 3 to 7, for the same reason the energy wall is: the hash's dataset is a card's worth of DRAM and its work is a fraction of a card's logic. The capex wall that exists is the PROJECT cost and the node it forces, and that is already in the break-even model.
|
||||
|
||||
### 16.2 The break-even row, recomputed with the capex column
|
||||
|
||||
The project cost is what moves the cap; the per-unit capex enters as the fleet term, which was 2 percent of revenue and grows to about 3 percent with the interposer: it still drops out. The rows (s = 0.30, the mission lane's method; the cap is `C_proj / 0.30` in years 1 to 2 and 2.3x more every two years with the emission glide):
|
||||
|
||||
| Chip project | What the hash forces | C_proj | Break-even cap, years 1 to 2 | Years 3 to 4 | Years 5 to 6 | Label |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 28 nm controller, no shadow (class v3, or class v5 with the shadow dropped) | a GDDR7 controller and PHY; the state-derived dataset adds a leaf buffer (6 KB today) and a link to a node: firmware; the era draws: firmware | USD 5 M | USD 17 M | 48 M | 114 M | model (future.md) |
|
||||
| 28 nm controller plus an N5 shadow core (class v4 as shipped) | an N5 die for the 14,000-lane ALU array; the two dies exchange 64 B of lane state per iteration: 17.5 G reads/s over 16 reads per iteration x 128 B = 140 GB/s, a PCB or organic-substrate link | USD 30 M | **USD 100 M** | 287 M | 682 M | model |
|
||||
| the same plus a 64 MiB L2-class hot table | the table joins the N5 die (32 mm^2 more): no node change, USD 15 of die; the project unchanged | USD 30 M to 33 M | USD 100 M to 110 M | 290 M | 690 M | approximate |
|
||||
| **the shadow per load** (the 256-instruction block split into 16 blocks of 16, one after every load, the same N): the chip's ALU core must sit inside every read's dependency, so the lane state crosses between controller and core twice per read: 17.5 G x 128 B = 2.2 TB/s, an interposer-class link, or one die carrying controller, lanes and PHY at N5 | a single N5 die or a 2.5D package: the mission lane's "N3 single die" row | USD 60 M | **USD 200 M** | 574 M | 1,363 M | approximate (the single-die cost is the mission lane's N3 row; a GDDR7 PHY on N5 is a real but unpriced item) |
|
||||
| the same plus the tensor shadow (section 15.2) | a licensable int8 MMA block beside the lanes: USD 4 of N5 silicon (algorithm.md 5.2); the project unchanged | USD 60 M | USD 200 M | 574 M | 1,363 M | approximate |
|
||||
| What the per-unit capex adds in every row | the fleet term, 2 to 3 percent of revenue | | nothing visible | | | model |
|
||||
|
||||
So the inverse lever's best design is the shadow per load: it costs the honest cards nothing by construction (the same N, the same instruction mix, a smaller block at 16 sites; the measured block-size effect on the 5090 and the M5 Max was that a 64-instruction block ran 2.5 to 3.5 percent FASTER than 256, latency-shadow sections 3 and 5, so 16 may be faster still or may cost compile-ahead, unmeasured), and it doubles the chip's project cost by forcing the controller and the core onto one advanced die or an interposer, which doubles the break-even cap from about USD 100 M to about USD 200 M in the first two years. It does not reach a capex wall per unit, and it does not change the energy identity (`k` is `k` whichever die the core sits on). It is a class change (the block positions are consensus) and a generator change; 4 to 6 agent hours plus the six gates; its measurement is one pack per card (rate, watts, compile-ahead at 16 sites).
|
||||
|
||||
## 17. The ranking, second pass
|
||||
|
||||
| Rank | Design | Chip edge per joule (GDDR7; 1,400 lock) | Premium: 5090 / 4070 | Verifier | Agent hours | Breaks on | Change |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | The operating point as the shipped default | class v3 3.6x; class v4 2.1x at k = 1 | lowers the base (330 to 223 W); the v4 premium 145 to 82 W | none | 2 to 4 | AMD and Apple have no lock | unchanged |
|
||||
| 2 | The SM-sparse miner kernel | **DEAD on measured rows (20.3b, 05:12 UTC): a quarter of the SMs holds 98 percent of the rate at the same draw; the draw follows the work, not the SM count** | none | none | 1 + 1 + 4 | measured: the energy per hash never falls below base | closed |
|
||||
| 3 | **The tensor shadow with a SIMD byte-dot verifier** (int8 tiles carrying the premium; a class change) | **DEAD on measured rows (15.1a, 20.4): the 5090's int8 MAC is 1.5 to 4 pJ measured, a 5 nm array 0.04 to 0.4 claimed, k 0.03 to 0.3, under the ALU shadow's band; at the measured premium the chip keeps 3.5x to 6.7x** | the same as class v4 by construction / the 4070 tensor-bound above about R 600 (approximate) | scalar FAILS; AVX2 and VNNI 2.9 to 5.1 ms on the M5 Max core, 7 to 13 ms on a 2019 core (approximate, unwritten) | 8 to 12 (the SIMD verifier, the AMD layout gate, Apple's emulation row) plus the six gates | the 2019-class core; the AMD layout; Apple loses 5 to 10 percent of rate on emulation; k near 1 is claimed, not measured | NEW this pass: the k-above-1 candidate |
|
||||
| 4 | The shadow per load (capex) | unchanged in energy | none by construction (the sound form's 5090 rows in the hash lane's v6 job) | the sound form 8.28 to 8.82 ms loaded on the reference core (measured, the invention lane) | 4 to 6 plus the gates | **REOPENED (8 October, 11:0x UTC): the 16 x 27 iterated form is dead on the no-era census (0.989 rejection); a drawn-era death before the 10:5x fix was an instrument artefact (the window's fixed bits); the sound form `mx8+shl4096x1` accepts 234 of 256 seeds no-era and its drawn-era verdict is the invention lane's 15:00 BST read** | doubles the break-even cap to about USD 200 M on the sound form (class v6 layer 5) |
|
||||
| 5 | The ALU shadow re-weighted toward shuffles and multiplies | 2.1x at k = 1; 2.9x at the pessimistic k 0.46 (modelled) | **raised: the 5090 pays 55.8 pJ per shuffle against 11.3 per add (15.1a), so a shuffle-heavy mix at the same instruction count costs the card up to 5x the premium per instruction** | unchanged | 4 to 6 plus the gates | Apple pays shfl 1.91x; the GPU side measured against it | HELD; the measured GPU side is against it |
|
||||
| 6 | The L2-resident hot table with cache hints | **DEAD on the GPU side (15.1a): an L2 hit costs the 5090 2.4 nJ unlocked and 1.4 at the lock against a chip's SRAM read at 0.2 to 0.5 nJ, k 0.1 to 0.3**; capex +USD 15 | 13 W if the hint holds the rate | under 0.5 ms (unmeasured) | 4 for the ldcs measurement (queued) | Metal has no hint; the rate without it | unchanged; a measurement |
|
||||
| 7 to 11 | class v5 without the shadow; the refresh as a cost; per-card classes; proof of useful work; memory shaping | as section 9 | | | 0 | dead by arithmetic | unchanged |
|
||||
|
||||
## 18. What was built in the second pass: the microbench
|
||||
|
||||
`proto-cuda/nvrtc/worker.cpp` gains `--microbench [--mb-seconds 60] [--mb-only a,b]`: no pack; 20 probes, one kernel each, compiled by NVRTC one at a time (a form the card or the compiler refuses drops that probe alone, its error on its RESULT row), each run at full residency (the occupancy query's blocks per SM x 256 threads x SMs) for the window, with a UTC start and end stamp per probe for a 1 Hz power sampler and a checksum of the lanes' outputs. The probes: `sleep` (the SM-resident floor: full occupancy, `__nanosleep`, no issue; every other row is read against it), `int_arx`, `int_mul`, `int_mulhi`, `prmt`, `lop3`, `shfl`, `fp32_fma`, `fp16x2_fma`, `mma_u8_m8n8k16`, `mma_s8_m16n8k32`, `mma_f16_m16n8k16`, `mma_bf16_m16n8k16`, `mma_e4m3_m16n8k32`, `l2_chase_32m`, `l2_chase_64m`, `l2_indep4_32m`, `dram_chase_1g` (the hash's own pattern, the control), `tex_point_u32_32m` and `tex_linear_f32_256k` (the sampler's address path and its interpolator, through `cuTexObjectCreate`, loaded optionally). The reading per row: (watts minus the sleep row's watts) over counted operations per second = picojoules per operation on this card at this clock. Gate: mingw cross-compile on igneum-build-1 under `lease pool 4 --class measure --label "ca4 research: worker cross-compile (microbench probes)"`, exit 0 with `-Wall -Wextra` clean, 21:20 UTC; exe `/srv/builds/ca4-research/nvrtc/igneum-worker-cuda-ca4mb.exe`, sha256 49aa60c4b28ed77a54655476d0f07c06a54aa22e4f6f6ccadd4019b84cbf0268; unrun on a card. The job is with the hash lane for PC 1 after the hot-table ldcs job: `--microbench --mb-seconds 60`, the 5090 alone, nvidia-smi at 1 Hz, at the unlocked clock and the 1,300 MHz knee. When its rows land, the GPU column of 15.1 becomes measured and every `k` band narrows from the GPU side; the chip side stays claimed.
|
||||
|
||||
## 19. Unverified and owed, second pass
|
||||
|
||||
- Every GPU figure in 15.1 except the ALU shadow's, the tensor tile's (a 4090) and the DRAM read's is unmeasured until the microbench rows land; the chip side is public claims at other voltages and nodes; `k` is a band, not a number, in every row.
|
||||
- The SIMD byte-dot verifier is unwritten; its 4x to 16x is a scaling from instruction widths.
|
||||
- The 5090's tensor free band (R about 2,000) is a scaling from the 4090's measured 40 percent at R = 512 and NVIDIA's claimed peaks.
|
||||
- The shadow-per-load design's block-size effect at 16 instructions and its compile-ahead are unmeasured; the single-die project cost is the mission lane's N3 row, and a GDDR7 PHY on an N5 die is unpriced.
|
||||
- The break-even model is the mission lane's (s = 0.30, a two-year life, the rental-equilibrium hash); its emission figures are that model's.
|
||||
|
||||
## 20. Third pass: the two prototypes, built (7 October 2026, 21:3x to 22:xx UTC)
|
||||
|
||||
The coordinator's order of 22:5x UK: both new ranks as experimental program classes in the kit, behind the pack, no consensus change. Built and gated tonight; the card rows are the hash lane's PC 1 job `run-ca4-pc1-packs-5090-20261007` (queued after the SM-sparse job and the microbench; the knee-lock states wait on PC 1's Power Helper, dead since 21:08 UTC, so the unlocked states land first).
|
||||
|
||||
### 20.1 What was built
|
||||
|
||||
| Item | Where | What it does | Gate |
|
||||
|---|---|---|---|
|
||||
| The per-load shadow, `+shl<S>x<R>` | `igneum-pow` (`ShadowClass.per_load`; `generator.rs`, `verify.rs`, `emit.rs`) | the class v4 shadow's 256 instructions and 27 passes, split into 16 sub-blocks of 16 consecutive instructions, sub-block `j` run 27 times right after the `j`-th load of the base program (the same work per hash, placed inside every read's dependency); id bytes `perload`; emitted in Metal, CUDA and OpenCL; the verifier runs it in the base loop | the base program and the shadow instructions are the class v4 program's draw for draw (unit test); the id differs; the suite green |
|
||||
| The int8 tile block, `+mm<R>` | `igneum-pow` (`ShadowClass.tiles`, module `mm8`) | `R` `mma.m8n8k16 u8` tiles per iteration after the shadow block, each over two registers with both outputs consumed (the `proto-newpow/mma-shadow` form), descriptors drawn from the stream after the shadow draws; CUDA runs the PTX tile natively, Metal and OpenCL the shuffle-and-byte-product reference; the Rust verifier runs the scalar reference or an AVX2 path (`maddubs` on a 4-way split of B so no 16-bit lane saturates; `IGNEUM_MM8_SCALAR=1` forces scalar); id bytes `mm8/` plus the count | SIMD equal to scalar on 64 seeds and the all-ones edge (unit test, run on the EPYC); the suite green: 64 + 7 + 4 + 19 + 2 + 7 tests, 0 failed, igneum-build-2 21:42 UTC |
|
||||
| Nothing moves for the chain | the packs test and a byte diff | the pinned v2, v3 and v4 packs are byte-identical; the re-exported `mx8+sh256x27` equals `packs-ca3-shadow/sh256x27` on all seven files | passed |
|
||||
| The packs | `proto-cuda/packs-ca4/` (also build-1 `/srv/builds/ca4-research/packs/`, the hash lane's kit `igneum-ca4-packs-20261007.zip`) | `mx8_sh256x27` (the control, class v4's shape), `mx8_shl256x27`, `mx8_mm128`, `mx8_mm512`, `mx8_mm1430` (11,440 tiles per hash: the ALU shadow's premium in tiles at the 4090's 0.056 pJ per MAC), all over seed `igneum-genesis`, day 2026-10-03, 1 GiB, generator 2 | every export `OVERALL: PASS` (vectors through the interpreter); the CUDA-against-CPU bit-exactness is the worker's self-test on the card (96 vector lanes through the bound kernel), unrun |
|
||||
| What is NOT built | | the NEON path of the tile verifier (the M5 Max runs scalar); the AVX-VNNI path (the AVX2 path stands in; VNNI would halve its instruction count, approximate); the acceptance rule's view of the per-load placement (the rule interprets the base program only, as for class v4; the sub-version 3 freshness fixpoint runs over base then shadow, which under per-load placement is not the execution order: a class, not a prototype, would re-derive it) | |
|
||||
|
||||
### 20.2 The verifier rows (igneum-build-2, EPYC 9454P, core 40 at nice 19 under `lease cores 40,88`, 21:5x to 22:07 UTC; the ladder's method: one cold warp per vector base, alone and with the SMT sibling core 88 running the same bench; the box at load 84 to 95 from other lanes, core 40 at 3.27 GHz)
|
||||
|
||||
| Class | Cold ms alone (warp base 0 / 4096 / 1000000) | Cold ms, sibling loaded | Items derived per warp (of 4,096 reads) | Reading |
|
||||
|---|---|---|---|---|
|
||||
| `mx8+sh256x27` (class v4's shape, the control) | 5.48 / 5.06 / 5.20 | 9.52 / 9.16 / 9.12 | 4,096 / 4,096 / 4,096 | the ladder's rung 0 read 5.14 and 8.77 on 6 October at load 25; tonight's box is heavier |
|
||||
| `mx8+shl256x27` (per load) | 5.09 / 4.84 / 4.86 | 8.66 / 8.34 / 8.38 | **3,627 / 3,595 / 3,633** | the same instructions, 7 to 9 percent FASTER: because 11 to 12 percent of the warp's reads hit an item another lane already derived (the finding below) |
|
||||
| `mx8+mm128` (128 tiles per iteration, no ALU shadow) | 5.33 / 5.13 / 5.08 | 8.59 / 8.37 / 8.25 | 4,096 | AVX2 tile path |
|
||||
| `mx8+mm512` | 5.31 / 5.12 / 5.12 | 9.30 / 9.15 / 9.03 | 4,096 | passes the gate loaded |
|
||||
| `mx8+mm1430` (11,440 tiles per hash, the ALU shadow's premium in tiles) | 5.82 / 5.64 / 5.66 | **10.14** / 9.58 / 9.55 | 4,096 | alone 5.8 ms; loaded the base-0 warp is over the gate by 0.14 ms (rung 3's class of miss, at a heavier load than the ladder's run) |
|
||||
| `mx8+mm1430`, the scalar tile path forced (`IGNEUM_MM8_SCALAR=1`) | 7.60 / 7.39 / 7.35 | not run | 4,096 | the AVX2 path is 4.6x the scalar path on the tile work |
|
||||
|
||||
The tile cost law from these rows: AVX2 (5.82 - 5.33) ms over (1,430 - 128) x 8 = 10,416 extra tiles per unit = 0.047 microseconds per tile per unit; scalar (7.60 - 5.33) / 10,416 = 0.22 microseconds per tile per unit (the 6 October figure of 1.07 was a naive C loop on a loaded core). So a tile class carrying the ALU shadow's premium costs the verifier about 0.5 ms per unit on an AVX2 core and 2.3 ms scalar; against class v4's own 55,296 shadow instructions at 0.1 ns per lane-instruction (about 0.18 ms per unit) the tile block is 3x the verifier cost per unit at the same premium with AVX2, 13x scalar. On the measured box core `mm1430` passes alone (5.8 ms) and misses the loaded gate by 0.14 ms; `mm512` passes loaded. On a 2019-class laptop core by the 2.5x rule: `mm512` about 13 ms loaded (FAILS), `mm128` about 12.8 (the ALU-free base is already 12.6 on that rule's loaded column; the rule itself is the open O-1.14). A NEON path (M-series: scalar tonight) and a VNNI path (halves the AVX2 count, approximate) are the two unbuilt verifier items.
|
||||
|
||||
**A finding on the per-load placement (not a tuning).** With the same base program and the same loads, the per-load class derives 3,595 to 3,633 distinct items per warp where the class v4 shape derives all 4,096: 11 to 12 percent of the warp's 4,096 reads land on an item another lane of the same warp has already read. The cause (read from the construction, not yet from a trace): a sub-block of 16 drawn ALU instructions ends right before the next load, and when its last writer of the load's source register is a `shfl` (29 of 256 shadow instructions are shuffles), two lanes carry a neighbour's value into the same address; in the class v4 shape the base program's own instructions re-randomise every register between a shuffle and a load, and the sub-version 3 source rule (a load's source last written by an injecting op or a rotate, judged over base then shadow) does not see the per-load order. Consequences: a within-warp duplicate read is served from L1 or L2 on the GPU and from a lane buffer on a chip, so it costs neither side DRAM energy but it is 11 percent fewer dependent reads per hash, which is exactly the uniformity bound the attack pass gates (F8's 1.2x on the hot set); the per-load class as drawn FAILS that spirit and is not a candidate as exported. The fix is one rule: a sub-block's instructions that would be the last writer of the next load's source are drawn from the injecting families only (or the sub-block ends one instruction before the load with a rotate), the sub-version 3 freshness fixpoint run over the real execution order; 2 to 3 agent hours plus a re-export and the F8 census at 2^24 on 64 seeds. The card rows of the exported pack still answer the energy question (the same instruction count per hash, 11 percent fewer DRAM reads, so the pack reads slightly FASTER than the control and that part of the delta is the duplicate reads, not the placement).
|
||||
|
||||
### 20.2a The per-load fault, fixed (22:16 to 22:33 UTC)
|
||||
|
||||
| Reading | Clock (UTC) | Value |
|
||||
|---|---|---|
|
||||
| The known-failed record (the first export, id 854050a4293f0615), traced | 22:16 | 10,728 distinct items of 12,288 over three units (the class v4 shape 12,286); 1,482 same-iteration duplicate lanes at sites 8, 10 and 15 only; the sub-block last writers of those loads' sources `mul`, `mul`, `mul`; shuffles and rotates clean |
|
||||
| The mechanism, from the 64-seed census of the first rule | 22:16 | 29 of 64 seeds failing, up to 620 duplicate lanes a seed, load sources collapsed to 1 to 17 distinct values in 32 lanes; the last base writers named `mulhi`, `mul`, `or`, `rotl`, `load`: a lossy BASE writer followed by 27 passes of a 16-instruction map collapses the register before the next load, so a last-writer rule alone does not cover it |
|
||||
| The fix, committed (2f718001) | 22:24 | two layers: (1) generator: a per-load sub-block instruction writing the NEXT load's source is redrawn from the injecting families when its op is `mul`, `mulhi` or `or`; (2) `accept.rs`: the dynamic test steps the per-load sub-blocks inside `run_unit` in the order the class executes, and a new rejection `DuplicateLanes` (this class only) refuses a candidate whose load reads one address in two lanes of a unit; a rejected candidate redraws the attempt |
|
||||
| After, the trace | 22:23 | 12,287 distinct of 12,288, 0 duplicate lanes on the genesis seed (accepted at attempt 3) |
|
||||
| After, the 64-seed census (2 units each on a second dataset, 16,384 load rows) | 22:23 | 1 duplicate pair in all (seed ca4-census/49, site 3): the chance floor of a 2^24 index space (32 x 31 / 2 / 2^24 per row, about 0.5 pairs expected; the class v4 shape's own trace carries 2 of 12,288 from the same floor) |
|
||||
| After, the drawn-era split (16 eras `igneum-era-test/0..15` over the per-load class, 2 units each) | 22:32 | R under 28: 12 eras, 3,072 rows, 0 duplicate pairs; R 28 and up: 4 eras, 1,024 rows, 0 pairs; every era accepted at attempt 3 |
|
||||
| The suite | 22:23 | 64 + 2 + 7 + 4 + 19 + 2 + 7 passed, 0 failed (igneum-build-2) |
|
||||
| The fixed pack `mx8_shl256x27_v2` | 22:29 | attempt 3, id bd64b207a30413fb, OVERALL PASS; on build-1 and in the hash lane's kit beside the first export (both run on PC 1, the row names its directory and id) |
|
||||
| Metal fingerprints (M5 Max, `packbench`, 3 batches of 2^24, under the measure lock) | 22:30 | `mx8_sh256x27` 3d2e8245cc084d07 (the 6 October fingerprint, 27.01 MH/s); `mx8_shl256x27_v2` ee5d7c71180e5ea7, vectors 3 of 3 standalone and in batch, 26.88 MH/s (-0.5 percent against the control: the per-load placement costs the Apple GPU nothing); `mx8_mm128` 270e4ae36b37e9a1, 3 of 3, **17.67 MH/s (-35 percent)**; `mx8_mm512` a1c1ff3148d775d1, 3 of 3, **5.98 MH/s (-78 percent)**, compile 15.0 s |
|
||||
|
||||
Two readings from the Metal row that move section 15.2's tile verdict. First, the reference tile (`mm8_ref`: 12 index shuffles and 32 byte products per lane) is bit-exact against the Rust verifier on all three vector warps of both tile packs, so the tile semantics are pinned on two implementations before any card runs the PTX form. Second, the Apple cost is far above the "10 ALU steps per tile" estimate: 1,024 tiles per hash cost the M5 Max 35 percent of its rate and 4,096 tiles 78 percent, so a tile shadow at the ALU shadow's premium (11,440 tiles per hash) would take the Apple tier out entirely unless Metal gains an integer matrix path reachable from the toolchain (`mpp::tensor_ops::matmul2d` with `uchar` operands, unverified). The tile shadow therefore stands as a class only with an Apple exemption nobody has designed, which moves it from rank 3 to beside rank 5 until that path is measured; the k question it answers is unchanged.
|
||||
|
||||
### 20.2a-close The per-load construction: REOPENED (8 October 2026, 11:0x UTC, the coordinator's wording): the 22:5x UTC death of the 16 x 27 form stands on the no-era census (both instruments: 0.986 to 0.990 rejection per candidate); a drawn-era read of any per-load form before the 10:5x UTC fix measured the instrument (the window's fixed top bits counted as biased); the sound form (one pass of a 256-instruction sub-block per load, `mx8+shl4096x1`) accepts 234 of 256 seeds on the no-era census and its verdict on drawn eras is the invention lane's 15:00 BST read
|
||||
|
||||
The fix of 20.2a held for distinctness and then met the value-level requirement of 20.2b, and the construction did not survive it. With the acceptance rule stepping the per-load sub-blocks in the order the class executes and judging both the duplicate lanes at a load row and the one-count of every index bit per site over the 64 units (6-sigma band), the 16 x 27 per-load class accepts 22 of 1,621 candidates over 64 seeds (1.4 percent); 42 of 64 seeds exhaust the chain's 32 attempts, which on the chain is an epoch without a program. The first failing test per candidate: a biased index bit 775, duplicate lanes 643, the base rule's lane-constant site 110, (b) 43, (a) 28. The genesis seed accepts none of 32; candidate 0 of the class carries index bit 0 set in 40 of 1,024 addresses at site 0 (z 29.5; the sub-block writer a `rotl` of a `mad` result). The structural reason, read from the record: 27 passes of a 16-instruction map immediately before a load is a tight iteration of a small function, and whatever lossy or product arithmetic it carries (or inherits from the base writer before it) collapses or biases the load's address register before any base instruction can re-randomise it; the class v4 shape places the same 6,912 instructions after instruction 63, where the next iteration's 64 base instructions and 16 loads stand between the block and every load. Both exports (854050a4293f0615, bd64b207a30413fb) were accepted only because the rule did not model the placement; their PC 1 rows stay as the energy reading of the placement, labelled "unsound construction, energy reading only". Design 4 (the shadow per load) and the USD 200 M capex row of 16.2 therefore rest on a construction that does not exist yet; the sound form to try is one pass of a 432-instruction sub-block per load (or 64 x 7), the same N, where the sub-block is a program segment rather than an iterated map; it is a new class to draw, accept and measure (kernel text 6,912 lines per iteration against the 1,024-line block's measured 17 percent on the M5 Max, so the footprint is its own gate), not tonight's. The class v4 shape across the same 16 drawn eras reads 0 duplicate pairs and carries the adv-cache-2 product bit at address bit R exactly in 14 of 17 eras on this pre-amendment generator (one-count 250 or 780 of 1,024, z 15 to 19; sites with `mul`, `rotl` and `or` writers), which is that lane's finding replicated by a second instrument.
|
||||
|
||||
### 20.2a-correction (8 October 2026, 10:5x UTC, the invention lane's reading of the per-load tests)
|
||||
|
||||
The 20.2a-close figures (22 of 1,621 candidates accepted, 42 of 64 seeds exhausting 32 attempts) are the NO-ERA figures: the census ran `mx8+shl256x27` with no era draw, so the index was the plain `x AND mask` and every bit was fair to judge. The instrument as first written was wrong under an era: `BiasedIndexBit` judged every index bit, and under an era a narrow-window site's top bits are fixed by design (`verify::window`), so under any drawn era every per-load form read 0 of 256 for the instrument's reason, not the construction's; the invention lane's census (build-1, this crate at 5984ffab) found it. Fixed in this branch at 10:5x UTC: the test judges only the bits inside each site's window mask. The invention lane's own no-era census confirms the construction's figure for the iterated forms (0.989 to 0.990 rejection per candidate for the 16-instruction iterated forms, the 16 x 27 form dead) and finds the sound form: `mx8+shl4096x1` (one pass of a 256-instruction sub-block after every load) accepts 234 of 256 seeds within 32 attempts at 0.927 rejection per candidate (P(exhaust at 256) about 4e-9), the constant-work ladder flat at 0.927 to 0.949 from a 36-instruction sub-block up; the remaining 0.93 is the product's low-bit law at the load's source (bit 0 in 1,563 of 2,329 bias rejections), which class v4's (a') dataflow rule removes at the draw and the per-load class never applied: the named fix. Its verifier on core 40 with core 88 loaded 8.28 to 8.82 ms against 8.33 to 8.63 for class v4's shape in the same minutes. The class v6 document carries the sound form as layer 5 (`docs/design/class-v6-rotating-family.md` section 7c) with its 5090 rows owed from the hash lane's v6 job.
|
||||
|
||||
### 20.2b A named requirement for every CA4 prototype: no biased product bits in an address (the crypto lane's adv-cache-2 reading, 7 October 2026, 22:1x UTC)
|
||||
|
||||
The crypto lane's finding: a product's low bits are biased (P(bit 0) = 1/4, measured exactly), the bias survives the odd stride multiplier, and the stride rotation places the biased bits at address bits R and up, inside the 28-bit item index unless R is 28 or more. The devnet era draws R = 29, which cuts them off, so 31 of 32 devnet-era programs read clean while 6 of 16 drawn-era programs (R from 3 to 22) show a site over 1.04x (2 over 1.2x, the worst 1.51x); under the 2 GiB genesis dataset (D = 29) R = 29 would show it too. The devnet's cleanliness is an accident of its era draw; the chain prevalence is the drawn-era figure; the price to a partial-store chip stays under 0.1 percent of a hash's reads per site, so no chip number moves. The requirement for any class this file proposes (the per-load shadow, the tile block, a re-weighted shadow): (1) a load whose source register's last writer is a product (`mul`, `mulhi`, `mad`) carries biased low bits into the address, and the acceptance must judge it at the VALUE level (the bit bias of the index at the site over the units), not by the index-distinctness ratio alone, which the duplicate-lane test above is; (2) the census of any candidate reads across the drawn eras split by R (3 to 22 against 28 to 31), as 20.2a now does for the per-load class, never the devnet era alone. The per-load class's dynamic rule covers distinctness, not bias; the value-level test is owed and is the same item for class v5's acceptance. Nothing in class v4 or v5 moves on this without main's word.
|
||||
|
||||
### 20.3 The card rows (PC 1, the RTX 5090 alone, the installed worker, 04:04:33 to 04:22:54 UTC, 8 October 2026 (05:04 to 05:22 UK); the hash lane's job `run-ca4-pc1-packs-5090-20261008-b`, 250 batches of 2^24 at one warp per block, nvidia-smi at 1 Hz with the three power fields agreeing, the 1,300 MHz lock through the helper)
|
||||
|
||||
Every pack's self-test PASS at both states (the cache, the dataset, the 96 vector lanes through the bound kernel), so the int8 tile's inline PTX compiles under NVRTC 12.8 on sm_120 and is bit-exact against the Rust verifier on the card, as the Metal reference was on the M5 Max; the 2^24 fingerprints are the same at both states and equal the Mac's where the Mac ran them (sh256x27 3d2e8245cc084d07, shl256x27_v2 ee5d7c71180e5ea7, mm128 270e4ae36b37e9a1, mm512 a1c1ff3148d775d1; mm1430 8e9b7066239d35d1 on CUDA, the Mac row not run).
|
||||
|
||||
| Pack | Unlocked (SM 2,842 to 2,865 MHz): MH/s / W / MH/W / microjoules per hash | Premium over mx8 unlocked | At the 1,300 lock (SM 1,290): MH/s / W / MH/W / microjoules | Premium at the lock | Label |
|
||||
|---|---|---|---|---|---|
|
||||
| mx8-genesis (class v3, the control) | 137.54 / 311.0 / 0.442 / 2.26 | | 127.32 / 213.0 / 0.598 / 1.67 | | measured |
|
||||
| mx8_sh256x27 (class v4's shape) | 137.51 / 462.2 / 0.298 / 3.36 | 151.2 W, 1.10 microjoules, 10.8 pJ per counted op | 126.93 / 295.8 / 0.429 / 2.33 | 82.8 W, 0.652 microjoules, 6.4 pJ per op | measured |
|
||||
| mx8_mm128 (1,024 tiles per hash, 32,768 MACs) | 137.45 / 332.9 / 0.413 / 2.42 | 21.9 W: 4.9 pJ per MAC | 127.01 / 217.8 / 0.583 / 1.71 | 4.8 W: 1.2 pJ per MAC | measured (the per-MAC figures corrected 8 October 2026, 06:xx UTC: 32 MACs per lane per tile) |
|
||||
| mx8_mm512 (4,096 tiles, 131,072 MACs) | 137.50 / 369.4 / 0.372 / 2.69 | 58.4 W: 3.2 pJ per MAC | 127.07 / 235.4 / 0.540 / 1.85 | 22.4 W: 1.3 pJ per MAC | measured (corrected) |
|
||||
| **mx8_mm1430 (11,440 tiles, 366,080 MACs per hash, the ALU shadow's premium in tiles)** | 137.45 / 457.7 / 0.300 / 3.33 | **146.7 W, 1.067 microjoules: 2.9 pJ per MAC**, 0.103 W per tile per iteration, linear within 5 percent | 126.87 / 284.5 / 0.446 / 2.24 | **71.5 W, 0.564 microjoules: 1.5 pJ per MAC** (0.050 W per tile) | measured (corrected: the first reading divided by 1,024 MACs per lane per tile where the tile gives 32) |
|
||||
| mx8_shl256x27 (the first per-load export, 854050a4293f0615: UNSOUND, energy reading only) | 158.62 / 472.8 / 0.336 | +161.8 W at a rate 15 percent OVER the control (the 11 percent duplicate reads land in L2) | 145.65 / 299.4 / 0.487 | +86.4 W | measured; the construction is dead (20.2a-close) |
|
||||
| mx8_shl256x27_v2 (the fixed export, bd64b207a30413fb: UNSOUND, energy reading only) | 135.90 / 448.3 / 0.303 | +137.3 W, 14 W under the whole-block shape at the same instruction count (the 16-instruction block effect) | 126.04 / 282.9 / 0.446 | +69.9 W, 13 W under the whole block | measured; dead as a class |
|
||||
|
||||
NVRTC compile per pack: mx8 190 ms, sh256x27 269, mm128 331, mm512 869, mm1430 1,239 unlocked and 2,107 at the lock (inside the epoch's compile-ahead budget, the 600-s floor's 38 s). The lock rows read 127 MH/s against the efficiency pass's 134 at the same clock: the installed worker at one warp per block against the app's tuned variant; every ratio here is within one run.
|
||||
|
||||
What the rows say:
|
||||
|
||||
1. **The hash rate is memory-bound on every sound pack at both states** (137.4 to 137.5 unlocked, 126.9 to 127.3 at the lock, within 0.5 percent of the control): 11,440 int8 tiles per hash are free in rate on the 5090, so the 4090's free band (R = 512) extends to at least R = 1,430 on the 5090, as section 15.2 scaled.
|
||||
2. **The tile block carries the ALU shadow's premium at the same hash rate and at a lower cost at the knee**: 146.7 W against 151.2 W unlocked (3 percent under), 71.5 W against 82.8 W at the 1,300 lock (14 percent under). The 5090's energy per MAC at full tile load is 2.9 pJ unlocked and 1.5 pJ at the lock (the 4090's 6 October figure, re-read with 32 MACs per lane per tile, is 1.8 pJ), with the fixed cost of the tensor path visible at R = 128 (4.9 pJ unlocked). The microbench (15.1a) reads the same path at 4.1 and 2.2 pJ per MAC on a dependent u8 chain and 1.36 and 0.83 on the wide s8 tile at 80 percent of peak.
|
||||
3. **The GPU's measured cost per MAC is NOT inside the band of what a 5 nm MAC array costs** (NVIDIA's own test chip at 0.46 V: 0.021 pJ per INT4 MAC, about 0.04 to 0.08 per INT8-class MAC; at nominal voltage about 5x that; JSSC 2023 via Dally's slides, claimed): against the 5090's 1.5 to 2.9 pJ per MAC the chip's `k` on tile work reads about 0.03 to 0.3 (approximate), BELOW the ALU shadow's 0.3 to 0.8. The earlier reading of this row (k 0.9 to 1.7) rested on the factor-of-32 error corrected in 15.1a.
|
||||
4. The per-load placement is cheaper per instruction than the whole block (the 16-instruction block effect: 13 to 14 W under the whole block for the same 55,296 instructions per hash) and the construction is dead (20.2a-close); the first export's 15 percent rate gain is the duplicate reads served from L2, the fault made visible on the card.
|
||||
|
||||
### 20.3a The SM-sparse job's first run (PC 1, 00:22 to 00:41 UTC, 8 October 2026): no variant ran; the knee rows it did read
|
||||
|
||||
The hash lane's run `run-ca4-pc1-ca4sparse-5090-20261007` (the ca4sparse exe, the 5090 alone, class v3 and v4 packs, unlocked and at the 1,300 MHz lock, 48 rows, exit 0, every fingerprint equal to the Mac's, self-test PASS) served the BASE kernel on every row: the worker's `--bench` path turned the race off and built the pair without one, so `--variant sp43-w32` was parsed and never applied ("race 0 ms variant base" on all 48 rows, no NVRTC error because NVRTC never saw the variant). The numbers confirm it: every sparse row's rate equals base (v4 137.0 to 137.1 MH/s unlocked, 133.8 to 134.3 at the lock; v3 136.7 to 136.8 and 133.8 to 134.0) and the draw drifts with heat, not with the SM count (v4 unlocked 465.5 W at 69 C on the first row rising to 485.2 W at 76 C on the sixth; v3 331.6 falling to 319.2 W as the card cooled from 71 to 62 C). The SM-sparse question is unanswered by this run. The fix (worker.cpp, this branch, 8 October 2026, 00:xx UTC): a `--bench` with `--variant <name>` runs the pinned race (base and the named variant only, no timing, the variant installed whatever its speed) and the RESULT line carries `variant=`, `sparse_blocks=` and `block_warps=`; a run whose served variant is not the requested one prints a `variant_not_installed` line, which is the known-failed case of this fix. The rerun is the hash lane's queue slot.
|
||||
|
||||
What the run did read, and keeps (the 5090 at the 1,300 MHz lock, base kernel, measured): class v4 134.26 MH/s at 309.9 W (0.433 MH/W, SM 1,290 MHz), class v3 134.03 at 219.4 W (0.611 MH/W); the class v4 premium 90.5 W at the lock against 133.9 W unlocked on this run (145.3 on the efficiency pass; the unlocked v4 rows ran hot, 465 to 485 W). The premium per counted op at the lock: 90.5 W over 134.26 M x 102,100 = 6.6 pJ (6.5 on the efficiency pass).
|
||||
|
||||
### 20.3b The SM-sparse reading (PC 1, the 5090 alone, 04:35:49 to 05:12:48 UTC, 8 October 2026 (05:35 to 06:12 UK); the hash lane's job `run-ca4-pc1-ca4sparse-5090-20261008-c` on the fourth exe, sha256 a4550202...): the candidate is dead
|
||||
|
||||
The run as a gate: the card-free check on the card's own exe read `variants=2 names=base,sp43-w32`, `rewrite=applied`, `call="igneum_hash_bound_unit(ds, out, baseNonce, mask, iw, gid)" names_match=1`, `nvrtc64_120_0.dll sm_120 compiled=1 image_bytes=31384`; every sparse row served its variant (`served=sp<N>-w32 sparse_blocks=N`, no "compile:" text) and every fingerprint on every row equals the Mac's (class v4 e370fb2080b7dbb1, class v3 90f794dd556f7a3b), so the rewritten persistent kernel is bit-exact. 32-second rows, the three power fields agreeing, idle 74 W; the drift check at the end repeats the unlocked rows within 1 W and 0.2 MH/s.
|
||||
|
||||
| Variant (blocks of 32 warps, about one per SM) | Class v4 unlocked: MH/s / W / MH/W | Class v3 unlocked | Class v4 at the 1,300 lock | Class v3 at the 1,300 lock | Label |
|
||||
|---|---|---|---|---|---|
|
||||
| base (the plain grid, one warp per block) | 137.07 / 450.8 / 0.304 | 136.77 / 313.9 / 0.436 | 134.31 / 301.0 / 0.446 | 134.04 / 211.4 / 0.634 | measured |
|
||||
| sp170-w32 (the persistent shape on every SM) | 136.09 / 461.2 / 0.295 | 136.02 / 313.3 / 0.434 | 128.95 / 297.6 / 0.433 | 129.20 / 208.2 / 0.621 | measured |
|
||||
| sp85-w32 (half the SMs) | 136.29 / 461.7 / 0.295 | 136.87 / 310.7 / 0.441 | 125.37 / 290.1 / 0.432 | 129.80 / 207.8 / 0.625 | measured |
|
||||
| **sp43-w32 (a quarter)** | **134.58 / 460.1 / 0.293** | **136.55 / 309.8 / 0.441** | 64.33 / 208.2 / 0.309 | 126.25 / 205.4 / 0.615 | measured |
|
||||
| sp21-w32 (an eighth) | 70.75 / 327.4 / 0.216 | 132.82 / 303.4 / 0.438 | 31.48 / 155.2 / 0.203 | 84.78 / 174.9 / 0.485 | measured |
|
||||
| sp11-w32 (a sixteenth) | 37.34 / 250.9 / 0.149 | 100.14 / 274.5 / 0.365 | 16.55 / 116.7 / 0.142 | 44.57 / 147.5 / 0.302 | measured |
|
||||
|
||||
The two readings the design asked for. (1) The shape itself: sp170-w32 against base costs 0.7 percent of rate on class v4 and 0.5 on v3, +10 W on v4 and 0 W on v3: within noise. (2) Watts minus idle per MH/s against base: class v4 unlocked base 2.75 W per MH/s, sp43 2.87, sp21 3.58, sp11 4.74; class v3 unlocked base 1.75, sp43 1.73, sp21 1.73, sp11 2.00. A quarter of the SMs holds 98.2 percent of the class v4 rate at the SAME draw (460 W against 451) and 99.8 percent of the class v3 rate at 4 W less; the draw falls only when the rate falls, and the energy per hash never falls below base on either class. **So the SM-side 99 W of section 2 is not activity that idle SMs would save: the card's draw follows the work, not the SM count; the ALU work per hash costs the same on 43 SMs as on 170, and the 43 SMs run it at the same energy.** The candidate is dead by its own rule (watts track the work one for one), and the lock rows say the same from the other side (class v4 sp43 at 1,300 MHz is compute-bound at 64 MH/s: the shadow's ops are real throughput work). The one number kept for the chip model: the class v4 premium at sp43 unlocked, 150.3 W over class v3 at a held rate, equal to the full-card premium (136.9 W base, 151.2 on the packs job), so the shadow's energy does not depend on how many SMs carry it; it is the energy of the ops.
|
||||
|
||||
What this closes in the ranking: rank 2 (the SM-sparse miner kernel) is dead; the premium-free floor of section 0 stays the card's idle plus its memory system plus whatever `E_wait` is at the knee, and the only miner-side lever on `E_card` is the operating point (rank 1). The 5090's four runs of this night (three failed, one read) also gave four repeats of the knee pass: the class v4 premium 133.9 to 151.2 W unlocked and 82.8 to 90.5 W at 1,300 MHz, with every one of the three power fields agreeing within 0.2 W.
|
||||
|
||||
### 20.3c The hot-table rows (PC 1, the 5090, the hash lane's job `run-ca4-pc1-hot-ldcs-5090-20261008b`, 36 rows all PASS, bench-log "8 October 2026, the hot-table packs on the RTX 5090", master c09dfee4 at 07:12 UTC): a record, and the call
|
||||
|
||||
(1) `--variant ldcs` (streaming dataset loads) equals base on every pack in both states within 0.1 MH/s and 1 W: the cache-policy hint is a dead lever, as 15.1a's L2 row already said. (2) At the 1,300 lock the replaced-form hot packs lose 2 percent of rate where mx8 loses 7.5 (hot32k4 147.4 to 144.4 MH/s, hot64k8 164.2 to 160.9, mx8 137.7 to 127.3) while every pack drops a third of its watts (319 to 215 W): the hot packs are latency-bound on the table. (3) Per watt at the lock: hot64k8 0.734 MH/W, hot32k4 0.671, hot64k4 0.645, hot96k4 0.635, hot64k2 0.630, the mx8 control 0.602 (equal to the efficiency pass's 0.60, so the rows are comparable), the added-form packs 0.52 to 0.54.
|
||||
|
||||
The call, on the identity: a hash 22 percent cheaper on the GPU (hot64k8, 1.36 against 1.66 microjoules at the lock) is a LOSS for resistance in the replaced form, not a gain, because the saving sits in the one path where the chip is cheaper still. The GPU saves 0.30 microjoules by moving 64 of its 128 reads from DRAM (8.7 nJ per read measured, 15.1a) to L2 (1.4 nJ): 4.7 nJ per replaced read; the f = 1 chip moves the same 64 reads from GDDR7 (2.0 nJ, modelled) to on-die SRAM (0.2 to 0.5 nJ, approximate), saving about 1.6 nJ per read, 0.10 microjoules of its 0.466, and, because its rate is bound by the memory's activate ceiling (166 MH/s on the 5090's 16 devices), halving its DRAM reads per hash doubles its rate on the same memory and halves its capex per MH/s. The edge per joule at the lock: mx8 1.66 / 0.466 = 3.6x; hot64k8 about 1.36 / 0.36 = 3.8x (modelled chip, approximate); per dollar the chip's 2.8 USD per MH/s becomes about 1.5. This is the 5 October finding (the replaced form "helps the on-die-cache chip, x1.33 at k = 4", counter-asic-2.md decision 5) re-read against the stored-dataset chip with measured GPU energies: the replaced form is a per-watt gift to every miner and a larger one to the chip. The added form ("a" packs, 0.52 to 0.54 MH/W) costs the GPU rate for no chip cost, as chip-model-v3 section 3 said. Nothing moves.
|
||||
|
||||
### 20.4 Into the chip model (the rows measured; the chip side modelled: f = 1 GDDR7 0.466 microjoules per hash, one HBM3 stack 0.321)
|
||||
|
||||
Energy per hash = the card's measured; the chip's = `E_mem + k x F`, `F` the measured premium per hash, `k` the chip's cost per forced operation over the 5090's at the same state.
|
||||
|
||||
| Row (the 5090) | Card microjoules | Premium F | Chip edge, GDDR7, at k = 0.3 / 0.5 / 1 / 1.5 / 2 | HBM3 one stack at k = 1 | The honest k band for the work | Break-even cap, years 1 to 2 (the mission lane's model) | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| class v3, unlocked | 2.26 | 0 | 4.85x at every k | 7.0x | | USD 17 M (28 nm controller) | measured card |
|
||||
| class v3, 1,300 lock | 1.67 | 0 | 3.59x | 5.2x | | USD 17 M | measured |
|
||||
| class v4 shape, unlocked | 3.36 | 1.10 | 4.21x / 3.31x / 2.15x / 1.59x / 1.26x | 2.37x | ALU: 0.3 to 0.8 | USD 100 M (an N5 shadow core) | measured; the served figures 2.1x at k = 1, 3.4x pessimistic |
|
||||
| class v4 shape, 1,300 lock | 2.33 | 0.652 | 3.52x / 2.94x / 2.08x / 1.62x / 1.32x | 2.39x | ALU: 0.3 to 0.8 | USD 100 M | measured |
|
||||
| **tile block mm1430, unlocked** | 3.33 | 1.067 | 4.23x / 3.33x / 2.17x / 1.61x / 1.28x; **at the measured k 0.03 to 0.3: 4.2x to 6.7x** | 2.40x | **int8 MAC: 0.03 to 0.3 (corrected 15.1a)**: the chip's array undercuts the tensor core 4x to 30x per MAC | USD 100 M (a licensable MMA block is USD 4 of N5; the N5 die is forced as for class v4) | measured card; the lever is WORSE than the ALU shadow |
|
||||
| **tile block mm1430, 1,300 lock** | 2.24 | 0.564 | 3.46x / 2.98x / 2.17x / 1.73x / 1.42x; **at k 0.03 to 0.3: 3.5x to 4.6x** | 2.53x | the same | USD 100 M | measured; worse than the ALU shadow's 3.5x at k 0.3 only at the very bottom of its band, and the band's centre is under 0.1 |
|
||||
| the per-load shape (the fixed export) | 3.30 / 2.24 | 0.998 / 0.555 | as the class v4 shape within 5 percent | | ALU | USD 200 M if the sound form exists (16.2) | dead as drawn (20.2a-close) |
|
||||
|
||||
The k column was priced at this point: section 15.2 set the tile count from the 4090's 6 October figure so that the block would carry the ALU shadow's 0.654 microjoules (R about 1,430), and the exported pack is 1,430 tiles per iteration, 11,440 per hash; the 5090 reads 2.9 pJ per MAC unlocked and 1.5 at the lock at that point (15.1a's correction: 32 MACs per lane per tile), so the GPU-side cost of the chip-model-v3 5.11 column is measured at the premium it was priced for (1.067 microjoules unlocked, 0.564 at the lock, against the ALU shadow's 1.10 and 0.652), and the column's premise (a chip's MAC no cheaper than the GPU's) is false by 4x to 30x on the public chip figures. Reading, corrected: at `k = 1` the tile block and the ALU shadow give the same 2.1x to 2.2x and the tile block's premium at the knee is 14 percent lower for it; but `k = 1` is not where a chip sits on tile work. The GPU's tensor core costs 1.5 to 4 pJ per int8 MAC measured, a 5 nm MAC array 0.04 to 0.4 claimed, so the chip's `k` on tiles is 0.03 to 0.3 against the ALU shadow's 0.3 to 0.8: at the same premium the tile block leaves the chip 3.5x to 6.7x where the ALU shadow leaves it 2.1x to 3.5x. **The tile block does not beat class v4's premium (it matches it, 14 percent cheaper at the knee) and does not beat class v4's chip edge at any k a chip can reach; it is the worse lever, and the 6 October verdict on scheme B stands for the right reason now (the honest card's tensor path is not near the floor; the chip's is).** The Apple side (35 to 78 percent of the M5 Max's rate at 1,024 and 4,096 tiles), the verifier (AVX2 0.047 microseconds per tile per unit, `mm1430` 10.14 ms loaded) and the AMD layout no longer need deciding; nothing served moves (the chip texts rest on the ALU shadow).
|
||||
|
||||
**The first sentence, on measured rows (06:xx UTC, 8 October 2026, after the microbench's correction): neither prototype beats class v4's premium or its chip edge; the tile block matches the premium at the same hash rate (146.7 W against 151.2 W unlocked, 71.5 against 82.8 W at the 1,300 lock) and is the worse lever against a chip, because the 5090's int8 MAC costs 1.5 to 4 pJ measured where a 5 nm array costs 0.04 to 0.4 claimed (k 0.03 to 0.3, under the ALU shadow's 0.3 to 0.8); the per-load placement is dead as a construction; the SM-sparse kernel is dead on measured rows (the draw follows the work, not the SM count); the L2 hot table is dead on the GPU side (an L2 hit costs 1.4 to 2.4 nJ against a chip's 0.2 to 0.5); and a shuffle-heavy re-weight costs the GPU 5x the add per op (55.8 pJ), so it stays held. The ALU shadow at the operating point's knee is the floor the night leaves: 2.1x at k = 1 for 82 to 90 W on a 5090, measured four times.**
|
||||
|
|
@ -1065,6 +1065,44 @@ UTC. Its node took the 38,000 headers and blocks from one peer, the seed node, i
|
|||
Atlantic. The only card is an Intel UHD integrated GPU: the OpenCL worker runs at 1.46 MH/s and found one block in its
|
||||
first four minutes; the identity's votes on checkpoints 1202 and 1203 were accepted by the network, so a laptop with no
|
||||
discrete card takes part in finality. Machine id 37ba0461 in the console; app log run `win-37ba0461-20261004-185342`.
|
||||
|
||||
## 5 October 2026, Igneum Wallet v1 on a test network: created, paid by the miner, a transfer sent and shown final under a checkpoint the wallet verified itself
|
||||
|
||||
The first run of Igneum Wallet (app/igneum-wallet, branch wallet-v1) against a private network: one devnet-v4
|
||||
integration `igneumd` on 127.0.0.1 ports 29580 to 29583 (network id igneum-devnet-958), the fast-time profile with
|
||||
`genesis_bits` lowered to 0x207fffff so one CPU thread of the devnet-v4 `igneum-miner` (stub engine, `--hold-ms 1000`)
|
||||
made about one block a second, two wallet engines (release build) pointed at that node through
|
||||
IGNEUM_WALLET_GRPC_PORT / IGNEUM_WALLET_EVM_PORT and driven over their own window API by
|
||||
`tools/wallet-testnet/run.mjs` under `tools/lock/with-lock.sh run`. Summary in /tmp/igneum-wallet-testnet/summary.json.
|
||||
|
||||
| Step | Wall time from the node's start | What was seen |
|
||||
|---|---|---|
|
||||
| Wallet A created (24 words, three typed back) | 0.3 s | address 0x190de714...9155 |
|
||||
| Wallet B: a wrong word refused, then created | 0.4 to 0.5 s | "word 1 is not right" |
|
||||
| Miner started, paying to A (`--evm-address`) | 0.5 s | |
|
||||
| A's balance from block rewards | 3.5 s | 10.14 IGN at block 4 |
|
||||
| Block rewards in A's history | 4.5 s | 4 listed, 172 by the end |
|
||||
| Zero address, 999,999,999 IGN, a short address | 4.5 s | all three refused by the quote |
|
||||
| Quote for 1.5 IGN to B | 4.6 s | gas 25,380; base fee 1 gwei; tip 1 gwei; fee at most 0.00007614 IGN |
|
||||
| Sent | 4.6 s | 0x121e5e6f...6469 |
|
||||
| In a block | 5.6 s | chain block 8 |
|
||||
| First locked checkpoint on the network | about 170 s | index 5 (fast-time: window 120 DAA plus depth) |
|
||||
| Final in wallet A | 175.2 s | under checkpoint 5, certificate verified by the wallet: 1 of 1 voters, 100% of total weight, weights at the checkpoint, chain height 150 |
|
||||
| B's view of the same transfer | 175 s | 1.5 IGN, final |
|
||||
|
||||
Send to final: 170.6 s, all of it the network's first lock. The wallet verified the certificate with the node's own
|
||||
code (`kaspa_consensus_core::finality::verify_aggregate` over the bitmap's keys, key hashes recomputed, canonical
|
||||
order checked, 2/3 of active and 2/3 of total weight) and placed the transaction under it by the checkpoint block's
|
||||
selected-chain height from the Ethereum RPC; the node's own `igneum_getTransactionStatus` still said `locked: false`
|
||||
(that field is not wired on this node line), which is why the wallet never reads it. Unit tests: 13 in the wallet
|
||||
(BIP-39 vector 1, the Hardhat phrase at m/44'/60'/0'/0/0, raw-key import, vault round trip and wrong password, RLP
|
||||
vectors, signing recovers to the signer and is deterministic, the finality state machine, a certificate made with real
|
||||
BLS keys verifies while a flipped byte, a wrong chain id, a thin quorum and a short voter list do not), 14 in
|
||||
igneum-common. Node source order in the engine: the miner app's node on 26790/26610 first, else the environment's
|
||||
node, else the bundled igneumd on 26620/26800/26621, else the packaged public RPC (none yet). Not tested tonight: the
|
||||
bundled-node path against the live devnet, the Windows host, the OTA manifest for the wallet. Mac app and DMG built
|
||||
under the build lock: packaging/mac/dist/Igneum-Wallet-0.1.0.dmg (19 MB); not deployed, no manifest published.
|
||||
|
||||
## 4 October 2026 (evening), finality rule v3: the frozen weight table (F21) and the certificate fold (F22), simulator, unit tests, fast-time 3-node network with 300-ms links (finality engineer)
|
||||
|
||||
The founder, 4 October 2026 evening: "we need to fix these serious issues before making things public". Both fixes sit behind one height switch, `finality_v3_activation_daa` (default never on every network, set by the override file like `difficulty_v2_activation_daa`), on branch `finality-fixes` of the node (worktree `vendor/igneum-node-finality`, from the proving head `8c0cff15`). Spec 03 Q4 (fold) and Q5 (frozen table), 3.3.1, 3.7 items 2 and 9, 3.10, 3.11; ledger F21 and F22 "Fix built, pending rollout"; the devnet plan in `docs/plans/finality-v3-rollout-devnet.md`. The live devnet was never touched; every network below ran on ports 29700 to 29799, suffix 970.
|
||||
|
|
|
|||
6
docs/build/build.md
vendored
|
|
@ -16,13 +16,13 @@ This file is the source of [igneum.network/build](https://igneum.network/build).
|
|||
|
||||
| | Devnet 3 | Testnet | Mainnet |
|
||||
|---|---|---|---|
|
||||
| Chain id | 4463 (`0x116f`) | 4462 (`0x116e`) | 4461 (`0x116d`) |
|
||||
| Chain id | 4464 (`0x1170`) since its class v5 floor on 8 October 2026; 4463 below it | 4462 (`0x116e`) | 4461 (`0x116d`) |
|
||||
| Network id | `igneum-devnet-3` | `igneum-testnet-1` | not started |
|
||||
| RPC | `https://rpc.devnet.igneum.network` (a node you run serves `http://127.0.0.1:26790`) | `https://rpc.testnet.igneum.network` (answers; nothing mines there yet, so a transaction waits) | none |
|
||||
| Coins | no value, resets without notice | no value, resets with notice | not started |
|
||||
| Symbol, decimals | IGN, 18 | IGN, 18 | IGN, 18 |
|
||||
|
||||
Devnet 3 is where you build today. It is a developer network: it resets without notice and its coins have no value. Its public RPC takes the read methods, `eth_sendRawTransaction` and a wRPC websocket at `/ws`, at 20 requests a second per address. The public testnet exists, its RPC answers, and no blocks are being produced on it until it opens. Mainnet has no date. Devnet 3 answers chain id 4463 until its class v5 floor, then 4464.
|
||||
Devnet 3 is where you build today. It is a developer network: it resets without notice and its coins have no value. Its public RPC takes the read methods, `eth_sendRawTransaction` and a wRPC websocket at `/ws`, at 20 requests a second per address. The public testnet exists, its RPC answers, and no blocks are being produced on it until it opens. Mainnet has no date. Devnet 3 answered 4463 until its class v5 floor at DAA 68,400 on 8 October 2026 and 4464 from it; read it with `eth_chainId` rather than fixing it, since a transaction signed for the wrong id is refused.
|
||||
|
||||
## Endpoints and tools
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ cast send 0xDEPLOYED "increment()" --rpc-url $RPC --private-key $PK
|
|||
cast call 0xDEPLOYED "number()(uint256)" --rpc-url $RPC
|
||||
```
|
||||
|
||||
The call answers `1`. Paste the transaction hash into [the explorer](/explorer). Hardhat, ethers and viem work the same way: chain id 4463, the RPC above.
|
||||
The call answers `1`. Paste the transaction hash into [the explorer](/explorer). Hardhat, ethers and viem work the same way: chain id 4464, the RPC above.
|
||||
|
||||
## The RPC the node serves
|
||||
|
||||
|
|
|
|||
2
docs/build/faucet.md
vendored
|
|
@ -5,7 +5,7 @@
|
|||
## What it is
|
||||
|
||||
- A small Node service on a build box, `infra/build-server/faucet/faucet.mjs`, behind Caddy at `faucet.igneum.network`. The page on igneum.network posts to it.
|
||||
- It signs a plain transfer with its own key and sends it to the Devnet 3 node on the same box by address (`FAUCET_RPC`), never chosen by chain id: Devnet 3 answers the same chain id as the shared devnet.
|
||||
- It signs a plain transfer with its own key and sends it to the Devnet 3 node on the same box by address (`FAUCET_RPC`), never chosen by chain id. The chain id it signs with is read from that node on every send: Devnet 3 moved from 4463 to 4464 at its class v5 floor on 8 October 2026.
|
||||
- The key lives only in the service's environment file on that box. It is not in the repository and not on the site.
|
||||
|
||||
## The rules, as the code enforces them
|
||||
|
|
|
|||
8
docs/build/first-contract.md
vendored
|
|
@ -10,7 +10,7 @@ With Foundry. Tested end to end on Devnet 3 from a build box on 8 October 2026;
|
|||
|
||||
```
|
||||
export RPC=https://rpc.devnet.igneum.network
|
||||
cast chain-id --rpc-url $RPC # 4463
|
||||
cast chain-id --rpc-url $RPC # 4464 (4463 before the class v5 floor of 8 October 2026)
|
||||
```
|
||||
|
||||
## 1. A key, for the devnet only
|
||||
|
|
@ -89,3 +89,9 @@ PASS 10:17:23Z: Devnet 3 chain id 4463, faucet drip 0x8620e9d894484e824e4ac33002
|
|||
```
|
||||
|
||||
Through the public endpoints, `https://rpc.devnet.igneum.network` and `https://faucet.igneum.network`, from the box: the first faucet drip and the first reader-path deploy on Devnet 3.
|
||||
|
||||
After the class v5 floor moved the chain id to 4464 the same afternoon, from a second box:
|
||||
|
||||
```
|
||||
PASS 12:38:39Z: Devnet 3 chain id 4464, faucet drip 0xa54fdb7e964dab095aaccaab36ea548fc3fa8727acb809b593c2d4d1359c233f, deployed Counter at 0x7de503a4F39feEBCE8c4ABbAcC04282EE75120Fe (tx 0x6ccdbb4f6393fcf09e58ba2f4de1af98c547eeab6f02d93eacfebae883200dc6), increment then setNumber(41) read back 41, receipt status 1 (success), block 29878, 12 s end to end, Foundry 1.8.5
|
||||
```
|
||||
|
|
|
|||
4
docs/build/rpc.md
vendored
|
|
@ -9,9 +9,9 @@ Transport: HTTP POST, JSON-RPC 2.0, one request per call, no batches tested. The
|
|||
| Method | Note |
|
||||
|---|---|
|
||||
| `web3_clientVersion` | `igneumd/<version>/execution-layer-v3` |
|
||||
| `net_version` | the chain id as a decimal string, `"4463"` |
|
||||
| `net_version` | the chain id as a decimal string, `"4464"` (`"4463"` before the class v5 floor) |
|
||||
| `net_listening`, `net_peerCount` | |
|
||||
| `eth_chainId` | `0x116f` (4463) on Devnet 3; `0x1170` after its class v5 floor |
|
||||
| `eth_chainId` | `0x1170` (4464) on Devnet 3 since its class v5 floor at DAA 68,400 on 8 October 2026; `0x116f` (4463) below it. Read it, never fix it |
|
||||
| `eth_blockNumber` | the executed chain tip |
|
||||
| `eth_syncing` | `false` when the executor is at the tip |
|
||||
| `eth_mining` | `false` from the RPC's point of view: mining is the miner's, not the node's |
|
||||
|
|
|
|||
|
|
@ -1,24 +1,61 @@
|
|||
{
|
||||
"network": "igneum-devnet-3",
|
||||
"chain_id": 4463,
|
||||
"chain_id_hex": "0x116f",
|
||||
"chain_id": 4464,
|
||||
"chain_id_hex": "0x1170",
|
||||
"note": "Devnet 3, test tokens, no value. The chain may reset; these addresses go with it.",
|
||||
"rpc": "https://rpc.devnet.igneum.network",
|
||||
"deployed_at": "2026-10-08T10:50:00Z",
|
||||
"deployer": "0x07DD4DBca5c1a66755AF28BACCA1D901a2D209aA",
|
||||
"source": "contracts/dex (Foundry, solc 0.8.28, evm_version paris, optimizer 200 runs)",
|
||||
"contracts": {
|
||||
"WIGN": { "address": "0x47447783D00e1760Eb815a34707b611D53f2A51e", "tx": "0x9fc48e0e3aa6ccdc41ff72381e1986d617722e22cd2b5f0f9f1e988eef60b2d6", "block": 26917 },
|
||||
"TTA": { "address": "0x9ad4F0435f9172A89C6765eE8631CAB2Db3B5052", "tx": "0xa16b6a6501d2705b7c7dc4cf01f8b65b87270e5f1f90b4638b19f120b4830dc0", "block": 26919, "name": "Test Token A", "faucet": "drip() mints 1,000 TTA to the caller once an hour" },
|
||||
"TTB": { "address": "0x996e3042089E80029348d7836472DB34C21841c1", "tx": "0x63eca8238d7f2c29e67c5aad33b0f1cb341d233f2ae0922621e4ba09d94eca61", "block": 26921, "name": "Test Token B", "faucet": "drip() mints 1,000 TTB to the caller once an hour" },
|
||||
"IgneumFactory": { "address": "0x09FF42dbb20448ddB673fD34F973a7D93E7139A8", "tx": "0xd25223b5f1adcba415e51a0936c1ba3e9ae1dcbb1299ec5c4a91293e41ace3bb", "block": 26923 },
|
||||
"IgneumRouter": { "address": "0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7", "tx": "0xabce2fcac81f87fdca02ac4528d4029ebbe1e6d49304dbe6427e1320f2e17b27", "block": 26924 }
|
||||
"WIGN": {
|
||||
"address": "0x47447783D00e1760Eb815a34707b611D53f2A51e",
|
||||
"tx": "0x9fc48e0e3aa6ccdc41ff72381e1986d617722e22cd2b5f0f9f1e988eef60b2d6",
|
||||
"block": 26917
|
||||
},
|
||||
"TTA": {
|
||||
"address": "0x9ad4F0435f9172A89C6765eE8631CAB2Db3B5052",
|
||||
"tx": "0xa16b6a6501d2705b7c7dc4cf01f8b65b87270e5f1f90b4638b19f120b4830dc0",
|
||||
"block": 26919,
|
||||
"name": "Test Token A",
|
||||
"faucet": "drip() mints 1,000 TTA to the caller once an hour"
|
||||
},
|
||||
"TTB": {
|
||||
"address": "0x996e3042089E80029348d7836472DB34C21841c1",
|
||||
"tx": "0x63eca8238d7f2c29e67c5aad33b0f1cb341d233f2ae0922621e4ba09d94eca61",
|
||||
"block": 26921,
|
||||
"name": "Test Token B",
|
||||
"faucet": "drip() mints 1,000 TTB to the caller once an hour"
|
||||
},
|
||||
"IgneumFactory": {
|
||||
"address": "0x09FF42dbb20448ddB673fD34F973a7D93E7139A8",
|
||||
"tx": "0xd25223b5f1adcba415e51a0936c1ba3e9ae1dcbb1299ec5c4a91293e41ace3bb",
|
||||
"block": 26923
|
||||
},
|
||||
"IgneumRouter": {
|
||||
"address": "0x9a6fA842C4e58A87AEF1F3aD15233d99283002B7",
|
||||
"tx": "0xabce2fcac81f87fdca02ac4528d4029ebbe1e6d49304dbe6427e1320f2e17b27",
|
||||
"block": 26924
|
||||
}
|
||||
},
|
||||
"pairs": {
|
||||
"TTA_WIGN": { "address": "0x5352Fc2723014Bb45A5b1767Df549DA755773A2B", "seeded": "500 TTA beside 100 IGN", "block": 26969 },
|
||||
"TTB_WIGN": { "address": "0x93Db70744e200D647f783C3953dF35d7394EdBC5", "seeded": "500 TTB beside 100 IGN", "block": 26970 },
|
||||
"TTA_TTB": { "address": "0xF35775db56dE4f1Cd8Ed7ea7CAb8D017d584a96F", "seeded": "500 TTA beside 500 TTB", "block": 26972 }
|
||||
"TTA_WIGN": {
|
||||
"address": "0x5352Fc2723014Bb45A5b1767Df549DA755773A2B",
|
||||
"seeded": "500 TTA beside 100 IGN",
|
||||
"block": 26969
|
||||
},
|
||||
"TTB_WIGN": {
|
||||
"address": "0x93Db70744e200D647f783C3953dF35d7394EdBC5",
|
||||
"seeded": "500 TTB beside 100 IGN",
|
||||
"block": 26970
|
||||
},
|
||||
"TTA_TTB": {
|
||||
"address": "0xF35775db56dE4f1Cd8Ed7ea7CAb8D017d584a96F",
|
||||
"seeded": "500 TTA beside 500 TTB",
|
||||
"block": 26972
|
||||
}
|
||||
},
|
||||
"fee": "0.3 percent per swap, kept in the pool",
|
||||
"gas_note": "Devnet 3 charges the proving dimension inside the execution gas: a local EVM estimate runs out (drip() failed at 133,603 gas in block 26925; the node's eth_estimateGas said 685,513). Send with the node's own estimate (forge script --skip-simulation, or eth_estimateGas before every send)."
|
||||
"gas_note": "Devnet 3 charges the proving dimension inside the execution gas: a local EVM estimate runs out (drip() failed at 133,603 gas in block 26925; the node's eth_estimateGas said 685,513). Send with the node's own estimate (forge script --skip-simulation, or eth_estimateGas before every send).",
|
||||
"chain_id_note": "4463 (0x116f) below the class v5 floor; 4464 (0x1170) from DAA 68,400 (crossed 8 October 2026, read 13:27 BST); the AMM was deployed under 4463 and the contracts are unchanged by the floor; read eth_chainId, never fix it"
|
||||
}
|
||||
|
|
|
|||
484
docs/design/class-v6-rotating-family.md
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
# Class v6, the rotating family: four layers that change the hash on a schedule no release carries
|
||||
|
||||
8 October 2026, 10:0x to 17:xx UTC (11:0x to 18:xx UK), branch `counter-asic-4`, the Counter ASIC 4.0 research lane under the Counter ASIC coordinator, the hash lane for the measured per-tier rows. Opened on the founder's question of 11:0x UK: "can we add in any more layers? class rotating? things that would render an ASIC useless as soon as it dropped." Status: a design document and a gate plan; no consensus code this week; nothing here touches the devnet, the testnet object or any served number. Every figure carries a label: **measured** (a card on a named job), **modelled** (arithmetic on the chip model's cited figures, `docs/analysis/chip-model-v3.md`), **claimed** (a vendor's or an author's figure), **approximate** (from memory or a scaling). Outline and layer table at 10:5x UTC; the hash lane's rows due 16:00 UK; the document complete by 18:00 UK; at each clock the default lands with the gaps named.
|
||||
|
||||
## 0. For the founder tonight: what each layer does to a chip on its release day, and what it costs a 5090, a 5070 Ti and an M5 Max
|
||||
|
||||
**The lead, as main ordered it (11:2x UK): the strongest chip in the five-year window is not a DRAM chip and no rotating layer reaches it.** Lane B's reading (`docs/analysis/class-v6/hardware-future.md`, master 34f63b3c; carried into the chip model as section 5.12): a 2 GiB SRAM full store on one reticle of merchant N2 (452 mm^2 of macro at 38 Mb/mm^2, claimed) reads about 2,100 MH/s per die at 300 W, 0.14 microjoules per hash, 17x the 5090 at its stock point per joule at zero shadow (8x to 30x on the read-energy band), 5.6x the M5 Max; with the class v4 shadow 4.8x at a core half as costly as a GPU lane and 2.7x at `k = 1`, and at the honest card's whole latency shadow 3.7x and 2.0x; USD 400 to 600 of silicon per die (USD 0.25 to 0.4 per MH/s), an N2 project of USD 100 M to 500 M and 18 to 24 months (claimed), which on the mission lane's model is a break-even cap of about USD 330 M to 1.7 B in the chain's first two years. Beside it a custom HBM4E base die (2028 or later) at 6.5x to 14x, untouched by all four layers; per-bank processing in memory structurally blind to the hash (1.6 percent of reads in-bank at 2 GiB). All modelled on the chip model's method; the GPU side measured. Against the SRAM store at the hash's own width floor lane 3 reads 66x at zero shadow (lane B's 17x was the 64-byte row) and 2.7x to 3.0x at k = 1 and 5.0x to 5.7x at k = 0.5 with the shadow (section 10.3), so the honest range with the shadow is 3x to 6x and the shadow is the whole hold; and the one layer that answers it is layer 2 as a FLOOR that grows faster than SRAM cost falls: each doubling of the floor adds a die (4 GiB two dies at about USD 1,000 and 15x, 8 GiB four dies at USD 2,000 to 2,500 and 13x) while every card tier pays in device memory and, on Apple, in rate. The schedule's cost is priced in section 3 (lane A's per-tier table and main's candidate schedule, 6 GiB at the v6 epoch, 10 two years on, 14 at four, land there by 17:00 UK) and the served chip line is reviewed against this reading in section 9 at 20:00.
|
||||
|
||||
The honest frame first, from last night's measured close (`docs/analysis/counter-asic-4-research.md` sections 2 and 15.1a). Two chips exist in the model. A **fixed-function chip** wires one hash: the mixer's rounds, the op mix's lane ratios, the read width, the program length and the block shape are silicon. A **GPU-like chip** stores the dataset in commodity DRAM and runs the hour's program on a programmable integer core (the `f = 1` chip of chip-model-v3 section 5, the chip anyone builds); for it every drawn parameter is firmware. The four layers below render the FIRST kind useless on the day a parameter leaves its wired value, and move nothing for the second kind except the size of the core it must carry and the capex that forces. What the second kind keeps is the identity of section 2: at zero shadow premium the card's whole-card energy over the chip's memory energy (3.6x on a 5090 at its knee, measured card, modelled chip), and with the class v4 shadow 2.1x at a core as good as a GPU lane, which the k lane's first RTL rows (10.2, 13:3x UK) say no chip's UNITS are: at N3 a bare unit pays about 0.18 of the locked 5090's pJ per op (a per-unit floor, synthesised on ASAP7 and scaled; no fetch, decode or register file, which is the part rotation forces a chip to carry), so the DRAM chip with the shadow reads up to 4.0x at the knee and 5.8x at stock as the worst case, with the sequencer-core row owed as the headline and k 0.5 (2.9x at the knee) the default until it lands. No rotation changes that; the layers change which chip can be built and how long its tape-out lives.
|
||||
|
||||
| Layer | A fixed-function chip on its release day | A GPU-like chip on its release day | RTX 5090 (measured where stated) | RTX 5070 Ti (MEASURED stock, a rented Vast pod, 10:08 to 10:11 UTC: class v3 78.69 MH/s at 140.8 W, class v4 78.78 at 224.0 W, the premium 83.2 W = 10.3 pJ per counted op, fingerprints equal to the Mac's; no core-lock grid, the host refused -lgc) | Apple M5 Max (measured where stated) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1. Per-era draws of the parameters now fixed by release (mixer round count, op-mix weights, read width, program length, shadow block shape), from chain state | dead at the first era whose draw leaves its wired value: a wired x8 mixer at an x4 era (x16 is out of the band: the verifier measured 11.4 ms with the sibling loaded for the whole row) recomputes nothing right; a 4-byte-granularity controller at a 16-byte era moves 4x the bytes; a core sized for 100,000 ops at a 200,000 era runs at half rate; the tape-out lives one era (180 days, layer 3) | firmware; the core sized for the band's top (200,000 ops: about 60 mm^2 of N5 instead of 30, USD 25 to 40 more per chip, modelled); `k` unchanged; capex per MH/s +10 to 20 percent (modelled) | rate: 0 across the band while latency-bound (the ladder's rungs 0 to 2: 0 and -2.7 percent at the 431 W cap, measured); watts: the premium follows N and the mix (6.2 to 11.3 pJ per counted op measured; a shuffle-heavy draw up to 5x per op, so the band excludes it); the verifier +0.2 to +1.5 ms per era draw (measured ladder, estimated mixer) | rate 0 at class v4 (78.69 to 78.78 MH/s, memory-bound, measured); the class v4 premium 83.2 W at stock (10.3 pJ per counted op, the 5090's 10.8), 224 W under its 300 W limit with no throttle, so the N band's top (200,000 ops) costs about 170 W of premium at stock by the per-op figure and the card's limit binds first (approximate); the knee rows need a host that allows -lgc | rate -3.3 to -10 points across the N band (measured ladder rungs 0 to 2), 0 for the mixer and the width; the Apple tier sets the band's top (130,000 ops at the 5 percent rule) |
|
||||
| 2. The state-derived dataset's size tracks chain-state growth with a floor (class v5's leaves scaled by the state, never below the 1.13.3 schedule) | a chip with fixed memory ages out when the dataset passes it: one HBM3 stack 24 GB, the 5090's board 32 GB; the time-memory curve (chip-model 5.4) says the excess must be recomputed at 6.3 nJ per item against 2.0 per read, so its energy per hash rises with the overflow | the same memory limit; a chip buys DRAM a card cannot (24 GB stacks at USD 200, modelled), so it ages out LAST: the 8, 12 and 16 GB card tiers go first | fine to 32 GB: the 2 GiB genesis dataset plus the state's leaves; at a used chain's 5 GB state (class-v5 section 3) the dataset is capped at the sample size, 2 GiB | 16 GB: fine to the 8 GiB step (year 12 on the schedule); the state floor does not move it | 36 to 128 GB unified: fine to the 16 GiB step; the daily build grows with the size (13 to 30 ms measured at 1 GiB) |
|
||||
| 3. Scheduled family epochs by height, every 180 days by default, no release (the reserve R0 to R8 of spec 1.13.2 unlocking by height, then rotating) | a chip without the family's datapath loses its weight of the mix at the unlock (4 points of 79) or emulates it at the vendor penalty (1.5x to 2.4x per op, measured on the cards); a chip taped out against one family set is a GPU-like chip or dead | pre-wires every family for about USD 4 of N5 silicon (algorithm.md 5.2, modelled); moves the per-joule edge under 10 percent per family | measured family step costs: shfla 1.53x, perm 1.30, mm8 2.43 the add-xor-rotate step; under 1 percent of rate at 4 points | pending; the same families on the same silicon generation | measured: shfla 1.91x, perm 1.13 emulated, mm8 emulated at 1.6x per dot4; under 1 percent of rate at 4 points |
|
||||
| 4. The acceptance floor (c''') and the F8 uniformity test generalised to every era's draw, with a redraw on failure (plus the per-site largest-bucket bound and the value-level bias test as the next class's two tests) | nothing on the chip; it is what makes layers 1 and 3 safe without per-era cryptanalysis | nothing | the generator's attempts per seed (today about 30 at 2.4 percent rejection under (c'''); a redraw costs nothing on a card) | the same | the same |
|
||||
|
||||
Per tier against the stored-dataset chip (GDDR7, 0.466 microjoules per hash, modelled), at stock: the 5090 at 2.26 microjoules (class v3) and 3.36 (class v4) reads 4.9x and 2.2x at `k = 1`; the 5070 Ti at 1.79 and 2.84 microjoules reads 3.8x and 1.9x at `k = 1` (a card whose stock point is nearer its knee); the M5 Max at 0.78 and 1.40 (GPU and DRAM channels) reads 1.7x and 0.9x. The layers do not move these; the operating point and the shadow do.
|
||||
|
||||
What a fully general chip still gets, in one line: the stored-dataset chip with a programmable core at the top of the band keeps 3.6x at zero premium and 2.1x at `k = 1` on a 5090 at its knee (section 2 of the research file), and the four layers cost it about USD 30 to 60 per chip of extra silicon and a project that must be N5-class from the first tape-out (the USD 100 M break-even cap of the mission lane's model); the chips of 2027 on (lane B, section 7a: a custom HBM4E base die, DRAM on logic, a 2 GiB SRAM store on one N2 reticle at 17x per joule at zero shadow and USD 0.25 to 0.4 per MH/s) are touched by none of the four layers except layer 2's capex, and against the SRAM chip the public 2x needs the honest card's whole latency shadow at a core as good as a GPU lane.
|
||||
|
||||
### 0.1 The per-tier table for the schedule decision (main's order, 11:4x UK: the year each tier falls off under the candidate schedule, and the share of today's measured cards)
|
||||
|
||||
The candidate schedule main named: a floor of 6 GiB at the class v6 epoch, 10 GiB two years on, 14 GiB at four years, each step by height like a class epoch, the schedule a consensus field (lane A prices it in section 3 by 17:00 UK). The device memory a tier needs is the dataset plus the cache (512 MiB from 4 GiB, 1 GiB from 8) plus the measured 0.4 GiB working set and about 0.5 GiB of driver and app (the hash lane's rows); a Mac holds about half its unified memory for the GPU under macOS, the display and the node (the card-lifetime table's share rule).
|
||||
|
||||
| Tier | Device memory | Needs at 6 / 10 / 14 GiB | Falls off the candidate schedule at | Falls off the 1.13.3 schedule at (year) | The Apple rate cost, measured today | Share of today's measured cards | Label |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| RTX 5090, 32 GB | 32 | 7.4 / 11.9 / 15.9 GiB | never inside the schedule (about 29 GiB) | 54 | | the bench table's row; the fleet census unread | modelled from the measured working set |
|
||||
| RTX 5070 Ti, 16 GB | 16 | 7.4 / 11.9 / 15.9 | at the 14 GiB step (four years on): 15.9 of 16 with no headroom | 23 (about 13.5 GiB) | | unread | modelled |
|
||||
| A 16 GB GPU (the 9070 XT class) | 16 | the same | the 14 GiB step | 23 | | unread | modelled |
|
||||
| A 12 GB card (the 4070 measured) | 12 | 7.4 / 11.9 / 15.9 | at the 10 GiB step (two years on): 11.9 of 12 with no headroom | 15 (about 9.5 GiB) | | unread | modelled |
|
||||
| An 8 GB card | 8 | 7.4 fits with 0.6 of headroom / out / out | at the 10 GiB step; at 6 GiB it keeps mining with 0.6 GiB spare (the 6 GB 2060 tier drops at the v6 epoch) | 12 | | unread | modelled |
|
||||
| Apple, 16 GB unified (about 8 GiB for the GPU) | 16 | 7.4 fits at the v6 epoch with no headroom / out / out | at the 10 GiB step (two years on) | 12 (about 8 GiB) | the rate -12 percent at 2 GiB, -20 at 4, -22 at 8 against 1 GiB on the M5 Max (measured 10:40 UTC); a 16 GB Mac at 6 GiB pays the 8 GiB row's cost or more | unread | modelled memory, measured rate |
|
||||
| Apple, 32 GB unified (about 16 GiB) | 32 | 7.4 / 11.9 / 15.9 | holds every step of the candidate schedule, 15.9 of about 16 at the 14 GiB step with no headroom | 28 | the same measured curve | unread | modelled memory, measured rate |
|
||||
| Apple, 64 GB and up (about 32 GiB) | 64 | fits | holds every step | 60 | the same measured curve (the M5 Max itself) | unread | modelled memory, measured rate |
|
||||
|
||||
Lane A's check of these steps under the standing 75 percent rule (section 3.4): 6 GiB does not fit the 8 GB tier (78 to 82 percent of the card), 10 GiB retires the 12 GB tier and the 16 GB Mac at year 2, 14 GiB retires the 16 GB tier at year 4; the schedule that drops the tiers in the order the note described is 5.5 / 8 / 11 GiB, each step retiring about a quarter of today's measured consumer cards by count. The Apple column is the one the founder decides on: the M5 Max is the honest best per joule (0.78 microjoules on its GPU and DRAM channels, 3.1x the 5090) and the Mac tier is a large audience; a 16 GB Mac is out at the 10 GiB step and every Mac pays the measured rate cost of a larger working set (-12 to -22 percent) before any memory limit, which the NVIDIA cards pay too at their knee by the 12:5x UK measurement (the 5090 at the 1,300 lock: -4.9 / -11.2 / -14.1 percent of rate at 2 / 4 / 8 GiB, 4 / 8 / 10 percent more energy per hash; unlocked -2.8 / -3.8 / -4.3; section 3.3). The share of today's measured cards per tier is unread until the fleet's census is sent; the bench table's rows name the cards, not their count.
|
||||
|
||||
## 1. Where the four layers sit in the design as it stands
|
||||
|
||||
| Item | Today (spec 01, the class system) | What class v6 adds |
|
||||
|---|---|---|
|
||||
| The era draw (1.13.1) | one SplitMix64 stream from the 1-hour VDF's `E_n`; draws the op-weight perturbation (B = 2 points, proposed), the fold rotations, the table layout and the working-set window (layers 4 and 8 of Counter ASIC 2.0, IN); `epoch_len` and the ladder's draws consumed and not used (set by signal) | layer 1: five more draws of the same stream, in a fixed order, each within a genesis-fixed band, each consumed whether used or not |
|
||||
| The mixer (1.8.5, `mixer_mult` 8 under class v3) | fixed at genesis "so the verify budget holds" | layer 1 draws it in {4, 8, 16} (the x4 and x8 rows measured, x16 the hash lane's row or the estimate), the verifier's budget the bound |
|
||||
| The read width (1.13.1 draw 1, `allowed = {1}`) | pinned to 4 bytes by the 5 October decision (w64 made the 5090 bandwidth-bound) | layer 1 draws from {1, 4} words (w16 within 2.7 percent on the 5090 and the 9070 XT, measured); never 16 words |
|
||||
| The shadow (class v4: 256 x 27, rung 0 of the ladder) | N moves by miner signal within the ladder | layer 1 draws the block shape (64 to 256 instructions, measured; never 1,024) and the op-mix weights within the band; N stays the ladder's (signal, not draw) |
|
||||
| The dataset (1.13.3) | 2 GiB plus 0.5 GiB a year; class v5's leaves (64 B per state record) | layer 2: the leaf count tracks the state with the schedule as the floor; the sample cap stays at the dataset size |
|
||||
| The reserve (1.13.2) | R1..R8 unlock one per era, by height | layer 3: the unlock cadence a genesis constant (180 days) and the set rotating after the reserve is exhausted (the retired family returns in a fixed cycle) |
|
||||
| The acceptance rule (1.4.6; sub-version 3's (a') (c') (c'''); F8's census as a gate) | judged on the genesis parameters; the census run per class by hand | layer 4: the rule and the census parameterised by the era draw; the generator redraws a candidate that fails any of the four tests |
|
||||
|
||||
## 2. Layer 1: per-era draws of the released parameters
|
||||
|
||||
The band a card sees across the draw's range, from the hash lane's cost rows (12:0x UK; every row labelled): a 5090 136 plus or minus 4 MH/s and 312 to about 460 W stock (223 to 300 W at the knee), a 5070 Ti 79 plus or minus 2 MH/s and 141 to 224 W, an M5 Max 27.7 to 28.3 MH/s and about 60 to 96 W (GPU power approximate); the band's width is the program-length and op-mix draws (watts), not the mixer or the read width (rate). The two re-weighted shadow packs run on the 5090 this afternoon and replace the op-mix line's modelled figures when they land. The table:
|
||||
|
||||
| Parameter | Band (genesis) | Why that band (the measured rows that set it) | Chip rows: fixed-function / GPU-like | Per tier: 5090 / 5070 Ti / M5 Max | Verifier | Open number |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Mixer applications per round `m` | **{4, 8} at genesis; 16 in the list as `admissible: false` (the ladder's rule: a flag in the genesis list, flipped only by the 90 percent upgrade path once the 2019-class core measures it)** | **The card pays nothing for the draw, MEASURED (the hash lane's kit b on PC 1, 12:49 to 12:58 UK, generator 2 with the era, the multiplier the only difference, 250 batches per row, fingerprints PASS): x8 137.73 MH/s at 320.2 W unlocked and 127.44 at 212.0 W at the 1,300 lock; x16 137.72 at 320.1 and 127.46 at 211.7; within 0.1 MH/s and 0.5 W at either state, item derivation hidden under the read chain, so the verifier's 1.86x per doubling is the draw's whole cost.** x4 and x8 measured (mixer-x4.md 6.4: 1.92 and 2.79 ms per unit on a loaded M5 Max core; the build unmoved on every discrete card, latency-bound); **x16 MEASURED (today's class v3 stream, the same id f5e904bc5d148926 for x8 and x16). Cold alone: build-1 core 4 x8 4.67 ms per warp, x16 8.69 (the hash lane, 10:13 UTC); build-3 core 4 (AX102, a faster core class) x8 3.48, x16 6.53 (the build-server lane, 11:01 UTC). With the SMT sibling loaded for the row's whole length (the method that counts: a sibling bench looped for the row, not run once, which ends in about a second and leaves the verify phase sibling-idle; the hash lane's earlier 5.41 / 10.11 / 9.04 to 9.10 ms rows were of that kind and are withdrawn): build-3 x8 6.24, x16 11.44 ms, about 1.8x on both classes. The multiplier is 1.86x on the verifier (the mixer IS the verifier's cost); chip-model-v3's estimate (9 ms on a 2019-class core) stands within the cold rows** | the `f = 0` recompute chip's rate halves per doubling (0.31x bare at x8, 0.16x at x16, modelled); the `f = 1` chip unmoved (it recomputes nothing) | rate 0 / 0 / 0 (latency-bound, measured at x4 and x8); the daily build 42 / pending / 29 ms at x8 (measured); the x16 build on the 5090 rides the 12:45 UK job | +1.9x per doubling measured (x8 to x16); **x16 at 11.4 ms loaded is over the 10 ms gate on the measured row, so `admissible: false` stands on the measurement, not only on the chip model's margin; the O-1.14 laptop run can only tighten it** | adv-mixer-3's margin (every statistic clean from k = 1, the SAT ladder k = 1 solved, k = 2..4 timeout): x4 keeps the margin by that report's reading; the band {4, 8} is the measured one |
|
||||
| Op-mix weights (the ten non-load families) | **B = 4 points on the injecting families only (add, sub, xor, mad, shfl, rotl, rotr); the lossy families (or, mul, mulhi) capped at their base, the ring-A rule `or + mul + mulhi` at most the table's 18 plus B, which keeps the per-candidate rejection r under 0.85 (r^256 under 1e-18)**; the shuffle weight capped at its class v4 value on the energy side | lane D's coverage run (4,900 drawn eras on three boxes, 11:3x UK, measured): at the lossy corner of B = 4 (or, mul and mulhi all at +4, 30 of 75 lossy against 18) r is 0.956 (the shipped 0.681) and 8 of 663 eras exhaust the 256-attempt cap (mean attempt 18.6, max 252), so 1.2 percent of that corner's epochs would take the last-resort program, which fails rule (a) in 9 percent of seeds (adv-accept-3); the random stratum with every weight drawn reads r 0.718, max attempt 107, 0 exhaustions in 1,444. The energy side (15.1a): shfl 55.8 pJ per op, mulhi 39.6, prmt 22.3, lop3 24.1, mul 13.9, arx 11.3; a shuffle-heavy table raises the premium per instruction up to 2x (modelled), a multiply-heavy one 1.1x | a chip that specialised its lane ratio loses the ratio; a general core nothing | the premium per instruction moves with the mix within the capped band (the cost rows: at N = 100,000 the 5090's +146 W stock becomes up to +160 W multiply-heavy; shuffle-heavy excluded by the cap); the 5070 Ti scaled by 83/146, the M5 Max by 16/146; the two packs' measured rows replace this when they land. **First row MEASURED (kit b, PC 1, 12:5x UK): the shuffle-heavy table (shfl 13 of 48 against the stock 5) on class mx8 without a shadow block reads 137.65 MH/s at 312.9 W unlocked (7 W under the stock table's 320.2) and 127.33 at 212.4 at the lock (level): the multiply-heavy table (mulhi 11, mad 8, mul 5 of 48 against the stock 9, 4, 1; shfl 1 against 5; kit c, 13:03 to 13:06 UK) reads 137.77 at 313.5 W unlocked and 128.42 at 211.8 W at the lock, so the three tables sit within 7 W unlocked and 0.6 W at the lock: on the 48-op base program the weight move is a 2 percent term whichever way it goes; the row layer 1 needs is the same two tables inside the shadow block (mx8+sh256x27, kit d, about 13:45 UK), and the microbench arithmetic stays the default until it lands** | +0 ms | the known-failed test: the 8 of 663 exhaustions at the uncapped lossy corner (61 of 5,000 on the 17:00 cut), which the capped band must read as 0: **MEASURED 0 of 3,000 under the band (lane D's 17:00 cut, 5.1b; r 0.595, mean attempt 1.47, max 59)** |
|
||||
| Read width `W` | **pinned at 4 words (16 bytes) at genesis, not drawn** (floor lane 3, 10.3: the width is the only wire lever on the SRAM die, 66x at 4 bytes against 44x at 16 at zero shadow; 8 words after the owed PC 1 and Mac rows; 16 never) | w4 and w16 measured 5 October: the 5090 139.8 against 136.1 MH/s, the 9070 XT 17.90 against 18.15, the M5 Max within 1 percent; w64 bandwidth-bound (71.9 MH/s on the 5090) | the SRAM die's energy per read rises with the bits moved (0.25 to 0.38 nJ); the DRAM chip's toward the card's | 0 / 0 / 0 (measured) | 0 | the W = 8 rows (owed) |
|
||||
| Shadow block shape | 64 to 256 instructions per block, the pass count the ladder's | 64-instruction blocks ran 2.5 to 3.5 percent FASTER than 256 on the 5090 and the M5 Max (measured 6 October); 1,024 cost the M5 Max 17 percent | nothing for any chip (the work is the same) | +2.5 to 0 percent / pending / +2.5 to 0 | 0 | none |
|
||||
| **The index fold (a ring-A design rule of layer 1, every era's draw passes through it): `load_index` folds a product's low bits before the stride rotation, so no era's R lands a biased product bit on an address bit** | every era; lane D's coverage (11:3x UK): the class is at HALF the family's epochs, not a corner: 52 to 58 percent of accepted programs in every stratum carry one site whose address bit R (or R+1, R+2) is biased over 6 sigma at 2^20, 33 to 40 percent over 100 sigma, the worst z 1,024, on programs (c''') passes; the F8 tail's bucket excess is the same mechanism at scale (+94 to +128 sigma at the biased bit) | lane D's family harness (build-1, 10:21 UTC, 16 drawn eras, every layer-1 parameter from the era's stream, the chain draw through the real rule, reads at the rule's own 2^20 sample): the index-bit bias fires HARD on 7 of 16 eras, abs z 130 to 511 at one site, every one at address bit R or R+1 (a product's bit 0 at P = 0.25 on era 15, R = 25, z -511; a product's bit 1 at 3/8 on era 13; an or-shaped source at 5/8 on era 6); the other 9 clean under abs z 3.8; the (c''') ratio sees none of it (0.9954 to 1.0000): adv-cache-2's era-stride class measured on class v5 accepted programs at the acceptance's own sample | a chip holding the favoured half of that site's window serves 75 percent of its reads instead of 50: about 1.6 percent of a hash's reads at f = 1/2 for one site, zero at f = 1 (the partial store already costs 1.26x the ops, chip-model 5.4): the f = 1 verdict does not move; the row is an auditor's flag on "uniform random reads", not a chip lever | nothing: the fold is one xor-rotate on the address path, measured as 0 on every card by the era-layout rows (the index form is the era draw's own) | 0 | the fold's form in `load_index` (fold the product's low bits before the rotation) and its vectors; a 6-sigma REFUSAL in layer 4 is not the lever: it would redraw about 40 percent of epochs (7 of 16 eras); the known-failed test is lane D's 7 of 16 eras at the rule's sample, which must read 0 of 16 with the fold |
|
||||
| Program length N | not drawn: the ladder's signal (latency-ladder.md) | an unconditional draw retires the Apple tier at 200,000 (measured -10 percent) | the core sized for the ladder's admissible top (rung 2, 199,600 ops) | the ladder's rows | the ladder's rows | none |
|
||||
|
||||
## 3. Layer 2: the dataset's size tracks the chain state with a floor, so fixed-memory silicon ages out
|
||||
|
||||
### 3.1 The rule
|
||||
|
||||
Class v5 (`docs/design/class-v5-stored-state.md`) already keys every item to a leaf of the chain's execution state and caps the leaf count at the dataset size (2^24 items at 1 GiB, 2^25 at the designed 2 GiB), sampling the state when it is larger. Layer 2 turns the cap into the size: the day's item count is the larger of the 1.13.3 schedule (2 GiB plus 0.5 GiB a year, the floor) and the state's record count times 64 bytes, rounded up to the power of two the index mapping needs (or the multiply-shift mapping of 1.13.3 option (a), which takes any size), with a genesis-fixed ceiling at the schedule's power-of-two step for the year (2 GiB to year 4, 4 to year 12, 8 to year 28, the cache doubling with it) so the state can only bring a step forward, never add one, the verifier's lazy derivation stays bounded, and every honest tier's lifetime is the card-lifetime table's at worst (lane A's finding: the ceiling per tier per year is the number to fix before the rate). Everything below the floor is today's design; everything above it is the chain's own growth deciding the memory a miner must hold.
|
||||
|
||||
| State size (records) | Leaves | Dataset under layer 2 | Who holds it | Label |
|
||||
|---|---|---|---|---|
|
||||
| 93 (the devnet today) | 6 KB | the floor: 2 GiB at genesis | every card from 8 GB up | measured state, designed floor |
|
||||
| 10 M (a used chain's accounts alone, class-v5 section 3) | 640 MB | the floor still: 2 GiB (the leaves are a fold into the items, not the items) | the same | arithmetic |
|
||||
| 61 M (10 M accounts, 50 M slots, 1 M code chunks) | 3.9 GB | 4 GiB (the next power of two above the leaves), the year-4 step reached by state instead of by the calendar | 8 GB cards at 75 percent of memory hold 6 GB: the dataset plus the cache (512 MiB) and the scratch fits; 4 GB cards are out | arithmetic on the card-lifetime table |
|
||||
| 250 M (an Ethereum-class state, approximate) | 16 GB | 16 GiB | 24 and 32 GB cards and Apple 64 GB; the 8, 12 and 16 GB tiers out | approximate |
|
||||
|
||||
### 3.2 The chip rows
|
||||
|
||||
The time-memory curve of chip-model-v3 section 5.4 is the whole argument: a stored item costs the chip 1.2 to 2.0 nJ (one random read) and a recomputed item 6.3 nJ and 9,360 ops, so every memory system's cheapest point is `f = 1` and a chip whose DRAM is smaller than the dataset falls back along the curve for the overflow. With the microbench's measured card side beside it:
|
||||
|
||||
| Chip memory | Holds up to | At a 16 GiB dataset | Energy per hash against the 5090's 8.7 nJ per dependent read at the lock (measured, whole card) | Label |
|
||||
|---|---|---|---|---|
|
||||
| GDDR7, 16 devices of 2 GB (the 5090's board without the GPU) | 32 GB | fits | 128 reads x 2.0 nJ = 0.26 microjoules plus static: unchanged | modelled |
|
||||
| One HBM3 stack, 24 GB | 24 GB | fits | 0.32 microjoules: unchanged | modelled |
|
||||
| A 16 GB chip (a cheaper board: 8 devices) | 16 GB | the dataset equals its memory; at 32 GiB it recomputes half: 64 x 2.0 + 64 x 6.3 nJ = 0.53 microjoules against 0.26, the rate halved by the recompute, the chip's capex per MH/s doubled | modelled |
|
||||
| A fixed SRAM mirror (the `f = 0` chip's 256 MiB cache) | the cache only | the cache doubles with the dataset (option C): the 128 mm^2 mirror becomes 255 at year 4, 510 at year 12 (chip-model section 2), at a used chain's state the same by layer 2 in the first year | modelled |
|
||||
|
||||
Reading: layer 2 does not move the chip anyone builds, because a chip buys DRAM a card cannot (a 24 GB HBM3 stack at about USD 200, modelled; a 32 GB GDDR7 board at USD 320) and the card tiers are smaller than the chip's memory at every step. What it does is honest and worth saying plainly: **it retires home cards before chips.** On the card-lifetime table (option (b) steps) the 4 GB tier ends at the 4 GiB step, the 8 GB tier at the 8 GiB step, the 12 and 16 GB tiers at 16 GiB; under layer 2 those steps arrive when the state does, not when the calendar does, so a chain that takes off is a chain whose 8 GB miners leave in its first years. The `f = 0` recompute chip, which nobody builds, is the one chip layer 2 kills outright (its mirror doubles with the state). The floor keeps the schedule's lower bound; a ceiling (the next cache doubling) keeps the verifier's bound.
|
||||
|
||||
### 3.3 Per tier
|
||||
|
||||
The hash lane's VRAM rows (12:0x UK, modelled from the measured 0.4 GiB working set plus about 0.5 GiB of driver and app): the dataset needs 3.2, 5.4 and 9.9 GiB of device memory at the floor, 2x and 4x; a 12 GB card falls off at about 9.5 GiB (year 15 on the 1.13.3 schedule), a 16 GB GPU at about 13.5 GiB (year 23), a 16 GB unified Mac at about 8 GiB (year 12), the 5090 at about 29 GiB (year 54). **The DRAM-read cost per hash on the NVIDIA cards is NOT size-independent at the knee, MEASURED (the hash lane's kit b, PC 1, 12:49 to 12:58 UK, the pinned class v3 program 73bcbfe8 at 2, 4 and 8 GiB against the 1 GiB control 137.65 MH/s at 312.2 W unlocked and 127.39 at 212.6 W at the 1,300 MHz lock; 250 batches per row, fingerprints PASS): unlocked 133.86 at 315.7 W (-2.8 percent), 132.42 at 317.2 (-3.8), 131.75 at 319.2 (-4.3); at the lock 121.14 at 210.3 W (-4.9 percent), 113.06 at 204.5 (-11.2), 109.39 at 201.6 (-14.1); MH/W at the lock 0.599, 0.576, 0.553, 0.543, which is 4 / 8 / 10 percent more energy per hash at 2 / 4 / 8 GiB.** The lane's earlier reading (2 MiB pages keep the TLB's reach past 8 GiB) holds unlocked, where the card hides most of the page-walk term in its slack; the latency-bound regime at the lock exposes it. So each step of the schedule costs a tuned 5090 about 4 to 5 percent per hash while the chip's joules do not move (floor lane 3: its ticket goes USD 1,500 / 2,500 / 3,000 at 5.5 / 8.5 / 11.5 GiB), and every chip edge against a card at its knee rises by 4 to 11 percent across the schedule; the honest sentence for the schedule decision is USD 1,000 of chip ticket per step for about 1 to 5 percent of the tuned 5090's energy and about a quarter of today's measured cards by count. **On the M5 Max it is not size-independent, measured by this lane at 10:40 UTC under the Mac's measure lock (Metal packbench, the hash lane's class v3 packs at 2^28 to 2^31 words, the same seed and era, 3 batches of 2^24, vectors 3 of 3 and fingerprints per pack): 26.48 MH/s at 1 GiB (footprint 1,664 MiB, the build 32 ms), 23.26 at 2 GiB (-12.2 percent; 2,688 MiB; 54 ms), 21.31 at 4 GiB (-19.5 percent; 4,736 MiB; 94 ms), 20.61 at 8 GiB (-22.2 percent; 8,832 MiB; 193 ms).** The Apple GPU's dependent random read costs more time as the working set grows past its page reach (approximate reading: a TLB-reach effect on unified LPDDR5X; the power channels were not sampled this run, so the joules per hash move by at least the rate's share), which is a real per-tier cost of layer 2 that the NVIDIA model does not show: at an 8 GiB floor the Apple tier mines 22 percent slower per card than at 1 GiB, before any memory limit. The table below carries it.
|
||||
|
||||
| Tier | At the floor (today to year 4) | At a 4 GiB state-driven step | At 16 GiB | Label |
|
||||
|---|---|---|---|---|
|
||||
| RTX 5090, 32 GB | the daily build 13.4 ms at 1 GiB (measured), about 27 ms at 2 GiB; nothing else moves | about 54 ms; nothing else | about 220 ms a day; fits | measured at 1 GiB, scaled |
|
||||
| RTX 5070 Ti, 16 GB | fits | fits (the card-lifetime 16 GB row: room to the 16 GiB step) | OUT (the dataset equals the card) | the table, approximate |
|
||||
| Apple M5 Max, 36 to 128 GB unified | the build 32 ms at 1 GiB, 54 at 2 (measured); the rate -12 percent at 2 GiB (measured) | fits; the rate -19.5 percent at 4 GiB (measured) | fits on 64 GB and up at the 50 percent share; the rate -22 percent at 8 GiB (measured); a 36 GB machine is out at 16 GiB | measured 10:40 UTC |
|
||||
| 8 and 12 GB cards | fit | 8 GB fits at 75 percent (6 GB usable against 4.5 GB of working set), 12 GB fits | OUT | the table |
|
||||
| A pool user | the pool ships the leaves, 64 B per record (16.5 KB/s to 10,000 members today; the WAN line at about 700,000 records, class-v5 2a.2) | the same | a 16 GB leaf array per member per window: above the WAN line; the pool serves the built dataset or the member builds from the state it holds | class-v5's arithmetic |
|
||||
| A node | one leaf pass per window (100 ns per record: 6 s on one core at 61 M records, 0.2 s on 32 threads) | the same | the same | class-v5's measured rate |
|
||||
|
||||
### 3.4 The dataset-floor schedule, priced (lane A, `docs/analysis/class-v6/history.md` section 4.2a, master 59963461 (branch commit 84b30841), 10:45 UTC; the method card-lifetime-2026-10-05.md's room per tier, 75 percent of card memory usable and 50 percent of Apple unified, the non-dataset working set 254 to 479 MiB best and 600 to 1,500 worst; the population the bench table's 32 measured consumer cards by count, the fleet's hashrate-weighted census owed; the arithmetic script-checked)
|
||||
|
||||
Main's candidate steps checked first under the standing budget rule: **6 GiB does NOT fit the 8 GB tier** (6,398 to 6,744 MiB, 78 to 82 percent of the card; it fits only at a headless-rig reading of about 85 percent); 10 GiB retires the 10, 11 and 12 GB tiers AND the Apple 16 GB laptop at year 2 (the 12 GB card at 86 to 89 percent); 14 GiB retires the 16 GB tier at year 4 (89 to 92 percent), leaving 24 GB and above. That is not the tier order the note described, so the table carries the schedule that drops the tiers in that order:
|
||||
|
||||
| Step | Dataset floor | What falls (share of today's measured consumer cards, by count) | What holds | Label |
|
||||
|---|---|---|---|---|
|
||||
| the class v6 epoch | 5.5 GiB | the 6 GB RTX 2060 (3 percent) | the 8 GB tier at 72 / 76 percent of the card (the worst case one point over the rule) | modelled on the measured working sets |
|
||||
| two years on | 8 GiB | the 8 GB tier (22 percent: RTX 3070, 3070 Ti, 3060 Ti, 4060, 4060 Ti 8 GB, 5060, 2070 Super), with it the 10 GB RTX 3080, the Apple 16 GB laptop, and in practice the 11 GB 1080 Ti and 2080 Ti at 100 percent of usable | the 12 GB tier at 69 / 72 percent | modelled |
|
||||
| four years on | 11 GiB | the 12 GB tier (22 percent: 4070, 4070 Ti, 4070 Super, 5070, 3080 Ti, 3060, Arc B580) | the 16 GB tier at 70 / 73 percent; Apple 32 GB at 71 percent of its half | modelled |
|
||||
| the fixed schedule's 16 GiB step, whenever the state brings it forward | 16 GiB | the 16 GB tier (28 percent) | 24 GB and above (16 percent of the measured consumer cards plus every datacentre part) through every step | modelled |
|
||||
|
||||
A step every two years retires about a quarter of today's measured consumer cards each time; on the Apple side every Mac pays the measured rate cost of the larger working set on top (section 3.3: -12 percent at 2 GiB, -20 at 4, -22 at 8 on the M5 Max).
|
||||
|
||||
Against the chips, with the number: (1) a hybrid-bonded or soldered chip sized at launch (the Jasminer X4 shape, the E3 shape) dies at the first step it cannot carry, as the E3 did 20 to 27 months after shipping; a chip sized to the floor with 8 GB dies at the year-2 step on either schedule, one with 16 GB holds through year 4; but the schedule is a consensus field read at genesis, so a maker sizes to the step it wants to survive (USD 160 more of GDDR7 on a USD 470 part; about 3x the 2021 memory die area for the Jasminer shape, approximate), and the E3 died because it was sized to the card fleet's limit, not to a schedule; (2) the f = 1 GDDR7 chip's 32 GB board pays USD 0 through 16 GiB and keeps 5.1x per joule and USD 2.8 per MH/s at every step; (3) the SRAM store pays capex only, at the cache doubling (USD 46 per die at 256 MiB to 111 at 512 MiB, 306 at 1 GiB for the recompute chip's mirror; lane B's full store a die per 2 GiB: USD 400 to 600 each). **The sentence for the close: the schedule is a fleet-retirement rule with a chip tax of USD 0 to 160 per unit on the chips that matter, and it kills only a chip whose maker ignores a public consensus field; if adopted, the 75 percent rule and a 6 GiB floor cannot both stand for the 8 GB tier (5.5 GiB keeps it inside the rule in the best case; about 85 percent is what makes 6 GiB fit).**
|
||||
|
||||
## 4. Layer 3: scheduled family epochs by height, every 180 days by default, no release
|
||||
|
||||
### 4.1 The rule
|
||||
|
||||
Spec 1.13.2 already unlocks reserve family `n` at the start of era `n` with weight `W_new` taken proportionally from the live families; the eras are the six-month `E_n` of the 1-hour VDF. Layer 3 fixes two things the reserve leaves open: the cadence is a genesis constant (`family_epoch_daa`, 180 days of DAA seconds by default, independent of the era draw so a family can rotate without an era), and the set rotates after the reserve is exhausted (at family epoch `n` past the reserve's end, the live set is the genesis eleven plus the reserve entries whose index is in a fixed cycle over the reserve, so a retired family returns on a fixed schedule and a chip can never wait one out). The weights move by the 1.13.2 rule (proportional). No release carries any of it; the emitter and the verifier hold every family from genesis, with the per-vendor conformance vectors of 1.15 for each.
|
||||
|
||||
### 4.2 The chip rows (the reserve's measured and modelled figures, algorithm.md 5.2 and counter-asic-3-reserve.md)
|
||||
|
||||
| Family | What a chip must add (N5, approximate) | Energy per op at the N5 floor | The honest cards' step cost (measured, ratio to the add-xor-rotate step): Apple / NVIDIA / AMD | What it does to a chip without it on the unlock day |
|
||||
|---|---|---|---|---|
|
||||
| R1 shfla (lane + delta) | a 32-lane crossbar per warp, 3 to 6 adders per lane | 1.00 pJ | 1.91 / 1.53 / 0.75 to 0.84 | at 4 points of 79 the chip without a crossbar emulates through its existing xor-shuffle path or loses 5 percent of the mix's work per op |
|
||||
| R2 perm (byte permute) | a 4x4 byte crossbar, 1 to 2 adders | 0.10 | 1.13 emulated / 1.30 / 1.73 to 1.93 emulated | under 1 percent |
|
||||
| R3 popc and clz | a popcount tree and a priority encoder, 1.5 to 3 adders | 0.10 | 0.87 and 1.01 / 1.50 and 1.63 / 0.91 to 1.30 | under 1 percent |
|
||||
| R4 bfe, R5 shl and shr, R6 sel, R7 andn | 0.1 to 0.3 adders each | 0.02 to 0.06 | 0.75 to 1.54 | 0 |
|
||||
| R8 mm8 (the int8 tile) | a u8 MAC tile per warp, about 100 adders per lane, licensable | 1.60 (the N5 floor); the GPU's own tile measured at 1.5 to 4 pJ per MAC (the research file's 15.1a) | Apple emulated at 1.6x per dot4 (10 steps per tile), NVIDIA 2.43, AMD 1.68 to 1.83 native (layout unverified) | nothing: a chip's MAC array is cheaper than the GPU's (k 0.03 to 0.3, measured GPU side); R8 is kept for datapath diversity, never for joules |
|
||||
| All eight pre-wired | about 8 to 14 adders per lane, about USD 4 of N5 on a 14,000-lane array | | | the GPU-like chip pays USD 4 once and is never surprised |
|
||||
|
||||
Reading: against the `f = 1` chip every family moves the per-joule edge under 10 percent (a family changes 4 points of 79 in the shadow mix at `N x k x 6.4 to 11.3 pJ`), and a chip that pre-wires the reserve pays USD 4; the layer's whole value is against a chip taped out without a family (a fixed-function datapath), which loses the family's share of the work on the unlock day or emulates it at the measured vendor penalties. The rotation after exhaustion closes the one gap the reserve has: a chip that waits for a family to retire.
|
||||
|
||||
### 4.3 Per tier and the known-failed case
|
||||
|
||||
The re-tune per family epoch (the hash lane's rows): a card with a lever (the 5090, the 5070 Ti, the 4070) re-runs Ember's search, 11 to 12 minutes at stock watts for about 15 s of hashing lost per 180 days (0.0001 percent; the 5090's 12 minutes at +235 W is 0.05 kWh, about 1 p); a card without one (the 9070 XT in 0.3.20, every Apple machine) runs the 3-minute baseline and loses nothing.
|
||||
|
||||
The cost of a live family is its step cost at its weight, checked per vendor at the unlock rehearsal (the 5 percent rule of 1.13.2): at 4 points the worst measured vendor (Apple on shfla, 1.91x per op) pays under 1 percent of its ALU time, which on a latency-bound card is 0 rate; the daily build and the verifier are unmoved (one op per instruction, under 0.01 ms per warp). The 5090 and the 5070 Ti pay the NVIDIA column (1.26 to 1.63x per op on a family's 4 points: 0 rate); the M5 Max pays the Apple column (0 rate at 4 points; the mm8 emulation at 1.6x per dot4 stays under the 8x bound). The known-failed case: a kernel built without the live family refuses at packcheck (the program id carries the family set through the class's allowed list, as `program_id_class` carries the era's `allowed[3]`), and the fast-time harness crosses one family epoch with three nodes and one stale miner, the stale miner's blocks rejected from the first block of the new epoch (the class signal harness's shape, `infra/fast-time/class-v5-signal.mjs`).
|
||||
|
||||
## 5. Layer 4: the acceptance rule and the census as a function of the era draw
|
||||
|
||||
### 5.1 The rule
|
||||
|
||||
Every test the generator applies to a candidate program is today a function of the program and the genesis parameters: (a) the stale-source rule, (b) the injecting-write rule, (c) the dynamic test over 64 units on the closed-form stand-in (constant bits, saturation, lane-constant sites, output bias, the distinct-address sum), sub-version 3's (a') freshness fixpoint, (c') saturation per site and (c''') the distinct ratio floor (0.995). Layer 4 makes the rule take the era draw as an input (the mixer multiplier, the weights, the width and the block shape of layer 1; the live family set of layer 3; the dataset size of layer 2) and adds the four tests the night's work named, so that layers 1 and 3 need no per-era cryptanalysis: every era's programs are drawn against the same tests, and a candidate that fails any is redrawn from the next stream values, exactly as today's attempts are.
|
||||
|
||||
| Test | Today | Under layer 4 | The number it rests on | The known-failed case |
|
||||
|---|---|---|---|---|
|
||||
| (c''') the distinct-item ratio floor | 0.995 over the 64 units at the genesis width and mixer | the same floor evaluated with the era's width (a 16-byte load touches one item too) and dataset size; 2.435 percent of candidates under it today, attempts +3.4 percent | class-v5 section 14 (measured census of 4,600 candidates) | a candidate below 0.995 on the exemplar seed 100767 is refused |
|
||||
| F8's uniformity (the largest 64-line bucket within 6 sigma over 2^28 derivations; the top 0.1 percent of items within 1.2x of the window model over 2^24 nonces on 64 seeds) | a gate run by hand per class on the attack board | run by the census tool per era draw at genesis (the band's corners plus 64 random eras) and by the node's acceptance as the per-site version below; a draw whose corner fails is excluded from the band | f8-uniform.md section 7: +4.84 sigma against the control's +4.18, PASS; the tail p4, p8, p10, p34 attributed this morning as per-site bucket concentration at a narrow-window site | the `quarter-lines` and `const-item` plants fire at +75.97 and +92,682 sigma (f8-uniform.md 2.1) |
|
||||
| The per-site largest-bucket bound (this morning's attribution) | named for a next class | per load site, the largest 256-item bucket over the units' addresses, stated in SIGMA against its own window's Poisson expectation, never as a ratio: ratios of 2.2 to 2.4 at full-window sites are the CLEAN maximum (65,536 Poisson(16) buckets read +4.4 sigma), so a ratio bound would refuse clean programs; lane D's rebuild carries the sigma column | AP-F8-1's tail: p10 1.50x, p8 1.38x, p34 1.25x, p4 1.22x, each a narrow-window site's bucket; the sigma figures from lane D's family-gate.md (17:00 UK) | the four tail seeds must be refused on sigma; the 60 passing seeds accepted |
|
||||
| The value-level bias test (adv-cache-2; the research file's 20.2b) | named for a next class | per load site, the one-count of every index bit over the 64 units within 6 sigma of n / 2 (the per-load prototype's `BiasedIndexBit`, built last night, measured as the record); AS A TEST ONLY AFTER the index fold of layer 1 is in, because on today's `load_index` it would redraw about 40 percent of epochs (lane D: 7 of 16 drawn eras at abs z 130 to 511); with the fold in, the test is the guard that the fold holds | a product's low bits at P(bit 0) = 1/4 placed at address bit R by the stride rotation; 6 of 16 drawn eras over 1.04x, 14 of 17 eras flagged by the instrument on the pre-amendment generator | a program whose site is sourced by a product under an era with R under 28 is refused; the devnet era's R = 29 is not relied on |
|
||||
| The duplicate-lane test (the research file's 20.2a) | built for the per-load class only | kept as a per-load-only test unless a drawn block shape ever places shadow work between loads (layer 1 does not: the block shape is the size, the placement stays after instruction 63) | 1,482 duplicate lanes on the per-load record, 0 to 2 on every sound class | the per-load candidate 0 is refused |
|
||||
|
||||
### 5.1a The three rings (lane D, `docs/analysis/class-v6/family-gate.md` on master at fbe62a8b, 10:40 UTC; the design as the harness measured it)
|
||||
|
||||
| Ring | Who runs it, when | What it checks | Cost |
|
||||
|---|---|---|---|
|
||||
| A | the chain, per era, at the cut | band membership of every layer-1 draw; the day-key cost rule (AP-F4-1); the rotation class recorded (whether R leaves a product's low bits inside the index at the era's D); the lossy-share bound on the weights (`or + mul + mulhi` at most the table's 18 plus B, so r stays under 0.85 and r^256 under 1e-18); a failure consumes the era stream's next block (a redraw) up to a cap, then the base table as the last resort, so the draw is total | microseconds |
|
||||
| B | the chain, per epoch | the acceptance rule keyed on the family's shape (`is_family_shape`: the shadow block in {64, 128, 256} at 6,912 per iteration, m in {4, 8, 16}, the mix one-hot on the drawn width) in the draw's source rule and the acceptance alike; (c'') and (c''') with the expectation divided by the width (at W = 4 words the index space is a quarter, else every width-4 program is refused); the per-site largest-256-item-bucket excess in sigma and the per-site index-bit one-count in sigma on the same 2^20 pass | about 2.2 s per chosen candidate |
|
||||
| C | offline, per drawn era | the attempts census (r, parts, exhaustion), the F8-form census at 2^24 on 64 seeds against the window model at the era's D, the stand-in gap at the era's D, the exhaustion count at 10^4 to 10^5 seeds per lossy corner | box-hours |
|
||||
|
||||
The sampling bound: n drawn eras all passing bound the failing fraction at 3/n at 95 percent (190 eras for 1 in 64, 3,067 for 2^-10); the union bound over the tests' miss rates is bounded by their known-failed CASE counts (9 hot sets give the (c''') floor a miss rate under 0.33), so the proof of testing carries per test the count of fired cases, and tightening is by cases from the adversarial tails, not by eras. The record: one JSON per drawn era (the family id, the draw, the pinned binary sha, the box, the seeds, every verdict with its statistic, threshold, log sha256 and core-seconds) plus a family summary (eras per stratum, the worst era per test, the refuse rate per band, the bound's arithmetic, the corner cells not reached). The mixer m is a per-FAMILY axis (no per-era statistic sees it; the index tests run on the closed form), closed by adv-mixer-3's ladder at m = 4 (2 of 4 applications of margin against 6 of 8) and the x16 verifier row; the per-load placement has one value in the band; W = 16 is excluded. Main's word (11:4x UK): lane D's band is layer 1's op-mix band and the index fold with the bias test as its guard is layer 1's rule, not a next-class note.
|
||||
|
||||
### 5.1b The 17:00 cut, measured (lane D, `docs/analysis/class-v6/family-gate.md` section 6 on the mirror's master at 238100b0, 13:13 UK; 24,000 drawn eras of the family on build-1 and build-3, 54 core-hours, one era per seed with every layer-1 parameter from the era's own stream, the acceptance keyed on the family's shapes; census rows, logs, scripts and the harness diff under `docs/analysis/class-v6/logs/`)
|
||||
|
||||
What the cut settles, each row measured unless marked:
|
||||
|
||||
| Finding | The numbers | What it moves in this document |
|
||||
|---|---|---|
|
||||
| The op-mix band is settled by measurement | B = 4 with or, mul and mulhi free to rise exhausts the 256-attempt cap in 1.2 percent of eras (61 of 5,000 at the lossy corner); with or, mul and mulhi never raised above their base (section 2's band) r = 0.595, mean attempt 1.47, max 59, 0 exhausted in 3,000. The lossy-share curve, complete at 3,000 eras per point (13:5x UK): r = 0.80, 0.88, 0.92, 0.96 at +1 to +4 points on or, mul and mulhi; exhaustion 0, 0.07, 0.20, 1.10 percent of eras, against independent-attempt estimates of 4e-25, 3e-15, 9e-10, 1e-5, so the per-era correlation is 10^5 to 10^10 above the geometric figure and the band's edge is the measured +2, not the arithmetic's | the layer 1 op-mix row's known-failed test now reads 0 of 3,000 under the band (it owed a 0); the cap on the lossy families stays at the base, with +2 points the most the band could ever open to |
|
||||
| What breaks at the lossy corner | class v5's last-resort scan passes at its first or second candidate on every exhausted era seen, so the corner costs liveness time, not an unchecked program; the per-era exhaustion is about 1,000x the independent-attempt estimate because one era's attempts share its weight table, which is the independence the scan's 1e-300 assumes | section 5.2's bound: the attempts within an era are not independent draws; the bound is per era from the census, not r^256 |
|
||||
| The (c''') floor needs a per-width calibration | at width 4 (expectation divided by the width) it refuses 4.5 to 6.5 percent of candidates against 0.6 to 1.0 percent at width 1 | ring B's row: with W pinned at 4 at genesis (section 10.3) it is one measurement, being taken now (the harness's next build records the pre-floor spread of every candidate at width 4 under the band over 3,000 eras plus the width-1 control); the 09:00 report states either a single width-4 floor at the shipped clean-refusal rate (about 2.4 percent of candidates) or the sigma-over-expectation form, whichever keeps the known-failed hot sets refused at the lower clean cost; the default here is the sigma form |
|
||||
| The shape axis moves the draw's cost, the mixer axis is invisible | 64 x 108: 1.8 attempts; 256 x 27: 3.8, through (a')'s fixpoint over the block; r 0.714 to 0.731 across m | m is closed per family by adv-mixer-3's ladder at m = 4 and the x16 verifier row, not by drawing eras (section 5.1a's reading stands, now measured); the block-shape row of layer 1 carries the attempt cost |
|
||||
| The era-stride bias at the bit level | 48 to 58 percent of accepted programs in every stratum carry one site biased at over 6 sigma at 2^20; 73 percent of those at address bit R exactly (12 percent at R+1, 5 at R+2: the product law's bits 0, 1, 2 through `rotl(x*M, R)`); a third over 100 sigma, worst z 1,024; under R in 28..31 the over-100-sigma share falls from 37.7 to 7.5 percent; the bucket statistic sees the same mechanism (p99 +6.8 sigma on bit-clean eras, +44 on biased ones) | one value-level test covers both; the remedy is structural, the index fold of the product's low bits in `load_index` before the rotation (layer 1's rule, section 2), not a per-epoch refusal that would redraw half the epochs; the live-dataset price per site (ring C) is being read now (the F8 census at 2^24 on 64 seeds at two band points; 31 of 64 seeds PASS so far at the shape-256 point, no test fired) |
|
||||
| The bound arithmetic on the counts | 10,000 random eras and 3,000 band eras passing bound the failing fraction on the ring-B tests at 3.0e-4 and 1.0e-3 at 95 percent; the floors' miss rates on the live-dataset classes rest on 9 and 5 known-failed cases (under 0.33 and 0.60), tightened by cases from the adversarial tails, not by eras | section 5.1a's sampling bound now has its measured n; the gate-record JSON per era lands with the full report |
|
||||
|
||||
Owed from lane D by 09:00 UK tomorrow: the per-width floor, the bucket bound in sigma, the finished lossy curve, the two ring-C live rows, the gate-record JSON per era.
|
||||
|
||||
### 5.2 The cost of the redraw, and its bound
|
||||
|
||||
Lane D's measured coverage (11:3x UK; the full table in family-gate.md at 17:00): the (c''') refuse rate per stratum 2.56 percent of candidates in the random stratum, 3.41 at shape 64, 0.42 at the lossy corner; the rule-of-three coverage line a failing fraction under 3e-4 at 95 percent at 10,000 eras. The hash lane's draw costs: the base rules reject about two thirds of raw candidates per attempt (milliseconds), (c'') about 4 percent at 2.2 s per pass on a box core (2.3 s per epoch draw per node, 4 to 5 s on slower cores), (c''') 2.435 percent in the same pass, the bucket bound 1 to 3 percent (estimate) and the value-level test about the same cost again inside the same histogram; expected attempts per accepted program stay about 2.1, and a parameter set under which 32 attempts fail is redrawn.
|
||||
|
||||
Attempts per seed are the price. Today's rule under sub-version 3 accepts a candidate at about 30 attempts per seed on the devnet stream (the hash lane's census: 0 of 24,631 seeds exhausted, max attempt 29 against the 256 cap). Each added test raises the rejection rate by its own fraction; the two new tests' fractions on the pre-amendment generator were 4.9 percent (the per-site bound, the tail's four seeds of 64) and up to 14 of 17 eras on the value-level test, which is the one that needs the generator's own fix (the sub-version 3 source rule already refuses a load sourced by a product on the base program; the value-level test catches what the static rule misses). The bound the chain keeps: 256 attempts, the last-resort draw, and the census's exhaustion count per era at genesis (0 of 10^6 is the standing requirement); a band corner whose exhaustion count is not 0 of 10^6 is excluded from the band at genesis, which is the mechanism that lets layer 1 draw without per-era cryptanalysis.
|
||||
|
||||
## 6. The gate plan: the family analysed as a family
|
||||
|
||||
The attack board (F1 to F10: the shadow's compressibility, the mixer's structure, the cache's recompute, the weak day, the X9 anchor, the verifier, the era draw, uniformity, grinding, the ladder) and the in-house pass's nine lanes (adv-mixer 1 to 3, adv-cache 1 to 3, adv-accept 1 to 3) were each run against one class with its genesis parameters. Under class v6 each harness takes the era draw as an input and the census walks the band:
|
||||
|
||||
| Gate | What runs | The window | The pass line | The known-failed case |
|
||||
|---|---|---|---|---|
|
||||
| G-band (layer 1) | every F row and every adv lane at the band's corners (m in {4, 8, 16} x W in {1, 4} x the weight extremes x the block shapes 64 and 256) plus 64 random eras of the stream | the testnet period, on the pool with --class measure | every row's own line at every corner (F8 1.2x, the 6-sigma buckets, adv-mixer-3's "clean from k = 1", adv-cache-2's hot-set share under 0.1 percent) | the quarter-lines and const-item plants at every corner; a corner that fails any row is out of the band |
|
||||
| G-size (layer 2) | the hot-set and recompute lanes at 2, 4, 8 and 16 GiB with the sample rule; the verifier's lazy bound at each cache doubling | the same | the verifier under 10 ms with the sibling loaded at every size (the ladder's method); the chip curve monotone | a stale-state hasher at a larger state reads 0 of 32 lanes |
|
||||
| G-family (layer 3) | the family-live 5 percent run per vendor at each reserve entry's weight (Metal, CUDA, OpenCL on NVIDIA and AMD), the conformance vectors of 1.15 per family, one family epoch crossed on the fast-time harness with a stale miner | the same | every vendor within 5 percent with the family live; the stale miner's blocks rejected from the first block | the stale kernel refused at packcheck |
|
||||
| G-accept (layer 4) | the generalised rule against the per-load record (candidate 0 refused), the tail's four seeds (refused) and the 60 passing seeds (accepted); the exhaustion census per corner (0 of 10^6) | the same | the hand census equals the rule's verdicts seed for seed | the two plants above |
|
||||
| G-vectors | every vendor's fingerprint equal on one pack per corner (the sub-version 3 pairing method: Metal, CUDA, Apple OpenCL, AMD OpenCL) | before any object | 96 of 96 lanes and the 2^24 fingerprint per corner | a corner's pack refused by a worker on the wrong class |
|
||||
|
||||
Hours (agent, never weeks): the generator's five draws and the band constants 6 to 8; the acceptance rule parameterised plus the two new tests 6 to 8 (the per-load prototype carries the two tests' code); the census tool over the band 4; the family cadence and rotation in the fork 6 to 8 (the class signal's shape); the fast-time harnesses 4 per layer; the attack board re-run over the band is pool time, about 20 corners x the board's 3 to 6 hours each, pipelined on both boxes over the testnet period.
|
||||
|
||||
## 7. What a fully general chip still gets
|
||||
|
||||
The identity of the research file's section 2, with the night's measured rows: `edge = (E_card + F) / (E_mem + k F)`. None of the four layers enters it except through `F` (the premium the card pays) and `k` (the chip core's cost per op over the card's), and the layers move neither for a GPU-like chip:
|
||||
|
||||
| Layer | Its effect on a GPU-like chip's edge | Its effect on that chip's capex | The honest line |
|
||||
|---|---|---|---|
|
||||
| 1 | none on `k` (firmware); `F` moves with the draw and the card pays it first; the core sized for the band's top | +USD 25 to 40 of N5 per chip; the project N5-class from the first tape-out: USD 30 M, a break-even cap of about USD 100 M (the mission lane's model) | a chip that is a GPU's memory system plus a programmable core is drawn against by nothing here |
|
||||
| 2 | none while the dataset fits its DRAM (24 to 32 GB against the card tiers' 8 to 32) | none until 16 GiB; then +USD 200 per HBM3 stack | retires cards before chips |
|
||||
| 3 | under 10 percent per family; USD 4 pre-wired | +USD 4 | a datapath taped out against one family set dies; a general one does not |
|
||||
| 4 | none | none | the safety of 1 and 3, not a lever |
|
||||
| All four, against a 5090 at its knee | **3.6x at zero premium, 2.1x at `k = 1` with the class v4 shadow (measured card, modelled chip), unchanged** | about +USD 30 to 60 per chip; the N5 project forced | "useless as soon as it dropped" is true of a fixed-function ASIC and false of the chip anyone builds; what holds the general chip is the price per joule of the honest card's own operating point and the shadow's premium, as last night's close said |
|
||||
|
||||
## 7a. The hardware future beside the four layers (lane B, `docs/analysis/class-v6/hardware-future.md` on master at 34f63b3c, 10:25 UTC; every chip figure modelled on chip-model-v3's method; the first cut, the full report by 09:00 UK tomorrow)
|
||||
|
||||
The chip rows of sections 2 to 7 price the GDDR7 board and one HBM3 stack. Lane B's table carries the memory systems a chip could buy from 2027 on, per joule at zero shadow against the 5090's 2.40 microjoules (the M5 Max's 0.78 in brackets) and with the class v4 shadow at the premium F = 1.10 at `k = 0.5 / k = 1`:
|
||||
|
||||
| Memory system (when) | Energy per random read, modelled | Edge at zero shadow | With the shadow, k 0.5 / 1 | What the four layers do to it |
|
||||
|---|---|---|---|---|
|
||||
| GDDR7 board, 28 nm controller (the record) | 2.0 nJ | 5.1x (1.7x) | 3.3x / 2.1x | layer 1 forces the N5 core (the project to USD 30 M); nothing else |
|
||||
| HBM3E, one stack | 1.2 nJ | 7.5x (2.4x) | 3.8x / 2.4x | the same |
|
||||
| HBM4, one stack, 2027 to 2028 | 1.0 to 1.1 nJ | 4.5x to 11x (the activate ceiling doubles if tFAW is per channel, unmeasured) | 4.4x / 2.6x | the same |
|
||||
| A custom HBM4E base die (controller and PHY in the stack, N3P; a non-hyperscaler from about 2028) | 0.9 to 1.0 nJ | 6.5x to 14x (2.1x to 4.4x) | 4.4x / 2.6x | beats all four layers: untouched by any of them |
|
||||
| DRAM on logic (FGDRAM-class 256-byte rows, hybrid bonded), 2029 to 2031 | 0.5 to 0.7 nJ | 12x to 20x (5.2x) | 5.1x / 2.8x | beats all four layers |
|
||||
| **An SRAM full store on one N2 reticle, 2 GiB (452 mm^2 of macro, USD 400 to 600 of silicon)** | 1.0 nJ (0.5 to 2.0) | power-bound at about 2,100 MH/s per die at 300 W: 17x (8x to 30x; 5.6x against the M5 Max), USD 0.25 to 0.4 per MH/s | 4.8x / 2.7x; at the 5090's whole latency shadow (F about 2.0) 3.7x / 2.0x | beats layers 1, 3 and 4; **layer 2 moves its capex, not its joules** (two dies at 4 GiB 15x and USD 1,000; four at 8 GiB 13x and USD 2,000 to 2,500) |
|
||||
| LPDDR6 controller chip | 1.5 to 2.0 nJ | 4x to 5x (1.3x to 1.6x) | | the honest SoC tier's own memory |
|
||||
| Per-bank PIM, UPMEM, an FPGA with HBM2e, wafer-scale, CXL, optical | | under 1x or no path (PIM is blind: 1.6 percent of reads in-bank at 2 GiB on a 32 MB bank, 0.4 at 8 GiB) | | |
|
||||
|
||||
What this changes in the design, taken into the layers:
|
||||
|
||||
1. **Layer 1's program-length band is sized against the N2 SRAM chip, not the GDDR7 board.** The lower bound is the length that holds the record's 2.1x today (rung 0, 102,100 ops); the upper bound is the honest cards' full latency shadow (the 5090 about 330,000 ops unlocked and 150,000 at the lock, the M5 Max 290,000 by its budget and 130,000 by the 5 percent rule, the 9070 XT 650,000, all measured or budgeted in the ladder's rows), re-based at each family epoch; N still moves by the ladder's signal inside that band, never by an unconditional draw. The public "2x" is not reachable against the SRAM chip at any `k` under 1 (2.0x needs the 5090's whole shadow at `k = 1`), which is the honest line section 0 now carries.
|
||||
2. **Layer 2's floor is a card-lifetime decision, not a chip lever**: the SRAM chip pays capex, not joules, for a larger dataset (USD 400 to 600 per 2 GiB die), and the real processing-near-memory threat is the custom base die, which no layer touches; the brake until about 2028 is HBM allocation and price (claimed: Samsung asking USD 4 to 5 per Gbit for HBM4 against 1.5 for HBM3E, 2 October 2026). The spec's schedule stands; the dataset stays inside 16 GB unified memory (8 GiB at the top of the schedule does).
|
||||
3. **The denominator: resistance is stated against the best honest joule** (the M5 Max at 0.78 microjoules, the 5090 at its knee at 1.67), where every chip edge is 2x to 3x smaller than against the 5090's stock point; section 0's per-tier line already reads so.
|
||||
|
||||
## 7b. The history beside the four layers (lane A, `docs/analysis/class-v6/history.md` on master at 4c58ad65, 10:26 UTC; the first cut, the full report by 09:00 UK tomorrow)
|
||||
|
||||
Lane A's verdicts, taken into the layers where they bind:
|
||||
|
||||
| Finding | What it does to this document |
|
||||
|---|---|
|
||||
| The chip that stores the dataset (every Ethash chip: E3 1.0x, Linzhi 2.1x, Jasminer X4 5.1x by DRAM hybrid-bonded onto a 40 nm logic die, E9 Pro 4.1x) is closed by none of the four layers; every per-era draw and every family epoch is firmware to it; the number v6 inherits is 5.1x on GDDR7 at zero premium, 3.6x at the 5090's knee, 2.1x with the class v4 shadow at `k = 1` | section 0's frame and section 7's table say the same; the Jasminer X4 is the measured precedent for the hybrid-bonded row of 7a |
|
||||
| Layer 2 as declared is not an anti-chip rate: at 2 GiB plus 0.5 GiB a year a 32 GB chip board lasts 60 years and the 8 GB card is out at year 12; the Ethash E3 is the only chip a growth rule ever killed (DDR3 at the fleet's own 4 GB limit, 20 to 27 months after shipping); the value of layer 2 is its floor (above every SRAM die) and a CEILING per honest tier; if the state grows as Ethereum's did the dataset outgrows every card in a decade (approximate), so the ceiling in GB per tier per year is the number to fix before the rate | layer 2's rule gains the ceiling as its first constant: the dataset never exceeds the schedule's power-of-two step for the year (2 GiB to year 4, 4 to year 12, 8 to year 28), whatever the state does, so the state can only bring a step forward, never add one; section 3.2's reading ("retires cards before chips") stands and is now the reason the ceiling exists |
|
||||
| Layer 3 closes the fork (Grin's tweaks worked only as hard forks on a lane built to die; Monero's chips were back inside 4 months of v8; X16R's drawn order cost the FPGA nothing) and taxes a sequencer chip only die area (Rao: ProgPoW's whole inner loop about 1 M gates, 0.025 mm^2 at 10 nm), because a reserve readable at genesis is built in from day one | section 4.2's "USD 4 pre-wired" row is the same fact with the history's gate count beside it; the random item-derivation program (R0) is the one reserve item unknowable at genesis and keeps its place at the head of the order |
|
||||
| Layer 4 is the one layer acting on a mechanism that beat hashes after launch (Kik's 64-bit seed, Dinur-Nadler on MTP, AP-F8-1); the window-model null changes with any draw of read width, program length or windows, so the census re-derives per era (2.2 s per candidate), and the shadow-written residue (about 1.0004x) needs its own ceiling per shadow placement | section 5.1's F8 row gains the per-era null: the census tool derives the window model from the era's own draws before judging; the residue ceiling is a constant of the block shape (64 and 256 are the two shapes, each with its own) |
|
||||
| Corrections to the 5 October history: the Antminer X9 withdrawn in May 2026 with zero units shipped; RandomX v2 shipped 25 March 2026 (program 256 to 384, CFROUND 16x rarer, AES in the loop, prefetch 2 ahead, +52.9 percent work; activation pending in Monero PR 10038); Vorick's "survives forks at under 5x" chip unverified | this document quotes none of the three; the research file's section 2 already retired the X9 as a `k` figure |
|
||||
| The ranked list: layer 2's floor and ceiling per tier first; the clock and the detector (two instruments: nonce pattern for a chip, share-by-key-and-template for a concentrated fleet, which is what found Qubic until it randomised); keep the read width out of the draw unless a width other than 4 B is measured latency-bound on every vendor; bound the op-mix draw by the per-vendor energy table; order the reserve by what a sequencer cannot fold into firmware, mm8 last, R0 the one unknowable item; a null per drawn parameter for layer 4; the per-load placement as research, not a draw value | taken as the order of section 6's gates; the read width: w16 is measured within 2.7 percent on the 5090 and the 9070 XT and within 1 percent on the M5 Max (latency-bound on all three), so {1, 4} words stays in the band with that measurement as its warrant; the per-load placement is already out of the draw (section 2) and dead as a construction (the research file 20.2a-close) |
|
||||
|
||||
## 9. The served chip line reviewed against the measured and modelled rows (for main's word at the 15:45 UK close; nothing served moves on this document)
|
||||
|
||||
The served sentence (`docs/plans/counter-asic-3-public-text-2026-10-07.md`, the homepage paragraph and row 17): "At launch the strongest chip in our public model reaches 2.1x per joule against an RTX 5090 with a core as good as a GPU lane, 3.4x with one three times better, under class v4 from the first block; a 5090 locked at its knee pays 82 W for that shadow work. Class v5 then makes the dataset the chain's own state, so a chip that stores it or recomputes it is wrong on every item. Without class v4 the same chip would reach 5x to 9x."
|
||||
|
||||
| Clause | Its basis today | Measured, modelled or claimed | Stands, or what it needs |
|
||||
|---|---|---|---|
|
||||
| "the strongest chip in our public model" | the GDDR7 stored-dataset chip of chip-model-v3 5.5, the 2027-on chips of 5.12 now in the model | the SRAM store (17x at zero shadow, 2.7x to 4.8x with it) and the custom base die (6.5x to 14x) are modelled in the same file since today | the clause is no longer true of the public model as written: the strongest chip in it is the SRAM store, five years out, with the project cost and the clock beside it |
|
||||
| "2.1x per joule against an RTX 5090 with a core as good as a GPU lane" | the class v4 shadow at the 5090's knee: 82.8 to 90.5 W premium measured four times, 6.4 to 6.6 pJ per counted op; the chip's memory 0.466 microjoules modelled | measured card, modelled chip | stands for the GDDR7 chip; against the SRAM store the same clause reads 2.7x at `k = 1` and 2.0x only at the card's whole latency shadow |
|
||||
| "3.4x with one three times better" | `k` 0.33 for an ALU-shaped core: an estimate from datapath and wire figures (approximate); the microbench bounds the GPU side (11.3 pJ per ARX op stock, 6.2 at the knee) and the re-weight's hold stands. **The k lane's first RTL rows (10.2, 13:3x UK): k 0.18 at the lock at N3 (0.35 on unscaled ASAP7), every chip figure a floor; the GDDR7 chip with the shadow 4.0x at the knee, 5.8x at stock, so "three times better" is the unscaled-ASAP7 unit case and a bare unit at N3 is five to six times better; the sequencer core a rotating family forces sits between, its row owed as the headline; the served figure must survive 4.0x at the knee as the worst case** | modelled; the chip side never measured | stands as the pessimistic column for the GDDR7 chip; the SRAM store's pessimistic column is 4.8x (`k` 0.5 on an N2 core, lane B) |
|
||||
| "a 5090 locked at its knee pays 82 W for that shadow work" | the efficiency pass: 81.8 W at the best points, 82.8 on the packs job, 90.5 on the sparse job's base rows | measured | stands |
|
||||
| "Class v5 then makes the dataset the chain's own state, so a chip that stores it or recomputes it is wrong on every item" | class-v5-stored-state.md: a stateless or stale chip is wrong on every item; a chip that holds the state (one node per farm, the leaves at 16.5 KB/s) is not | designed, measured on the harness | the clause overstates: "a chip that does not follow the chain is wrong on every item" is the true form; a chip that stores the dataset and follows the chain is unmoved (chip-model 5.10) |
|
||||
| "Without class v4 the same chip would reach 5x to 9x" | chip-model 5.4: 5.1x GDDR7 to 9.2x eight HBM3 stacks at zero shadow | modelled | stands for the DRAM chips; the SRAM store reads 17x |
|
||||
| The served "random reads" sentences (uniform random reads over the dataset) | lane D and adv-cache-2: the reads spread over the whole dataset; a bit-level bias at one site in about half of epochs; 1.6 percent of a hash's reads to a half store, nothing to a full store; the next class folds it out | measured | CORRECTED today by the audit lane on those figures (main's word, 11:4x UK); not pending |
|
||||
| The convention behind every "x per joule with the shadow" figure | the record's convention (chip-model-v3 and the served text): `k` is the chip core's op cost as a fraction of the GPU's at the same operating point, so when the 5090 locks to 6.2 pJ the chip's core is priced at 6.2 k pJ too; the absolute convention: the chip's core costs its own pJ per op (the k lane's RTL figure), the GPU's its measured pJ at its point | measured GPU side; the chip side claimed until the k lane's RTL rows | the record's convention flatters every chip row at the lock by up to 1.6x (floor lane 3, 13:06 UK: the SRAM die 6.1x against 3.8x at k 0.5, 3.3x against 2.0x at k 1); the served line's figures are at the knee, so they are in the record's convention and need re-reading in the absolute one before main's word; the close carries both, absolute first |
|
||||
| The schedule's cost beside the SRAM store's edge (main's order) | section 3: the floor and ceiling per tier; main's candidate schedule (6, 10, 14 GiB) priced by lane A by 17:00; the Apple rate cost measured today (-12 / -20 / -22 percent at 2 / 4 / 8 GiB) | measured and modelled | lands at 17:00 |
|
||||
|
||||
The wording this lane proposes for main's word, if the SRAM-store reading stands at the 15:45 UK close (the synthesis on the mirror's master by 16:30; the decision itself stays the founder's): "At launch the strongest chip we can price today, a memory-controller chip that stores the dataset, reaches 2.1x per joule against an RTX 5090 at its knee with a core as good as a GPU lane and 3.4x with one three times better, under class v4 from the first block; a 5090 locked at its knee pays 82 W for that shadow work. The strongest chip we can model for 2027 to 2028, a 2 GiB SRAM store on one 2 nm die, would reach 3x to 5x with that same shadow and 17x without it, at an N2 project of USD 100 M to 500 M and 18 months or more; the dataset's size is the one lever on it, and the schedule says how it grows. Class v5 makes the dataset the chain's own state, so a chip that does not follow the chain is wrong on every item. The model and every measurement are public." Every number in it is labelled in this document and the chip model; nothing served moves on this lane's word.
|
||||
|
||||
## 7c. Beyond the four: the invention lane's layers 5 to 7 and its rejected list (lane C, `docs/analysis/class-v6/invention.md` on master at a9f03598, 10:48 UTC; the census script `tools/attack/v6-invention/v6inv-census.sh`)
|
||||
|
||||
| Layer | What it is | The chip rows | The card cost | Status |
|
||||
|---|---|---|---|---|
|
||||
| 5. The shadow placed per load, in its sound form (`mx8+shl4096x1`: one pass of a 256-instruction sub-block after every load, the same 4,096 shadow instructions per iteration as class v4) | the capex lever of the research file's section 16.2: the chip's core sits inside every read's dependency, so controller and core share one N5-class die or an interposer | the project about USD 60 M against 30 M, the break-even cap about USD 200 M against 100 M (modelled); `k` unchanged | measured on build-1 (10:4x UTC, this crate): 234 of 256 seeds accept within 32 attempts at 0.927 rejection per candidate (P(exhaust at 256) about 4e-9); the verifier 8.28 to 8.82 ms on core 40 with core 88 loaded against 8.33 to 8.63 for class v4's shape; **the 5090 rows MEASURED (the hash lane's v6 job, 11:14 to 11:20 UTC, every pack PASS at both states): mx8-genesis 137.65 MH/s at 312.2 W unlocked and 127.39 at 212.6 W at 1,300 (0.599 MH/W); class v4's shape mx8_sh256x27 137.62 at 464.6 and 126.99 at 296.8 (0.428); the sound per-load form mx8_shl4096x1 135.99 at 428.6 and 126.08 at 271.8 (0.464); mx8_shl2304x3 135.85 at 483.6 and 125.88 at 303.4 (0.415). The rate within 1.3 percent of the control on both forms; the premium over class v3: shl4096x1 116.4 W unlocked and 59.2 W at the lock against the whole block's 152.4 and 84.2, so at class v4's instruction count the one-pass per-load form costs the 5090 24 percent LESS unlocked and 30 percent less at the knee (the 16-instruction block effect of 6 October, now with a sound construction); shl2304x3 (three passes of 2,304) costs 19 W MORE unlocked and 6.6 W more at the lock than the whole block, so the saving is the one-pass shape, not the placement**; the Apple footprint of a 4,096-line block owed (the 1,024-line block cost the M5 Max 17 percent on 6 October) | a candidate class; the 16 x 27 iterated form stays dead (20.2a-close of the research file, corrected to the no-era figure) |
|
||||
| 6. The register-file width drawn per era in {8, 16, 32} | the link tax on layer 5: 4.5 to 9 TB/s of die-to-die traffic closes the interposer branch (modelled, the link figures approximate) | forces the single die | about 0 rate on every card by the occupancy arithmetic (unmeasured) | research |
|
||||
| 7. Warp-uniform data-dependent block selection (B drawn sub-blocks, one selected per iteration by a warp-folded register, no divergence) | moves the FPGA lane only (a per-program bitstream must carry every block) | nothing against the `f = 1` chip | B capped by the Apple compile footprint | research |
|
||||
| The reserve ordered by hardware orthogonality inside layer 3 | shfla first, the int8 tile last | section 4.2's order | | taken |
|
||||
|
||||
Rejected by the invention lane with the number (its section 2): per-lane data-dependent branches, reads tied to the shard proof per block, randomised memory topology, the VRAM ratchet as a lever (layer 2's floor stands as written), pool-sampled witnesses, prover-gated eligibility (1.6 kW of proving network-wide at any hash rate: 0.7 percent of the hash at 100 GH/s), time-locked commitments beyond the era VDF, derived reads in the hash, row-straddling reads, scratch draws, the refresh per block. No candidate moves the per-joule identity.
|
||||
|
||||
## 10. The floor: the stored-dataset chip's per-joule floor and everything behind it (the five floor lanes of 12:55 UK; their rows by 15:30 UK (the coordinator's clock, 13:30 UK; the earlier 19:30 defaults became 15:30), their documents under `docs/analysis/class-v6/floor/` by 19:30; this section closes at 15:45 UK on every row in by 15:30, each missing row's default stated and the row owed; later rows are amendments with their own time)
|
||||
|
||||
The question the founder set: the stored-dataset chip pays the DRAM's own energy per random read and nothing else; the honest card pays that plus everything its silicon spends around the read; the floor is the ratio, and every term in it is a lever. The identity (the research file's section 2): at zero shadow `edge = E_card / E_mem`; with the shadow `(E_card + F) / (E_mem + k F)`. The lanes each take one term.
|
||||
|
||||
| Lane (branch) | The term | What this document already holds (measured) | The default if the lane's rows are not in by 15:30 UK | The row owed |
|
||||
|---|---|---|---|---|
|
||||
| SM-sparse (`class-v6-floor-sm`) | `E_card` above `E_mem`: the honest card's watts toward the DRAM's own at the activate ceiling, on rented 5090, 4090 and H100 and PC 1 through the hash lane's jobs, with a worker patch | the research file's 20.3b (PC 1, 7 October night, the fourth exe): a quarter of the SMs holds 98.2 percent of the class v4 rate at the SAME draw (460 against 451 W) and 99.8 percent of class v3 at 4 W less; watts minus idle per MH/s never falls below base; the draw follows the work, not the SM count; at the 1,300 lock the sparse shapes collapse. The candidate was closed on that row | the 20.3b reading stands: the honest card's premium-free floor is its idle plus its memory system plus whatever the SMs spend waiting (99 W of 228 at the lock, measured as the residual, not reached by idling SMs); the 5090 at its knee 1.66 microjoules against the GDDR7 chip's 0.466: 3.6x | a measured breakdown of the 99 W that an idle SM does not save (clock tree, L2, fabric), and whether a different occupancy shape (fewer warps per SM at full SM count) moves it |
|
||||
| The shadow's k (`class-v6-floor-k`) | `k` from RTL synthesis (Yosys plus OpenROAD on build-4) replacing every claimed chip-side k; the op mix that maximises k | the GPU side measured (the research file's 15.1a): ARX 11.3 / 6.2 pJ per counted op, mul 13.9 / 8.3, mulhi 39.6 / 21.0, prmt 22.3 / 11.5, lop3 24.1 / 13.0, shfl 55.8 / 29.4, fp32 FMA 9.2 / 5.2, the int8 tile 1.4 to 4.1 per MAC; the chip side was claimed only (a 5 nm SIMD array 2 to 5 pJ per op, approximate) until the lane's first rows (10.2, 13:3x UK): synthesised on ASAP7, k 0.18 at the lock in the absolute convention at N3 (0.35 unscaled), the ARX families 0.17 to 0.18, mad 0.20, mulhi 0.03 | the default stays the record's rows at k 0.5 (the DRAM chip 2.9x at the knee) with the unit floors as the lower bound: the GDDR7 chip with the class v4 shadow at 4.0x at the lock and 5.8x at stock (absolute, floor k) is the worst case the served line must survive, not the reading; the sequencer-core row with the M5 Max column is the headline when it lands; the op mix held at class v4's (15.1b: the shuffle at 4.9x the add on the GPU side; mulhi the worst lever on the card by 6x) | the synthesised pJ per op per family for a 32-lane SIMD core at the chosen node, the resulting k per family, and the mix that maximises k at a fixed GPU premium |
|
||||
| The SRAM full-store die (`class-v6-floor-sram`) | `E_mem` for the chip lane B named strongest: the wire-energy lever, the dataset schedule that keeps the store above USD 5,000 of silicon through 2031, the capex wall recomputed with rotation | lane B's rows (7a, chip-model 5.12): 2 GiB on one N2 reticle, 1.0 nJ per read (0.5 to 2.0), 17x at zero shadow, 2.7x to 4.8x with it, USD 400 to 600 per die; lane A's schedule (3.4): 5.5 / 8 / 11 GiB retiring a quarter of today's consumer cards per step; the M5 Max's measured rate cost of the larger working set (3.3) | lane B's and lane A's rows stand; the schedule decision is the founder's with the per-tier table (0.1) | the schedule in GiB per year that keeps the store at USD 5,000 or more of silicon through 2031 on the SRAM cost curve, and what it costs each tier by year |
|
||||
| The honest denominator (`class-v6-floor-denominator`) | `E_card` per tier with every software knob (the core lock, the undervolt, the memory clock, the occupancy, the block shape); the Ember tier table; FIRST TABLE IN (10.4, 13:4x UK): the honest NVIDIA floor is the 16 GB Blackwell card at its knee (the 5080 2.06 measured), the only lever the operating point | the knee rows (the research file's 20.3a and 20.3b; the cost rows): the 5090 at 1,300 MHz 1.66 to 1.69 microjoules, the 4070 at its tune 2.57, the 5070 Ti stock 1.79, the M5 Max 0.78 (GPU and DRAM channels); the operating point is rank 1 of the research file | those rows stand as the per-tier floor; the Ember knob ships as ordered for 0.3.24 | the per-tier best point with every knob, the Ember table, and the AMD and Apple lines (ADLX or nothing; no lever) |
|
||||
| Invention beyond the four (`class-v6-floor-invention`) | the tensor core's k re-read, the RT core, controller plus PHY cost, proof of latency, era-driven address mapping, the literature since 2023 | the research file: the tensor tile's measured 1.4 to 4.1 pJ per MAC against a 5 nm array's claimed 0.04 to 0.4 (k 0.03 to 0.3, the worse lever); the L2 hit at 1.4 to 2.4 nJ against a chip's SRAM (k 0.1 to 0.3); the texture interpolator excluded (not bit-exact across vendors); the literature table (sections 5 and 8) | those readings stand; no new lever | anything that reads k above 1 on measured rows, which nothing has |
|
||||
|
||||
The close (15:45 UK; the synthesis on the mirror's master by 16:30): the honest floor per tier against each chip row, the recommended class v6 changes (the op mix, the SM-sparse default, the dataset schedule), the served chip line as a measured range, and what stays for v7, written here from the lanes' rows as they land.
|
||||
|
||||
### 10.0 The close at 15:45 UK (the founder's standing rule, 13:3x UK: anything doable in 30 minutes to 2 hours gets a clock inside that window; so 15:45 is THE close, not a provisional: the floor lands on every row in by 15:30, and the lock rows and the k lane's remaining unit rows go in below as amendments, each with its own time; there is no 20:00 event. Lane 3's 7bc9de4b capex numbers are final for the close; the SRAM die headlines the k lane's synthesised sequencer-core row the moment it lands, by 15:00, with its 6x to 10x at the full shadow kept beside it marked as the floor-k worst case)
|
||||
|
||||
The table, filled with the defaults, each cell replaced as its lane's row lands before 15:30 (the k lane's sequencer-core row by 15:00; the SM-sparse 5090 knee row by 15:00; lane 5's W = 16 row on a rented 5090 by 14:45; the denominator lane's table); after 15:45 each later row is an amendment under this section with its own time. The edge is whole-card joules per hash over whole-chip joules per hash; the card's energy is measured (class v4, the shadow on), the chip's memory energy modelled (chip-model-v3 5.5, lane B's 5.12 and lane 3's wire figure for the SRAM die at the genesis width of 4 words, 0.051 microjoules per hash), and the chip's shadow is priced two ways in every cell: first at the DEFAULT k of 0.5 in the record's convention (half the card's own shadow premium: 0.55 microjoules at stock, 0.33 at the lock, 0.31 on the M5 Max; marked `default`), then at the k lane's per-unit floor (0.11 microjoules per hash at N3, absolute, every unit a floor; marked `floor`), the worst case the served line must survive; the sequencer-core row replaces the default when it lands.
|
||||
|
||||
| Honest tier (measured joules per hash, class v4) | GDDR7 board (0.466) | HBM3 one stack (0.321) | SRAM die at W = 4 (0.051) | With SM-sparse | W = 16 variant |
|
||||
|---|---|---|---|---|---|
|
||||
| RTX 5090 stock (3.36; class v3 2.26) | 3.3x default / 5.8x floor | 3.9x / 7.8x | 5.6x / 21x | 1.3 to 3.9 percent at best, MEASURED (10.1, 14:10 UK: four rented 5090s, the ceiling held to 43 of 170 SMs; the 4090 saves 4 W at 16 SMs, the H100 1.4 percent); the residual is the clock domain, which only the lock takes off; the fraction a chip cannot strip is 16 to 19 percent of the 5090's stock joules, 25 percent at the lock | not applied (default: outcome A, the card -47 percent, dead; the rented-5090 row owed by 14:45) |
|
||||
| RTX 5090 at the 1,300 MHz lock, the record's operating point (2.33 = 1.67 + 0.65) | 2.9x / 4.0x | 3.6x / 5.4x | 3.9x absolute (6.1x in the record's convention, marked) / 14x | no change (the lock pass on PC 1 running; the knee row by 15:00) | not applied (same default) |
|
||||
| Apple M5 Max (1.40; class v3 0.78; the GPU and DRAM channels) | 1.8x / 2.4x | 2.2x / 3.2x | 3.9x / 8.7x | not applicable (no SM lever on Apple) | not applicable (the M5 Max pays 0 at 64 bytes, measured; the chip +33 percent) |
|
||||
| RTX 5080 at its 1,100 MHz lock, the honest NVIDIA floor (2.06 class v4 measured: 71.20 MH/s at 146.6 W; class v5 2.10; floor lane 4, 10.4) | 2.6x / 3.6x | 3.0x / 4.8x | 3.6x / 13x | no change | not applied |
|
||||
| RTX 4090 stock (5.005; class v3 3.588; rented pod, 10.1) | 4.3x / 8.7x | 4.9x / 11.6x | 6.6x / 31x | no change (measured: 16 of 128 SMs saves 4 W) | not applied |
|
||||
| H100 HBM3 at its 700 W cap (2.776, throttled to 1,750 MHz; class v3 1.769; rented pod, 10.1) | 2.9x / 4.8x | 3.4x / 6.4x | 5.0x / 17x | no change (measured: per-SM throughput-bound, 1.4 percent) | not applied |
|
||||
|
||||
The honest floor in one line, on the defaults: against the chip anyone can build (the GDDR7 board) the strongest honest tier by joule, the M5 Max, holds the chip to under 2x, the 16 GB Blackwell card at its knee (the 5080 measured, the 5070 Ti and 5070 modelled; floor lane 4's finding that the honest NVIDIA floor is not the 5090) to about 2.6x and a 5090 at its knee to about 3x; against the chip that needs an N2 project (the SRAM die) the holds are about 4x and 4x; every cell's floor-k figure is the worst case if a chip's core costs no more than its bare units.
|
||||
|
||||
The capex wall's two thresholds (lane 3, 10.3, re-folded 13:18 UK on lane 5's corrected project floor of USD 20 to 75 M for the cheapest DRAM-board chip): no rational chip project of any kind below about USD 23 M a year of miner revenue (IGN 0.03 at launch emission, USD 62 K a day, a cap of about USD 60 M); every DRAM-board project at a third of the network above about USD 340 M a year (IGN 0.44, USD 0.93 M a day, a cap of about USD 0.9 B), where the SRAM project also starts. The project cost moves the threshold 5x, the chip's edge 1.4x.
|
||||
|
||||
The served line in one sentence, on the defaults: a chip that stores the dataset reaches about 3x per joule against an RTX 5090 at its knee and under 2x against an M5 Max, 4x against both if it is built on an N2 SRAM store for USD 100 M or more, and no rotating layer moves those figures; the layers decide which chip can be built and how long its tape-out lives. The worst case beside it, if a chip's core costs no more than its bare units (the floor k of 10.2): 4x and 2.4x against the DRAM board, 14x and 9x against the SRAM die (lane 3's line: 6x to 10x at the full shadow, never under 2x).
|
||||
|
||||
The three class v6 changes it implies: (1) the op mix stays class v4's with the lossy families capped at their base (lane D: 0 of 3,000 eras exhausted under the band; the k lane: mulhi is the card's worst lever by 6x, so no re-weight helps the card); (2) no SM-sparse default (dead on the measured 5090 rows; the per-watt gift of the hot table is the chip's, 20.3c); (3) the dataset schedule 5.5 / 8.5 / 11.5 GiB with the read width pinned at 4 words (the chip's ticket USD 1,500 / 2,500 / 3,000; a tuned 5090 pays 1 to 5 percent per step; about a quarter of today's cards by count per step).
|
||||
|
||||
### 10.1 SM-sparse on the honest card (floor lane 1, `docs/analysis/class-v6/floor/sm-sparse.md` on class-v6-floor-sm at 9e478daf, first rows 13:25 UK; the knee row by 15:00, the file by 15:30)
|
||||
|
||||
**The 20.3b reading holds on three more cards, and the residual over the DRAM's own energy is the clock domain, not the SMs.** Rented pods at stock (the worker's `--bench`, 250 x 2^24, nvidia-smi at 1 Hz, bit-exact on every row); microjoules per hash, the edge card over chip (GDDR7 0.466, HBM3 0.321, SRAM at lane B's 0.14):
|
||||
|
||||
| Card (measured) | Class v3 base | SM-sparse best | What the SM count does | Class v4 |
|
||||
|---|---|---|---|---|
|
||||
| RTX 4090 (128 SMs, idle 34.7 W) | 70.63 MH/s at 253.4 W = 3.588 microjoules (7.7x / 11.2x / 25.6x) | 16 SMs 69.95 at 249.6 W = 3.568 (saves 4 W); 32 SMs 70.42 at 251.4; 8 SMs 65.07 at 239.1 = 3.675; 128 SMs x 1 warp 66.28 at 242.7 | the draw over idle is 205 to 219 W at every shape; the knee 16 of 128 SMs | 70.63 at 353.5 W = 5.005 (premium 100 W, 11.1 pJ per counted op) |
|
||||
| H100 HBM3 (132 SMs, idle 71.3 W) | 254.63 at 450.4 W = 1.769 (3.8x / 5.5x / 12.6x) | the self-tune's sp127-w32 253.2 at 441.6 W = 1.744 (1.4 percent under base); 132 SMs x 8 warps 99.2 percent at the same draw | the rate falls one for one with the SM count (99 SMs 212.7 at 402.8 W, 1.894; 66 SMs 189.2 at 372.9; 33 SMs 123.4 at 291.0, 2.358): per-SM throughput-bound at 1.93 MH/s per SM, not activate-bound; -pl and -lgc refused, -lmc accepted but the memory clock stayed at 2,619 MHz | 251.9 at the 700 W cap = 2.776 (clock throttled to 1,750 MHz); class v5 250.2 at 698.8 W = 2.793 |
|
||||
| RTX 5090 (PC 1, unlocked) | 20.3b's points: full 2.30 at 313.9 W; a quarter 2.27 at 309.8; an eighth 2.28 at 303.4; a sixteenth 2.74 at 274.5 | the finer ladder (170 to 11 SMs at 32 warps; 170 SMs at 16 to 1 warps; the matched-warp pairs) runs on four rented 5090s and on PC 1 (`run-ca4-pc1-floorsm-5090-20261008`, the unlocked pass done, the 1,300 lock pass running) | 170 x 8 warps 137.0 at 482.9 W against base 137.16 at 452.8 on class v4; 170 x 4 134.9 at 483.9: fewer warps per SM at full SM count does not lower the draw either | base 137.16 at 452.8 W |
|
||||
|
||||
The reading for section 2's term: what an idle SM does not save is the die's clock-domain cost (the clock tree, L2 and the crossbar, the memory controllers, leakage at 2,700 to 2,850 MHz); the 4090 at 8 SMs and 65 MH/s still draws 204 W over idle at 2,715 MHz, while the 5090's lock takes 100 W off at the same read rate. **The honest floor at stock per card is the base row within 2 percent; the lever stays rank 1, the operating point.** The patch that ships the auto-tune anyway (`--sm-sparse auto|off|N` and `--sm-hold` on the worker, the tuning-file keys `sm_sparse` and `sm_hold` per card, the pure pieces tested in `emu/variant-test.cpp` on the H100 pod) is on class-v6-floor-sm at 9e478daf; the design's default is off.
|
||||
|
||||
The 5090 ladder (14:10 UK; four rented 5090s on four hosts, base rows 2.08 to 2.49 microjoules at 142 MH/s by board; the ladder as a percent of each host's own base; bit-exact; placement checked by `%smid`). **At stock the 5090 holds class v3's ceiling down to 43 of 170 SMs (99.5 percent) and 28 (98.3), falls at 21 (96.6) and 16 (91); the occupancy saves 1.3 to 3.9 percent of energy per hash by host and never more; class v4's knee is 43 SMs by rate (compute-bound below it) with no saving.** Host c (idle 7 W), class v3, 32 warps per block, one block per SM:
|
||||
|
||||
| SMs | MH/s (percent of base) | W | Microjoules per hash | GDDR7 / HBM3 / SRAM (lane B's 0.14) |
|
||||
|---|---|---|---|---|
|
||||
| 170 | 142.6 (100) | 296.4 | 2.079 | 4.46x / 6.48x / 14.9x |
|
||||
| 85 | 142.4 (99.9) | 296.0 | 2.078 | |
|
||||
| 43 | 141.8 (99.4) | 291.1 | 2.053 | 4.41x / 6.40x / 14.7x |
|
||||
| 28 | 140.3 (98.3) | 288.6 | 2.058 | |
|
||||
| 21 | 137.8 (96.6) | 288.1 | 2.091 | |
|
||||
| 16 | 130.0 (91.1) | 280.4 | 2.157 | |
|
||||
| 11 | 100.9 (70.7) | 255.5 | 2.533 | 5.4x |
|
||||
|
||||
Host e (idle 12 W): base 142.0 at 352.7 W = 2.485; 43 SMs 141.2 at 337.4 = 2.389 (the 3.9 percent case); 21 SMs 137.2 at 334.9 = 2.440. Full SM count with fewer warps (host c): 8 warps per SM 142.4 at 293.8 W = 2.063; 2 warps 139.3 at 288.7; 1 warp 119.6 at 270.3 = 2.260. Matched warps (43 x 32 against 170 x 8): 2.053 against 2.063, equal: the warps in flight set the rate and nothing in the occupancy sets the watts. Class v4 at stock: host c base 142.6 at 450.0 W (the cap) = 3.157; PC 1 unlocked today: base 137.2 at 452.8 W, 43 SMs 134.5 at 470 W, 36 SMs 120.3, 28 SMs 94.4, 21 SMs 70.7, 16 SMs 54.0 (the shadow compute-bound under 43 SMs; the pass drifted 62 to 75 C). The 1,300 lock ladder on PC 1 (`run-ca4-pc1-floorsm-5090-20261008`) is the amendment if not in by 15:30; the default is 20.3b's lock points.
|
||||
|
||||
The decomposition for section 2's term (the H100 microbench, full residency at the boost clock, measured): idle 80 W; resident SMs issuing nothing 136.6 W (+57 W, the SM clock domain); the ALU probe 437.9; the dependent DRAM chase 450.3 W at 32.2 G reads a second (11.5 nJ per read whole-card); the hash 450.4 W at 32.6 G reads a second. The memory path on the GPU side is 313 of the 370 W over idle (9.6 nJ per read above the HBM's modelled 1.2). The 4090 and 5090 probes land by 15:00; PC 1's memory-clock ladder at the 1,300 lock is queued (`run-ca4-pc1-memclk-5090-20261008`). **The fraction a chip cannot strip** (the memory side's own on chip-model 5.3's rows: DRAM plus static plus a controller, about 0.40 microjoules per hash on GDDR7 at 128 reads, 0.47 on the 4090's GDDR6X at its rate, 0.23 on HBM3): the 5090 at stock 16 to 19 percent, at the 1,300 lock 25 percent, the 4090 13 percent, the H100 13 percent. Everything else is the card's silicon around the read and a chip strips it; the honest floor's real number per tier is that fraction, and the only honest-side lever on the rest is the clock (rank 1).
|
||||
|
||||
|
||||
### 10.2 The shadow's k from RTL (floor lane 2, `docs/analysis/class-v6/floor/shadow-k.md` on class-v6-floor-k, build-4 and build-3, first synthesised rows 13:3x UK; RTL and flow under `tools/chip-model/rtl`; full by 15:30 UK (the earlier 19:30 clock pulled by the coordinator at 13:30))
|
||||
|
||||
**The first synthesised k is 0.18 at the 5090's lock in the absolute convention at N3, and nothing reads inside the claimed 0.3 to 0.8 band except the unscaled ASAP7 figure at the lock (0.35). These are PER-UNIT FLOORS: no fetch, no decode, no register file beyond an 8-entry window, which is the chip rotation kills (section 1). The coordinator's order (13:5x UK): the headline k for the close is the programmable sequencer-core row (fetch, decode, a 32-register file, the per-era registers, the drawn program), asked of the lane with the M5 Max column; until it lands the default is the record's shadowed rows at k 0.5 with these unit floors beside them as the lower bound, and the GDDR7 board at 4.0x at the lock and 5.8x at stock on the floor k is the WORST CASE the served line must survive, not the reading.** Method: a minimal lane (instruction register, an 8 x 32-bit register window of flops, read muxes, the unit, one write port; no fetch or decode) in Yosys 0.68 plus OpenROAD on ASAP7, routed, SPEF, power from a random-input gate-level VCD with every pin annotated, the TC corner at 0.70 V; so every chip figure is a FLOOR and every k a floor. The node scaling is claimed from TSMC's headline per-node power reductions (N5 x0.70, N3 x0.50, N2 x0.36 of ASAP7; approximate). The GPU side is the research file's 15.1a, measured.
|
||||
|
||||
| Family (chip RTL) | pJ per op ASAP7 (synthesised) | pJ per op N3 (scaled, claimed) | 5090 pJ per op stock / lock (measured) | k absolute, N3 chip against stock / lock | k at ASAP7 unscaled against the lock |
|
||||
|---|---|---|---|---|---|
|
||||
| ARX add | 2.16 | 1.09 | 11.3 / 6.2 | 0.096 / 0.18 | 0.35 |
|
||||
| ARX xor | 2.09 | 1.05 | 11.3 / 6.2 | 0.093 / 0.17 | 0.34 |
|
||||
| ARX rotl (imm) / rotr (reg) | 2.15 / 2.13 | 1.08 / 1.07 | 11.3 / 6.2 | 0.095 / 0.17 | 0.35 |
|
||||
| ARX sub | 2.14 | 1.08 | 11.3 / 6.2 | 0.095 / 0.17 | 0.35 |
|
||||
| or (lossy; values saturate, activity falls) | 1.45 | 0.73 | 11.3 / 6.2 | 0.065 / 0.12 | 0.23 |
|
||||
| ARX random mix | 2.24 | 1.13 | 11.3 / 6.2 | 0.10 / 0.18 | 0.36 |
|
||||
| mul (32 x 32 low) | 1.35 | 0.68 | 13.9 / 8.3 | 0.049 / 0.082 | 0.16 |
|
||||
| mulhi (high word, the same multiplier) | 1.35 | 0.68 | 39.6 / 21.0 | 0.017 / 0.032 | 0.064 |
|
||||
| mad (3 reads, mul plus add) | 3.29 | 1.66 | 13.9 / 8.3 | 0.12 / 0.20 | 0.40 |
|
||||
| index fold (mul by M, rotl R, masks) | 2.37 | 1.19 | 13.9 / 8.3 | 0.086 / 0.14 | 0.29 |
|
||||
| prmt (byte permute) | 1.27 | 0.64 | 22.3 / 11.5 | 0.029 / 0.056 | 0.11 |
|
||||
| lop3 (8-bit LUT) | 1.42 | 0.72 | 24.1 / 13.0 | 0.030 / 0.055 | 0.11 |
|
||||
|
||||
The ranking by k, highest first (hardest for a chip; absolute, N3 against the lock): mad 0.20, the ARX families 0.17 to 0.18, the fold 0.14, or 0.12, mul 0.08, prmt and lop3 0.06, mulhi 0.03. mulhi is the worst lever on the card by 6x: the 5090 pays 21 pJ for a high word that costs the chip the same 0.68 pJ as a low word, which is the measured reason the op-mix band keeps mulhi capped at its base (section 2) and the reason the research file's 15.1b hold on a re-weight stands from the chip side as well. The class v4 mix costs the chip about 1.0 to 1.2 pJ per op at N3 (2.0 to 2.4 at ASAP7), the shuffle row pending.
|
||||
|
||||
What it does to the GDDR7 stored-dataset board (E_mem 0.466 microjoules, modelled) with the class v4 shadow (102,100 ops per hash): absolute, the chip's shadow is 102,100 x about 1.1 pJ = 0.11 microjoules at N3, E_chip about 0.58, so the edge reads **4.0x at the lock (the card 2.33) and 5.8x at stock (3.36)**, against the served 2.1x at k = 1 and 3.4x at k 0.33. In the record's convention it is k 0.18 at the lock, 2.33 / (0.466 + 0.18 x 0.652) = 4.0x, the same number there, and at stock k 0.10, 3.36 / (0.466 + 0.10 x 1.10) = 5.8x. **The shadow buys the card about 0.5x to 1x of edge out of the 5.2x and 3.6x zero-shadow figures, not the 1.5x the served k = 1 row implies.** This is the row the served line must survive (section 9): the served "2.1x" is the k = 1 reading; the unit floor says a core's units are five to six times cheaper than that at N3, and the sequencer core that a rotating family forces (fetch, decode, register file, the drawn program's control) sits between the two, where the lane's next row puts it; until then the default reading is k 0.5 (the DRAM chip 2.9x at the knee in the record's convention) with 4.0x as the floor-k worst case. Pending from the lane by 15:30 UK (later rows as amendments): the 32-lane shuffle butterfly and the general crossbar (in place now), the 8 KB scratch as a flop array, the int8 8x8x8 tile, the mix optimiser over the layer-1 band, and the re-fold of lane 3's SRAM rows at W = 4 and 8 in both conventions.
|
||||
|
||||
### 10.3 The SRAM die and the dataset floor (floor lane 3, `docs/analysis/class-v6/floor/sram-and-floor.md` on class-v6-floor-sram at 708d01b4, first reading 12:0x UTC; full by 15:30 UK (the earlier 19:30 clock pulled by the coordinator at 13:30); everything chip-side modelled on lane B's wire figure, 1.3 pJ per bit plus 0.1 nJ per macro access and 0.05 nJ of controller; the GPU side measured)
|
||||
|
||||
**The read width is the only wire lever on the SRAM die, and the floor is a ticket, not a joule.** Lane B's 1.0 nJ was the 64-byte row; the die reads what the hash asks for, and the wire scales with the bits moved while the macro access does not:
|
||||
|
||||
**The convention rule for every shadowed row (the coordinator's order, 13:1x UK): the headline is the ABSOLUTE convention, pJ per op on each side at the operating point (the die's core costs what it costs; the GPU's op at its own clock, 11.3 pJ stock and 6.2 at the 1,300 lock on the 5090); the record's convention (k against the GPU's op cost at the same operating point, so the die's core gets cheaper when the card locks) is carried beside it, marked, and it flatters the die at the lock by 1.6x (6.1x against 3.8x at k 0.5; 3.3x against 2.0x at k 1).** The shadowed columns in the table below are the lane's first reading at the card's stock point, where the two conventions coincide. The lock rows, both conventions, absolute first (floor lane 3's 2.2a at d550dadd, 13:09 UK; the card at the lock 1.67 plus 0.65 microjoules measured, the die's E_hash0 from its 2.2; the absolute die pays k x 1.10 microjoules whatever the card does, the record's k x 0.65):
|
||||
|
||||
| W words | Absolute, k 0.5 / 1 (the headline) | The record's convention, k 0.5 / 1 (marked: flatters the die by 1.6x) | Label |
|
||||
|---|---|---|---|
|
||||
| 1 | 4.0x / 2.0x | 6.4x / 3.4x | modelled chip, measured card |
|
||||
| 4 (genesis) | 3.8x / 2.0x | 6.1x / 3.3x | modelled chip, measured card |
|
||||
| 8 | 3.7x / 2.0x | 5.8x / 3.2x | modelled chip, measured card |
|
||||
| 16 | 3.4x / 1.9x | 5.2x / 3.0x | modelled chip, measured card |
|
||||
|
||||
The k lane's RTL rows (floor lane 2, in absolute) replace the k axis of this table when they land; until then the honest shadowed reading of the SRAM die against a 5090 at its knee is 2.0x at k 1 and 3.7x to 4.0x at k 0.5, whatever the read width.
|
||||
|
||||
| W words | Bits per read | Chip E_read | GH/s per die (300 W) | Zero shadow against the 5090 | With the class v4 shadow, k 0.5 / 1 (stock point; the record's convention, to be re-folded by the k lane in absolute) | At the card's whole shadow (F 2.0), k 0.5 / 1 (same convention note) | The honest card's cost | Label |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 1 (4 bytes, today) | 80 | 0.25 nJ | 8.3 | **66x** | 5.7x / 3.0x | 4.1x / 2.1x | 0 | modelled chip; measured card |
|
||||
| 4 (16 bytes) | 176 | 0.38 | 5.6 | 44x | 5.6x / 2.9x | 4.0x / 2.1x | the 5090 +2.7 percent, the 9070 XT -1.4, the M5 Max within 1 (measured 5 October) | measured card |
|
||||
| 8 (32 bytes, the GDDR7 sector the 5090 fetches anyway) | 304 | 0.55 | 3.9 | 31x | 5.4x / 2.9x | 4.0x / 2.0x | free by the measured rows (13:06 UK: the 5090's ceiling about 18 G sectors a second, w16 moved 573 GB/s of sectors at 139.8 MH/s and w64 589 at 71.9; an aligned 32-byte read is one sector per load as w16; AMD moves its 64 B line either way; the M5 Max 1.03 at both w16 and w64); one PC 1 row to confirm, and the crate's width set is {1, 4, 16} words, so a pack at 8 needs a generator and emitter line first | measured card (inferred at 8) |
|
||||
| 16 (64 bytes, lane B's record) | 560 | 0.88 | 2.4 | 19x (the record's 17x) | 5.0x / 2.7x | 3.8x / 2.0x | the 5090 -47 percent: dead | measured card |
|
||||
|
||||
Meaning: at the hash's own width the die is 3.5x stronger than the record said at zero shadow; with the shadow on, every row sits at 5.0x to 5.7x (k 0.5) and 2.7x to 3.0x (k 1): **the shadow is the whole hold, the memory moves it 0.2x to 0.7x.** The fold is dst-keyed (`verify::fold_words`), so a wide read cannot be pre-folded; dependent chains per step leave the ratio unchanged (per-read on both sides); banking plus a sequencer cannot localise (uniform on a window of at least 256 MiB, the 16 sites alternating; moving the lane costs about 290 bits, which caps the wire lever at about W = 8); the per-site window shrink buys the die and the DRAM chip nothing; a hop at the honest widths is 0.04 to 0.15 nJ, so a ten-die store still reads 25x to 58x at zero shadow. This moves layer 1's width row: **pin W = 4 (16 bytes) at genesis (measured free on all three vendors; the die from 66x to 44x), W = 8 (31x; 5.4x at k 0.5) once the generator and emitter carry it and one PC 1 row confirms, never 16.**
|
||||
|
||||
The floor as a ticket: SRAM is flat at about USD 250 per GiB to 2031 (density +6 to 11 percent per node against dearer wafers; claimed, approximate), so USD 5,000 of silicon per store is 20 GiB at N2 and about 21 GiB in 2031, which retires every card under 32 GB and every Mac under 64 GB; the constraint and "fewest cards" cannot both hold. The per-MH/s does not rise with the floor (every die powered: USD 0.25 to 0.5 per MH/s at any size); the floor raises the minimum ticket only. The honest card's side of the floor is measured (the hash lane's kit b, section 3.3): the 5090 at its knee pays 4 / 8 / 10 percent more energy per hash at 2 / 4 / 8 GiB, so every chip row's edge against a card at its knee rises by 4 to 11 percent across the schedule while the die's joules do not move; the schedule's sentence is USD 1,000 of chip ticket per step for 1 to 5 percent of the tuned 5090's energy and about a quarter of today's cards by count. The k convention matters at the lock: the record's (k against the GPU's op cost at the same operating point) reads the SRAM die at 6.1x (k 0.5) and 3.3x (k 1); the absolute convention (the die's core costs what it costs, k against the stock 11.3 pJ per op) reads 3.8x and 2.0x; the record's flatters the die by 1.6x at the lock, so the served line in section 9 and the close carry the absolute rows as the headline and the record's beside them marked.
|
||||
|
||||
| Floor | Reticles 2026 / 2031 | USD of silicon | Tiers out (worst / best working set) | Label |
|
||||
|---|---|---|---|---|
|
||||
| 5.5 GiB (the v6 epoch) | 3 / 3 | 1,500 | the 8 GB tier in the worst case only / none | modelled |
|
||||
| 8.5 GiB (year 2) | 5 / 4 | 2,500 | 8 GB, 12 GB cache-resident, Apple 16 GB / 8 GB, Apple 16 (the 12 GB tier holds at 73 to 75 percent) | modelled |
|
||||
| 11.5 GiB (year 4) | 6 / 5 | 3,000 | 8, 12, 16 GB cache-resident, Apple 16 / 8, 12, Apple 16 (the 16 GB tier holds at 73 to 75 percent) | modelled |
|
||||
| 16 GiB | 8 / 7 | 4,000 | adds 16 GB, 24 GB cache-resident, Apple 32 | modelled |
|
||||
| 20 GiB (USD 5,000) | 10 / 9 | 5,000 | everything under 32 GB; Macs under 64 GB | modelled |
|
||||
|
||||
The lane's recommended schedule: 5.5 / 8.5 / 11.5 GiB (each just under a tier's room and just over a reticle multiple; 8.5 forces a fifth die where 8.0 does not, at no tier cost with the cache freed; the non-power-of-two floors need the multiply-shift mapping of 1.13.3 option (a)); the Apple rate cost on top, -21 to -25 percent (measured to 8 GiB, section 3.3); the state sizes assumed 92 M / 143 M / 193 M records, the 16 GiB step by state above 268 M.
|
||||
|
||||
The capex wall on the spec's emission constant (3,168,808,781 base units per DAA second, 10^8 per IGN, 80 percent to miners: 0.77 / 0.80 / 0.40 / 0.40 / 0.20 / 0.20 B IGN to miners in years 1 to 6; the mission lane's E2 4.18 B is not this constant's figure and is carried beside it); a project starting at launch, shipping at 24 months, mining years 3 to 6, a 10 percent discount; rotation costs it nothing (firmware); the floor's fleet term drops out (under two dies for the whole network at every price):
|
||||
|
||||
| Project USD M | Share of the hash | p* at an edge of 3x / 5x / 13x (USD per IGN) | Launch-year miner revenue at p* | Label |
|
||||
|---|---|---|---|---|
|
||||
| 100 | 0.30 | 0.59 / 0.49 / 0.43 | USD 330 to 450 M a year (0.9 to 1.2 M a day) | modelled |
|
||||
| 100 | 1.00 (takes the chain) | 0.18 / 0.15 / 0.13 | 98 to 136 M a year | modelled |
|
||||
| 250 | 0.30 | 1.47 / 1.23 / 1.06 | 0.8 to 1.1 B a year | modelled |
|
||||
| 500 | 0.30 | 2.94 / 2.45 / 2.12 | 1.6 to 2.3 B a year | modelled |
|
||||
|
||||
Below about USD 100 M a year of miner revenue (IGN 0.13) no rational SRAM project starts at any edge; above about USD 2.3 B a year (IGN 2.9) every one does; at IGN 0.01 / 0.10 / 1.00 (assumed) a USD 150 M project at 5x and 30 percent reads NPV -148 / -130 / +54 M. **The edge moves p* 1.4x across 3x to 13x; the project cost moves it 5x: the hash does not set the wall, the project cost and the detector do.** Killed with the number: per-era re-fill (0.96 J per era), per-block re-fill (0.96 W on the die against 1.3 to 3.2 percent of GPU rate), straddling atoms (+0.02 nJ on the die, +19 percent of items on the verifier at the gate), a second hot table (the die's own kind of memory, k 0.1 to 0.3). Kept: the per-lane scratch pins the lane (a dependency of the width lever); 3D-stacked SRAM would remove the floor's last effect on joules. The lane's line for the served sentence: about 4x at k 0.5 and 2x at k 1 against a 5090 paying its whole latency shadow, 20x to 60x without it, with the price threshold beside it (about USD 0.4 per IGN at a third of the network, 0.13 for a maker who takes the chain). Owed: the W = 8 rows, the Apple curve past 8 GiB.
|
||||
|
||||
The lane's close rows (class-v6-floor-sram 7bc9de4b, 13:18 UK), three of them.
|
||||
|
||||
(1) The capex wall re-folded on floor lane 5's corrected project floor (the cheapest DRAM-board chip project USD 20 to 75 M, not the record's 5 M; the same model: ships at 24 months, mines years 3 to 6, a 10 percent discount; an 18-month ship lowers p* by 15 percent):
|
||||
|
||||
| Project USD M | Share | p* at 5x / 3x (USD per IGN) | Launch-year miner revenue | Per day | Cap at the end of year 2 | Label |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 20 | takes the chain | 0.029 / 0.035 | USD 23 / 27 M a year | 62 / 74 K | 0.06 / 0.07 B | modelled |
|
||||
| 20 | 0.30 | 0.098 / 0.118 | 75 / 91 M | 207 / 248 K | 0.19 / 0.23 B | modelled |
|
||||
| 75 | takes the chain | 0.110 / 0.132 | 85 / 102 M | 233 / 279 K | 0.22 / 0.26 B | modelled |
|
||||
| 75 | 0.30 | 0.368 / 0.441 | 283 / 340 M | 776 / 931 K | 0.72 / 0.86 B | modelled |
|
||||
|
||||
The two numbers for the close: no rational chip project of any kind below about USD 23 M a year of miner revenue (IGN 0.03, USD 62 K a day, a cap of about USD 60 M); every DRAM-board project at a third of the network above about USD 340 M a year (IGN 0.44, USD 0.93 M a day, a cap of about USD 0.9 B), where the SRAM project also starts (USD 330 M at 13x, or a whole-chain maker). The correction moves the lower number 4x (from the 100 M of the SRAM-only reading) and the upper not at all.
|
||||
|
||||
(2) W = 16 at each chip's own cost (GDDR7 +33 percent on the memory term, HBM3 +24, the die at 560 bits, 0.88 nJ), zero shadow, two card readings (the 5090 passes within 5 percent at its best lane count, or the measured w64 row stands: 71.9 MH/s, 1.89x the energy if the watts hold):
|
||||
|
||||
| Chip | W = 1 | W = 16 if the 5090 passes | At the measured w64 row | Class v4 shadow (the synthesised core) | The full shadow | Label |
|
||||
|---|---|---|---|---|---|---|
|
||||
| GDDR7 board | 5.1x | 4.4x | 8.3x | 5.1x | 4.7x | modelled chip, measured card |
|
||||
| HBM3 one stack | 7.5x | 6.7x | 12.7x | 7.2x | 5.9x | modelled chip, measured card |
|
||||
| SRAM die | 66x | 19x | 36x | 14x | 8.8x | modelled chip, measured card |
|
||||
|
||||
W = 16 is the one width that moves the die more than 2x at zero shadow and it costs the DRAM chips 11 to 15 percent; worth it only if the PC 1 row passes, since at the measured w64 row every chip's edge nearly doubles. If it passes, W = 16 replaces W = 8 as the lane's width recommendation (and the design's `never` is lifted by that one measurement, section 10.5).
|
||||
|
||||
(3) The shadowed SRAM rows on the k lane's synthesised core (0.11 microjoules of shadow at N3, absolute k about 0.1, the shuffle open; the lane's 2.2b): under class v4 21x at W = 4 and 18x at W = 8 at stock, 14x and 12x at the knee; at the honest cards' whole latency shadow 10x and 9.7x at stock, 6.5x and 6.1x at the knee; an N2 core about 1.2x more. On the synthesised core the shadow is not the whole hold (the record's claimed band read 2x to 6x, marked beside): the memory is a third to a half of the die's energy, the width is worth 1.3x with the shadow on, and the lane's line for the served sentence is 6x to 10x at the full shadow (2x to 4x on the claimed band, marked), never under 2x. These are the per-unit-floor figures of 10.2; the k lane's sequencer-core row re-folds them.
|
||||
|
||||
|
||||
### 10.4 The honest denominator per tier (floor lane 4, `docs/analysis/class-v6/floor/denominator.md` on class-v6-floor-denominator, first table 13:4x UK; the rented sweep by 15:00, the final table by 15:15; the Ember tier table `app/igneum-app/tiers/class-v5-tiers.json`, 30 card classes, 5 measured, test 10 of 10 on build-3)
|
||||
|
||||
**The honest NVIDIA floor is the 16 GB Blackwell card at its knee, not the 5090.** Class v5 is class v4's shape plus the state leaves (0.0 percent of rate, +2.0 percent of watts at the 5090's knee, measured, the v5lock job). The chip columns are the chip's class v5 energy at its shadow priced in absolute picojoules per forced op (3.2 pJ, the record's k 0.5 at the 5090's knee; 6.4 pJ, k 1): GDDR7 0.79 / 1.11, HBM3 one stack 0.65 / 0.97, N2 SRAM 0.46 / 0.79 microjoules per hash (the SRAM column at lane B's 64-byte row; at the genesis width, 10.3, the die's memory term is 0.051 and the column reads higher).
|
||||
|
||||
| Tier | Card, the point | Class v5 microjoules per hash | Label | GDDR7 k 0.5 / k 1 | HBM3 | SRAM |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 32 GB | 5090, 1,300 MHz lock (Balanced) | 2.37 (v4 2.32 measured at 134.76 MH/s, 312.5 W) | measured | 3.0x / 2.1x | 3.6x / 2.4x | 5.0x / 3.0x |
|
||||
| 32 GB | 5090, 1,200 MHz (Efficiency, rate -2.2 percent) | 2.33 | measured | 3.0x / 2.1x | 3.6x / 2.4x | 5.0x / 3.0x |
|
||||
| 16 GB | 5080, 1,100 MHz lock | 2.10 (v4 2.06 measured: 71.20 MH/s at 146.6 W) | measured | 2.7x / 1.9x | 3.3x / 2.2x | 4.5x / 2.7x |
|
||||
| 16 GB | 5070 Ti at its knee | 1.70 (stock v4 2.84 measured; the Blackwell shape) | modelled | 2.2x / 1.5x | 2.6x / 1.8x | 3.7x / 2.2x |
|
||||
| 12 GB | 5070 at its knee | 1.75 (stock v4 2.99 measured) | modelled | 2.2x / 1.6x | 2.7x / 1.8x | 3.8x / 2.2x |
|
||||
| 12 GB | 4070 at its tune (1,860 MHz, 50 percent cap) | 3.58 (v4 3.51 measured: 31.08 at 109.0 W) | measured | 4.5x / 3.2x | 5.6x / 3.7x | 7.7x / 4.5x |
|
||||
| 24 GB | 4090 at an Ada knee | 3.64 (stock v4 5.0 measured by lane 1; the 4070's shape) | modelled, stock measured | 4.6x / 3.3x | 5.6x / 3.8x | 7.8x / 4.6x |
|
||||
| 24 GB | 3090 at the Ampere cap | 6.4 (the cap is the only lever, 10 to 15 percent) | modelled | 8.1x / 5.7x | 9.9x / 6.6x | 13.8x / 8.1x |
|
||||
| 16 GB AMD | 9070 XT, ADLX -500 MHz / -30 percent | 8.1 (v4 7.9 measured: 18.9 MH/s at 149.3 W) | measured | 10.3x / 7.3x | 12.6x / 8.4x | 17.5x / 10.3x |
|
||||
| DC | H100 at a lock (an owned host) | 2.0 (stock v4 2.77 measured) | modelled | 2.5x / 1.8x | 3.1x / 2.1x | 4.3x / 2.5x |
|
||||
| Apple | M5 Max, no lever (the GPU and DRAM meter) | 1.43 (v4 1.40 measured) | measured | 1.8x / 1.3x | 2.2x / 1.5x | 3.1x / 1.8x |
|
||||
| Apple LPDDR6, five years out | a Max-class SoC on JESD209-6 | 1.18 at the meter (0.27 DRAM, 0.35 GPU, 0.56 shadow) | modelled | 1.5x / 1.06x | 1.8x / 1.2x | 2.5x / 1.5x |
|
||||
|
||||
What it means: against the 16 GB Blackwell card at its knee (2.1 measured on the 5080; 1.7 to 2.1 modelled on the 5070 Ti and 5070) the GDDR7 chip is under 2x at k 1 and the SRAM die 2.7x; against the M5 Max 1.3x and 1.8x; against the LPDDR6 Apple part five years out the GDDR7 chip is at parity at k 1 and the SRAM die 1.5x. The only software lever is the operating point (the lock on Blackwell and Ada, the cap on Ampere, the ADLX offsets on AMD, nothing on Apple or Intel); the occupancy is worth 1 to 2 percent (10.1), the memory clock untouched, the block shape and the cache hint closed. The lane's per-tier scoring rule: size the shadow by the Apple tier's 5 percent point as the 2.0 rule already does and keep N at 100,000 (sizing to the Apple ceiling of 130,000 costs every honest miner 8 to 15 percent of electricity for about 0.15x of edge: the 5090's GDDR7 edge at k 1 goes from 2.13x to 1.95x); do not score the acceptance floor per tier (one network-wide number). The re-measure rule after a class flip: a stored set is stale under a new class, the stored point is the provisional start of the re-measure, max is stock and never stale; the measured v4 to v5 flip moved the 5090's knee by nothing. Owed and labelled: every Ada and Ampere knee is modelled because no rented host allows -lgc (14 pods today, every one refused); the rented stock rows are measured; the sweep adds class v5 watts measured on 16 card classes and a memory-clock try on each.
|
||||
|
||||
### 10.5 Invention beyond the four layers (floor lane 5, `docs/analysis/class-v6/floor/invention.md` on class-v6-floor-invention, first KEEP/KILL table 13:4x UK; full by 15:30 UK (the earlier 19:30 clock pulled by the coordinator at 13:30))
|
||||
|
||||
**One lever found, conditional on one measurement: the second DRAM sector.** The identity's only `k = 1` work is the memory's own, and the record forced one 32-byte sector per read while the dataset item is 64 bytes. Reading the whole item (W = 16 words; the read-width branch's class w64, fingerprint 836e56e7d496e980, the verifier 0.630 against 0.604 ms) forces a second sector in the same open row: on the chip model's own inputs (909 pJ activate plus 1,150 pJ of movement per sector) the GDDR7 chip's read goes from 2.0 to 3.2 nJ and E_mem from 0.466 to 0.62 microjoules (+33 percent at an unchanged 166 MH/s), one HBM3 stack 0.321 to 0.40 (+24 percent), and on floor lane 3's wire figure the N2 SRAM die 0.036 to 0.126 microjoules (66x to 19x). So the band {1, 4} words is inside one sector and "the chip pays nothing" is right to 32 bytes and wrong at 64; this is the measured reason W = 8 is free for the chip too (section 10.3) and W = 16 is the only width that costs it. The card is the whole question: the 5090 ran w64 at 71.9 MH/s (-47 percent; the exported kernel at full occupancy, four uint4 loads per item, no request hint) while its own 64-byte probe reached 15.7 G reads a second at its best lane count (-13 percent); 589 GB/s is a third of the stream, so the bind is the request path, not the pins. The 9070 XT (-3 percent) and the M5 Max (0) are free at 64 bytes (measured).
|
||||
|
||||
| Candidate | Verdict | Number (5090 knee, GDDR7 chip) | Label |
|
||||
|---|---|---|---|
|
||||
| The second sector, W = 16 words (w64) | KEEP rank 1, conditional | outcome A (the exported form, -47 percent of rate): 5.3x, dead; B (the probe's -13 percent): 3.4x; C (a one-request form within 5 percent of the 4-byte rate): 3.0x to 3.2x at zero shadow, 2.7x at k 0.5, 2.0x at k 1; the chip +33 percent whatever the card does; the M5 Max and the 9070 XT pay nothing | card measured at two occupancies (unlocked, no watts); chip modelled |
|
||||
| The job that decides it | one PC 1 run, under an hour; asked of the hash lane at 13:5x UK, after kit d | the w64 pack plus the w4 control, the race's occupancy variants, a variant with `ld.global.L2::64B` on the first load (a variantSource anchor rewrite), both clocks, nvidia-smi at 1 Hz; the pass line 95 percent of the 4-byte rate, the kill line under it | owed; default if not run by 15:30 UK: outcome A stands, W = 16 stays `never` |
|
||||
| Tensor tiles | KILL as content; the k band corrected | merchant silicon at a nominal 0.30 to 0.56 pJ per INT8 MAC (MTIA v2 0.51, AI 100 Ultra 0.34, B200 0.44, MI355X 0.56, M4 ANE 0.30 measured; the H800 loop 0.34 measured); against the class's tile (1.5 pJ at the knee) k 0.2 to 0.4, against the dense tile (0.83) 0.4 to 0.7: never above the ALU band; "3x to 30x" needs an INT4 layer at 0.46 V (the VSQ chip reads 0.052 at its nominal 0.67 V); Apple -35 percent at 1,024 tiles kills it | claimed, measured |
|
||||
| The RT core | KILL | traversal implementation-defined (Vulkan, DXR, the NVIDIA forum 14 Oct 2024, Blender's Cycles); the BVH opaque; dedicated units 4 to 33 nJ per ray against 290 to 750 measured board-level on an RTX 2080: k 0.01 to 0.2 | claimed, measured |
|
||||
| Channels per dollar, controller plus PHY | KILL as a lever; KEEP as a model correction (rank 2) | no 28 nm GDDR7 PHY exists (Cadence N3 2024, Innosilicon FinFET; the oldest GDDR6 PHY 12 nm): the record's USD 5 M project and 17 M cap row is unbuildable; the floor is 12 nm GDDR6 at USD 20 to 30 M (cap 70 to 100 M) or N5 GDDR7 at USD 50 to 75 M (cap 170 to 250 M); SK hynix ISSCC 2024 PAM3 I/O 1.10 TX plus 0.76 RX pJ per bit measured, E_mem moves under 0.06 | claimed, modelled |
|
||||
| Proof of latency | KILL | the chain checks values, never time: a 1 s block against a 0.4 microsecond round trip (2.5 M per block); the farm's node is on its LAN; a sequential prefix favours the chip about 4x; no sub-RTT PoW in the literature | measured latency, modelled |
|
||||
| A drawn address map | KILL | firmware, a few hundred gates; no prefetch on a dependent chain; 0 to the chip, 0.8 to 3.2 percent of spread to the cards; literature null | measured spread |
|
||||
| The literature since 2023 | nothing outside the identity | new rows: iPollo V2H 0.14 J per MH, about 13x a 5090 on Ethash (vendor; the precedent's top, not 6.8x); Jasminer X16-Q 5.7x; Pinecone R1X unverified; Qubic pivoted to Doge in April 2026; no random-read joule measured on any current DRAM | claimed |
|
||||
| Own: video decode, row straddle, scratch in DRAM, independent chains per hash | KILL | a decoder-bound card and a ms verifier; 3.1x at -39 percent of rate (dominated); 465 KB sits in L2; the 5090's read rate plateaus over lanes (expected 0 to 5 percent) | measured, modelled |
|
||||
| Own: W = 32 (two items) | hold for v7 behind rank 1 | the chip bandwidth-bound at 110 MH/s and 1.02 microjoules; 2.4x only if the card held its rate, which at 128 B the pins forbid (-20 percent at best) | modelled |
|
||||
|
||||
Nothing reads k above 1 on a measured GPU figure; the second sector reads k about 0.75 to 0.8 on GDDR7 (1.15 nJ of DRAM movement at k = 1 plus the card's fabric share), the highest on the table. Section 10's default (no new lever) is right unless the w64 job passes.
|
||||
|
||||
## 8. Unverified and owed
|
||||
|
||||
- Lane D's family harness (the family gate's measured coverage: the 10,000-era random stratum and the three corner strata, the lossy cap, width 4, shape 64) lands in `family-gate.md` by 17:00 UK and is layer 4's coverage table; this document's layer 4 cites it where it is named and does not restate it.
|
||||
|
||||
- The 5070 Ti tier: no card owned; the hash lane's rented row or the 5070's row scaled (approximate).
|
||||
- The x16 mixer: the verifier and build rows, or the estimate.
|
||||
- The op-mix band: the two re-weighted packs, or the microbench arithmetic.
|
||||
- Every chip figure is the model's; no chip has been measured.
|
||||
40
docs/plans/release-wallet-0.1.5.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Igneum Wallet 0.1.5: the 0.3.14 node and the thirteen-field consensus object bundled; prepared to the gate, 6 October 2026
|
||||
|
||||
Release engineer, from 15:55 UTC, on the coordinator's order (after 0.3.13's merge: "Igneum Wallet 0.1.5 (Mac and Windows), carrying the
|
||||
0.3.13 node and the thirteen-field consensus object as its bundled override, so the wallet's own node peers again and its balance view
|
||||
follows the restarted state; its OTA path the same as the miner's; its node's RPC bound to localhost; the fresh-joiner behaviour verified
|
||||
on a wallet started from scratch on a clean data dir"). The node it bundles is the 0.3.14 one once that exists (the 0.3.13 node's exec
|
||||
layer cannot recover from the 15:44Z reorg, `release-0.3.13.md` section 4a), so this gate follows 0.3.14's. Worktree
|
||||
`/Users/joshm/Projects/igneum-wt-wallet015`, branch `wallet-0.1.5` = `wallet-v1` a238781 (the 0.1.4 shipping branch) with release-0.3.13
|
||||
merged in (65814ad; the five tooling files taken from the 0.3.13 side, the wallet's `write_wallet_config` block re-added over
|
||||
`packaged-config.sh`, 338063f); the org login's commits; times UTC.
|
||||
|
||||
## 1. What it carries
|
||||
|
||||
| Change | Where | State |
|
||||
|---|---|---|
|
||||
| The bundled node: 0.3.14's igneumd (the exec layer that survives a deep reorg and a moved pruning point; the finality route fix; protocol 16) | `vendor/igneum-node-0314` (the 0.3.14 fork), `NODE=` to `build-wallet-dmg.sh` | (pending the 0.3.14 node) |
|
||||
| The bundled consensus object: the thirteen-field one (`proving_v1_fresh_rule_daa` 198000, `exec_restart_number` 27276, `exec_restart_hash` bb45cf0d..., `exec_restart_trust_daa` 200000) through `packaged-config.sh`'s `NODE_OVERRIDE_PARAMS` into `igneum-wallet.json` `node_override_params`, which the wallet writes to `override-params.json` and passes as `--override-params-file` (`node.rs` `plan_own_node`) | the 0.3.13 tree's packaged line (7dd3ff7 lineage) | in |
|
||||
| The three version files | 6b0cff0 (`Cargo.toml` 0.1.5, `wallet-version.h` 0.1.5 / 0,1,5,0, `Igneum-Wallet.iss` 0.1.5) | in |
|
||||
| The node's RPC bound to localhost: gRPC `127.0.0.1:26620`, Ethereum `127.0.0.1:26800`; only p2p on `0.0.0.0:26621` (`node.rs` 174 to 176; decision 9 of the ledger) | already so in 0.1.4 | verified in the DMG's `igneum-wallet.json` and the node's command line at the clean-data-dir start |
|
||||
| The rule row: the wallet's node start never touches another igneumd (`node.rs` kills only `self.child`; no pkill, no port takeover: it starts its own node only when the miner's is not on this Mac) | already so in code; written here as the rule after the 14:56:28Z SIGTERM question (`release-0.3.13.md` 4a) | rule |
|
||||
| The OTA path: `igneum-wallet-latest.json`, signed with the miner's key (`publish-manifest.sh --product wallet --override '<thirteen>' --mac <dmg> [--windows <exe>] --notes "..." --public --deploy`), the installed 0.1.4 swaps itself in at a safe moment | the 0.1.1 updater | the same as the miner's |
|
||||
|
||||
## 2. The gate and the checks
|
||||
|
||||
| Check | How | Result |
|
||||
|---|---|---|
|
||||
| The engine builds against the 0.3.13 tree | `cargo build --release` in `app/igneum-wallet` under the lock (the vendor links first: the wallet links `vendor/igneum-node/rpc/{grpc/client,core}`) | 16:21Z: `igneum-wallet 0.1.5` (7,789,200 before the bump, rebuilt in the DMG) |
|
||||
| The DMG (the recipe check, on the 0.3.13 node) | `NODE=<the 0.3.13 igneumd bb43e9a8> tools/lock/with-lock.sh build packaging/mac/build-wallet-dmg.sh` | 16:21:41Z: `Igneum-Wallet-0.1.5.dmg` 105cbb06ef7eb2618077871fc3b5776a1270c2170929bb29fa92ea713cc4a7e3 (20,080,717), engine 0.1.5, the node cdbed318... inside, hdiutil checksum valid; two fixes on the way: `write_wallet_config` read `TOKEN_FILE` and `LOG_KEY`, which the 0.3.13 tree no longer defines (the build died on `set -u`), now through the tree's `igneum_secret_file` and `igneum_read_trimmed` helpers (352ec6f, 5c78987). The FINAL DMG is rebuilt on the 0.3.14 node before the gate |
|
||||
| The DMG (final, on the 0.3.14 node) | the same with `NODE=<the 0.3.14 igneumd>` | (pending 0.3.14's node) |
|
||||
| The bundled object and the RPC binding | `igneum-wallet.json` inside the DMG carries the thirteen-field object; the node's args at start carry `--rpclisten=127.0.0.1:26620 --evm-rpclisten=127.0.0.1:26800 --override-params-file=...` | the recipe-check DMG's `igneum-wallet.json`: `node_override_params` 13 fields (`exec_restart_number` 27276, `exec_restart_trust_daa` 200000, `proving_v1_fresh_rule_daa` 198000), `update_manifest` set; the ports are constants in `node.rs` (`OWN_GRPC` 26620, `OWN_EVM` 26800 on 127.0.0.1; `OWN_P2P` 26621 on 0.0.0.0); the start-line check at the fresh-joiner run |
|
||||
| The fresh joiner | the wallet engine started from the DMG's bundle on a CLEAN data dir (`IGNEUM_WALLET_NODE_DIR` under the scratchpad) against the live devnet, under the run lock, with the miner app's node 1 running (so the wallet starts its OWN node on 26620/26621/26800 only when node 1 is absent: the check runs with node 1 stopped for its 10 minutes, announced): its node must reach the tip, peer (b18ed271), and EXECUTE (`eth_blockNumber` on 127.0.0.1:26800 climbing, `igneum_getExecStatus` not blocked), not sit at 0x0 | (pending the 0.3.14 node) |
|
||||
| Windows | `Igneum-Wallet.iss` exists (written untested on a PC, 0.1.2); no CI path builds the wallet host or installer; a PC build job for `igneum-wallet.exe` + the host + the installer is the way, or the Windows platform is owed | (the coordinator's word: Mac at the gate, Windows owed unless a PC job is granted) |
|
||||
|
||||
## 3. The one line for a wallet user
|
||||
|
||||
When it ships: the wallet updates itself at a safe moment; its own node (when the miner's is not on the machine) peers again and follows
|
||||
the restarted state, so the balance view shows every IGN earned since 6 October 11:40Z (chain block 27,276) and nothing before it (the
|
||||
devnet's one-time state reset); the wallet's node listens for RPC on this machine only; nothing else on the machine is touched.
|
||||
|
||||
## 4. Owed
|
||||
209
docs/plans/wallet-ui-3-audit.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Igneum Wallet UI 3: the audit, 6 October 2026
|
||||
|
||||
The founder, 18:5x UK: "rebuild the wallet to the same standard" as the redesigned miner (miner-ui-3, shipped in Igneum Miner
|
||||
0.3.14), "and update the site on that too". Branch `wallet-ui-3`, worktree `../igneum-wt-wallet-ui`, from `wallet-0.1.5`
|
||||
1fa6fa7 (the shipper's 0.1.5 skeleton: wallet-v1 and release-0.3.13 merged, the version files at 0.1.5). The design is
|
||||
`docs/plans/wallet-ui-3.md`; this file is what was found. The standard is `docs/plans/miner-ui-3.md` and its audit.
|
||||
|
||||
## 1. How it was looked at
|
||||
|
||||
| What | How |
|
||||
|---|---|
|
||||
| Every wallet screen | `tools/ui-mock/wallet.mjs` on port 4320 (new in this branch): a stand-in engine that serves `app/igneum-wallet/ui` and answers `/api/state` from a scenario. No vault, no key and no node were read; every address is one of the well-known test keys (`0x7E5F…5Bdf`, `0x2B5A…D6cF`). the founder's live wallet data were not touched. |
|
||||
| The captures | `tools/ui-mock/shoot-wallet.mjs`: Playwright's own chromium (the cached Chrome for Testing build, headless, never Chrome.app) at 1280 x 820 (the shipped window is 1120 x 780, the hosts' floor 900 x 620), 900 x 600 and 390 x 844, dark and light. |
|
||||
| States this Mac cannot show | the scenarios: `welcome`, `unlock` (Touch ID enrolled), `home` (the miner app's node, a verified checkpoint, a history with every state), `public` (the public RPC, no certificate verification), `scanning` (the bundled node syncing), `nonode`, `empty`, `update` (an update ready; the card opens by itself). |
|
||||
|
||||
Screenshots: `docs/plans/wallet-ui-3/00-*.png` to `24-*.png`, named below. The build's screenshots carry the same names
|
||||
with an `n` in front. `05-unlock-password` did not capture: the old lock screen's "Use password" link never became
|
||||
clickable under the mock (the control area keeps its height and the link sits under the fingerprint button's line);
|
||||
the 0.1.4 lock screen is kept as it is, so the build's `n05` covers it.
|
||||
|
||||
## 2. What a first-time wallet user sees, screen by screen
|
||||
|
||||
Rating: 10 = nothing to change. The three questions per screen, as the miner's audit: what does it say in jargon,
|
||||
which number has no unit or no meaning, which 0 or blank has no reason word. A fourth for a wallet: where does the
|
||||
user's money sit, and what does each state word let them do.
|
||||
|
||||
### 2.1 Welcome (`00-welcome.png`), 8/10
|
||||
|
||||
Good: one heading, one lead, three plain tiles (your key stays here, final means verified, works with the miner), two
|
||||
buttons, the "nobody will ask for your words" line. The eyebrow "devnet v4 · nothing is bought or sold" is honest.
|
||||
|
||||
| Problem | Where |
|
||||
|---|---|
|
||||
| The coin is the retired ringed coin (`brand/README.md`: retired as a source; the miner-ui-3 welcome uses the mark). | the hero |
|
||||
| 60 px headline, 148 px coin, three tiles and two buttons need 1,000 px of height; at 900 x 620 (the hosts' floor) the buttons sit under the fold. | whole screen |
|
||||
|
||||
### 2.2 Create (`01-create-password.png`, `02-create-words.png`), 8/10
|
||||
|
||||
Good: three steps named, one sentence each, the words in a numbered grid, three typed back before the words are gone.
|
||||
Keep. The button "Make my words" is right; "Open my wallet" on step 3 is right.
|
||||
|
||||
| Problem | Where |
|
||||
|---|---|
|
||||
| The address under the words is a 42-character string with no Copy and no word for what it is beyond "Address". | step 2 |
|
||||
| The step eyebrows are ember, the miner's are ash: two conventions for one thing. | the eyebrows |
|
||||
|
||||
### 2.3 Import (`03-import.png`), 8/10
|
||||
|
||||
Good: three modes as a segmented control, the miner's key as the third, the note that rewards keep going to the same
|
||||
address. Keep.
|
||||
|
||||
### 2.4 The lock screen (`04-unlock-touch.png`), 9/10
|
||||
|
||||
The 0.1.4 lock screen is the best screen in the app: the glow, the name, the short address, one control, the quiet
|
||||
"Use password". It keeps the retired coin; the build swaps the mark in and keeps everything else, including
|
||||
`lock-screen.js` and its tests.
|
||||
|
||||
### 2.5 Home (`06-home.png`, lower `07-home-lower.png`), 6/10
|
||||
|
||||
What a wallet user sees first. Good: the balance large, the address with Copy, Send and Receive on the card, the
|
||||
history on the same page.
|
||||
|
||||
| Problem | Where |
|
||||
|---|---|
|
||||
| No money. "31.6321 IGN" with no pounds line and no word for why there is none. The miner's Earnings says "nothing is bought or sold on devnet" on its money line; the wallet says it only on the welcome eyebrow. | the balance |
|
||||
| Two engine cards side by side: Node (source, chain, block, finality, note: "igneum-devnet-20", "active, locked 4674", "using the miner app's node on this machine") and Finality ("verified here checkpoint 4674", "signed 11 of 12 voters, 91.2% of weight", "chain height 140,246", "weights at the checkpoint", "checked 6 Oct 19:05"). Ten rows of plumbing between the balance and the history. A wallet user needs one line: the node is synced and the wallet has verified up to a checkpoint. | the two cards |
|
||||
| The history's state column is the wallet's label in capitals: "PENDING", "IN BLOCK 140262", "FINAL · CP 4673", "FAILED". The node has five state words for a transaction (`igneum_getTransactionStatus`: pending, included, executed, proven, finalised; `vendor/igneum-node` `igneum/exec/src/rpc.rs`, "ONE state word, never a stronger word than the chain's own state") and the wallet shows none of them: "in block" covers included, executed and proven alike. | the history |
|
||||
| No reason under a state: "PENDING" (waiting for a block), "FAILED" (the execution failed; the fee was paid) are bare. | the history |
|
||||
| "SENT", "RECEIVED", "REWARD", "PROVING", "SELF" pills in capitals; "to 0x2b5ad5…ccd6cf" in lower-case hex while the balance card shows the checksummed form. | the history |
|
||||
| The amount column is the only coloured cell and it colours incoming molten; the state word, which is what changes, is grey. | the history |
|
||||
| The pill in the top bar: "MINER NODE · BLOCK 140,286" in capitals: the node's source and the chain height, neither of which a wallet user asked for. | top bar |
|
||||
| Settings is behind a gear icon; Receive and History have no place of their own; the page is one long scroll. | the chrome |
|
||||
|
||||
Apple would: the balance first with its money line, Send and Receive, one node line with Details behind it, then
|
||||
the five newest rows with "All history". The engine rows go behind Details.
|
||||
|
||||
### 2.6 Send (`08-send.png`, review `09-send-review.png`, sent `10-send-sent.png`), 7/10
|
||||
|
||||
Good: two fields, Review, a review card with the amount, the address, the fee at most and what leaves the wallet at
|
||||
most; the Touch ID confirm on the Send button; a Sent screen.
|
||||
|
||||
| Problem | Where |
|
||||
|---|---|
|
||||
| The fee note is a 40-word formula: "Fee = gas × price. Gas 25,380. Price = base fee 1 gwei (burned by the network) + tip 1 gwei (to the miner), capped at 3.000 gwei per gas; what is not used comes back." Gas, gwei, base fee, tip, cap: five terms a wallet user never chose. | the review |
|
||||
| The review is a second screen, not a confirmation in place: Edit goes back, Send now goes on, and the two fields are gone from view. | the review |
|
||||
| The Sent screen's one paragraph "It is pending until a block carries it, then in a block, then final once this wallet verifies the checkpoint over it." is the right idea as three state words, written as a sentence; the hash is a bare 66-character line with no Copy. | sent |
|
||||
| No balance on the form: "Amount, IGN" with no "of 31.6321" beside it, so the only way to learn the limit is the quote's refusal. | the form |
|
||||
|
||||
### 2.7 Receive (`11-receive.png`), 8/10
|
||||
|
||||
Good: the QR drawn locally, the address, Copy address, one note. Keep. The address is a paragraph, not a box with
|
||||
Copy beside it, so the eye finds the button under it rather than next to it.
|
||||
|
||||
### 2.8 Transaction (`12-tx-final.png`), 6/10
|
||||
|
||||
| Problem | Where |
|
||||
|---|---|
|
||||
| "FINAL · under checkpoint 4673, certificate verified by this wallet" in a mono box, then eight key-value rows: amount, when, from, to, fee paid, hash, block "139,900 · 0x0d9c…9c0d", verified checkpoint "4674 at chain height 140,246". Right facts, engine order. | whole screen |
|
||||
| It is a page of its own with Back; the history row it came from is out of view. | the chrome |
|
||||
|
||||
### 2.9 Settings (`13-settings.png`, `14-settings-lower.png`, `15-settings-lowest.png`), 5/10
|
||||
|
||||
The page with the most work in it. Seven cards, 2,000 px tall, every one with a password field.
|
||||
|
||||
| Problem | Where |
|
||||
|---|---|
|
||||
| Three password fields on one page (Backup, Export the private key, Remove this wallet) plus two for the password change. Each sits open on the page waiting to be typed into, where the miner uses a question in place that opens only when asked. | Backup, Export, Password, Remove |
|
||||
| The switches are the browser's checkboxes (fixed 5 October 2026 to be visible at all), not the miner's track switch; the labels have no second sentence. | Touch ID, This machine |
|
||||
| "Export to MetaMask" lists network name, chain id "7762959 (0x76770f)", rpc url, symbol, decimals, then a warning box, then the password field. "Network" repeats source "miner", "ethereum rpc http://127.0.0.1:26790", "grpc 127.0.0.1:26610", chain id, network, public rpc. "This machine" lists machine "d937c69d", version, logs path, "miner key file present". Three cards of engine facts. | MetaMask, Network, This machine |
|
||||
| No Appearance. No light mode (`<meta name="color-scheme" content="dark">`, one token set). | whole page |
|
||||
| The version line "Igneum Wallet 0.1.5 · up to date" sits as a mono line under the heading; the update controls (Check for updates, Install now, the auto-update checkbox) sit two cards down inside "This machine". The miner has one update card. | version-row, This machine |
|
||||
| "Remove this wallet from this machine" uses `confirm()`, a dialog the viewer never shows (the brief's rule, as the miner's quit). | `app.js` `remove-go` |
|
||||
| The node endpoint cannot be changed; the engine has no route for it (the source is chosen by `node.rs`: the miner's node, else the bundled node, else the public RPC). Shown, not settable. | Network |
|
||||
| The Back button at the foot is the only way out besides the gear. | foot |
|
||||
|
||||
Apple would: Settings as plain rows with one sentence each, grouped: Security (Touch ID, ask on open, lock when idle,
|
||||
change password), Backup (show my words, export the key, MetaMask), This machine (start at login, Appearance), one
|
||||
update card, Node (the endpoint in use), Advanced (the ids, the folders, remove).
|
||||
|
||||
### 2.10 The public RPC, scanning, no node, empty (`16-home-public.png`, `17-home-scanning.png`, `18-home-nonode.png`, `19-home-empty.png`), 6/10
|
||||
|
||||
| Problem | Where |
|
||||
|---|---|
|
||||
| Public RPC: "finality not checkable without a node" and "no node here: certificates cannot be verified" in ember, twice, in two cards; the history's "IN BLOCK" rows carry no word that they will stay there. The pill says "PUBLIC NODE" when there is no node. | 16 |
|
||||
| Scanning: "reading the chain, 61,200 of 140,270 blocks" under a dimmed balance "0": a 0 in the hero while the real number is unknown. | 17 |
|
||||
| No node: balance "0" dimmed with "waiting for a node"; the Node card says "no node: install Igneum Miner, or wait for the bundled node". Right words, wrong place (a card, not the node line). | 18 |
|
||||
| Empty: "Nothing yet. Receive IGN, or point the miner app at this address." is good. "Your words have not been written down yet. Back up now." as a note under the buttons is good. Keep both. | 19 |
|
||||
|
||||
### 2.11 The update card (`20-update-card.png`), 9/10
|
||||
|
||||
The miner's card: the mark with the ring, "Igneum Wallet 0.1.6", one line, three notes, the size, Install now and
|
||||
Later. `update-card.js` already says `NAME = 'Igneum Wallet'` on this branch (the miner-ui-3 owed row "the wallet's copy
|
||||
still says Igneum Ember" was true of wallet-v1 before 0.1.3 and is closed here). Keep.
|
||||
|
||||
### 2.12 Narrow (`21-narrow-900-home.png`, phone `22-phone-home.png`, `23-phone-settings.png`)
|
||||
|
||||
900 x 600: usable; the two engine cards squeeze, the history's four columns hold. 390 wide: broken. The pill wraps to
|
||||
four lines over the gear, "31.6321 IGN" overflows the card to the right, the Node and Finality cards' values wrap one
|
||||
character per line ("ch / ec / kp / oi / nt"), Settings' password fields overflow. No phone layout exists.
|
||||
|
||||
### 2.13 Light mode (`24-light-home.png`)
|
||||
|
||||
None. The page is dark whatever the OS says. The Mac host also pins `window.appearance = darkAqua`
|
||||
(`app/mac/IgneumWallet.swift` `buildWindow`), so even a page that followed `prefers-color-scheme` would see dark.
|
||||
|
||||
## 3. Every feature the old UI has (the build's tick list)
|
||||
|
||||
| # | Feature | Where it is today | Route |
|
||||
|---|---|---|---|
|
||||
| 1 | Welcome: create or import | welcome | none |
|
||||
| 2 | Create: password, 24 words, three typed back | create, 3 steps | `api/create`, `api/create/confirm` |
|
||||
| 3 | Import: 24 words, a private key, the miner's key file (disabled when absent) | import | `api/import` |
|
||||
| 4 | The lock screen: Touch ID button, the one automatic prompt, Use password, the line under the button | unlock (`lock-screen.js`) | `api/unlock` (the host), the host bridge |
|
||||
| 5 | Lock (button, the host's menu, the idle lock with the activity ping every 20 s) | top bar | `api/lock`, `api/activity` |
|
||||
| 6 | Balance in IGN, "reading the chain N of M blocks", "reading the balance", "waiting for a node" | home | state |
|
||||
| 7 | Address with Copy | home | state |
|
||||
| 8 | "Your words have not been written down yet. Back up now." | home | state `backed_up` |
|
||||
| 9 | Send: to, amount, the quote (address, amount, balance checks), the review (amount, to, fee at most, leaves at most, the fee note), Touch ID confirm or Send now, Edit, the Sent screen with the hash, View, Done | send | `api/send/quote`, `api/send`, the host `confirm` |
|
||||
| 10 | Receive: the QR (SVG from the engine), the address, Copy address | receive | `api/receive` |
|
||||
| 11 | History: up to 60 rows, kind, who, when, amount, the wallet's label (pending, in block N, final · cp N, failed), a click opens the transaction | home | state `history` |
|
||||
| 12 | Transaction: the finality line, amount, when, from, to, fee paid, hash, block and block hash, note, the verified checkpoint | tx | state (`api/tx/<hash>` exists and is unused by the page) |
|
||||
| 13 | Node: source, chain, block, finality active and latest locked, the engine's message | home card | state `node` |
|
||||
| 14 | Finality: verified checkpoint, signers of voters and the weight fraction, chain height, weights exact or latest, checked at; else the message | home card | state `finality` |
|
||||
| 15 | Touch ID: turn on (password once, then the prompt), turn off, "Ask when the wallet opens", lock when idle (1, 5, 15, 60, never), the needs-enrol line after a password change, the "needs the app window" and "not set up" lines | settings | `api/biometric/*`, `api/settings` |
|
||||
| 16 | Backup: show the words and the key with the password or Touch ID, Hide, marks backed up | settings | `api/reveal`, `api/backed-up` |
|
||||
| 17 | Change the password | settings | `api/password` |
|
||||
| 18 | MetaMask: network name, chain id, RPC, symbol, decimals; Add to MetaMask (the site page); Copy network settings; export the key with the password or Touch ID, Copy key, Hide | settings | `api/network`, `api/open`, `api/reveal` |
|
||||
| 19 | Network: source, Ethereum RPC, gRPC, chain id, network, public RPC | settings | state |
|
||||
| 20 | This machine: start at login, install updates by itself, machine id, version, logs folder, miner key file present, Check for updates, Install now, the update note | settings | `api/settings`, `api/update/*` |
|
||||
| 21 | Remove this wallet (password, a `confirm()` dialog) | settings | `api/remove` |
|
||||
| 22 | The update banner (one strip) and the update card (`update-card.js`); Later per version and stage; the urgent path | everywhere | `api/update/install`, `open` |
|
||||
| 23 | The pill: source and block, starting, connecting, public rpc, stopping, engine gone | top bar | state |
|
||||
| 24 | The version in the header and the version line in Settings | chrome | state |
|
||||
| 25 | The welcome eyebrow names the network | welcome | state `settings.network` |
|
||||
| 26 | `?bio=touch`, `?update=<state>[&auto=0][&card=1]`, `?host=mac` | URL | none |
|
||||
| 27 | The quit path (the host's menu; `api/quit` exists, no button) | none | `api/quit` |
|
||||
|
||||
What the engine has that the page never shows: `api/tx/<hash>` with `node_status` (the node's one state word per
|
||||
transaction, `igneum_getTransactionStatus`), `events` (the engine's own activity lines: "sent 1.5 IGN to …", "earned
|
||||
10.14 IGN as a block reward"), `uptime_s`, `app_dir`. What no route gives: a market price (`price_gbp_per_ign`, owed by
|
||||
the engine as in the miner's plan, section 11), a settable node endpoint.
|
||||
|
||||
## 4. The top ten findings
|
||||
|
||||
| # | Finding | Consequence per tier |
|
||||
|---|---|---|
|
||||
| 1 | No money line and no reason for its absence: "31.6321 IGN" alone. The miner's Earnings says why there is no £. | Every user. A wallet that shows a number with no value and no word for it reads as a placeholder. |
|
||||
| 2 | The node's five state words (pending, included, executed, proven, finalised) are not shown; the history says "in block" for three of them and the design rule (one state word, never stronger than the chain's) is not applied. | Every user; most for a sender waiting to know whether the other side can rely on a payment. |
|
||||
| 3 | Ten rows of node and finality plumbing on the home screen between the balance and the history. | Every user; a home miner's wallet should read like the miner's Mine page: one node line, Details behind it. |
|
||||
| 4 | Settings is seven cards with three open password fields and browser checkboxes; the update controls are split between a mono line at the top and a card at the bottom; no Appearance. | Every user. |
|
||||
| 5 | The send fee is a 40-word gas formula; the review is a second screen rather than a question in place; the Sent screen's states are a sentence. | Every sender. |
|
||||
| 6 | A `confirm()` dialog on Remove (the viewer never shows one). | Every user who removes a wallet: the button appears to do nothing. |
|
||||
| 7 | No layout under 900 px: at 390 the pill covers the gear, the balance overflows, the engine cards wrap letter by letter. | Windows laptop users with a half-screen window; the Windows host's window can be dragged narrow. |
|
||||
| 8 | No light mode on the page and the Mac host pins dark appearance. | Every Mac user on a light desktop. |
|
||||
| 9 | Capitals everywhere a label sits (SENT, RECEIVED, IN BLOCK, MINER NODE · BLOCK 140,286): the engine's vocabulary shouted. | Every user. |
|
||||
| 10 | The chrome has no sections: Settings behind a gear, Receive and History without a place, Back buttons at the foot of each view. | Every user. |
|
||||
|
||||
Smaller, fixed in the build with no mention elsewhere: the retired ringed coin on the welcome and the lock screen; the
|
||||
ember step eyebrows; the lower-case hex in the history rows; the bare hash on the Sent screen; the address under the
|
||||
words with no Copy; "PUBLIC NODE" when there is no node; the dimmed "0" while the balance is unknown.
|
||||
|
||||
## 5. What the earlier wallet work set out to do and what it achieved
|
||||
|
||||
0.1.0 to 0.1.4 (4 to 5 October 2026) built the engine's surface one screen at a time: the vault and the words, the
|
||||
quote and the send, the QR, the history with rewards, the certificate verification, Touch ID, the update card, the
|
||||
lock screen. Each screen is honest and complete; the lock screen and the update card are already at the standard.
|
||||
What it did not do: put the screens on one design (the chrome is the miner's 0.3.3 top bar with a gear), say what the
|
||||
balance is worth or why it cannot, use the node's state words, or lay out under 900 px. UI 3 keeps every feature,
|
||||
every route and the two finished screens, and puts the rest on the miner's system.
|
||||
246
docs/plans/wallet-ui-3.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# Igneum Wallet UI 3: the wallet on the miner's system
|
||||
|
||||
6 October 2026. The founder, 18:5x UK: "rebuild the wallet to the same standard" as the redesigned miner (miner-ui-3, in
|
||||
Igneum Miner 0.3.14), "and update the site on that too". The audit is `docs/plans/wallet-ui-3-audit.md`. Branch
|
||||
`wallet-ui-3`, worktree `../igneum-wt-wallet-ui`, from `wallet-0.1.5` 1fa6fa7. The 0.1.5 shipper takes it into the
|
||||
wallet cut; this branch never touches a release branch. The Rust engine is read as it is: every route the page needs
|
||||
exists (`server.rs`), so `src/` is untouched. One host line changes (section 9).
|
||||
|
||||
## 1. The one job of each screen
|
||||
|
||||
| Screen | Its one job | What it holds | What left it |
|
||||
|---|---|---|---|
|
||||
| Welcome, Create, Import | a wallet in under a minute, the words written down | as 0.1.4, the mark instead of the retired coin, the address with Copy under the words, ash step eyebrows | the coin |
|
||||
| Lock screen | one tap back in | as 0.1.4 (`lock-screen.js` and its tests unchanged), the mark on the glow | the coin |
|
||||
| Home | how much is here, and can it be relied on | the balance in IGN with its money line, the address with Copy, Send and Receive, the node line with Details, the five newest rows with "All history" | the Node and Finality cards (to the node line's Details), the long history (to its page) |
|
||||
| Send | one payment, confirmed where it was typed | address, amount with the balance beside it, the fee line, Review turns the form into a question in place, then the pending row | the second screen, the gas formula, the Sent paragraph |
|
||||
| Receive | the address, two ways | the address in a box with Copy, the QR, one line | nothing |
|
||||
| History | one row per transaction with the node's word | every row: kind, who, when, amount, the state word with its reason; a row opens its details in place | the capitals, the wallet-only label, the transaction page |
|
||||
| Settings | the switches, one sentence each | Security, Backup, This machine (with Appearance), one update card named Igneum Wallet, Node (the endpoint), Advanced | the seven cards, the three open password fields, the browser checkboxes, the `confirm()` |
|
||||
|
||||
Five sections in the rail: Home, Send, Receive, History, Settings. Lock and Quit in the foot. The rail keeps the
|
||||
arrow keys and `aria-current`, as the miner's. Under 720 px it is a bottom tab bar with the five sections; Lock stays
|
||||
in the top bar; Quit lives in the host's menu (the Mac and Windows hosts both have one).
|
||||
|
||||
## 2. Home
|
||||
|
||||
```
|
||||
31.6321 IGN no market price on devnet: coins have no value
|
||||
0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf [Copy] [Send] [Receive]
|
||||
|
||||
● Node synced · the miner app's node · block 140,286 · verified to checkpoint 4674 Details ⌄
|
||||
|
||||
Recent All history
|
||||
row · row · row · row · row
|
||||
```
|
||||
|
||||
| Element | Rule |
|
||||
|---|---|
|
||||
| The balance | `state.balance` in Unbounded at `--t-h1`; "IGN" in mono beside it. While `balance_known` is false the number is not shown: the line reads `reading the balance` (node ok), `reading the chain, 61,200 of 140,270 blocks` (scanning) or `waiting for a node` (no node) in ash. Never a 0 for an unknown. |
|
||||
| The money line | with `price_gbp_per_ign` on the state (owed by the engine, as the miner's plan section 11): `≈ £12.34` with "at today's price" in ash. Without it on devnet: `no market price on devnet: coins have no value`. Without it elsewhere: `no market price yet`. Money first when it exists; one grey line explaining its absence when it does not. |
|
||||
| The address | the checksummed form in a box, Copy beside it (`data-copy`). The backup note `Your words are not written down yet.` with "Back up now" under it while `backed_up` is false, as before. |
|
||||
| Send, Receive | the two buttons; Send is the page's one primary. |
|
||||
| The node line | `View.nodeLine(state)`: the state word first (`synced`, `starting`, `connecting`, `lost`, `off`), then the source in plain words (`the miner app's node`, `the bundled node`, `the public RPC`, `the node named by the environment`), the block, and the verification: `verified to checkpoint 4674` when `finality.verified_index` is set, `certificates cannot be verified without a node` on the public RPC, `waiting for the first certificate` otherwise. The dot is molten when ok, ember when lost or off, ash while starting. |
|
||||
| Node details | the old Node and Finality cards' facts as `kv` rows with one-line meanings: source (the engine's message), chain (`igneum-devnet-20`, chain id), block, the endpoint in use with Copy, finality on the node (`active, locked 4674` or `paused` or `not checkable without a node`), the verified checkpoint (`4674`, "the newest certificate this wallet checked itself"), signed (`11 of 12 voters, 91.2% of all weight`), the checkpoint's chain height, weights (`taken at the checkpoint` or `the latest table`), checked (`14 s ago`). |
|
||||
| Recent | the five newest history rows (section 5's row), then "All history" opens the History page. Empty: `Nothing yet. Receive IGN, or point the miner app at this address.` |
|
||||
|
||||
## 3. Send
|
||||
|
||||
```
|
||||
To [0x… ]
|
||||
Amount [1.5 ] IGN of 31.6321 IGN
|
||||
Fee at most 0.00007614 IGN Details ⌄
|
||||
[Review]
|
||||
```
|
||||
|
||||
Review posts `api/send/quote` and turns the card's foot into the question, in place, the fields still visible above
|
||||
it and locked:
|
||||
|
||||
```
|
||||
Send 1.5 IGN to 0x2B5A…D6cF? Fee at most 0.00007614 IGN; 1.50007614 IGN leaves the wallet at most.
|
||||
[Confirm with Touch ID] [Cancel]
|
||||
```
|
||||
|
||||
| Element | Rule |
|
||||
|---|---|
|
||||
| The fee line | before a quote: `the fee is shown when you review`; after: `at most 0.00007614 IGN`. Details opens one grey line: `Gas 25,380 at a base fee of 1 gwei (burned) plus a 1 gwei tip (to the miner), capped at 3 gwei; what is not used comes back.` The numbers are the quote's (`gas`, `base_fee_gwei`, `tip_gwei`, `max_fee`). |
|
||||
| The balance beside the amount | `of 31.6321 IGN` from `state.balance`; absent while the balance is unknown. |
|
||||
| The ask | `View.sendAsk(quote)`: the words above; the button reads `Confirm with Touch ID` (or Windows Hello) when `confirm_needed` and the app window is the host, else `Send now`; the host-missing case keeps the old sentence in ember under the ask. Cancel unlocks the fields. |
|
||||
| After the send | the card's foot becomes the pending row: `● pending · waiting for a block` with the hash short and Copy, then `View in History` and `Send another`. The row's word follows the node (section 5) on every poll, so the user can watch it go pending → included → executed without leaving. |
|
||||
| Errors | the quote's refusals (not an address, more than the balance, the zero address) under the fields in ember, as before. |
|
||||
|
||||
The update card never opens while the Send page is up (`UpdateCard.blocked`, `ctx.view === 'send'`, unchanged).
|
||||
|
||||
## 4. Receive
|
||||
|
||||
The address in a box with Copy, the QR under it (240 px, the engine's SVG), one line: `Only IGN on the Igneum
|
||||
network. Rewards from the miner app land here when it pays this address.` The QR is fetched when the page opens
|
||||
(`api/receive`), as before.
|
||||
|
||||
## 5. History: one row per transaction, the node's word
|
||||
|
||||
```
|
||||
↓ Received from 0x2B5A…D6cF · 19:03 +0.25 IGN ● included in block 140,262, not executed yet ⌄
|
||||
↑ Sent to 0x2B5A…D6cF · 19:06 −1.5 IGN ● pending waiting for a block
|
||||
◆ Reward block reward · 19:01 +10.14 IGN ● executed in block 140,201
|
||||
```
|
||||
|
||||
| Part | Rule |
|
||||
|---|---|
|
||||
| Kind | a word, not a pill: Sent, Received, To yourself, Reward, Proving payout; an arrow glyph in ash before it. |
|
||||
| Who | `from 0x2b5a…d6cf` or `to …` as the engine gives it (lower-case hex, the first 6 and last 4), `block reward`, `shard payout`; the time as `19:03`, with the date when not today. The checksummed form needs keccak, which the page does not carry: the engine could add `display_from` and `display_to` (owed, section 10). |
|
||||
| Amount | `+0.25 IGN` (incoming, molten) or `−1.5 IGN` (outgoing, bone); up to six decimals, trailing zeros cut. |
|
||||
| State word | ONE word, the node's (Ledger P17, design 2.4: never a stronger word than the chain's). `View.stateWord(entry, nodeState)`: the wallet's own `final` (a checkpoint this wallet verified covers it) → `finalised`; the wallet's `failed` → `failed`; else the node's `state` from `api/tx/<hash>` (`pending`, `included`, `executed`, `proven`, `finalised`, `finality not active`) when the page has it; else the wallet's label (`pending` → `pending`, `in_block` → `included`). A reward or a proving payout has no transaction hash (`reward-N-i`): its row says `executed` in a block and `finalised` under a verified checkpoint. |
|
||||
| Reason | one grey line: pending `waiting for a block`; included `in block N, not executed yet`; executed `in block N`; proven `in block N, proof paid`; finalised `under checkpoint N, verified here` (or `under a locked checkpoint` when the word is the node's and the wallet has not verified it); finality not active `under a locked checkpoint; finality is paused on the network`; failed `the execution failed; the fee was still paid`. |
|
||||
| Colour | pending and included ash; executed and proven ink; finalised molten; failed ember. The dot carries the colour. |
|
||||
| Details | the chevron opens the row: hash with Copy, from, to (full checksummed), fee paid, block and block hash, the checkpoint, the engine's note, and the node's word verbatim (`node: proven`) so the two sources are side by side. |
|
||||
| Where the node's word comes from | `api/tx/<hash>` GET (exists, unused before). The page asks for the rows that are not `final` and not `failed`, at most 12 per poll, newest first, and keeps the answer per hash; a row the wallet marks `final` stops asking. On the public RPC the call is tried once and the fallback label is used when it errors. |
|
||||
|
||||
The page lists every entry the state carries (up to 2,000 in the engine's file; the page renders them all, the list is
|
||||
short). Empty: as Home's.
|
||||
|
||||
## 6. Settings
|
||||
|
||||
Plain rows, one sentence each, grouped as the miner's:
|
||||
|
||||
| Group | Rows |
|
||||
|---|---|
|
||||
| Security | Touch ID (or Windows Hello): on → `Touch ID is on. It unlocks the wallet, confirms each send and shows the backup; the password still works everywhere.` with Turn off; off → the switch opens the password field in place with Turn on (the enrolment flow as before: password once, then the prompt); the "needs the app window" and "not set up" lines as before · Ask for Touch ID when the wallet opens (switch) · Lock when idle (the select: 1, 5, 15, 60 minutes, never; one sentence) · Change password (a disclosure: current, new, Change) |
|
||||
| Backup | Show my words (a disclosure; the ask in place: `Show the 24 words and the key on screen? Anyone who sees them has your IGN.` then the password field or the Touch ID button, then the words grid, the key, Hide; marks `backed_up`) · Export the key for MetaMask (a disclosure with the warning sentence, the same ask, Copy key, Hide) · MetaMask network: one line (`Igneum devnet · chain id 7762959 · rpc.devnet.igneum.network`) with Add to MetaMask and Copy settings |
|
||||
| This machine | Start at login (switch: `Opens when you sign in; the wallet locks itself when idle.`) · Appearance: System / Light / Dark (the page's own storage, as the miner's) |
|
||||
| Updates | one card: `Igneum Wallet 0.1.5 · up to date, checked 13 min ago` · Check · Install now when ready · `Install updates by itself when nothing is being sent` (switch) |
|
||||
| Node | the endpoint in use with Copy (`http://127.0.0.1:26790`, read-only) · the source sentence (`the miner app's node on this machine`) · the public RPC · the chain id and the gRPC port in one grey line. A field to set the endpoint is owed: the engine chooses the source (`node.rs plan_own_node`, the probe order miner → bundled → public) and has no `api/settings {node_endpoint}`. The row says so in one line: `The wallet picks the miner's node when it runs here, else its own, else the public RPC.` |
|
||||
| Advanced (details) | the machine id · the logs folder · the wallet folder · `the miner's key file: present` · Remove this wallet from this machine: the password field and the ask in place `Remove this wallet from this machine? The vault file is deleted. Only your 24 words or the key bring it back. Remove Cancel` |
|
||||
|
||||
The version row at the top of the old Settings and the Back button at its foot are gone: the update card carries the
|
||||
version, the rail carries the way out.
|
||||
|
||||
## 7. Confirmations in place
|
||||
|
||||
No `confirm()`, no dialogs. A costly action turns its own row into a question with two buttons:
|
||||
|
||||
| Action | The row says |
|
||||
|---|---|
|
||||
| Send | `Send 1.5 IGN to 0x2B5A…D6cF? Fee at most 0.00007614 IGN; 1.50007614 IGN leaves the wallet at most. Confirm with Touch ID / Send now Cancel` |
|
||||
| Show my words, Export the key | `Show the 24 words and the key on screen? Anyone who sees them has your IGN. Show (or Show with Touch ID) Cancel` |
|
||||
| Remove the wallet | `Remove this wallet from this machine? The vault file is deleted. Only your 24 words or the key bring it back. Remove Cancel` |
|
||||
| Quit | `Quit Igneum Wallet? The bundled node stops with it. Quit Cancel` (the strip over the page, as the miner's) |
|
||||
| Lock, Copy, Touch ID off, the switches, Check | no question: each reverts in one tap and the toast says what happened |
|
||||
|
||||
## 8. Type, spacing, colour, widths
|
||||
|
||||
The miner's tokens, verbatim (`docs/plans/miner-ui-3.md` section 9): the eight type sizes, the six-step spacing scale,
|
||||
the radii, the dark and light sets, ember as the one accent, molten for live states. `app.css` starts from the miner's
|
||||
file; the wallet's own blocks (the words grid, the QR, the history row, the ask) are added on the same tokens.
|
||||
|
||||
| Width | Layout |
|
||||
|---|---|
|
||||
| 1180 px and up | rail 196 px, the balance and its money line on one row, the history row with every cell |
|
||||
| 1000 to 1180 | the rail folds to icons |
|
||||
| 720 to 1000 | the history row's reason goes under the state word |
|
||||
| under 720 | a bottom tab bar (Home, Send, Receive, History, Settings), the balance stacks, the row is two lines (kind and who; amount and state), 16 px gutters, no horizontal scroll |
|
||||
|
||||
## 9. The one host change
|
||||
|
||||
`app/mac/IgneumWallet.swift` `buildWindow` sets `window.appearance = NSAppearance(named: .darkAqua)`, which pins the
|
||||
WKWebView's `prefers-color-scheme` to dark. The line is removed so System follows the Mac; the window's background
|
||||
stays obsidian until the page paints (the page covers it). Nothing else in the host moves. The Windows host
|
||||
(`app/windows/wallet-host.cpp`) does not pin a scheme; nothing to change.
|
||||
|
||||
## 10. What the engine is asked for (none of it built here)
|
||||
|
||||
| Field or route | Why | Owner |
|
||||
|---|---|---|
|
||||
| `price_gbp_per_ign` | the money line | the engine, when a market exists (the miner's plan says the same) |
|
||||
| `api/settings {node_endpoint}` or a `node_source` choice | the Node row's field | the engine; today `node.rs` chooses |
|
||||
| `node_status` per entry on `/api/state` | would save the per-row `api/tx/<hash>` calls | the engine, later; the page works without it |
|
||||
|
||||
Until a field arrives the page treats it as unset: the money line explains, the Node row is read-only, the state words
|
||||
come from `api/tx/<hash>`.
|
||||
|
||||
## 11. Tests
|
||||
|
||||
`view.test.mjs` (new; `node --test`, no dependencies, loads the `View` block of `app.js` as the miner's does): the
|
||||
five pages and their titles; the state word for every combination of the wallet's label and the node's word
|
||||
(pending, included, executed, proven, finalised, finality not active, failed; a reward's words; the fallback without
|
||||
a node word); the reason line per word; the money line with and without a price, on devnet and off; the node line per
|
||||
source and state; the balance line while unknown (reading, scanning, waiting); the send ask's words with and without
|
||||
Touch ID; the fee words; the short address; the row model (kind word, who, sign, amount); the Settings' Touch ID
|
||||
sentence. `lock-screen.test.mjs` and `update-card.test.mjs` are unchanged and pass.
|
||||
|
||||
## 12. The build
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `app/igneum-wallet/ui/index.html` | the rail (five sections, Lock and Quit in the foot), the top bar with the page title and the pill, the quit ask strip, the welcome with the mark, Create and Import as before with ash eyebrows and the address Copy, the lock screen as 0.1.4 with the mark, the five pages, the update card, the toast |
|
||||
| `app/igneum-wallet/ui/app.css` | the miner's tokens and chrome (dark and light sets, the rail, the top bar, the notices strip, the ask, the disclosures, the segmented control, the switch, the kv, the update card, the bottom tab bar under 720 px), plus the wallet's blocks: the balance hero, the words grid, the checks, the QR, the history row and its details, the send card, the lock screen |
|
||||
| `app/igneum-wallet/ui/app.js` | `View` (pure, exported for the test): `PAGES`, `page`, `shortHex`, `ign`, `moneyLine`, `balanceLine`, `nodeWords`, `nodeLine`, `nodeDetails`, `rowModel`, `stateWord`, `stateReason`, `feeWords`, `sendAsk`, `bioSentence`, `updateCardLine`; the DOM block: the pages behind the rail, the per-hash node word cache (`api/tx/<hash>`), the asks, the appearance, `?page=`, the existing `?bio=`, `?update=`, `?host=` switches; the Touch ID bridge, the lock-screen logic and the update card logic carried over unchanged |
|
||||
| `app/igneum-wallet/ui/view.test.mjs` | section 11 |
|
||||
| `app/mac/IgneumWallet.swift` | section 9, one line |
|
||||
| `tools/ui-mock/wallet.mjs`, `tools/ui-mock/shoot-wallet.mjs` | the mock and the capture list (the audit's) |
|
||||
| `docs/plans/wallet-ui-3/n*.png` | the result, the same names as the audit's captures, plus the real Mac window (`m*.png`) from the built host on a fresh data dir |
|
||||
|
||||
## 13. The build (6 October 2026, evening)
|
||||
|
||||
Three UI files rewritten, `view.test.mjs` added, one Swift line removed, nothing in `src/`. `node --test` on the four UI
|
||||
files: 20 pass (11 view, 5 lock screen, 4 update card). The engine was built in this worktree under the Mac build lock
|
||||
(a warm `target`, 100 s) so the new files are compiled in (`include_str!`); the window host was built with `swiftc`
|
||||
under the same lock. Captures:
|
||||
|
||||
| Set | What | How |
|
||||
|---|---|---|
|
||||
| `n00` to `n24` | every audit screen on the new UI, the same names | the mock on 4320, Playwright's cached chromium at nice 19, one browser, closed after the batch |
|
||||
| `m00-welcome`, `m01-home`, `m02-history`, `m03-receive`, `m04-settings`, `m05-light`, `m06-lock` | the real Mac window (`Igneum Wallet --snapshot`, 1120 x 780, the shipped size) on this worktree's engine, a fresh scratch data dir, a throwaway wallet created through the engine's own API for the shot and deleted with the dir afterwards (never funded, never the founder's) | the engine at nice 19, the host at nice 19 |
|
||||
|
||||
What the real run showed: the engine found the miner app's node on this Mac at 18:31:47Z and lost it 20 s later
|
||||
(`connect 127.0.0.1:26790: Connection refused`); on the second run it found no node at all. Node 1 on this Mac was
|
||||
not answering steadily between 18:31Z and 18:36Z. The wallet's words for it were right (`Node lost`, then `Node not
|
||||
found`, the pill `NODE LOST` / `NO NODE`, the balance line `waiting for a node`); one leak was fixed on the spot: the
|
||||
node line's sub showed the engine's raw error, it now says `the node stopped answering; trying again` and keeps the
|
||||
raw message in Details. A bare engine has no bundled `igneumd` beside it, so the second run's message is the packaged
|
||||
build's own ("igneumd is not next to the wallet"); the DMG carries the node.
|
||||
|
||||
Light mode on the real window follows the page's `?theme=light` switch (added for the captures, never stored) and,
|
||||
with the host line removed, the Mac's own appearance.
|
||||
|
||||
### What the 0.1.5 shipper takes
|
||||
|
||||
| Item | Where |
|
||||
|---|---|
|
||||
| The three UI files, `view.test.mjs`, `update-card.js` and `lock-screen.js` unchanged | `app/igneum-wallet/ui/` |
|
||||
| The one Swift line (no pinned `darkAqua`) | `app/mac/IgneumWallet.swift` |
|
||||
| The mock and the shot script, the two docs and the capture folder | `tools/ui-mock/wallet.mjs`, `tools/ui-mock/shoot-wallet.mjs`, `docs/plans/wallet-ui-3*.md`, `docs/plans/wallet-ui-3/` |
|
||||
| The site: `site/wallet.html`, the wallet section of `site/index.html`, `site/img/wallet-home.webp` and `site/img/wallet-final.webp` (from the mock at 1120 x 780, example addresses, 2240 x 1560) | `site/` |
|
||||
| The DMG recipe is unchanged: `packaging/mac/build-wallet-dmg.sh` compiles the engine and the host from these files | the shipper's cut |
|
||||
| `tools/ci/no-secrets-check.sh` fails on `app/igneum-wallet/src/vault.rs:129` (a test fixture, a 64-hex key next to the word key) on `wallet-0.1.5` before this branch; untouched here, the shipper's to settle | pre-existing |
|
||||
| Owed to the engine: `price_gbp_per_ign`; a settable node endpoint; `display_from` and `display_to` per entry (the page has no keccak, so the row shows the plain hex short form); `node_status` per entry on the state | section 10 |
|
||||
|
||||
### The tick list (section 3 of the audit)
|
||||
|
||||
| # | Feature | Where it is now |
|
||||
|---|---|---|
|
||||
| 1 to 3 | Welcome, Create, Import | as before; the mark, the address Copy, ash eyebrows |
|
||||
| 4 | The lock screen | as 0.1.4; the mark |
|
||||
| 5 | Lock, the idle lock, the activity ping | Lock in the rail foot and the top bar on narrow widths; the ping unchanged |
|
||||
| 6 | The balance and its unknown states | Home, the hero; no 0 for an unknown |
|
||||
| 7 | The address with Copy | Home, Receive |
|
||||
| 8 | The backup note | Home |
|
||||
| 9 | Send: quote, review, Touch ID, Sent | Send, the ask in place, the pending row |
|
||||
| 10 | Receive | Receive |
|
||||
| 11 | History | History (every row), Home (five) |
|
||||
| 12 | Transaction details | the row's disclosure |
|
||||
| 13, 14 | Node and Finality | the node line and its Details |
|
||||
| 15 | Touch ID, ask on open, idle lock | Settings, Security |
|
||||
| 16 | Backup | Settings, Backup, with the ask |
|
||||
| 17 | Change password | Settings, Security, a disclosure |
|
||||
| 18 | MetaMask | Settings, Backup |
|
||||
| 19 | Network | Settings, Node |
|
||||
| 20 | This machine, updates | Settings, This machine and the update card |
|
||||
| 21 | Remove | Settings, Advanced, with the ask (no dialog) |
|
||||
| 22 | The update banner and card | unchanged (`update-card.js`); the banner is the notices strip |
|
||||
| 23 | The pill | the top bar: the wallet's own words (`synced`, `starting`, `no node`, `locked`, `stopping`, `engine gone`) |
|
||||
| 24 | The version | the update card; the rail foot |
|
||||
| 25 | The welcome eyebrow | unchanged |
|
||||
| 26 | The URL switches | as before, plus `?page=` |
|
||||
| 27 | Quit | the rail foot, with the ask strip |
|
||||
| new | The money line, the node's state words with reasons, the node line, Appearance and light mode, the bottom tab bar, the Node endpoint row | this build |
|
||||
BIN
docs/plans/wallet-ui-3/00-welcome.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
docs/plans/wallet-ui-3/01-create-password.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/plans/wallet-ui-3/02-create-words.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
docs/plans/wallet-ui-3/03-import.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/plans/wallet-ui-3/04-unlock-touch.png
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
docs/plans/wallet-ui-3/06-home.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
docs/plans/wallet-ui-3/07-home-lower.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
docs/plans/wallet-ui-3/08-send.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
docs/plans/wallet-ui-3/09-send-review.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/plans/wallet-ui-3/10-send-sent.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/plans/wallet-ui-3/11-receive.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/plans/wallet-ui-3/12-tx-final.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
docs/plans/wallet-ui-3/13-settings.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/plans/wallet-ui-3/14-settings-lower.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/plans/wallet-ui-3/15-settings-lowest.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/plans/wallet-ui-3/16-home-public.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
docs/plans/wallet-ui-3/17-home-scanning.png
Normal file
|
After Width: | Height: | Size: 184 KiB |