From b2e7b23f855f8be5605fa0189e444c2c9fdd8afd Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:47:28 +0000 Subject: [PATCH 001/150] Igneum Wallet v1: desktop wallet on the miner's bones, igneum-common crate, Mac app and DMG, test-network proof app/igneum-common (new library crate): the platform helpers with the app identity as a parameter, the payout key code, the signed OTA manifest (byte-identical to the miner's), the manifest fetch and download check, the 127.0.0.1 server primitives and a JSON client, run_timeout, the packaged config and machine id. Nothing in app/igneum-app changed; `cargo check -p igneum-app` still passes. app/igneum-wallet (new): create (24 words, three typed back) or import (words, raw key, the miner's wallet.json), sealed with Argon2id + XChaCha20-Poly1305 under the user's password; balance, send (EIP-1559, signed in Rust, zero address refused, fee shown as base fee + tip), receive with a QR drawn locally, history (transfers, block rewards from the execution records, shard payouts when they exist); finality per transaction verified by the wallet itself with the node's own finality code (certificate from the blocks after the checkpoint, canonical voter list, aggregate BLS signature, 2/3 of active and of total weight), shown as pending / in a block / final with the checkpoint index; export to MetaMask (key with a warning, network parameters, add-network link); node source order: the miner app's node, the environment's node, the bundled igneumd, the packaged public RPC. 13 unit tests. Hosts and packaging: app/mac/IgneumWallet.swift, app/windows/wallet-host.* (untested), packaging/mac/build-wallet-dmg.sh, packaging/windows/Igneum-Wallet.iss, publish-manifest.sh --product wallet (miner path unchanged). tools/wallet-testnet/run.mjs and the bench-log entry: on igneum-devnet-958 a transfer went pending, in a block and final under checkpoint 5, 170.6 s after sending, the certificate verified by the wallet. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 4 + app/igneum-common/Cargo.lock | 490 ++ app/igneum-common/Cargo.toml | 19 + app/igneum-common/src/config.rs | 89 + app/igneum-common/src/fetch.rs | 71 + app/igneum-common/src/http.rs | 201 + app/igneum-common/src/keys.rs | 109 + app/igneum-common/src/lib.rs | 37 + app/igneum-common/src/manifest.rs | 527 +++ app/igneum-common/src/platform.rs | 480 ++ app/igneum-common/src/run.rs | 40 + app/igneum-wallet/Cargo.lock | 5207 +++++++++++++++++++++ app/igneum-wallet/Cargo.toml | 41 + app/igneum-wallet/src/engine.rs | 946 ++++ app/igneum-wallet/src/evm.rs | 147 + app/igneum-wallet/src/finality.rs | 227 + app/igneum-wallet/src/hd.rs | 99 + app/igneum-wallet/src/history.rs | 137 + app/igneum-wallet/src/main.rs | 118 + app/igneum-wallet/src/node.rs | 223 + app/igneum-wallet/src/qr.rs | 31 + app/igneum-wallet/src/server.rs | 163 + app/igneum-wallet/src/state.rs | 132 + app/igneum-wallet/src/tx.rs | 261 ++ app/igneum-wallet/src/updater.rs | 90 + app/igneum-wallet/src/vault.rs | 153 + app/igneum-wallet/ui/app.css | 403 ++ app/igneum-wallet/ui/app.js | 267 ++ app/igneum-wallet/ui/index.html | 263 ++ app/igneum-wallet/ui/mark.svg | 1 + app/mac/IgneumWallet.swift | 360 ++ app/windows/BUILD-WALLET-APP.bat | 61 + app/windows/wallet-host.cpp | 457 ++ app/windows/wallet-host.rc | 36 + app/windows/wallet-version.h | 7 + docs/bench-log.md | 37 + packaging/mac/app/IgneumWallet-Info.plist | 45 + packaging/mac/build-wallet-dmg.sh | 91 + packaging/mac/dmg/README-wallet.txt | 15 + packaging/mac/dmg/wallet-settings.py | 38 + packaging/mac/packaged-config.sh | 27 + packaging/ota/publish-manifest.sh | 28 +- packaging/windows/Igneum-Wallet.iss | 87 + tools/wallet-testnet/run.mjs | 178 + 44 files changed, 12431 insertions(+), 12 deletions(-) create mode 100644 app/igneum-common/Cargo.lock create mode 100644 app/igneum-common/Cargo.toml create mode 100644 app/igneum-common/src/config.rs create mode 100644 app/igneum-common/src/fetch.rs create mode 100644 app/igneum-common/src/http.rs create mode 100644 app/igneum-common/src/keys.rs create mode 100644 app/igneum-common/src/lib.rs create mode 100644 app/igneum-common/src/manifest.rs create mode 100644 app/igneum-common/src/platform.rs create mode 100644 app/igneum-common/src/run.rs create mode 100644 app/igneum-wallet/Cargo.lock create mode 100644 app/igneum-wallet/Cargo.toml create mode 100644 app/igneum-wallet/src/engine.rs create mode 100644 app/igneum-wallet/src/evm.rs create mode 100644 app/igneum-wallet/src/finality.rs create mode 100644 app/igneum-wallet/src/hd.rs create mode 100644 app/igneum-wallet/src/history.rs create mode 100644 app/igneum-wallet/src/main.rs create mode 100644 app/igneum-wallet/src/node.rs create mode 100644 app/igneum-wallet/src/qr.rs create mode 100644 app/igneum-wallet/src/server.rs create mode 100644 app/igneum-wallet/src/state.rs create mode 100644 app/igneum-wallet/src/tx.rs create mode 100644 app/igneum-wallet/src/updater.rs create mode 100644 app/igneum-wallet/src/vault.rs create mode 100644 app/igneum-wallet/ui/app.css create mode 100644 app/igneum-wallet/ui/app.js create mode 100644 app/igneum-wallet/ui/index.html create mode 100644 app/igneum-wallet/ui/mark.svg create mode 100644 app/mac/IgneumWallet.swift create mode 100644 app/windows/BUILD-WALLET-APP.bat create mode 100644 app/windows/wallet-host.cpp create mode 100644 app/windows/wallet-host.rc create mode 100644 app/windows/wallet-version.h create mode 100644 packaging/mac/app/IgneumWallet-Info.plist create mode 100755 packaging/mac/build-wallet-dmg.sh create mode 100644 packaging/mac/dmg/README-wallet.txt create mode 100644 packaging/mac/dmg/wallet-settings.py create mode 100644 packaging/windows/Igneum-Wallet.iss create mode 100644 tools/wallet-testnet/run.mjs diff --git a/.gitignore b/.gitignore index 4fa1a60f4..9b9220427 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ proto-opencl/igneum-bench-cl-generic-test* proto-opencl/soak-*.log proto-opencl/hardened-check*.log vendor/igneum-node-ship/ +# the wallet and the common crate (4 October 2026) +app/igneum-wallet/target/ +app/igneum-common/target/ +packaging/mac/build-wallet/ diff --git a/app/igneum-common/Cargo.lock b/app/igneum-common/Cargo.lock new file mode 100644 index 000000000..70fa4a261 --- /dev/null +++ b/app/igneum-common/Cargo.lock @@ -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" diff --git a/app/igneum-common/Cargo.toml b/app/igneum-common/Cargo.toml new file mode 100644 index 000000000..1424838b6 --- /dev/null +++ b/app/igneum-common/Cargo.toml @@ -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" diff --git a/app/igneum-common/src/config.rs b/app/igneum-common/src/config.rs new file mode 100644 index 000000000..744b1398b --- /dev/null +++ b/app/igneum-common/src/config.rs @@ -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, + /// 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::(&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 { + 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()); + } +} diff --git a/app/igneum-common/src/fetch.rs b/app/igneum-common/src/fetch.rs new file mode 100644 index 000000000..70e7a0f6d --- /dev/null +++ b/app/igneum-common/src/fetch.rs @@ -0,0 +1,71 @@ +//! 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 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(); + if code.starts_with("200") { + 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 `` and `.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 { + 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) +} + +pub fn file_name(url: &str) -> String { + url.rsplit('/').next().unwrap_or("download").split('?').next().unwrap_or("download").to_string() +} + +/// Downloads the platform entry into `dir` (skipped when a file with the right size and sha256 is there already) +/// and checks size and sha256. Returns the path. +pub fn download(e: &PlatformEntry, dir: &Path) -> Result { + 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 tmp = dir.join(format!("{}.part", file_name(&e.url))); + curl_get(&e.url, &tmp, Duration::from_secs(1800))?; + let size = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0); + if size != e.size { + let _ = std::fs::remove_file(&tmp); + return Err(format!("the download is {size} bytes, the manifest says {}", e.size)); + } + let sum = manifest::sha256_file(&tmp).map_err(|e| e.to_string())?; + if sum != e.sha256 { + let _ = std::fs::remove_file(&tmp); + return Err("the download's sha256 does not match the manifest".into()); + } + std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?; + Ok(dest) +} diff --git a/app/igneum-common/src/http.rs b/app/igneum-common/src/http.rs new file mode 100644 index 000000000..a5df92efb --- /dev/null +++ b/app/igneum-common/src/http.rs @@ -0,0 +1,201 @@ +//! 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//`, 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, + pub origin: Option, + pub sec_fetch_site: Option, + pub host: Option, +} + +pub fn read_request(stream: &mut TcpStream) -> Option { + 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) = (None, None, None); + 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()); + } + } + } + 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 }) +} + +/// 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 { + 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(handle: F) -> std::io::Result +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) +} + +/// 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:` (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 { + 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 = 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::())); + } + Ok(text) +} diff --git a/app/igneum-common/src/keys.rs b/app/igneum-common/src/keys.rs new file mode 100644 index 000000000..c0047c6a4 --- /dev/null +++ b/app/igneum-common/src/keys.rs @@ -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> { + 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 { + 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 { + 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"); + } +} diff --git a/app/igneum-common/src/lib.rs b/app/igneum-common/src/lib.rs new file mode 100644 index 000000000..4a9683860 --- /dev/null +++ b/app/igneum-common/src/lib.rs @@ -0,0 +1,37 @@ +//! 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 and its sha256 check (through curl, like the miner) +//! - `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 + +pub mod config; +pub mod fetch; +pub mod http; +pub mod keys; +pub mod manifest; +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, +} + +pub const MINER: AppId = AppId { name: "Igneum Miner", bundle: "network.igneum.miner", host_exe: "Igneum Miner.exe", data_sub: "app" }; +pub const WALLET: AppId = AppId { name: "Igneum Wallet", bundle: "network.igneum.wallet", host_exe: "Igneum Wallet.exe", data_sub: "wallet" }; diff --git a/app/igneum-common/src/manifest.rs b/app/igneum-common/src/manifest.rs new file mode 100644 index 000000000..e879657fa --- /dev/null +++ b/app/igneum-common/src/manifest.rs @@ -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, + pub windows: Option, + pub min_supported_version: String, + pub notes: String, + pub activation_height: Option, + pub deadline_note: String, + /// consensus.override: the exact object the engine writes to /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, +} + +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> { + 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 { + 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 { + 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, 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 { + 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 { + fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec) -> 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 { + 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, +} + +pub fn parse_version(s: &str) -> Option { + 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 { + 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, + /// 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, 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"").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"), ""); + } +} diff --git a/app/igneum-common/src/platform.rs b/app/igneum-common/src/platform.rs new file mode 100644 index 000000000..b98962993 --- /dev/null +++ b/app/igneum-common/src/platform.rs @@ -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//" (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(""); + 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-, nvidia-). +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, +} + +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 { + 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 { + 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!(" {}\n", a.replace('&', "&").replace('<', "<"))) + .collect(); + let plist = format!( + "\n\n\n\n Label\n {}\n ProgramArguments\n \n{args} \n RunAtLoad\n \n\n\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::>().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 { + #[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// (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"); + } +} diff --git a/app/igneum-common/src/run.rs b/app/igneum-common/src/run.rs new file mode 100644 index 000000000..7982f9f95 --- /dev/null +++ b/app/igneum-common/src/run.rs @@ -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 { + 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}") }) +} diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock new file mode 100644 index 000000000..8e799b773 --- /dev/null +++ b/app/igneum-wallet/Cargo.lock @@ -0,0 +1,5207 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "ark-bn254" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bc66f96ebe2a17a499475b4f94791d379817592ef494171586967ffdc6f95db" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-crypto-primitives" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b3409b1846fe459d19c95df039481575ac6d5842ae63858ad75cc31219bfc1" +dependencies = [ + "ahash", + "ark-crypto-primitives-macros", + "ark-ec", + "ark-ff", + "ark-r1cs-std", + "ark-relations", + "ark-serialize", + "ark-snark", + "ark-std", + "blake2", + "blake3", + "derivative", + "digest", + "fnv", + "merlin", + "num-bigint", + "rayon", + "sha2", +] + +[[package]] +name = "ark-crypto-primitives-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8352a2b2aedf6ba2cc38f7520fc51191d518dde96175c729af19f2d059f191c4" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.17.1", + "itertools 0.14.0", + "num-bigint", + "num-integer", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "digest", + "educe", + "num-bigint", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-groth16" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a293328aa422e65527e285614ce5d1dceb0bd7b8b18d18b1b63191ee1f74cb41" +dependencies = [ + "ark-crypto-primitives", + "ark-ec", + "ark-ff", + "ark-poly", + "ark-relations", + "ark-serialize", + "ark-snark", + "ark-std", + "rayon", +] + +[[package]] +name = "ark-poly" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f55af10b672002b8d953e230282c51206842e20e5791a94432219b4201de5c" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.17.1", + "rayon", +] + +[[package]] +name = "ark-r1cs-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291f1c6628bfcac79b0dc2adbe401aa9100e2e96daa971645e0b18fc94de9a98" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-relations", + "ark-std", + "educe", + "itertools 0.14.0", + "num-bigint", + "num-integer", + "num-traits", + "tracing", +] + +[[package]] +name = "ark-relations" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4c11c797a64b8a23e22bf4e77bf582ac27bb21395e3183a9a506ba2561e9f9" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "foldhash 0.1.5", + "indexmap 2.14.2", + "rayon", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest", + "num-bigint", + "rayon", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-snark" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bdb461d2be9b2bd6f303c79fffc89f5858790a7b4d33257bca3178e2c071fb9" +dependencies = [ + "ark-ff", + "ark-relations", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.8", + "rayon", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bip32" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db40d3dfbeab4e031d78c844642fa0caa0b0db11ce1607ac9d2986dff1405c69" +dependencies = [ + "bs58", + "hmac", + "k256", + "once_cell", + "pbkdf2", + "rand_core 0.6.4", + "ripemd", + "secp256k1 0.27.0", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.8", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[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 = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blst" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "sha2", + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "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 = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[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 = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "destructure_traitobject" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "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 = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elf" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b" + +[[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 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "faster-hex" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183" +dependencies = [ + "serde", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hexplay" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da1f4f846e8dcc1b5225caf702924816cabd855e4b46115c334ba09d5254a21" +dependencies = [ + "atty", + "termcolor", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "igneum-common" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "getrandom 0.2.17", + "k256", + "libc", + "serde", + "serde_json", + "sha2", + "sha3", +] + +[[package]] +name = "igneum-wallet" +version = "0.1.0" +dependencies = [ + "argon2", + "bip32", + "bip39", + "chacha20poly1305", + "getrandom 0.2.17", + "igneum-common", + "k256", + "kaspa-consensus-core", + "kaspa-grpc-client", + "kaspa-hashes", + "kaspa-rpc-core", + "libc", + "qrcodegen", + "serde", + "serde_json", + "sha2", + "sha3", + "tokio", + "zeroize", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[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", + "sha2", + "signature", +] + +[[package]] +name = "kaspa-addresses" +version = "2.1.0" +dependencies = [ + "borsh", + "js-sys", + "serde", + "smallvec", + "thiserror 2.0.21", + "wasm-bindgen", + "workflow-log", + "workflow-wasm", +] + +[[package]] +name = "kaspa-consensus-client" +version = "2.1.0" +dependencies = [ + "ahash", + "borsh", + "cfg-if", + "faster-hex", + "hex", + "itertools 0.13.0", + "js-sys", + "kaspa-addresses", + "kaspa-consensus-core", + "kaspa-hashes", + "kaspa-math", + "kaspa-txscript", + "kaspa-utils", + "kaspa-wasm-core", + "rand 0.8.8", + "secp256k1 0.29.1", + "serde", + "serde-wasm-bindgen", + "serde_json", + "thiserror 2.0.21", + "wasm-bindgen", + "workflow-log", + "workflow-wasm", +] + +[[package]] +name = "kaspa-consensus-core" +version = "2.1.0" +dependencies = [ + "arc-swap", + "async-trait", + "bitflags 2.13.2", + "blst", + "borsh", + "cfg-if", + "faster-hex", + "futures-util", + "getrandom 0.2.17", + "itertools 0.13.0", + "js-sys", + "kaspa-addresses", + "kaspa-core", + "kaspa-hashes", + "kaspa-math", + "kaspa-merkle", + "kaspa-muhash", + "kaspa-smt", + "kaspa-txscript-errors", + "kaspa-utils", + "rand 0.8.8", + "secp256k1 0.29.1", + "serde", + "serde-value", + "serde-wasm-bindgen", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.21", + "wasm-bindgen", + "workflow-core", + "workflow-log", + "workflow-serializer", + "workflow-wasm", +] + +[[package]] +name = "kaspa-consensus-notify" +version = "2.1.0" +dependencies = [ + "async-channel 2.5.0", + "cfg-if", + "derive_more 0.99.20", + "futures", + "kaspa-consensus-core", + "kaspa-core", + "kaspa-hashes", + "kaspa-notify", + "kaspa-utils", + "log", + "paste", + "thiserror 2.0.21", + "triggered", +] + +[[package]] +name = "kaspa-consensus-wasm" +version = "2.1.0" +dependencies = [ + "cfg-if", + "faster-hex", + "js-sys", + "kaspa-addresses", + "kaspa-consensus-client", + "kaspa-consensus-core", + "kaspa-hashes", + "kaspa-txscript", + "kaspa-utils", + "rand 0.8.8", + "secp256k1 0.29.1", + "serde", + "serde-wasm-bindgen", + "serde_json", + "thiserror 2.0.21", + "wasm-bindgen", + "workflow-log", + "workflow-wasm", +] + +[[package]] +name = "kaspa-core" +version = "2.1.0" +dependencies = [ + "anyhow", + "cfg-if", + "ctrlc", + "downcast", + "futures-util", + "log", + "log4rs", + "num_cpus", + "thiserror 2.0.21", + "tokio", + "triggered", + "wasm-bindgen", + "workflow-log", +] + +[[package]] +name = "kaspa-grpc-client" +version = "2.1.0" +dependencies = [ + "async-channel 2.5.0", + "async-stream", + "async-trait", + "faster-hex", + "futures", + "futures-util", + "h2", + "itertools 0.13.0", + "kaspa-core", + "kaspa-grpc-core", + "kaspa-notify", + "kaspa-rpc-core", + "kaspa-utils", + "kaspa-utils-tower", + "log", + "parking_lot", + "paste", + "prost", + "rand 0.8.8", + "regex", + "rustls", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tonic", + "triggered", +] + +[[package]] +name = "kaspa-grpc-core" +version = "2.1.0" +dependencies = [ + "async-channel 2.5.0", + "async-stream", + "async-trait", + "faster-hex", + "futures", + "h2", + "kaspa-addresses", + "kaspa-consensus-core", + "kaspa-core", + "kaspa-notify", + "kaspa-rpc-core", + "kaspa-utils", + "log", + "paste", + "prost", + "rand 0.8.8", + "regex", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "triggered", + "workflow-core", +] + +[[package]] +name = "kaspa-hashes" +version = "2.1.0" +dependencies = [ + "blake2b_simd", + "blake3", + "borsh", + "cc", + "faster-hex", + "js-sys", + "kaspa-utils", + "keccak", + "serde", + "sha2", + "sha2-const-stable", + "wasm-bindgen", + "workflow-wasm", + "zerocopy", +] + +[[package]] +name = "kaspa-index-core" +version = "2.1.0" +dependencies = [ + "async-channel 2.5.0", + "async-trait", + "derive_more 0.99.20", + "futures", + "kaspa-consensus-core", + "kaspa-hashes", + "kaspa-notify", + "kaspa-utils", + "log", + "paste", + "serde", + "thiserror 2.0.21", + "triggered", +] + +[[package]] +name = "kaspa-math" +version = "2.1.0" +dependencies = [ + "borsh", + "faster-hex", + "js-sys", + "kaspa-utils", + "malachite-base", + "malachite-nz", + "serde", + "serde-wasm-bindgen", + "thiserror 2.0.21", + "wasm-bindgen", + "workflow-core", + "workflow-log", + "workflow-wasm", +] + +[[package]] +name = "kaspa-merkle" +version = "2.1.0" +dependencies = [ + "kaspa-hashes", +] + +[[package]] +name = "kaspa-mining-errors" +version = "2.1.0" +dependencies = [ + "kaspa-consensus-core", + "thiserror 2.0.21", +] + +[[package]] +name = "kaspa-muhash" +version = "2.1.0" +dependencies = [ + "kaspa-hashes", + "kaspa-math", + "rand_chacha 0.3.1", + "serde", +] + +[[package]] +name = "kaspa-notify" +version = "2.1.0" +dependencies = [ + "async-channel 2.5.0", + "async-trait", + "borsh", + "derive_more 0.99.20", + "futures", + "futures-util", + "indexmap 2.14.2", + "itertools 0.13.0", + "kaspa-addresses", + "kaspa-consensus-core", + "kaspa-core", + "kaspa-hashes", + "kaspa-txscript", + "kaspa-txscript-errors", + "kaspa-utils", + "log", + "parking_lot", + "paste", + "rand 0.8.8", + "serde", + "thiserror 2.0.21", + "triggered", + "workflow-core", + "workflow-log", + "workflow-serializer", +] + +[[package]] +name = "kaspa-rpc-core" +version = "2.1.0" +dependencies = [ + "async-channel 2.5.0", + "async-trait", + "borsh", + "cfg-if", + "derive_more 0.99.20", + "downcast", + "faster-hex", + "hex", + "js-sys", + "kaspa-addresses", + "kaspa-consensus-client", + "kaspa-consensus-core", + "kaspa-consensus-notify", + "kaspa-consensus-wasm", + "kaspa-core", + "kaspa-hashes", + "kaspa-index-core", + "kaspa-math", + "kaspa-mining-errors", + "kaspa-notify", + "kaspa-rpc-macros", + "kaspa-txscript", + "kaspa-utils", + "log", + "paste", + "rand 0.8.8", + "serde", + "serde-wasm-bindgen", + "smallvec", + "thiserror 2.0.21", + "uuid", + "wasm-bindgen", + "workflow-core", + "workflow-serializer", + "workflow-wasm", +] + +[[package]] +name = "kaspa-rpc-macros" +version = "2.1.0" +dependencies = [ + "convert_case 0.6.0", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", +] + +[[package]] +name = "kaspa-smt" +version = "2.1.0" +dependencies = [ + "blake3", + "kaspa-hashes", + "thiserror 2.0.21", + "zerocopy", +] + +[[package]] +name = "kaspa-txscript" +version = "2.1.0" +dependencies = [ + "ark-bn254", + "ark-ec", + "ark-groth16", + "ark-relations", + "ark-serialize", + "ark-snark", + "blake2b_simd", + "blake3", + "borsh", + "cfg-if", + "faster-hex", + "hexplay", + "indexmap 2.14.2", + "itertools 0.13.0", + "kaspa-addresses", + "kaspa-consensus-core", + "kaspa-hashes", + "kaspa-txscript-errors", + "kaspa-utils", + "log", + "parking_lot", + "rand 0.8.8", + "risc0-binfmt", + "risc0-circuit-recursion", + "risc0-core", + "risc0-zkp", + "risc0-zkvm-platform", + "secp256k1 0.29.1", + "serde", + "serde-wasm-bindgen", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.21", + "wasm-bindgen", + "workflow-wasm", +] + +[[package]] +name = "kaspa-txscript-errors" +version = "2.1.0" +dependencies = [ + "borsh", + "kaspa-hashes", + "secp256k1 0.29.1", + "thiserror 2.0.21", +] + +[[package]] +name = "kaspa-utils" +version = "2.1.0" +dependencies = [ + "async-channel 2.5.0", + "borsh", + "cfg-if", + "faster-hex", + "ipnet", + "itertools 0.13.0", + "log", + "once_cell", + "parking_lot", + "serde", + "sha2", + "smallvec", + "thiserror 2.0.21", + "triggered", + "uuid", + "wasm-bindgen", +] + +[[package]] +name = "kaspa-utils-tower" +version = "2.1.0" +dependencies = [ + "bytes", + "cfg-if", + "futures", + "http-body", + "http-body-util", + "log", + "pin-project-lite", + "tokio", + "tower", + "tower-http", +] + +[[package]] +name = "kaspa-wasm-core" +version = "2.1.0" +dependencies = [ + "faster-hex", + "hexplay", + "js-sys", + "wasm-bindgen", + "workflow-wasm", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "log-mdc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" + +[[package]] +name = "log4rs" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e947bb896e702c711fccc2bf02ab2abb6072910693818d1d6b07ee2b9dfd86c" +dependencies = [ + "anyhow", + "arc-swap", + "chrono", + "derive_more 2.1.1", + "flate2", + "fnv", + "humantime", + "libc", + "log", + "log-mdc", + "mock_instant", + "parking_lot", + "rand 0.9.5", + "serde", + "serde-value", + "serde_json", + "serde_yaml", + "thiserror 2.0.21", + "thread-id", + "typemap-ors", + "unicode-segmentation", + "winapi", +] + +[[package]] +name = "malachite-base" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f44099731f17094b07825c88ccb5fbd1bfa1f82fafff7daa33e8b8652db16e" +dependencies = [ + "hashbrown 0.16.1", + "itertools 0.14.0", + "libm", + "ryu", +] + +[[package]] +name = "malachite-nz" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a137660cdba20f136c8a223125f08088adb4e0b72fbb8466f08c43e31cc0427d" +dependencies = [ + "itertools 0.14.0", + "libm", + "malachite-base", + "wide", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.2", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mock_instant" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399" + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.3", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-variants" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b4d1bb0b90012ce8056bd6bb5168d8de026d633dd592a732da5a25d3f6c74c" +dependencies = [ + "parse-variants-derive", +] + +[[package]] +name = "parse-variants-derive" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d80829147ec6f1b27109c7daaea62fc3a21a0348cdddf22b4093f0c35ab25a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[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 = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.13.2", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "unarray", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest", +] + +[[package]] +name = "risc0-binfmt" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1883f0c5d19b865f395209a137dcb29e56dc49951424967b8d0114c129f46e77" +dependencies = [ + "anyhow", + "borsh", + "bytemuck", + "derive_more 2.1.1", + "elf", + "lazy_static", + "postcard", + "risc0-zkp", + "risc0-zkvm-platform", + "ruint", + "semver", + "serde", + "tracing", +] + +[[package]] +name = "risc0-circuit-recursion" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2347e909c6b2a65584b5898f3802eec5b8c1b4b45329edfdd8587b6a04dd3357" +dependencies = [ + "anyhow", + "bytemuck", + "hex", + "metal", + "risc0-core", + "risc0-zkp", + "tracing", +] + +[[package]] +name = "risc0-core" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b956a976b8ce4713694dcc6c370b522a42ccef4ba45da5b6e57dbf26cdb7b1" +dependencies = [ + "bytemuck", + "rand_core 0.9.5", +] + +[[package]] +name = "risc0-zkp" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f40d362a6c146ec6dc69208f539b92fd86e47b0dbc2083801423034a38155a2" +dependencies = [ + "anyhow", + "blake2", + "borsh", + "bytemuck", + "cfg-if", + "digest", + "hex", + "hex-literal", + "metal", + "paste", + "rand_core 0.9.5", + "risc0-core", + "risc0-zkvm-platform", + "serde", + "sha2", + "stability", + "tracing", +] + +[[package]] +name = "risc0-zkvm-platform" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4db893788c416287e2e1a87e6b8f5302511a04a45329e699d6a32a16874fd24f" +dependencies = [ + "cfg-if", + "num_enum", + "paste", + "stability", +] + +[[package]] +name = "rlimit" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7043b63bd0cd1aaa628e476b80e6d4023a3b50eb32789f2728908107bd0c793a" +dependencies = [ + "libc", +] + +[[package]] +name = "ruint" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2973657b5127d510e230f5c63d2d106af9c8f79393d8b9f4647323e8196bdde5" +dependencies = [ + "borsh", + "proptest", + "rand 0.8.8", + "rand 0.9.5", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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 = "secp256k1" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25996b82292a7a57ed3508f052cfff8640d38d32018784acd714758b43da9c8f" +dependencies = [ + "secp256k1-sys 0.8.2", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.8", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4473013577ec77b4ee3668179ef1186df3146e2cf2d927bd200974c6fe60fd99" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[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 = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "time", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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 = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread-id" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2010d27add3f3240c1fef7959f46c814487b216baee662af53be645ba7831c07" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "flate2", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.2", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "triggered" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "593eddbc8a11f3e099e942c8c065fe376b9d1776741430888f2796682e08ab43" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typemap-ors" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68c24b707f02dd18f1e4ccceb9d49f2058c2fb86384ef9972592904d7a28867" +dependencies = [ + "unsafe-any-ors", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-any-ors" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a303d30665362d9680d7d91d78b23f5f899504d4f08b3c4cf08d055d87c0ad" +dependencies = [ + "destructure_traitobject", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "uuid" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "rand 0.10.3", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" + +[[package]] +name = "vergen" +version = "8.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990d9ea5967266ea0ccf413a4aa5c42a93dbcfda9cb49a97de6931726b12566" +dependencies = [ + "anyhow", + "cargo_metadata", + "cfg-if", + "regex", + "rustc_version", + "rustversion", + "time", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wide" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "workflow-core" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d67bbe225ea90aa6979167f28935275506696ac867661e218893d3a42e1666" +dependencies = [ + "async-channel 2.5.0", + "async-std", + "borsh", + "bs58", + "cfg-if", + "chrono", + "dirs", + "faster-hex", + "futures", + "getrandom 0.2.17", + "instant", + "js-sys", + "rand 0.8.8", + "rlimit", + "serde", + "serde-wasm-bindgen", + "thiserror 1.0.69", + "tokio", + "triggered", + "vergen", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "workflow-core-macros", + "workflow-log", +] + +[[package]] +name = "workflow-core-macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65659ed208b0066a9344142218abda353eb6c6cc1fc3ae4808b750c560de004b" +dependencies = [ + "convert_case 0.6.0", + "parse-variants", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "sha2", + "syn 1.0.109", + "workflow-macro-tools", +] + +[[package]] +name = "workflow-log" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bf52c539193f219b7a79eb0c7c5f6c222ccf9b95c5e0bd59e924feb762256f" +dependencies = [ + "cfg-if", + "console", + "downcast", + "hexplay", + "lazy_static", + "log", + "termcolor", + "wasm-bindgen", +] + +[[package]] +name = "workflow-macro-tools" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085d3045d5ca780fb589d230030e34fec962b3638d6c69806a72a7d7d1affea4" +dependencies = [ + "convert_case 0.6.0", + "parse-variants", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "workflow-panic-hook" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c76ca8b459e4f0c949f06ce2d45565a6769748e83ca7064d36671bbd67b4da" +dependencies = [ + "cfg-if", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "workflow-serializer" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64679db6856852a472caff4ce869e3ecebe291fbccc9406e9643eb5951a0904a" +dependencies = [ + "ahash", + "borsh", + "serde", +] + +[[package]] +name = "workflow-wasm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799e5fbf266e0fffb5c24d6103735eb2b94bb31f93b664b91eaaf63b4f959804" +dependencies = [ + "cfg-if", + "faster-hex", + "futures", + "js-sys", + "serde", + "serde-wasm-bindgen", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "workflow-core", + "workflow-log", + "workflow-panic-hook", + "workflow-wasm-macros", +] + +[[package]] +name = "workflow-wasm-macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40237c65ecff78dbfedb13985e33f802a31f6f7de72dff12a6674fcdcf601822" +dependencies = [ + "js-sys", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", + "wasm-bindgen", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml new file mode 100644 index 000000000..fe73bb7a7 --- /dev/null +++ b/app/igneum-wallet/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "igneum-wallet" +version = "0.1.0" +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 diff --git a/app/igneum-wallet/src/engine.rs b/app/igneum-wallet/src/engine.rs new file mode 100644 index 000000000..a19d4873f --- /dev/null +++ b/app/igneum-wallet/src/engine.rs @@ -0,0 +1,946 @@ +//! The wallet engine: the vault and the unlocked key (engine memory only), the node source, the balance and history +//! polling, the finality checks, the updater, the send path. One thread (`Engine::run`) plus the server threads; +//! everything the window reads is `Shared.state`. + +use crate::finality::{Status, VerifiedCheckpoint}; +use crate::history::{Entry, History}; +use crate::node::{Grpc, OwnNode, Source}; +use crate::state::{Rings, State}; +use crate::vault::{self, Secret}; +use igneum_common::config::Packaged; +use igneum_common::keys; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use zeroize::Zeroize; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const APP: igneum_common::AppId = igneum_common::WALLET; + +#[derive(Clone, Serialize, Deserialize)] +pub struct Settings { + #[serde(default = "yes")] + pub auto_update: bool, + /// the last block the window scrolled to; display only + #[serde(default)] + pub display_name: String, +} +fn yes() -> bool { + true +} +impl Default for Settings { + fn default() -> Settings { + Settings { auto_update: true, display_name: String::new() } + } +} +impl Settings { + pub fn load(p: &std::path::Path) -> Settings { + std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() + } +} + +pub struct Paths { + pub app_dir: PathBuf, + pub log_dir: PathBuf, + pub vault: PathBuf, + pub settings: PathBuf, + pub miner_wallet: PathBuf, +} + +pub enum Cmd { + Quit, + Refresh, + CheckUpdate, + OpenUpdate, + /// a transaction this wallet just sent: watch it from the first tick + Sent(Entry), +} + +/// A fee quote the window confirms before `send`. +#[derive(Clone, Serialize, Deserialize)] +pub struct Quote { + pub to: String, + pub value: String, + pub gas: u64, + pub base_fee: String, + pub tip: String, + pub max_fee: String, + pub fee_max: String, + pub total_max: String, + pub chain_id: u64, + pub nonce: u64, +} + +pub struct Shared { + pub token: String, + pub state: Mutex, + pub rings: Mutex, + #[allow(dead_code)] // read by the window through state; kept for the next settings + pub settings: Mutex, + pub paths: Paths, + pub packaged: Packaged, + pub machine_id: String, + cmd_tx: Mutex>, + pub started: Instant, + engine_log: Mutex>, + #[allow(dead_code)] // the log uploader (follow-up) names it + pub log_path: PathBuf, + port: std::sync::atomic::AtomicU16, + /// the unlocked key; None while locked + secret: Mutex>, + /// words shown on the create screen, waiting for the three-word confirmation + pending_words: Mutex>, // (words, password) + pub source: Mutex, + pub evm: Mutex>, + pub verified: Mutex>, + pub history: Mutex>, +} + +impl Shared { + pub fn new(token: String, paths: Paths, packaged: Packaged, settings: Settings, machine_id: String, cmd_tx: Sender, engine_log: Option, log_path: PathBuf) -> Shared { + let mut st = State { version: VERSION.into(), ..Default::default() }; + let v = vault::load(&paths.vault); + st.has_vault = v.is_some(); + st.phase = if v.is_some() { "unlock".into() } else { "welcome".into() }; + if let Some(v) = &v { + st.address = v.address.clone(); + st.display = keys::checksum(&v.address); + st.backed_up = v.backed_up; + st.source = v.source.clone(); + } + st.node.source = "none".into(); + st.node.state = "off".into(); + st.node.message = "looking for a node".into(); + st.finality.message = "no verified checkpoint yet".into(); + st.settings.start_at_login = igneum_common::platform::start_at_login_is_on(APP); + st.settings.network = std::env::var("IGNEUM_WALLET_NETWORK").unwrap_or_else(|_| "devnet".into()); + st.miner_wallet_file = paths.miner_wallet.display().to_string(); + st.miner_wallet_present = paths.miner_wallet.is_file(); + st.add_network_page = packaged.add_network_page.clone(); + st.public_rpc = packaged.public_rpc.clone(); + st.machine_id = machine_id.clone(); + st.host = igneum_common::platform::host_label(); + st.log_dir = paths.log_dir.display().to_string(); + st.update.status = if packaged.update_manifest.is_empty() { "off".into() } else { "unknown".into() }; + Shared { + token, + state: Mutex::new(st), + rings: Mutex::new(Rings::new()), + settings: Mutex::new(settings), + paths, + packaged, + machine_id, + cmd_tx: Mutex::new(cmd_tx), + started: Instant::now(), + engine_log: Mutex::new(engine_log), + log_path, + port: std::sync::atomic::AtomicU16::new(0), + secret: Mutex::new(None), + pending_words: Mutex::new(None), + source: Mutex::new(Source::None), + evm: Mutex::new(None), + verified: Mutex::new(None), + history: Mutex::new(None), + } + } + + /// IGNEUM-WALLET version= machine= platform= node=: the first line of the log, as the miner's + /// IGNEUM-APP header, so the console parses either. + pub fn header(&self) -> String { + let src = self.state.lock().unwrap().node.source.clone(); + format!("IGNEUM-WALLET version={} machine={} platform={} node={}", VERSION, &self.machine_id[..8.min(self.machine_id.len())], igneum_common::manifest::platform_name(), src) + } + pub fn set_port(&self, p: u16) { + self.port.store(p, std::sync::atomic::Ordering::Relaxed); + } + pub fn port(&self) -> u16 { + self.port.load(std::sync::atomic::Ordering::Relaxed) + } + pub fn send(&self, c: Cmd) { + let _ = self.cmd_tx.lock().unwrap().send(c); + } + pub fn log(&self, text: &str) { + let text = &igneum_common::platform::redact(text); + let stamp = igneum_common::platform::unix_now_f(); + if let Some(f) = self.engine_log.lock().unwrap().as_mut() { + let _ = writeln!(f, "{stamp:.0} {text}"); + } + self.rings.lock().unwrap().log("wallet", false, text); + } + pub fn event(&self, kind: &str, text: &str) { + let text = &igneum_common::platform::redact(text); + self.rings.lock().unwrap().event(kind, text); + self.log(&format!("[{kind}] {text}")); + } + pub fn state_json(&self) -> Value { + let mut st = self.state.lock().unwrap().clone(); + st.now = igneum_common::platform::unix_now_f(); + st.uptime_s = self.started.elapsed().as_secs(); + st.events = self.rings.lock().unwrap().events.iter().cloned().collect(); + if let Some(h) = self.history.lock().unwrap().as_ref() { + st.history = h.entries.clone(); + st.scanned_to = h.scanned_to; + } + serde_json::to_value(st).unwrap_or(json!({})) + } + pub fn wrapper_state(&self) -> Value { + let st = self.state.lock().unwrap(); + json!({ "phase": st.phase, "unlocked": st.unlocked, "balance": st.balance, "node": st.node.state, "source": st.node.source, "block": st.node.block, "quitting": st.quitting, "update": st.update.status == "ready" }) + } + pub fn unlocked(&self) -> bool { + self.secret.lock().unwrap().is_some() + } + pub fn address(&self) -> String { + self.state.lock().unwrap().address.clone() + } + + // ---- the vault ------------------------------------------------------------------------------------------ + + fn install(&self, secret: Secret, address: &str, source: &str, password: &str, backed_up: bool) -> Result<(), String> { + let mut v = vault::seal(&secret, password, address, source)?; + v.backed_up = backed_up; + vault::save(&self.paths.vault, &v)?; + *self.secret.lock().unwrap() = Some(secret); + let mut st = self.state.lock().unwrap(); + st.has_vault = true; + st.unlocked = true; + st.backed_up = backed_up; + st.source = source.into(); + st.address = address.to_ascii_lowercase(); + st.display = keys::checksum(address); + st.phase = "home".into(); + st.balance_known = false; + st.balance.clear(); + drop(st); + *self.history.lock().unwrap() = None; + self.event("ok", &format!("wallet ready: {}", keys::checksum(address))); + self.send(Cmd::Refresh); + Ok(()) + } + + /// Create: 24 words for the window, shown once; nothing is written until `create_confirm`. + pub fn create_begin(&self, password: &str) -> Result { + if self.state.lock().unwrap().has_vault { + return Err("this machine already has a wallet; remove it in Settings first".into()); + } + if password.len() < 8 { + return Err("the password needs at least 8 characters".into()); + } + let words = crate::hd::new_words()?; + let d = crate::hd::derive(&words)?; + let list: Vec<&str> = words.split(' ').collect(); + *self.pending_words.lock().unwrap() = Some((words.clone(), password.to_string())); + self.log("new words made; waiting for the three-word check"); + Ok(json!({ "ok": true, "words": list, "address": d.address, "display": keys::checksum(&d.address) })) + } + + /// Confirm: three positions (1-based) with the words typed; on a match the vault is written and unlocked. + pub fn create_confirm(&self, checks: &[(usize, String)]) -> Result { + let pending = self.pending_words.lock().unwrap().clone().ok_or("no words are waiting; start again")?; + let list: Vec<&str> = pending.0.split(' ').collect(); + if checks.len() < 3 { + return Err("three words are checked".into()); + } + for (pos, typed) in checks { + let want = list.get(pos.wrapping_sub(1)).ok_or("bad position")?; + if typed.trim().to_lowercase() != *want { + return Err(format!("word {pos} is not right")); + } + } + let d = crate::hd::derive(&pending.0)?; + let secret = Secret { private_key: d.private_key, mnemonic: Some(pending.0.clone()) }; + self.install(secret, &d.address, "created", &pending.1, true)?; + *self.pending_words.lock().unwrap() = None; + Ok(json!({ "ok": true, "address": d.address, "display": keys::checksum(&d.address) })) + } + + /// Import: words, a raw key, or the miner's wallet.json next door. + pub fn import(&self, mode: &str, data: &str, password: &str) -> Result { + if self.state.lock().unwrap().has_vault { + return Err("this machine already has a wallet; remove it in Settings first".into()); + } + let (secret, address, source) = match mode { + "seed" => { + let words = crate::hd::parse_words(data)?; + let d = crate::hd::derive(&words)?; + (Secret { private_key: d.private_key, mnemonic: Some(words) }, d.address, "seed") + } + "key" => { + let d = crate::hd::parse_private_key(data)?; + (Secret { private_key: d.private_key, mnemonic: None }, d.address, "key") + } + "miner" => { + let w = keys::load(&self.paths.miner_wallet).ok_or("no miner wallet file on this machine (the miner's address was pasted, or the miner is not installed)")?; + let d = crate::hd::parse_private_key(&w.private_key)?; + if !d.address.eq_ignore_ascii_case(&w.address) { + return Err("the miner's wallet file does not match its own address".into()); + } + (Secret { private_key: d.private_key, mnemonic: None }, d.address, "miner") + } + _ => return Err("mode is seed, key or miner".into()), + }; + self.install(secret, &address, source, password, true)?; + self.log(&format!("imported from {source}")); + Ok(json!({ "ok": true, "address": address, "display": keys::checksum(&address), "source": source })) + } + + pub fn unlock(&self, password: &str) -> Result { + let v = vault::load(&self.paths.vault).ok_or("no wallet on this machine")?; + let s = vault::open(&v, password)?; + let d = crate::hd::parse_private_key(&s.private_key)?; + if !d.address.eq_ignore_ascii_case(&v.address) { + return Err("the vault's key does not match its address".into()); + } + *self.secret.lock().unwrap() = Some(s); + { + let mut st = self.state.lock().unwrap(); + st.unlocked = true; + st.phase = "home".into(); + } + self.log("unlocked"); + self.send(Cmd::Refresh); + Ok(json!({ "ok": true })) + } + + pub fn lock(&self) { + if let Some(mut s) = self.secret.lock().unwrap().take() { + s.zeroize(); + } + let mut st = self.state.lock().unwrap(); + st.unlocked = false; + if st.has_vault { + st.phase = "unlock".into(); + } + drop(st); + self.log("locked"); + } + + /// The backup sheet: the words (or the key) once more, against the password. + pub fn reveal(&self, password: &str) -> Result { + let v = vault::load(&self.paths.vault).ok_or("no wallet")?; + let s = vault::open(&v, password)?; + self.log("the backup was shown in the window"); + Ok(json!({ "ok": true, "words": s.mnemonic.as_ref().map(|w| w.split(' ').collect::>()), "private_key": s.private_key, "address": v.address, "display": keys::checksum(&v.address) })) + } + + pub fn mark_backed_up(&self) -> Result { + let mut v = vault::load(&self.paths.vault).ok_or("no wallet")?; + v.backed_up = true; + vault::save(&self.paths.vault, &v)?; + self.state.lock().unwrap().backed_up = true; + Ok(json!({ "ok": true })) + } + + pub fn change_password(&self, old: &str, new: &str) -> Result { + let v = vault::load(&self.paths.vault).ok_or("no wallet")?; + let s = vault::open(&v, old)?; + let mut nv = vault::seal(&s, new, &v.address, &v.source)?; + nv.backed_up = v.backed_up; + nv.created = v.created; + vault::save(&self.paths.vault, &nv)?; + self.log("password changed"); + Ok(json!({ "ok": true })) + } + + /// Removes the vault from this machine (the window asks twice and for the password). + pub fn remove(&self, password: &str) -> Result { + let v = vault::load(&self.paths.vault).ok_or("no wallet")?; + vault::open(&v, password)?; + self.lock(); + std::fs::remove_file(&self.paths.vault).map_err(|e| e.to_string())?; + let mut st = self.state.lock().unwrap(); + st.has_vault = false; + st.phase = "welcome".into(); + st.address.clear(); + st.display.clear(); + st.balance.clear(); + st.balance_known = false; + drop(st); + *self.history.lock().unwrap() = None; + self.event("info", "the wallet was removed from this machine"); + Ok(json!({ "ok": true })) + } + + // ---- network parameters, for MetaMask --------------------------------------------------------------------- + + pub fn network_json(&self) -> Value { + let st = self.state.lock().unwrap(); + let rpc = if !self.packaged.public_rpc.is_empty() { self.packaged.public_rpc.clone() } else { st.node.evm.clone() }; + let chain_id = st.node.chain_id; + json!({ + "ok": true, + "chain_id": chain_id, + "chain_id_hex": format!("0x{chain_id:x}"), + "chain_name": if st.settings.network == "devnet" { "Igneum devnet" } else { "Igneum" }, + "rpc_url": rpc, + "symbol": "IGN", + "decimals": 18, + "add_network_page": self.packaged.add_network_page, + "local_rpc": st.node.evm, + "public_rpc": self.packaged.public_rpc, + }) + } + + // ---- send ---------------------------------------------------------------------------------------------------- + + fn evm(&self) -> Result { + self.evm.lock().unwrap().clone().ok_or("no node: the wallet cannot read the chain right now".into()) + } + + pub fn quote(&self, to: &str, amount: &str) -> Result { + if !self.unlocked() { + return Err("unlock first".into()); + } + let to = to.trim().to_ascii_lowercase(); + if !keys::valid_address(&to) { + return Err("an address is 0x followed by 40 hex characters".into()); + } + if to == "0x0000000000000000000000000000000000000000" { + return Err("that is the zero address: nothing sent there can be recovered".into()); + } + let value = crate::evm::parse_ign(amount)?; + if value == 0 { + return Err("the amount is zero".into()); + } + let evm = self.evm()?; + let me = self.address(); + let chain_id = evm.chain_id()?; + let nonce = evm.nonce(&me)?; + let (base, tip) = evm.fees()?; + let gas = evm.estimate_gas(&me, &to, value).unwrap_or(21_000).max(21_000); + let max_fee = base.saturating_mul(2).saturating_add(tip).max(1); + let fee_max = (gas as u128).saturating_mul(max_fee); + let total = value.checked_add(fee_max).ok_or("amount too large")?; + let balance = evm.balance(&me)?; + if total > balance { + return Err(format!("not enough IGN: {} needed with the fee, {} in the wallet", crate::evm::ign(total, 6), crate::evm::ign(balance, 6))); + } + Ok(Quote { to, value: value.to_string(), gas, base_fee: base.to_string(), tip: tip.to_string(), max_fee: max_fee.to_string(), fee_max: fee_max.to_string(), total_max: total.to_string(), chain_id, nonce }) + } + + /// Signs and sends what the window confirmed (the quote it was shown, verbatim). + pub fn send_tx(&self, q: &Quote) -> Result { + let to = q.to.to_ascii_lowercase(); + if !keys::valid_address(&to) || to == "0x0000000000000000000000000000000000000000" { + return Err("refused: bad or zero address".into()); + } + let value: u128 = q.value.parse().map_err(|_| "bad value")?; + let max_fee: u128 = q.max_fee.parse().map_err(|_| "bad fee")?; + let tip: u128 = q.tip.parse().map_err(|_| "bad tip")?; + let evm = self.evm()?; + let me = self.address(); + let chain_id = evm.chain_id()?; + if chain_id != q.chain_id { + return Err("the chain id changed under the quote; ask for a new one".into()); + } + let nonce = evm.nonce(&me)?; + let mut to20 = [0u8; 20]; + to20.copy_from_slice(&keys::unhex(&to).ok_or("address")?); + let t = crate::tx::Transfer { chain_id, nonce, max_priority_fee: tip, max_fee, gas_limit: q.gas, to: to20, value, data: vec![] }; + let signed = { + let guard = self.secret.lock().unwrap(); + let s = guard.as_ref().ok_or("locked")?; + let raw = keys::unhex(&s.private_key).ok_or("key")?; + let mut k = [0u8; 32]; + k.copy_from_slice(&raw); + let r = crate::tx::sign(&t, &k); + k.zeroize(); + r? + }; + // the signed bytes recover to this wallet's address, or nothing leaves + if crate::tx::recover_from(&signed.raw).as_deref() != Some(me.as_str()) { + return Err("refused: the signed transaction does not recover to this wallet".into()); + } + let hash = evm.send_raw(&signed.raw)?; + let ours = crate::tx::hex0x(&signed.hash); + if !hash.eq_ignore_ascii_case(&ours) { + self.log(&format!("the node named {hash} for the transaction this wallet hashed as {ours}")); + } + let e = Entry { hash: hash.clone(), kind: if to == me { "self".into() } else { "sent".into() }, block: 0, block_hash: String::new(), from: me.clone(), to: to.clone(), value: value.to_string(), fee: String::new(), ok: true, time: igneum_common::platform::unix_now(), finality: "pending".into(), checkpoint: None, note: String::new() }; + self.event("ok", &format!("sent {} IGN to {} ({})", crate::evm::ign(value, 6), keys::checksum(&to), &hash[..10])); + self.send(Cmd::Sent(e)); + Ok(json!({ "ok": true, "hash": hash, "nonce": nonce })) + } + + /// One transaction, with its finality line, for the detail screen. + pub fn tx_detail(&self, hash: &str) -> Result { + let entry = self.history.lock().unwrap().as_ref().and_then(|h| h.entries.iter().find(|e| e.hash.eq_ignore_ascii_case(hash)).cloned()); + let evm = self.evm()?; + let tx = evm.tx(hash).unwrap_or(None); + let receipt = evm.receipt(hash).unwrap_or(None); + let status = evm.tx_status(hash).unwrap_or(Value::Null); + let verified = self.verified.lock().unwrap().clone(); + Ok(json!({ "ok": true, "entry": entry, "tx": tx, "receipt": receipt, "node_status": status, "verified": verified })) + } + + pub fn set_start_at_login(&self, on: bool) -> Result { + igneum_common::platform::set_start_at_login(APP, on)?; + self.state.lock().unwrap().settings.start_at_login = on; + Ok(json!({ "ok": true })) + } +} + +// ---- the engine thread ------------------------------------------------------------------------------------------ + +pub struct Engine { + shared: Arc, + rx: Receiver, + wrapper: bool, + grpc: Option, + own: Option, + own_plan: Option, + updater: crate::updater::Updater, + last_source_try: Instant, + last_poll: Instant, + last_finality: Instant, + last_scan: Instant, + last_state_line: Instant, + chain_name: String, + watch: Vec, + scan_done_once: bool, +} + +impl Engine { + pub fn new(shared: Arc, rx: Receiver, wrapper: bool) -> Engine { + let url = std::env::var("IGNEUM_WALLET_UPDATE_MANIFEST").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| shared.packaged.update_manifest.clone()); + let updater = crate::updater::Updater::new(url, VERSION, &shared.paths.app_dir); + let now = Instant::now(); + Engine { shared, rx, wrapper, grpc: None, own: None, own_plan: None, updater, last_source_try: now - Duration::from_secs(60), last_poll: now - Duration::from_secs(60), last_finality: now - Duration::from_secs(60), last_scan: now - Duration::from_secs(60), last_state_line: now, chain_name: String::new(), watch: vec![], scan_done_once: false } + } + + pub fn run(mut self) { + self.shared.log(&self.shared.header()); + loop { + while let Ok(c) = self.rx.try_recv() { + match c { + Cmd::Quit => { + self.quit(); + return; + } + Cmd::Refresh => { + self.last_poll = Instant::now() - Duration::from_secs(60); + self.last_scan = Instant::now() - Duration::from_secs(60); + } + Cmd::CheckUpdate => self.check_update(), + Cmd::OpenUpdate => { + if let Err(e) = self.updater.open_file() { + self.shared.event("error", &format!("could not open the download: {e}")); + } + } + Cmd::Sent(e) => { + if let Some(h) = self.shared.history.lock().unwrap().as_mut() { + h.put(e.clone()); + } + self.watch.push(e); + } + } + } + if self.last_source_try.elapsed() >= Duration::from_secs(10) { + self.last_source_try = Instant::now(); + self.ensure_source(); + } + if self.last_poll.elapsed() >= Duration::from_secs(3) { + self.last_poll = Instant::now(); + self.poll(); + } + if self.last_finality.elapsed() >= Duration::from_secs(5) { + self.last_finality = Instant::now(); + self.finality(); + } + if self.last_scan.elapsed() >= Duration::from_secs(2) { + self.last_scan = Instant::now(); + self.scan(); + } + if self.updater.due() { + self.check_update(); + } + if self.wrapper && self.last_state_line.elapsed() >= Duration::from_secs(2) { + self.last_state_line = Instant::now(); + println!("STATE {}", self.shared.wrapper_state()); + let _ = std::io::stdout().flush(); + } + std::thread::sleep(Duration::from_millis(250)); + } + } + + fn quit(&mut self) { + self.shared.state.lock().unwrap().quitting = true; + self.shared.lock(); + if let Some(g) = self.grpc.take() { + g.disconnect(); + } + if let Some(mut n) = self.own.take() { + self.shared.log("stopping the bundled node"); + n.stop(); + } + self.shared.log("quit"); + if self.wrapper { + println!("EXIT"); + let _ = std::io::stdout().flush(); + } + } + + // ---- the node source -------------------------------------------------------------------------------------- + + fn set_source(&mut self, s: Source) { + let evm = s.evm(); + let mut st = self.shared.state.lock().unwrap(); + st.node.source = s.name().into(); + st.node.evm = evm.as_ref().map(|e| e.endpoint.clone()).unwrap_or_default(); + st.node.grpc = s.grpc_port().map(|p| format!("127.0.0.1:{p}")).unwrap_or_default(); + st.node.state = if s == Source::None { "off".into() } else { "ok".into() }; + st.node.message = match &s { + Source::Miner { .. } => "using the miner app's node on this machine".into(), + Source::External { .. } => "using the node named by the environment".into(), + Source::Public { .. } => "using the public RPC; no node here, so finality cannot be verified".into(), + Source::Own { .. } => "running the bundled node".into(), + Source::None => "no node: install Igneum Miner, or wait for the bundled node".into(), + }; + drop(st); + *self.shared.evm.lock().unwrap() = evm; + *self.shared.source.lock().unwrap() = s; + } + + fn ensure_source(&mut self) { + let current = self.shared.source.lock().unwrap().clone(); + // is the current one still alive? + if current != Source::None { + let alive = match ¤t { + Source::Own { .. } => self.own.as_mut().map(|n| n.alive()).unwrap_or(false), + Source::Public { .. } => true, + s => s.evm().map(|e| e.chain_id().is_ok()).unwrap_or(false), + }; + if alive { + if self.grpc.is_none() { + if let Some(p) = current.grpc_port() { + match Grpc::connect(p) { + Ok(g) => { + self.shared.log(&format!("gRPC connected to {}", g.url)); + self.grpc = Some(g); + } + Err(e) => self.shared.state.lock().unwrap().node.message = format!("Ethereum RPC is up, gRPC not yet: {e}"), + } + } + } + return; + } + self.shared.event("error", &format!("the {} node went away", current.name())); + if let Some(g) = self.grpc.take() { + g.disconnect(); + } + self.set_source(Source::None); + } + // 0. the environment's node (tests) + if let Some(ext) = crate::node::external_from_env() { + let port = match &ext { + Source::External { evm, .. } => *evm, + _ => 0, + }; + if port != 0 && crate::node::evm_alive(port) { + self.shared.log(&format!("using the external node (env) {:?}", ext)); + self.set_source(ext); + return; + } + self.shared.state.lock().unwrap().node.message = "waiting for the external node named by the environment".into(); + self.shared.state.lock().unwrap().node.state = "connecting".into(); + return; + } + // 1. the miner app's node + if crate::node::evm_alive(crate::node::MINER_EVM) { + self.shared.event("ok", "found the miner app's node on this machine; using it"); + self.set_source(Source::Miner { grpc: crate::node::MINER_GRPC, evm: crate::node::MINER_EVM }); + return; + } + // 2. our own node, if it is already up from an earlier start + if self.own.is_some() && crate::node::evm_alive(crate::node::OWN_EVM) { + self.set_source(Source::Own { grpc: crate::node::OWN_GRPC, evm: crate::node::OWN_EVM }); + return; + } + if self.own.as_mut().map(|n| n.alive()).unwrap_or(false) { + self.shared.state.lock().unwrap().node.state = "starting".into(); + self.shared.state.lock().unwrap().node.message = "the bundled node is starting".into(); + return; + } + // 3. the seed's public RPC, when the package names one and the bundled node is not available + let no_node = std::env::var("IGNEUM_WALLET_NO_NODE").map(|v| v == "1").unwrap_or(false); + let plan = if no_node { Err("IGNEUM_WALLET_NO_NODE=1".to_string()) } else { crate::node::plan_own_node(&self.shared.paths.app_dir, &self.shared.paths.log_dir, &self.shared.packaged) }; + match plan { + Ok(p) => { + self.shared.log(&format!("starting the bundled node: {} {}", p.bin.display(), p.args.join(" "))); + match crate::node::start_own_node(&p) { + Ok(n) => { + self.own = Some(n); + self.own_plan = Some(p); + let mut st = self.shared.state.lock().unwrap(); + st.node.state = "starting".into(); + st.node.message = "the bundled node is starting; the chain syncs from the seed".into(); + st.node.source = "own".into(); + } + Err(e) => self.shared.event("error", &format!("{e}")), + } + } + Err(e) => { + if !self.shared.packaged.public_rpc.is_empty() { + let url = self.shared.packaged.public_rpc.clone(); + self.shared.log(&format!("no local node ({e}); using the public RPC {url}")); + self.set_source(Source::Public { url }); + } else { + let mut st = self.shared.state.lock().unwrap(); + st.node.state = "off".into(); + st.node.message = format!("no node: {e}"); + } + } + } + } + + // ---- balance and the chain head ------------------------------------------------------------------------------ + + fn poll(&mut self) { + let Some(evm) = self.shared.evm.lock().unwrap().clone() else { return }; + let address = self.shared.address(); + match evm.chain_id().and_then(|c| evm.block_number().map(|b| (c, b))) { + Ok((chain_id, block)) => { + let mut st = self.shared.state.lock().unwrap(); + st.node.chain_id = chain_id; + st.node.block = block; + st.node.state = "ok".into(); + } + Err(e) => { + let mut st = self.shared.state.lock().unwrap(); + st.node.state = "lost".into(); + st.node.message = e; + return; + } + } + if !address.is_empty() { + match evm.balance(&address) { + Ok(b) => { + let mut st = self.shared.state.lock().unwrap(); + st.balance_wei = b.to_string(); + st.balance = crate::evm::ign(b, 6); + st.balance_known = true; + } + Err(e) => self.shared.state.lock().unwrap().node.message = e, + } + } + if let Some(g) = &self.grpc { + if let Ok(info) = g.dag_info() { + let mut st = self.shared.state.lock().unwrap(); + st.node.synced = info.sink != kaspa_hashes::ZERO_HASH; + if self.chain_name.is_empty() { + self.chain_name = info.network.to_string(); + } + st.node.chain = self.chain_name.clone(); + } + } + } + + // ---- finality ---------------------------------------------------------------------------------------------- + + fn finality(&mut self) { + let outcome: Option> = { + let Some(g) = &self.grpc else { + if self.shared.source.lock().unwrap().grpc_port().is_none() { + self.shared.state.lock().unwrap().finality.message = "no node here: certificates cannot be checked, nothing is shown as final".into(); + } + self.update_entries(); + return; + }; + let cps = match g.checkpoints(30) { + Ok(c) => c, + Err(e) => { + self.shared.state.lock().unwrap().finality.message = format!("getFinalityCheckpoints: {e}"); + return; + } + }; + { + let mut st = self.shared.state.lock().unwrap(); + st.node.finality_active = cps.finality_active; + st.node.latest_locked = cps.latest_locked_index; + st.node.chain = cps.chain_id.clone(); + } + let have = self.shared.verified.lock().unwrap().as_ref().map(|v| v.index).unwrap_or(0); + let newest = cps.checkpoints.iter().filter(|c| c.state == "locked" && c.index > have).max_by_key(|c| c.index).cloned(); + match newest { + None => { + if have == 0 { + self.shared.state.lock().unwrap().finality.message = if cps.latest_locked_index == 0 { "the network has not locked a checkpoint yet".into() } else { "waiting for a certificate to verify".into() }; + } + None + } + Some(cp) => { + let evm = self.shared.evm.lock().unwrap().clone(); + let r = crate::finality::find_certificate(g, &cp, 4).and_then(|(cert, carrier)| { + let w = g.weights()?; + crate::finality::verify(&cps.chain_id, &cp, &cert, &carrier, &w) + }); + Some(match r { + Ok(mut v) => { + // the checkpoint block's height on the execution side + if let Some(evm) = &evm { + if let Ok(Some(b)) = evm.block_by_hash(&format!("0x{}", v.hash)) { + v.chain_number = b.get("number").and_then(crate::evm::q).map(|n| n as u64); + } + } + Ok(v) + } + Err(e) => Err((cp.index, e)), + }) + } + } + }; + match outcome { + Some(Ok(v)) => { + self.shared.log(&format!("checkpoint {} verified: {} of {} voters signed, {:.1}% of total weight, carried by {}, chain height {:?}", v.index, v.signers, v.voters, v.fraction_total * 100.0, &v.carrier[..12.min(v.carrier.len())], v.chain_number)); + let mut st = self.shared.state.lock().unwrap(); + st.finality = crate::state::FinalityView { verified_index: v.index, verified_hash: v.hash.clone(), chain_number: v.chain_number, signers: v.signers, voters: v.voters, fraction_total: v.fraction_total, fraction_active: v.fraction_active, weights_exact: v.weights_at_index == v.index, verified_at: v.verified_at, message: format!("checkpoint {} verified here", v.index) }; + drop(st); + *self.shared.verified.lock().unwrap() = Some(v); + } + Some(Err((index, e))) => { + self.shared.state.lock().unwrap().finality.message = format!("checkpoint {index}: {e}"); + } + None => {} + } + self.update_entries(); + } + + /// Re-labels every non-final entry against the receipt, the chain's current block at that height and the + /// verified checkpoint. Pending entries (just sent) are looked up by hash. + fn update_entries(&mut self) { + let Some(evm) = self.shared.evm.lock().unwrap().clone() else { return }; + let verified = self.shared.verified.lock().unwrap().clone(); + let mut guard = self.shared.history.lock().unwrap(); + let Some(h) = guard.as_mut() else { return }; + let mut changed = false; + for e in h.entries.iter_mut() { + if e.finality == "final" && e.checkpoint.is_some() { + continue; + } + let receipt = if e.kind == "reward" || e.kind == "proving" { + Some((e.block, e.block_hash.clone(), true)) + } else { + match evm.receipt(&e.hash) { + Ok(Some(r)) => { + let n = r.get("blockNumber").and_then(crate::evm::q).unwrap_or(0) as u64; + let bh = r.get("blockHash").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let ok = r.get("status").and_then(crate::evm::q).unwrap_or(1) == 1; + if e.block == 0 { + e.block = n; + e.block_hash = bh.clone(); + let gas = r.get("gasUsed").and_then(crate::evm::q).unwrap_or(0); + let price = r.get("effectiveGasPrice").and_then(crate::evm::q).unwrap_or(0); + e.fee = (gas * price).to_string(); + e.ok = ok; + } + Some((n, bh, ok)) + } + _ => None, + } + }; + let canonical = receipt.as_ref().and_then(|(n, _, _)| evm.block(*n, false).ok().flatten()).and_then(|b| b.get("hash").and_then(|v| v.as_str()).map(|s| s.to_string())); + let s = crate::finality::status(receipt.as_ref().map(|(n, h, ok)| (*n, h.as_str(), *ok)), canonical.as_deref(), verified.as_ref()); + let (label, cp) = match &s { + Status::Pending => ("pending", None), + Status::InBlock { .. } => ("in_block", None), + Status::Final { index, .. } => ("final", Some(*index)), + Status::Failed { .. } => ("failed", None), + }; + if e.finality != label || e.checkpoint != cp { + e.finality = label.into(); + e.checkpoint = cp; + changed = true; + if label == "final" { + self.watch.retain(|w| !w.hash.eq_ignore_ascii_case(&e.hash)); + } + } + } + if changed { + h.save(&self.history_path(h.chain_id, &h.address)); + } + } + + fn history_path(&self, chain_id: u64, address: &str) -> PathBuf { + self.shared.paths.app_dir.join(format!("history-{chain_id}-{}.json", address.trim_start_matches("0x"))) + } + + // ---- history scan ------------------------------------------------------------------------------------------ + + fn scan(&mut self) { + let Some(evm) = self.shared.evm.lock().unwrap().clone() else { return }; + let address = self.shared.address(); + if address.is_empty() { + return; + } + let (chain_id, tip) = { + let st = self.shared.state.lock().unwrap(); + (st.node.chain_id, st.node.block) + }; + if chain_id == 0 { + return; + } + let path = self.history_path(chain_id, &address); + let mut guard = self.shared.history.lock().unwrap(); + if guard.as_ref().map(|h| h.chain_id != chain_id || !h.address.eq_ignore_ascii_case(&address)).unwrap_or(true) { + *guard = Some(History::load(&path, chain_id, &address)); + } + let h = guard.as_mut().unwrap(); + let from = h.scanned_to.map(|n| n + 1).unwrap_or(0); + if from > tip { + self.shared.state.lock().unwrap().scanning = false; + return; + } + let to = (from + 40).min(tip); + self.shared.state.lock().unwrap().scanning = true; + match crate::history::scan(&evm, &address, from, to) { + Ok(entries) => { + let n = entries.len(); + for e in entries { + if !h.has(&e.hash) || h.entries.iter().any(|x| x.hash.eq_ignore_ascii_case(&e.hash) && x.block == 0) { + if e.kind == "received" || e.kind == "reward" || e.kind == "proving" { + if self.scan_done_once { + self.shared.event("ok", &format!("{} {} IGN{}", if e.kind == "received" { "received" } else { "earned" }, crate::evm::ign(e.value.parse().unwrap_or(0), 6), if e.kind == "reward" { " as a block reward" } else { "" })); + } + } + h.put(e); + } + } + h.scanned_to = Some(to); + if n > 0 || to == tip { + h.save(&path); + } + if to == tip { + self.scan_done_once = true; + self.shared.state.lock().unwrap().scanning = false; + } + } + Err(e) => { + self.shared.state.lock().unwrap().node.message = format!("history: {e}"); + } + } + } + + fn check_update(&mut self) { + if self.updater.url.is_empty() { + return; + } + self.shared.state.lock().unwrap().update.status = "checking".into(); + let r = self.updater.check(); + let mut st = self.shared.state.lock().unwrap(); + st.update.status = self.updater.status.clone(); + st.update.error = self.updater.error.clone(); + st.update.checked_at = self.updater.checked_at; + st.update.version = self.updater.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default(); + st.update.notes = self.updater.manifest.as_ref().map(|m| m.notes.clone()).unwrap_or_default(); + st.update.file = self.updater.file.as_ref().map(|f| f.display().to_string()).unwrap_or_default(); + drop(st); + match r { + Ok(m) => self.shared.log(&format!("update check: {m}")), + Err(e) => self.shared.log(&format!("update check failed: {e}")), + } + } +} diff --git a/app/igneum-wallet/src/evm.rs b/app/igneum-wallet/src/evm.rs new file mode 100644 index 000000000..1ea742595 --- /dev/null +++ b/app/igneum-wallet/src/evm.rs @@ -0,0 +1,147 @@ +//! 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 { + 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 { + 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::()))?; + 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 { + q(&self.call("eth_chainId", json!([]))?).map(|v| v as u64).ok_or("eth_chainId: no number".into()) + } + pub fn block_number(&self) -> Result { + 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 { + q(&self.call("eth_getBalance", json!([address, "latest"]))?).ok_or("eth_getBalance: no number".into()) + } + pub fn nonce(&self, address: &str) -> Result { + 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 { + 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()) + } + pub fn send_raw(&self, raw: &[u8]) -> Result { + 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, 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, 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, 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, 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, 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 { + 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 { + 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)); + } +} diff --git a/app/igneum-wallet/src/finality.rs b/app/igneum-wallet/src/finality.rs new file mode 100644 index 000000000..2d45e6931 --- /dev/null +++ b/app/igneum-wallet/src/finality.rs @@ -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/ 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, + 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 { + 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) -> 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 = (0..3).map(|i| VoteSecretKey::from_label(&format!("wallet-test-{i}"))).collect(); + let mut table: Vec = 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 = (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")); + } +} diff --git a/app/igneum-wallet/src/hd.rs b/app/igneum-wallet/src/hd.rs new file mode 100644 index 000000000..ed7a1c082 --- /dev/null +++ b/app/igneum-wallet/src/hd.rs @@ -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 { + 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::>().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 { + let words: Vec = 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 { + 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 { + 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 + } +} diff --git a/app/igneum-wallet/src/history.rs b/app/igneum-wallet/src/history.rs new file mode 100644 index 000000000..e829c99e7 --- /dev/null +++ b/app/igneum-wallet/src/history.rs @@ -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 `/wallet/history--
.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, + #[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, + pub entries: Vec, +} + +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::(&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, 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) +} diff --git a/app/igneum-wallet/src/main.rs b/app/igneum-wallet/src/main.rs new file mode 100644 index 000000000..a434b9730 --- /dev/null +++ b/app/igneum-wallet/src/main.rs @@ -0,0 +1,118 @@ +//! Igneum Wallet engine. Keeps the key, signs, reads a node, verifies finality certificates, and serves the window on +//! 127.0.0.1:/t//. 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. +//! +//! 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. +#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] + +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 = 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"), + 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 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 shared = Arc::new(engine::Shared::new(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); + } + }; + 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}"); + 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) +} diff --git a/app/igneum-wallet/src/node.rs b/app/igneum-wallet/src/node.rs new file mode 100644 index 000000000..0a958412f --- /dev/null +++ b/app/igneum-wallet/src/node.rs @@ -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 { + match self { + Source::Miner { grpc, .. } | Source::External { grpc, .. } | Source::Own { grpc, .. } => Some(*grpc), + _ => None, + } + } + pub fn evm(&self) -> Option { + 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 { + 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, + pub url: String, +} + +impl Grpc { + pub fn connect(port: u16) -> Result { + 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(&self, f: F) -> Result + where + F: std::future::Future>, + { + 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 { + self.run(self.client.get_finality_checkpoints(last)) + } + pub fn weights(&self) -> Result { + 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, String> { + let r = self.run(self.client.get_blocks(Some(low), true, true))?; + Ok(r.blocks) + } + pub fn dag_info(&self) -> Result { + 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, + 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 { + 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 { + 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 = 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 = 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 { + 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)) + } +} diff --git a/app/igneum-wallet/src/qr.rs b/app/igneum-wallet/src/qr.rs new file mode 100644 index 000000000..35f9f1ef9 --- /dev/null +++ b/app/igneum-wallet/src/qr.rs @@ -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 { + 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!( + "" + )) +} + +#[cfg(test)] +mod tests { + #[test] + fn draws() { + let s = super::svg("ethereum:0x7e5f4552091a69125d5dfcb7b8c2659029395bdf").unwrap(); + assert!(s.starts_with("/` (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}; +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 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) -> std::io::Result { + let s = shared.clone(); + let port = igneum_common::http::serve(move |stream| handle(stream, s.clone()))?; + shared.set_port(port); + Ok(port) +} + +fn handle(mut stream: TcpStream, shared: Arc) { + 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", "/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 })); + } + ("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!({})) }; + match api_post(&shared, p, body) { + 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), + } +} + +fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + let s = |k: &str| body.get(k).and_then(|v| v.as_str()).map(|v| v.to_string()); + 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")?), + "/api/lock" => { + shared.lock(); + Ok(json!({ "ok": true })) + } + "/api/reveal" => 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::().unwrap_or(0) * 1_000_000_000, 3)); + v["tip_gwei"] = json!(crate::evm::ign(q.tip.parse::().unwrap_or(0) * 1_000_000_000, 3)); + v["display_to"] = json!(igneum_common::keys::checksum(&q.to)); + 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) + } + "/api/settings" => { + if let Some(on) = body.get("start_at_login").and_then(|v| v.as_bool()) { + shared.set_start_at_login(on)?; + } + Ok(json!({ "ok": true })) + } + "/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/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()), + } +} diff --git a/app/igneum-wallet/src/state.rs b/app/igneum-wallet/src/state.rs new file mode 100644 index 000000000..67b9dd22a --- /dev/null +++ b/app/igneum-wallet/src/state.rs @@ -0,0 +1,132 @@ +//! 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, + 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, +} + +#[derive(Clone, Serialize, Default)] +pub struct UpdateState { + pub status: String, // unknown | checking | current | available | downloading | ready | error | off + pub version: String, + pub notes: String, + pub file: String, + pub error: String, + pub checked_at: f64, +} + +#[derive(Clone, Serialize, Default)] +pub struct SettingsState { + pub start_at_login: bool, + pub network: String, +} + +#[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, + pub scanning: bool, + pub scanned_to: Option, + pub update: UpdateState, + pub settings: SettingsState, + pub events: Vec, + 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 uptime_s: u64, + pub now: f64, + pub quitting: bool, +} + +pub struct Rings { + pub events: VecDeque, + pub log: VecDeque, + 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 { + let mut out: Vec = self.log.iter().filter(|l| l.seq > after).cloned().collect(); + if out.len() > limit { + out = out.split_off(out.len() - limit); + } + out + } +} diff --git a/app/igneum-wallet/src/tx.rs b/app/igneum-wallet/src/tx.rs new file mode 100644 index 000000000..50e77059c --- /dev/null +++ b/app/igneum-wallet/src/tx.rs @@ -0,0 +1,261 @@ +//! 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, +} + +// ---- RLP -------------------------------------------------------------------------------------------------- + +fn rlp_len(len: usize, offset: u8, out: &mut Vec) { + 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) { + 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) { + 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) { + rlp_len(items.len(), 0xc0, out); + out.extend_from_slice(items); +} + +fn fields(t: &Transfer, out: &mut Vec) { + 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 { + 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, + 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 { + 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 }) +} + +/// 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) { + 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 { + // 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 { + 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"); + } +} diff --git a/app/igneum-wallet/src/updater.rs b/app/igneum-wallet/src/updater.rs new file mode 100644 index 000000000..e38fd90a9 --- /dev/null +++ b/app/igneum-wallet/src/updater.rs @@ -0,0 +1,90 @@ +//! Over-the-air updates for the wallet, the first cut: the signed manifest (`igneum-wallet-latest.json`, the same +//! Ed25519 key as the miner's, igneum_common::manifest) checked an hour apart, the disk image or installer downloaded +//! and checked against the manifest's sha256, then "Install now" opens it. No unattended swap yet: the wallet has no +//! safe-moment logic to borrow from the miner (nothing mines here) and the macOS swap helper lives in the miner's +//! src/ota.rs; that is the follow-up. + +use igneum_common::fetch; +use igneum_common::manifest::{self, Manifest, PlatformEntry}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +pub struct Updater { + pub url: String, + pub current: String, + pub dir: PathBuf, + pub next: Instant, + pub manifest: Option, + pub entry: Option, + pub file: Option, + pub status: String, + pub error: String, + pub checked_at: f64, +} + +impl Updater { + pub fn new(url: String, current: &str, app_dir: &Path) -> Updater { + let dir = app_dir.join("updates"); + let _ = std::fs::create_dir_all(&dir); + let status = if url.is_empty() { "off" } else { "unknown" }; + Updater { url, current: current.to_string(), dir, next: Instant::now() + Duration::from_secs(25), manifest: None, entry: None, file: None, status: status.into(), error: String::new(), checked_at: 0.0 } + } + + pub fn due(&self) -> bool { + !self.url.is_empty() && Instant::now() >= self.next + } + + /// One check: manifest, newer?, download. Blocking; the engine calls it from its own thread. + pub fn check(&mut self) -> Result { + self.next = Instant::now() + Duration::from_secs(3600); + self.checked_at = igneum_common::platform::unix_now_f(); + self.status = "checking".into(); + let m = match fetch::fetch_manifest(&self.url, &self.dir) { + Ok(m) => m, + Err(e) => { + self.status = "error".into(); + self.error = e.clone(); + self.next = Instant::now() + Duration::from_secs(600); + return Err(e); + } + }; + self.error.clear(); + if !manifest::newer(&m.version, &self.current) { + self.status = "current".into(); + self.manifest = Some(m); + return Ok("current".into()); + } + let Some(e) = m.this_platform().cloned() else { + self.status = "current".into(); + self.manifest = Some(m); + return Ok("no build for this platform yet".into()); + }; + self.status = "downloading".into(); + self.entry = Some(e.clone()); + let v = m.version.clone(); + self.manifest = Some(m); + match fetch::download(&e, &self.dir) { + Ok(p) => { + self.file = Some(p); + self.status = "ready".into(); + Ok(format!("{v} downloaded and checked")) + } + Err(err) => { + self.status = "error".into(); + self.error = err.clone(); + Err(err) + } + } + } + + pub fn open_file(&self) -> Result<(), String> { + let f = self.file.as_ref().ok_or("nothing downloaded")?; + #[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()) + } +} diff --git a/app/igneum-wallet/src/vault.rs b/app/igneum-wallet/src/vault.rs new file mode 100644 index 000000000..183476a21 --- /dev/null +++ b/app/igneum-wallet/src/vault.rs @@ -0,0 +1,153 @@ +//! The encrypted key file: `/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, +} + +#[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 { + 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 { + 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 = 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 { + 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()) }; + 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()); + } +} diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css new file mode 100644 index 000000000..a931efb3d --- /dev/null +++ b/app/igneum-wallet/ui/app.css @@ -0,0 +1,403 @@ +/* Igneum Miner dashboard. The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, ash; + Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. */ +@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{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; +--gutter:28px;--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:20px;--gap-tile:12px; +--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:52px} +*{box-sizing:border-box} +html,body{height:100%} +body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none} +.mono,code,pre{font-family:var(--mono)} +.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:52px;letter-spacing:-.01em} +h2{font-weight:700;font-size:32px} +h3{font-weight:700;font-size:16px} +p{margin:0} +a{color:inherit} +button{font:inherit;color:inherit} +.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:8px} +.eyebrow.ember{color:var(--ember)} +[hidden]{display:none !important} + +/* buttons */ +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:15px;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;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:#FF6A2B;border-color:#FF6A2B} +.btn.big{min-height:52px;padding:12px 28px;font-size:16px} +.btn.small{min-height:34px;padding:6px 14px;font-size:13px;border-radius:8px} +.btn.tiny{min-height:26px;padding:2px 10px;font-size:12px;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)} +.icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line-2);background:transparent;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink-2);font-size:20px;line-height:1} +.icon-btn:hover{color:var(--bone);border-color:var(--ash)} +:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px} + +/* top bar */ +.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag} +.top button,.top .pill{-webkit-app-region:no-drag} +body.mac .top{padding-left:92px} +.brand{display:flex;align-items:center;gap:10px} +.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em} +.brand .miner{font-family:var(--mono);font-size:11px;letter-spacing:.22em;color:var(--ash);margin-left:4px;padding-top:3px} +.top-right{display:flex;align-items:center;gap:12px} +.pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;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)} +.pill.on{color:var(--molten);border-color:rgba(255,179,92,.35)} +.pill.warn{color:var(--ember)} +.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px} +.dot.small{width:7px;height:7px;flex-basis:7px} +.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite} +.warn .dot{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}} + +/* update banner */ +.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:rgba(242,84,27,.12);border-bottom:1px solid rgba(242,84,27,.4);font-size:14px} + +.banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap} +.banner.clock.warn{background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} +.banner .hint{font-size:11px;color:var(--ash);flex-basis:100%;text-align:center} +.banner.update{flex-wrap:wrap;row-gap:8px} +.banner.update.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600} +.banner .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-2px} +.banner .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear} +/* remote job strip (src/jobrun.rs): running, then the outcome */ +.banner.job{flex-wrap:wrap;row-gap:8px;background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} +.banner.job.failed{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5)} +.banner.job .job-results{flex-basis:100%;margin:0;font-size:11px;line-height:1.5;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto} +.job-history table{margin-top:8px} +.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:11px;padding:4px 6px 4px 0} +.job-history td{padding:5px 6px 5px 0;border-top:1px solid var(--line);vertical-align:top} +.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)} +.job-history tr.running td{color:var(--molten)} +.clock-card{margin-top:12px;border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px} +.clock-card.warn{border-color:rgba(255,179,92,.4);background:rgba(255,179,92,.06)} +.clock-msg{font-size:14px;color:var(--bone);line-height:1.45} +.clock-card .note{margin-top:0} + +/* screens */ +main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)} +body.has-bottom main{bottom:var(--bottom)} +body.drawer-open main{bottom:calc(var(--bottom) + 260px)} +.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease} +body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block} +@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} +@media (prefers-reduced-motion:reduce){.screen{animation:none}} + +/* welcome */ +.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:18px;padding:40px 0 48px} +.coin-wrap{position:relative;width:148px;height:148px;margin-bottom:6px} +.coin-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} +.coin{position:relative;display:block;border-radius:50%;filter:drop-shadow(0 10px 30px rgba(242,84,27,.35))} +@keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}} +.lead{font-size:18px;color:var(--ink-2);max-width:54ch} +.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-tile);width:100%;max-width:860px;margin-top:10px;text-align:left} +.tile{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} +.tile .k{font-family:var(--mono);font-size:11px;letter-spacing:.14em;color:var(--ember)} +.tile .t{font-family:var(--head);font-weight:700;font-size:15px;line-height:1.25} +.tile .s{font-size:13px;color:var(--ash);line-height:1.45} +.cta{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;margin-top:10px} +.seedline{font-size:12px;color:var(--ash);letter-spacing:.04em} + +/* steps */ +.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:16px} +.step .sub{font-size:16px;color:var(--ink-2);max-width:60ch} +.step .cta{justify-content:flex-start;margin-top:8px} +.note{font-size:13px;color:var(--ash);line-height:1.5} +.note.small{font-size:12px;word-break:break-all} +.cards{display:flex;flex-direction:column;gap:12px;margin-top:8px} +.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0} +.card.detecting{display:flex;align-items:center;gap:18px} +.card.detecting .t{font-family:var(--head);font-weight:700;font-size:16px} +.card.detecting .s{font-size:12px;color:var(--ash);margin-top:4px} +.spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px} +@keyframes spin{to{transform:rotate(360deg)}} +.gpu{display:flex;align-items:center;gap:18px} +.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} +.gpu .badge.apple{color:var(--bone)} +.gpu .badge.nvidia{color:#8BE37A} +.gpu .badge.amd{color:var(--ember)} +.gpu .name{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.2} +.gpu .meta{font-family:var(--mono);font-size:12px;color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px} +.gpu .meta b{color:var(--ink-2);font-weight:500} +.gpu .tick{margin-left:auto;width:36px;height:36px;border-radius:50%;background:var(--ember);display:flex;align-items:center;justify-content:center;flex:0 0 36px} +.gpu.off .tick{background:var(--line-2)} +.gpu .tick svg path{stroke-dasharray:30;stroke-dashoffset:30;animation:draw .8s .2s ease forwards} +@keyframes draw{to{stroke-dashoffset:0}} +.gpu .msg{font-size:12px;color:var(--ember);margin-top:4px} + +/* GPU rows (first run and settings) */ +.gpu-row{display:flex;align-items:center;gap:16px;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0} +.gpu-row.off{opacity:.72} +.gpu-row .badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} +.gpu-row .badge.apple{color:var(--bone)} +.gpu-row .badge.nvidia{color:#8BE37A} +.gpu-row .badge.amd{color:var(--ember)} +.gpu-row .info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} +.gpu-row .name{font-family:var(--head);font-weight:700;font-size:16px;line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500} +.kind.discrete,.kind.apple{color:var(--molten);border-color:rgba(255,179,92,.4)} +.kind.external{color:var(--bone)} +.gpu-row .meta{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px} +.gpu-row .meta b{color:var(--ink-2);font-weight:500} +.gpu-row .reason{font-size:12px;color:var(--ash)} +.gpu-row .msg{font-size:12px;color:var(--ember)} +.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto} +.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:4px} +.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1} +.ids button:hover{border-color:var(--ash)} +.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:13px;user-select:text;-webkit-user-select:text} +.ids input:focus{outline:none;border-color:var(--ember)} +.ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0} +.gpu-row.off .ids{opacity:.4;pointer-events:none} +.gpu-row .switch{padding:0;flex:0 0 auto} +.cards.compact .gpu-row{padding:12px 14px;gap:12px;border-radius:14px} +.cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px} +.cards.compact .gpu-row .name{font-size:14px} +.cards.compact .ids .k{display:none} + +.power{display:flex;align-items:center;gap:8px;flex:0 0 auto} +.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.power input[type=range]{width:110px;accent-color:var(--ember)} +.power .pv{font-size:12px;color:var(--ink-2);min-width:84px} +.cards.compact .power .k{display:none} +.cards.compact .power input[type=range]{width:80px} +.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)} +.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)} +.gpu-tile .msg.ok,.gpu-row .msg.ok{color:var(--molten)} +.gpu-row .msg.hot,.gpu-tile .msg.hot{color:var(--ember)} +.gpu-tile .msg .btn.tiny,.gpu-row .msg .btn.tiny{margin-left:6px;vertical-align:middle} +.gpu-tile .msg.warm{color:var(--molten)} +.gpu-tile .msg.hot{color:var(--ember)} + +/* per-card tiles on the dashboard */ +.gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)} +.gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0} +.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:14px;line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums} +.gpu-tile .h .unit{font-family:var(--mono);font-size:11px;color:var(--ash);font-weight:500;letter-spacing:.08em} +.gpu-tile.idle .h{color:var(--ash)} +.gpu-tile .m{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px} +.gpu-tile .m b{color:var(--ink-2);font-weight:500} +.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} +.gpu-tile .st.mining{color:var(--molten)} +.gpu-tile .st.bad{color:var(--ember)} +.gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} +.gpu-tile .msg{font-size:12px;color:var(--ash)} +.gpu-off{margin-top:12px;font-size:12px;color:var(--ash)} + +/* address options */ +.options{display:flex;flex-direction:column;gap:12px;margin-top:6px} +.option{display:flex;gap:16px;align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease} +.option:hover{border-color:var(--line-2)} +.option.on{border-color:var(--ember);background:#1A1614} +.option input[type=radio]{position:absolute;opacity:0;width:0;height:0} +.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative} +.option.on .radio{border-color:var(--ember)} +.option.on .radio::after{content:"";position:absolute;inset:4px;border-radius:50%;background:var(--ember)} +.option .body{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1} +.option .t{font-family:var(--head);font-weight:700;font-size:16px;display:flex;align-items:center;gap:10px} +.option .s{font-size:14px;color:var(--ash);line-height:1.5} +.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid rgba(255,179,92,.4);border-radius:999px;padding:2px 8px} +.addr-input{width:100%;margin-top:8px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:14px;letter-spacing:.02em;user-select:text;-webkit-user-select:text} +.addr-input:focus{outline:none;border-color:var(--ember)} +.option:not(.on) .addr-input{display:none} +.err{font-size:13px;color:var(--ember)} + +/* dashboard */ +#screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)} +body[data-phase="dashboard"] #screen-dashboard{display:flex} +.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)} +.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} +.cell .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.cell .v{font-family:var(--head);font-weight:700;font-size:26px;line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:8px} +.cell.ember .v{color:var(--ember)} +.cell .v .unit{font-family:var(--mono);font-size:12px;color:var(--ash);font-weight:500;letter-spacing:.08em} +.cell .v.state{text-transform:capitalize;font-size:22px} +.cell .s{font-family:var(--mono);font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.cell.ok .v.state{color:var(--molten)} +.cell.bad .v.state{color:var(--ember)} +.grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start} +.col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0} +.card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap} +.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:12px;color:var(--ash)} +.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums} +#dag{display:block;width:100%;height:190px;border-radius:12px;background:var(--obsidian)} +.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:12px;font-size:12px;color:var(--ink-2)} +.legend span{display:inline-flex;align-items:center;gap:8px} +.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)} +.sw.ember{background:var(--ember);border-color:var(--ember)} +.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)} +.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0} +.tbl{overflow-x:auto} +table{border-collapse:collapse;width:100%;font-size:13px} +th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap} +th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500} +td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)} +tr:last-child td{border-bottom:0} +td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} +td .st.mining{color:var(--molten)} +td .st.bad{color:var(--ember)} +td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} +td .sub{display:block;font-family:var(--mono);font-size:11px;color:var(--ash);white-space:normal;max-width:280px} +.empty{color:var(--ash);font-size:13px;padding:10px 0} +.kv{display:flex;flex-direction:column} +.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:7px 0;border-bottom:1px solid var(--line)} +.kv>div:last-child{border-bottom:0} +.kv .k{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)} +.kv .v{font-size:14px;font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.card .note{margin-top:10px} +.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:12px;color:var(--ink-2);max-height:300px;overflow:auto} +.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline} +.feed>div:last-child{border-bottom:0} +.feed .t{color:var(--ash);flex:0 0 auto;font-size:11px} +.feed .k{color:var(--molten);margin-right:6px} +.feed .k.error{color:var(--ember)} +.feed .k.block{color:var(--ember)} +.feed .k.build{color:var(--ink-2)} + +/* sheet (the key) */ +.sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px} +.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease} +.sheet .sub{font-size:15px;color:var(--ink-2)} +.field{display:flex;flex-direction:column;gap:8px;margin-top:6px} +.field .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.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:13px;word-break:break-all;user-select:text;-webkit-user-select:text} +.box span{flex:1;min-width:0} +.box.key{color:var(--molten)} +.check{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;margin-top:4px} +.check.small{font-size:12px;color:var(--ash)} +.check input{width:18px;height:18px;accent-color:var(--ember)} +.sheet .cta{justify-content:flex-start} + +/* settings panel */ +.panel-wrap{justify-content:flex-end;padding:0} +.panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease} +@keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}} +.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)} +.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px} +.row{display:flex;gap:10px;align-items:center} +.row.between{justify-content:space-between} +.row .addr-input{margin-top:0} +.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:14px;user-select:text;-webkit-user-select:text} +.num:focus{outline:none;border-color:var(--ember)} +.switch{display:flex;align-items:center;gap:12px;font-size:14px;cursor:pointer;padding:6px 0} +.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} +.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease} +.switch input:checked+.track{background:var(--ember)} +.switch input:checked+.track::after{transform:translateX(18px)} + +/* bottom bar */ +.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21} +.bottom .left,.bottom .right{display:flex;align-items:center;gap:8px} +.bottom .mid{font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;flex:1} +.bottom .right .mono{font-size:12px} + +/* log drawer */ +.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column} +body.drawer-open .drawer{height:260px} +.drawer-head{display:flex;justify-content:space-between;align-items:center;padding:8px var(--gutter);border-bottom:1px solid var(--line);flex:0 0 auto} +.chips{display:flex;gap:6px} +.chip{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer} +.chip.on{color:var(--molten);border-color:rgba(255,179,92,.4)} +.log{margin:0;flex:1;overflow:auto;padding:10px var(--gutter);font-size:12px;line-height:1.55;color:var(--ink-2);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text} +.log .src{color:var(--ash)} +.log .src.node{color:#7FA7C9} +.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4{color:var(--molten)} +.log .src.app{color:var(--ember)} +.log .e{color:var(--ember)} + +.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:13px;z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5)} + +@media (max-width:860px){ + .strip{grid-template-columns:repeat(2,minmax(0,1fr))} + .grid{grid-template-columns:1fr} + .three{grid-template-columns:1fr} + h1{font-size:40px} +} + +/* ---- Igneum Wallet (added to the miner's tokens and base styles above) ---- */ +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} +.view{display:none} +body[data-view="overview"] #view-overview,body[data-view="send"] #view-send,body[data-view="receive"] #view-receive,body[data-view="tx"] #view-tx,body[data-view="settings"] #view-settings{display:block} +body{user-select:text;-webkit-user-select:text} +.field{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--ash)} +.field input,.field textarea,.inline input{font:inherit;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;min-height:44px} +.field textarea{font-family:var(--mono);font-size:14px;line-height:1.5;resize:vertical} +.field input.mono{font-family:var(--mono)} +.field input:focus,.field textarea:focus,.inline input:focus{outline:none;border-color:var(--ember)} +.err{color:var(--ember);font-size:13px;min-height:18px} +.err:empty{display:none} +.words{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:8px 0 0;padding:0 0 0 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:14px;display:flex;gap:8px;align-items:baseline} +.words li::before{content:counter(w);color:var(--ash);font-size:11px;min-width:18px;text-align:right} +.words.small{grid-template-columns:repeat(6,minmax(0,1fr))} +.words.small li{font-size:12px;padding:5px 8px} +.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:12px;color:var(--ash)} +.checks input{font:inherit;font-family:var(--mono);font-size:15px;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px} +.segs{display:flex;gap:6px;background:var(--graphite);border:1px solid var(--line);border-radius:12px;padding:4px;width:max-content} +.seg{font:inherit;font-size:13px;font-weight:600;color:var(--ash);background:transparent;border:0;border-radius:9px;padding:8px 14px;cursor:pointer} +.seg.on{background:var(--obsidian);color:var(--bone)} +.seg:disabled{opacity:.4;cursor:default} +.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} +.balance-card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:28px var(--card-pad);margin-top:28px;display:flex;flex-direction:column;gap:12px;align-items:flex-start} +.balance{display:flex;align-items:baseline;gap:12px;font-family:var(--head);font-weight:900;font-size:48px;letter-spacing:-.01em;line-height:1} +.balance .unit{font-family:var(--mono);font-size:14px;color:var(--ash);letter-spacing:.12em} +.addr-row{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)} +.actions{display:flex;gap:12px;margin-top:6px} +.split{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap);margin:var(--gap) 0} +.card+.card{margin-top:var(--gap)} +.card h3{margin-bottom:8px} +.kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;font-size:13px;margin-top:10px} +.kv span{color:var(--ash)} +.kv b{font-weight:500;font-family:var(--mono);word-break:break-all} +.kv b.ok{color:var(--molten)} +.kv b.warn{color:var(--ember)} +.history{margin-top:10px;display:flex;flex-direction:column} +.history .row{display:grid;grid-template-columns:90px 1fr max-content 110px;gap:14px;align-items:center;padding:10px 0;border-top:1px solid var(--line);cursor:pointer;font-size:13px} +.history .row:hover{background:rgba(255,255,255,.02)} +.history .row:first-child{border-top:0} +.history .kind{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)} +.history .who{font-family:var(--mono);color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.history .amt{font-family:var(--mono);font-weight:500} +.history .amt.in{color:var(--molten)} +.history .fin{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;text-align:right} +.fin.pending{color:var(--ash)}.fin.in_block{color:var(--ink-2)}.fin.final{color:var(--molten)}.fin.failed{color:var(--ember)} +.history .empty{color:var(--ash);font-size:13px;padding:8px 0} +.confirm{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);display:flex;flex-direction:column;gap:12px} +.confirm .row{display:flex;justify-content:space-between;gap:16px;font-size:14px;color:var(--ash)} +.confirm .row b{color:var(--bone);font-weight:500;text-align:right} +.confirm .row b.small{font-size:12px;word-break:break-all} +.confirm .row.total{border-top:1px solid var(--line);padding-top:12px;color:var(--ink-2)} +.qr{width:240px;height:240px;background:var(--bone);border-radius:14px;padding:8px} +.qr svg{width:100%;height:100%;display:block} +.addr-big{font-size:14px;word-break:break-all;color:var(--ink-2)} +.finality-line{font-family:var(--mono);font-size:13px;padding:12px 14px;border-radius:12px;border:1px solid var(--line);background:var(--graphite)} +.finality-line.final{border-color:rgba(255,179,92,.4);color:var(--molten)} +.finality-line.failed{border-color:rgba(242,84,27,.5);color:var(--ember)} +.inline{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:8px} +.inline input{min-width:200px} +.warn-box{margin-top:14px;border:1px solid rgba(242,84,27,.4);background:rgba(242,84,27,.06);border-radius:12px;padding:12px 14px} +.warn-box b{font-size:14px} +.danger-card{border-color:rgba(242,84,27,.35)} +.switch{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer} +.switch input{width:18px;height:18px;accent-color:var(--ember)} +.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bone);color:var(--obsidian);font-family:var(--mono);font-size:13px;padding:10px 16px;border-radius:999px;z-index:30} +.step .card{margin-top:12px} +#view-settings .step{max-width:760px} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js new file mode 100644 index 000000000..7a106bb59 --- /dev/null +++ b/app/igneum-wallet/ui/app.js @@ -0,0 +1,267 @@ +// Igneum Wallet window. Talks to the engine on the same origin (the token is in the path); polls /api/state every +// 2 s; never holds a key: words and keys only pass through when the engine shows them once. +'use strict'; +const $ = id => document.getElementById(id); +const base = location.pathname.replace(/\/$/, ''); +const api = async (path, body) => { + const r = await fetch(base + path, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const j = await r.json().catch(() => ({ ok: false, error: 'bad answer' })); + if (!r.ok || j.ok === false) throw new Error(j.error || ('http ' + r.status)); + return j; +}; +const post = (path, body = {}) => api(path, body); +let state = null, quote = null, sentHash = null, txHash = null, lastPhase = null; +if (location.search.includes('host=mac')) document.body.classList.add('mac'); + +function toast(text) { const t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(() => { t.hidden = true; }, 1800); } +function copy(text, what) { navigator.clipboard.writeText(text).then(() => toast((what || 'copied') + ' to the clipboard'), () => toast('could not copy')); } +function ign(wei, places = 6) { + const w = BigInt(wei || 0); const whole = w / 10n ** 18n; let frac = (w % 10n ** 18n).toString().padStart(18, '0').slice(0, places).replace(/0+$/, ''); + return frac ? `${whole}.${frac}` : `${whole}`; +} +function short(a) { return a ? a.slice(0, 8) + '…' + a.slice(-6) : ''; } +function when(t) { if (!t) return ''; const d = new Date(t * 1000); return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } +function setPhase(p) { document.body.dataset.phase = p; } +function setView(v) { document.body.dataset.view = v; window.scrollTo(0, 0); $('main').scrollTop = 0; } +function kv(el, rows) { el.innerHTML = rows.map(([k, v, cls]) => `${k}${v}`).join(''); } +const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +// ---- state ---- +async function poll() { + try { state = await api('/api/state'); render(); } catch (e) { $('pill-text').textContent = 'engine gone'; $('pill').className = 'pill warn'; } +} +function render() { + const s = state; + const phase = s.phase; + const inFlow = ['create', 'import'].includes(document.body.dataset.phase); + if (!inFlow || phase === 'home') { if (lastPhase !== phase) { setPhase(phase); if (phase === 'home') setView('overview'); } } + lastPhase = phase; + $('btn-settings').hidden = phase !== 'home'; + $('btn-lock').hidden = phase !== 'home'; + // pill + const n = s.node; + let pillText = 'no node', pillCls = 'pill warn'; + if (n.state === 'ok') { pillText = `${n.source} node · block ${n.block.toLocaleString()}`; pillCls = 'pill on'; } + else if (n.state === 'starting' || n.state === 'connecting') { pillText = n.state; pillCls = 'pill'; } + else if (n.source === 'public') { pillText = 'public rpc'; pillCls = 'pill'; } + if (s.quitting) { pillText = 'stopping'; pillCls = 'pill'; } + $('pill-text').textContent = pillText; $('pill').className = pillCls; + $('welcome-eyebrow').textContent = `${s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network} · nothing is bought or sold`; + // update banner + const u = s.update; + $('update-banner').hidden = !(u.status === 'ready' && !sessionStorage.getItem('update-later-' + u.version)); + $('update-text').textContent = `Igneum Wallet ${u.version} is downloaded and checked.${u.notes ? ' ' + u.notes : ''}`; + $('update-note').textContent = u.status === 'off' ? 'updates are off in this build' : u.status === 'ready' ? `${u.version} downloaded` : u.status === 'error' ? u.error : u.status === 'current' ? 'up to date' : u.status; + // unlock + $('unlock-address').textContent = s.display; + // home + $('balance').textContent = s.balance_known ? s.balance : '…'; + $('home-address').textContent = s.display; + $('backup-note').hidden = s.backed_up; + kv($('node-kv'), [ + ['source', esc(n.source === 'miner' ? 'the miner app' : n.source === 'own' ? 'bundled node' : n.source === 'public' ? 'public RPC' : n.source), n.state === 'ok' ? 'ok' : 'warn'], + ['chain', esc(n.chain || (n.chain_id ? 'id ' + n.chain_id : '…'))], + ['block', n.block.toLocaleString()], + ['finality', n.finality_active ? `active, locked ${n.latest_locked}` : (n.source === 'public' ? 'not checkable without a node' : 'paused')], + ['note', esc(n.message)], + ]); + const f = s.finality; + kv($('fin-kv'), f.verified_index ? [ + ['verified here', `checkpoint ${f.verified_index}`, 'ok'], + ['signed', `${f.signers} of ${f.voters} voters, ${(f.fraction_total * 100).toFixed(1)}% of weight`], + ['chain height', f.chain_number == null ? 'pending' : f.chain_number.toLocaleString()], + ['weights', f.weights_exact ? 'at the checkpoint' : 'latest table'], + ['checked', when(f.verified_at)], + ] : [['status', esc(f.message || 'waiting'), 'warn']]); + $('scan-note').textContent = s.scanning ? `· reading blocks (${s.scanned_to == null ? 0 : s.scanned_to.toLocaleString()} of ${n.block.toLocaleString()})` : ''; + renderHistory(s.history); + // settings + $('start-login').checked = !!s.settings.start_at_login; + kv($('settings-node-kv'), [['source', esc(n.source)], ['ethereum rpc', esc(n.evm || 'none')], ['grpc', esc(n.grpc || 'none')], ['chain id', n.chain_id || '…'], ['network', esc(s.settings.network)], ['public rpc', esc(s.public_rpc || 'none in this build')]]); + kv($('machine-kv'), [['machine', esc(s.machine_id.slice(0, 8))], ['version', esc(s.version)], ['logs', esc(s.log_dir)], ['miner key file', s.miner_wallet_present ? 'present' : 'none']]); + $('seg-miner').disabled = !s.miner_wallet_present; + if (document.body.dataset.view === 'tx' && txHash) renderTx(); +} +function renderHistory(list) { + const el = $('history'); + if (!list || !list.length) { el.innerHTML = `
${state.scanning ? 'Reading the chain.' : 'Nothing yet. Receive IGN, or point the miner app at this address.'}
`; return; } + el.innerHTML = list.slice(0, 60).map(e => { + const incoming = e.kind === 'received' || e.kind === 'reward' || e.kind === 'proving'; + const who = e.kind === 'reward' ? 'block reward' : e.kind === 'proving' ? 'shard payout' : e.kind === 'self' ? 'to yourself' : incoming ? 'from ' + short(e.from) : 'to ' + short(e.to); + const fin = e.finality === 'final' ? `final · cp ${e.checkpoint}` : e.finality === 'in_block' ? `in block ${e.block}` : e.finality; + return `
${esc(e.kind)}${esc(who)} · ${when(e.time)}${incoming ? '+' : '−'}${ign(e.value)} IGN${esc(fin)}
`; + }).join(''); + el.querySelectorAll('.row').forEach(r => r.addEventListener('click', () => openTx(r.dataset.hash))); +} + +// ---- create ---- +$('go-create').onclick = () => { setPhase('create'); $('create-1').hidden = false; $('create-2').hidden = true; $('create-3').hidden = true; $('create-pw').focus(); }; +$('create-back').onclick = () => setPhase('welcome'); +let words = []; +$('create-next').onclick = async () => { + $('create-err').textContent = ''; + const a = $('create-pw').value, b = $('create-pw2').value; + if (a.length < 8) return $('create-err').textContent = 'at least 8 characters'; + if (a !== b) return $('create-err').textContent = 'the two passwords differ'; + try { + const r = await post('/api/create', { password: a }); + words = r.words; + $('words').innerHTML = words.map(w => `
  • ${esc(w)}
  • `).join(''); + $('create-address').textContent = r.display; + $('create-1').hidden = true; $('create-2').hidden = false; + } catch (e) { $('create-err').textContent = e.message; } +}; +$('create-written').onclick = () => { + const picks = []; while (picks.length < 3) { const p = 1 + Math.floor(Math.random() * 24); if (!picks.includes(p)) picks.push(p); } + picks.sort((a, b) => a - b); + $('checks').innerHTML = picks.map(p => ``).join(''); + $('words').innerHTML = ''; words = []; + $('create-2').hidden = true; $('create-3').hidden = false; + $('checks').querySelector('input').focus(); +}; +$('create-again').onclick = () => { setPhase('create'); $('create-3').hidden = true; $('create-1').hidden = false; $('confirm-err').textContent = 'start again: the words are made fresh each time'; }; +$('create-confirm').onclick = async () => { + $('confirm-err').textContent = ''; + const checks = [...$('checks').querySelectorAll('input')].map(i => ({ position: Number(i.dataset.pos), word: i.value.trim() })); + try { await post('/api/create/confirm', { checks }); $('checks').innerHTML = ''; await poll(); setPhase('home'); setView('overview'); toast('wallet ready'); } + catch (e) { $('confirm-err').textContent = e.message; } +}; + +// ---- import ---- +let importMode = 'seed'; +$('go-import').onclick = () => { setPhase('import'); }; +$('import-back').onclick = () => setPhase('welcome'); +$('import-mode').querySelectorAll('.seg').forEach(b => b.onclick = () => { + importMode = b.dataset.mode; + $('import-mode').querySelectorAll('.seg').forEach(x => x.classList.toggle('on', x === b)); + $('import-data-field').hidden = importMode === 'miner'; + $('import-miner-note').hidden = importMode !== 'miner'; + $('import-data-label').textContent = importMode === 'seed' ? 'The words, in order' : 'The private key, 64 hex characters'; +}); +$('import-go').onclick = async () => { + $('import-err').textContent = ''; + const a = $('import-pw').value, b = $('import-pw2').value; + if (a.length < 8) return $('import-err').textContent = 'at least 8 characters'; + if (a !== b) return $('import-err').textContent = 'the two passwords differ'; + try { await post('/api/import', { mode: importMode, data: $('import-data').value, password: a }); $('import-data').value = ''; await poll(); setPhase('home'); setView('overview'); toast('imported'); } + catch (e) { $('import-err').textContent = e.message; } +}; + +// ---- unlock / lock ---- +$('unlock-form').onsubmit = async ev => { + ev.preventDefault(); $('unlock-err').textContent = ''; + try { await post('/api/unlock', { password: $('unlock-pw').value }); $('unlock-pw').value = ''; await poll(); } + catch (e) { $('unlock-err').textContent = e.message; } +}; +$('btn-lock').onclick = async () => { await post('/api/lock'); await poll(); }; +$('btn-settings').onclick = () => setView('settings'); +$('settings-back').onclick = () => setView('overview'); +$('copy-address').onclick = () => copy(state.display, 'address'); +$('backup-link').onclick = ev => { ev.preventDefault(); setView('settings'); $('backup-pw').focus(); }; + +// ---- send ---- +$('go-send').onclick = () => { setView('send'); $('send-form').hidden = false; $('send-confirm').hidden = true; $('send-done').hidden = true; $('send-err').textContent = ''; $('send-to').focus(); }; +$('send-cancel').onclick = () => setView('overview'); +$('send-edit').onclick = () => { $('send-form').hidden = false; $('send-confirm').hidden = true; }; +$('send-quote').onclick = async () => { + $('send-err').textContent = ''; + try { + quote = await post('/api/send/quote', { to: $('send-to').value, amount: $('send-amount').value }); + $('c-amount').textContent = `${quote.value_ign} IGN`; + $('c-to').textContent = quote.display_to; + $('c-fee').textContent = `${quote.fee_max_ign} IGN`; + $('c-total').textContent = `${quote.total_max_ign} IGN`; + $('c-fee-note').textContent = `Fee = gas × price. Gas ${quote.gas.toLocaleString()}. Price = base fee ${quote.base_fee_gwei} gwei (burned by the network) + tip ${quote.tip_gwei} gwei (to the miner), capped at ${ign(quote.max_fee, 0) === '0' ? (Number(quote.max_fee) / 1e9).toFixed(3) + ' gwei' : ign(quote.max_fee) + ' IGN'} per gas; what is not used comes back.`; + $('send-form').hidden = true; $('send-confirm').hidden = false; $('confirm-send-err').textContent = ''; + } catch (e) { $('send-err').textContent = e.message; } +}; +$('send-go').onclick = async () => { + $('confirm-send-err').textContent = ''; $('send-go').disabled = true; + try { + const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, ...q } = quote; + const r = await post('/api/send', { quote: q }); + sentHash = r.hash; $('sent-hash').textContent = r.hash; + $('send-confirm').hidden = true; $('send-done').hidden = false; $('send-to').value = ''; $('send-amount').value = ''; + await poll(); + } catch (e) { $('confirm-send-err').textContent = e.message; } + $('send-go').disabled = false; +}; +$('sent-home').onclick = () => setView('overview'); +$('sent-view').onclick = () => openTx(sentHash); + +// ---- receive ---- +$('go-receive').onclick = async () => { + try { const r = await post('/api/receive'); $('qr').innerHTML = r.svg; $('receive-address').textContent = r.display; setView('receive'); } + catch (e) { toast(e.message); } +}; +$('receive-copy').onclick = () => copy(state.display, 'address'); +$('receive-back').onclick = () => setView('overview'); + +// ---- transaction detail ---- +async function openTx(hash) { txHash = hash; setView('tx'); await renderTx(); } +async function renderTx() { + const e = (state.history || []).find(x => x.hash.toLowerCase() === txHash.toLowerCase()); + if (!e) return; + const incoming = ['received', 'reward', 'proving'].includes(e.kind); + $('tx-title').textContent = e.kind === 'reward' ? 'Block reward' : e.kind === 'proving' ? 'Shard payout' : e.kind === 'sent' ? 'Sent' : e.kind === 'self' ? 'To yourself' : 'Received'; + const fl = $('tx-finality'); fl.className = 'finality-line ' + e.finality; + fl.textContent = e.finality === 'final' ? `FINAL · under checkpoint ${e.checkpoint}, certificate verified by this wallet` : e.finality === 'in_block' ? `IN A BLOCK · chain block ${e.block.toLocaleString()}; final once a verified checkpoint covers it` : e.finality === 'failed' ? 'FAILED · the execution failed; the fee was still paid' : 'PENDING · waiting for a block'; + const rows = [['amount', `${incoming ? '+' : '−'}${ign(e.value, 18)} IGN`], ['when', when(e.time)]]; + if (e.kind !== 'reward' && e.kind !== 'proving') rows.push(['from', esc(e.from)], ['to', esc(e.to)], ['fee paid', e.fee ? `${ign(e.fee, 9)} IGN` : 'pending'], ['hash', esc(e.hash)]); + rows.push(['block', e.block ? `${e.block.toLocaleString()} · ${short(e.block_hash)}` : 'none yet']); + if (e.note) rows.push(['note', esc(e.note)]); + const f = state.finality; + rows.push(['verified checkpoint', f.verified_index ? `${f.verified_index} at chain height ${f.chain_number == null ? '…' : f.chain_number.toLocaleString()}` : 'none yet']); + kv($('tx-kv'), rows); +} +$('tx-back').onclick = () => setView('overview'); + +// ---- settings ---- +$('backup-show').onclick = async () => { + $('backup-err').textContent = ''; + try { + const r = await post('/api/reveal', { password: $('backup-pw').value }); $('backup-pw').value = ''; + $('backup-words').innerHTML = (r.words || []).map(w => `
  • ${esc(w)}
  • `).join(''); + $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; + if (!state.backed_up) await post('/api/backed-up'); + } catch (e) { $('backup-err').textContent = e.message; } +}; +$('backup-hide').onclick = () => { $('backup-out').hidden = true; $('backup-words').innerHTML = ''; $('backup-key').textContent = ''; }; +$('pw-change').onclick = async () => { + $('pw-err').textContent = ''; + try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('password changed'); } + catch (e) { $('pw-err').textContent = e.message; } +}; +async function network() { return api('/api/network'); } +async function renderNetwork() { + try { + const n = await network(); + kv($('net-kv'), [['network name', esc(n.chain_name)], ['chain id', `${n.chain_id} (${n.chain_id_hex})`], ['rpc url', esc(n.rpc_url || 'none yet')], ['symbol', 'IGN'], ['decimals', '18']]); + $('net-add').disabled = !n.add_network_page; + $('net-add').title = n.add_network_page ? '' : 'the site page is not set in this build; copy the settings instead'; + } catch (e) { kv($('net-kv'), [['network', esc(e.message)]]); } +} +$('net-add').onclick = async () => { const n = await network(); if (n.add_network_page) post('/api/open', { url: n.add_network_page }); }; +$('net-copy').onclick = async () => { const n = await network(); copy(`Network name: ${n.chain_name}\nRPC URL: ${n.rpc_url}\nChain ID: ${n.chain_id}\nCurrency symbol: IGN\nDecimals: 18`, 'network settings'); }; +$('export-show').onclick = async () => { + $('export-err').textContent = ''; + try { const r = await post('/api/reveal', { password: $('export-pw').value }); $('export-pw').value = ''; $('export-key').textContent = r.private_key; $('export-out').hidden = false; } + catch (e) { $('export-err').textContent = e.message; } +}; +$('export-copy').onclick = () => copy($('export-key').textContent, 'private key'); +$('export-hide').onclick = () => { $('export-out').hidden = true; $('export-key').textContent = ''; }; +$('start-login').onchange = async ev => { try { await post('/api/settings', { start_at_login: ev.target.checked }); } catch (e) { toast(e.message); } }; +$('update-check').onclick = () => post('/api/update/check'); +$('update-open').onclick = () => post('/api/update/open'); +$('update-later').onclick = () => { sessionStorage.setItem('update-later-' + state.update.version, '1'); $('update-banner').hidden = true; }; +$('remove-go').onclick = async () => { + $('remove-err').textContent = ''; + if (!confirm('Remove this wallet from this machine? Only your 24 words or the key bring it back.')) return; + try { await post('/api/remove', { password: $('remove-pw').value }); $('remove-pw').value = ''; await poll(); } + catch (e) { $('remove-err').textContent = e.message; } +}; +document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); }); +new MutationObserver(() => { if (document.body.dataset.view === 'settings') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-view'] }); + +poll(); +setInterval(poll, 2000); diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html new file mode 100644 index 000000000..538e4ec18 --- /dev/null +++ b/app/igneum-wallet/ui/index.html @@ -0,0 +1,263 @@ + + + + + +Igneum Wallet + + + + + + +
    +
    + + IGNEUMWALLET +
    +
    +
    starting
    + + +
    +
    + + + +
    + + +
    +
    +
    +
    devnet v4 · nothing is bought or sold
    +

    Igneum Wallet

    +

    Your IGN, your key, on this machine. The key is made here and never leaves this app.

    +
    +
    01
    Your key stays here
    Encrypted with a password you choose. Signing happens inside the app.
    +
    02
    Final means verified
    A payment is final when this wallet has checked the network's certificate itself.
    +
    03
    Works with the miner
    Uses the miner app's node when it runs here. Imports the miner's key in one tap.
    +
    +
    + + +
    +
    Nobody from Igneum will ever ask for your words or your key.
    +
    +
    + + +
    +
    +
    step 1 of 3
    +

    Choose a password

    +

    It locks the key on this machine. Nobody can reset it for you. At least 8 characters.

    + + +
    +
    +
    + + +
    + + +
    +
    +
    import
    +

    Bring a key here

    +

    Words from any wallet, a raw private key, or the key the miner app made on this machine.

    +
    + + + +
    + + + + +
    +
    +
    +
    + + +
    +
    +
    +

    Unlock

    +

    +
    + + +
    +
    +
    Forgot it? Only the 24 words or the key open this wallet again: Settings is locked too.
    +
    +
    + + +
    + +
    +
    +
    balance
    +
     IGN
    +
    +
    + + +
    + +
    +
    +
    +
    node
    +
    +
    +
    +
    finality
    +
    +
    +
    +
    +
    history
    +
    +
    +
    + +
    +
    +
    send
    +

    Send IGN

    +
    + + +
    +
    +
    + + +
    +
    + +
    +
    +
    receive
    +

    Your address

    +
    +

    +
    +

    Only IGN on the Igneum network. Rewards from the miner app land here when the miner pays to this address.

    +
    +
    + +
    +
    +
    transaction
    +

    Transfer

    +
    +
    +
    +
    +
    + +
    +
    +
    settings
    +

    Settings

    + +

    Backup

    +

    Show the 24 words (or the key) again. Needs the password.

    +
    +
    + +
    + +

    Password

    +
    +
    +
    + +

    Export to MetaMask

    +

    Add the network, then import the private key. The key leaves this app only when you copy it here.

    +
    +
    + + +
    +
    + Export the private key +

    A copied key can be stolen from the clipboard, a screenshot or a notes app. Paste it straight into MetaMask and clear the clipboard.

    +
    +
    + +
    +
    + +

    Network

    +
    +
    + +

    This machine

    + +
    +
    +
    + +

    Remove this wallet from this machine

    +

    The vault file is deleted. Only your 24 words or the key bring it back.

    +
    +
    +
    + +
    +
    +
    + +
    +
    + + + + + diff --git a/app/igneum-wallet/ui/mark.svg b/app/igneum-wallet/ui/mark.svg new file mode 100644 index 000000000..bce08ac84 --- /dev/null +++ b/app/igneum-wallet/ui/mark.svg @@ -0,0 +1 @@ + diff --git a/app/mac/IgneumWallet.swift b/app/mac/IgneumWallet.swift new file mode 100644 index 000000000..8ca0b13a8 --- /dev/null +++ b/app/mac/IgneumWallet.swift @@ -0,0 +1,360 @@ +// 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 -framework Cocoa -framework WebKit +// It starts Contents/MacOS/igneum-wallet --wrapper, reads "URL ..." 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. +// +// Snapshot mode, used to make the design screenshots without a browser: +// "Igneum Wallet" --snapshot --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 { + 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 + 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 + window.appearance = NSAppearance(named: .darkAqua) + let conf = WKWebViewConfiguration() + 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.. 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 + } + + // ---- 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 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)") } + } + } +} + +let app = NSApplication.shared +let delegate = App() +var 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() diff --git a/app/windows/BUILD-WALLET-APP.bat b/app/windows/BUILD-WALLET-APP.bat new file mode 100644 index 000000000..60c7bb11c --- /dev/null +++ b/app/windows/BUILD-WALLET-APP.bat @@ -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) and the internet on the first run +rem (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 || (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 diff --git a/app/windows/wallet-host.cpp b/app/windows/wallet-host.cpp new file mode 100644 index 000000000..3f0939156 --- /dev/null +++ b/app/windows/wallet-host.cpp @@ -0,0 +1,457 @@ +// 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. +// +// 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 +#include +#include +#include +#include +#include +#include +#include "WebView2.h" +#include "wallet-version.h" + +using namespace Microsoft::WRL; + +#define WM_ENGINE_LINE (WM_APP + 1) +#define WM_TRAY (WM_APP + 2) +#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 g_controller; +static ComPtr g_webview; +static std::wstring g_url, g_status = L"starting the engine"; +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 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([](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([](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 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([](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([](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); + RECT rc; GetClientRect(g_hwnd, &rc); + g_controller->put_Bounds(rc); + COREWEBVIEW2_COLOR dark = { 255, 12, 12, 14 }; + ComPtr 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 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("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_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; +} diff --git a/app/windows/wallet-host.rc b/app/windows/wallet-host.rc new file mode 100644 index 000000000..2f31e6230 --- /dev/null +++ b/app/windows/wallet-host.rc @@ -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 +#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 diff --git a/app/windows/wallet-version.h b/app/windows/wallet-version.h new file mode 100644 index 000000000..f923221b8 --- /dev/null +++ b/app/windows/wallet-version.h @@ -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.0" +#define IGNEUM_HOST_VERSION_RC 0,1,0,0 +#endif diff --git a/docs/bench-log.md b/docs/bench-log.md index 2cfda1a36..a00d3a0ae 100644 --- a/docs/bench-log.md +++ b/docs/bench-log.md @@ -1065,3 +1065,40 @@ 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. diff --git a/packaging/mac/app/IgneumWallet-Info.plist b/packaging/mac/app/IgneumWallet-Info.plist new file mode 100644 index 000000000..fb6c7cf8e --- /dev/null +++ b/packaging/mac/app/IgneumWallet-Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleName + Igneum Wallet + CFBundleDisplayName + Igneum Wallet + CFBundleIdentifier + network.igneum.wallet + CFBundleVersion + VERSION_STAMP + CFBundleShortVersionString + 0.1.0 + CFBundlePackageType + APPL + CFBundleExecutable + Igneum Wallet + CFBundleIconFile + igneum + CFBundleDevelopmentRegion + en + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + 11.0 + LSArchitecturePriority + + arm64 + + LSRequiresNativeExecution + + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.utilities + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSHumanReadableCopyright + Igneum. Nothing is bought or sold. + + diff --git a/packaging/mac/build-wallet-dmg.sh b/packaging/mac/build-wallet-dmg.sh new file mode 100755 index 000000000..5b21cff10 --- /dev/null +++ b/packaging/mac/build-wallet-dmg.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Builds packaging/mac/dist/Igneum-Wallet-.dmg: "Igneum Wallet.app" + README.txt + a link to /Applications, on the +# same branded image as the miner's (build-dmg.sh, which this script follows step for step). 4 October 2026. +# +# The bundle: +# Contents/MacOS/Igneum Wallet the window (app/mac/IgneumWallet.swift, WKWebView + menu-bar item), compiled here +# Contents/MacOS/igneum-wallet the engine (app/igneum-wallet, cargo --release), compiled here unless ENGINE= names one +# Contents/Resources/bin/igneumd the node (vendor/igneum-node/target-integration/release, the devnet-v4 integration +# build): started only when the miner app's node is not running on this Mac +# Contents/Resources/igneum-wallet.json the update manifest URL (igneum-wallet-latest.json), the public RPC, the +# add-to-MetaMask page (packaged-config.sh write_wallet_config) +# Contents/Resources/igneum.icns the master mark +# +# packaging/mac/build-wallet-dmg.sh build (the version is app/igneum-wallet/Cargo.toml's; VERSION= overrides it) +# packaging/ota/publish-manifest.sh --product wallet --version --mac dist/Igneum-Wallet-.dmg --notes "..." +# NODE= ENGINE= use other binaries +# Runs under the build lock: tools/lock/with-lock.sh build packaging/mac/build-wallet-dmg.sh +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +VERSION="${VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-wallet/Cargo.toml" | head -1)}" +NODE="${NODE:-$ROOT/vendor/igneum-node/target-integration/release/igneumd}" +ENGINE="${ENGINE:-}" +ICONS="$ROOT/brand/icons" +BUILD="$HERE/build-wallet" +DIST="$HERE/dist" +DMG="$DIST/Igneum-Wallet-$VERSION.dmg" +STAGE="$BUILD/dmg" +APP="$STAGE/Igneum Wallet.app" +STAMP="$(date -u +%Y%m%d%H%M)" +export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:$PATH" +. "$HERE/packaged-config.sh" + +[ -x "$NODE" ] || { echo "no node binary at $NODE"; exit 1; } +file "$NODE" | grep -q 'arm64' || { echo "$NODE is not an arm64 binary"; exit 1; } +for f in igneum.icns igneum-volume.icns; do [ -f "$ICONS/$f" ] || { echo "no $ICONS/$f; run: python3 brand/icons/make-icons.py"; exit 1; }; done +command -v swiftc >/dev/null 2>&1 || { echo "swiftc is needed for the window (xcode-select --install)"; exit 1; } + +rm -rf "$BUILD" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources/bin" "$DIST" "$BUILD/window" + +# the engine (cargo, nice 19, 4 jobs) +if [ -z "$ENGINE" ]; then + echo "building the engine (app/igneum-wallet, cargo --release)" + (cd "$ROOT/app/igneum-wallet" && nice -n 19 cargo build --release -j 4 --quiet) + ENGINE="$ROOT/app/igneum-wallet/target/release/igneum-wallet" +fi +[ -x "$ENGINE" ] || { echo "missing: $ENGINE"; exit 1; } +"$ENGINE" --version + +# the window +echo "building the window (app/mac/IgneumWallet.swift)" +(cd "$ROOT/app/mac" && nice -n 19 swiftc -O -target arm64-apple-macos11 -o "$BUILD/window/Igneum Wallet" IgneumWallet.swift -framework Cocoa -framework WebKit 2>&1 | grep -v 'warning\|^ *\^\|^$' || true) +[ -x "$BUILD/window/Igneum Wallet" ] || { echo "the window did not build"; exit 1; } + +# the bundle +sed -e "s/VERSION_STAMP/$STAMP/" "$HERE/app/IgneumWallet-Info.plist" > "$APP/Contents/Info.plist" +plutil -replace CFBundleShortVersionString -string "$VERSION" "$APP/Contents/Info.plist" +plutil -lint "$APP/Contents/Info.plist" >/dev/null +printf 'APPL????' > "$APP/Contents/PkgInfo" +cp "$BUILD/window/Igneum Wallet" "$APP/Contents/MacOS/Igneum Wallet" +cp "$ENGINE" "$APP/Contents/MacOS/igneum-wallet" +cp "$ICONS/igneum.icns" "$APP/Contents/Resources/igneum.icns" +cp "$NODE" "$APP/Contents/Resources/bin/igneumd" +write_wallet_config "$APP/Contents/Resources/igneum-wallet.json" +chmod 755 "$APP/Contents/MacOS/"* "$APP/Contents/Resources/bin/"* + +# strip the copies, then sign them ad hoc again +for b in "$APP/Contents/Resources/bin/"* "$APP/Contents/MacOS/"*; do + before=$(stat -f %z "$b") + strip "$b" 2>/dev/null || strip -x "$b" 2>/dev/null || true + codesign -s - -f "$b" 2>/dev/null + codesign -v "$b" + echo "$(basename "$b"): $before -> $(stat -f %z "$b") bytes, signed ad hoc" +done +codesign -s - -f "$APP" 2>/dev/null || true +v="$("$APP/Contents/Resources/bin/igneumd" --version 2>&1 || true)"; echo "node: ${v%%$'\n'*}" +v="$("$APP/Contents/MacOS/igneum-wallet" --version 2>&1 || true)"; case "$v" in "igneum-wallet $VERSION") echo "engine: $v" ;; igneum-wallet*) echo "the engine says '$v' but this DMG is $VERSION (set VERSION= or rebuild the engine)"; exit 1 ;; *) echo "the engine copy does not run: $v"; exit 1 ;; esac + +cp "$HERE/dmg/README-wallet.txt" "$STAGE/README.txt" +rm -f "$DMG" +if command -v dmgbuild >/dev/null 2>&1; then + dmgbuild -s "$HERE/dmg/wallet-settings.py" -D "app=$APP" -D "stage=$STAGE" -D "icons=$ICONS" "Igneum Wallet" "$DMG" +else + echo "note: dmgbuild is not installed (pip3 install dmgbuild); building a plain image without icon positions or background" + ln -s /Applications "$STAGE/Applications" + cp "$ICONS/igneum-volume.icns" "$STAGE/.VolumeIcon.icns" + hdiutil create -volname "Igneum Wallet" -srcfolder "$STAGE" -ov -format ULFO -fs HFS+ "$DMG" >/dev/null +fi +hdiutil verify "$DMG" >/dev/null +echo "built $DMG ($(du -h "$DMG" | cut -f1), $(stat -f %z "$DMG") bytes, version $VERSION build $STAMP)" diff --git a/packaging/mac/dmg/README-wallet.txt b/packaging/mac/dmg/README-wallet.txt new file mode 100644 index 000000000..09b60b6e7 --- /dev/null +++ b/packaging/mac/dmg/README-wallet.txt @@ -0,0 +1,15 @@ +Igneum Wallet + +Drag "Igneum Wallet" to Applications and open it. + +The first time, macOS may say the app is from an unidentified developer: open System Settings > Privacy & Security +and click "Open Anyway", or right-click the app and choose Open. + +What it does +- Makes a key on this Mac (24 words, shown once) or imports one: words, a private key, or the Igneum Miner key. +- Keeps the key encrypted with a password you choose. Signing happens inside the app. The key never leaves it. +- Shows your IGN, sends and receives, and lists what happened to the address. +- Calls a payment final only when it has verified the network's certificate itself. +- Uses the Igneum Miner node when the miner runs on this Mac; otherwise it runs the bundled node. + +Nobody from Igneum will ever ask for your words or your key. Nothing is bought or sold on this network. diff --git a/packaging/mac/dmg/wallet-settings.py b/packaging/mac/dmg/wallet-settings.py new file mode 100644 index 000000000..0a2a6cb25 --- /dev/null +++ b/packaging/mac/dmg/wallet-settings.py @@ -0,0 +1,38 @@ +# dmgbuild settings for Igneum-Wallet-.dmg (used by build-wallet-dmg.sh; pip3 install dmgbuild). +# Defines passed in: app (the built .app), stage (the folder with README.txt), icons (brand/icons). +import os +app = defines['app'] +stage = defines['stage'] +icons = defines['icons'] +appname = os.path.basename(app) + +format = 'ULFO' +filesystem = 'HFS+' +size = None +files = [app, os.path.join(stage, 'README.txt')] +symlinks = {'Applications': '/Applications'} +icon = os.path.join(icons, 'igneum-volume.icns') +background = os.path.join(icons, 'dmg-background.tiff') if os.path.exists(os.path.join(icons, 'dmg-background.tiff')) else os.path.join(icons, 'dmg-background.png') +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +sidebar_width = 180 +window_rect = ((200, 120), (660, 400)) +default_view = 'icon-view' +show_icon_preview = False +include_icon_view_settings = 'auto' +include_list_view_settings = 'auto' +arrange_by = None +grid_offset = (0, 0) +grid_spacing = 100 +scroll_position = (0, 0) +label_pos = 'bottom' +text_size = 12 +icon_size = 96 +icon_locations = { + appname: (165, 130), + 'Applications': (495, 130), + 'README.txt': (165, 330), +} diff --git a/packaging/mac/packaged-config.sh b/packaging/mac/packaged-config.sh index 92528a3c3..180bfe1bd 100644 --- a/packaging/mac/packaged-config.sh +++ b/packaging/mac/packaged-config.sh @@ -35,3 +35,30 @@ $override_line } JSON } + +# ---- Igneum Wallet (build-wallet-dmg.sh): igneum-wallet.json next to the wallet engine ---- +# Its manifest is igneum-wallet-latest.json, signed with the same OTA key (publish-manifest.sh --product wallet). +# PUBLIC_RPC: the seed's public Ethereum RPC for users without a node (none yet, 4 October 2026: empty = the wallet +# starts the bundled node). ADD_NETWORK_PAGE: the site page that adds the network to MetaMask (to be hosted). +WALLET_PUBLIC_RPC='' +WALLET_ADD_NETWORK_PAGE='https://igneum.network/wallet/add' +write_wallet_config() { + local out="$1" token="" manifest="" + [ -f "$TOKEN_FILE" ] && token="$(tr -d '[:space:]' < "$TOKEN_FILE")" + [ -n "$token" ] && manifest="https://dl.igneum.network/dl/$token/igneum-wallet-latest.json" + [ -n "$manifest" ] || echo "note: no $TOKEN_FILE; the update check is disabled in this build" + local override_line="" + [ -n "$NODE_OVERRIDE_PARAMS" ] && override_line=" \"node_override_params\": $NODE_OVERRIDE_PARAMS," + cat > "$out" <&2; exit 2 ;; esac done +case "$PRODUCT" in miner) MANIFEST_NAME="igneum-app-latest.json" ;; wallet) MANIFEST_NAME="igneum-wallet-latest.json" ;; *) echo "--product is miner or wallet" >&2; exit 2 ;; esac [ -n "$VERSION" ] || { echo "--version is required" >&2; exit 2; } case "$VERSION" in [0-9]*.[0-9]*.[0-9]*) ;; *) echo "--version must be major.minor.patch" >&2; exit 2 ;; esac [ -f "$KEY" ] || { echo "no $KEY: run $SIGNER keygen $KEY $PUB once (the public key then goes into src/manifest.rs)" >&2; exit 1; } @@ -97,7 +101,7 @@ entry() { # -> "url sha256 size kind" MAC_ENTRY=""; WIN_ENTRY="" [ -n "$MAC" ] && MAC_ENTRY="$(entry "$MAC")" [ -n "$WIN" ] && WIN_ENTRY="$(entry "$WIN")" -OLD="$DEST/igneum-app-latest.json" +OLD="$DEST/$MANIFEST_NAME" if [ -f "$OLD" ]; then OLD_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version",""))' "$OLD" 2>/dev/null || true)" if [ "$OLD_VERSION" = "$VERSION" ]; then @@ -114,7 +118,7 @@ if [ -z "$MIN_SUPPORTED" ] && [ -f "$OLD" ]; then fi # canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes -NEW="$DEST/igneum-app-latest.json.new" +NEW="$DEST/$MANIFEST_NAME.new" python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" <<'PY' import json, sys, datetime out, version, channel, notes, min_supported, activation, deadline, mac, win = sys.argv[1:10] @@ -135,11 +139,11 @@ open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure PY "$SIGNER" sign "$KEY" "$NEW" > "$NEW.sig" "$SIGNER" verify "$PUB" "$NEW" "$NEW.sig" -mv "$NEW" "$DEST/igneum-app-latest.json" -mv "$NEW.sig" "$DEST/igneum-app-latest.json.sig" -echo "manifest: $DEST/igneum-app-latest.json" -cat "$DEST/igneum-app-latest.json"; echo -echo "signature: $(cat "$DEST/igneum-app-latest.json.sig")" +mv "$NEW" "$DEST/$MANIFEST_NAME" +mv "$NEW.sig" "$DEST/$MANIFEST_NAME.sig" +echo "manifest: $DEST/$MANIFEST_NAME" +cat "$DEST/$MANIFEST_NAME"; echo +echo "signature: $(cat "$DEST/$MANIFEST_NAME.sig")" echo "key fingerprint: $("$SIGNER" fingerprint "$PUB" | tail -1)" if [ "$DEPLOY" = 1 ]; then @@ -147,17 +151,17 @@ if [ "$DEPLOY" = 1 ]; then echo "deploying $DLSITE" (cd "$DLSITE" && npx --yes vercel@latest --global-config "$HOME/.config/igneum/vercel" deploy --prod --yes 2>&1 | grep -v "$TOKEN" || true) TMP="$(mktemp -d)" - curl -fsSL -o "$TMP/m.json" "$BASE/igneum-app-latest.json" && curl -fsSL -o "$TMP/m.sig" "$BASE/igneum-app-latest.json.sig" \ - && "$SIGNER" verify "$PUB" "$TMP/m.json" "$TMP/m.sig" && echo "live manifest verified at $BASE/igneum-app-latest.json" \ + curl -fsSL -o "$TMP/m.json" "$BASE/$MANIFEST_NAME" && curl -fsSL -o "$TMP/m.sig" "$BASE/$MANIFEST_NAME.sig" \ + && "$SIGNER" verify "$PUB" "$TMP/m.json" "$TMP/m.sig" && echo "live manifest verified at $BASE/$MANIFEST_NAME" \ || { echo "the live manifest is not reachable or does not verify yet; check the deploy output" >&2; rm -rf "$TMP"; exit 1; } rm -rf "$TMP" # the console's Builds tab (relay/): one build event and a fresh downloads listing; never fatal - node "$ROOT/tools/console.mjs" post --kind build --title "OTA manifest $VERSION ($CHANNEL) live" --body "$NOTES" >/dev/null 2>&1 || true + node "$ROOT/tools/console.mjs" post --kind build --title "OTA manifest $PRODUCT $VERSION ($CHANNEL) live" --body "$NOTES" >/dev/null 2>&1 || true node "$ROOT/tools/console.mjs" sync-dl >/dev/null 2>&1 || true else if [ -n "$DLSITE" ]; then echo "not deployed: cd $DLSITE && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes" - echo "then the apps see it at $BASE/igneum-app-latest.json (checked hourly, and from Settings > Check now)" + echo "then the apps see it at $BASE/$MANIFEST_NAME (checked hourly, and from Settings > Check now)" else echo "written to $DEST for $BASE (test manifest; not the downloads folder)" fi diff --git a/packaging/windows/Igneum-Wallet.iss b/packaging/windows/Igneum-Wallet.iss new file mode 100644 index 000000000..175c8da9a --- /dev/null +++ b/packaging/windows/Igneum-Wallet.iss @@ -0,0 +1,87 @@ +; Igneum Wallet installer for Windows, Inno Setup 6.3 or newer. A copy of Igneum-Miner.iss with the names, the AppId and +; the payload changed: the wallet engine (igneum-wallet.exe), "Igneum Wallet.exe" (the window host when +; BUILD-WALLET-APP.bat made it), igneumd.exe (started only when the miner's node is not running). Untested on a PC at +; the time of writing (4 October 2026): the wallet's Windows payload script is the follow-up. +#ifndef Payload + #define Payload "igneum-windows-wallet" +#endif +#ifndef ArtDir + #define ArtDir "..\..\brand\icons" +#endif +#ifndef AppVersion + #define AppVersion "0.1.0" +#endif +#define AppName "Igneum Wallet" +#define Publisher "Igneum" +#define Url "https://igneum.network" + +[Setup] +AppId={{B7D2E1F3-7C9E-4F8B-A042-3D6E9F8C0B12} +AppName={#AppName} +AppVersion={#AppVersion} +AppVerName={#AppName} {#AppVersion} +AppPublisher={#Publisher} +AppPublisherURL={#Url} +AppSupportURL={#Url} +AppUpdatesURL={#Url} +AppCopyright=Igneum. Nothing is bought or sold. +VersionInfoVersion={#AppVersion}.0 +VersionInfoCompany={#Publisher} +VersionInfoProductName={#AppName} +VersionInfoDescription={#AppName} Setup +DefaultDirName={localappdata}\Programs\{#AppName} +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +LicenseFile=LICENSE.txt +OutputDir=dist +OutputBaseFilename=Igneum-Wallet-Setup-{#AppVersion} +SetupIconFile={#ArtDir}\igneum.ico +UninstallDisplayIcon={app}\igneum.ico +UninstallDisplayName={#AppName} +WizardStyle=modern +WizardImageFile={#ArtDir}\inno-wizard-164x314.bmp +WizardSmallImageFile={#ArtDir}\inno-wizard-small-55x58.bmp +Compression=lzma2/max +SolidCompression=yes +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +PrivilegesRequired=lowest +MinVersion=10.0 +CloseApplications=yes +RestartApplications=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Messages] +english.WelcomeLabel2=This will install [name/ver] on your computer.%n%nYour key is made on this PC and kept encrypted with a password you choose. Nothing is bought or sold on this network. +english.FinishedHeadingLabel=Igneum Wallet is installed + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Files] +Source: "{#Payload}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion; Excludes: "*.log,*.DS_Store,build\*,dist\*" +Source: "{#ArtDir}\igneum.ico"; DestDir: "{app}"; Flags: ignoreversion +Source: "LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\Igneum Wallet"; Filename: "{app}\igneum-wallet.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Comment: "Open your Igneum wallet" +Name: "{group}\Igneum Wallet logs"; Filename: "{localappdata}\igneum\logs"; IconFilename: "{app}\igneum.ico"; Comment: "The folder the log files land in" +Name: "{group}\Uninstall Igneum Wallet"; Filename: "{uninstallexe}"; IconFilename: "{app}\igneum.ico" +Name: "{autodesktop}\Igneum Wallet"; Filename: "{app}\igneum-wallet.exe"; Parameters: "--launch"; WorkingDir: "{app}"; IconFilename: "{app}\igneum.ico"; Tasks: desktopicon + +[Run] +Filename: "{app}\igneum-wallet.exe"; Parameters: "--launch"; Description: "Open Igneum Wallet now"; Flags: postinstall nowait skipifsilent runasoriginaluser + +[UninstallDelete] +; the WebView2 cache is not in the install log; remove it with the folder. The vault in %LOCALAPPDATA%\igneum\wallet stays. +Type: filesandordirs; Name: "{app}" + +[Code] +procedure CurPageChanged(CurPageID: Integer); +begin + if CurPageID = wpFinished then + WizardForm.FinishedLabel.Caption := WizardForm.FinishedLabel.Caption + #13#10#13#10 + + 'Nobody from Igneum will ever ask for your words or your key.'; +end; diff --git a/tools/wallet-testnet/run.mjs b/tools/wallet-testnet/run.mjs new file mode 100644 index 000000000..397e7d8d6 --- /dev/null +++ b/tools/wallet-testnet/run.mjs @@ -0,0 +1,178 @@ +// Igneum Wallet v1 on a private test network. One igneumd (devnet-v4 integration build) on 127.0.0.1 ports 29580 +// and up (gRPC 29580, p2p 29581, wRPC JSON 29582, Ethereum RPC 29583; 29550 to 29579 were in use by the finality +// red-team runs on the night of 4 October 2026), network id igneum-devnet-958, the fast-time profile +// (infra/fast-time/override-60x.json) with genesis_bits lowered to 0x207fffff so the devnet-v4 igneum-miner's stub +// engine (kHeavyHash on one CPU thread, 300 ms per template) finds a block on every try and --hold-ms 1000 paces +// them to about one a second (Poisson). Two wallet engines (app/igneum-wallet) run against +// that node through IGNEUM_WALLET_GRPC_PORT / IGNEUM_WALLET_EVM_PORT and are driven over their own window API: +// 1. wallet A is created (24 words, three-word check); the miner pays to A's address +// 2. A's balance rises from block rewards and the rewards appear in its history +// 3. wallet B is created; A sends 1.5 IGN to B +// 4. the transfer goes pending -> in a block -> final with the checkpoint index, once A verified the certificate +// Prints a summary and the lines for docs/bench-log.md. Zero dependencies (Node 22). 4 October 2026. +// +// tools/lock/with-lock.sh run node tools/wallet-testnet/run.mjs [--secs 600] +// Environment: IGNEUMD, IGNEUM_MINER, IGNEUM_WALLET (binaries), IGNEUM_WT_KEEP=1 keeps the processes up at the end. + +import { spawn } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs'; + +const ROOT = new URL('../../', import.meta.url).pathname; +const MAIN = '/Users/joshm/Projects/igneum/'; // vendor clones live outside version control, next to the main checkout +const IGNEUMD = process.env.IGNEUMD || `${MAIN}vendor/igneum-node/target-integration/release/igneumd`; +const MINER = process.env.IGNEUM_MINER || `${MAIN}vendor/igneum-node/target-integration/release/igneum-miner`; +const WALLET = process.env.IGNEUM_WALLET || `${ROOT}app/igneum-wallet/target/debug/igneum-wallet`; +const FAST = `${ROOT}infra/fast-time/override-60x.json`; +const TMP = '/tmp/igneum-wallet-testnet'; +const GRPC = 29580, P2P = 29581, JSONP = 29582, EVM = 29583, SUFFIX = 958; +const SECS = Number((process.argv.find((a, i) => process.argv[i - 1] === '--secs')) || 600); +const t0 = Date.now(); +const since = () => ((Date.now() - t0) / 1000).toFixed(1); +const log = (...a) => console.log(`[${since().padStart(6)} s]`, ...a); +const sleep = ms => new Promise(r => setTimeout(r, ms)); +const procs = []; +function run(bin, args, name, env = {}) { + const out = openSync(`${TMP}/${name}.log`, 'a'); + // wallets: stdout piped for the URL line; node and miner: stdout into the log file (a closed pipe kills a Rust process) + const piped = name.startsWith('wallet'); + const p = spawn(bin, args, { stdio: ['pipe', piped ? 'pipe' : out, out], env: { ...process.env, ...env } }); + p.name = name; p.lines = []; if (piped) p.stdout.on('data', d => { for (const l of String(d).split('\n')) if (l.trim()) p.lines.push(l); }); + p.on('exit', (code, sig) => { p.exited = { code, sig }; log(`${name} exited`, code, sig || '', p.lines.slice(-3).join(' | ').replace(/\/t\/[0-9a-f]{16,}/g, '/t/')); }); + procs.push(p); + return p; +} +async function stopAll() { + for (const p of procs.reverse()) { + if (p.exited) continue; + try { p.kill(p.name === 'node' ? 'SIGTERM' : 'SIGINT'); } catch { } + for (let i = 0; i < 100 && !p.exited; i++) await sleep(100); + if (!p.exited) try { p.kill('SIGKILL'); } catch { } + } +} +for (const b of [IGNEUMD, MINER, WALLET]) if (!existsSync(b)) { console.error('missing binary', b); process.exit(2); } +process.on('uncaughtException', async e => { console.error('FAILED', e.message); await stopAll(); process.exit(1); }); +process.on('unhandledRejection', async e => { console.error('FAILED', e && e.message || e); await stopAll(); process.exit(1); }); +rmSync(TMP, { recursive: true, force: true }); +mkdirSync(TMP, { recursive: true }); +const override = `${TMP}/override.json`; +// edited as text: JSON.parse would turn the file's 18446744073709551615 (the never-activate heights) into a float +const fastText = readFileSync(FAST, 'utf8'); +if (!fastText.includes('"skip_proof_of_work": false')) throw new Error('fast-time file has no skip_proof_of_work line'); +// the devnet-v4 integration igneumd predates proving v0: its params file does not take that key (added 4 Oct 2026) +const easy = fastText.replace('"skip_proof_of_work": false', '"skip_proof_of_work": true').replace(/,\s*"proving_v0_activation_daa":\s*\d+/, '').replace(/"genesis_bits": \d+/, '"genesis_bits": 545259519'); +if (!easy.includes('545259519')) throw new Error('genesis_bits line not found'); +writeFileSync(override, easy); + +// ---- the node ---- +const nodeArgs = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', + `--appdir=${TMP}/node`, `--rpclisten=127.0.0.1:${GRPC}`, `--rpclisten-json=127.0.0.1:${JSONP}`, `--evm-rpclisten=127.0.0.1:${EVM}`, + `--listen=127.0.0.1:${P2P}`, `--override-params-file=${override}`, '--loglevel=info', '--yes']; +log('node:', IGNEUMD, nodeArgs.join(' ')); +run(IGNEUMD, nodeArgs, 'node'); +async function evm(method, params = []) { + const r = await fetch(`http://127.0.0.1:${EVM}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }); + const j = await r.json(); if (j.error) throw new Error(j.error.message); return j.result; +} +for (let i = 0; ; i++) { try { const c = await evm('eth_chainId'); log('node up, chain id', Number(c)); break; } catch { if (i > 120) { console.error('node did not come up'); await stopAll(); process.exit(1); } await sleep(1000); } } + +// ---- a wallet engine, driven over its API ---- +class Wallet { + constructor(name) { this.name = name; this.dir = `${TMP}/${name}`; mkdirSync(`${this.dir}/data`, { recursive: true }); mkdirSync(`${this.dir}/logs`, { recursive: true }); } + async start() { + this.p = run(WALLET, ['--no-open', '--print-url'], this.name, { IGNEUM_APP_DATA: `${this.dir}/data`, IGNEUM_APP_LOGS: `${this.dir}/logs`, IGNEUM_WALLET_GRPC_PORT: String(GRPC), IGNEUM_WALLET_EVM_PORT: String(EVM), IGNEUM_WALLET_NO_NODE: '1' }); + for (let i = 0; i < 100; i++) { const u = this.p.lines.find(l => l.startsWith('URL ')); if (u) { this.url = u.slice(4).trim().replace(/\/$/, ''); break; } await sleep(100); } + if (!this.url) throw new Error(`${this.name}: no URL line`); + this.origin = new URL(this.url).origin; + log(`${this.name} up at ${this.origin}/t//`); + return this; + } + async get(path) { const r = await fetch(this.url + path); const j = await r.json(); if (j.ok === false) throw new Error(j.error); return j; } + async post(path, body = {}) { + const r = await fetch(this.url + path, { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: this.origin }, body: JSON.stringify(body) }); + const j = await r.json(); if (!r.ok || j.ok === false) throw new Error(`${this.name} ${path}: ${j.error || r.status}`); return j; + } + async create(password) { + const r = await this.post('/api/create', { password }); + const words = r.words; if (words.length !== 24) throw new Error('not 24 words'); + const checks = [2, 11, 24].map(position => ({ position, word: words[position - 1] })); + const c = await this.post('/api/create/confirm', { checks }); + this.address = c.address; this.words = words; + log(`${this.name} created: ${c.display}`); + return c; + } + async state() { return this.get('/api/state'); } + async quit() { try { await this.post('/api/quit'); } catch { } } +} + +// ---- 1. wallet A, then the miner paying to it ---- +const A = await new Wallet('walletA').start(); +await A.create('test password A'); +// the wallet must refuse a wrong three-word check and the zero address later; try a wrong word on a fresh B first +const B = await new Wallet('walletB').start(); +{ + const r = await B.post('/api/create', { password: 'test password B' }); + let refused = false; + try { await B.post('/api/create/confirm', { checks: [{ position: 1, word: 'zzzz' }, { position: 2, word: r.words[1] }, { position: 3, word: r.words[2] }] }); } catch (e) { refused = true; log('B: wrong word refused:', e.message); } + if (!refused) throw new Error('a wrong word was accepted'); + const c = await B.post('/api/create/confirm', { checks: [1, 2, 3].map(position => ({ position, word: r.words[position - 1] })) }); + B.address = c.address; log(`walletB created: ${c.display}`); +} +const minerArgs = ['mine', `grpc://127.0.0.1:${GRPC}`, '1', String(SECS + 120), 'wallet-test', '--evm-address', A.address, '--hold-ms', '1000', '--status-secs', '15', '--network', 'devnet']; +log('miner:', MINER, minerArgs.join(' ')); +run(MINER, minerArgs, 'miner', { IGNEUM_POW_DAY_MS: '1440000' }); + +// ---- 2. rewards reach A ---- +let sA; +for (let i = 0; ; i++) { + sA = await A.state(); + if (sA.balance_known && BigInt(sA.balance_wei) > 0n) { log(`A balance ${sA.balance} IGN at block ${sA.node.block} (source ${sA.node.source})`); break; } + if (i % 10 === 0) log(`waiting for rewards: block ${sA.node.block}, node ${sA.node.state}, ${sA.node.message}`); + if (i > 180) throw new Error('no rewards after 3 min'); + await sleep(1000); +} +let rewardsSeen = 0; +for (let i = 0; i < 120; i++) { sA = await A.state(); rewardsSeen = sA.history.filter(e => e.kind === 'reward').length; if (rewardsSeen > 0) break; await sleep(1000); } +log(`A history: ${rewardsSeen} block rewards listed (scanned to ${sA.scanned_to})`); +const firstRewardAt = +since(); + +// ---- 3. the send (the zero address and a too-large amount are refused first) ---- +for (const [to, amount, why] of [['0x0000000000000000000000000000000000000000', '1', 'zero address'], [B.address, '999999999', 'more than the balance'], ['0x12', '1', 'short address']]) { + let refused = null; try { await A.post('/api/send/quote', { to, amount }); } catch (e) { refused = e.message; } + if (!refused) throw new Error(`${why} was not refused`); log(`refused as expected (${why}): ${refused}`); +} +const q = await A.post('/api/send/quote', { to: B.address, amount: '1.5' }); +log(`quote: ${q.value_ign} IGN to ${q.display_to}, gas ${q.gas}, base fee ${q.base_fee_gwei} gwei, tip ${q.tip_gwei} gwei, fee at most ${q.fee_max_ign} IGN, nonce ${q.nonce}, chain ${q.chain_id}`); +const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, ...quote } = q; +const sent = await A.post('/api/send', { quote }); +const sentAt = +since(); +log(`sent: ${sent.hash}`); + +// ---- 4. pending -> in a block -> final, with the checkpoint index ---- +const seen = {}; +let entry = null, finalAt = null, inBlockAt = null; +for (let i = 0; i < SECS; i++) { + const s = await A.state(); + entry = s.history.find(e => e.hash.toLowerCase() === sent.hash.toLowerCase()); + const st = entry ? entry.finality : 'unknown'; + if (!seen[st]) { seen[st] = +since(); log(`transfer is ${st}${entry && entry.block ? ` (block ${entry.block})` : ''}${entry && entry.checkpoint ? ` under checkpoint ${entry.checkpoint}` : ''}; wallet A verified checkpoint ${s.finality.verified_index || 'none'} (${s.finality.message})`); } + if (st === 'in_block' && inBlockAt == null) inBlockAt = +since(); + if (st === 'final') { finalAt = +since(); break; } + if (i % 30 === 0 && i) log(`t+${i}s: node locked ${s.node.latest_locked}, finality active ${s.node.finality_active}, verified ${s.finality.verified_index || 'none'}: ${s.finality.message}`); + await sleep(1000); +} +const sB = await B.state(); +const sA2 = await A.state(); +const tx = await A.get(`/api/tx/${sent.hash}`); +const summary = { + chain_id: sA2.node.chain_id, chain: sA2.node.chain, a: A.address, b: B.address, sent: sent.hash, + a_balance: sA2.balance, b_balance: sB.balance, b_received: sB.history.find(e => e.hash.toLowerCase() === sent.hash.toLowerCase())?.finality || 'not listed', + rewards_listed: sA2.history.filter(e => e.kind === 'reward').length, first_reward_s: firstRewardAt, sent_s: sentAt, in_block_s: inBlockAt, final_s: finalAt, + checkpoint: entry?.checkpoint ?? null, verified: sA2.finality, receipt_block: tx.receipt?.blockNumber ?? null, node_status: tx.node_status, + seconds_to_final: finalAt != null ? +(finalAt - sentAt).toFixed(1) : null, +}; +console.log('SUMMARY ' + JSON.stringify(summary)); +writeFileSync(`${TMP}/summary.json`, JSON.stringify(summary, null, 2)); +if (summary.final_s == null) console.log('RESULT not final within the time limit'); +else console.log(`RESULT final: ${summary.seconds_to_final} s from send to final under checkpoint ${summary.checkpoint}; B holds ${summary.b_balance} IGN (${summary.b_received})`); +if (process.env.IGNEUM_WT_KEEP !== '1') { await A.quit(); await B.quit(); await sleep(1500); await stopAll(); } +process.exit(summary.final_s == null ? 1 : 0); From c2f6b68c021663d3e616996eb07368988c4e617b Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:25:22 +0000 Subject: [PATCH 002/150] Igneum Wallet 0.1.1: the coin on the home screen, over-the-air updates v2 (unattended install) The 0.1.0 DMG was built before server.rs served /coin.png, so the coin was blank; 0.1.1 ships the route (brand/igneum-coin-1024.png, checked byte for byte through the engine). Updates: the apply side of the miner's ota.rs moved into igneum-common/src/ota.rs with the app's names from AppId (engine_exe added): stage next to the running bundle, digest, detached helper that swaps and relaunches, pending/result files, rollback when the new app does not start twice. fetch.rs downloads with resume and reports progress. The wallet's updater.rs runs check (25 s, then hourly), download, stage, apply in threads; the safe moment is no send in flight (/api/send running, a quote in the last 180 s, a sent transaction not yet in a block, a create flow half way). Setting "Install updates by itself when nothing is being sent" (default on), banner with Install now and Later, settings line with the states. Unit tests: manifest, versions, plan, safe moment, helper templates, pending/result files. build-wallet-dmg.sh takes BUILD= and refuses to wipe a work folder an app runs from. README with the states and what is untested (Windows path, LaunchServices relaunch with the window host, rollback). Verified end to end with a scratch 0.1.1 bundle against a 0.1.2 test manifest on 127.0.0.1: check, download, stage, swap, relaunch, "updated to Igneum Wallet 0.1.2 from 0.1.1". Co-Authored-By: Claude Fable 5.1 --- app/igneum-common/src/fetch.rs | 62 ++- app/igneum-common/src/lib.rs | 12 +- app/igneum-common/src/ota.rs | 524 ++++++++++++++++++++ app/igneum-wallet/Cargo.lock | 2 +- app/igneum-wallet/Cargo.toml | 2 +- app/igneum-wallet/README.md | 72 +++ app/igneum-wallet/src/engine.rs | 143 +++++- app/igneum-wallet/src/server.rs | 9 + app/igneum-wallet/src/state.rs | 21 +- app/igneum-wallet/src/updater.rs | 779 +++++++++++++++++++++++++++--- app/igneum-wallet/ui/app.js | 61 ++- app/igneum-wallet/ui/index.html | 7 +- packaging/README-ship.md | 14 + packaging/mac/build-wallet-dmg.sh | 8 +- 14 files changed, 1601 insertions(+), 115 deletions(-) create mode 100644 app/igneum-common/src/ota.rs create mode 100644 app/igneum-wallet/README.md diff --git a/app/igneum-common/src/fetch.rs b/app/igneum-common/src/fetch.rs index 70e7a0f6d..0f25a4968 100644 --- a/app/igneum-common/src/fetch.rs +++ b/app/igneum-common/src/fetch.rs @@ -1,7 +1,7 @@ //! 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 and checked against -//! the manifest's sha256 and size. +//! 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}; @@ -13,7 +13,8 @@ pub fn curl(args: &[&str], limit: Duration) -> Result<(), String> { 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(); - if code.starts_with("200") { + // 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 })) @@ -40,12 +41,29 @@ pub fn fetch_manifest(url: &str, dir: &Path) -> Result { 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 { - url.rsplit('/').next().unwrap_or("download").split('?').next().unwrap_or("download").to_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 } } -/// Downloads the platform entry into `dir` (skipped when a file with the right size and sha256 is there already) -/// and checks size and sha256. Returns the path. +/// 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 { let dest = dir.join(file_name(&e.url)); let ok = |p: &Path| -> bool { @@ -54,18 +72,36 @@ pub fn download(e: &PlatformEntry, dir: &Path) -> Result { if ok(&dest) { return Ok(dest); } - let tmp = dir.join(format!("{}.part", file_name(&e.url))); - curl_get(&e.url, &tmp, Duration::from_secs(1800))?; - let size = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0); + 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(&tmp); + let _ = std::fs::remove_file(&part); return Err(format!("the download is {size} bytes, the manifest says {}", e.size)); } - let sum = manifest::sha256_file(&tmp).map_err(|e| e.to_string())?; + let sum = manifest::sha256_file(&part).map_err(|e| e.to_string())?; if sum != e.sha256 { - let _ = std::fs::remove_file(&tmp); + let _ = std::fs::remove_file(&part); return Err("the download's sha256 does not match the manifest".into()); } - std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?; + 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"); + } +} diff --git a/app/igneum-common/src/lib.rs b/app/igneum-common/src/lib.rs index 4a9683860..91adeefe0 100644 --- a/app/igneum-common/src/lib.rs +++ b/app/igneum-common/src/lib.rs @@ -6,7 +6,9 @@ //! - `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 and its sha256 check (through curl, like the miner) +//! - `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 @@ -17,6 +19,7 @@ pub mod fetch; pub mod http; pub mod keys; pub mod manifest; +pub mod ota; pub mod platform; pub mod run; @@ -31,7 +34,10 @@ pub struct AppId { 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" }; -pub const WALLET: AppId = AppId { name: "Igneum Wallet", bundle: "network.igneum.wallet", host_exe: "Igneum Wallet.exe", data_sub: "wallet" }; +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" }; diff --git a/app/igneum-common/src/ota.rs b/app/igneum-common/src/ota.rs new file mode 100644 index 000000000..8dd51c3fe --- /dev/null +++ b/app/igneum-common/src/ota.rs @@ -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.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.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 { + parse_pending(&std::fs::read_to_string(pending_path(app_dir)).ok()?) +} + +pub fn parse_pending(text: &str) -> Option { + 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 { + parse_result(&std::fs::read_to_string(result_path(app_dir)).ok()?) +} + +pub fn parse_result(text: &str) -> Option { + 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 { + 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 /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 = 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 { + #[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 = 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 { + 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 { + 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 [env file] [digest] +# apply: waits for the engine (it exits right after starting this), asks the window to quit, moves the running +# bundle to ".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 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 -Installer -Version -Result -InstallDir -Sha256 +# 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); + } +} diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index 8e799b773..c519e31b8 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.0" +version = "0.1.1" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index fe73bb7a7..811eeb3fc 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.0" +version = "0.1.1" 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" diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md new file mode 100644 index 000000000..5d740e2e6 --- /dev/null +++ b/app/igneum-wallet/README.md @@ -0,0 +1,72 @@ +# 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) | + +## 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 --mac packaging/mac/dist/Igneum-Wallet-.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 `/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 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 --base-url http://127.0.0.1:`; 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". + +### 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 relaunch through LaunchServices (`open -n`) with the real window host, and the host quitting by bundle id + (`network.igneum.wallet`): the scratch test relaunches the engine alone. The first real run is 0.1.1 -> 0.1.2 on + the project lead's Mac. +- 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). diff --git a/app/igneum-wallet/src/engine.rs b/app/igneum-wallet/src/engine.rs index a19d4873f..829980ba0 100644 --- a/app/igneum-wallet/src/engine.rs +++ b/app/igneum-wallet/src/engine.rs @@ -41,6 +41,15 @@ impl Settings { pub fn load(p: &std::path::Path) -> Settings { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() } + pub fn save(&self, p: &std::path::Path) { + if let Some(d) = p.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Ok(t) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(p, t); + igneum_common::platform::lock_permissions(p, false); + } + } } pub struct Paths { @@ -56,6 +65,10 @@ pub enum Cmd { Refresh, CheckUpdate, OpenUpdate, + InstallUpdate, + AutoUpdate(bool), + /// the over-the-air updater's threads report here (src/updater.rs) + Ota(crate::updater::Event), /// a transaction this wallet just sent: watch it from the first tick Sent(Entry), } @@ -79,7 +92,6 @@ pub struct Shared { pub token: String, pub state: Mutex, pub rings: Mutex, - #[allow(dead_code)] // read by the window through state; kept for the next settings pub settings: Mutex, pub paths: Paths, pub packaged: Packaged, @@ -98,6 +110,18 @@ pub struct Shared { pub evm: Mutex>, pub verified: Mutex>, pub history: Mutex>, + /// /api/send calls running right now (the updater waits for zero) + sends: std::sync::atomic::AtomicU32, + /// when the last quote was given: a confirm screen may be open for QUOTE_HOLDS_S after it + last_quote: Mutex>, +} + +/// Counts one /api/send from entry to exit, whatever the outcome. +struct SendGuard<'a>(&'a Shared); +impl Drop for SendGuard<'_> { + fn drop(&mut self) { + self.0.sends.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } } impl Shared { @@ -145,9 +169,25 @@ impl Shared { evm: Mutex::new(None), verified: Mutex::new(None), history: Mutex::new(None), + sends: std::sync::atomic::AtomicU32::new(0), + last_quote: Mutex::new(None), } } + /// A send is in flight: /api/send is running, or a quote was given in the last QUOTE_HOLDS_S (the confirm + /// screen may be open). The engine adds "a sent transaction not yet in a block" from its watch list. + pub fn send_in_flight(&self) -> bool { + if self.sends.load(std::sync::atomic::Ordering::SeqCst) > 0 { + return true; + } + self.last_quote.lock().unwrap().map(|t| t.elapsed() < Duration::from_secs(crate::updater::QUOTE_HOLDS_S)).unwrap_or(false) + } + + /// The create flow is half way: the 24 words are on the screen, waiting for the confirmation. + pub fn creating(&self) -> bool { + self.pending_words.lock().unwrap().is_some() + } + /// IGNEUM-WALLET version= machine= platform= node=: the first line of the log, as the miner's /// IGNEUM-APP header, so the console parses either. pub fn header(&self) -> String { @@ -419,11 +459,14 @@ impl Shared { if total > balance { return Err(format!("not enough IGN: {} needed with the fee, {} in the wallet", crate::evm::ign(total, 6), crate::evm::ign(balance, 6))); } + *self.last_quote.lock().unwrap() = Some(Instant::now()); Ok(Quote { to, value: value.to_string(), gas, base_fee: base.to_string(), tip: tip.to_string(), max_fee: max_fee.to_string(), fee_max: fee_max.to_string(), total_max: total.to_string(), chain_id, nonce }) } /// Signs and sends what the window confirmed (the quote it was shown, verbatim). pub fn send_tx(&self, q: &Quote) -> Result { + self.sends.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _guard = SendGuard(self); let to = q.to.to_ascii_lowercase(); if !keys::valid_address(&to) || to == "0x0000000000000000000000000000000000000000" { return Err("refused: bad or zero address".into()); @@ -506,14 +549,23 @@ pub struct Engine { impl Engine { pub fn new(shared: Arc, rx: Receiver, wrapper: bool) -> Engine { - let url = std::env::var("IGNEUM_WALLET_UPDATE_MANIFEST").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| shared.packaged.update_manifest.clone()); - let updater = crate::updater::Updater::new(url, VERSION, &shared.paths.app_dir); + let updater = crate::updater::Updater::new(&shared); let now = Instant::now(); Engine { shared, rx, wrapper, grpc: None, own: None, own_plan: None, updater, last_source_try: now - Duration::from_secs(60), last_poll: now - Duration::from_secs(60), last_finality: now - Duration::from_secs(60), last_scan: now - Duration::from_secs(60), last_state_line: now, chain_name: String::new(), watch: vec![], scan_done_once: false } } pub fn run(mut self) { self.shared.log(&self.shared.header()); + if self.updater.needs_rollback() { + // this version died twice before it was healthy: the helper puts the previous one back + match self.updater.launch_rollback(&self.shared, self.host_pid()) { + Ok(()) => { + self.quit(); + return; + } + Err(e) => self.shared.event("error", &format!("rollback not possible: {e}")), + } + } loop { while let Ok(c) = self.rx.try_recv() { match c { @@ -525,7 +577,10 @@ impl Engine { self.last_poll = Instant::now() - Duration::from_secs(60); self.last_scan = Instant::now() - Duration::from_secs(60); } - Cmd::CheckUpdate => self.check_update(), + Cmd::CheckUpdate => self.updater.check_now(&self.shared), + Cmd::InstallUpdate => self.updater.install_now(&self.shared), + Cmd::AutoUpdate(on) => self.updater.set_auto(&self.shared, on), + Cmd::Ota(ev) => self.updater.event(&self.shared, ev), Cmd::OpenUpdate => { if let Err(e) = self.updater.open_file() { self.shared.event("error", &format!("could not open the download: {e}")); @@ -555,8 +610,11 @@ impl Engine { self.last_scan = Instant::now(); self.scan(); } - if self.updater.due() { - self.check_update(); + let ctx = crate::updater::Ctx { send_in_flight: self.shared.send_in_flight() || !self.watch.is_empty(), creating: self.shared.creating() }; + if let Some(crate::updater::Action::Apply) = self.updater.tick(&self.shared, &ctx) { + if self.apply_update() { + return; + } } if self.wrapper && self.last_state_line.elapsed() >= Duration::from_secs(2) { self.last_state_line = Instant::now(); @@ -567,6 +625,59 @@ impl Engine { } } + /// Hands over to the update helper. macOS: the engine then leaves through the quit path (the node stops, EXIT for + /// the window) and returns true; the helper waits for this process to end before it swaps the app. Windows: the + /// installer runs first and stops this engine itself; false, the loop goes on. + fn apply_update(&mut self) -> bool { + let v = self.updater.version(); + match self.updater.launch_apply(&self.shared, self.host_pid()) { + Ok(igneum_common::ota::Launch::QuitNow) => { + { + let mut st = self.shared.state.lock().unwrap(); + st.update.applying = true; + st.update.status = "applying".into(); + st.update.wait = String::new(); + } + self.shared.event("info", &format!("installing Igneum Wallet {v}: the app closes and opens again by itself")); + if self.wrapper { + println!("STATE {}", self.shared.wrapper_state()); + let _ = std::io::stdout().flush(); + } + self.quit(); + true + } + Ok(igneum_common::ota::Launch::InstallerRunning) => { + { + let mut st = self.shared.state.lock().unwrap(); + st.update.applying = true; + st.update.status = "applying".into(); + st.update.wait = "the installer is starting; if Windows asks for permission the wallet keeps running until it is given".into(); + } + self.shared.event("info", &format!("installing Igneum Wallet {v}: the installer runs first, then the app opens again")); + false + } + Err(e) => { + self.shared.event("error", &format!("the update could not start: {e}")); + let mut st = self.shared.state.lock().unwrap(); + st.update.error = e; + st.update.status = "error".into(); + false + } + } + } + + /// The window host's pid when the engine runs under one (macOS: the helper asks it to quit). + fn host_pid(&self) -> u32 { + #[cfg(unix)] + { + if self.wrapper { unsafe { libc::getppid() as u32 } } else { 0 } + } + #[cfg(not(unix))] + { + 0 + } + } + fn quit(&mut self) { self.shared.state.lock().unwrap().quitting = true; self.shared.lock(); @@ -923,24 +1034,4 @@ impl Engine { } } } - - fn check_update(&mut self) { - if self.updater.url.is_empty() { - return; - } - self.shared.state.lock().unwrap().update.status = "checking".into(); - let r = self.updater.check(); - let mut st = self.shared.state.lock().unwrap(); - st.update.status = self.updater.status.clone(); - st.update.error = self.updater.error.clone(); - st.update.checked_at = self.updater.checked_at; - st.update.version = self.updater.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default(); - st.update.notes = self.updater.manifest.as_ref().map(|m| m.notes.clone()).unwrap_or_default(); - st.update.file = self.updater.file.as_ref().map(|f| f.display().to_string()).unwrap_or_default(); - drop(st); - match r { - Ok(m) => self.shared.log(&format!("update check: {m}")), - Err(e) => self.shared.log(&format!("update check failed: {e}")), - } - } } diff --git a/app/igneum-wallet/src/server.rs b/app/igneum-wallet/src/server.rs index 08abcc1bd..14d617786 100644 --- a/app/igneum-wallet/src/server.rs +++ b/app/igneum-wallet/src/server.rs @@ -145,6 +145,15 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + 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://") { diff --git a/app/igneum-wallet/src/state.rs b/app/igneum-wallet/src/state.rs index 67b9dd22a..165a91fca 100644 --- a/app/igneum-wallet/src/state.rs +++ b/app/igneum-wallet/src/state.rs @@ -33,20 +33,37 @@ pub struct FinalityView { 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, // unknown | checking | current | available | downloading | ready | error | off + 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, + 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, } #[derive(Clone, Serialize)] diff --git a/app/igneum-wallet/src/updater.rs b/app/igneum-wallet/src/updater.rs index e38fd90a9..77d6de29d 100644 --- a/app/igneum-wallet/src/updater.rs +++ b/app/igneum-wallet/src/updater.rs @@ -1,84 +1,617 @@ -//! Over-the-air updates for the wallet, the first cut: the signed manifest (`igneum-wallet-latest.json`, the same -//! Ed25519 key as the miner's, igneum_common::manifest) checked an hour apart, the disk image or installer downloaded -//! and checked against the manifest's sha256, then "Install now" opens it. No unattended swap yet: the wallet has no -//! safe-moment logic to borrow from the miner (nothing mines here) and the macOS swap helper lives in the miner's -//! src/ota.rs; that is the follow-up. +//! 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 project lead'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 /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), + /// The disk image or installer on disk, size and sha256 checked. + Downloaded(Result), + /// macOS: the new bundle staged next to the running one. Windows: the installer path again. + Staged(Result), +} + +/// 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, - pub dir: PathBuf, - pub next: Instant, - pub manifest: Option, - pub entry: Option, - pub file: Option, - pub status: String, - pub error: String, - pub checked_at: f64, + app_dir: PathBuf, + dir: PathBuf, + auto: bool, + manifest: Option, + entry: Option, + file: Option, + staged: Option, + staged_digest: String, + busy: bool, + next_check: Instant, + ready_since: Option, + install_asked: bool, + pending: Option, + started: Instant, + healthy_marked: bool, + /// versions whose apply failed or that were rolled back: never re-applied by themselves + failed_versions: Vec, + /// 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, + /// Windows: the administrator prompt was not answered; no automatic retry before this + deferred_until: Option, } impl Updater { - pub fn new(url: String, current: &str, app_dir: &Path) -> Updater { + pub fn new(shared: &Arc) -> 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 status = if url.is_empty() { "off" } else { "unknown" }; - Updater { url, current: current.to_string(), dir, next: Instant::now() + Duration::from_secs(25), manifest: None, entry: None, file: None, status: status.into(), error: String::new(), checked_at: 0.0 } - } - - pub fn due(&self) -> bool { - !self.url.is_empty() && Instant::now() >= self.next - } - - /// One check: manifest, newer?, download. Blocking; the engine calls it from its own thread. - pub fn check(&mut self) -> Result { - self.next = Instant::now() + Duration::from_secs(3600); - self.checked_at = igneum_common::platform::unix_now_f(); - self.status = "checking".into(); - let m = match fetch::fetch_manifest(&self.url, &self.dir) { - Ok(m) => m, - Err(e) => { - self.status = "error".into(); - self.error = e.clone(); - self.next = Instant::now() + Duration::from_secs(600); - return Err(e); - } + 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, }; - self.error.clear(); - if !manifest::newer(&m.version, &self.current) { - self.status = "current".into(); - self.manifest = Some(m); - return Ok("current".into()); + u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::>(&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 Some(e) = m.this_platform().cloned() else { - self.status = "current".into(); - self.manifest = Some(m); - return Ok("no build for this platform yet".into()); - }; - self.status = "downloading".into(); - self.entry = Some(e.clone()); - let v = m.version.clone(); - self.manifest = Some(m); - match fetch::download(&e, &self.dir) { - Ok(p) => { - self.file = Some(p); - self.status = "ready".into(); - Ok(format!("{v} downloaded and checked")) + { + 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, 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) { + 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; } - Err(err) => { - self.status = "error".into(); - self.error = err.clone(); - Err(err) + } + 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) { + 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, 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) { + 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, 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, ctx: &Ctx) -> Option { + 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, 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) { + if self.busy { + return; + } + self.clear_error(shared); + self.start_check(shared); + } + + fn start_check(&mut self, shared: &Arc) { + 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, 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) { + 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, 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) { + 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")?; + 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)] @@ -87,4 +620,132 @@ impl Updater { 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, host_pid: u32) -> Result { + 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, 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()); + } } diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index 7a106bb59..a3fc74455 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -47,11 +47,7 @@ function render() { if (s.quitting) { pillText = 'stopping'; pillCls = 'pill'; } $('pill-text').textContent = pillText; $('pill').className = pillCls; $('welcome-eyebrow').textContent = `${s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network} · nothing is bought or sold`; - // update banner - const u = s.update; - $('update-banner').hidden = !(u.status === 'ready' && !sessionStorage.getItem('update-later-' + u.version)); - $('update-text').textContent = `Igneum Wallet ${u.version} is downloaded and checked.${u.notes ? ' ' + u.notes : ''}`; - $('update-note').textContent = u.status === 'off' ? 'updates are off in this build' : u.status === 'ready' ? `${u.version} downloaded` : u.status === 'error' ? u.error : u.status === 'current' ? 'up to date' : u.status; + renderUpdate(s); // unlock $('unlock-address').textContent = s.display; // home @@ -251,9 +247,60 @@ $('export-show').onclick = async () => { $('export-copy').onclick = () => copy($('export-key').textContent, 'private key'); $('export-hide').onclick = () => { $('export-out').hidden = true; $('export-key').textContent = ''; }; $('start-login').onchange = async ev => { try { await post('/api/settings', { start_at_login: ev.target.checked }); } catch (e) { toast(e.message); } }; -$('update-check').onclick = () => post('/api/update/check'); + +// ---- over-the-air updates (src/updater.rs): one banner, the settings line ---- +function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } +function updateLine(u) { + const v = 'Igneum Wallet ' + u.version; + if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' }; + switch (u.status) { + case 'available': return { text: v + ' is available. Downloading it.' }; + case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true }; + case 'staging': return { text: v + ' downloaded and verified. Preparing it.' }; + case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs as soon as nothing is being sent.'), install: true }; + case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the app closes and opens again by itself.') }; + case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC.', install: true }; + case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true }; + case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) }; + default: return null; + } +} +function updateNote(u) { + const l = updateLine(u), parts = []; + if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.'); + if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.'); + if (u.status === 'off') parts.push('Updates are off in this build.'); + else if (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text); + else if (u.status === 'checking') parts.push('Checking.'); + else if (u.status === 'current') parts.push('This is the latest version.'); + else if (u.error) parts.push(u.error); + else parts.push('Not checked yet.'); + return parts.join(' '); +} +function renderUpdate(s) { + const u = s.update, b = $('update-banner'), l = updateLine(u); + const key = u.status + ':' + u.version; + const show = !!l && (u.urgent || u.applying || sessionStorage.getItem('update-later') !== key); + if (show) { + $('update-text').textContent = l.text; + b.classList.toggle('urgent', !!l.urgent); + $('update-install').hidden = !l.install || u.applying; + $('update-open').hidden = !l.open; + $('update-later').hidden = !!l.urgent || !!u.applying; + $('update-prog').hidden = !l.prog; + $('update-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%'; + } + b.hidden = !show; + $('update-note').textContent = updateNote(u); + $('update-install-s').hidden = !(l && l.install) || u.applying; + if (document.activeElement !== $('auto-update')) $('auto-update').checked = !!s.settings.auto_update; +} +$('update-check').onclick = () => { post('/api/update/check'); toast('checking for updates'); }; $('update-open').onclick = () => post('/api/update/open'); -$('update-later').onclick = () => { sessionStorage.setItem('update-later-' + state.update.version, '1'); $('update-banner').hidden = true; }; +$('update-install').onclick = () => { post('/api/update/install'); toast('installing now'); }; +$('update-install-s').onclick = () => { post('/api/update/install'); toast('installing now'); }; +$('update-later').onclick = () => { sessionStorage.setItem('update-later', state.update.status + ':' + state.update.version); $('update-banner').hidden = true; }; +$('auto-update').onchange = async ev => { try { await post('/api/update/auto', { on: ev.target.checked }); } catch (e) { toast(e.message); } }; $('remove-go').onclick = async () => { $('remove-err').textContent = ''; if (!confirm('Remove this wallet from this machine? Only your 24 words or the key bring it back.')) return; diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index 538e4ec18..e0bbbb9ec 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -26,8 +26,10 @@
    @@ -240,8 +242,9 @@

    This machine

    +
    -
    +

    Remove this wallet from this machine

    diff --git a/packaging/README-ship.md b/packaging/README-ship.md index 685c040fa..7023ec4b9 100644 --- a/packaging/README-ship.md +++ b/packaging/README-ship.md @@ -84,3 +84,17 @@ from the repository root (the project's root directory is `site`), but the norma (`igneum-relay`) and the downloads folder (`igneum-dl`) are the other two projects in the `igneum` team; both deploy by CLI from their own folders (`relay/README.md`, `packaging/ota/README.md`). CLAUDE.md still says the site sits in the [other-business] team and deploys with `--scope [other-business]` from `site/`: that was true on 3 October and is not now. + +## Igneum Wallet (5 October 2026) + +The wallet has no ship script yet; three commands cut a Mac version, each under the build lock where it builds: + + tools/lock/with-lock.sh build packaging/mac/build-wallet-dmg.sh # version from app/igneum-wallet/Cargo.toml + packaging/ota/publish-manifest.sh --product wallet --version --mac packaging/mac/dist/Igneum-Wallet-.dmg --notes "..." --deploy + cp packaging/mac/dist/Igneum-Wallet-.dmg ~/Desktop/ # the hand-install copy + +The manifest is `igneum-wallet-latest.json` next to the miner's, same key. Installed wallets (0.1.1 and later) swap +themselves in: states, the safe moment and what is still untested are in `app/igneum-wallet/README.md`. A 0.1.0 +wallet only downloads the DMG and offers "Open the download". `build-wallet-dmg.sh` refuses to wipe a work folder an +app is running from; build with `BUILD=` then. Windows: the wallet installer is built by the runner +from `packaging/windows/Igneum-Wallet.iss` and its over-the-air path is untested. diff --git a/packaging/mac/build-wallet-dmg.sh b/packaging/mac/build-wallet-dmg.sh index 5b21cff10..b7a70a75f 100755 --- a/packaging/mac/build-wallet-dmg.sh +++ b/packaging/mac/build-wallet-dmg.sh @@ -14,6 +14,9 @@ # packaging/mac/build-wallet-dmg.sh build (the version is app/igneum-wallet/Cargo.toml's; VERSION= overrides it) # packaging/ota/publish-manifest.sh --product wallet --version --mac dist/Igneum-Wallet-.dmg --notes "..." # NODE= ENGINE= use other binaries +# BUILD= the work folder (default packaging/mac/build-wallet); the script refuses to +# wipe a folder an app is running from (5 October 2026: the staged bundle had +# been opened by hand and was still running) # Runs under the build lock: tools/lock/with-lock.sh build packaging/mac/build-wallet-dmg.sh set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" @@ -22,7 +25,7 @@ VERSION="${VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-wall NODE="${NODE:-$ROOT/vendor/igneum-node/target-integration/release/igneumd}" ENGINE="${ENGINE:-}" ICONS="$ROOT/brand/icons" -BUILD="$HERE/build-wallet" +BUILD="${BUILD:-$HERE/build-wallet}" DIST="$HERE/dist" DMG="$DIST/Igneum-Wallet-$VERSION.dmg" STAGE="$BUILD/dmg" @@ -36,6 +39,9 @@ file "$NODE" | grep -q 'arm64' || { echo "$NODE is not an arm64 binary"; exit 1; for f in igneum.icns igneum-volume.icns; do [ -f "$ICONS/$f" ] || { echo "no $ICONS/$f; run: python3 brand/icons/make-icons.py"; exit 1; }; done command -v swiftc >/dev/null 2>&1 || { echo "swiftc is needed for the window (xcode-select --install)"; exit 1; } +if pgrep -f "$BUILD/" >/dev/null 2>&1; then + echo "something is running from $BUILD (pgrep -f \"$BUILD/\"); quit it or build with BUILD="; exit 1 +fi rm -rf "$BUILD" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources/bin" "$DIST" "$BUILD/window" From 96a9729de4292e1d3dbc8c12d2f1f0c9567fb8b7 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:12:09 +0000 Subject: [PATCH 003/150] Igneum Wallet 0.1.2: Touch ID (unlock, every send, the backup, idle lock), Windows Hello written untested; the coin and the chain line on the balance card; the version in the header and Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window host owns the prompt and the secret (app/mac/Biometric.swift): LAPolicy.deviceOwnerAuthenticationWithBiometrics with "Use password" as the fallback button (never the device password), the wallet's password sealed to a Secure Enclave key made with .biometryCurrentSet (the Keychain refuses biometric access controls under the ad hoc signature, -34018, measured) in /wallet/biometric.json; a fingerprint change invalidates it. The engine owns the gate (igneum-common/src/biometric.rs): a nonce per action, read by the host with its token (the HOST line on stdout, X-Igneum-Host on host-only calls), confirmed after the prompt, taken once within 30 s and bound to the exact quote; /api/send refuses without it while enrolled; /api/reveal with a nonce reads the unlocked key in memory; the password never goes through the page (enrolment parks it under a one-time token the host takes). Idle lock after 5 minutes without window activity (setting, default on). A password change or a wallet removal deletes the sealed file. Reason lines in our voice ("Unlock your wallet", "Send 1.5 IGN to 0x7E5F…5Bdf", "Show your recovery words"); the page shows its own ember line after every prompt. Windows: app/windows/biometric.h (UserConsentVerifier through IUserConsentVerifierInterop, DPAPI), wired into wallet-host.cpp and BUILD-WALLET-APP.bat, not yet compiled on a PC. Hosts gain a @main entry so Biometric.swift compiles alongside; build-wallet-dmg.sh links LocalAuthentication. Balance card: the coin at 56 px, "0" (or the balance) as soon as the node answers, "reading the chain, N of M blocks" under it while the history scans. Version: v0.1.2 in the brand band, "Igneum Wallet 0.1.2 · up to date" in Settings. Unit tests: the gate (7, igneum-common), the wallet's 17 still green. README: the flows, the threat model, what was verified on this Mac (enrol and unlock through the real prompt) and what was not. Co-Authored-By: Claude Fable 5.1 --- app/igneum-common/src/biometric.rs | 352 ++++++++++++++++++++++++++ app/igneum-common/src/http.rs | 8 +- app/igneum-common/src/lib.rs | 3 + app/igneum-wallet/Cargo.lock | 2 +- app/igneum-wallet/Cargo.toml | 2 +- app/igneum-wallet/README.md | 105 ++++++++ app/igneum-wallet/src/engine.rs | 224 ++++++++++++++++- app/igneum-wallet/src/main.rs | 15 +- app/igneum-wallet/src/server.rs | 59 ++++- app/igneum-wallet/src/state.rs | 3 + app/igneum-wallet/ui/app.css | 18 ++ app/igneum-wallet/ui/app.js | 173 ++++++++++++- app/igneum-wallet/ui/index.html | 40 ++- app/mac/Biometric.swift | 328 ++++++++++++++++++++++++ app/mac/IgneumWallet.swift | 86 +++++-- app/windows/BUILD-WALLET-APP.bat | 6 +- app/windows/biometric.h | 377 ++++++++++++++++++++++++++++ app/windows/wallet-host.cpp | 29 +++ app/windows/wallet-version.h | 4 +- packaging/mac/build-wallet-dmg.sh | 5 +- packaging/windows/Igneum-Wallet.iss | 2 +- 21 files changed, 1778 insertions(+), 63 deletions(-) create mode 100644 app/igneum-common/src/biometric.rs create mode 100644 app/mac/Biometric.swift create mode 100644 app/windows/biometric.h diff --git a/app/igneum-common/src/biometric.rs b/app/igneum-common/src/biometric.rs new file mode 100644 index 000000000..df08b41fe --- /dev/null +++ b/app/igneum-common/src/biometric.rs @@ -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, + used: bool, +} + +#[derive(Default)] +pub struct Gate { + challenges: Vec, + 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 { + 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); + } +} diff --git a/app/igneum-common/src/http.rs b/app/igneum-common/src/http.rs index a5df92efb..08c4a23c5 100644 --- a/app/igneum-common/src/http.rs +++ b/app/igneum-common/src/http.rs @@ -13,6 +13,8 @@ pub struct Req { pub origin: Option, pub sec_fetch_site: Option, pub host: Option, + /// X-Igneum-Host: the window host's token (the engine printed it on stdout; the page never sees it) + pub host_token: Option, } pub fn read_request(stream: &mut TcpStream) -> Option { @@ -24,7 +26,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option { 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) = (None, None, None); + let (mut origin, mut sec_fetch_site, mut host, mut host_token) = (None, None, None, None); loop { let mut h = String::new(); reader.read_line(&mut h).ok()?; @@ -42,6 +44,8 @@ pub fn read_request(stream: &mut TcpStream) -> Option { 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()); } } } @@ -56,7 +60,7 @@ pub fn read_request(stream: &mut TcpStream) -> Option { Some((p, q)) => (p.to_string(), q.to_string()), None => (target, String::new()), }; - Some(Req { method, path, query, body, origin, sec_fetch_site, host }) + Some(Req { method, path, query, body, origin, sec_fetch_site, host, host_token }) } /// R4.3.7: a mutating request must come from the dashboard itself. A browser sends Sec-Fetch-Site (same-origin for diff --git a/app/igneum-common/src/lib.rs b/app/igneum-common/src/lib.rs index 91adeefe0..3433476c2 100644 --- a/app/igneum-common/src/lib.rs +++ b/app/igneum-common/src/lib.rs @@ -13,7 +13,10 @@ //! 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; diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index c519e31b8..c87ebe423 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.1" +version = "0.1.2" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index 811eeb3fc..190bfb891 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.1" +version = "0.1.2" 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" diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md index 5d740e2e6..b3186dc92 100644 --- a/app/igneum-wallet/README.md +++ b/app/igneum-wallet/README.md @@ -11,6 +11,111 @@ packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet |---|---|---| | 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 | + +## 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 and "Lock after 5 minutes idle" on (the default), the engine zeroes the key after 5 +minutes 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 the prompt again, or the password. + +### 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) diff --git a/app/igneum-wallet/src/engine.rs b/app/igneum-wallet/src/engine.rs index 829980ba0..a9ba0aae0 100644 --- a/app/igneum-wallet/src/engine.rs +++ b/app/igneum-wallet/src/engine.rs @@ -7,6 +7,7 @@ use crate::history::{Entry, History}; use crate::node::{Grpc, OwnNode, Source}; use crate::state::{Rings, State}; use crate::vault::{self, Secret}; +use igneum_common::biometric::{self, BiometricState, Gate, GateError}; use igneum_common::config::Packaged; use igneum_common::keys; use serde::{Deserialize, Serialize}; @@ -28,13 +29,17 @@ pub struct Settings { /// the last block the window scrolled to; display only #[serde(default)] pub display_name: String, + /// lock after IDLE_LOCK_MIN minutes without the window reporting activity; only acts while Touch ID or Windows + /// Hello is enrolled (the password still works) + #[serde(default = "yes")] + pub idle_lock: bool, } fn yes() -> bool { true } impl Default for Settings { fn default() -> Settings { - Settings { auto_update: true, display_name: String::new() } + Settings { auto_update: true, display_name: String::new(), idle_lock: true } } } impl Settings { @@ -58,6 +63,8 @@ pub struct Paths { pub vault: PathBuf, pub settings: PathBuf, pub miner_wallet: PathBuf, + /// the sealed password (macOS: a Secure Enclave key blob + AES-GCM box; Windows: DPAPI), written by the window host + pub biometric: PathBuf, } pub enum Cmd { @@ -114,6 +121,12 @@ pub struct Shared { sends: std::sync::atomic::AtomicU32, /// when the last quote was given: a confirm screen may be open for QUOTE_HOLDS_S after it last_quote: Mutex>, + /// the window host's token (printed on stdout as HOST {...}; the page never sees it): the host's calls carry it + pub host_token: String, + /// the biometric gate: challenges, confirmations, the one-time enrolment token + gate: Mutex, + /// the last time the window reported a person at it (the idle lock) + last_activity: Mutex, } /// Counts one /api/send from entry to exit, whatever the outcome. @@ -125,7 +138,7 @@ impl Drop for SendGuard<'_> { } impl Shared { - pub fn new(token: String, paths: Paths, packaged: Packaged, settings: Settings, machine_id: String, cmd_tx: Sender, engine_log: Option, log_path: PathBuf) -> Shared { + pub fn new(token: String, host_token: String, paths: Paths, packaged: Packaged, settings: Settings, machine_id: String, cmd_tx: Sender, engine_log: Option, log_path: PathBuf) -> Shared { let mut st = State { version: VERSION.into(), ..Default::default() }; let v = vault::load(&paths.vault); st.has_vault = v.is_some(); @@ -149,6 +162,8 @@ impl Shared { st.machine_id = machine_id.clone(); st.host = igneum_common::platform::host_label(); st.log_dir = paths.log_dir.display().to_string(); + st.app_dir = paths.app_dir.display().to_string(); + st.biometric = BiometricState { enrolled: biometric_file_present(&paths.biometric), idle_lock: settings.idle_lock, idle_lock_min: biometric::IDLE_LOCK_MIN, ..Default::default() }; st.update.status = if packaged.update_manifest.is_empty() { "off".into() } else { "unknown".into() }; Shared { token, @@ -171,6 +186,9 @@ impl Shared { history: Mutex::new(None), sends: std::sync::atomic::AtomicU32::new(0), last_quote: Mutex::new(None), + host_token, + gate: Mutex::new(Gate::new()), + last_activity: Mutex::new(Instant::now()), } } @@ -328,7 +346,7 @@ impl Shared { Ok(json!({ "ok": true, "address": address, "display": keys::checksum(&address), "source": source })) } - pub fn unlock(&self, password: &str) -> Result { + pub fn unlock(&self, password: &str, via_host: bool) -> Result { let v = vault::load(&self.paths.vault).ok_or("no wallet on this machine")?; let s = vault::open(&v, password)?; let d = crate::hd::parse_private_key(&s.private_key)?; @@ -341,7 +359,8 @@ impl Shared { st.unlocked = true; st.phase = "home".into(); } - self.log("unlocked"); + self.touch_activity(); + self.log(if via_host { "unlocked with the biometric prompt" } else { "unlocked with the password" }); self.send(Cmd::Refresh); Ok(json!({ "ok": true })) } @@ -367,6 +386,17 @@ impl Shared { Ok(json!({ "ok": true, "words": s.mnemonic.as_ref().map(|w| w.split(' ').collect::>()), "private_key": s.private_key, "address": v.address, "display": keys::checksum(&v.address) })) } + /// The backup sheet after a biometric confirmation: the words from the unlocked key in memory (the password is + /// not asked and not stored anywhere the engine can read). + pub fn reveal_confirmed(&self, nonce: &str) -> Result { + self.take_nonce(nonce, "reveal", "")?; + let v = vault::load(&self.paths.vault).ok_or("no wallet")?; + let guard = self.secret.lock().unwrap(); + let s = guard.as_ref().ok_or("unlock first")?; + self.log(&format!("the backup was shown in the window (after {})", self.what())); + Ok(json!({ "ok": true, "words": s.mnemonic.as_ref().map(|w| w.split(' ').collect::>()), "private_key": s.private_key, "address": v.address, "display": keys::checksum(&v.address) })) + } + pub fn mark_backed_up(&self) -> Result { let mut v = vault::load(&self.paths.vault).ok_or("no wallet")?; v.backed_up = true; @@ -383,6 +413,14 @@ impl Shared { nv.created = v.created; vault::save(&self.paths.vault, &nv)?; self.log("password changed"); + if self.biometric_remove_file() { + let what = self.what(); + let mut st = self.state.lock().unwrap(); + st.biometric.enrolled = false; + st.biometric.needs_enrol = true; + drop(st); + self.event("info", &format!("{what} was turned off: the sealed password is stale; turn it on again in Settings")); + } Ok(json!({ "ok": true })) } @@ -392,7 +430,10 @@ impl Shared { vault::open(&v, password)?; self.lock(); std::fs::remove_file(&self.paths.vault).map_err(|e| e.to_string())?; + self.biometric_remove_file(); let mut st = self.state.lock().unwrap(); + st.biometric.enrolled = false; + st.biometric.needs_enrol = false; st.has_vault = false; st.phase = "welcome".into(); st.address.clear(); @@ -463,14 +504,20 @@ impl Shared { Ok(Quote { to, value: value.to_string(), gas, base_fee: base.to_string(), tip: tip.to_string(), max_fee: max_fee.to_string(), fee_max: fee_max.to_string(), total_max: total.to_string(), chain_id, nonce }) } - /// Signs and sends what the window confirmed (the quote it was shown, verbatim). - pub fn send_tx(&self, q: &Quote) -> Result { + /// Signs and sends what the window confirmed (the quote it was shown, verbatim). With Touch ID or Windows Hello + /// enrolled, `nonce` must be a confirmation the host posted for this very quote within CHALLENGE_TTL_S. + pub fn send_tx(&self, q: &Quote, nonce: Option<&str>) -> Result { self.sends.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let _guard = SendGuard(self); let to = q.to.to_ascii_lowercase(); if !keys::valid_address(&to) || to == "0x0000000000000000000000000000000000000000" { return Err("refused: bad or zero address".into()); } + if self.enrolled() { + let bound = biometric::send_binding(&to, &q.value, q.nonce, q.chain_id); + self.take_nonce(nonce.unwrap_or(""), "send", &bound).map_err(|e| format!("refused: {e}"))?; + } + self.touch_activity(); let value: u128 = q.value.parse().map_err(|_| "bad value")?; let max_fee: u128 = q.max_fee.parse().map_err(|_| "bad fee")?; let tip: u128 = q.tip.parse().map_err(|_| "bad tip")?; @@ -525,6 +572,167 @@ impl Shared { self.state.lock().unwrap().settings.start_at_login = on; Ok(json!({ "ok": true })) } + + // ---- Touch ID / Windows Hello (igneum_common::biometric; the prompt and the sealed password live in the host) ---- + + /// "Touch ID" or "Windows Hello", for messages. + pub fn what(&self) -> String { + let k = self.state.lock().unwrap().biometric.kind.clone(); + match k.as_str() { + "hello" => "Windows Hello".into(), + "touchid" => "Touch ID".into(), + _ if cfg!(windows) => "Windows Hello".into(), + _ => "Touch ID".into(), + } + } + pub fn enrolled(&self) -> bool { + self.state.lock().unwrap().biometric.enrolled + } + /// The host's token on a request, in constant time. + pub fn host_ok(&self, given: Option<&str>) -> bool { + !self.host_token.is_empty() && given.map(|g| biometric::constant_eq(g, &self.host_token)).unwrap_or(false) + } + fn biometric_remove_file(&self) -> bool { + if self.paths.biometric.exists() { + let _ = std::fs::remove_file(&self.paths.biometric); + true + } else { + false + } + } + fn take_nonce(&self, nonce: &str, purpose: &str, bound: &str) -> Result<(), String> { + let r = self.gate.lock().unwrap().take(nonce, purpose, bound, Instant::now()); + r.map_err(|e: GateError| e.text(&self.what())) + } + /// The host reports what it can do, once at start (with its token). + pub fn biometric_status(&self, available: bool, kind: &str, message: &str) -> Result { + let mut st = self.state.lock().unwrap(); + st.biometric.available = available; + st.biometric.kind = kind.to_string(); + st.biometric.message = message.to_string(); + drop(st); + self.log(&format!("biometric host: {} {}{}", kind, if available { "available" } else { "unavailable" }, if message.is_empty() { String::new() } else { format!(" ({message})") })); + Ok(json!({ "ok": true })) + } + /// The host reports a prompt's outcome. + pub fn biometric_report(&self, op: &str, ok: bool, code: &str, message: &str) -> Result { + let mut st = self.state.lock().unwrap(); + st.biometric.last_result = if ok { "ok".into() } else if code.is_empty() { "failed".into() } else { code.to_string() }; + st.biometric.last_op = op.to_string(); + st.biometric.last_at = igneum_common::platform::unix_now_f(); + drop(st); + self.log(&format!("{} {op}: {}{}", self.what(), if ok { "ok" } else { code }, if message.is_empty() { String::new() } else { format!(" ({message})") })); + Ok(json!({ "ok": true })) + } + /// The window asks for a challenge (reveal); send challenges come with the quote. + pub fn challenge(&self, purpose: &str) -> Result { + if !self.unlocked() { + return Err("unlock first".into()); + } + let reason = match purpose { + "reveal" => "Show your recovery words", + _ => return Err("purpose is reveal".into()), + }; + let nonce = self.gate.lock().unwrap().issue(purpose, "", reason, Instant::now()); + Ok(json!({ "ok": true, "nonce": nonce, "reason": reason, "purpose": purpose })) + } + pub fn challenge_for_send(&self, q: &Quote, amount_ign: &str) -> (String, String) { + let reason = biometric::send_reason(amount_ign, &keys::checksum(&q.to)); + let bound = biometric::send_binding(&q.to, &q.value, q.nonce, q.chain_id); + let nonce = self.gate.lock().unwrap().issue("send", &bound, &reason, Instant::now()); + (nonce, reason) + } + /// The host reads what the prompt must say. + pub fn challenge_reason(&self, nonce: &str) -> Result { + let (purpose, reason) = self.gate.lock().unwrap().reason_of(nonce).ok_or("unknown or used challenge")?; + Ok(json!({ "ok": true, "purpose": purpose, "reason": reason })) + } + /// The host confirms: the prompt succeeded for this nonce. + pub fn confirm(&self, nonce: &str) -> Result { + self.gate.lock().unwrap().confirm(nonce, Instant::now()).map_err(|e| e.text(&self.what()))?; + self.touch_activity(); + Ok(json!({ "ok": true })) + } + /// Enrolment, step 1 (the window): the password is checked and parked under a one-time token for the host. + pub fn enrol_begin(&self, password: &str) -> Result { + if !self.state.lock().unwrap().biometric.available { + return Err(format!("{} is not available on this machine", self.what())); + } + let v = vault::load(&self.paths.vault).ok_or("no wallet")?; + vault::open(&v, password)?; + let token = self.gate.lock().unwrap().enrol_begin(password, Instant::now()); + self.log("enrolment started: waiting for the host's prompt"); + Ok(json!({ "ok": true, "token": token })) + } + /// Enrolment, step 2 (the host, after the prompt): the password, once. + pub fn enrol_take(&self, token: &str) -> Result { + let p = self.gate.lock().unwrap().enrol_take(token, Instant::now()).ok_or("no enrolment is waiting (it may have timed out: start again)")?; + Ok(json!({ "ok": true, "secret": p })) + } + /// Enrolment, step 3 (the host): the sealed file is written; the engine checks it is there. + pub fn enrolled_set(&self, kind: &str) -> Result { + if !biometric_file_present(&self.paths.biometric) { + return Err("the sealed file was not written".into()); + } + igneum_common::platform::lock_permissions(&self.paths.biometric, false); + let mut st = self.state.lock().unwrap(); + st.biometric.enrolled = true; + st.biometric.needs_enrol = false; + if !kind.is_empty() { + st.biometric.kind = kind.to_string(); + } + drop(st); + self.touch_activity(); + self.event("ok", &format!("{} is on: it unlocks the wallet, confirms sends and shows the backup", self.what())); + Ok(json!({ "ok": true })) + } + pub fn enrol_cancel(&self) -> Result { + self.gate.lock().unwrap().enrol_cancel(); + Ok(json!({ "ok": true })) + } + /// Settings > turn off: the sealed file goes; the password is the only way in again. + pub fn biometric_remove(&self) -> Result { + self.biometric_remove_file(); + let mut st = self.state.lock().unwrap(); + st.biometric.enrolled = false; + st.biometric.needs_enrol = false; + drop(st); + self.event("info", &format!("{} is off", self.what())); + Ok(json!({ "ok": true })) + } + pub fn set_idle_lock(&self, on: bool) -> Result { + { + let mut s = self.settings.lock().unwrap(); + s.idle_lock = on; + s.save(&self.paths.settings); + } + self.state.lock().unwrap().biometric.idle_lock = on; + Ok(json!({ "ok": true })) + } + /// The window reports a person at it (mouse, keys), every few seconds while active. + pub fn touch_activity(&self) { + *self.last_activity.lock().unwrap() = Instant::now(); + } + /// The idle lock: enrolled, the setting on, unlocked, nothing being sent, and IDLE_LOCK_MIN minutes without + /// activity. Only the engine thread calls this. + pub fn idle_lock_due(&self) -> bool { + if !self.unlocked() || self.send_in_flight() || self.creating() { + return false; + } + let st = self.state.lock().unwrap(); + if !(st.biometric.enrolled && st.biometric.idle_lock) { + return false; + } + drop(st); + // IGNEUM_WALLET_IDLE_LOCK_S: tests only, a shorter period + let secs = std::env::var("IGNEUM_WALLET_IDLE_LOCK_S").ok().and_then(|v| v.parse().ok()).unwrap_or(biometric::IDLE_LOCK_MIN * 60); + self.last_activity.lock().unwrap().elapsed() >= Duration::from_secs(secs) + } +} + +/// The sealed file counts when it parses as JSON with a `kind` (the host writes it whole, then tells the engine). +pub fn biometric_file_present(p: &std::path::Path) -> bool { + std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str::(&t).ok()).map(|v| v.get("kind").and_then(|k| k.as_str()).map(|k| !k.is_empty()).unwrap_or(false)).unwrap_or(false) } // ---- the engine thread ------------------------------------------------------------------------------------------ @@ -610,6 +818,10 @@ impl Engine { self.last_scan = Instant::now(); self.scan(); } + if self.shared.idle_lock_due() { + self.shared.lock(); + self.shared.event("info", &format!("locked after {} minutes idle", biometric::IDLE_LOCK_MIN)); + } let ctx = crate::updater::Ctx { send_in_flight: self.shared.send_in_flight() || !self.watch.is_empty(), creating: self.shared.creating() }; if let Some(crate::updater::Action::Apply) = self.updater.tick(&self.shared, &ctx) { if self.apply_update() { diff --git a/app/igneum-wallet/src/main.rs b/app/igneum-wallet/src/main.rs index a434b9730..e37b4a52a 100644 --- a/app/igneum-wallet/src/main.rs +++ b/app/igneum-wallet/src/main.rs @@ -1,13 +1,15 @@ //! Igneum Wallet engine. Keeps the key, signs, reads a node, verifies finality certificates, and serves the window on //! 127.0.0.1:/t//. 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. +//! 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_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 engine; @@ -59,6 +61,7 @@ fn main() { 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(), }; @@ -66,10 +69,12 @@ fn main() { 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 shared = Arc::new(engine::Shared::new(token.clone(), paths, packaged, settings, machine_id, tx, engine_log, stamp_file.clone())); + 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) => { @@ -87,6 +92,10 @@ fn main() { 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 { diff --git a/app/igneum-wallet/src/server.rs b/app/igneum-wallet/src/server.rs index 14d617786..fd08312b2 100644 --- a/app/igneum-wallet/src/server.rs +++ b/app/igneum-wallet/src/server.rs @@ -62,6 +62,18 @@ fn handle(mut stream: TcpStream, shared: Arc) { 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) { @@ -75,7 +87,12 @@ fn handle(mut stream: TcpStream, shared: Arc) { return; } let body: Value = if req.body.is_empty() { json!({}) } else { serde_json::from_slice(&req.body).unwrap_or(json!({})) }; - match api_post(&shared, p, body) { + 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 })), } @@ -84,8 +101,13 @@ fn handle(mut stream: TcpStream, shared: Arc) { } } -fn api_post(shared: &Arc, path: &str, body: Value) -> Result { +/// 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, path: &str, body: Value, from_host: bool) -> Result { 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" => { @@ -94,12 +116,15 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result 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")?), + "/api/unlock" => shared.unlock(&s("password").ok_or("password missing")?, from_host), "/api/lock" => { shared.lock(); Ok(json!({ "ok": true })) } - "/api/reveal" => shared.reveal(&s("password").ok_or("password missing")?), + "/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")?), @@ -121,18 +146,40 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result().unwrap_or(0) * 1_000_000_000, 3)); v["tip_gwei"] = json!(crate::evm::ign(q.tip.parse::().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) + shared.send_tx(&q, s("nonce").as_deref()) } "/api/settings" => { - if let Some(on) = body.get("start_at_login").and_then(|v| v.as_bool()) { + 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)?; + } 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()), "/api/refresh" => { shared.send(Cmd::Refresh); Ok(json!({ "ok": true })) diff --git a/app/igneum-wallet/src/state.rs b/app/igneum-wallet/src/state.rs index 165a91fca..5ba6bfe9e 100644 --- a/app/igneum-wallet/src/state.rs +++ b/app/igneum-wallet/src/state.rs @@ -103,6 +103,8 @@ pub struct State { pub scanned_to: Option, 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, pub miner_wallet_file: String, pub miner_wallet_present: bool, @@ -111,6 +113,7 @@ pub struct State { 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, diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css index a931efb3d..b3d9db29b 100644 --- a/app/igneum-wallet/ui/app.css +++ b/app/igneum-wallet/ui/app.css @@ -401,3 +401,21 @@ body{user-select:text;-webkit-user-select:text} .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bone);color:var(--obsidian);font-family:var(--mono);font-size:13px;padding:10px 16px;border-radius:999px;z-index:30} .step .card{margin-top:12px} #view-settings .step{max-width:760px} + +/* 5 October 2026: the version in the brand band, the coin on the balance card, Touch ID / Windows Hello controls */ +.brand .ver{font-size:11px;letter-spacing:.08em;color:var(--ash);margin-left:10px;align-self:center} +.balance-row{display:flex;align-items:center;gap:18px} +.coin.small{width:56px;height:56px;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))} +.reading{font-size:12px;color:var(--ash);letter-spacing:.04em;margin-top:8px} +.version-row{font-size:12px;color:var(--ash);letter-spacing:.06em;margin-top:-6px} +.version-row b{color:var(--ink-2);font-weight:500} +.btn.fp svg{flex:0 0 auto} +.bio-row{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:6px} +.bio-row .note{text-align:center;max-width:46ch} +.unlock-dim .unlock{opacity:.7} +#send-go .fp-ico[hidden]{display:none} +.bio-state{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;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)} +.err .bio-state{font-family:var(--mono)} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index a3fc74455..e4f40a607 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -13,6 +13,63 @@ const post = (path, body = {}) => api(path, body); let state = null, quote = null, sentHash = null, txHash = null, lastPhase = null; if (location.search.includes('host=mac')) document.body.classList.add('mac'); +// ---- Touch ID / Windows Hello: the window host's bridge (app/mac/Biometric.swift; app/windows/wallet-host.cpp) ---- +// The page posts {id, op, ...}; the host answers window.__igneumBiometric(id, {ok, code, message}). The secret never +// comes this way: the host posts the password to the engine itself, and confirmations are nonces the engine issued. +const bio = (() => { + const pending = new Map(); let seq = 0; + const mac = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.biometric); + const win = !!(window.chrome && window.chrome.webview); + window.__igneumBiometric = (id, r) => { const p = pending.get(id); if (p) { pending.delete(id); p(r || { ok: false, code: 'bad', message: 'no answer' }); } }; + if (win) window.chrome.webview.addEventListener('message', ev => { let d = ev.data; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } } if (d && d.id != null) window.__igneumBiometric(d.id, d); }); + return { + host: mac ? 'mac' : win ? 'windows' : null, + busy: false, + call(op, params = {}) { + return new Promise(res => { + if (!this.host) return res({ ok: false, code: 'nohost', message: what() + ' needs the Igneum Wallet app window.' }); + const id = ++seq; pending.set(id, res); this.busy = true; + const m = Object.assign({ id, op }, params); + if (mac) window.webkit.messageHandlers.biometric.postMessage(m); else window.chrome.webview.postMessage(JSON.stringify(m)); + setTimeout(() => { if (pending.has(id)) { pending.delete(id); res({ ok: false, code: 'timeout', message: what() + ' did not answer.' }); } }, 180000); + }).then(r => { this.busy = false; return r; }); + } + }; +})(); +function what() { const k = state && state.biometric && state.biometric.kind; return k === 'hello' || (!k && /Win/.test(navigator.platform)) ? 'Windows Hello' : 'Touch ID'; } +// the page's own line after the system prompt: the glyph in ember and what happened, in our words +const FP = ''; +function bioLine(r, okText) { + if (!r) return ''; + if (r.ok) return `${FP}${esc(what() + ' ' + (okText || 'confirmed'))}`; + const w = what(), c = r.code; + let t; + if (c === 'cancelled' || c === 'fallback') t = w + ' cancelled, enter your password'; + else if (c === 'failed') t = w + ' did not match, try again or enter your password'; + else if (c === 'locked') t = w + ' is locked, enter your password'; + else if (c === 'invalidated') t = w + ' was turned off (a fingerprint changed), enter your password and turn it on again in Settings'; + else if (c === 'not_set_up' || c === 'unavailable') t = r.message || (w + ' is not set up on this Mac'); + else if (c === 'nohost') t = w + ' needs the Igneum Wallet app window'; + else t = r.message || (w + ' did not succeed'); + return `${FP}${esc(t)}`; +} +function setLine(el, html) { el.innerHTML = html; } +function bioEnrolled() { return !!(state && state.biometric && state.biometric.enrolled); } +let promptPending = false, lastPrompt = 0; +async function unlockWithTouch() { + if (bio.busy) return; + lastPrompt = Date.now(); + $('unlock-touch').disabled = true; setLine($('unlock-bio-note'), `${FP}${esc('Waiting for ' + what())}`); + const r = await bio.call('unlock'); + $('unlock-touch').disabled = false; + setLine($('unlock-bio-note'), bioLine(r, 'confirmed, unlocking')); + if (r.ok) await poll(); else $('unlock-pw').focus(); +} +// the idle lock: the window tells the engine a person is here, every 20 s while there is input +let active = false; +['mousemove', 'keydown', 'mousedown', 'wheel', 'touchstart'].forEach(e => document.addEventListener(e, () => { active = true; }, { passive: true })); +setInterval(() => { if (active && state && state.phase === 'home') { active = false; post('/api/activity').catch(() => {}); } }, 20000); + function toast(text) { const t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(() => { t.hidden = true; }, 1800); } function copy(text, what) { navigator.clipboard.writeText(text).then(() => toast((what || 'copied') + ' to the clipboard'), () => toast('could not copy')); } function ign(wei, places = 6) { @@ -34,8 +91,9 @@ function render() { const s = state; const phase = s.phase; const inFlow = ['create', 'import'].includes(document.body.dataset.phase); - if (!inFlow || phase === 'home') { if (lastPhase !== phase) { setPhase(phase); if (phase === 'home') setView('overview'); } } + if (!inFlow || phase === 'home') { if (lastPhase !== phase) { setPhase(phase); if (phase === 'home') setView('overview'); if (phase === 'unlock') promptPending = true; } } lastPhase = phase; + $('ver').textContent = 'v' + s.version; $('btn-settings').hidden = phase !== 'home'; $('btn-lock').hidden = phase !== 'home'; // pill @@ -48,10 +106,20 @@ function render() { $('pill-text').textContent = pillText; $('pill').className = pillCls; $('welcome-eyebrow').textContent = `${s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network} · nothing is bought or sold`; renderUpdate(s); - // unlock + // unlock: the fingerprint button when enrolled and the app window is the host; the password form stays $('unlock-address').textContent = s.display; + const b = s.biometric || {}; + const bioHere = b.enrolled && !!bio.host; + $('unlock-bio').hidden = !bioHere; + $('unlock-touch-text').textContent = 'Unlock with ' + what(); + if (phase === 'unlock' && bioHere && promptPending && !document.hidden && !bio.busy) { promptPending = false; unlockWithTouch(); } // home - $('balance').textContent = s.balance_known ? s.balance : '…'; + $('balance').textContent = s.balance_known ? s.balance : '0'; + $('balance').classList.toggle('dim', !s.balance_known); + const note = $('balance-note'); + if (s.scanning) { note.textContent = `reading the chain, ${(s.scanned_to == null ? 0 : s.scanned_to).toLocaleString()} of ${n.block.toLocaleString()} blocks`; note.hidden = false; } + else if (!s.balance_known) { note.textContent = n.state === 'ok' ? 'reading the balance' : 'waiting for a node'; note.hidden = false; } + else note.hidden = true; $('home-address').textContent = s.display; $('backup-note').hidden = s.backed_up; kv($('node-kv'), [ @@ -73,6 +141,7 @@ function render() { renderHistory(s.history); // settings $('start-login').checked = !!s.settings.start_at_login; + renderBio(s); kv($('settings-node-kv'), [['source', esc(n.source)], ['ethereum rpc', esc(n.evm || 'none')], ['grpc', esc(n.grpc || 'none')], ['chain id', n.chain_id || '…'], ['network', esc(s.settings.network)], ['public rpc', esc(s.public_rpc || 'none in this build')]]); kv($('machine-kv'), [['machine', esc(s.machine_id.slice(0, 8))], ['version', esc(s.version)], ['logs', esc(s.log_dir)], ['miner key file', s.miner_wallet_present ? 'present' : 'none']]); $('seg-miner').disabled = !s.miner_wallet_present; @@ -168,14 +237,25 @@ $('send-quote').onclick = async () => { $('c-fee').textContent = `${quote.fee_max_ign} IGN`; $('c-total').textContent = `${quote.total_max_ign} IGN`; $('c-fee-note').textContent = `Fee = gas × price. Gas ${quote.gas.toLocaleString()}. Price = base fee ${quote.base_fee_gwei} gwei (burned by the network) + tip ${quote.tip_gwei} gwei (to the miner), capped at ${ign(quote.max_fee, 0) === '0' ? (Number(quote.max_fee) / 1e9).toFixed(3) + ' gwei' : ign(quote.max_fee) + ' IGN'} per gas; what is not used comes back.`; + const needs = !!quote.confirm_needed && !!bio.host; + $('send-go-text').textContent = needs ? 'Confirm with ' + what() : 'Send now'; + $('send-go').querySelector('.fp-ico').hidden = !needs; $('send-form').hidden = true; $('send-confirm').hidden = false; $('confirm-send-err').textContent = ''; + if (quote.confirm_needed && !bio.host) $('confirm-send-err').textContent = what() + ' is on for this wallet, and only the Igneum Wallet app window can show it.'; } catch (e) { $('send-err').textContent = e.message; } }; $('send-go').onclick = async () => { $('confirm-send-err').textContent = ''; $('send-go').disabled = true; try { - const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, ...q } = quote; - const r = await post('/api/send', { quote: q }); + const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, confirm_nonce, confirm_reason, confirm_needed, ...q } = quote; + if (confirm_needed) { + // the prompt shows the engine's own line (amount and address); the host confirms the nonce to the engine + setLine($('send-bio-line'), `${FP}${esc('Waiting for ' + what())}`); + const c = await bio.call('confirm', { nonce: confirm_nonce }); + setLine($('send-bio-line'), bioLine(c, 'confirmed, sending')); + if (!c.ok) { $('send-go').disabled = false; return; } + } + const r = await post('/api/send', { quote: q, nonce: confirm_nonce }); sentHash = r.hash; $('sent-hash').textContent = r.hash; $('send-confirm').hidden = true; $('send-done').hidden = false; $('send-to').value = ''; $('send-amount').value = ''; await poll(); @@ -223,6 +303,82 @@ $('backup-show').onclick = async () => { } catch (e) { $('backup-err').textContent = e.message; } }; $('backup-hide').onclick = () => { $('backup-out').hidden = true; $('backup-words').innerHTML = ''; $('backup-key').textContent = ''; }; +// the backup and the export after a confirmation: the words come from the unlocked key, no password typed +async function revealWithTouch(errEl) { + errEl.textContent = ''; + const c = await post('/api/biometric/challenge', { purpose: 'reveal' }); + setLine(errEl, `${FP}${esc('Waiting for ' + what())}`); + const h = await bio.call('confirm', { nonce: c.nonce }); + setLine(errEl, bioLine(h, 'confirmed')); + if (!h.ok) return null; + return post('/api/reveal', { nonce: c.nonce }); +} +$('backup-touch').onclick = async () => { + try { + const r = await revealWithTouch($('backup-err')); + if (!r) return; + $('backup-words').innerHTML = (r.words || []).map(w => `
  • ${esc(w)}
  • `).join(''); + $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; + if (!state.backed_up) await post('/api/backed-up'); + } catch (e) { $('backup-err').textContent = e.message; } +}; +$('export-touch').onclick = async () => { + try { const r = await revealWithTouch($('export-err')); if (!r) return; $('export-key').textContent = r.private_key; $('export-out').hidden = false; } + catch (e) { $('export-err').textContent = e.message; } +}; +// ---- Touch ID / Windows Hello in Settings: enrol (password once, then the prompt), the idle lock, turn off ---- +function versionLine(s) { + const u = s.update || {}, v = 'Igneum Wallet ' + s.version; + let tail; + switch (u.status) { + case 'current': tail = 'up to date'; break; + case 'available': case 'downloading': tail = u.version + ' downloading'; break; + case 'staging': case 'ready': case 'deferred': case 'manual': tail = u.version + ' downloaded'; break; + case 'applying': tail = 'installing ' + u.version; break; + case 'checking': tail = 'checking for updates'; break; + case 'off': tail = 'updates off in this build'; break; + case 'error': tail = 'update check failed'; break; + default: tail = 'not checked yet'; + } + return `${esc(v)} · ${esc(tail)}`; +} +function renderBio(s) { + const b = s.biometric || {}, w = what(); + $('version-row').innerHTML = versionLine(s); + $('bio-title').textContent = w; + $('bio-enrol-text').textContent = 'Turn on ' + w; + $('idle-lock-text').textContent = `Lock after ${b.idle_lock_min || 5} minutes idle`; + $('backup-touch').hidden = !(b.enrolled && bio.host); $('backup-touch').querySelector('span').textContent = 'Show with ' + w; + $('export-touch').hidden = !(b.enrolled && bio.host); $('export-touch').querySelector('span').textContent = 'Show with ' + w; + $('backup-note-text').textContent = b.enrolled ? `Show the 24 words (or the key) again, with ${w} or the password.` : 'Show the 24 words (or the key) again. Needs the password.'; + $('bio-on').hidden = !b.enrolled; $('bio-off').hidden = b.enrolled; + if (document.activeElement !== $('idle-lock')) $('idle-lock').checked = !!b.idle_lock; + let off = ''; + if (!bio.host) off = w + ' needs the Igneum Wallet app window; this page is open in a browser.'; + else if (!b.available) off = b.message || (w === 'Touch ID' ? 'Touch ID is not set up on this Mac.' : 'Windows Hello is not set up on this PC.'); + $('bio-off-note').textContent = off; + $('bio-enrol').disabled = !!off; $('bio-pw').disabled = !!off; + $('bio-on-note').textContent = b.needs_enrol ? '' : (b.last_result && b.last_result !== 'ok' && b.last_op ? `last ${b.last_op}: ${b.last_result}` : ''); + if (b.needs_enrol && !$('bio-err').textContent) $('bio-err').textContent = `The password changed, so ${w} was turned off. Turn it on again here.`; +} +$('bio-enrol').onclick = async () => { + $('bio-err').textContent = ''; + const pw = $('bio-pw').value; + if (!pw) return $('bio-err').textContent = 'type the password first'; + $('bio-enrol').disabled = true; + try { + const r = await post('/api/biometric/enrol/begin', { password: pw }); + setLine($('bio-err'), `${FP}${esc('Waiting for ' + what())}`); + const h = await bio.call('enrol', { token: r.token }); + setLine($('bio-err'), bioLine(h, 'is on')); + if (!h.ok) { post('/api/biometric/enrol/cancel').catch(() => {}); $('bio-enrol').disabled = false; return; } + $('bio-pw').value = ''; toast(what() + ' is on'); await poll(); + } catch (e) { $('bio-err').textContent = e.message; } + $('bio-enrol').disabled = false; +}; +$('bio-remove').onclick = async () => { try { await post('/api/biometric/remove'); $('bio-err').textContent = ''; toast(what() + ' is off'); await poll(); } catch (e) { $('bio-err').textContent = e.message; } }; +$('idle-lock').onchange = async ev => { try { await post('/api/settings', { idle_lock: ev.target.checked }); } catch (e) { toast(e.message); } }; +$('unlock-touch').onclick = unlockWithTouch; $('pw-change').onclick = async () => { $('pw-err').textContent = ''; try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('password changed'); } @@ -307,7 +463,12 @@ $('remove-go').onclick = async () => { try { await post('/api/remove', { password: $('remove-pw').value }); $('remove-pw').value = ''; await poll(); } catch (e) { $('remove-err').textContent = e.message; } }; -document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); }); +document.addEventListener('visibilitychange', () => { + if (document.hidden) return; + poll(); + // the window came back (menu bar, Dock): the prompt once more on the unlock screen, not within 10 s of the last + if (state && state.phase === 'unlock' && bioEnrolled() && bio.host && Date.now() - lastPrompt > 10000) promptPending = true; +}); new MutationObserver(() => { if (document.body.dataset.view === 'settings') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-view'] }); poll(); diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index e0bbbb9ec..428729f7d 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -9,11 +9,12 @@ +
    - IGNEUMWALLET + IGNEUMWALLET
    starting
    @@ -109,9 +110,13 @@

    Unlock

    +
    - +
    Forgot it? Only the 24 words or the key open this wallet again: Settings is locked too.
    @@ -124,7 +129,13 @@
    balance
    -
     IGN
    +
    + +
    +
    0IGN
    + +
    +
    @@ -167,7 +178,8 @@

    -
    +
    +
    + + + + diff --git a/app/igneum-wallet/ui/update-card.js b/app/igneum-wallet/ui/update-card.js new file mode 100644 index 000000000..42052bcdb --- /dev/null +++ b/app/igneum-wallet/ui/update-card.js @@ -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; diff --git a/app/igneum-wallet/ui/update-card.test.mjs b/app/igneum-wallet/ui/update-card.test.mjs new file mode 100644 index 000000000..4cc7b82c5 --- /dev/null +++ b/app/igneum-wallet/ui/update-card.test.mjs @@ -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' }); +}); From 526c99730bd6f5446e262716163c12360a08990b Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:24:53 +0000 Subject: [PATCH 005/150] Igneum Wallet 0.1.3: the update card ships; version bump, the two real OTA runs recorded Cargo.toml and Cargo.lock to 0.1.3. README: the 0.1.3 row; the 0.1.1 -> 0.1.2 and 0.1.2 -> 0.1.3 runs on the project lead's Mac replace the stale "first real run" line (0.1.2 -> 0.1.3 took 28 s from the check to 0.1.3 up). Co-Authored-By: Claude Fable 5.1 --- app/igneum-wallet/Cargo.lock | 2 +- app/igneum-wallet/Cargo.toml | 2 +- app/igneum-wallet/README.md | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index c87ebe423..cf559c3c4 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.2" +version = "0.1.3" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index 190bfb891..882354a57 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.2" +version = "0.1.3" 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" diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md index ee79654e0..54db43b5d 100644 --- a/app/igneum-wallet/README.md +++ b/app/igneum-wallet/README.md @@ -12,6 +12,7 @@ packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet | 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`) | ## Touch ID and Windows Hello (src/engine.rs, igneum-common/src/biometric.rs, app/mac/Biometric.swift, app/windows/biometric.h) @@ -173,14 +174,14 @@ Files in `~/Library/Application Support/Igneum/wallet/` (Windows: `%LOCALAPPDATA `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 project lead'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`). ### 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 relaunch through LaunchServices (`open -n`) with the real window host, and the host quitting by bundle id - (`network.igneum.wallet`): the scratch test relaunches the engine alone. The first real run is 0.1.1 -> 0.1.2 on - the project lead's Mac. - 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 From abff030f7f3013f485daaefe7f49c7ceaabdfc9a Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:51:07 +0000 Subject: [PATCH 006/150] Igneum Wallet 0.1.4: the lock screen; the Touch ID sheet on a tap, once by itself when the wallet opens; the idle lock's minutes The lock screen (ui/lock-screen.js, pure, with lock-screen.test.mjs): the coin large on the ember glow, the name in Unbounded, the short address in mono, one control. Touch ID enrolled in the app window: the fingerprint button, its line, a quiet "Use password" that reveals the field in place (the control area keeps one height). Otherwise the password field is the control. Locking is a 250 ms transition from the home screen, not a cut. The glow breathes; reduced motion stops it. No automatic sheet: it appears on a tap, Return or Space on the button (focused on arrival and when the window comes back). One exception: the first arrival after the person opened the app, 600 ms after the lock screen is drawn, when "Ask for Touch ID when the wallet opens" is on (new setting, default on). Never on an idle lock, a hand lock, the window coming back, after a cancelled sheet ("Touch ID cancelled, tap to try again", no modal), or on the updater's relaunch (updated_from set). Escape in the password field returns to the button. The idle lock: Settings > Lock when idle, 1, 5 (default), 15, 60 minutes or never (settings.json idle_lock_min; idle_lock mirrors on/off for 0.1.2 and 0.1.3; /api/settings takes idle_lock_min and ask_on_open and refuses other minutes). The engine's lock event names the minutes. Also: the three wallet switches in Settings were invisible (the miner's .switch hid the input behind a .track the wallet never renders); the box shows now. ?bio=touch shows the Touch ID layout without a host, for screenshots. Tests: node --test ui/lock-screen.test.mjs (5) + update-card.test.mjs (4); cargo test in app/igneum-wallet, 18 passed (the settings migration test is new). Verified in the browser pane at 900x700 and 1280x800 against a scratch engine. Shipped: Igneum-Wallet-0.1.4.dmg published to the new download folder and bridged into the old one (the installed 0.1.3 polls the old folder; rotation phase 2 had removed the wallet manifest there); the project lead's wallet went 0.1.3 -> 0.1.4 in 28 s (check 15:48:50Z, 0.1.4 up 15:49:19Z), no sheet on the relaunch. Co-Authored-By: Claude Fable 5.1 --- app/igneum-wallet/Cargo.lock | 2 +- app/igneum-wallet/Cargo.toml | 2 +- app/igneum-wallet/README.md | 44 +++++++- app/igneum-wallet/src/engine.rs | 118 ++++++++++++++++++---- app/igneum-wallet/src/server.rs | 8 ++ app/igneum-wallet/src/state.rs | 4 + app/igneum-wallet/ui/app.css | 45 ++++++++- app/igneum-wallet/ui/app.js | 96 ++++++++++++++---- app/igneum-wallet/ui/index.html | 47 ++++++--- app/igneum-wallet/ui/lock-screen.js | 78 ++++++++++++++ app/igneum-wallet/ui/lock-screen.test.mjs | 93 +++++++++++++++++ app/windows/wallet-version.h | 4 +- packaging/windows/Igneum-Wallet.iss | 2 +- 13 files changed, 481 insertions(+), 62 deletions(-) create mode 100644 app/igneum-wallet/ui/lock-screen.js create mode 100644 app/igneum-wallet/ui/lock-screen.test.mjs diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index cf559c3c4..0d02a1e85 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.3" +version = "0.1.4" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index 882354a57..22357afff 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.3" +version = "0.1.4" 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" diff --git a/app/igneum-wallet/README.md b/app/igneum-wallet/README.md index 54db43b5d..ab0ed5f28 100644 --- a/app/igneum-wallet/README.md +++ b/app/igneum-wallet/README.md @@ -13,6 +13,33 @@ packaging: `packaging/mac/build-wallet-dmg.sh`, `packaging/windows/Igneum-Wallet | 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.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) @@ -63,9 +90,11 @@ parks it under a one-time token for 120 s; the host shows the prompt ("Turn on T 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 and "Lock after 5 minutes idle" on (the default), the engine zeroes the key after 5 -minutes 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 the prompt again, or the password. +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) @@ -177,6 +206,15 @@ Files in `~/Library/Application Support/Igneum/wallet/` (Windows: `%LOCALAPPDATA - For real on the project lead'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 diff --git a/app/igneum-wallet/src/engine.rs b/app/igneum-wallet/src/engine.rs index a9ba0aae0..f5f586dc5 100644 --- a/app/igneum-wallet/src/engine.rs +++ b/app/igneum-wallet/src/engine.rs @@ -29,23 +29,43 @@ pub struct Settings { /// the last block the window scrolled to; display only #[serde(default)] pub display_name: String, - /// lock after IDLE_LOCK_MIN minutes without the window reporting activity; only acts while Touch ID or Windows - /// Hello is enrolled (the password still works) + /// 0.1.2 and 0.1.3 wrote on or off; since 0.1.4 `idle_lock_min` carries the minutes and this mirrors it + /// (minutes > 0), so an older build still reads the file #[serde(default = "yes")] pub idle_lock: bool, + /// lock after this many minutes without the window reporting activity (1, 5, 15, 60; 0 is never); only acts + /// while Touch ID or Windows Hello is enrolled (the password still works). None: a file from before 0.1.4 + #[serde(default)] + pub idle_lock_min: Option, + /// the first arrival after the person opened the app may raise the Touch ID sheet once, by itself + #[serde(default = "yes")] + pub ask_on_open: bool, } fn yes() -> bool { true } +/// The idle lock's choices, minutes; 0 is never (ui/lock-screen.js IDLE_CHOICES). +pub const IDLE_CHOICES: [u64; 5] = [1, 5, 15, 60, 0]; impl Default for Settings { fn default() -> Settings { - Settings { auto_update: true, display_name: String::new(), idle_lock: true } + Settings { auto_update: true, display_name: String::new(), idle_lock: true, idle_lock_min: Some(biometric::IDLE_LOCK_MIN), ask_on_open: true } } } impl Settings { pub fn load(p: &std::path::Path) -> Settings { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() } + /// The idle lock in minutes, 0 for never: the 0.1.4 field, else the older on/off (on = IDLE_LOCK_MIN). + pub fn idle_minutes(&self) -> u64 { + match self.idle_lock_min { + Some(m) => m, + None => if self.idle_lock { biometric::IDLE_LOCK_MIN } else { 0 }, + } + } + pub fn set_idle_minutes(&mut self, min: u64) { + self.idle_lock_min = Some(min); + self.idle_lock = min > 0; + } pub fn save(&self, p: &std::path::Path) { if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); @@ -155,6 +175,8 @@ impl Shared { st.finality.message = "no verified checkpoint yet".into(); st.settings.start_at_login = igneum_common::platform::start_at_login_is_on(APP); st.settings.network = std::env::var("IGNEUM_WALLET_NETWORK").unwrap_or_else(|_| "devnet".into()); + st.settings.idle_lock_min = settings.idle_minutes(); + st.settings.ask_on_open = settings.ask_on_open; st.miner_wallet_file = paths.miner_wallet.display().to_string(); st.miner_wallet_present = paths.miner_wallet.is_file(); st.add_network_page = packaged.add_network_page.clone(); @@ -163,7 +185,7 @@ impl Shared { st.host = igneum_common::platform::host_label(); st.log_dir = paths.log_dir.display().to_string(); st.app_dir = paths.app_dir.display().to_string(); - st.biometric = BiometricState { enrolled: biometric_file_present(&paths.biometric), idle_lock: settings.idle_lock, idle_lock_min: biometric::IDLE_LOCK_MIN, ..Default::default() }; + st.biometric = BiometricState { enrolled: biometric_file_present(&paths.biometric), idle_lock: settings.idle_minutes() > 0, idle_lock_min: settings.idle_minutes(), ..Default::default() }; st.update.status = if packaged.update_manifest.is_empty() { "off".into() } else { "unknown".into() }; Shared { token, @@ -700,33 +722,93 @@ impl Shared { self.event("info", &format!("{} is off", self.what())); Ok(json!({ "ok": true })) } + /// Settings (0.1.2, 0.1.3 pages): on or off; on keeps the minutes already chosen, or the default. pub fn set_idle_lock(&self, on: bool) -> Result { + let min = if on { self.settings.lock().unwrap().idle_minutes().max(1) } else { 0 }; + let min = if on && !IDLE_CHOICES.contains(&min) { biometric::IDLE_LOCK_MIN } else { min }; + self.set_idle_lock_min(min) + } + /// Settings > Lock when idle: 1, 5, 15 or 60 minutes, or 0 for never. + pub fn set_idle_lock_min(&self, min: u64) -> Result { + if !IDLE_CHOICES.contains(&min) { + return Err(format!("the idle lock is 1, 5, 15 or 60 minutes, or 0 for never (not {min})")); + } { let mut s = self.settings.lock().unwrap(); - s.idle_lock = on; + s.set_idle_minutes(min); s.save(&self.paths.settings); } - self.state.lock().unwrap().biometric.idle_lock = on; + let mut st = self.state.lock().unwrap(); + st.biometric.idle_lock = min > 0; + st.biometric.idle_lock_min = min; + st.settings.idle_lock_min = min; + drop(st); + self.touch_activity(); + self.log(&format!("idle lock: {}", if min == 0 { "never".to_string() } else { format!("{min} min") })); + Ok(json!({ "ok": true })) + } + /// Settings > Ask for Touch ID when the wallet opens: the one automatic sheet on the first arrival. + pub fn set_ask_on_open(&self, on: bool) -> Result { + { + let mut s = self.settings.lock().unwrap(); + s.ask_on_open = on; + s.save(&self.paths.settings); + } + self.state.lock().unwrap().settings.ask_on_open = on; + self.log(&format!("ask for {} when the wallet opens: {}", self.what(), if on { "on" } else { "off" })); Ok(json!({ "ok": true })) } /// The window reports a person at it (mouse, keys), every few seconds while active. pub fn touch_activity(&self) { *self.last_activity.lock().unwrap() = Instant::now(); } - /// The idle lock: enrolled, the setting on, unlocked, nothing being sent, and IDLE_LOCK_MIN minutes without - /// activity. Only the engine thread calls this. - pub fn idle_lock_due(&self) -> bool { + /// The idle lock: enrolled, minutes chosen (not never), unlocked, nothing being sent, and that many minutes + /// without activity. Some(minutes) when it is due. Only the engine thread calls this. + pub fn idle_lock_due(&self) -> Option { if !self.unlocked() || self.send_in_flight() || self.creating() { - return false; + return None; } - let st = self.state.lock().unwrap(); - if !(st.biometric.enrolled && st.biometric.idle_lock) { - return false; + if !self.state.lock().unwrap().biometric.enrolled { + return None; + } + let min = self.settings.lock().unwrap().idle_minutes(); + if min == 0 { + return None; } - drop(st); // IGNEUM_WALLET_IDLE_LOCK_S: tests only, a shorter period - let secs = std::env::var("IGNEUM_WALLET_IDLE_LOCK_S").ok().and_then(|v| v.parse().ok()).unwrap_or(biometric::IDLE_LOCK_MIN * 60); - self.last_activity.lock().unwrap().elapsed() >= Duration::from_secs(secs) + let secs = std::env::var("IGNEUM_WALLET_IDLE_LOCK_S").ok().and_then(|v| v.parse().ok()).unwrap_or(min * 60); + if self.last_activity.lock().unwrap().elapsed() >= Duration::from_secs(secs) { Some(min) } else { None } + } +} + +#[cfg(test)] +mod settings_tests { + use super::*; + + #[test] + fn idle_minutes_old_and_new_files() { + // 0.1.2 and 0.1.3 wrote on or off + let on: Settings = serde_json::from_str(r#"{"auto_update":true,"idle_lock":true}"#).unwrap(); + assert_eq!(on.idle_minutes(), 5); + assert!(on.ask_on_open); + let off: Settings = serde_json::from_str(r#"{"idle_lock":false}"#).unwrap(); + assert_eq!(off.idle_minutes(), 0); + // 0.1.4 writes the minutes; they win over the mirror + let fifteen: Settings = serde_json::from_str(r#"{"idle_lock":true,"idle_lock_min":15,"ask_on_open":false}"#).unwrap(); + assert_eq!(fifteen.idle_minutes(), 15); + assert!(!fifteen.ask_on_open); + let never: Settings = serde_json::from_str(r#"{"idle_lock":true,"idle_lock_min":0}"#).unwrap(); + assert_eq!(never.idle_minutes(), 0); + // the default, and what a save writes for an older build to read + let mut d = Settings::default(); + assert_eq!(d.idle_minutes(), 5); + d.set_idle_minutes(0); + assert!(!d.idle_lock); + let back: Settings = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap(); + assert_eq!(back.idle_minutes(), 0); + d.set_idle_minutes(60); + assert!(d.idle_lock); + assert_eq!(IDLE_CHOICES, [1, 5, 15, 60, 0]); } } @@ -818,9 +900,9 @@ impl Engine { self.last_scan = Instant::now(); self.scan(); } - if self.shared.idle_lock_due() { + if let Some(min) = self.shared.idle_lock_due() { self.shared.lock(); - self.shared.event("info", &format!("locked after {} minutes idle", biometric::IDLE_LOCK_MIN)); + self.shared.event("info", &format!("locked after {} idle", if min == 1 { "1 minute".to_string() } else { format!("{min} minutes") })); } let ctx = crate::updater::Ctx { send_in_flight: self.shared.send_in_flight() || !self.watch.is_empty(), creating: self.shared.creating() }; if let Some(crate::updater::Action::Apply) = self.updater.tick(&self.shared, &ctx) { diff --git a/app/igneum-wallet/src/server.rs b/app/igneum-wallet/src/server.rs index aad764e89..d302620aa 100644 --- a/app/igneum-wallet/src/server.rs +++ b/app/igneum-wallet/src/server.rs @@ -11,6 +11,7 @@ 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"); @@ -46,6 +47,7 @@ fn handle(mut stream: TcpStream, shared: Arc) { ("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), @@ -166,6 +168,12 @@ fn api_post(shared: &Arc, path: &str, body: Value, from_host: bool) -> R 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) ---- diff --git a/app/igneum-wallet/src/state.rs b/app/igneum-wallet/src/state.rs index 5ba6bfe9e..e4d812ab9 100644 --- a/app/igneum-wallet/src/state.rs +++ b/app/igneum-wallet/src/state.rs @@ -64,6 +64,10 @@ 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)] diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css index a4db10f98..356b35332 100644 --- a/app/igneum-wallet/ui/app.css +++ b/app/igneum-wallet/ui/app.css @@ -397,7 +397,9 @@ body{user-select:text;-webkit-user-select:text} .warn-box b{font-size:14px} .danger-card{border-color:rgba(242,84,27,.35)} .switch{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer} -.switch input{width:18px;height:18px;accent-color:var(--ember)} +/* the miner's switch hides its input behind a .track span the wallet does not render: the box itself shows here + (fixed 5 October 2026: the three wallet switches were invisible, only their words could be clicked) */ +.switch input{position:static;opacity:1;width:18px;height:18px;margin:0;accent-color:var(--ember);flex:0 0 18px} .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bone);color:var(--obsidian);font-family:var(--mono);font-size:13px;padding:10px 16px;border-radius:999px;z-index:30} .step .card{margin-top:12px} #view-settings .step{max-width:760px} @@ -453,3 +455,44 @@ body{user-select:text;-webkit-user-select:text} .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:8px}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}} + +/* ---- the lock screen (0.1.4, 5 October 2026; ui/lock-screen.js, index.html #screen-unlock) ---- + The coin large and centred on the obsidian ground with the ember glow, the name in Unbounded, the short address + in mono, one primary control. It lies over the main area (absolute, the header stays), enters in ENTER_MS (250 ms) + and the home screen leaves beneath it in the same 250 ms, so locking is a transition, not a cut. 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:220px;height:220px;margin-bottom:22px;flex:0 0 auto} +.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-coin .coin{position:relative;width:220px;height:220px;filter:drop-shadow(0 18px 48px rgba(242,84,27,.45))} +.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:14px;color:var(--ash);letter-spacing:.06em;margin-top:2px} +/* the control area keeps one height, so "Use password" reveals the field in place and nothing above it moves */ +.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:12px;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:13px;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)} +.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,.lock-coin .coin{width:176px;height:176px}.lock-coin{margin-bottom:14px}.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}} + +/* Settings > Touch ID: the idle lock's choices and "Ask for Touch ID when the wallet opens" */ +.select{font:inherit;font-size:14px;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:7px 12px;min-height:38px;cursor:pointer} +.select:focus{outline:none;border-color:var(--ember)} +.idle-row{margin-top:4px} +.idle-label{font-size:14px} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index e37feb1be..1fdb9660f 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -54,17 +54,72 @@ function bioLine(r, okText) { return `${FP}${esc(t)}`; } function setLine(el, html) { el.innerHTML = html; } -function bioEnrolled() { return !!(state && state.biometric && state.biometric.enrolled); } -let promptPending = false, lastPrompt = 0; +// ?bio=touch: the enrolled look without a host (screenshots); a tap then gets the "needs the app window" line +const forcedBio = new URLSearchParams(location.search).get('bio') === 'touch'; +function hostHere() { return !!bio.host || forcedBio; } + +// ---- the lock screen (ui/lock-screen.js): the sheet on a tap, once by itself on the first arrival ---- +// firstPhase: the first phase this page saw ('unlock' means the app opened locked); autoMem.used: the one chance went +let firstPhase = null, locking = false, autoTimer = 0, pwRevealed = false; +const autoMem = { used: false }; +const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); +function lockLayout() { + const s = forcedBio && state ? Object.assign({}, state, { biometric: Object.assign({}, state.biometric, { enrolled: true, available: true, kind: 'touchid' }) }) : state; + return LockScreen.layout(s, hostHere()); +} +function renderLock() { + const lay = lockLayout(); + $('unlock-address').textContent = lay.address; + $('unlock-touch-text').textContent = 'Unlock with ' + what(); + $('unlock-bio').hidden = !lay.touch; + document.querySelector('.lock-controls').classList.toggle('touch', lay.touch); + const showForm = !lay.touch || pwRevealed; + $('unlock-form').hidden = !showForm; + $('unlock-use-pw').hidden = !lay.touch || pwRevealed; +} +function showPasswordField() { + pwRevealed = true; renderLock(); + $('unlock-pw').focus({ preventScroll: true }); +} +function hidePasswordField() { + if (!lockLayout().touch) return; + pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; renderLock(); + $('unlock-touch').focus({ preventScroll: true }); +} +function focusLock() { + const lay = lockLayout(); + if (lay.touch && !pwRevealed) $('unlock-touch').focus({ preventScroll: true }); else $('unlock-pw').focus({ preventScroll: true }); +} +// the home screen leaves and the lock screen enters together, ENTER_MS (250 ms); then the phase flips +function lockTransition() { + locking = true; document.body.classList.add('locking'); $('main').scrollTop = 0; + pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); renderLock(); + setTimeout(() => { locking = false; document.body.classList.remove('locking'); setPhase('unlock'); onLockScreen('lock'); }, reducedMotion() ? 0 : LockScreen.ENTER_MS); +} +// the lock screen is up: reason is arrival (the page opened on it), lock (idle or by hand) or focus +function onLockScreen(reason) { + clearTimeout(autoTimer); + if (reason !== 'lock') { pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); } + renderLock(); focusLock(); + const lay = lockLayout(); + const d = LockScreen.autoPrompt({ reason, phase: 'unlock', touch: lay.touch, askOnOpen: !!(state.settings && state.settings.ask_on_open), hidden: document.hidden, busy: bio.busy, firstPhase, updatedFrom: state.update && state.update.updated_from }, autoMem); + if (reason === 'arrival') autoMem.used = true; + if (d.prompt) autoTimer = setTimeout(() => { if (state && state.phase === 'unlock' && !document.hidden && !bio.busy && !pwRevealed) unlockWithTouch(); }, (reducedMotion() ? 0 : LockScreen.ENTER_MS) + d.delay); +} async function unlockWithTouch() { if (bio.busy) return; - lastPrompt = Date.now(); + clearTimeout(autoTimer); $('unlock-touch').disabled = true; setLine($('unlock-bio-note'), `${FP}${esc('Waiting for ' + what())}`); const r = await bio.call('unlock'); $('unlock-touch').disabled = false; - setLine($('unlock-bio-note'), bioLine(r, 'confirmed, unlocking')); - if (r.ok) await poll(); else $('unlock-pw').focus(); + const l = LockScreen.line(r, what()); + setLine($('unlock-bio-note'), l.text ? `${FP}${esc(l.text)}` : ''); + if (r.ok) { await poll(); return; } + // a cancel or a miss: the button again, never another sheet by itself; the password when the sheet cannot help + if (l.password) showPasswordField(); else $('unlock-touch').focus({ preventScroll: true }); } +$('unlock-use-pw').onclick = showPasswordField; +$('unlock-pw').addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); hidePasswordField(); } }); // the idle lock: the window tells the engine a person is here, every 20 s while there is input let active = false; ['mousemove', 'keydown', 'mousedown', 'wheel', 'touchstart'].forEach(e => document.addEventListener(e, () => { active = true; }, { passive: true })); @@ -91,7 +146,11 @@ function render() { const s = state; const phase = s.phase; const inFlow = ['create', 'import'].includes(document.body.dataset.phase); - if (!inFlow || phase === 'home') { if (lastPhase !== phase) { setPhase(phase); if (phase === 'home') setView('overview'); if (phase === 'unlock') promptPending = true; } } + if (firstPhase === null) firstPhase = phase; + if ((!inFlow || phase === 'home') && lastPhase !== phase && !locking) { + if (lastPhase === 'home' && phase === 'unlock') lockTransition(); + else { setPhase(phase); if (phase === 'home') setView('overview'); if (phase === 'unlock') onLockScreen(lastPhase === null ? 'arrival' : 'lock'); } + } lastPhase = phase; $('ver').textContent = 'v' + s.version; $('btn-settings').hidden = phase !== 'home'; @@ -108,13 +167,8 @@ function render() { if (forcedUpdate) s.update = sampleUpdate(forcedUpdate); renderUpdate(s); renderUpdateCard(s); - // unlock: the fingerprint button when enrolled and the app window is the host; the password form stays - $('unlock-address').textContent = s.display; - const b = s.biometric || {}; - const bioHere = b.enrolled && !!bio.host; - $('unlock-bio').hidden = !bioHere; - $('unlock-touch-text').textContent = 'Unlock with ' + what(); - if (phase === 'unlock' && bioHere && promptPending && !document.hidden && !bio.busy) { promptPending = false; unlockWithTouch(); } + // the lock screen: the fingerprint button when enrolled and the app window is the host, else the password field + if (phase === 'unlock' || locking) renderLock(); // home $('balance').textContent = s.balance_known ? s.balance : '0'; $('balance').classList.toggle('dim', !s.balance_known); @@ -349,12 +403,15 @@ function renderBio(s) { $('version-row').innerHTML = versionLine(s); $('bio-title').textContent = w; $('bio-enrol-text').textContent = 'Turn on ' + w; - $('idle-lock-text').textContent = `Lock after ${b.idle_lock_min || 5} minutes idle`; + $('ask-on-open-text').textContent = `Ask for ${w} when the wallet opens`; + const idleMin = s.settings && s.settings.idle_lock_min != null ? s.settings.idle_lock_min : (b.idle_lock ? (b.idle_lock_min || 5) : 0); + if (document.activeElement !== $('idle-lock')) $('idle-lock').value = String(LockScreen.IDLE_CHOICES.includes(Number(idleMin)) ? idleMin : LockScreen.IDLE_DEFAULT_MIN); + $('idle-lock-note').textContent = Number(idleMin) > 0 ? `The key is cleared after ${LockScreen.idleLabel(idleMin)} without you; ${w} or the password opens it again.` : 'The wallet stays open until you lock it.'; + if (document.activeElement !== $('ask-on-open')) $('ask-on-open').checked = !!(s.settings && s.settings.ask_on_open); $('backup-touch').hidden = !(b.enrolled && bio.host); $('backup-touch').querySelector('span').textContent = 'Show with ' + w; $('export-touch').hidden = !(b.enrolled && bio.host); $('export-touch').querySelector('span').textContent = 'Show with ' + w; $('backup-note-text').textContent = b.enrolled ? `Show the 24 words (or the key) again, with ${w} or the password.` : 'Show the 24 words (or the key) again. Needs the password.'; $('bio-on').hidden = !b.enrolled; $('bio-off').hidden = b.enrolled; - if (document.activeElement !== $('idle-lock')) $('idle-lock').checked = !!b.idle_lock; let off = ''; if (!bio.host) off = w + ' needs the Igneum Wallet app window; this page is open in a browser.'; else if (!b.available) off = b.message || (w === 'Touch ID' ? 'Touch ID is not set up on this Mac.' : 'Windows Hello is not set up on this PC.'); @@ -379,8 +436,9 @@ $('bio-enrol').onclick = async () => { $('bio-enrol').disabled = false; }; $('bio-remove').onclick = async () => { try { await post('/api/biometric/remove'); $('bio-err').textContent = ''; toast(what() + ' is off'); await poll(); } catch (e) { $('bio-err').textContent = e.message; } }; -$('idle-lock').onchange = async ev => { try { await post('/api/settings', { idle_lock: ev.target.checked }); } catch (e) { toast(e.message); } }; -$('unlock-touch').onclick = unlockWithTouch; +$('idle-lock').onchange = async ev => { try { await post('/api/settings', { idle_lock_min: Number(ev.target.value) }); await poll(); } catch (e) { toast(e.message); } }; +$('ask-on-open').onchange = async ev => { try { await post('/api/settings', { ask_on_open: ev.target.checked }); } catch (e) { toast(e.message); } }; +$('unlock-touch').onclick = () => unlockWithTouch(); $('pw-change').onclick = async () => { $('pw-err').textContent = ''; try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('password changed'); } @@ -544,8 +602,8 @@ $('remove-go').onclick = async () => { document.addEventListener('visibilitychange', () => { if (document.hidden) return; poll(); - // the window came back (menu bar, Dock): the prompt once more on the unlock screen, not within 10 s of the last - if (state && state.phase === 'unlock' && bioEnrolled() && bio.host && Date.now() - lastPrompt > 10000) promptPending = true; + // the window came back (menu bar, Dock): the button gets the focus so Return unlocks; never a sheet by itself + if (state && state.phase === 'unlock' && !locking) focusLock(); }); new MutationObserver(() => { if (document.body.dataset.view === 'settings') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-view'] }); diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index ca1ef5bd4..c931301cc 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -104,22 +104,29 @@ - -
    -
    -
    -

    Unlock

    -

    -
    @@ -309,6 +323,7 @@ + diff --git a/app/igneum-wallet/ui/lock-screen.js b/app/igneum-wallet/ui/lock-screen.js new file mode 100644 index 000000000..45356314c --- /dev/null +++ b/app/igneum-wallet/ui/lock-screen.js @@ -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; diff --git a/app/igneum-wallet/ui/lock-screen.test.mjs b/app/igneum-wallet/ui/lock-screen.test.mjs new file mode 100644 index 000000000..57fffca16 --- /dev/null +++ b/app/igneum-wallet/ui/lock-screen.test.mjs @@ -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'); +}); diff --git a/app/windows/wallet-version.h b/app/windows/wallet-version.h index 4b46930cf..7320c3f05 100644 --- a/app/windows/wallet-version.h +++ b/app/windows/wallet-version.h @@ -2,6 +2,6 @@ // 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.2" -#define IGNEUM_HOST_VERSION_RC 0,1,2,0 +#define IGNEUM_HOST_VERSION_STR "0.1.4" +#define IGNEUM_HOST_VERSION_RC 0,1,4,0 #endif diff --git a/packaging/windows/Igneum-Wallet.iss b/packaging/windows/Igneum-Wallet.iss index d14e3a44b..2e3e49db4 100644 --- a/packaging/windows/Igneum-Wallet.iss +++ b/packaging/windows/Igneum-Wallet.iss @@ -9,7 +9,7 @@ #define ArtDir "..\..\brand\icons" #endif #ifndef AppVersion - #define AppVersion "0.1.2" + #define AppVersion "0.1.4" #endif #define AppName "Igneum Wallet" #define Publisher "Igneum" From 625b0c6d79298dffefe00f853684504f318961df Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:55:14 +0000 Subject: [PATCH 007/150] wallet 0.1.5: the wallet's packaged-config block (write_wallet_config, the manifest URL, the public RPC) re-added over the 0.3.13 packaged-config.sh Co-Authored-By: Claude Fable 5.1 --- packaging/mac/packaged-config.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packaging/mac/packaged-config.sh b/packaging/mac/packaged-config.sh index 1d6e80a78..edd4994e5 100644 --- a/packaging/mac/packaged-config.sh +++ b/packaging/mac/packaged-config.sh @@ -79,6 +79,33 @@ $override_line JSON } +# ---- Igneum Wallet (build-wallet-dmg.sh): igneum-wallet.json next to the wallet engine ---- +# Its manifest is igneum-wallet-latest.json, signed with the same OTA key (publish-manifest.sh --product wallet). +# PUBLIC_RPC: the seed's public Ethereum RPC for users without a node (none yet, 4 October 2026: empty = the wallet +# starts the bundled node). ADD_NETWORK_PAGE: the site page that adds the network to MetaMask (to be hosted). +WALLET_PUBLIC_RPC='' +WALLET_ADD_NETWORK_PAGE='https://igneum.network/wallet/add' +write_wallet_config() { + local out="$1" token="" manifest="" + [ -f "$TOKEN_FILE" ] && token="$(tr -d '[:space:]' < "$TOKEN_FILE")" + [ -n "$token" ] && manifest="https://dl.igneum.network/dl/$token/igneum-wallet-latest.json" + [ -n "$manifest" ] || echo "note: no $TOKEN_FILE; the update check is disabled in this build" + local override_line="" + [ -n "$NODE_OVERRIDE_PARAMS" ] && override_line=" \"node_override_params\": $NODE_OVERRIDE_PARAMS," + cat > "$out" < Date: Tue, 6 Oct 2026 15:56:07 +0000 Subject: [PATCH 008/150] Igneum Wallet 0.1.5: the three version files (the 0.3.14 node and the thirteen-field consensus object bundled; the wallet's node peers again) Co-Authored-By: Claude Fable 5.1 --- app/igneum-wallet/Cargo.lock | 2 +- app/igneum-wallet/Cargo.toml | 2 +- app/windows/wallet-version.h | 4 ++-- packaging/windows/Igneum-Wallet.iss | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/igneum-wallet/Cargo.lock b/app/igneum-wallet/Cargo.lock index 0d02a1e85..1bc3e1038 100644 --- a/app/igneum-wallet/Cargo.lock +++ b/app/igneum-wallet/Cargo.lock @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "igneum-wallet" -version = "0.1.4" +version = "0.1.5" dependencies = [ "argon2", "bip32", diff --git a/app/igneum-wallet/Cargo.toml b/app/igneum-wallet/Cargo.toml index 22357afff..6f571392b 100644 --- a/app/igneum-wallet/Cargo.toml +++ b/app/igneum-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-wallet" -version = "0.1.4" +version = "0.1.5" 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" diff --git a/app/windows/wallet-version.h b/app/windows/wallet-version.h index 7320c3f05..efcbef88b 100644 --- a/app/windows/wallet-version.h +++ b/app/windows/wallet-version.h @@ -2,6 +2,6 @@ // 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.4" -#define IGNEUM_HOST_VERSION_RC 0,1,4,0 +#define IGNEUM_HOST_VERSION_STR "0.1.5" +#define IGNEUM_HOST_VERSION_RC 0,1,5,0 #endif diff --git a/packaging/windows/Igneum-Wallet.iss b/packaging/windows/Igneum-Wallet.iss index 2e3e49db4..3393210d5 100644 --- a/packaging/windows/Igneum-Wallet.iss +++ b/packaging/windows/Igneum-Wallet.iss @@ -9,7 +9,7 @@ #define ArtDir "..\..\brand\icons" #endif #ifndef AppVersion - #define AppVersion "0.1.4" + #define AppVersion "0.1.5" #endif #define AppName "Igneum Wallet" #define Publisher "Igneum" From 959a8bbeb1f70c6b6d009e885560d68606fb8c77 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:56:49 +0000 Subject: [PATCH 009/150] wallet 0.1.5: the plan skeleton (the bundled node and object, the checks, the Windows question, the rule row) Co-Authored-By: Claude Fable 5.1 --- docs/plans/release-wallet-0.1.5.md | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/plans/release-wallet-0.1.5.md diff --git a/docs/plans/release-wallet-0.1.5.md b/docs/plans/release-wallet-0.1.5.md new file mode 100644 index 000000000..219730b9a --- /dev/null +++ b/docs/plans/release-wallet-0.1.5.md @@ -0,0 +1,39 @@ +# 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); igneum-labs 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 '' --mac [--windows ] --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 | (pending) | +| The DMG | `NODE=<0.3.14 igneumd> tools/lock/with-lock.sh build packaging/mac/build-wallet-dmg.sh` | (pending) | +| 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=...` | (pending) | +| 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 From 29b70a0be5a0ecd5840a3643c877d01d7dcae6c0 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:20:52 +0000 Subject: [PATCH 010/150] wallet 0.1.5: write_wallet_config reads the token through the 0.3.13 tree's helpers (TOKEN_FILE no longer exists) Co-Authored-By: Claude Fable 5.1 --- packaging/mac/packaged-config.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packaging/mac/packaged-config.sh b/packaging/mac/packaged-config.sh index edd4994e5..e623fcf25 100644 --- a/packaging/mac/packaged-config.sh +++ b/packaging/mac/packaged-config.sh @@ -87,9 +87,10 @@ WALLET_PUBLIC_RPC='' WALLET_ADD_NETWORK_PAGE='https://igneum.network/wallet/add' write_wallet_config() { local out="$1" token="" manifest="" - [ -f "$TOKEN_FILE" ] && token="$(tr -d '[:space:]' < "$TOKEN_FILE")" - [ -n "$token" ] && manifest="https://dl.igneum.network/dl/$token/igneum-wallet-latest.json" - [ -n "$manifest" ] || echo "note: no $TOKEN_FILE; the update check is disabled in this build" + # the token as write_packaged_config reads it (6 October 2026: the .next-file helpers of the 0.3.13 tree) + token="$(igneum_read_trimmed "$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)")" + [ -n "$token" ] && manifest="${DL_HOST:-https://dl.igneum.network}/dl/$token/igneum-wallet-latest.json" + [ -n "$manifest" ] || echo "note: no download token file; the update check is disabled in this build" local override_line="" [ -n "$NODE_OVERRIDE_PARAMS" ] && override_line=" \"node_override_params\": $NODE_OVERRIDE_PARAMS," cat > "$out" < Date: Tue, 6 Oct 2026 16:21:16 +0000 Subject: [PATCH 011/150] wallet 0.1.5: the intake key through the tree's helper as well (LOG_KEY no longer exists) Co-Authored-By: Claude Fable 5.1 --- packaging/mac/packaged-config.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packaging/mac/packaged-config.sh b/packaging/mac/packaged-config.sh index e623fcf25..e17a74aec 100644 --- a/packaging/mac/packaged-config.sh +++ b/packaging/mac/packaged-config.sh @@ -89,6 +89,7 @@ write_wallet_config() { local out="$1" token="" manifest="" # the token as write_packaged_config reads it (6 October 2026: the .next-file helpers of the 0.3.13 tree) token="$(igneum_read_trimmed "$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)")" + local key; key="$(igneum_read_trimmed "$(igneum_secret_file IGNEUM_INTAKE_KEY_FILE log-intake-key)")" [ -n "$token" ] && manifest="${DL_HOST:-https://dl.igneum.network}/dl/$token/igneum-wallet-latest.json" [ -n "$manifest" ] || echo "note: no download token file; the update check is disabled in this build" local override_line="" @@ -98,7 +99,7 @@ write_wallet_config() { $override_line "update_manifest": "$manifest", "log_intake_url": "$LOG_URL", - "log_intake_key": "$LOG_KEY", + "log_intake_key": "$key", "live_page": "$LIVE_PAGE", "download_page": "$DOWNLOAD_PAGE", "public_rpc": "$WALLET_PUBLIC_RPC", From ab25300dea93622257f6ccfad4b6dcfb5483220a Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:23:31 +0000 Subject: [PATCH 012/150] wallet 0.1.5: the recipe-check DMG on the 0.3.13 node, the two packaged-config fixes, the bundled object verified Co-Authored-By: Claude Fable 5.1 --- docs/plans/release-wallet-0.1.5.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/plans/release-wallet-0.1.5.md b/docs/plans/release-wallet-0.1.5.md index 219730b9a..5bcb5f1f1 100644 --- a/docs/plans/release-wallet-0.1.5.md +++ b/docs/plans/release-wallet-0.1.5.md @@ -24,9 +24,10 @@ merged in (65814ad; the five tooling files taken from the 0.3.13 side, the walle | Check | How | Result | |---|---|---| -| The engine builds against the 0.3.13 tree | `cargo build --release` in `app/igneum-wallet` under the lock | (pending) | -| The DMG | `NODE=<0.3.14 igneumd> tools/lock/with-lock.sh build packaging/mac/build-wallet-dmg.sh` | (pending) | -| 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=...` | (pending) | +| 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= 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=` | (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) | From a5f053f0ad9e71c1a3a1228e3cec5dd8b23e207e Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:38:49 +0000 Subject: [PATCH 013/150] Igneum Wallet UI 3: the wallet on the miner's system (audit, design, build, site) The audit (docs/plans/wallet-ui-3-audit.md, 24 before-captures), the design (docs/plans/wallet-ui-3.md) and the build: five sections in the miner's rail (Home, Send, Receive, History, Settings), the balance first with the money line ("no market price on devnet: coins have no value" until price_gbp_per_ign exists), one node line with Details, Send confirmed in place with the fee behind Details and the pending row after, Receive with the address box and the QR, History with the node's ONE state word per row (pending, included, executed, proven, finalised; the wallet's own verified final wins; failed from the receipt) and a reason line, Settings as plain rows (Security, Backup, This machine with Appearance, the Igneum Wallet update card, Node with the endpoint, Advanced with the remove ask), no dialogs, dark and light, a bottom tab bar under 720 px. The Rust engine is untouched: the node words come from the existing GET /api/tx/. One Swift line removed so the window follows the Mac's appearance. view.test.mjs (11) with lock-screen (5) and update-card (4): 20 pass. tools/ui-mock/wallet.mjs and shoot-wallet.mjs (one niced headless chromium, closed after the batch). Result captures n00 to n24 beside the audit's, and the real Mac window m00 to m06 from this worktree's engine on a scratch data dir with a throwaway wallet (deleted after). Site: wallet.html and the wallet section of index.html on the shipped design; wallet-home.webp and wallet-final.webp from the mock with example addresses. Co-Authored-By: Claude Fable 5.1 --- app/igneum-wallet/ui/app.css | 773 ++++----- app/igneum-wallet/ui/app.js | 1511 ++++++++++------- app/igneum-wallet/ui/index.html | 423 +++-- app/igneum-wallet/ui/view.test.mjs | 161 ++ app/mac/IgneumWallet.swift | 1 - docs/plans/wallet-ui-3-audit.md | 209 +++ docs/plans/wallet-ui-3.md | 246 +++ docs/plans/wallet-ui-3/00-welcome.png | Bin 0 -> 237405 bytes docs/plans/wallet-ui-3/01-create-password.png | Bin 0 -> 82490 bytes docs/plans/wallet-ui-3/02-create-words.png | Bin 0 -> 140646 bytes docs/plans/wallet-ui-3/03-import.png | Bin 0 -> 92131 bytes docs/plans/wallet-ui-3/04-unlock-touch.png | Bin 0 -> 479153 bytes docs/plans/wallet-ui-3/06-home.png | Bin 0 -> 216729 bytes docs/plans/wallet-ui-3/07-home-lower.png | Bin 0 -> 251969 bytes docs/plans/wallet-ui-3/08-send.png | Bin 0 -> 58558 bytes docs/plans/wallet-ui-3/09-send-review.png | Bin 0 -> 125790 bytes docs/plans/wallet-ui-3/10-send-sent.png | Bin 0 -> 122595 bytes docs/plans/wallet-ui-3/11-receive.png | Bin 0 -> 87567 bytes docs/plans/wallet-ui-3/12-tx-final.png | Bin 0 -> 127283 bytes docs/plans/wallet-ui-3/13-settings.png | Bin 0 -> 141837 bytes docs/plans/wallet-ui-3/14-settings-lower.png | Bin 0 -> 170178 bytes docs/plans/wallet-ui-3/15-settings-lowest.png | Bin 0 -> 160109 bytes docs/plans/wallet-ui-3/16-home-public.png | Bin 0 -> 198984 bytes docs/plans/wallet-ui-3/17-home-scanning.png | Bin 0 -> 187987 bytes docs/plans/wallet-ui-3/18-home-nonode.png | Bin 0 -> 177597 bytes docs/plans/wallet-ui-3/19-home-empty.png | Bin 0 -> 180592 bytes docs/plans/wallet-ui-3/20-update-card.png | Bin 0 -> 347759 bytes docs/plans/wallet-ui-3/21-narrow-900-home.png | Bin 0 -> 156711 bytes docs/plans/wallet-ui-3/22-phone-home.png | Bin 0 -> 132262 bytes docs/plans/wallet-ui-3/23-phone-settings.png | Bin 0 -> 119431 bytes docs/plans/wallet-ui-3/24-light-home.png | Bin 0 -> 216490 bytes docs/plans/wallet-ui-3/m00-welcome.png | Bin 0 -> 252985 bytes docs/plans/wallet-ui-3/m01-home.png | Bin 0 -> 190580 bytes docs/plans/wallet-ui-3/m02-history.png | Bin 0 -> 134977 bytes docs/plans/wallet-ui-3/m03-receive.png | Bin 0 -> 162942 bytes docs/plans/wallet-ui-3/m04-settings.png | Bin 0 -> 241348 bytes docs/plans/wallet-ui-3/m05-light.png | Bin 0 -> 186363 bytes docs/plans/wallet-ui-3/m06-lock.png | Bin 0 -> 781442 bytes docs/plans/wallet-ui-3/n00-welcome.png | Bin 0 -> 183323 bytes .../plans/wallet-ui-3/n01-create-password.png | Bin 0 -> 78613 bytes docs/plans/wallet-ui-3/n02-create-words.png | Bin 0 -> 137370 bytes docs/plans/wallet-ui-3/n03-import.png | Bin 0 -> 86851 bytes docs/plans/wallet-ui-3/n04-unlock-touch.png | Bin 0 -> 373796 bytes .../plans/wallet-ui-3/n05-unlock-password.png | Bin 0 -> 375720 bytes docs/plans/wallet-ui-3/n06-home.png | Bin 0 -> 202786 bytes docs/plans/wallet-ui-3/n07-home-lower.png | Bin 0 -> 205822 bytes docs/plans/wallet-ui-3/n08-send.png | Bin 0 -> 85697 bytes docs/plans/wallet-ui-3/n09-send-review.png | Bin 0 -> 135826 bytes docs/plans/wallet-ui-3/n10-send-sent.png | Bin 0 -> 101096 bytes docs/plans/wallet-ui-3/n11-receive.png | Bin 0 -> 104845 bytes docs/plans/wallet-ui-3/n12-tx-final.png | Bin 0 -> 216233 bytes docs/plans/wallet-ui-3/n13-settings.png | Bin 0 -> 209266 bytes docs/plans/wallet-ui-3/n14-settings-lower.png | Bin 0 -> 197625 bytes .../plans/wallet-ui-3/n15-settings-lowest.png | Bin 0 -> 230382 bytes docs/plans/wallet-ui-3/n16-home-public.png | Bin 0 -> 206029 bytes docs/plans/wallet-ui-3/n17-home-scanning.png | Bin 0 -> 208259 bytes docs/plans/wallet-ui-3/n18-home-nonode.png | Bin 0 -> 193814 bytes docs/plans/wallet-ui-3/n19-home-empty.png | Bin 0 -> 136209 bytes docs/plans/wallet-ui-3/n20-update-card.png | Bin 0 -> 323841 bytes .../plans/wallet-ui-3/n21-narrow-900-home.png | Bin 0 -> 113648 bytes docs/plans/wallet-ui-3/n22-phone-home.png | Bin 0 -> 119118 bytes docs/plans/wallet-ui-3/n23-phone-settings.png | Bin 0 -> 136607 bytes docs/plans/wallet-ui-3/n24-light-home.png | Bin 0 -> 205812 bytes site/img/wallet-final.webp | Bin 54052 -> 90212 bytes site/img/wallet-home.webp | Bin 83350 -> 75720 bytes site/index.html | 16 +- site/wallet.html | 39 +- tools/ui-mock/shoot-wallet.mjs | 89 + tools/ui-mock/wallet.mjs | 177 ++ 69 files changed, 2455 insertions(+), 1190 deletions(-) create mode 100644 app/igneum-wallet/ui/view.test.mjs create mode 100644 docs/plans/wallet-ui-3-audit.md create mode 100644 docs/plans/wallet-ui-3.md create mode 100644 docs/plans/wallet-ui-3/00-welcome.png create mode 100644 docs/plans/wallet-ui-3/01-create-password.png create mode 100644 docs/plans/wallet-ui-3/02-create-words.png create mode 100644 docs/plans/wallet-ui-3/03-import.png create mode 100644 docs/plans/wallet-ui-3/04-unlock-touch.png create mode 100644 docs/plans/wallet-ui-3/06-home.png create mode 100644 docs/plans/wallet-ui-3/07-home-lower.png create mode 100644 docs/plans/wallet-ui-3/08-send.png create mode 100644 docs/plans/wallet-ui-3/09-send-review.png create mode 100644 docs/plans/wallet-ui-3/10-send-sent.png create mode 100644 docs/plans/wallet-ui-3/11-receive.png create mode 100644 docs/plans/wallet-ui-3/12-tx-final.png create mode 100644 docs/plans/wallet-ui-3/13-settings.png create mode 100644 docs/plans/wallet-ui-3/14-settings-lower.png create mode 100644 docs/plans/wallet-ui-3/15-settings-lowest.png create mode 100644 docs/plans/wallet-ui-3/16-home-public.png create mode 100644 docs/plans/wallet-ui-3/17-home-scanning.png create mode 100644 docs/plans/wallet-ui-3/18-home-nonode.png create mode 100644 docs/plans/wallet-ui-3/19-home-empty.png create mode 100644 docs/plans/wallet-ui-3/20-update-card.png create mode 100644 docs/plans/wallet-ui-3/21-narrow-900-home.png create mode 100644 docs/plans/wallet-ui-3/22-phone-home.png create mode 100644 docs/plans/wallet-ui-3/23-phone-settings.png create mode 100644 docs/plans/wallet-ui-3/24-light-home.png create mode 100644 docs/plans/wallet-ui-3/m00-welcome.png create mode 100644 docs/plans/wallet-ui-3/m01-home.png create mode 100644 docs/plans/wallet-ui-3/m02-history.png create mode 100644 docs/plans/wallet-ui-3/m03-receive.png create mode 100644 docs/plans/wallet-ui-3/m04-settings.png create mode 100644 docs/plans/wallet-ui-3/m05-light.png create mode 100644 docs/plans/wallet-ui-3/m06-lock.png create mode 100644 docs/plans/wallet-ui-3/n00-welcome.png create mode 100644 docs/plans/wallet-ui-3/n01-create-password.png create mode 100644 docs/plans/wallet-ui-3/n02-create-words.png create mode 100644 docs/plans/wallet-ui-3/n03-import.png create mode 100644 docs/plans/wallet-ui-3/n04-unlock-touch.png create mode 100644 docs/plans/wallet-ui-3/n05-unlock-password.png create mode 100644 docs/plans/wallet-ui-3/n06-home.png create mode 100644 docs/plans/wallet-ui-3/n07-home-lower.png create mode 100644 docs/plans/wallet-ui-3/n08-send.png create mode 100644 docs/plans/wallet-ui-3/n09-send-review.png create mode 100644 docs/plans/wallet-ui-3/n10-send-sent.png create mode 100644 docs/plans/wallet-ui-3/n11-receive.png create mode 100644 docs/plans/wallet-ui-3/n12-tx-final.png create mode 100644 docs/plans/wallet-ui-3/n13-settings.png create mode 100644 docs/plans/wallet-ui-3/n14-settings-lower.png create mode 100644 docs/plans/wallet-ui-3/n15-settings-lowest.png create mode 100644 docs/plans/wallet-ui-3/n16-home-public.png create mode 100644 docs/plans/wallet-ui-3/n17-home-scanning.png create mode 100644 docs/plans/wallet-ui-3/n18-home-nonode.png create mode 100644 docs/plans/wallet-ui-3/n19-home-empty.png create mode 100644 docs/plans/wallet-ui-3/n20-update-card.png create mode 100644 docs/plans/wallet-ui-3/n21-narrow-900-home.png create mode 100644 docs/plans/wallet-ui-3/n22-phone-home.png create mode 100644 docs/plans/wallet-ui-3/n23-phone-settings.png create mode 100644 docs/plans/wallet-ui-3/n24-light-home.png create mode 100644 tools/ui-mock/shoot-wallet.mjs create mode 100644 tools/ui-mock/wallet.mjs diff --git a/app/igneum-wallet/ui/app.css b/app/igneum-wallet/ui/app.css index 356b35332..ab7d2e8f7 100644 --- a/app/igneum-wallet/ui/app.css +++ b/app/igneum-wallet/ui/app.css @@ -1,5 +1,10 @@ -/* Igneum Miner dashboard. The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, ash; - Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. */ +/* 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')} @@ -9,426 +14,310 @@ @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{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E; ---gutter:28px;--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:20px;--gap-tile:12px; ---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:52px} +: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:15px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none} -.mono,code,pre{font-family:var(--mono)} +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:52px;letter-spacing:-.01em} -h2{font-weight:700;font-size:32px} -h3{font-weight:700;font-size:16px} +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} -.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:8px} +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:8px;min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:15px;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;white-space:nowrap} +.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:#FF6A2B;border-color:#FF6A2B} -.btn.big{min-height:52px;padding:12px 28px;font-size:16px} -.btn.small{min-height:34px;padding:6px 14px;font-size:13px;border-radius:8px} -.btn.tiny{min-height:26px;padding:2px 10px;font-size:12px;border-radius:7px;font-family:var(--mono);font-weight:500} +.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)} -.icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line-2);background:transparent;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink-2);font-size:20px;line-height:1} -.icon-btn:hover{color:var(--bone);border-color:var(--ash)} +.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;padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag} +.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 .top{padding-left:92px} -.brand{display:flex;align-items:center;gap:10px} +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:11px;letter-spacing:.22em;color:var(--ash);margin-left:4px;padding-top:3px} -.top-right{display:flex;align-items:center;gap:12px} -.pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;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)} -.pill.on{color:var(--molten);border-color:rgba(255,179,92,.35)} +.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} -.dot.small{width:7px;height:7px;flex-basis:7px} .on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite} -.warn .dot{background:var(--ember);animation:none} +.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}} -/* update banner */ -.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:rgba(242,84,27,.12);border-bottom:1px solid rgba(242,84,27,.4);font-size:14px} +/* 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} -.banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap} -.banner.clock.warn{background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} -.banner .hint{font-size:11px;color:var(--ash);flex-basis:100%;text-align:center} -.banner.update{flex-wrap:wrap;row-gap:8px} -.banner.update.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600} -.banner .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-2px} -.banner .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear} -/* remote job strip (src/jobrun.rs): running, then the outcome */ -.banner.job{flex-wrap:wrap;row-gap:8px;background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)} -.banner.job.failed{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5)} -.banner.job .job-results{flex-basis:100%;margin:0;font-size:11px;line-height:1.5;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto} -.job-history table{margin-top:8px} -.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:11px;padding:4px 6px 4px 0} -.job-history td{padding:5px 6px 5px 0;border-top:1px solid var(--line);vertical-align:top} -.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)} -.job-history tr.running td{color:var(--molten)} -.clock-card{margin-top:12px;border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px} -.clock-card.warn{border-color:rgba(255,179,92,.4);background:rgba(255,179,92,.06)} -.clock-msg{font-size:14px;color:var(--bone);line-height:1.45} -.clock-card .note{margin-top:0} +/* 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 */ -main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)} -body.has-bottom main{bottom:var(--bottom)} -body.drawer-open main{bottom:calc(var(--bottom) + 260px)} +/* 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="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block} +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{animation: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:18px;padding:40px 0 48px} -.coin-wrap{position:relative;width:148px;height:148px;margin-bottom:6px} -.coin-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} -.coin{position:relative;display:block;border-radius:50%;filter:drop-shadow(0 10px 30px rgba(242,84,27,.35))} +.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)}} -.lead{font-size:18px;color:var(--ink-2);max-width:54ch} -.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-tile);width:100%;max-width:860px;margin-top:10px;text-align:left} -.tile{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} -.tile .k{font-family:var(--mono);font-size:11px;letter-spacing:.14em;color:var(--ember)} -.tile .t{font-family:var(--head);font-weight:700;font-size:15px;line-height:1.25} -.tile .s{font-size:13px;color:var(--ash);line-height:1.45} -.cta{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;margin-top:10px} -.seedline{font-size:12px;color:var(--ash);letter-spacing:.04em} +@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 */ -.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:16px} -.step .sub{font-size:16px;color:var(--ink-2);max-width:60ch} -.step .cta{justify-content:flex-start;margin-top:8px} -.note{font-size:13px;color:var(--ash);line-height:1.5} -.note.small{font-size:12px;word-break:break-all} -.cards{display:flex;flex-direction:column;gap:12px;margin-top:8px} -.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0} -.card.detecting{display:flex;align-items:center;gap:18px} -.card.detecting .t{font-family:var(--head);font-weight:700;font-size:16px} -.card.detecting .s{font-size:12px;color:var(--ash);margin-top:4px} -.spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px} -@keyframes spin{to{transform:rotate(360deg)}} -.gpu{display:flex;align-items:center;gap:18px} -.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} -.gpu .badge.apple{color:var(--bone)} -.gpu .badge.nvidia{color:#8BE37A} -.gpu .badge.amd{color:var(--ember)} -.gpu .name{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.2} -.gpu .meta{font-family:var(--mono);font-size:12px;color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px} -.gpu .meta b{color:var(--ink-2);font-weight:500} -.gpu .tick{margin-left:auto;width:36px;height:36px;border-radius:50%;background:var(--ember);display:flex;align-items:center;justify-content:center;flex:0 0 36px} -.gpu.off .tick{background:var(--line-2)} -.gpu .tick svg path{stroke-dasharray:30;stroke-dashoffset:30;animation:draw .8s .2s ease forwards} -@keyframes draw{to{stroke-dashoffset:0}} -.gpu .msg{font-size:12px;color:var(--ember);margin-top:4px} - -/* GPU rows (first run and settings) */ -.gpu-row{display:flex;align-items:center;gap:16px;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0} -.gpu-row.off{opacity:.72} -.gpu-row .badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)} -.gpu-row .badge.apple{color:var(--bone)} -.gpu-row .badge.nvidia{color:#8BE37A} -.gpu-row .badge.amd{color:var(--ember)} -.gpu-row .info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} -.gpu-row .name{font-family:var(--head);font-weight:700;font-size:16px;line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap} -.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500} -.kind.discrete,.kind.apple{color:var(--molten);border-color:rgba(255,179,92,.4)} -.kind.external{color:var(--bone)} -.gpu-row .meta{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px} -.gpu-row .meta b{color:var(--ink-2);font-weight:500} -.gpu-row .reason{font-size:12px;color:var(--ash)} -.gpu-row .msg{font-size:12px;color:var(--ember)} -.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto} -.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:4px} -.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1} -.ids button:hover{border-color:var(--ash)} -.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:13px;user-select:text;-webkit-user-select:text} -.ids input:focus{outline:none;border-color:var(--ember)} -.ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0} -.gpu-row.off .ids{opacity:.4;pointer-events:none} -.gpu-row .switch{padding:0;flex:0 0 auto} -.cards.compact .gpu-row{padding:12px 14px;gap:12px;border-radius:14px} -.cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px} -.cards.compact .gpu-row .name{font-size:14px} -.cards.compact .ids .k{display:none} - -.power{display:flex;align-items:center;gap:8px;flex:0 0 auto} -.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.power input[type=range]{width:110px;accent-color:var(--ember)} -.power .pv{font-size:12px;color:var(--ink-2);min-width:84px} -.cards.compact .power .k{display:none} -.cards.compact .power input[type=range]{width:80px} -.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)} -.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)} -.gpu-tile .msg.ok,.gpu-row .msg.ok{color:var(--molten)} -.gpu-row .msg.hot,.gpu-tile .msg.hot{color:var(--ember)} -.gpu-tile .msg .btn.tiny,.gpu-row .msg .btn.tiny{margin-left:6px;vertical-align:middle} -.gpu-tile .msg.warm{color:var(--molten)} -.gpu-tile .msg.hot{color:var(--ember)} - -/* per-card tiles on the dashboard */ -.gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)} -.gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0} -.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:14px;line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap} -.gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums} -.gpu-tile .h .unit{font-family:var(--mono);font-size:11px;color:var(--ash);font-weight:500;letter-spacing:.08em} -.gpu-tile.idle .h{color:var(--ash)} -.gpu-tile .m{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px} -.gpu-tile .m b{color:var(--ink-2);font-weight:500} -.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} -.gpu-tile .st.mining{color:var(--molten)} -.gpu-tile .st.bad{color:var(--ember)} -.gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} -.gpu-tile .msg{font-size:12px;color:var(--ash)} -.gpu-off{margin-top:12px;font-size:12px;color:var(--ash)} - -/* address options */ -.options{display:flex;flex-direction:column;gap:12px;margin-top:6px} -.option{display:flex;gap:16px;align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease} -.option:hover{border-color:var(--line-2)} -.option.on{border-color:var(--ember);background:#1A1614} -.option input[type=radio]{position:absolute;opacity:0;width:0;height:0} -.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative} -.option.on .radio{border-color:var(--ember)} -.option.on .radio::after{content:"";position:absolute;inset:4px;border-radius:50%;background:var(--ember)} -.option .body{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1} -.option .t{font-family:var(--head);font-weight:700;font-size:16px;display:flex;align-items:center;gap:10px} -.option .s{font-size:14px;color:var(--ash);line-height:1.5} -.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid rgba(255,179,92,.4);border-radius:999px;padding:2px 8px} -.addr-input{width:100%;margin-top:8px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:14px;letter-spacing:.02em;user-select:text;-webkit-user-select:text} -.addr-input:focus{outline:none;border-color:var(--ember)} -.option:not(.on) .addr-input{display:none} -.err{font-size:13px;color:var(--ember)} - -/* dashboard */ -#screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)} -body[data-phase="dashboard"] #screen-dashboard{display:flex} -.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)} -.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0} -.cell .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.cell .v{font-family:var(--head);font-weight:700;font-size:26px;line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:8px} -.cell.ember .v{color:var(--ember)} -.cell .v .unit{font-family:var(--mono);font-size:12px;color:var(--ash);font-weight:500;letter-spacing:.08em} -.cell .v.state{text-transform:capitalize;font-size:22px} -.cell .s{font-family:var(--mono);font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.cell.ok .v.state{color:var(--molten)} -.cell.bad .v.state{color:var(--ember)} -.grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start} -.col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0} -.card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap} -.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:12px;color:var(--ash)} -.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums} -#dag{display:block;width:100%;height:190px;border-radius:12px;background:var(--obsidian)} -.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:12px;font-size:12px;color:var(--ink-2)} -.legend span{display:inline-flex;align-items:center;gap:8px} -.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)} -.sw.ember{background:var(--ember);border-color:var(--ember)} -.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)} -.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0} -.tbl{overflow-x:auto} -table{border-collapse:collapse;width:100%;font-size:13px} -th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap} -th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500} -td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)} -tr:last-child td{border-bottom:0} -td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)} -td .st.mining{color:var(--molten)} -td .st.bad{color:var(--ember)} -td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block} -td .sub{display:block;font-family:var(--mono);font-size:11px;color:var(--ash);white-space:normal;max-width:280px} -.empty{color:var(--ash);font-size:13px;padding:10px 0} -.kv{display:flex;flex-direction:column} -.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:7px 0;border-bottom:1px solid var(--line)} -.kv>div:last-child{border-bottom:0} -.kv .k{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)} -.kv .v{font-size:14px;font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.card .note{margin-top:10px} -.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:12px;color:var(--ink-2);max-height:300px;overflow:auto} -.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline} -.feed>div:last-child{border-bottom:0} -.feed .t{color:var(--ash);flex:0 0 auto;font-size:11px} -.feed .k{color:var(--molten);margin-right:6px} -.feed .k.error{color:var(--ember)} -.feed .k.block{color:var(--ember)} -.feed .k.build{color:var(--ink-2)} - -/* sheet (the key) */ -.sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px} -.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease} -.sheet .sub{font-size:15px;color:var(--ink-2)} -.field{display:flex;flex-direction:column;gap:8px;margin-top:6px} -.field .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} -.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:13px;word-break:break-all;user-select:text;-webkit-user-select:text} +/* 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)} -.check{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;margin-top:4px} -.check.small{font-size:12px;color:var(--ash)} -.check input{width:18px;height:18px;accent-color:var(--ember)} -.sheet .cta{justify-content:flex-start} +.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)} -/* settings panel */ -.panel-wrap{justify-content:flex-end;padding:0} -.panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease} -@keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}} -.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)} -.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px} -.row{display:flex;gap:10px;align-items:center} -.row.between{justify-content:space-between} -.row .addr-input{margin-top:0} -.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:14px;user-select:text;-webkit-user-select:text} -.num:focus{outline:none;border-color:var(--ember)} -.switch{display:flex;align-items:center;gap:12px;font-size:14px;cursor:pointer;padding:6px 0} +/* 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} -.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease} +.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} -/* bottom bar */ -.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21} -.bottom .left,.bottom .right{display:flex;align-items:center;gap:8px} -.bottom .mid{font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;flex:1} -.bottom .right .mono{font-size:12px} +/* 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)} -/* log drawer */ -.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column} -body.drawer-open .drawer{height:260px} -.drawer-head{display:flex;justify-content:space-between;align-items:center;padding:8px var(--gutter);border-bottom:1px solid var(--line);flex:0 0 auto} -.chips{display:flex;gap:6px} -.chip{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer} -.chip.on{color:var(--molten);border-color:rgba(255,179,92,.4)} -.log{margin:0;flex:1;overflow:auto;padding:10px var(--gutter);font-size:12px;line-height:1.55;color:var(--ink-2);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text} -.log .src{color:var(--ash)} -.log .src.node{color:#7FA7C9} -.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4{color:var(--molten)} -.log .src.app{color:var(--ember)} -.log .e{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)} -.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:13px;z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5)} +/* 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} -@media (max-width:860px){ - .strip{grid-template-columns:repeat(2,minmax(0,1fr))} - .grid{grid-template-columns:1fr} - .three{grid-template-columns:1fr} - h1{font-size:40px} -} - -/* ---- Igneum Wallet (added to the miner's tokens and base styles above) ---- */ -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} -.view{display:none} -body[data-view="overview"] #view-overview,body[data-view="send"] #view-send,body[data-view="receive"] #view-receive,body[data-view="tx"] #view-tx,body[data-view="settings"] #view-settings{display:block} -body{user-select:text;-webkit-user-select:text} -.field{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--ash)} -.field input,.field textarea,.inline input{font:inherit;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;min-height:44px} -.field textarea{font-family:var(--mono);font-size:14px;line-height:1.5;resize:vertical} -.field input.mono{font-family:var(--mono)} -.field input:focus,.field textarea:focus,.inline input:focus{outline:none;border-color:var(--ember)} -.err{color:var(--ember);font-size:13px;min-height:18px} -.err:empty{display:none} -.words{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin:8px 0 0;padding:0 0 0 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:14px;display:flex;gap:8px;align-items:baseline} -.words li::before{content:counter(w);color:var(--ash);font-size:11px;min-width:18px;text-align:right} -.words.small{grid-template-columns:repeat(6,minmax(0,1fr))} -.words.small li{font-size:12px;padding:5px 8px} -.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:12px;color:var(--ash)} -.checks input{font:inherit;font-family:var(--mono);font-size:15px;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px} -.segs{display:flex;gap:6px;background:var(--graphite);border:1px solid var(--line);border-radius:12px;padding:4px;width:max-content} -.seg{font:inherit;font-size:13px;font-weight:600;color:var(--ash);background:transparent;border:0;border-radius:9px;padding:8px 14px;cursor:pointer} -.seg.on{background:var(--obsidian);color:var(--bone)} -.seg:disabled{opacity:.4;cursor:default} -.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} -.balance-card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:28px var(--card-pad);margin-top:28px;display:flex;flex-direction:column;gap:12px;align-items:flex-start} -.balance{display:flex;align-items:baseline;gap:12px;font-family:var(--head);font-weight:900;font-size:48px;letter-spacing:-.01em;line-height:1} -.balance .unit{font-family:var(--mono);font-size:14px;color:var(--ash);letter-spacing:.12em} -.addr-row{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)} -.actions{display:flex;gap:12px;margin-top:6px} -.split{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap);margin:var(--gap) 0} -.card+.card{margin-top:var(--gap)} -.card h3{margin-bottom:8px} -.kv{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;font-size:13px;margin-top:10px} -.kv span{color:var(--ash)} -.kv b{font-weight:500;font-family:var(--mono);word-break:break-all} -.kv b.ok{color:var(--molten)} -.kv b.warn{color:var(--ember)} -.history{margin-top:10px;display:flex;flex-direction:column} -.history .row{display:grid;grid-template-columns:90px 1fr max-content 110px;gap:14px;align-items:center;padding:10px 0;border-top:1px solid var(--line);cursor:pointer;font-size:13px} -.history .row:hover{background:rgba(255,255,255,.02)} -.history .row:first-child{border-top:0} -.history .kind{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)} -.history .who{font-family:var(--mono);color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.history .amt{font-family:var(--mono);font-weight:500} -.history .amt.in{color:var(--molten)} -.history .fin{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;text-align:right} -.fin.pending{color:var(--ash)}.fin.in_block{color:var(--ink-2)}.fin.final{color:var(--molten)}.fin.failed{color:var(--ember)} -.history .empty{color:var(--ash);font-size:13px;padding:8px 0} -.confirm{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);display:flex;flex-direction:column;gap:12px} -.confirm .row{display:flex;justify-content:space-between;gap:16px;font-size:14px;color:var(--ash)} -.confirm .row b{color:var(--bone);font-weight:500;text-align:right} -.confirm .row b.small{font-size:12px;word-break:break-all} -.confirm .row.total{border-top:1px solid var(--line);padding-top:12px;color:var(--ink-2)} -.qr{width:240px;height:240px;background:var(--bone);border-radius:14px;padding:8px} +/* 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} -.addr-big{font-size:14px;word-break:break-all;color:var(--ink-2)} -.finality-line{font-family:var(--mono);font-size:13px;padding:12px 14px;border-radius:12px;border:1px solid var(--line);background:var(--graphite)} -.finality-line.final{border-color:rgba(255,179,92,.4);color:var(--molten)} -.finality-line.failed{border-color:rgba(242,84,27,.5);color:var(--ember)} -.inline{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:8px} -.inline input{min-width:200px} -.warn-box{margin-top:14px;border:1px solid rgba(242,84,27,.4);background:rgba(242,84,27,.06);border-radius:12px;padding:12px 14px} -.warn-box b{font-size:14px} -.danger-card{border-color:rgba(242,84,27,.35)} -.switch{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer} -/* the miner's switch hides its input behind a .track span the wallet does not render: the box itself shows here - (fixed 5 October 2026: the three wallet switches were invisible, only their words could be clicked) */ -.switch input{position:static;opacity:1;width:18px;height:18px;margin:0;accent-color:var(--ember);flex:0 0 18px} -.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bone);color:var(--obsidian);font-family:var(--mono);font-size:13px;padding:10px 16px;border-radius:999px;z-index:30} -.step .card{margin-top:12px} -#view-settings .step{max-width:760px} +.qr-row .help{flex:1 1 240px;padding-top:var(--s-2)} -/* 5 October 2026: the version in the brand band, the coin on the balance card, Touch ID / Windows Hello controls */ -.brand .ver{font-size:11px;letter-spacing:.08em;color:var(--ash);margin-left:10px;align-self:center} -.balance-row{display:flex;align-items:center;gap:18px} -.coin.small{width:56px;height:56px;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))} -.reading{font-size:12px;color:var(--ash);letter-spacing:.04em;margin-top:8px} -.version-row{font-size:12px;color:var(--ash);letter-spacing:.06em;margin-top:-6px} -.version-row b{color:var(--ink-2);font-weight:500} -.btn.fp svg{flex:0 0 auto} -.bio-row{display:flex;flex-direction:column;align-items:center;gap:10px;margin-top:6px} -.bio-row .note{text-align:center;max-width:46ch} -.unlock-dim .unlock{opacity:.7} -#send-go .fp-ico[hidden]{display:none} -.bio-state{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;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)} -.err .bio-state{font-family:var(--mono)} - -/* the update card (update-card.js, app.js renderUpdateCard; the miner carries the same block): the mark with its - ring, the name, one line, up to three note lines, the size, Install now and Later. Over every screen. */ -.upd-wrap{position:fixed;inset:0;z-index:35;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease} +/* 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:8px;box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none;user-select:none;-webkit-user-select:none} +.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:12px} +.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} @@ -436,63 +325,153 @@ body{user-select:text;-webkit-user-select:text} .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:11px;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-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:16px;color:var(--ink-2);line-height:1.4;position:relative} -.upd-cause{font-size:12px;color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word} -.upd-notes{list-style:none;margin:8px auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:14px;color:var(--bone);line-height:1.4} +.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:12px;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{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:13px;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:12px;color:var(--ash);letter-spacing:.04em;margin-top:8px;min-height:18px;line-height:1.5;max-width:44ch} +.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:12px;align-items:center;justify-content:center;flex-wrap:wrap;margin-top:16px;position:relative} +.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:8px}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}} +@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, 5 October 2026; ui/lock-screen.js, index.html #screen-unlock) ---- - The coin large and centred on the obsidian ground with the ember glow, the name in Unbounded, the short address - in mono, one primary control. It lies over the main area (absolute, the header stays), enters in ENTER_MS (250 ms) - and the home screen leaves beneath it in the same 250 ms, so locking is a transition, not a cut. The glow breathes - slowly; reduced motion stops it. */ +/* 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:220px;height:220px;margin-bottom:22px;flex:0 0 auto} +.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-coin .coin{position:relative;width:220px;height:220px;filter:drop-shadow(0 18px 48px rgba(242,84,27,.45))} +.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:14px;color:var(--ash);letter-spacing:.06em;margin-top:2px} -/* the control area keeps one height, so "Use password" reveals the field in place and nothing above it moves */ +.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:12px;letter-spacing:.04em;color:var(--ash);text-align:center;max-width:60ch;line-height:1.5;text-wrap:balance} +.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:13px;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{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,.lock-coin .coin{width:176px;height:176px}.lock-coin{margin-bottom:14px}.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 (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}} -/* Settings > Touch ID: the idle lock's choices and "Ask for Touch ID when the wallet opens" */ -.select{font:inherit;font-size:14px;color:var(--bone);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:7px 12px;min-height:38px;cursor:pointer} -.select:focus{outline:none;border-color:var(--ember)} -.idle-row{margin-top:4px} -.idle-label{font-size:14px} +/* 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} +} diff --git a/app/igneum-wallet/ui/app.js b/app/igneum-wallet/ui/app.js index 1fdb9660f..c8e1c5ef7 100644 --- a/app/igneum-wallet/ui/app.js +++ b/app/igneum-wallet/ui/app.js @@ -1,611 +1,938 @@ -// Igneum Wallet window. Talks to the engine on the same origin (the token is in the path); polls /api/state every -// 2 s; never holds a key: words and keys only pass through when the engine shows them once. +// Igneum Wallet window (wallet-ui-3, on the miner's system). Talks to the engine on the same origin (the token is in +// the path); polls /api/state every 2 s; never holds a key: words and keys only pass through when the engine shows +// them once. The first block, View, is pure (the words every page shows for a state) and is what view.test.mjs loads: +// the file is run with `module` defined and no `document`, so only View executes there. The DOM block follows. 'use strict'; -const $ = id => document.getElementById(id); -const base = location.pathname.replace(/\/$/, ''); -const api = async (path, body) => { - const r = await fetch(base + path, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - const j = await r.json().catch(() => ({ ok: false, error: 'bad answer' })); - if (!r.ok || j.ok === false) throw new Error(j.error || ('http ' + r.status)); - return j; -}; -const post = (path, body = {}) => api(path, body); -let state = null, quote = null, sentHash = null, txHash = null, lastPhase = null; -if (location.search.includes('host=mac')) document.body.classList.add('mac'); -// ---- Touch ID / Windows Hello: the window host's bridge (app/mac/Biometric.swift; app/windows/wallet-host.cpp) ---- -// The page posts {id, op, ...}; the host answers window.__igneumBiometric(id, {ok, code, message}). The secret never -// comes this way: the host posts the password to the engine itself, and confirmations are nonces the engine issued. -const bio = (() => { - const pending = new Map(); let seq = 0; - const mac = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.biometric); - const win = !!(window.chrome && window.chrome.webview); - window.__igneumBiometric = (id, r) => { const p = pending.get(id); if (p) { pending.delete(id); p(r || { ok: false, code: 'bad', message: 'no answer' }); } }; - if (win) window.chrome.webview.addEventListener('message', ev => { let d = ev.data; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } } if (d && d.id != null) window.__igneumBiometric(d.id, d); }); - return { - host: mac ? 'mac' : win ? 'windows' : null, - busy: false, - call(op, params = {}) { - return new Promise(res => { - if (!this.host) return res({ ok: false, code: 'nohost', message: what() + ' needs the Igneum Wallet app window.' }); - const id = ++seq; pending.set(id, res); this.busy = true; - const m = Object.assign({ id, op }, params); - if (mac) window.webkit.messageHandlers.biometric.postMessage(m); else window.chrome.webview.postMessage(JSON.stringify(m)); - setTimeout(() => { if (pending.has(id)) { pending.delete(id); res({ ok: false, code: 'timeout', message: what() + ' did not answer.' }); } }, 180000); - }).then(r => { this.busy = false; return r; }); +var View = (function () { + var PAGES = [ + { id: 'home', title: 'Home', sub: 'your balance, your address, the node' }, + { id: 'send', title: 'Send', sub: 'one payment, confirmed here' }, + { id: 'receive', title: 'Receive', sub: 'your address, two ways' }, + { id: 'history', title: 'History', sub: "every transaction, with the node's word" }, + { id: 'settings', title: 'Settings', sub: 'security, backup, this machine, updates, the node' } + ]; + function page(id) { for (var i = 0; i < PAGES.length; i++) if (PAGES[i].id === id) return PAGES[i]; return PAGES[0]; } + function withCommas(n) { return (Number(n) || 0).toLocaleString('en-GB'); } + function shortHex(a, n, m) { n = n || 6; m = m || 4; return a && a.length > n + m + 2 ? a.slice(0, 2 + n) + '…' + a.slice(-m) : (a || ''); } + // wei (a decimal string) to IGN with up to `places` decimals, trailing zeros cut; never scientific + function ign(wei, places) { + places = places == null ? 6 : places; + var w; try { w = BigInt(String(wei || '0').trim() || '0'); } catch (e) { return '0'; } + var neg = w < 0n; if (neg) w = -w; + var whole = w / 1000000000000000000n, frac = (w % 1000000000000000000n).toString().padStart(18, '0').slice(0, places).replace(/0+$/, ''); + return (neg ? '-' : '') + (frac ? whole + '.' + frac : String(whole)); + } + function ago(s) { s = Math.max(0, Math.round(s || 0)); return s < 60 ? s + ' s ago' : s < 3600 ? Math.floor(s / 60) + ' min ago' : s < 86400 ? Math.floor(s / 3600) + ' h ago' : Math.floor(s / 86400) + ' d ago'; } + function timeWord(t, now) { + if (!t) return ''; + var d = new Date(t * 1000), n = new Date((now || Date.now() / 1000) * 1000); + var hm = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }); + var same = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate(); + return same ? hm : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' + hm; + } + + // ---------- the balance and its money line ---------- + // the hero shows the number only when it is known; otherwise one line says why (never a 0 for an unknown) + function balanceLine(s) { + var n = s.node || {}; + if (s.balance_known) return { known: true, text: '' }; + if (s.scanning) return { known: false, text: 'reading the chain, ' + withCommas(s.scanned_to || 0) + ' of ' + withCommas(n.block || 0) + ' blocks' }; + if (n.state === 'ok') return { known: false, text: 'reading the balance' }; + if (n.state === 'starting' || n.state === 'connecting') return { known: false, text: 'waiting for the node to start' }; + return { known: false, text: 'waiting for a node' }; + } + // money first when a market price exists (price_gbp_per_ign on the state, owed by the engine); else one grey line + function moneyLine(s) { + var p = Number(s.price_gbp_per_ign || 0); + if (p > 0 && s.balance_known) { + var v = parseFloat(s.balance || '0') * p; + return { money: '£' + v.toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }), text: "at today's price" }; } - }; + var net = (s.settings && s.settings.network) || 'devnet'; + return { money: '', text: net === 'devnet' ? 'no market price on devnet: coins have no value' : 'no market price yet' }; + } + + // ---------- the node line ---------- + function nodeWords(n) { + switch (n.state) { + case 'ok': return { word: 'synced', tone: 'ok' }; + case 'starting': return { word: 'starting', tone: '' }; + case 'connecting': return { word: 'connecting', tone: '' }; + case 'lost': return { word: 'lost', tone: 'bad' }; + } + return { word: n.source === 'none' || !n.source ? 'not found' : 'off', tone: 'bad' }; + } + function sourceWords(src) { + switch (src) { + case 'miner': return "the miner app's node"; + case 'own': return 'the bundled node'; + case 'public': return 'the public RPC'; + case 'external': return 'the node named by the environment'; + } + return 'no node'; + } + function verifyWords(s) { + var f = s.finality || {}, n = s.node || {}; + if (f.verified_index > 0) return 'verified to checkpoint ' + withCommas(f.verified_index); + if (n.source === 'public') return 'certificates cannot be verified without a node'; + if (n.state !== 'ok') return ''; + return 'waiting for the first certificate'; + } + // "Node synced · the miner app's node · block 140,286 · verified to checkpoint 4674" + function nodeLine(s) { + var n = s.node || {}, w = nodeWords(n), parts = ['Node ' + w.word]; + if (n.source && n.source !== 'none') parts.push(sourceWords(n.source)); + if (n.block > 0) parts.push('block ' + withCommas(n.block)); + var v = verifyWords(s); if (v) parts.push(v); + var sub = n.state === 'ok' ? '' : n.state === 'lost' ? 'the node stopped answering; trying again' : (n.message || ''); + return { text: parts.join(' · '), sub: sub, tone: w.tone }; + } + // the old Node and Finality cards as rows with one-line meanings: [key, value, meaning, class] + function nodeDetails(s, now) { + var n = s.node || {}, f = s.finality || {}, rows = []; + rows.push(['source', sourceWords(n.source), n.message || '', n.state === 'ok' ? 'ok' : 'warn']); + rows.push(['chain', n.chain || (n.chain_id ? 'chain id ' + n.chain_id : 'not read yet'), n.chain_id ? 'chain id ' + n.chain_id : 'the chain the node follows', n.chain ? '' : 'dim']); + rows.push(['block', n.block > 0 ? withCommas(n.block) : 'none yet', 'the newest block this node holds', n.block > 0 ? '' : 'dim']); + rows.push(['endpoint', n.evm || 'none yet', 'where this wallet reads balances and sends', n.evm ? '' : 'dim']); + rows.push(['finality on the node', n.source === 'public' ? 'not checkable without a node' : n.finality_active ? 'active, locked ' + withCommas(n.latest_locked) : 'paused', n.source === 'public' ? 'the public RPC carries no certificates' : 'what the node says; the wallet checks for itself below', n.finality_active ? 'ok' : 'dim']); + if (f.verified_index > 0) { + rows.push(['verified here', 'checkpoint ' + withCommas(f.verified_index), 'the newest certificate this wallet checked itself', 'ok']); + rows.push(['signed by', f.signers + ' of ' + f.voters + ' voters', ((f.fraction_total || 0) * 100).toFixed(1) + '% of all weight; two thirds is the rule', '']); + rows.push(['checkpoint height', f.chain_number == null ? 'pending' : withCommas(f.chain_number), 'blocks at or under it are final', f.chain_number == null ? 'dim' : '']); + rows.push(['weights', f.weights_exact ? 'taken at the checkpoint' : 'the latest table', 'the voter weights the signature was checked against', '']); + rows.push(['checked', f.verified_at ? ago((now || Date.now() / 1000) - f.verified_at) : 'not yet', '', '']); + } else { + rows.push(['verified here', 'nothing yet', f.message || 'waiting', 'dim']); + } + return rows; + } + // the pill: the wallet's own words + function pillWords(s) { + if (s.quitting) return { text: 'stopping', cls: '' }; + if (s.phase === 'unlock') return { text: 'locked', cls: '' }; + var n = s.node || {}; + if (n.state === 'ok') return { text: n.source === 'public' ? 'public rpc' : 'synced', cls: 'on' }; + if (n.state === 'starting' || n.state === 'connecting') return { text: n.state, cls: '' }; + if (n.state === 'lost') return { text: 'node lost', cls: 'warn' }; + return { text: 'no node', cls: 'warn' }; + } + + // ---------- the history row ---------- + function kindWord(e) { return { sent: 'Sent', received: 'Received', self: 'To yourself', reward: 'Reward', proving: 'Proving payout' }[e.kind] || e.kind; } + function incoming(e) { return e.kind === 'received' || e.kind === 'reward' || e.kind === 'proving'; } + function synthetic(e) { return /^(reward|proving)-/.test(e.hash || ''); } + function whoLine(e) { + if (e.kind === 'reward') return 'block reward'; + if (e.kind === 'proving') return 'shard payout'; + if (e.kind === 'self') return 'to yourself'; + return incoming(e) ? 'from ' + shortHex(e.from) : 'to ' + shortHex(e.to); + } + // ONE state word per row, never stronger than the chain's own (ledger P17): the wallet's own verified "final" wins + // as finalised; failed from the receipt; else the node's word (igneum_getTransactionStatus); else the wallet's label + function stateWord(e, nodeState) { + var blk = e.block > 0 ? 'block ' + withCommas(e.block) : 'a block'; + if (e.finality === 'final') return { word: 'finalised', tone: 'ok', why: 'under checkpoint ' + withCommas(e.checkpoint || 0) + ', verified here' }; + if (e.finality === 'failed') return { word: 'failed', tone: 'bad', why: 'the execution failed; the fee was still paid' }; + if (synthetic(e)) return e.finality === 'in_block' ? { word: 'executed', tone: 'ink', why: 'in ' + blk } : { word: 'pending', tone: '', why: 'waiting for a block' }; + switch (nodeState) { + case 'pending': return { word: 'pending', tone: '', why: 'waiting for a block' }; + case 'included': return { word: 'included', tone: '', why: 'in ' + blk + ', not executed yet' }; + case 'executed': return { word: 'executed', tone: 'ink', why: 'in ' + blk }; + case 'proven': return { word: 'proven', tone: 'ink', why: 'in ' + blk + ', proof paid' }; + case 'finalised': return { word: 'finalised', tone: 'ink', why: 'under a locked checkpoint, not yet verified here' }; + case 'finality not active': return { word: 'finality not active', tone: 'ink', why: 'under a locked checkpoint; finality is paused on the network' }; + case 'unknown': return { word: 'pending', tone: '', why: 'not in any block the node holds' }; + } + if (e.finality === 'in_block') return { word: 'included', tone: '', why: 'in ' + blk }; + return { word: 'pending', tone: '', why: 'waiting for a block' }; + } + function rowModel(e, nodeState, now) { + var inn = incoming(e), st = stateWord(e, nodeState); + return { hash: e.hash, kind: kindWord(e), who: whoLine(e), when: timeWord(e.time, now), amount: (inn ? '+' : '−') + ign(e.value, 6), in: inn, state: st, synthetic: synthetic(e), nodeWord: nodeState || '' }; + } + // the rows the page asks the node about: not final, not failed, not a reward; newest first, at most `max` + function needsNodeWord(list, max) { + var out = []; + for (var i = 0; i < (list || []).length && out.length < (max || 12); i++) { + var e = list[i]; + if (e.finality === 'final' || e.finality === 'failed' || synthetic(e)) continue; + out.push(e.hash); + } + return out; + } + + // ---------- send ---------- + function gwei(weiPerGas) { var v = Number(weiPerGas || 0) / 1e9; return (v >= 10 ? Math.round(v) : +v.toFixed(v >= 1 ? 1 : 3)) + ' gwei'; } + function feeWords(q) { + if (!q) return { line: 'shown when you review', detail: '' }; + return { + line: 'at most ' + q.fee_max_ign + ' IGN', + detail: 'Gas ' + withCommas(q.gas) + ' at a base fee of ' + q.base_fee_gwei + ' gwei (burned) plus a ' + q.tip_gwei + ' gwei tip (to the miner), capped at ' + gwei(q.max_fee) + '; what is not used comes back.' + }; + } + function sendAsk(q, touch, bioName) { + return { + text: 'Send ' + q.value_ign + ' IGN to ' + shortHex(q.display_to || q.to) + '? Fee at most ' + q.fee_max_ign + ' IGN; ' + q.total_max_ign + ' IGN leaves the wallet at most.', + button: touch ? 'Confirm with ' + (bioName || 'Touch ID') : 'Send now' + }; + } + + // ---------- settings ---------- + function bioSentence(b, what, host) { + b = b || {}; + if (b.enrolled) return what + ' is on. It unlocks the wallet, confirms each send and shows the backup; the password still works everywhere.'; + if (!host) return what + ' needs the Igneum Wallet app window; this page is open in a browser.'; + if (!b.available) return b.message || (what + (what === 'Touch ID' ? ' is not set up on this Mac.' : ' is not set up on this PC.')); + return 'Unlock, confirm each send and show the backup with ' + what + '. The password still works everywhere.'; + } + function idleSentence(min, what) { return Number(min) > 0 ? 'The key is cleared after ' + LockScreenIdle(min) + ' without you; ' + what + ' or the password opens it again.' : 'The wallet stays open until you lock it.'; } + function LockScreenIdle(min) { var m = Number(min) || 0; return m <= 0 ? 'never' : m === 60 ? '1 hour' : m % 60 === 0 ? (m / 60) + ' hours' : m === 1 ? '1 minute' : m + ' minutes'; } + + // ---------- updates: the strip line and the card's note ---------- + function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } + function updateLine(u) { + var v = 'Igneum Wallet ' + u.version; + if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' }; + switch (u.status) { + case 'available': return { text: v + ' is available. Downloading it.' }; + case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true }; + case 'staging': return { text: v + ' downloaded and verified. Preparing it.' }; + case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs as soon as nothing is being sent.'), install: true }; + case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the app closes and opens again by itself.') }; + case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC.', install: true }; + case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true }; + case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) }; + } + return null; + } + // the update card on Settings: "up to date, checked 13 min ago" + function updateNote(u, now) { + var l = updateLine(u), parts = []; + if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.'); + if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.'); + if (u.status === 'off') parts.push('Updates are off in this build.'); + else if (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text); + else if (u.status === 'checking') parts.push('Checking.'); + else if (u.status === 'current') parts.push('Up to date' + (u.checked_at ? ', checked ' + ago((now || Date.now() / 1000) - u.checked_at) : '') + '.'); + else if (u.error) parts.push(u.error); + else parts.push('Not checked yet.'); + return parts.join(' '); + } + + return { PAGES: PAGES, page: page, withCommas: withCommas, shortHex: shortHex, ign: ign, ago: ago, timeWord: timeWord, balanceLine: balanceLine, moneyLine: moneyLine, nodeWords: nodeWords, sourceWords: sourceWords, verifyWords: verifyWords, nodeLine: nodeLine, nodeDetails: nodeDetails, pillWords: pillWords, kindWord: kindWord, whoLine: whoLine, stateWord: stateWord, rowModel: rowModel, needsNodeWord: needsNodeWord, gwei: gwei, feeWords: feeWords, sendAsk: sendAsk, bioSentence: bioSentence, idleSentence: idleSentence, updateLine: updateLine, updateNote: updateNote, cap: cap }; })(); -function what() { const k = state && state.biometric && state.biometric.kind; return k === 'hello' || (!k && /Win/.test(navigator.platform)) ? 'Windows Hello' : 'Touch ID'; } -// the page's own line after the system prompt: the glyph in ember and what happened, in our words -const FP = ''; -function bioLine(r, okText) { - if (!r) return ''; - if (r.ok) return `${FP}${esc(what() + ' ' + (okText || 'confirmed'))}`; - const w = what(), c = r.code; - let t; - if (c === 'cancelled' || c === 'fallback') t = w + ' cancelled, enter your password'; - else if (c === 'failed') t = w + ' did not match, try again or enter your password'; - else if (c === 'locked') t = w + ' is locked, enter your password'; - else if (c === 'invalidated') t = w + ' was turned off (a fingerprint changed), enter your password and turn it on again in Settings'; - else if (c === 'not_set_up' || c === 'unavailable') t = r.message || (w + ' is not set up on this Mac'); - else if (c === 'nohost') t = w + ' needs the Igneum Wallet app window'; - else t = r.message || (w + ' did not succeed'); - return `${FP}${esc(t)}`; -} -function setLine(el, html) { el.innerHTML = html; } -// ?bio=touch: the enrolled look without a host (screenshots); a tap then gets the "needs the app window" line -const forcedBio = new URLSearchParams(location.search).get('bio') === 'touch'; -function hostHere() { return !!bio.host || forcedBio; } +if (typeof module === 'object' && module && module.exports) { module.exports = { View: View }; } -// ---- the lock screen (ui/lock-screen.js): the sheet on a tap, once by itself on the first arrival ---- -// firstPhase: the first phase this page saw ('unlock' means the app opened locked); autoMem.used: the one chance went -let firstPhase = null, locking = false, autoTimer = 0, pwRevealed = false; -const autoMem = { used: false }; -const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); -function lockLayout() { - const s = forcedBio && state ? Object.assign({}, state, { biometric: Object.assign({}, state.biometric, { enrolled: true, available: true, kind: 'touchid' }) }) : state; - return LockScreen.layout(s, hostHere()); -} -function renderLock() { - const lay = lockLayout(); - $('unlock-address').textContent = lay.address; - $('unlock-touch-text').textContent = 'Unlock with ' + what(); - $('unlock-bio').hidden = !lay.touch; - document.querySelector('.lock-controls').classList.toggle('touch', lay.touch); - const showForm = !lay.touch || pwRevealed; - $('unlock-form').hidden = !showForm; - $('unlock-use-pw').hidden = !lay.touch || pwRevealed; -} -function showPasswordField() { - pwRevealed = true; renderLock(); - $('unlock-pw').focus({ preventScroll: true }); -} -function hidePasswordField() { - if (!lockLayout().touch) return; - pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; renderLock(); - $('unlock-touch').focus({ preventScroll: true }); -} -function focusLock() { - const lay = lockLayout(); - if (lay.touch && !pwRevealed) $('unlock-touch').focus({ preventScroll: true }); else $('unlock-pw').focus({ preventScroll: true }); -} -// the home screen leaves and the lock screen enters together, ENTER_MS (250 ms); then the phase flips -function lockTransition() { - locking = true; document.body.classList.add('locking'); $('main').scrollTop = 0; - pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); renderLock(); - setTimeout(() => { locking = false; document.body.classList.remove('locking'); setPhase('unlock'); onLockScreen('lock'); }, reducedMotion() ? 0 : LockScreen.ENTER_MS); -} -// the lock screen is up: reason is arrival (the page opened on it), lock (idle or by hand) or focus -function onLockScreen(reason) { - clearTimeout(autoTimer); - if (reason !== 'lock') { pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); } - renderLock(); focusLock(); - const lay = lockLayout(); - const d = LockScreen.autoPrompt({ reason, phase: 'unlock', touch: lay.touch, askOnOpen: !!(state.settings && state.settings.ask_on_open), hidden: document.hidden, busy: bio.busy, firstPhase, updatedFrom: state.update && state.update.updated_from }, autoMem); - if (reason === 'arrival') autoMem.used = true; - if (d.prompt) autoTimer = setTimeout(() => { if (state && state.phase === 'unlock' && !document.hidden && !bio.busy && !pwRevealed) unlockWithTouch(); }, (reducedMotion() ? 0 : LockScreen.ENTER_MS) + d.delay); -} -async function unlockWithTouch() { - if (bio.busy) return; - clearTimeout(autoTimer); - $('unlock-touch').disabled = true; setLine($('unlock-bio-note'), `${FP}${esc('Waiting for ' + what())}`); - const r = await bio.call('unlock'); - $('unlock-touch').disabled = false; - const l = LockScreen.line(r, what()); - setLine($('unlock-bio-note'), l.text ? `${FP}${esc(l.text)}` : ''); - if (r.ok) { await poll(); return; } - // a cancel or a miss: the button again, never another sheet by itself; the password when the sheet cannot help - if (l.password) showPasswordField(); else $('unlock-touch').focus({ preventScroll: true }); -} -$('unlock-use-pw').onclick = showPasswordField; -$('unlock-pw').addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); hidePasswordField(); } }); -// the idle lock: the window tells the engine a person is here, every 20 s while there is input -let active = false; -['mousemove', 'keydown', 'mousedown', 'wheel', 'touchstart'].forEach(e => document.addEventListener(e, () => { active = true; }, { passive: true })); -setInterval(() => { if (active && state && state.phase === 'home') { active = false; post('/api/activity').catch(() => {}); } }, 20000); - -function toast(text) { const t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(() => { t.hidden = true; }, 1800); } -function copy(text, what) { navigator.clipboard.writeText(text).then(() => toast((what || 'copied') + ' to the clipboard'), () => toast('could not copy')); } -function ign(wei, places = 6) { - const w = BigInt(wei || 0); const whole = w / 10n ** 18n; let frac = (w % 10n ** 18n).toString().padStart(18, '0').slice(0, places).replace(/0+$/, ''); - return frac ? `${whole}.${frac}` : `${whole}`; -} -function short(a) { return a ? a.slice(0, 8) + '…' + a.slice(-6) : ''; } -function when(t) { if (!t) return ''; const d = new Date(t * 1000); return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } -function setPhase(p) { document.body.dataset.phase = p; } -function setView(v) { document.body.dataset.view = v; window.scrollTo(0, 0); $('main').scrollTop = 0; } -function kv(el, rows) { el.innerHTML = rows.map(([k, v, cls]) => `${k}${v}`).join(''); } -const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); - -// ---- state ---- -async function poll() { - try { state = await api('/api/state'); render(); } catch (e) { $('pill-text').textContent = 'engine gone'; $('pill').className = 'pill warn'; } -} -function render() { - const s = state; - const phase = s.phase; - const inFlow = ['create', 'import'].includes(document.body.dataset.phase); - if (firstPhase === null) firstPhase = phase; - if ((!inFlow || phase === 'home') && lastPhase !== phase && !locking) { - if (lastPhase === 'home' && phase === 'unlock') lockTransition(); - else { setPhase(phase); if (phase === 'home') setView('overview'); if (phase === 'unlock') onLockScreen(lastPhase === null ? 'arrival' : 'lock'); } +if (typeof document !== 'undefined') (function () { + var $ = function (id) { return document.getElementById(id); }; + var base = location.pathname.replace(/\/$/, ''); + var api = async function (path, body) { + var r = await fetch(base + path, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + var j = await r.json().catch(function () { return { ok: false, error: 'bad answer' }; }); + if (!r.ok || j.ok === false) throw new Error(j.error || ('http ' + r.status)); + return j; + }; + var post = function (path, body) { return api(path, body || {}); }; + var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; + var state = null, quote = null, sentHash = null, lastPhase = null, page = 'home', firstHome = true; + var params = new URLSearchParams(location.search); + if (params.get('host') === 'mac') document.body.classList.add('mac'); + function stored(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } + function store(k, v) { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch (e) { /* private mode */ } } + function setText(id, t) { var el = $(id); if (el && el.textContent !== t) el.textContent = t; } + function toast(text) { var t = $('toast'); t.textContent = text; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(function () { t.hidden = true; }, 1800); } + function copyText(text, what) { + if (!text) { toast('Nothing to copy yet'); return; } + var done = function () { toast((what || 'Copied') + (what ? ' copied' : '')); }; + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, function () { fallbackCopy(text, done); }); else fallbackCopy(text, done); } - lastPhase = phase; - $('ver').textContent = 'v' + s.version; - $('btn-settings').hidden = phase !== 'home'; - $('btn-lock').hidden = phase !== 'home'; - // pill - const n = s.node; - let pillText = 'no node', pillCls = 'pill warn'; - if (n.state === 'ok') { pillText = `${n.source} node · block ${n.block.toLocaleString()}`; pillCls = 'pill on'; } - else if (n.state === 'starting' || n.state === 'connecting') { pillText = n.state; pillCls = 'pill'; } - else if (n.source === 'public') { pillText = 'public rpc'; pillCls = 'pill'; } - if (s.quitting) { pillText = 'stopping'; pillCls = 'pill'; } - $('pill-text').textContent = pillText; $('pill').className = pillCls; - $('welcome-eyebrow').textContent = `${s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network} · nothing is bought or sold`; - if (forcedUpdate) s.update = sampleUpdate(forcedUpdate); - renderUpdate(s); - renderUpdateCard(s); - // the lock screen: the fingerprint button when enrolled and the app window is the host, else the password field - if (phase === 'unlock' || locking) renderLock(); - // home - $('balance').textContent = s.balance_known ? s.balance : '0'; - $('balance').classList.toggle('dim', !s.balance_known); - const note = $('balance-note'); - if (s.scanning) { note.textContent = `reading the chain, ${(s.scanned_to == null ? 0 : s.scanned_to).toLocaleString()} of ${n.block.toLocaleString()} blocks`; note.hidden = false; } - else if (!s.balance_known) { note.textContent = n.state === 'ok' ? 'reading the balance' : 'waiting for a node'; note.hidden = false; } - else note.hidden = true; - $('home-address').textContent = s.display; - $('backup-note').hidden = s.backed_up; - kv($('node-kv'), [ - ['source', esc(n.source === 'miner' ? 'the miner app' : n.source === 'own' ? 'bundled node' : n.source === 'public' ? 'public RPC' : n.source), n.state === 'ok' ? 'ok' : 'warn'], - ['chain', esc(n.chain || (n.chain_id ? 'id ' + n.chain_id : '…'))], - ['block', n.block.toLocaleString()], - ['finality', n.finality_active ? `active, locked ${n.latest_locked}` : (n.source === 'public' ? 'not checkable without a node' : 'paused')], - ['note', esc(n.message)], - ]); - const f = s.finality; - kv($('fin-kv'), f.verified_index ? [ - ['verified here', `checkpoint ${f.verified_index}`, 'ok'], - ['signed', `${f.signers} of ${f.voters} voters, ${(f.fraction_total * 100).toFixed(1)}% of weight`], - ['chain height', f.chain_number == null ? 'pending' : f.chain_number.toLocaleString()], - ['weights', f.weights_exact ? 'at the checkpoint' : 'latest table'], - ['checked', when(f.verified_at)], - ] : [['status', esc(f.message || 'waiting'), 'warn']]); - $('scan-note').textContent = s.scanning ? `· reading blocks (${s.scanned_to == null ? 0 : s.scanned_to.toLocaleString()} of ${n.block.toLocaleString()})` : ''; - renderHistory(s.history); - // settings - $('start-login').checked = !!s.settings.start_at_login; - renderBio(s); - kv($('settings-node-kv'), [['source', esc(n.source)], ['ethereum rpc', esc(n.evm || 'none')], ['grpc', esc(n.grpc || 'none')], ['chain id', n.chain_id || '…'], ['network', esc(s.settings.network)], ['public rpc', esc(s.public_rpc || 'none in this build')]]); - kv($('machine-kv'), [['machine', esc(s.machine_id.slice(0, 8))], ['version', esc(s.version)], ['logs', esc(s.log_dir)], ['miner key file', s.miner_wallet_present ? 'present' : 'none']]); - $('seg-miner').disabled = !s.miner_wallet_present; - if (document.body.dataset.view === 'tx' && txHash) renderTx(); -} -function renderHistory(list) { - const el = $('history'); - if (!list || !list.length) { el.innerHTML = `
    ${state.scanning ? 'Reading the chain.' : 'Nothing yet. Receive IGN, or point the miner app at this address.'}
    `; return; } - el.innerHTML = list.slice(0, 60).map(e => { - const incoming = e.kind === 'received' || e.kind === 'reward' || e.kind === 'proving'; - const who = e.kind === 'reward' ? 'block reward' : e.kind === 'proving' ? 'shard payout' : e.kind === 'self' ? 'to yourself' : incoming ? 'from ' + short(e.from) : 'to ' + short(e.to); - const fin = e.finality === 'final' ? `final · cp ${e.checkpoint}` : e.finality === 'in_block' ? `in block ${e.block}` : e.finality; - return `
    ${esc(e.kind)}${esc(who)} · ${when(e.time)}${incoming ? '+' : '−'}${ign(e.value)} IGN${esc(fin)}
    `; - }).join(''); - el.querySelectorAll('.row').forEach(r => r.addEventListener('click', () => openTx(r.dataset.hash))); -} + function fallbackCopy(text, done) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (e) { toast('Select and copy it by hand'); } document.body.removeChild(ta); } + document.addEventListener('click', function (e) { + var b = e.target.closest('[data-copy],[data-copy-text]'); if (!b) return; + if (b.dataset.copyText != null) { copyText(b.dataset.copyText, b.dataset.copyWhat || ''); return; } + var el = $(b.getAttribute('data-copy')), t = el ? el.textContent : ''; + if (!t || /^(not set|none yet|not read)/.test(t)) { toast('Nothing to copy yet'); return; } + copyText(t, b.dataset.copyWhat || ''); + }); -// ---- create ---- -$('go-create').onclick = () => { setPhase('create'); $('create-1').hidden = false; $('create-2').hidden = true; $('create-3').hidden = true; $('create-pw').focus(); }; -$('create-back').onclick = () => setPhase('welcome'); -let words = []; -$('create-next').onclick = async () => { - $('create-err').textContent = ''; - const a = $('create-pw').value, b = $('create-pw2').value; - if (a.length < 8) return $('create-err').textContent = 'at least 8 characters'; - if (a !== b) return $('create-err').textContent = 'the two passwords differ'; - try { - const r = await post('/api/create', { password: a }); - words = r.words; - $('words').innerHTML = words.map(w => `
  • ${esc(w)}
  • `).join(''); - $('create-address').textContent = r.display; - $('create-1').hidden = true; $('create-2').hidden = false; - } catch (e) { $('create-err').textContent = e.message; } -}; -$('create-written').onclick = () => { - const picks = []; while (picks.length < 3) { const p = 1 + Math.floor(Math.random() * 24); if (!picks.includes(p)) picks.push(p); } - picks.sort((a, b) => a - b); - $('checks').innerHTML = picks.map(p => ``).join(''); - $('words').innerHTML = ''; words = []; - $('create-2').hidden = true; $('create-3').hidden = false; - $('checks').querySelector('input').focus(); -}; -$('create-again').onclick = () => { setPhase('create'); $('create-3').hidden = true; $('create-1').hidden = false; $('confirm-err').textContent = 'start again: the words are made fresh each time'; }; -$('create-confirm').onclick = async () => { - $('confirm-err').textContent = ''; - const checks = [...$('checks').querySelectorAll('input')].map(i => ({ position: Number(i.dataset.pos), word: i.value.trim() })); - try { await post('/api/create/confirm', { checks }); $('checks').innerHTML = ''; await poll(); setPhase('home'); setView('overview'); toast('wallet ready'); } - catch (e) { $('confirm-err').textContent = e.message; } -}; + // ---------- Touch ID / Windows Hello: the window host's bridge (unchanged from 0.1.4) ---------- + var bio = (function () { + var pending = new Map(), seq = 0; + var mac = !!(window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.biometric); + var win = !!(window.chrome && window.chrome.webview); + window.__igneumBiometric = function (id, r) { var p = pending.get(id); if (p) { pending.delete(id); p(r || { ok: false, code: 'bad', message: 'no answer' }); } }; + if (win) window.chrome.webview.addEventListener('message', function (ev) { var d = ev.data; if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } } if (d && d.id != null) window.__igneumBiometric(d.id, d); }); + return { + host: mac ? 'mac' : win ? 'windows' : null, + busy: false, + call: function (op, p) { + var self = this; + return new Promise(function (res) { + if (!self.host) return res({ ok: false, code: 'nohost', message: what() + ' needs the Igneum Wallet app window.' }); + var id = ++seq; pending.set(id, res); self.busy = true; + var m = Object.assign({ id: id, op: op }, p || {}); + if (mac) window.webkit.messageHandlers.biometric.postMessage(m); else window.chrome.webview.postMessage(JSON.stringify(m)); + setTimeout(function () { if (pending.has(id)) { pending.delete(id); res({ ok: false, code: 'timeout', message: what() + ' did not answer.' }); } }, 180000); + }).then(function (r) { self.busy = false; return r; }); + } + }; + })(); + function what() { var k = state && state.biometric && state.biometric.kind; return k === 'hello' || (!k && /Win/.test(navigator.platform)) ? 'Windows Hello' : 'Touch ID'; } + var FP = ''; + function bioLine(r, okText) { + if (!r) return ''; + if (r.ok) return '' + FP + esc(what() + ' ' + (okText || 'confirmed')) + ''; + var w = what(), c = r.code, t; + if (c === 'cancelled' || c === 'fallback') t = w + ' cancelled, enter your password'; + else if (c === 'failed') t = w + ' did not match, try again or enter your password'; + else if (c === 'locked') t = w + ' is locked, enter your password'; + else if (c === 'invalidated') t = w + ' was turned off (a fingerprint changed), enter your password and turn it on again in Settings'; + else if (c === 'not_set_up' || c === 'unavailable') t = r.message || (w + ' is not set up on this Mac'); + else if (c === 'nohost') t = w + ' needs the Igneum Wallet app window'; + else t = r.message || (w + ' did not succeed'); + return '' + FP + esc(t) + ''; + } + function setLine(el, html) { el.innerHTML = html; } + var forcedBio = params.get('bio') === 'touch'; + function hostHere() { return !!bio.host || forcedBio; } -// ---- import ---- -let importMode = 'seed'; -$('go-import').onclick = () => { setPhase('import'); }; -$('import-back').onclick = () => setPhase('welcome'); -$('import-mode').querySelectorAll('.seg').forEach(b => b.onclick = () => { - importMode = b.dataset.mode; - $('import-mode').querySelectorAll('.seg').forEach(x => x.classList.toggle('on', x === b)); - $('import-data-field').hidden = importMode === 'miner'; - $('import-miner-note').hidden = importMode !== 'miner'; - $('import-data-label').textContent = importMode === 'seed' ? 'The words, in order' : 'The private key, 64 hex characters'; -}); -$('import-go').onclick = async () => { - $('import-err').textContent = ''; - const a = $('import-pw').value, b = $('import-pw2').value; - if (a.length < 8) return $('import-err').textContent = 'at least 8 characters'; - if (a !== b) return $('import-err').textContent = 'the two passwords differ'; - try { await post('/api/import', { mode: importMode, data: $('import-data').value, password: a }); $('import-data').value = ''; await poll(); setPhase('home'); setView('overview'); toast('imported'); } - catch (e) { $('import-err').textContent = e.message; } -}; + // ---------- the lock screen (ui/lock-screen.js, 0.1.4, unchanged) ---------- + var firstPhase = null, locking = false, autoTimer = 0, pwRevealed = false; + var autoMem = { used: false }; + var reducedMotion = function () { return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); }; + function lockLayout() { + var s = forcedBio && state ? Object.assign({}, state, { biometric: Object.assign({}, state.biometric, { enrolled: true, available: true, kind: 'touchid' }) }) : state; + return LockScreen.layout(s, hostHere()); + } + function renderLock() { + var lay = lockLayout(); + $('unlock-address').textContent = lay.address; + $('unlock-touch-text').textContent = 'Unlock with ' + what(); + $('unlock-bio').hidden = !lay.touch; + document.querySelector('.lock-controls').classList.toggle('touch', lay.touch); + var showForm = !lay.touch || pwRevealed; + $('unlock-form').hidden = !showForm; + $('unlock-use-pw').hidden = !lay.touch || pwRevealed; + } + function showPasswordField() { pwRevealed = true; renderLock(); $('unlock-pw').focus({ preventScroll: true }); } + function hidePasswordField() { if (!lockLayout().touch) return; pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; renderLock(); $('unlock-touch').focus({ preventScroll: true }); } + function focusLock() { var lay = lockLayout(); if (lay.touch && !pwRevealed) $('unlock-touch').focus({ preventScroll: true }); else $('unlock-pw').focus({ preventScroll: true }); } + function lockTransition() { + locking = true; document.body.classList.add('locking'); $('main').scrollTop = 0; + pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); renderLock(); + setTimeout(function () { locking = false; document.body.classList.remove('locking'); setPhase('unlock'); onLockScreen('lock'); }, reducedMotion() ? 0 : LockScreen.ENTER_MS); + } + function onLockScreen(reason) { + clearTimeout(autoTimer); + if (reason !== 'lock') { pwRevealed = false; $('unlock-pw').value = ''; $('unlock-err').textContent = ''; setLine($('unlock-bio-note'), ''); } + renderLock(); focusLock(); + var lay = lockLayout(); + var d = LockScreen.autoPrompt({ reason: reason, phase: 'unlock', touch: lay.touch, askOnOpen: !!(state.settings && state.settings.ask_on_open), hidden: document.hidden, busy: bio.busy, firstPhase: firstPhase, updatedFrom: state.update && state.update.updated_from }, autoMem); + if (reason === 'arrival') autoMem.used = true; + if (d.prompt) autoTimer = setTimeout(function () { if (state && state.phase === 'unlock' && !document.hidden && !bio.busy && !pwRevealed) unlockWithTouch(); }, (reducedMotion() ? 0 : LockScreen.ENTER_MS) + d.delay); + } + async function unlockWithTouch() { + if (bio.busy) return; + clearTimeout(autoTimer); + $('unlock-touch').disabled = true; setLine($('unlock-bio-note'), '' + FP + esc('Waiting for ' + what()) + ''); + var r = await bio.call('unlock'); + $('unlock-touch').disabled = false; + var l = LockScreen.line(r, what()); + setLine($('unlock-bio-note'), l.text ? '' + FP + esc(l.text) + '' : ''); + if (r.ok) { await poll(); return; } + if (l.password) showPasswordField(); else $('unlock-touch').focus({ preventScroll: true }); + } + $('unlock-use-pw').onclick = showPasswordField; + $('unlock-pw').addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); hidePasswordField(); } }); + $('unlock-touch').onclick = function () { unlockWithTouch(); }; + $('unlock-form').onsubmit = async function (ev) { + ev.preventDefault(); $('unlock-err').textContent = ''; + try { await post('/api/unlock', { password: $('unlock-pw').value }); $('unlock-pw').value = ''; await poll(); } + catch (e) { $('unlock-err').textContent = e.message; } + }; + // the idle lock: the window tells the engine a person is here, every 20 s while there is input + var active = false; + ['mousemove', 'keydown', 'mousedown', 'wheel', 'touchstart'].forEach(function (e) { document.addEventListener(e, function () { active = true; }, { passive: true }); }); + setInterval(function () { if (active && state && state.phase === 'home') { active = false; post('/api/activity').catch(function () {}); } }, 20000); -// ---- unlock / lock ---- -$('unlock-form').onsubmit = async ev => { - ev.preventDefault(); $('unlock-err').textContent = ''; - try { await post('/api/unlock', { password: $('unlock-pw').value }); $('unlock-pw').value = ''; await poll(); } - catch (e) { $('unlock-err').textContent = e.message; } -}; -$('btn-lock').onclick = async () => { await post('/api/lock'); await poll(); }; -$('btn-settings').onclick = () => setView('settings'); -$('settings-back').onclick = () => setView('overview'); -$('copy-address').onclick = () => copy(state.display, 'address'); -$('backup-link').onclick = ev => { ev.preventDefault(); setView('settings'); $('backup-pw').focus(); }; + // ---------- appearance: system, light or dark (the page's own storage) ---------- + function applyTheme(t, keep) { + t = t === 'light' || t === 'dark' ? t : 'system'; + if (t === 'system') document.documentElement.removeAttribute('data-theme'); else document.documentElement.setAttribute('data-theme', t); + document.querySelectorAll('#theme-seg .seg-b').forEach(function (b) { var on = b.dataset.theme === t; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); }); + if (keep) store('igneum.wallet.theme', t === 'system' ? null : t); + } + // ?theme=light|dark shows a scheme without storing it (screenshots); otherwise the page's own stored choice + applyTheme(params.get('theme') || stored('igneum.wallet.theme') || 'system', false); + $('theme-seg').addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) applyTheme(b.dataset.theme, true); }); -// ---- send ---- -$('go-send').onclick = () => { setView('send'); $('send-form').hidden = false; $('send-confirm').hidden = true; $('send-done').hidden = true; $('send-err').textContent = ''; $('send-to').focus(); }; -$('send-cancel').onclick = () => setView('overview'); -$('send-edit').onclick = () => { $('send-form').hidden = false; $('send-confirm').hidden = true; }; -$('send-quote').onclick = async () => { - $('send-err').textContent = ''; - try { - quote = await post('/api/send/quote', { to: $('send-to').value, amount: $('send-amount').value }); - $('c-amount').textContent = `${quote.value_ign} IGN`; - $('c-to').textContent = quote.display_to; - $('c-fee').textContent = `${quote.fee_max_ign} IGN`; - $('c-total').textContent = `${quote.total_max_ign} IGN`; - $('c-fee-note').textContent = `Fee = gas × price. Gas ${quote.gas.toLocaleString()}. Price = base fee ${quote.base_fee_gwei} gwei (burned by the network) + tip ${quote.tip_gwei} gwei (to the miner), capped at ${ign(quote.max_fee, 0) === '0' ? (Number(quote.max_fee) / 1e9).toFixed(3) + ' gwei' : ign(quote.max_fee) + ' IGN'} per gas; what is not used comes back.`; - const needs = !!quote.confirm_needed && !!bio.host; - $('send-go-text').textContent = needs ? 'Confirm with ' + what() : 'Send now'; - $('send-go').querySelector('.fp-ico').hidden = !needs; - $('send-form').hidden = true; $('send-confirm').hidden = false; $('confirm-send-err').textContent = ''; - if (quote.confirm_needed && !bio.host) $('confirm-send-err').textContent = what() + ' is on for this wallet, and only the Igneum Wallet app window can show it.'; - } catch (e) { $('send-err').textContent = e.message; } -}; -$('send-go').onclick = async () => { - $('confirm-send-err').textContent = ''; $('send-go').disabled = true; - try { - const { ok, value_ign, fee_max_ign, total_max_ign, base_fee_gwei, tip_gwei, display_to, confirm_nonce, confirm_reason, confirm_needed, ...q } = quote; - if (confirm_needed) { - // the prompt shows the engine's own line (amount and address); the host confirms the nonce to the engine - setLine($('send-bio-line'), `${FP}${esc('Waiting for ' + what())}`); - const c = await bio.call('confirm', { nonce: confirm_nonce }); - setLine($('send-bio-line'), bioLine(c, 'confirmed, sending')); - if (!c.ok) { $('send-go').disabled = false; return; } + // ---------- phases (welcome, create, import, unlock, home) and the pages behind the rail ---------- + function setPhase(p) { + document.body.dataset.phase = p; + var home = p === 'home'; + document.body.classList.toggle('has-rail', home); + $('rail').hidden = !home; + $('top-brand').hidden = home; + $('page-title').hidden = !home; + $('btn-lock-top').hidden = !home; + if (home) showPage(page); + } + function showPage(id) { + var meta = View.page(id); + page = meta.id; + document.body.dataset.page = page; + document.querySelectorAll('.page').forEach(function (el) { el.hidden = el.dataset.page !== page; }); + document.querySelectorAll('#rail-nav .nav').forEach(function (b) { + var on = b.dataset.page === page; + b.classList.toggle('on', on); + if (on) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); + }); + setText('page-title-text', meta.title); setText('page-sub', meta.sub); + $('main').scrollTop = 0; + if (page === 'receive') loadReceive(); + if (page === 'send' && !$('send-form').dataset.locked && $('send-done').hidden) $('send-to').focus({ preventScroll: true }); + if (state) renderPage(state); + } + $('rail-nav').addEventListener('click', function (e) { var b = e.target.closest('.nav[data-page]'); if (b) showPage(b.dataset.page); }); + $('rail-nav').addEventListener('keydown', function (e) { + var items = Array.prototype.slice.call(document.querySelectorAll('#rail-nav .nav')), i = items.indexOf(document.activeElement); + if (i < 0) return; + var j = e.key === 'ArrowDown' || e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : -1; + if (j < 0 || j >= items.length) return; + e.preventDefault(); items[j].focus(); showPage(items[j].dataset.page); + }); + function lockNow() { post('/api/lock').then(poll).catch(function (e) { toast(e.message); }); } + $('btn-lock').onclick = lockNow; + $('btn-lock-top').onclick = lockNow; + // quit: a strip in place of a dialog + $('btn-quit').onclick = function () { $('ask-quit').hidden = false; $('ask-quit-yes').focus(); }; + $('ask-quit-no').onclick = function () { $('ask-quit').hidden = true; }; + $('ask-quit-yes').onclick = function () { $('ask-quit').hidden = true; post('/api/quit').catch(function () {}); toast('Quitting'); }; + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') return; + if (!$('upd').hidden) { e.preventDefault(); cardLater(); return; } + if (!$('ask-quit').hidden) { $('ask-quit').hidden = true; return; } + if (!$('ask-send').hidden) { cancelAsk(); return; } + if (!$('ask-remove').hidden) { $('ask-remove').hidden = true; } + }); + // disclosures: aria-expanded flips, the target shows + document.addEventListener('click', function (e) { + var b = e.target.closest('.disclose[aria-controls]'); if (!b) return; + var open = b.getAttribute('aria-expanded') !== 'true'; b.setAttribute('aria-expanded', open ? 'true' : 'false'); + var t = $(b.getAttribute('aria-controls')); if (t) t.hidden = !open; + if (!open && b.id === 'backup-toggle') resetBackup(); + if (!open && b.id === 'export-toggle') resetExport(); + }); + + // ---------- state ---------- + async function poll() { + try { state = await api('/api/state'); render(); } catch (e) { setText('pill-text', 'engine gone'); $('pill').className = 'pill warn'; } + } + function render() { + var s = state, phase = s.phase; + var inFlow = ['create', 'import'].indexOf(document.body.dataset.phase) >= 0; + if (firstPhase === null) firstPhase = phase; + if ((!inFlow || phase === 'home') && lastPhase !== phase && !locking) { + if (lastPhase === 'home' && phase === 'unlock') lockTransition(); + else { setPhase(phase); if (phase === 'unlock') onLockScreen(lastPhase === null ? 'arrival' : 'lock'); } + } + lastPhase = phase; + if (phase === 'home' && firstHome) { firstHome = false; if (params.get('page')) showPage(params.get('page')); } + var pw = View.pillWords(s); + setText('pill-text', pw.text); $('pill').className = 'pill ' + pw.cls; + setText('welcome-eyebrow', (s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network) + ' · nothing is bought or sold'); + if (forcedUpdate) s.update = sampleUpdate(forcedUpdate); + renderNotices(s); + renderUpdateCard(s); + if (phase === 'unlock' || locking) renderLock(); + $('seg-miner').disabled = !s.miner_wallet_present; + if (phase === 'home') { + $('rail-status').innerHTML = '' + esc(View.shortHex(s.display, 6, 4)) + '
    ' + esc(s.settings.network === 'devnet' ? 'devnet v4' : s.settings.network); + setText('foot-version', 'v' + s.version); + $('nav-updates-dot').hidden = !(s.update && (s.update.status === 'ready' || s.update.status === 'deferred' || s.update.status === 'manual')); + renderPage(s); } - const r = await post('/api/send', { quote: q, nonce: confirm_nonce }); - sentHash = r.hash; $('sent-hash').textContent = r.hash; - $('send-confirm').hidden = true; $('send-done').hidden = false; $('send-to').value = ''; $('send-amount').value = ''; - await poll(); - } catch (e) { $('confirm-send-err').textContent = e.message; } - $('send-go').disabled = false; -}; -$('sent-home').onclick = () => setView('overview'); -$('sent-view').onclick = () => openTx(sentHash); - -// ---- receive ---- -$('go-receive').onclick = async () => { - try { const r = await post('/api/receive'); $('qr').innerHTML = r.svg; $('receive-address').textContent = r.display; setView('receive'); } - catch (e) { toast(e.message); } -}; -$('receive-copy').onclick = () => copy(state.display, 'address'); -$('receive-back').onclick = () => setView('overview'); - -// ---- transaction detail ---- -async function openTx(hash) { txHash = hash; setView('tx'); await renderTx(); } -async function renderTx() { - const e = (state.history || []).find(x => x.hash.toLowerCase() === txHash.toLowerCase()); - if (!e) return; - const incoming = ['received', 'reward', 'proving'].includes(e.kind); - $('tx-title').textContent = e.kind === 'reward' ? 'Block reward' : e.kind === 'proving' ? 'Shard payout' : e.kind === 'sent' ? 'Sent' : e.kind === 'self' ? 'To yourself' : 'Received'; - const fl = $('tx-finality'); fl.className = 'finality-line ' + e.finality; - fl.textContent = e.finality === 'final' ? `FINAL · under checkpoint ${e.checkpoint}, certificate verified by this wallet` : e.finality === 'in_block' ? `IN A BLOCK · chain block ${e.block.toLocaleString()}; final once a verified checkpoint covers it` : e.finality === 'failed' ? 'FAILED · the execution failed; the fee was still paid' : 'PENDING · waiting for a block'; - const rows = [['amount', `${incoming ? '+' : '−'}${ign(e.value, 18)} IGN`], ['when', when(e.time)]]; - if (e.kind !== 'reward' && e.kind !== 'proving') rows.push(['from', esc(e.from)], ['to', esc(e.to)], ['fee paid', e.fee ? `${ign(e.fee, 9)} IGN` : 'pending'], ['hash', esc(e.hash)]); - rows.push(['block', e.block ? `${e.block.toLocaleString()} · ${short(e.block_hash)}` : 'none yet']); - if (e.note) rows.push(['note', esc(e.note)]); - const f = state.finality; - rows.push(['verified checkpoint', f.verified_index ? `${f.verified_index} at chain height ${f.chain_number == null ? '…' : f.chain_number.toLocaleString()}` : 'none yet']); - kv($('tx-kv'), rows); -} -$('tx-back').onclick = () => setView('overview'); - -// ---- settings ---- -$('backup-show').onclick = async () => { - $('backup-err').textContent = ''; - try { - const r = await post('/api/reveal', { password: $('backup-pw').value }); $('backup-pw').value = ''; - $('backup-words').innerHTML = (r.words || []).map(w => `
  • ${esc(w)}
  • `).join(''); - $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; - if (!state.backed_up) await post('/api/backed-up'); - } catch (e) { $('backup-err').textContent = e.message; } -}; -$('backup-hide').onclick = () => { $('backup-out').hidden = true; $('backup-words').innerHTML = ''; $('backup-key').textContent = ''; }; -// the backup and the export after a confirmation: the words come from the unlocked key, no password typed -async function revealWithTouch(errEl) { - errEl.textContent = ''; - const c = await post('/api/biometric/challenge', { purpose: 'reveal' }); - setLine(errEl, `${FP}${esc('Waiting for ' + what())}`); - const h = await bio.call('confirm', { nonce: c.nonce }); - setLine(errEl, bioLine(h, 'confirmed')); - if (!h.ok) return null; - return post('/api/reveal', { nonce: c.nonce }); -} -$('backup-touch').onclick = async () => { - try { - const r = await revealWithTouch($('backup-err')); - if (!r) return; - $('backup-words').innerHTML = (r.words || []).map(w => `
  • ${esc(w)}
  • `).join(''); - $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; - if (!state.backed_up) await post('/api/backed-up'); - } catch (e) { $('backup-err').textContent = e.message; } -}; -$('export-touch').onclick = async () => { - try { const r = await revealWithTouch($('export-err')); if (!r) return; $('export-key').textContent = r.private_key; $('export-out').hidden = false; } - catch (e) { $('export-err').textContent = e.message; } -}; -// ---- Touch ID / Windows Hello in Settings: enrol (password once, then the prompt), the idle lock, turn off ---- -function versionLine(s) { - const u = s.update || {}, v = 'Igneum Wallet ' + s.version; - let tail; - switch (u.status) { - case 'current': tail = 'up to date'; break; - case 'available': case 'downloading': tail = u.version + ' downloading'; break; - case 'staging': case 'ready': case 'deferred': case 'manual': tail = u.version + ' downloaded'; break; - case 'applying': tail = 'installing ' + u.version; break; - case 'checking': tail = 'checking for updates'; break; - case 'off': tail = 'updates off in this build'; break; - case 'error': tail = 'update check failed'; break; - default: tail = 'not checked yet'; } - return `${esc(v)} · ${esc(tail)}`; -} -function renderBio(s) { - const b = s.biometric || {}, w = what(); - $('version-row').innerHTML = versionLine(s); - $('bio-title').textContent = w; - $('bio-enrol-text').textContent = 'Turn on ' + w; - $('ask-on-open-text').textContent = `Ask for ${w} when the wallet opens`; - const idleMin = s.settings && s.settings.idle_lock_min != null ? s.settings.idle_lock_min : (b.idle_lock ? (b.idle_lock_min || 5) : 0); - if (document.activeElement !== $('idle-lock')) $('idle-lock').value = String(LockScreen.IDLE_CHOICES.includes(Number(idleMin)) ? idleMin : LockScreen.IDLE_DEFAULT_MIN); - $('idle-lock-note').textContent = Number(idleMin) > 0 ? `The key is cleared after ${LockScreen.idleLabel(idleMin)} without you; ${w} or the password opens it again.` : 'The wallet stays open until you lock it.'; - if (document.activeElement !== $('ask-on-open')) $('ask-on-open').checked = !!(s.settings && s.settings.ask_on_open); - $('backup-touch').hidden = !(b.enrolled && bio.host); $('backup-touch').querySelector('span').textContent = 'Show with ' + w; - $('export-touch').hidden = !(b.enrolled && bio.host); $('export-touch').querySelector('span').textContent = 'Show with ' + w; - $('backup-note-text').textContent = b.enrolled ? `Show the 24 words (or the key) again, with ${w} or the password.` : 'Show the 24 words (or the key) again. Needs the password.'; - $('bio-on').hidden = !b.enrolled; $('bio-off').hidden = b.enrolled; - let off = ''; - if (!bio.host) off = w + ' needs the Igneum Wallet app window; this page is open in a browser.'; - else if (!b.available) off = b.message || (w === 'Touch ID' ? 'Touch ID is not set up on this Mac.' : 'Windows Hello is not set up on this PC.'); - $('bio-off-note').textContent = off; - $('bio-enrol').disabled = !!off; $('bio-pw').disabled = !!off; - $('bio-on-note').textContent = b.needs_enrol ? '' : (b.last_result && b.last_result !== 'ok' && b.last_op ? `last ${b.last_op}: ${b.last_result}` : ''); - if (b.needs_enrol && !$('bio-err').textContent) $('bio-err').textContent = `The password changed, so ${w} was turned off. Turn it on again here.`; -} -$('bio-enrol').onclick = async () => { - $('bio-err').textContent = ''; - const pw = $('bio-pw').value; - if (!pw) return $('bio-err').textContent = 'type the password first'; - $('bio-enrol').disabled = true; - try { - const r = await post('/api/biometric/enrol/begin', { password: pw }); - setLine($('bio-err'), `${FP}${esc('Waiting for ' + what())}`); - const h = await bio.call('enrol', { token: r.token }); - setLine($('bio-err'), bioLine(h, 'is on')); - if (!h.ok) { post('/api/biometric/enrol/cancel').catch(() => {}); $('bio-enrol').disabled = false; return; } - $('bio-pw').value = ''; toast(what() + ' is on'); await poll(); - } catch (e) { $('bio-err').textContent = e.message; } - $('bio-enrol').disabled = false; -}; -$('bio-remove').onclick = async () => { try { await post('/api/biometric/remove'); $('bio-err').textContent = ''; toast(what() + ' is off'); await poll(); } catch (e) { $('bio-err').textContent = e.message; } }; -$('idle-lock').onchange = async ev => { try { await post('/api/settings', { idle_lock_min: Number(ev.target.value) }); await poll(); } catch (e) { toast(e.message); } }; -$('ask-on-open').onchange = async ev => { try { await post('/api/settings', { ask_on_open: ev.target.checked }); } catch (e) { toast(e.message); } }; -$('unlock-touch').onclick = () => unlockWithTouch(); -$('pw-change').onclick = async () => { - $('pw-err').textContent = ''; - try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('password changed'); } - catch (e) { $('pw-err').textContent = e.message; } -}; -async function network() { return api('/api/network'); } -async function renderNetwork() { - try { - const n = await network(); - kv($('net-kv'), [['network name', esc(n.chain_name)], ['chain id', `${n.chain_id} (${n.chain_id_hex})`], ['rpc url', esc(n.rpc_url || 'none yet')], ['symbol', 'IGN'], ['decimals', '18']]); - $('net-add').disabled = !n.add_network_page; - $('net-add').title = n.add_network_page ? '' : 'the site page is not set in this build; copy the settings instead'; - } catch (e) { kv($('net-kv'), [['network', esc(e.message)]]); } -} -$('net-add').onclick = async () => { const n = await network(); if (n.add_network_page) post('/api/open', { url: n.add_network_page }); }; -$('net-copy').onclick = async () => { const n = await network(); copy(`Network name: ${n.chain_name}\nRPC URL: ${n.rpc_url}\nChain ID: ${n.chain_id}\nCurrency symbol: IGN\nDecimals: 18`, 'network settings'); }; -$('export-show').onclick = async () => { - $('export-err').textContent = ''; - try { const r = await post('/api/reveal', { password: $('export-pw').value }); $('export-pw').value = ''; $('export-key').textContent = r.private_key; $('export-out').hidden = false; } - catch (e) { $('export-err').textContent = e.message; } -}; -$('export-copy').onclick = () => copy($('export-key').textContent, 'private key'); -$('export-hide').onclick = () => { $('export-out').hidden = true; $('export-key').textContent = ''; }; -$('start-login').onchange = async ev => { try { await post('/api/settings', { start_at_login: ev.target.checked }); } catch (e) { toast(e.message); } }; + function renderPage(s) { + if (page === 'home') renderHome(s); + else if (page === 'send') renderSend(s); + else if (page === 'receive') renderReceive(s); + else if (page === 'history') renderRows($('history'), s.history, 0, s); + else if (page === 'settings') renderSettings(s); + refreshNodeWords(s); + } -// ---- over-the-air updates (src/updater.rs): one banner, the settings line ---- -function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; } -function updateLine(u) { - const v = 'Igneum Wallet ' + u.version; - if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' }; - switch (u.status) { - case 'available': return { text: v + ' is available. Downloading it.' }; - case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true }; - case 'staging': return { text: v + ' downloaded and verified. Preparing it.' }; - case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs as soon as nothing is being sent.'), install: true }; - case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the app closes and opens again by itself.') }; - case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC.', install: true }; - case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true }; - case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) }; - default: return null; + // ---------- Home ---------- + function renderHome(s) { + var bl = View.balanceLine(s); + $('h-bal').hidden = !bl.known; + setText('h-balance', bl.known ? s.balance : ''); + setText('h-balance-line', bl.text); + var ml = View.moneyLine(s); + $('h-money').innerHTML = (ml.money ? '' + esc(ml.money) + '' : '') + esc(ml.text); + setText('home-address', s.display || 'not set'); + $('backup-note').hidden = !!s.backed_up; + var nl = View.nodeLine(s); + setText('node-line-text', nl.text); setText('node-sub', nl.sub); + $('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : ''); + $('node-card').classList.toggle('bad', nl.tone === 'bad'); + renderKv($('node-kv'), View.nodeDetails(s, s.now)); + renderRows($('recent'), s.history, 5, s); + setText('hist-eyebrow', ''); } -} -function updateNote(u) { - const l = updateLine(u), parts = []; - if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.'); - if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.'); - if (u.status === 'off') parts.push('Updates are off in this build.'); - else if (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text); - else if (u.status === 'checking') parts.push('Checking.'); - else if (u.status === 'current') parts.push('This is the latest version.'); - else if (u.error) parts.push(u.error); - else parts.push('Not checked yet.'); - return parts.join(' '); -} -function renderUpdate(s) { - const u = s.update, b = $('update-banner'), l = updateLine(u); - const key = u.status + ':' + u.version; - const show = !!l && (u.urgent || u.applying || sessionStorage.getItem('update-later') !== key); - if (show) { - $('update-text').textContent = l.text; - b.classList.toggle('urgent', !!l.urgent); - $('update-install').hidden = !l.install || u.applying; - $('update-open').hidden = !l.open; - $('update-later').hidden = !!l.urgent || !!u.applying; - $('update-prog').hidden = !l.prog; - $('update-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%'; + function renderKv(el, rows) { + el.innerHTML = rows.map(function (r) { return '
    ' + esc(r[0]) + '' + esc(r[1]) + '' + esc(r[2] || '') + '
    '; }).join(''); } - b.hidden = !show; - $('update-note').textContent = updateNote(u); - $('update-install-s').hidden = !(l && l.install) || u.applying; - if (document.activeElement !== $('auto-update')) $('auto-update').checked = !!s.settings.auto_update; -} -$('update-check').onclick = () => { post('/api/update/check'); toast('checking for updates'); }; -$('update-open').onclick = () => post('/api/update/open'); -$('update-install').onclick = () => { post('/api/update/install'); toast('installing now'); }; -$('update-install-s').onclick = () => { post('/api/update/install'); toast('installing now'); }; -$('update-later').onclick = () => { sessionStorage.setItem('update-later', state.update.status + ':' + state.update.version); $('update-banner').hidden = true; }; + $('go-send').onclick = function () { resetSend(); showPage('send'); }; + $('go-receive').onclick = function () { showPage('receive'); }; + $('recent-all').onclick = function () { showPage('history'); }; + $('backup-link').onclick = function (ev) { ev.preventDefault(); showPage('settings'); var t = $('backup-toggle'); if (t.getAttribute('aria-expanded') !== 'true') t.click(); t.scrollIntoView({ block: 'center' }); }; -// ---- the update card (update-card.js): one update, centred, over every screen; the banner above stays the small strip ---- -// mem: open (the card is up), later (the key Later was pressed on), seen (the version it was shown for); -// asked: Install now was pressed on this card and the engine has not flipped to applying yet -const updMem = { open: false, later: '', seen: '' }; -let updSig = '', updAsked = '', updMore = false; -// ?update=[&auto=0][&card=1]: the card and the banner for each state, without an engine (screenshots) -const forcedUpdate = new URLSearchParams(location.search).get('update'); -if (forcedUpdate && new URLSearchParams(location.search).get('card') === '1') updMem.open = true; -function sampleUpdate(kind) { - const u = { status: 'ready', version: '0.1.3', url: '', 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.', file: '', error: '', checked_at: Date.now() / 1000 - 40, available: true, downloaded: true, ready: true, applying: false, progress: 1, size: 19479807, auto: new URLSearchParams(location.search).get('auto') !== '0', wait: 'installs as soon as nothing is being sent', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '' }; - if (!u.auto) u.wait = 'waiting for Install now (automatic updates are off)'; - if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; } - if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = Math.min(0.97, 0.43 + ((Date.now() / 1000) % 60) / 110); } - if (kind === 'staging') { u.status = 'staging'; u.ready = false; } - if (kind === 'applying') { u.status = 'applying'; u.applying = true; } - if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission)'; } - if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.urgent_text = 'Igneum Wallet 0.1.2 is no longer supported by the network. Installing 0.1.3 now.'; } - if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; } - if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; } - return u; -} -function cardCtx(s) { - const bioLine = [...document.querySelectorAll('.bio-state')].some(e => e.offsetParent !== null); - return { phase: document.body.dataset.phase, view: document.body.dataset.view, bioBusy: bio.busy, bioLine, bioName: what(), quitting: !!s.quitting }; -} -function renderUpdateCard(s) { - const m = UpdateCard.model(s.update, s), d = UpdateCard.decide(m, cardCtx(s), updMem), wrap = $('upd'); - if (!d.show) { - if (updMem.open || !wrap.hidden) { updMem.open = false; wrap.hidden = true; updSig = ''; } - return; + // ---------- the history rows (Home shows five, History every one) ---------- + var nodeWord = {}, openRows = {}, fetching = false; + function renderRows(el, list, limit, s) { + list = list || []; + if (limit) list = list.slice(0, limit); + if (!list.length) { el.innerHTML = '
    ' + esc(s.scanning ? 'Reading the chain.' : 'Nothing yet. Receive IGN, or point the miner app at this address.') + '
    '; return; } + el.innerHTML = list.map(function (e) { + var nw = nodeWord[e.hash.toLowerCase()], m = View.rowModel(e, nw && nw.word, s.now), open = !!openRows[e.hash.toLowerCase()]; + return '
    ' + + '
    ' + + '' + esc(m.kind) + '' + esc(m.who) + (m.when ? ' · ' + esc(m.when) : '') + '' + + '' + esc(m.amount) + 'IGN' + + '' + esc(m.state.word) + '' + esc(m.state.why) + '' + + '
    ' + + (open ? rowDetails(e, m) : '') + '
    '; + }).join(''); } - if (!updMem.open) { updMem.open = true; updMem.seen = m.version; updMore = false; if (updAsked !== m.key) updAsked = ''; } - const asked = updAsked === m.key && m.stage !== 'installing' && m.stage !== 'failed'; - const sig = [m.key, m.stage, m.line, m.note, m.cause, m.ring, asked ? 'asked' : '', m.lines.join('|')].join('\u0001'); - if (sig !== updSig) { - updSig = sig; - $('upd-name').textContent = m.name; - $('upd-line').textContent = m.line; - $('upd-cause').textContent = m.cause; $('upd-cause').hidden = !m.cause; - $('upd-notes').innerHTML = m.lines.map(l => `
  • ${esc(l)}
  • `).join(''); - $('upd-all').innerHTML = m.all.map(l => `
  • ${esc(l)}
  • `).join(''); - $('upd-more').hidden = !m.more; if (!m.more) updMore = false; - $('upd-more').textContent = updMore ? 'Less' : 'What changed'; $('upd-more').setAttribute('aria-expanded', updMore ? 'true' : 'false'); - $('upd-all').hidden = !updMore; - $('upd-mark').className = 'upd-mark ' + m.ring; - const acts = asked ? [{ act: 'install', label: m.stage === 'ready' || m.stage === 'deferred' ? 'Installing' : 'Installs when ready', primary: true, disabled: true }, { act: 'later', label: 'Later' }] : m.actions; - $('upd-actions').innerHTML = acts.map(a => ``).join(''); - wrap.dataset.dismiss = m.dismissable ? '1' : ''; + function rowDetails(e, m) { + var rows = []; + if (!m.synthetic) rows.push(['hash', e.hash, 'hash']); + if (e.from) rows.push(['from', e.from, 'address']); + if (e.to) rows.push(['to', e.to, 'address']); + if (!m.synthetic) rows.push(['fee paid', e.fee && e.fee !== '0' ? View.ign(e.fee, 9) + ' IGN' : e.block ? 'none' : 'pending', '']); + rows.push(['block', e.block ? View.withCommas(e.block) + (e.block_hash ? ' · ' + View.shortHex(e.block_hash, 6, 4) : '') : 'none yet', '']); + if (e.checkpoint) rows.push(['checkpoint', View.withCommas(e.checkpoint), 'verified by this wallet']); + if (e.note) rows.push(['note', e.note, '']); + rows.push(['the node says', m.nodeWord || (m.synthetic ? 'a block reward has no transaction' : 'not asked yet'), 'igneum_getTransactionStatus, one word']); + return '
    ' + rows.map(function (r) { + var copy = r[2] === 'hash' || r[2] === 'address' ? ' ' : ''; + return '
    ' + esc(r[0]) + '' + esc(r[1]) + copy + '' + (copy ? '' : '' + esc(r[2] || '') + '') + '
    '; + }).join('') + '
    '; } - // the ring and the percent move every poll; the words above do not - if (m.ring === 'progress') { $('upd-arc').style.strokeDashoffset = (276.5 * (1 - m.pct / 100)).toFixed(1); $('upd-pct').textContent = m.pct + '%'; $('upd-pct').hidden = m.stage !== 'downloading'; } - else { $('upd-arc').style.strokeDashoffset = ''; $('upd-pct').hidden = true; } - const meta = []; - if (m.stage === 'downloading' && m.size) meta.push(m.pct + '% of ' + m.size); - else if (m.size && m.stage === 'available') meta.push(m.size + ' download'); - else if (m.size && (m.stage === 'ready' || m.stage === 'manual' || m.stage === 'deferred' || m.stage === 'staging')) meta.push(m.size + ', downloaded'); - if (m.note) meta.push(m.note); - $('upd-meta').textContent = meta.join(' · '); - if (wrap.hidden) { wrap.hidden = false; $('upd-card').focus({ preventScroll: true }); } -} -function cardLater() { - if (!updMem.open || !$('upd').dataset.dismiss) return; - const m = UpdateCard.model(state && state.update, state || {}); - updMem.later = m ? m.key : updMem.later; updMem.open = false; $('upd').hidden = true; updSig = ''; -} -$('upd').addEventListener('click', e => { - if (e.target === $('upd')) { cardLater(); return; } - const b = e.target.closest('[data-act]'); if (!b || b.disabled) return; - const act = b.dataset.act, m = UpdateCard.model(state && state.update, state || {}); - if (act === 'later') cardLater(); - else if (act === 'install' || act === 'retry') { post('/api/update/install').catch(() => {}); updAsked = m ? m.key : ''; updSig = ''; toast(act === 'retry' ? 'trying the update again' : m && m.stage === 'ready' ? 'installing now' : 'installs as soon as it is downloaded'); if (state) renderUpdateCard(state); } - else if (act === 'open') post('/api/update/open').catch(() => {}); -}); -$('upd-more').onclick = function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); }; -document.addEventListener('keydown', e => { if (e.key === 'Escape' && !$('upd').hidden) { e.preventDefault(); cardLater(); } }); -$('auto-update').onchange = async ev => { try { await post('/api/update/auto', { on: ev.target.checked }); } catch (e) { toast(e.message); } }; -$('remove-go').onclick = async () => { - $('remove-err').textContent = ''; - if (!confirm('Remove this wallet from this machine? Only your 24 words or the key bring it back.')) return; - try { await post('/api/remove', { password: $('remove-pw').value }); $('remove-pw').value = ''; await poll(); } - catch (e) { $('remove-err').textContent = e.message; } -}; -document.addEventListener('visibilitychange', () => { - if (document.hidden) return; + document.addEventListener('click', function (e) { + if (e.target.closest('[data-copy-text]')) return; + var main = e.target.closest('.hrow:not(.sent-row) .hr-main'); if (!main) return; + var h = main.parentNode.dataset.hash.toLowerCase(); + openRows[h] = !openRows[h]; + if (state) renderPage(state); + }); + // the node's one word per transaction, from GET /api/tx/ (node_status.state), for the rows that are not final + async function refreshNodeWords(s) { + if (fetching) return; + var want = View.needsNodeWord(s.history, 12), now = Date.now() / 1000; + var due = want.filter(function (h) { var w = nodeWord[h.toLowerCase()]; return !w || now - w.at > (w.word ? 4 : 30); }); + if (!due.length) return; + fetching = true; + try { + for (var i = 0; i < due.length; i++) { + var h = due[i].toLowerCase(), word = ''; + try { var r = await api('/api/tx/' + due[i]); word = (r.node_status && r.node_status.state) || ''; } catch (e) { word = ''; } + nodeWord[h] = { word: word, at: Date.now() / 1000 }; + } + } finally { fetching = false; } + if (state) renderRowsOnly(state); + } + function renderRowsOnly(s) { + if (page === 'home') renderRows($('recent'), s.history, 5, s); + else if (page === 'history') renderRows($('history'), s.history, 0, s); + else if (page === 'send') renderSentRow(s); + } + + // ---------- Send: the form, the question in place, the pending row ---------- + function renderSend(s) { + var bl = View.balanceLine(s); + setText('send-balance', bl.known ? 'of ' + s.balance + ' IGN' : ''); + setText('send-eyebrow', s.node && s.node.state === 'ok' ? '' : 'needs a node'); + renderSentRow(s); + } + function resetSend() { + quote = null; sentHash = null; + $('send-form').hidden = false; $('send-form').dataset.locked = ''; + $('send-to').value = ''; $('send-amount').value = ''; + $('send-err').textContent = ''; $('confirm-send-err').textContent = ''; setLine($('send-bio-line'), ''); + setText('fee-line', View.feeWords(null).line); $('fee-toggle').hidden = true; $('fee-details').hidden = true; $('fee-toggle').setAttribute('aria-expanded', 'false'); + $('send-cta').hidden = false; $('ask-send').hidden = true; $('send-done').hidden = true; + } + function cancelAsk() { $('ask-send').hidden = true; $('send-cta').hidden = false; $('send-form').dataset.locked = ''; quote = null; $('send-to').focus({ preventScroll: true }); } + $('ask-send-no').onclick = cancelAsk; + $('send-quote').onclick = async function () { + $('send-err').textContent = ''; + try { + quote = await post('/api/send/quote', { to: $('send-to').value.trim(), amount: $('send-amount').value.trim() }); + var fw = View.feeWords(quote); + setText('fee-line', fw.line); setText('fee-details', fw.detail); $('fee-toggle').hidden = false; + var needs = !!quote.confirm_needed && !!bio.host; + var ask = View.sendAsk(quote, needs, what()); + setText('ask-send-text', ask.text); setText('send-go-text', ask.button); + $('send-go').querySelector('.fp-ico').hidden = !needs; + $('confirm-send-err').textContent = quote.confirm_needed && !bio.host ? what() + ' is on for this wallet, and only the Igneum Wallet app window can show it.' : ''; + setLine($('send-bio-line'), ''); + $('send-form').dataset.locked = '1'; $('send-cta').hidden = true; $('ask-send').hidden = false; $('send-go').disabled = false; + $('send-go').focus({ preventScroll: true }); + } catch (e) { $('send-err').textContent = e.message; } + }; + $('fee-toggle').onclick = function () { var open = this.getAttribute('aria-expanded') !== 'true'; this.setAttribute('aria-expanded', open ? 'true' : 'false'); $('fee-details').hidden = !open; this.textContent = open ? 'Less' : 'Details'; }; + ['send-to', 'send-amount'].forEach(function (id) { $(id).addEventListener('keydown', function (e) { if (e.key === 'Enter' && !$('send-form').dataset.locked) { e.preventDefault(); $('send-quote').click(); } }); }); + $('send-go').onclick = async function () { + if (!quote) return; + $('confirm-send-err').textContent = ''; $('send-go').disabled = true; + try { + var q = Object.assign({}, quote); + ['ok', 'value_ign', 'fee_max_ign', 'total_max_ign', 'base_fee_gwei', 'tip_gwei', 'display_to', 'confirm_nonce', 'confirm_reason', 'confirm_needed'].forEach(function (k) { delete q[k]; }); + if (quote.confirm_needed) { + setLine($('send-bio-line'), '' + FP + esc('Waiting for ' + what()) + ''); + var c = await bio.call('confirm', { nonce: quote.confirm_nonce }); + setLine($('send-bio-line'), bioLine(c, 'confirmed, sending')); + if (!c.ok) { $('send-go').disabled = false; return; } + } + var r = await post('/api/send', { quote: q, nonce: quote.confirm_nonce }); + sentHash = r.hash; setText('sent-hash', r.hash); + $('ask-send').hidden = true; $('send-form').hidden = true; $('send-done').hidden = false; + $('send-to').value = ''; $('send-amount').value = ''; $('send-form').dataset.locked = ''; quote = null; + await poll(); + } catch (e) { $('confirm-send-err').textContent = e.message; } + $('send-go').disabled = false; + }; + function renderSentRow(s) { + if (!sentHash || $('send-done').hidden) return; + var e = (s.history || []).filter(function (x) { return x.hash.toLowerCase() === sentHash.toLowerCase(); })[0]; + var el = $('sent-row'); + if (!e) { el.innerHTML = '
    Senthanded to the nodependingwaiting for a block
    '; return; } + var nw = nodeWord[e.hash.toLowerCase()], m = View.rowModel(e, nw && nw.word, s.now); + el.className = 'hrow sent-row' + (m.in ? ' in' : ''); + el.innerHTML = '
    ' + esc(m.kind) + '' + esc(m.who) + (m.when ? ' · ' + esc(m.when) : '') + '' + esc(m.amount) + 'IGN' + esc(m.state.word) + '' + esc(m.state.why) + '
    '; + } + $('sent-view').onclick = function () { if (sentHash) openRows[sentHash.toLowerCase()] = true; showPage('history'); }; + $('sent-again').onclick = function () { resetSend(); $('send-to').focus({ preventScroll: true }); }; + + // ---------- Receive ---------- + var receiveFor = ''; + function renderReceive(s) { setText('receive-address', s.display || 'not set'); } + async function loadReceive() { + if (!state || !state.display || receiveFor === state.display) return; + try { var r = await post('/api/receive'); $('qr').innerHTML = r.svg; receiveFor = state.display; } + catch (e) { toast(e.message); } + } + + // ---------- create ---------- + $('go-create').onclick = function () { setPhase('create'); $('create-1').hidden = false; $('create-2').hidden = true; $('create-3').hidden = true; $('create-pw').focus(); }; + $('create-back').onclick = function () { setPhase('welcome'); }; + var words = []; + $('create-next').onclick = async function () { + $('create-err').textContent = ''; + var a = $('create-pw').value, b = $('create-pw2').value; + if (a.length < 8) { $('create-err').textContent = 'at least 8 characters'; return; } + if (a !== b) { $('create-err').textContent = 'the two passwords differ'; return; } + try { + var r = await post('/api/create', { password: a }); + words = r.words; + $('words').innerHTML = words.map(function (w) { return '
  • ' + esc(w) + '
  • '; }).join(''); + $('create-address').textContent = r.display; + $('create-1').hidden = true; $('create-2').hidden = false; + } catch (e) { $('create-err').textContent = e.message; } + }; + $('create-written').onclick = function () { + var picks = []; while (picks.length < 3) { var p = 1 + Math.floor(Math.random() * 24); if (picks.indexOf(p) < 0) picks.push(p); } + picks.sort(function (a, b) { return a - b; }); + $('checks').innerHTML = picks.map(function (p) { return ''; }).join(''); + $('words').innerHTML = ''; words = []; + $('create-2').hidden = true; $('create-3').hidden = false; + $('checks').querySelector('input').focus(); + }; + $('create-again').onclick = function () { $('create-3').hidden = true; $('create-1').hidden = false; $('confirm-err').textContent = ''; $('create-err').textContent = 'start again: the words are made fresh each time'; }; + $('create-confirm').onclick = async function () { + $('confirm-err').textContent = ''; + var checks = Array.prototype.map.call($('checks').querySelectorAll('input'), function (i) { return { position: Number(i.dataset.pos), word: i.value.trim() }; }); + try { await post('/api/create/confirm', { checks: checks }); $('checks').innerHTML = ''; await poll(); setPhase('home'); showPage('home'); toast('Wallet ready'); } + catch (e) { $('confirm-err').textContent = e.message; } + }; + + // ---------- import ---------- + var importMode = 'seed'; + $('go-import').onclick = function () { setPhase('import'); }; + $('import-back').onclick = function () { setPhase('welcome'); }; + $('import-mode').addEventListener('click', function (e) { + var b = e.target.closest('.seg-b'); if (!b || b.disabled) return; + importMode = b.dataset.mode; + document.querySelectorAll('#import-mode .seg-b').forEach(function (x) { var on = x === b; x.classList.toggle('on', on); x.setAttribute('aria-checked', on ? 'true' : 'false'); }); + $('import-data-field').hidden = importMode === 'miner'; + $('import-miner-note').hidden = importMode !== 'miner'; + setText('import-data-label', importMode === 'seed' ? 'The words, in order' : 'The private key, 64 hex characters'); + }); + $('import-go').onclick = async function () { + $('import-err').textContent = ''; + var a = $('import-pw').value, b = $('import-pw2').value; + if (a.length < 8) { $('import-err').textContent = 'at least 8 characters'; return; } + if (a !== b) { $('import-err').textContent = 'the two passwords differ'; return; } + try { await post('/api/import', { mode: importMode, data: $('import-data').value, password: a }); $('import-data').value = ''; await poll(); setPhase('home'); showPage('home'); toast('Imported'); } + catch (e) { $('import-err').textContent = e.message; } + }; + + // ---------- Settings ---------- + function renderSettings(s) { + var b = s.biometric || {}, w = what(), host = hostHere(); + setText('bio-title', w); + setText('bio-sentence', View.bioSentence(b, w, host)); + setText('bio-toggle-btn', b.enrolled ? 'Turn off' : 'Turn on'); + $('bio-toggle-btn').disabled = !b.enrolled && (!host || !b.available); + $('bio-toggle-btn').classList.toggle('danger', !!b.enrolled); + if (b.enrolled) $('bio-enrol').hidden = true; + $('bio-off-note').hidden = true; + setText('bio-enrol-text', 'Turn on ' + w); + $('ask-on-open-row').hidden = !b.enrolled; $('idle-row').hidden = !b.enrolled; + setText('ask-on-open-text', 'Ask for ' + w + ' when the wallet opens'); + var idleMin = s.settings && s.settings.idle_lock_min != null ? s.settings.idle_lock_min : (b.idle_lock ? (b.idle_lock_min || 5) : 0); + if (document.activeElement !== $('idle-lock')) $('idle-lock').value = String(LockScreen.IDLE_CHOICES.indexOf(Number(idleMin)) >= 0 ? idleMin : LockScreen.IDLE_DEFAULT_MIN); + setText('idle-lock-note', View.idleSentence(idleMin, w)); + if (document.activeElement !== $('ask-on-open')) $('ask-on-open').checked = !!(s.settings && s.settings.ask_on_open); + if (b.needs_enrol && !$('bio-err').textContent) $('bio-err').textContent = 'The password changed, so ' + w + ' was turned off. Turn it on again here.'; + var touch = !!(b.enrolled && bio.host); + $('backup-touch').hidden = !touch; $('backup-touch').querySelector('span').textContent = 'Show with ' + w; + $('export-touch').hidden = !touch; $('export-touch').querySelector('span').textContent = 'Show with ' + w; + setText('backup-eyebrow', s.backed_up ? 'written down' : 'not written down yet'); + if (document.activeElement !== $('start-login')) $('start-login').checked = !!s.settings.start_at_login; + // the update card + var u = s.update || {}; + setText('s-version', 'Igneum Wallet ' + s.version); + setText('s-update-note', View.updateNote(u, s.now)); + var ul = View.updateLine(u); + $('s-install').hidden = !(ul && ul.install) || u.applying; + if (document.activeElement !== $('auto-update')) $('auto-update').checked = !!s.settings.auto_update; + // the node + var n = s.node || {}; + setText('node-endpoint', n.evm || 'none yet'); + setText('node-source-line', View.cap(n.message || '')); + setText('node-eyebrow', View.sourceWords(n.source)); + setText('node-ids', 'chain id ' + (n.chain_id || '…') + ' · gRPC ' + (n.grpc || 'none') + ' · public RPC ' + (s.public_rpc || 'none in this build')); + // advanced + setText('s-mid', 'machine id ' + (s.machine_id || '').slice(0, 8)); + setText('s-dirs', 'logs ' + s.log_dir + ' · wallet ' + s.app_dir); + setText('s-miner-file', s.miner_wallet_present ? "The miner app's key file is on this machine; Import can read it." : "No miner key file on this machine."); + } + $('bio-toggle-btn').onclick = async function () { + var b = (state && state.biometric) || {}; + if (b.enrolled) { try { await post('/api/biometric/remove'); $('bio-err').textContent = ''; toast(what() + ' is off'); await poll(); } catch (e) { $('bio-err').textContent = e.message; } return; } + $('bio-enrol').hidden = false; $('bio-pw').focus({ preventScroll: true }); + }; + $('bio-enrol-cancel').onclick = function () { $('bio-enrol').hidden = true; $('bio-pw').value = ''; $('bio-err').textContent = ''; }; + $('bio-enrol-go').onclick = async function () { + $('bio-err').textContent = ''; + var pw = $('bio-pw').value; + if (!pw) { $('bio-err').textContent = 'type the password first'; return; } + $('bio-enrol-go').disabled = true; + try { + var r = await post('/api/biometric/enrol/begin', { password: pw }); + setLine($('bio-err'), '' + FP + esc('Waiting for ' + what()) + ''); + var h = await bio.call('enrol', { token: r.token }); + setLine($('bio-err'), bioLine(h, 'is on')); + if (!h.ok) { post('/api/biometric/enrol/cancel').catch(function () {}); $('bio-enrol-go').disabled = false; return; } + $('bio-pw').value = ''; $('bio-enrol').hidden = true; toast(what() + ' is on'); await poll(); + } catch (e) { $('bio-err').textContent = e.message; } + $('bio-enrol-go').disabled = false; + }; + $('idle-lock').onchange = async function (ev) { try { await post('/api/settings', { idle_lock_min: Number(ev.target.value) }); await poll(); } catch (e) { toast(e.message); } }; + $('ask-on-open').onchange = async function (ev) { try { await post('/api/settings', { ask_on_open: ev.target.checked }); } catch (e) { toast(e.message); } }; + $('pw-change').onclick = async function () { + $('pw-err').textContent = ''; + try { await post('/api/password', { old: $('pw-old').value, new: $('pw-new').value }); $('pw-old').value = ''; $('pw-new').value = ''; toast('Password changed'); await poll(); } + catch (e) { $('pw-err').textContent = e.message; } + }; + // backup and export: the ask in place, then the password field or the prompt + function resetBackup() { $('backup-pw-row').hidden = true; $('backup-pw').value = ''; $('backup-err').textContent = ''; $('backup-out').hidden = true; $('backup-words').innerHTML = ''; $('backup-key').textContent = ''; $('ask-backup').hidden = false; } + function resetExport() { $('export-pw-row').hidden = true; $('export-pw').value = ''; $('export-err').textContent = ''; $('export-out').hidden = true; $('export-key').textContent = ''; $('ask-export').hidden = false; } + function closeDisclosure(id) { var t = $(id); t.setAttribute('aria-expanded', 'false'); $(t.getAttribute('aria-controls')).hidden = true; } + $('backup-cancel').onclick = function () { resetBackup(); closeDisclosure('backup-toggle'); }; + $('export-cancel').onclick = function () { resetExport(); closeDisclosure('export-toggle'); }; + $('backup-use-pw').onclick = function () { $('backup-pw-row').hidden = false; $('backup-pw').focus({ preventScroll: true }); }; + $('export-use-pw').onclick = function () { $('export-pw-row').hidden = false; $('export-pw').focus({ preventScroll: true }); }; + function showBackup(r) { + $('backup-words').innerHTML = (r.words || []).map(function (w) { return '
  • ' + esc(w) + '
  • '; }).join(''); + $('backup-key').textContent = r.private_key; $('backup-out').hidden = false; $('ask-backup').hidden = true; $('backup-pw-row').hidden = true; + if (state && !state.backed_up) post('/api/backed-up').then(poll).catch(function () {}); + } + $('backup-show').onclick = async function () { + $('backup-err').textContent = ''; + try { var r = await post('/api/reveal', { password: $('backup-pw').value }); $('backup-pw').value = ''; showBackup(r); } + catch (e) { $('backup-err').textContent = e.message; } + }; + $('backup-pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); $('backup-show').click(); } }); + $('export-pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); $('export-show').click(); } }); + async function revealWithTouch(errEl) { + errEl.textContent = ''; + var c = await post('/api/biometric/challenge', { purpose: 'reveal' }); + setLine(errEl, '' + FP + esc('Waiting for ' + what()) + ''); + var h = await bio.call('confirm', { nonce: c.nonce }); + setLine(errEl, bioLine(h, 'confirmed')); + if (!h.ok) return null; + var r = await post('/api/reveal', { nonce: c.nonce }); + errEl.textContent = ''; + return r; + } + $('backup-touch').onclick = async function () { try { var r = await revealWithTouch($('backup-err')); if (r) showBackup(r); } catch (e) { $('backup-err').textContent = e.message; } }; + $('export-touch').onclick = async function () { try { var r = await revealWithTouch($('export-err')); if (r) { $('export-key').textContent = r.private_key; $('export-out').hidden = false; $('ask-export').hidden = true; } } catch (e) { $('export-err').textContent = e.message; } }; + $('export-show').onclick = async function () { + $('export-err').textContent = ''; + try { var r = await post('/api/reveal', { password: $('export-pw').value }); $('export-pw').value = ''; $('export-key').textContent = r.private_key; $('export-out').hidden = false; $('ask-export').hidden = true; $('export-pw-row').hidden = true; } + catch (e) { $('export-err').textContent = e.message; } + }; + $('backup-hide').onclick = function () { resetBackup(); }; + $('export-hide').onclick = function () { resetExport(); }; + // MetaMask + var netCache = null; + async function network() { if (!netCache) netCache = await api('/api/network'); return netCache; } + async function renderNetwork() { + try { + var n = await network(); + setText('net-line', n.chain_name + ' · chain id ' + n.chain_id + ' · ' + (n.rpc_url || 'no RPC yet')); + $('net-add').disabled = !n.add_network_page; + $('net-add').title = n.add_network_page ? '' : 'the site page is not set in this build; copy the settings instead'; + } catch (e) { setText('net-line', e.message); } + } + $('net-add').onclick = async function () { var n = await network(); if (n.add_network_page) post('/api/open', { url: n.add_network_page }); }; + $('net-copy').onclick = async function () { var n = await network(); copyText('Network name: ' + n.chain_name + '\nRPC URL: ' + n.rpc_url + '\nChain ID: ' + n.chain_id + '\nCurrency symbol: IGN\nDecimals: 18', 'Network settings'); }; + $('start-login').onchange = async function (ev) { try { await post('/api/settings', { start_at_login: ev.target.checked }); } catch (e) { toast(e.message); } }; + $('auto-update').onchange = async function (ev) { try { await post('/api/update/auto', { on: ev.target.checked }); } catch (e) { toast(e.message); } }; + $('s-update').onclick = function () { post('/api/update/check').catch(function () {}); toast('Checking for updates'); }; + $('s-install').onclick = function () { post('/api/update/install').catch(function () {}); toast('Installing now'); }; + // remove: the ask in place, never a dialog + $('remove-start').onclick = function () { $('ask-remove').hidden = false; $('remove-pw').focus({ preventScroll: true }); }; + $('ask-remove-no').onclick = function () { $('ask-remove').hidden = true; $('remove-pw').value = ''; $('remove-err').textContent = ''; }; + $('remove-go').onclick = async function () { + $('remove-err').textContent = ''; + try { await post('/api/remove', { password: $('remove-pw').value }); $('remove-pw').value = ''; $('ask-remove').hidden = true; await poll(); } + catch (e) { $('remove-err').textContent = e.message; } + }; + $('remove-pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); $('remove-go').click(); } }); + new MutationObserver(function () { if (document.body.dataset.page === 'settings' && document.body.dataset.phase === 'home') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-page', 'data-phase'] }); + + // ---------- the update strip (one notice) and the update card ---------- + var stripH = 0, noticeSig = ''; + function renderNotices(s) { + var u = s.update, l = View.updateLine(u), strip = $('notices'); + var key = u.status + ':' + u.version; + var show = !!l && (u.urgent || u.applying || sessionStorage.getItem('update-later') !== key); + if (!show) { if (!strip.hidden) { strip.hidden = true; noticeSig = ''; layoutStrip(); } return; } + var acts = []; + if (l.install && !u.applying) acts.push({ act: 'install', label: 'Install now', primary: true }); + if (l.open) acts.push({ act: 'open', label: 'Open the download', primary: true }); + var sig = [key, l.text, l.urgent ? 'u' : '', l.prog ? 'p' : '', JSON.stringify(acts)].join('|'); + if (sig !== noticeSig) { + noticeSig = sig; + $('notice').className = 'notice' + (l.urgent ? ' urgent' : ''); + $('notice').dataset.key = key; + setText('notice-text', l.text); + $('notice-actions').innerHTML = acts.map(function (a) { return ''; }).join(''); + $('notice-close').hidden = !!l.urgent || !!u.applying; + $('notice-prog').hidden = !l.prog; + strip.hidden = false; + } + if (l.prog) $('notice-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%'; + layoutStrip(); + } + function layoutStrip() { + var strip = $('notices'), h = strip.hidden ? 0 : strip.offsetHeight; + if (h === stripH) return; + stripH = h; + var top = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--top'), 10) || 60; + $('main').style.top = h ? (top + h) + 'px' : ''; + } + window.addEventListener('resize', layoutStrip); + $('notice').addEventListener('click', function (e) { + var b = e.target.closest('[data-act]'); if (!b) return; + var act = b.dataset.act; + if (act === 'install') { post('/api/update/install').catch(function () {}); toast('Installing now'); } + else if (act === 'open') post('/api/update/open').catch(function () {}); + }); + $('notice-close').onclick = function () { sessionStorage.setItem('update-later', $('notice').dataset.key || ''); $('notices').hidden = true; noticeSig = ''; layoutStrip(); }; + + // the update card (update-card.js): one update, centred, over every screen; mem: open, later, seen; asked: Install + // now was pressed on this card and the engine has not flipped to applying yet + var updMem = { open: false, later: '', seen: '' }; + var updSig = '', updAsked = '', updMore = false; + var forcedUpdate = params.get('update'); + if (forcedUpdate && params.get('card') === '1') updMem.open = true; + function sampleUpdate(kind) { + var u = { status: 'ready', version: '0.1.6', url: '', notes: "The wallet on the miner's design: money first, one row per transaction with the node's state word, an Appearance setting, a bottom tab bar on a narrow window. Touch ID unchanged.", file: '', error: '', checked_at: Date.now() / 1000 - 40, available: true, downloaded: true, ready: true, applying: false, progress: 1, size: 20080717, auto: params.get('auto') !== '0', wait: 'installs as soon as nothing is being sent', urgent: false, urgent_text: '', unsupported: false, min_supported: '', updated_from: '', rolled_back: '' }; + if (!u.auto) u.wait = 'waiting for Install now (automatic updates are off)'; + if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; } + if (kind === 'downloading') { u.status = 'downloading'; u.downloaded = false; u.ready = false; u.progress = Math.min(0.97, 0.43 + ((Date.now() / 1000) % 60) / 110); } + if (kind === 'staging') { u.status = 'staging'; u.ready = false; } + if (kind === 'applying') { u.status = 'applying'; u.applying = true; } + if (kind === 'deferred') { u.status = 'deferred'; u.wait = 'waits for the next time someone is at this PC (Windows asks for permission)'; } + if (kind === 'urgent') { u.status = 'downloading'; u.ready = false; u.downloaded = false; u.progress = 0.27; u.urgent = true; u.urgent_text = 'Igneum Wallet 0.1.4 is no longer supported by the network. Installing 0.1.6 now.'; } + if (kind === 'manual') { u.status = 'manual'; u.ready = false; u.wait = '/Applications is not writable; open the downloaded disk image and drag the app over the old one'; } + if (kind === 'error') { u.status = 'error'; u.error = 'sha256 mismatch: the file is not what the manifest signed'; u.downloaded = false; u.ready = false; } + return u; + } + function cardCtx(s) { + var line = Array.prototype.some.call(document.querySelectorAll('.bio-state'), function (e) { return e.offsetParent !== null; }); + return { phase: document.body.dataset.phase, view: document.body.dataset.phase === 'home' ? page : '', bioBusy: bio.busy, bioLine: line, bioName: what(), quitting: !!s.quitting }; + } + function renderUpdateCard(s) { + var m = UpdateCard.model(s.update, s), d = UpdateCard.decide(m, cardCtx(s), updMem), wrap = $('upd'); + if (!d.show) { if (updMem.open || !wrap.hidden) { updMem.open = false; wrap.hidden = true; updSig = ''; } return; } + if (!updMem.open) { updMem.open = true; updMem.seen = m.version; updMore = false; if (updAsked !== m.key) updAsked = ''; } + var asked = updAsked === m.key && m.stage !== 'installing' && m.stage !== 'failed'; + var sig = [m.key, m.stage, m.line, m.note, m.cause, m.ring, asked ? 'asked' : '', m.lines.join('|')].join('\u0001'); + if (sig !== updSig) { + updSig = sig; + $('upd-name').textContent = m.name; + $('upd-line').textContent = m.line; + $('upd-cause').textContent = m.cause; $('upd-cause').hidden = !m.cause; + $('upd-notes').innerHTML = m.lines.map(function (l) { return '
  • ' + esc(l) + '
  • '; }).join(''); + $('upd-all').innerHTML = m.all.map(function (l) { return '
  • ' + esc(l) + '
  • '; }).join(''); + $('upd-more').hidden = !m.more; if (!m.more) updMore = false; + $('upd-more').textContent = updMore ? 'Less' : 'What changed'; $('upd-more').setAttribute('aria-expanded', updMore ? 'true' : 'false'); + $('upd-all').hidden = !updMore; + $('upd-mark').className = 'upd-mark ' + m.ring; + var acts = asked ? [{ act: 'install', label: m.stage === 'ready' || m.stage === 'deferred' ? 'Installing' : 'Installs when ready', primary: true, disabled: true }, { act: 'later', label: 'Later' }] : m.actions; + $('upd-actions').innerHTML = acts.map(function (a) { return ''; }).join(''); + wrap.dataset.dismiss = m.dismissable ? '1' : ''; + } + if (m.ring === 'progress') { $('upd-arc').style.strokeDashoffset = (276.5 * (1 - m.pct / 100)).toFixed(1); $('upd-pct').textContent = m.pct + '%'; $('upd-pct').hidden = m.stage !== 'downloading'; } + else { $('upd-arc').style.strokeDashoffset = ''; $('upd-pct').hidden = true; } + var meta = []; + if (m.stage === 'downloading' && m.size) meta.push(m.pct + '% of ' + m.size); + else if (m.size && m.stage === 'available') meta.push(m.size + ' download'); + else if (m.size && (m.stage === 'ready' || m.stage === 'manual' || m.stage === 'deferred' || m.stage === 'staging')) meta.push(m.size + ', downloaded'); + if (m.note) meta.push(m.note); + $('upd-meta').textContent = meta.join(' · '); + if (wrap.hidden) { wrap.hidden = false; $('upd-card').focus({ preventScroll: true }); } + } + function cardLater() { + if (!updMem.open || !$('upd').dataset.dismiss) return; + var m = UpdateCard.model(state && state.update, state || {}); + updMem.later = m ? m.key : updMem.later; updMem.open = false; $('upd').hidden = true; updSig = ''; + } + $('upd').addEventListener('click', function (e) { + if (e.target === $('upd')) { cardLater(); return; } + var b = e.target.closest('[data-act]'); if (!b || b.disabled) return; + var act = b.dataset.act, m = UpdateCard.model(state && state.update, state || {}); + if (act === 'later') cardLater(); + else if (act === 'install' || act === 'retry') { post('/api/update/install').catch(function () {}); updAsked = m ? m.key : ''; updSig = ''; toast(act === 'retry' ? 'Trying the update again' : m && m.stage === 'ready' ? 'Installing now' : 'Installs as soon as it is downloaded'); if (state) renderUpdateCard(state); } + else if (act === 'open') post('/api/update/open').catch(function () {}); + }); + $('upd-more').onclick = function () { updMore = !updMore; $('upd-all').hidden = !updMore; this.textContent = updMore ? 'Less' : 'What changed'; this.setAttribute('aria-expanded', updMore ? 'true' : 'false'); }; + + document.addEventListener('visibilitychange', function () { + if (document.hidden) return; + poll(); + if (state && state.phase === 'unlock' && !locking) focusLock(); + }); + + resetSend(); poll(); - // the window came back (menu bar, Dock): the button gets the focus so Return unlocks; never a sheet by itself - if (state && state.phase === 'unlock' && !locking) focusLock(); -}); -new MutationObserver(() => { if (document.body.dataset.view === 'settings') renderNetwork(); }).observe(document.body, { attributes: true, attributeFilter: ['data-view'] }); - -poll(); -setInterval(poll, 2000); + setInterval(poll, 2000); +})(); diff --git a/app/igneum-wallet/ui/index.html b/app/igneum-wallet/ui/index.html index c931301cc..281779d17 100644 --- a/app/igneum-wallet/ui/index.html +++ b/app/igneum-wallet/ui/index.html @@ -4,33 +4,67 @@ Igneum Wallet - + - + + + +
    -
    +
    - IGNEUMWALLET + IGNEUMWALLET +
    +
    starting
    - - +
    -
    -
    step 1 of 3
    +
    step 1 of 3

    Choose a password

    It locks the key on this machine. Nobody can reset it for you. At least 8 characters.

    - - + +