From b03bf694e47276fac615d593a42ac4ff7b200fb9 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] 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);