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 <noreply@anthropic.com>
This commit is contained in:
parent
779c310490
commit
b2e7b23f85
44 changed files with 12431 additions and 12 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
490
app/igneum-common/Cargo.lock
generated
Normal file
490
app/igneum-common/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"elliptic-curve",
|
||||
"signature",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"sec1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "igneum-common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom",
|
||||
"k256",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "k256"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"der",
|
||||
"generic-array",
|
||||
"pkcs8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
19
app/igneum-common/Cargo.toml
Normal file
19
app/igneum-common/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "igneum-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "What Igneum Miner and Igneum Wallet share: platform helpers, the payout key code, the signed update manifest, the local dashboard server primitives and the packaged configuration"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
k256 = { version = "0.13", default-features = false, features = ["arithmetic", "std"] }
|
||||
sha3 = { version = "0.10", default-features = false }
|
||||
getrandom = "0.2"
|
||||
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
89
app/igneum-common/src/config.rs
Normal file
89
app/igneum-common/src/config.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! The packager's configuration next to the binary (`igneum-app.json` for the miner, `igneum-wallet.json` for the
|
||||
//! wallet; macOS: Contents/Resources) and the per-install machine id. From app/igneum-app/src/config.rs.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Written by the packager (build-dmg.sh, make-payload.sh). Missing fields disable the feature.
|
||||
#[derive(Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Packaged {
|
||||
#[serde(default)]
|
||||
pub update_manifest: String,
|
||||
#[serde(default)]
|
||||
pub log_intake_url: String,
|
||||
#[serde(default)]
|
||||
pub log_intake_key: String,
|
||||
#[serde(default)]
|
||||
pub live_page: String,
|
||||
#[serde(default)]
|
||||
pub download_page: String,
|
||||
/// Consensus parameters the packager pins for the bundled node (`--override-params-file`). Absent = none.
|
||||
#[serde(default)]
|
||||
pub node_override_params: Option<serde_json::Value>,
|
||||
/// Wallet: the public Ethereum JSON-RPC of the seed, when one exists (`https://...`); empty = none known.
|
||||
#[serde(default)]
|
||||
pub public_rpc: String,
|
||||
/// Wallet: the page on the site that adds the network to MetaMask (`https://igneum.network/wallet/add`).
|
||||
#[serde(default)]
|
||||
pub add_network_page: String,
|
||||
}
|
||||
|
||||
impl Packaged {
|
||||
pub fn load(candidates: &[PathBuf]) -> Packaged {
|
||||
for c in candidates {
|
||||
if let Some(p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::<Packaged>(&t).ok()) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
Packaged::default()
|
||||
}
|
||||
/// The places the packaged file can be: the binary's folder (Windows), Contents/Resources (macOS), IGNEUM_APP_BIN.
|
||||
pub fn candidates(file_name: &str) -> Vec<PathBuf> {
|
||||
let mut out = vec![];
|
||||
if let Some(d) = std::env::var_os("IGNEUM_APP_BIN") {
|
||||
out.push(PathBuf::from(d).join(file_name));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(d) = exe.parent() {
|
||||
out.push(d.join(file_name));
|
||||
if let Some(c) = d.parent() {
|
||||
out.push(c.join("Resources").join(file_name));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the machine id, or makes one on the first run (16 hex from the OS) and locks the file to the user.
|
||||
pub fn machine_id(app_dir: &Path) -> String {
|
||||
let path = app_dir.join("machine-id");
|
||||
if let Some(id) = std::fs::read_to_string(&path).ok().map(|s| s.trim().to_ascii_lowercase()) {
|
||||
if id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
let mut raw = [0u8; 8];
|
||||
getrandom::getrandom(&mut raw).expect("os randomness");
|
||||
let id = crate::keys::hex(&raw);
|
||||
let _ = std::fs::create_dir_all(app_dir);
|
||||
crate::platform::lock_permissions(app_dir, true);
|
||||
let _ = std::fs::write(&path, format!("{id}\n"));
|
||||
crate::platform::lock_permissions(&path, false);
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn packaged_carries_the_wallet_fields() {
|
||||
let p: Packaged = serde_json::from_str(r#"{"update_manifest":"","public_rpc":"https://rpc.igneum.network","node_override_params":{"difficulty_v2_activation_daa":123456}}"#).unwrap();
|
||||
assert_eq!(p.public_rpc, "https://rpc.igneum.network");
|
||||
assert_eq!(p.node_override_params.as_ref().unwrap()["difficulty_v2_activation_daa"], 123456);
|
||||
let p: Packaged = serde_json::from_str(r#"{"update_manifest":""}"#).unwrap();
|
||||
assert!(p.node_override_params.is_none());
|
||||
assert!(p.public_rpc.is_empty());
|
||||
}
|
||||
}
|
||||
71
app/igneum-common/src/fetch.rs
Normal file
71
app/igneum-common/src/fetch.rs
Normal file
|
|
@ -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 `<url>` and `<url>.sig` into `dir`, verifies the bytes with the embedded OTA public key, parses.
|
||||
/// Writes `manifest.json` to `dir` only after the check.
|
||||
pub fn fetch_manifest(url: &str, dir: &Path) -> Result<Manifest, String> {
|
||||
let m = dir.join("manifest.json.new");
|
||||
let s = dir.join("manifest.json.sig.new");
|
||||
curl_get(url, &m, Duration::from_secs(30)).map_err(|e| format!("manifest: {e}"))?;
|
||||
curl_get(&format!("{url}.sig"), &s, Duration::from_secs(30)).map_err(|e| format!("manifest signature: {e}"))?;
|
||||
let bytes = std::fs::read(&m).map_err(|e| e.to_string())?;
|
||||
let sig = std::fs::read_to_string(&s).map_err(|e| e.to_string())?;
|
||||
let parsed = manifest::verify_and_parse(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?;
|
||||
let _ = std::fs::rename(&m, dir.join("manifest.json"));
|
||||
let _ = std::fs::rename(&s, dir.join("manifest.json.sig"));
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
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<PathBuf, String> {
|
||||
let dest = dir.join(file_name(&e.url));
|
||||
let ok = |p: &Path| -> bool {
|
||||
std::fs::metadata(p).map(|m| m.len() == e.size).unwrap_or(false) && manifest::sha256_file(p).map(|s| s == e.sha256).unwrap_or(false)
|
||||
};
|
||||
if ok(&dest) {
|
||||
return Ok(dest);
|
||||
}
|
||||
let 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)
|
||||
}
|
||||
201
app/igneum-common/src/http.rs
Normal file
201
app/igneum-common/src/http.rs
Normal file
|
|
@ -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/<token>/`, one thread per connection, Connection: close. And a small JSON client
|
||||
//! for a node's Ethereum JSON-RPC on 127.0.0.1 (no TLS: the wallet reaches a public https RPC through curl instead).
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
pub struct Req {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub query: String,
|
||||
pub body: Vec<u8>,
|
||||
pub origin: Option<String>,
|
||||
pub sec_fetch_site: Option<String>,
|
||||
pub host: Option<String>,
|
||||
}
|
||||
|
||||
pub fn read_request(stream: &mut TcpStream) -> Option<Req> {
|
||||
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
|
||||
let mut reader = BufReader::new(stream.try_clone().ok()?);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).ok()?;
|
||||
let mut parts = line.split_whitespace();
|
||||
let method = parts.next()?.to_string();
|
||||
let target = parts.next()?.to_string();
|
||||
let mut content_length = 0usize;
|
||||
let (mut origin, mut sec_fetch_site, mut host) = (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<String> {
|
||||
q.split('&').find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
if k == key { Some(v.to_string()) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
/// Binds 127.0.0.1 on a random port and serves every connection on its own thread through `handle`.
|
||||
pub fn serve<F>(handle: F) -> std::io::Result<u16>
|
||||
where
|
||||
F: Fn(TcpStream) + Send + Sync + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let handle = std::sync::Arc::new(handle);
|
||||
std::thread::spawn(move || {
|
||||
for conn in listener.incoming() {
|
||||
let Ok(stream) = conn else { continue };
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || handle(stream));
|
||||
}
|
||||
});
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// The per-launch dashboard token: 32 hex characters from the OS.
|
||||
pub fn new_token() -> String {
|
||||
let mut raw = [0u8; 16];
|
||||
getrandom::getrandom(&mut raw).expect("os randomness");
|
||||
crate::keys::hex(&raw)
|
||||
}
|
||||
|
||||
// ---- a JSON POST to 127.0.0.1 (the node's Ethereum RPC) -------------------------------------------------------
|
||||
|
||||
/// POSTs `body` as JSON to `http://127.0.0.1:<port><path>` (or any plain-http host:port) and returns the body.
|
||||
pub fn post_json(host_port: &str, path: &str, body: &str, timeout: std::time::Duration) -> Result<String, String> {
|
||||
let mut s = TcpStream::connect(host_port).map_err(|e| format!("connect {host_port}: {e}"))?;
|
||||
let _ = s.set_read_timeout(Some(timeout));
|
||||
let _ = s.set_write_timeout(Some(timeout));
|
||||
let req = format!(
|
||||
"POST {path} HTTP/1.1\r\nHost: {host_port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
s.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
|
||||
let mut reader = BufReader::new(s);
|
||||
let mut status = String::new();
|
||||
reader.read_line(&mut status).map_err(|e| e.to_string())?;
|
||||
let code: u16 = status.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
|
||||
let mut len: Option<usize> = None;
|
||||
let mut chunked = false;
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
reader.read_line(&mut h).map_err(|e| e.to_string())?;
|
||||
let h = h.trim_end();
|
||||
if h.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((k, v)) = h.split_once(':') {
|
||||
if k.eq_ignore_ascii_case("content-length") {
|
||||
len = v.trim().parse().ok();
|
||||
} else if k.eq_ignore_ascii_case("transfer-encoding") && v.trim().eq_ignore_ascii_case("chunked") {
|
||||
chunked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if chunked {
|
||||
loop {
|
||||
let mut l = String::new();
|
||||
reader.read_line(&mut l).map_err(|e| e.to_string())?;
|
||||
let n = usize::from_str_radix(l.trim().split(';').next().unwrap_or("0"), 16).unwrap_or(0);
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let mut buf = vec![0u8; n];
|
||||
reader.read_exact(&mut buf).map_err(|e| e.to_string())?;
|
||||
out.extend_from_slice(&buf);
|
||||
let mut crlf = String::new();
|
||||
let _ = reader.read_line(&mut crlf);
|
||||
}
|
||||
} else if let Some(n) = len {
|
||||
out = vec![0u8; n];
|
||||
reader.read_exact(&mut out).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
reader.read_to_end(&mut out).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out).into_owned();
|
||||
if code != 200 {
|
||||
return Err(format!("http {code}: {}", text.chars().take(200).collect::<String>()));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
109
app/igneum-common/src/keys.rs
Normal file
109
app/igneum-common/src/keys.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! The payout key: a secp256k1 private key made on this machine, its EVM address, and the miner's wallet file.
|
||||
//! The miner's file lives in its app data directory (`app/wallet.json`), plain JSON with the permissions locked to the
|
||||
//! user: 0600 on macOS and Linux, an icacls grant to the signed-in user alone on Windows. Igneum Wallet imports that
|
||||
//! file and keeps its own key encrypted (app/igneum-wallet/src/vault.rs). From app/igneum-app/src/keys.rs.
|
||||
|
||||
use k256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
use k256::SecretKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha3::{Digest, Keccak256};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Wallet {
|
||||
pub address: String, // 0x + 40 lower-case hex
|
||||
pub private_key: String, // 0x + 64 hex, secp256k1
|
||||
pub created: u64, // unix seconds
|
||||
}
|
||||
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Hex (with or without 0x) to bytes; None when not hex or odd length.
|
||||
pub fn unhex(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim().trim_start_matches("0x");
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
|
||||
}
|
||||
|
||||
/// The lower-case 0x address of a raw 32-byte private key, or None when the scalar is not a valid key.
|
||||
pub fn address_of_raw(raw: &[u8; 32]) -> Option<String> {
|
||||
SecretKey::from_slice(raw).ok().map(|sk| address_of(&sk))
|
||||
}
|
||||
|
||||
/// The EVM address of a secp256k1 private key: keccak256 of the uncompressed public key (without the 0x04 prefix), last 20 bytes.
|
||||
pub fn address_of(sk: &SecretKey) -> String {
|
||||
let pk = sk.public_key();
|
||||
let point = pk.to_encoded_point(false);
|
||||
let bytes = point.as_bytes();
|
||||
let h = Keccak256::digest(&bytes[1..]);
|
||||
format!("0x{}", hex(&h[12..]))
|
||||
}
|
||||
|
||||
/// A fresh key from the operating system's randomness.
|
||||
pub fn generate() -> Wallet {
|
||||
loop {
|
||||
let mut raw = [0u8; 32];
|
||||
getrandom::getrandom(&mut raw).expect("os randomness");
|
||||
if let Ok(sk) = SecretKey::from_slice(&raw) {
|
||||
let address = address_of(&sk);
|
||||
return Wallet { address, private_key: format!("0x{}", hex(&raw)), created: crate::platform::unix_now() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// EIP-55 mixed-case form of a lower-case address, for display.
|
||||
pub fn checksum(addr: &str) -> String {
|
||||
let body = addr.trim_start_matches("0x").to_ascii_lowercase();
|
||||
let h = Keccak256::digest(body.as_bytes());
|
||||
let mut out = String::with_capacity(42);
|
||||
out.push_str("0x");
|
||||
for (i, c) in body.chars().enumerate() {
|
||||
let nibble = (h[i / 2] >> (if i % 2 == 0 { 4 } else { 0 })) & 0xf;
|
||||
if c.is_ascii_alphabetic() && nibble >= 8 {
|
||||
out.push(c.to_ascii_uppercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True for 0x followed by 40 hex characters.
|
||||
pub fn valid_address(s: &str) -> bool {
|
||||
let s = s.trim();
|
||||
s.len() == 42 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
pub fn save(path: &Path, w: &Wallet) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
crate::platform::lock_permissions(dir, true);
|
||||
}
|
||||
let text = serde_json::to_string_pretty(w).expect("wallet json");
|
||||
std::fs::write(path, text)?;
|
||||
crate::platform::lock_permissions(path, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Option<Wallet> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_vector() {
|
||||
// The well-known test key 0x01: address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf
|
||||
let mut raw = [0u8; 32];
|
||||
raw[31] = 1;
|
||||
let sk = SecretKey::from_slice(&raw).unwrap();
|
||||
assert_eq!(checksum(&address_of(&sk)), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
|
||||
}
|
||||
}
|
||||
37
app/igneum-common/src/lib.rs
Normal file
37
app/igneum-common/src/lib.rs
Normal file
|
|
@ -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" };
|
||||
527
app/igneum-common/src/manifest.rs
Normal file
527
app/igneum-common/src/manifest.rs
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
//! The over-the-air update manifest: what the downloads host publishes as `igneum-app-latest.json` with a detached
|
||||
//! Ed25519 signature next to it (`igneum-app-latest.json.sig`, 64 bytes as 128 hex characters). The signature is over
|
||||
//! the exact bytes of the manifest file; the publisher (packaging/ota/publish-manifest.sh) writes the file in canonical
|
||||
//! form (sorted keys, no whitespace) and the app verifies the bytes before it parses them.
|
||||
//!
|
||||
//! This module is self-contained (serde_json, ed25519-dalek, sha2 only), so the signer binary
|
||||
//! (src/bin/ota-sign.rs) includes it with `#[path]` and signs with the very code that verifies.
|
||||
//!
|
||||
//! Manifest shape:
|
||||
//! {
|
||||
//! "version": "0.3.1", "published_at": "2026-10-04T13:00:00Z", "channel": "devnet",
|
||||
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
|
||||
//! "min_supported_version": "0.3.0", "notes": "one line",
|
||||
//! "consensus": { "activation_height": null|number, "deadline_note": "" }
|
||||
//! }
|
||||
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// The public half of ~/.config/igneum/ota-signing-key (generated once on the Mac with `igneum-ota-sign keygen`;
|
||||
/// the private key never enters the repo or CI). Fingerprint: SHA-256 of these 32 bytes, see `fingerprint()`.
|
||||
pub const OTA_PUBLIC_KEY_HEX: &str = "b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd";
|
||||
|
||||
/// How many DAA blocks before a consensus activation height the app stops waiting for a safe moment.
|
||||
pub const FORK_URGENT_BLOCKS: u64 = 1_800;
|
||||
/// No apply within this many seconds of an hourly program boundary.
|
||||
pub const BOUNDARY_GUARD_S: i64 = 180;
|
||||
/// A ready update that found no safe moment for this long is applied anyway (an unsynced node mines nothing).
|
||||
pub const SAFE_MOMENT_PATIENCE_S: u64 = 6 * 3600;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct PlatformEntry {
|
||||
pub url: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub kind: String, // dmg | zip | inno-setup
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Manifest {
|
||||
pub version: String,
|
||||
pub published_at: String,
|
||||
pub channel: String,
|
||||
pub mac: Option<PlatformEntry>,
|
||||
pub windows: Option<PlatformEntry>,
|
||||
pub min_supported_version: String,
|
||||
pub notes: String,
|
||||
pub activation_height: Option<u64>,
|
||||
pub deadline_note: String,
|
||||
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
|
||||
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest.
|
||||
pub override_params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn platform(&self, name: &str) -> Option<&PlatformEntry> {
|
||||
match name {
|
||||
"mac" => self.mac.as_ref(),
|
||||
"windows" => self.windows.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn this_platform(&self) -> Option<&PlatformEntry> {
|
||||
self.platform(platform_name())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn platform_name() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"mac"
|
||||
} else if cfg!(windows) {
|
||||
"windows"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
|
||||
}
|
||||
|
||||
pub fn hex_encode(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn public_key(hex: &str) -> Result<VerifyingKey, String> {
|
||||
let bytes = hex_decode(hex).ok_or("public key is not hex")?;
|
||||
let arr: [u8; 32] = bytes.try_into().map_err(|_| "public key is not 32 bytes")?;
|
||||
VerifyingKey::from_bytes(&arr).map_err(|e| format!("public key invalid: {e}"))
|
||||
}
|
||||
|
||||
/// SHA-256 of the raw public key bytes, as hex: what the report and the docs quote.
|
||||
pub fn fingerprint(pub_hex: &str) -> String {
|
||||
match hex_decode(pub_hex) {
|
||||
Some(b) => hex_encode(&Sha256::digest(&b)),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the detached signature (hex) over the manifest bytes with the given public key (hex).
|
||||
pub fn verify_signature(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<(), String> {
|
||||
let key = public_key(pub_hex)?;
|
||||
let sig = hex_decode(sig_hex).ok_or("signature is not hex")?;
|
||||
let sig: [u8; 64] = sig.try_into().map_err(|_| "signature is not 64 bytes")?;
|
||||
let sig = Signature::from_bytes(&sig);
|
||||
key.verify(manifest_bytes, &sig).map_err(|_| "manifest signature does not verify".to_string())
|
||||
}
|
||||
|
||||
/// Parses the manifest JSON (after the signature was checked).
|
||||
pub fn parse(text: &str) -> Result<Manifest, String> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("manifest is not JSON: {e}"))?;
|
||||
let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let version = s(&v, "version");
|
||||
if parse_version(&version).is_none() {
|
||||
return Err(format!("manifest version '{version}' is not a version"));
|
||||
}
|
||||
let entry = |name: &str| -> Result<Option<PlatformEntry>, String> {
|
||||
let Some(p) = v.get("platforms").and_then(|p| p.get(name)) else { return Ok(None) };
|
||||
if p.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let e = PlatformEntry { url: s(p, "url"), sha256: s(p, "sha256").to_ascii_lowercase(), size: p.get("size").and_then(|x| x.as_u64()).unwrap_or(0), kind: s(p, "kind") };
|
||||
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
|
||||
return Err(format!("{name}: the url is not https"));
|
||||
}
|
||||
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!("{name}: sha256 is not 64 hex characters"));
|
||||
}
|
||||
if e.size == 0 {
|
||||
return Err(format!("{name}: size is missing"));
|
||||
}
|
||||
if !["dmg", "zip", "inno-setup"].contains(&e.kind.as_str()) {
|
||||
return Err(format!("{name}: kind '{}' is unknown", e.kind));
|
||||
}
|
||||
Ok(Some(e))
|
||||
};
|
||||
let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null);
|
||||
Ok(Manifest {
|
||||
version,
|
||||
published_at: s(&v, "published_at"),
|
||||
channel: s(&v, "channel"),
|
||||
mac: entry("mac")?,
|
||||
windows: entry("windows")?,
|
||||
min_supported_version: s(&v, "min_supported_version"),
|
||||
notes: s(&v, "notes"),
|
||||
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
|
||||
deadline_note: s(&consensus, "deadline_note"),
|
||||
override_params: match consensus.get("override") {
|
||||
Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()),
|
||||
Some(o) if !o.is_null() => return Err("consensus.override must be an object".into()),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies, then parses.
|
||||
pub fn verify_and_parse(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<Manifest, String> {
|
||||
verify_signature(manifest_bytes, sig_hex, pub_hex)?;
|
||||
let text = std::str::from_utf8(manifest_bytes).map_err(|_| "manifest is not UTF-8")?;
|
||||
parse(text)
|
||||
}
|
||||
|
||||
/// A digest of a whole directory (the staged app bundle): sha256 over "relative path\nsha256 of the file\n" for every
|
||||
/// regular file in byte-sorted path order (symlinks skipped). The macOS helper recomputes the same with find, sort
|
||||
/// and shasum right before the swap (R4.3.5).
|
||||
pub fn digest_dir(root: &std::path::Path) -> std::io::Result<String> {
|
||||
fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<String>) -> std::io::Result<()> {
|
||||
for e in std::fs::read_dir(dir)? {
|
||||
let e = e?;
|
||||
let ft = e.file_type()?;
|
||||
let p = e.path();
|
||||
if ft.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if ft.is_dir() {
|
||||
walk(&p, root, out)?;
|
||||
} else if ft.is_file() {
|
||||
out.push(p.strip_prefix(root).unwrap_or(&p).to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk(root, root, &mut files)?;
|
||||
files.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
|
||||
let mut h = Sha256::new();
|
||||
for f in files {
|
||||
let sum = sha256_file(&root.join(&f))?;
|
||||
h.update(f.as_bytes());
|
||||
h.update(b"\n");
|
||||
h.update(sum.as_bytes());
|
||||
h.update(b"\n");
|
||||
}
|
||||
Ok(hex_encode(&h.finalize()))
|
||||
}
|
||||
|
||||
/// SHA-256 of a file, streamed, as hex.
|
||||
pub fn sha256_file(path: &std::path::Path) -> std::io::Result<String> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = f.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(&buf[..n]);
|
||||
}
|
||||
Ok(hex_encode(&h.finalize()))
|
||||
}
|
||||
|
||||
// ---- versions -----------------------------------------------------------------------------------------------
|
||||
|
||||
/// major.minor.patch plus an optional pre-release tag ("0.4.0-rc1" sorts before "0.4.0"). A leading "v" is allowed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Version {
|
||||
pub parts: [u64; 3],
|
||||
pub pre: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_version(s: &str) -> Option<Version> {
|
||||
let s = s.trim().trim_start_matches('v');
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (num, pre) = match s.split_once('-') {
|
||||
Some((n, p)) if !p.is_empty() => (n, Some(p.to_string())),
|
||||
Some(_) => return None,
|
||||
None => (s, None),
|
||||
};
|
||||
let mut parts = [0u64; 3];
|
||||
let mut n = 0;
|
||||
for p in num.split('.') {
|
||||
if n >= 3 || p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
parts[n] = p.parse().ok()?;
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Version { parts, pre })
|
||||
}
|
||||
|
||||
impl PartialOrd for Version {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Version {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match self.parts.cmp(&other.parts) {
|
||||
std::cmp::Ordering::Equal => match (&self.pre, &other.pre) {
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(Some(a), Some(b)) => a.cmp(b),
|
||||
},
|
||||
o => o,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `latest` is a newer version than `current`. Unparseable input is never newer.
|
||||
pub fn newer(latest: &str, current: &str) -> bool {
|
||||
match (parse_version(latest), parse_version(current)) {
|
||||
(Some(a), Some(b)) => a > b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- when to apply --------------------------------------------------------------------------------------------
|
||||
|
||||
/// What the engine knows when it asks whether now is a safe moment.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Moment {
|
||||
pub node_synced: bool,
|
||||
/// DAA blocks (about seconds) to the next hourly program boundary; None when the node has not said.
|
||||
pub boundary_eta_s: Option<i64>,
|
||||
/// A worker is starting, exporting a pack or being built: let it finish.
|
||||
pub miner_busy: bool,
|
||||
/// How long the update has been ready and waiting.
|
||||
pub ready_for_s: u64,
|
||||
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
|
||||
pub urgent: bool,
|
||||
/// This minute is the machine's own slot (slot_minute): machines take turns, two never restart together.
|
||||
pub slot_ok: bool,
|
||||
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
|
||||
pub network_drop_pct: f64,
|
||||
}
|
||||
|
||||
/// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet).
|
||||
pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0;
|
||||
|
||||
/// The minute of the hour in which this machine applies updates: the first 8 hex of the machine id modulo 60.
|
||||
pub fn slot_minute(id8: &str) -> u64 {
|
||||
u64::from_str_radix(id8.trim(), 16).unwrap_or(0) % 60
|
||||
}
|
||||
|
||||
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
|
||||
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
||||
if m.urgent {
|
||||
return Ok(());
|
||||
}
|
||||
if m.network_drop_pct > NETWORK_DROP_HOLD_PCT {
|
||||
return Err(format!("the network lost {:.0}% of its identities in the last 10 minutes; holding the update", m.network_drop_pct));
|
||||
}
|
||||
if !m.slot_ok {
|
||||
return Err("waiting for this machine's own minute of the hour (machines take turns)".into());
|
||||
}
|
||||
if m.ready_for_s >= SAFE_MOMENT_PATIENCE_S {
|
||||
return Ok(());
|
||||
}
|
||||
if !m.node_synced {
|
||||
return Err("waiting for the node to sync".into());
|
||||
}
|
||||
if let Some(eta) = m.boundary_eta_s {
|
||||
if (0..=BOUNDARY_GUARD_S).contains(&eta) {
|
||||
return Err(format!("hourly program boundary in {} s; installing after it", eta.max(1)));
|
||||
}
|
||||
}
|
||||
if m.miner_busy {
|
||||
return Err("a worker is starting; installing once it runs".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score).
|
||||
pub fn fork_is_close(activation_height: Option<u64>, daa: u64) -> bool {
|
||||
match activation_height {
|
||||
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `current` is older than the manifest's min_supported_version.
|
||||
pub fn unsupported(m: &Manifest, current: &str) -> bool {
|
||||
!m.min_supported_version.is_empty() && newer(&m.min_supported_version, current)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
/// The helper's bash recipe (find | sort | shasum per file | shasum) must equal digest_dir.
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn digest_dir_matches_the_helper() {
|
||||
let root = std::env::temp_dir().join(format!("igneum-digest-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("Contents/MacOS")).unwrap();
|
||||
std::fs::write(root.join("Contents/MacOS/igneum-app"), b"engine").unwrap();
|
||||
std::fs::write(root.join("Contents/Info.plist"), b"<plist/>").unwrap();
|
||||
std::fs::write(root.join("Contents/zed.txt"), b"z").unwrap();
|
||||
let ours = digest_dir(&root).unwrap();
|
||||
let recipe = r#"(cd "$1" && /usr/bin/find . -type f -print | LC_ALL=C /usr/bin/sort | while IFS= read -r f; do printf '%s\n%s\n' "${f#./}" "$(/usr/bin/shasum -a 256 "$f" | /usr/bin/cut -d' ' -f1)"; done) | /usr/bin/shasum -a 256 | /usr/bin/cut -d' ' -f1"#;
|
||||
let out = std::process::Command::new("/bin/bash").args(["-c", recipe, "x", &root.display().to_string()]).output().unwrap();
|
||||
let theirs = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(ours, theirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consensus_override_parses() {
|
||||
let m = parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"activation_height":5000,"override":{"difficulty_v2_activation_daa":5000}}}"#).unwrap();
|
||||
assert_eq!(m.activation_height, Some(5000));
|
||||
assert_eq!(m.override_params.unwrap()["difficulty_v2_activation_daa"], 5000);
|
||||
assert!(parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"override":"no"}}"#).is_err());
|
||||
}
|
||||
|
||||
const SAMPLE: &str = r#"{"channel":"devnet","consensus":{"activation_height":120000,"deadline_note":"difficulty v2"},"min_supported_version":"0.3.0","notes":"difficulty v2 at height 120000","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":20588331,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-0.3.1.dmg"},"windows":{"kind":"inno-setup","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":43978429,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-Setup-0.3.1.exe"}},"published_at":"2026-10-04T13:00:00Z","version":"0.3.1"}"#;
|
||||
|
||||
fn key() -> (SigningKey, String) {
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = hex_encode(sk.verifying_key().as_bytes());
|
||||
(sk, pk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_manifest() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert_eq!(m.version, "0.3.1");
|
||||
assert_eq!(m.channel, "devnet");
|
||||
assert_eq!(m.activation_height, Some(120000));
|
||||
assert_eq!(m.deadline_note, "difficulty v2");
|
||||
assert_eq!(m.min_supported_version, "0.3.0");
|
||||
let mac = m.mac.as_ref().unwrap();
|
||||
assert_eq!(mac.kind, "dmg");
|
||||
assert_eq!(mac.size, 20588331);
|
||||
assert_eq!(m.windows.as_ref().unwrap().kind, "inno-setup");
|
||||
assert_eq!(m.platform("linux"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_platform_is_none_and_bad_entries_fail() {
|
||||
let m = parse(r#"{"version":"0.3.1","platforms":{"mac":null},"consensus":{"activation_height":null}}"#).unwrap();
|
||||
assert!(m.mac.is_none() && m.windows.is_none());
|
||||
assert_eq!(m.activation_height, None);
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("https"));
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://127.0.0.1:29790/x.dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"dmg"}}}"#).is_ok());
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("sha256"));
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"tar"}}}"#).unwrap_err().contains("kind"));
|
||||
assert!(parse(r#"{"version":"latest"}"#).is_err());
|
||||
assert!(parse("not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_verifies_and_tampering_fails() {
|
||||
let (sk, pk) = key();
|
||||
let sig = hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, &pk).is_ok());
|
||||
let m = verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).unwrap();
|
||||
assert_eq!(m.version, "0.3.1");
|
||||
// a tampered sha256 inside the manifest: the bytes changed, the signature no longer verifies
|
||||
let tampered = SAMPLE.replace("aaaaaaaa", "aaaaaaab");
|
||||
assert!(verify_and_parse(tampered.as_bytes(), &sig, &pk).is_err());
|
||||
// a bad signature
|
||||
let mut bad = sig.clone();
|
||||
bad.replace_range(0..2, if &sig[0..2] == "00" { "01" } else { "00" });
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &bad, &pk).is_err());
|
||||
// another key
|
||||
let other = hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, &other).is_err());
|
||||
// garbage
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), "zz", &pk).is_err());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, "abcd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_of_file_is_checked_by_the_caller() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-manifest-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("x.bin");
|
||||
std::fs::write(&f, b"abc").unwrap();
|
||||
assert_eq!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
std::fs::write(&f, b"abd").unwrap();
|
||||
assert_ne!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions() {
|
||||
assert!(newer("0.3.1", "0.3.0"));
|
||||
assert!(newer("0.4.0", "0.3.9"));
|
||||
assert!(newer("1.0.0", "0.99.99"));
|
||||
assert!(newer("v0.3.1", "0.3.0"));
|
||||
assert!(!newer("0.3.0", "0.3.0"));
|
||||
assert!(!newer("0.2.9", "0.3.0"));
|
||||
assert!(!newer("0.3", "0.3.0"));
|
||||
assert!(newer("0.3.1", "0.3"));
|
||||
assert!(newer("0.3.1", "0.3.1-rc1"));
|
||||
assert!(!newer("0.3.1-rc1", "0.3.1"));
|
||||
assert!(newer("0.3.1-rc2", "0.3.1-rc1"));
|
||||
assert!(!newer("", "0.3.0"));
|
||||
assert!(!newer("latest", "0.3.0"));
|
||||
assert!(!newer("0.3.1", "garbage"));
|
||||
assert!(!newer("0.3.1-", "0.3.0"));
|
||||
assert!(!newer("0.3.1.2", "0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_moments() {
|
||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
|
||||
assert!(safe_to_apply(&base).is_ok());
|
||||
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(181), ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: None, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker"));
|
||||
// urgent beats every wait
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, boundary_eta_s: Some(5), miner_busy: true, urgent: true, ..base.clone() }).is_ok());
|
||||
// patience: an unsynced node for 6 h applies anyway
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
|
||||
// the machine's slot: outside it nothing applies, not even with patience; urgent ignores it
|
||||
assert!(safe_to_apply(&Moment { slot_ok: false, ..base.clone() }).unwrap_err().contains("own minute"));
|
||||
assert!(safe_to_apply(&Moment { slot_ok: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { slot_ok: false, urgent: true, ..base.clone() }).is_ok());
|
||||
// the network guard: over 30% of identities gone in 10 minutes holds the update (we are the devnet)
|
||||
assert!(safe_to_apply(&Moment { network_drop_pct: 30.0, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { network_drop_pct: 30.1, ..base.clone() }).unwrap_err().contains("lost 30%"));
|
||||
assert!(safe_to_apply(&Moment { network_drop_pct: 80.0, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { network_drop_pct: 80.0, urgent: true, ..base.clone() }).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots() {
|
||||
assert_eq!(slot_minute("1ccfe586"), 58); // PC 2
|
||||
assert_eq!(slot_minute("00000000"), 0);
|
||||
assert_eq!(slot_minute("0000003c"), 0);
|
||||
assert_eq!(slot_minute("0000003b"), 59);
|
||||
assert_eq!(slot_minute("zz"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_closeness_and_support() {
|
||||
assert!(!fork_is_close(None, 100_000));
|
||||
assert!(!fork_is_close(Some(120_000), 0));
|
||||
assert!(!fork_is_close(Some(120_000), 118_199));
|
||||
assert!(fork_is_close(Some(120_000), 118_200));
|
||||
assert!(fork_is_close(Some(120_000), 120_000));
|
||||
assert!(fork_is_close(Some(120_000), 130_000));
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert!(!unsupported(&m, "0.3.0"));
|
||||
assert!(unsupported(&m, "0.2.9"));
|
||||
assert!(!unsupported(&parse(r#"{"version":"0.3.1"}"#).unwrap(), "0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_sha256_of_key_bytes() {
|
||||
let (_, pk) = key();
|
||||
assert_eq!(fingerprint(&pk).len(), 64);
|
||||
assert_eq!(fingerprint("zz"), "");
|
||||
}
|
||||
}
|
||||
480
app/igneum-common/src/platform.rs
Normal file
480
app/igneum-common/src/platform.rs
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
//! What differs per operating system: directories, file permissions, keeping the machine awake, opening a URL,
|
||||
//! starting at login, and stopping a child process gracefully. From app/igneum-app/src/platform.rs; the login item
|
||||
//! takes the app identity (`crate::AppId`) instead of naming Igneum Miner.
|
||||
|
||||
use crate::AppId;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// The absolute path of a system helper (R4.3.3: never a bare name on PATH). Windows: System32 (and NVIDIA's own
|
||||
/// folder for nvidia-smi); macOS: /usr/bin, /usr/sbin, /bin. Unknown names fall back to the bare name.
|
||||
pub fn tool(name: &str) -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
let sys = format!("{root}\\System32");
|
||||
let p = match name {
|
||||
"powershell" => format!("{sys}\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" => format!("{sys}\\{name}.exe"),
|
||||
"nvidia-smi" => {
|
||||
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into());
|
||||
let a = format!("{pf}\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe");
|
||||
let b = format!("{sys}\\nvidia-smi.exe");
|
||||
if Path::new(&a).is_file() { a } else { b }
|
||||
}
|
||||
_ => name.to_string(),
|
||||
};
|
||||
PathBuf::from(p)
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let p = match name {
|
||||
"curl" | "osascript" | "xattr" | "open" | "caffeinate" | "hdiutil" | "ditto" | "nohup" | "pgrep" | "pkill" | "shasum" | "xcrun" => format!("/usr/bin/{name}"),
|
||||
"sntp" | "scutil" | "system_profiler" | "sysctl" => format!("/usr/sbin/{name}"),
|
||||
"bash" | "sh" => format!("/bin/{name}"),
|
||||
_ => name.to_string(),
|
||||
};
|
||||
PathBuf::from(p)
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
for dir in ["/usr/bin", "/bin", "/usr/sbin", "/usr/local/bin"] {
|
||||
let p = Path::new(dir).join(name);
|
||||
if p.is_file() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
PathBuf::from(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strips the dashboard token from a line: "/t/<32 hex>/" becomes "/t/<token>/" (R4.3.8: the token is never logged
|
||||
/// and the logs are uploaded).
|
||||
pub fn redact(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("/t/") {
|
||||
out.push_str(&rest[..i + 3]);
|
||||
let after = &rest[i + 3..];
|
||||
let hex_len = after.chars().take_while(|c| c.is_ascii_hexdigit()).count();
|
||||
if hex_len >= 16 {
|
||||
out.push_str("<token>");
|
||||
rest = &after[hex_len..];
|
||||
} else {
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// True when a line still carries something that looks like the dashboard token (the upload guard).
|
||||
pub fn carries_token(s: &str) -> bool {
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("/t/") {
|
||||
let after = &rest[i + 3..];
|
||||
if after.chars().take_while(|c| c.is_ascii_hexdigit()).count() >= 16 {
|
||||
return true;
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn unix_now_f() -> f64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn home() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(p) = std::env::var_os("USERPROFILE") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// The root both apps write under (the miner under `app/`, the wallet under `wallet/`, see `AppId::data_sub`).
|
||||
/// macOS: ~/Library/Application Support/Igneum. Windows: %LOCALAPPDATA%\igneum (the same folder today's launchers
|
||||
/// use, so an existing devnet-v4 database is reused).
|
||||
pub fn data_root() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("IGNEUM_APP_DATA") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
home().join("Library").join("Application Support").join("Igneum")
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var_os("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|| home().join("AppData").join("Local")).join("igneum")
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
home().join(".igneum")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_root() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("IGNEUM_APP_LOGS") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
home().join("Library").join("Logs").join("Igneum")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
data_root().join("logs")
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's short name, cleaned to [A-Za-z0-9-], for the miner labels (mac-<host>, nvidia-<pc>).
|
||||
pub fn host_label() -> String {
|
||||
let raw = {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Command::new(tool("scutil")).args(["--get", "LocalHostName"]).output().ok().and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("COMPUTERNAME").ok()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
std::fs::read_to_string("/etc/hostname").ok()
|
||||
}
|
||||
};
|
||||
let raw = raw.unwrap_or_default();
|
||||
let cleaned: String = raw.trim().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect();
|
||||
let cleaned = cleaned.trim_matches('-').to_string();
|
||||
if cleaned.is_empty() {
|
||||
if cfg!(windows) { "pc".into() } else { "mac".into() }
|
||||
} else {
|
||||
cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/// Restricts a file (or directory) to the current user.
|
||||
pub fn lock_permissions(path: &Path, dir: bool) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(if dir { 0o700 } else { 0o600 }));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = dir;
|
||||
let user = std::env::var("USERNAME").unwrap_or_default();
|
||||
if !user.is_empty() {
|
||||
let _ = Command::new(tool("icacls"))
|
||||
.arg(path)
|
||||
.args(["/inheritance:r", "/grant:r", &format!("{user}:F")])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a URL in the default browser (the fallback when no window host runs).
|
||||
pub fn open_url(url: &str) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = Command::new(tool("open")).arg(url).spawn();
|
||||
#[cfg(windows)]
|
||||
let _ = quiet(&mut Command::new(tool("cmd"))).args(["/c", "start", "", url]).spawn();
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let _ = Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
|
||||
/// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state,
|
||||
/// which must be refreshed (call `keep_awake_tick` every minute).
|
||||
pub struct KeepAwake {
|
||||
#[cfg(target_os = "macos")]
|
||||
child: Option<std::process::Child>,
|
||||
}
|
||||
|
||||
impl KeepAwake {
|
||||
pub fn start() -> KeepAwake {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let child = Command::new(tool("caffeinate")).args(["-dims", "-w", &std::process::id().to_string()]).spawn().ok();
|
||||
KeepAwake { child }
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
keep_awake_tick();
|
||||
KeepAwake {}
|
||||
}
|
||||
}
|
||||
pub fn stop(&mut self) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(c) = self.child.as_mut() {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
const ES_CONTINUOUS: u32 = 0x8000_0000;
|
||||
#[cfg(windows)]
|
||||
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
|
||||
#[cfg(windows)]
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn SetThreadExecutionState(flags: u32) -> u32;
|
||||
}
|
||||
|
||||
pub fn keep_awake_tick() {
|
||||
#[cfg(windows)]
|
||||
unsafe {
|
||||
SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a child to stop. Unix: SIGTERM (the node closes its database cleanly). Windows: TerminateProcess through
|
||||
/// std (what today's launcher does with taskkill /F).
|
||||
pub fn terminate(child: &mut std::process::Child) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// The app bundle on macOS (Igneum Miner.app) when the engine runs from inside one.
|
||||
pub fn bundle_path() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let macos = exe.parent()?;
|
||||
let contents = macos.parent()?;
|
||||
if macos.file_name()? == "MacOS" && contents.file_name()? == "Contents" {
|
||||
contents.parent().map(|p| p.to_path_buf())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What a login item should run: the bundle (macOS) or the window host / the engine (Windows).
|
||||
fn login_command(app: AppId) -> Vec<String> {
|
||||
let _ = app; // macOS runs the bundle by path; Windows names the host executable
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(b) = bundle_path() {
|
||||
return vec!["/usr/bin/open".into(), "-a".into(), b.to_string_lossy().into_owned()];
|
||||
}
|
||||
}
|
||||
let exe = std::env::current_exe().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
let host = dir.join(app.host_exe);
|
||||
if host.exists() {
|
||||
return vec![host.to_string_lossy().into_owned()];
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![exe]
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_agent_path(app: AppId) -> PathBuf {
|
||||
home().join("Library").join("LaunchAgents").join(format!("{}.plist", app.bundle))
|
||||
}
|
||||
|
||||
pub fn set_start_at_login(app: AppId, on: bool) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let path = launch_agent_path(app);
|
||||
if on {
|
||||
let args: String = login_command(app)
|
||||
.iter()
|
||||
.map(|a| format!(" <string>{}</string>\n", a.replace('&', "&").replace('<', "<")))
|
||||
.collect();
|
||||
let plist = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>{}</string>\n <key>ProgramArguments</key>\n <array>\n{args} </array>\n <key>RunAtLoad</key>\n <true/>\n</dict>\n</plist>\n",
|
||||
app.bundle
|
||||
);
|
||||
if let Some(d) = path.parent() {
|
||||
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&path, plist).map_err(|e| e.to_string())?;
|
||||
} else if path.exists() {
|
||||
std::fs::remove_file(&path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
let out = if on {
|
||||
let cmd = login_command(app).iter().map(|a| format!("\"{a}\"")).collect::<Vec<_>>().join(" ");
|
||||
Command::new(tool("reg")).args(["add", key, "/v", app.name, "/t", "REG_SZ", "/d", &cmd, "/f"]).output()
|
||||
} else {
|
||||
Command::new(tool("reg")).args(["delete", key, "/v", app.name, "/f"]).output()
|
||||
};
|
||||
match out {
|
||||
Ok(o) if o.status.success() || !on => Ok(()),
|
||||
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = (app, on);
|
||||
Err("start at login is not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_at_login_is_on(app: AppId) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
launch_agent_path(app).exists()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Command::new(tool("reg"))
|
||||
.args(["query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", app.name])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = app;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the quarantine flag from the app's own files (macOS): after Gatekeeper lets the app through, each binary
|
||||
/// inside would still be checked on its first exec. Best effort; only works on a writable volume.
|
||||
pub fn clear_quarantine() {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(b) = bundle_path() {
|
||||
let _ = Command::new(tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(b.join("Contents")).output();
|
||||
}
|
||||
}
|
||||
|
||||
/// The manual instruction for fixing the clock on this platform.
|
||||
pub fn clock_hint() -> &'static str {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"System Settings > General > Date & Time: turn on \"Set time and date automatically\", or run: sudo sntp -sS time.apple.com"
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"Settings > Time & language > Date & time: turn on \"Set time automatically\" and click \"Sync now\", or run as administrator: w32tm /resync"
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
"run: sudo chronyc makestep, or sudo timedatectl set-ntp true"
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the operating system to set the clock from a time server (an administrator prompt appears). Returns what
|
||||
/// happened, for the window. Blocking; call from a thread.
|
||||
pub fn sync_clock() -> Result<String, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let out = Command::new(tool("osascript"))
|
||||
.args(["-e", "do shell script \"/usr/bin/sntp -sS time.apple.com 2>&1\" with administrator privileges"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
if out.status.success() {
|
||||
Ok(if text.trim().is_empty() { "clock set from time.apple.com".into() } else { text.trim().to_string() })
|
||||
} else if text.contains("canceled") || text.contains("cancelled") {
|
||||
Err("the administrator prompt was cancelled".into())
|
||||
} else {
|
||||
Err(text.trim().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let cmd = tool("cmd").display().to_string();
|
||||
let script = format!("Start-Process -FilePath '{cmd}' -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden");
|
||||
let mut c = Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
|
||||
quiet(&mut c);
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok("asked Windows Time to resync (w32tm /resync)".into())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
for args in [vec!["chronyc", "makestep"], vec!["timedatectl", "set-ntp", "true"]] {
|
||||
if let Ok(out) = Command::new("pkexec").args(&args).output() {
|
||||
if out.status.success() {
|
||||
return Ok(format!("ran {}", args.join(" ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("neither chronyc nor timedatectl could set the clock".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a command line with administrator rights (one prompt): the NVIDIA power cap needs it on Windows.
|
||||
/// Blocking; call from a thread.
|
||||
pub fn run_elevated(cmdline: &str) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let escaped = cmdline.replace('\'', "''");
|
||||
let cmd = tool("cmd").display().to_string();
|
||||
let script = format!("$p = Start-Process -FilePath '{cmd}' -ArgumentList '/c {escaped}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode");
|
||||
let mut c = Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
|
||||
quiet(&mut c);
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.contains("canceled") || err.contains("cancelled") || err.is_empty() { "the administrator prompt was cancelled".into() } else { err })
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let out = Command::new("pkexec").args(["sh", "-c", cmdline]).output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) }
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
{
|
||||
let _ = cmdline;
|
||||
Err("not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a command that runs without a console window on Windows.
|
||||
pub fn quiet(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn token_redaction() {
|
||||
let l = "dashboard at http://127.0.0.1:58776/t/a3a01c537130bceeaa1f6118ba48d63e/ (log x)";
|
||||
assert_eq!(super::redact(l), "dashboard at http://127.0.0.1:58776/t/<token>/ (log x)");
|
||||
assert!(super::carries_token(l));
|
||||
assert!(!super::carries_token("GET /t/short/ nothing"));
|
||||
assert_eq!(super::redact("no token here"), "no token here");
|
||||
}
|
||||
}
|
||||
40
app/igneum-common/src/run.rs
Normal file
40
app/igneum-common/src/run.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! A command with a time limit (app/igneum-app/src/detect.rs `run_timeout`).
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Runs a command with a time limit; returns stdout (and stderr appended) or None.
|
||||
pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option<String> {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
cmd.stdin(if stdin_text.is_some() { Stdio::piped() } else { Stdio::null() });
|
||||
crate::platform::quiet(cmd);
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
if let (Some(text), Some(mut stdin)) = (stdin_text, child.stdin.take()) {
|
||||
let _ = stdin.write_all(text.as_bytes());
|
||||
drop(stdin);
|
||||
}
|
||||
let out = child.stdout.take()?;
|
||||
let err = child.stderr.take()?;
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(out), &mut s);
|
||||
let mut e = String::new();
|
||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(err), &mut e);
|
||||
(s, e)
|
||||
});
|
||||
let deadline = Instant::now() + limit;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (s, e) = reader.join().ok()?;
|
||||
Some(if e.is_empty() { s } else { format!("{s}\n{e}") })
|
||||
}
|
||||
5207
app/igneum-wallet/Cargo.lock
generated
Normal file
5207
app/igneum-wallet/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
41
app/igneum-wallet/Cargo.toml
Normal file
41
app/igneum-wallet/Cargo.toml
Normal file
|
|
@ -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
|
||||
946
app/igneum-wallet/src/engine.rs
Normal file
946
app/igneum-wallet/src/engine.rs
Normal file
|
|
@ -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<State>,
|
||||
pub rings: Mutex<Rings>,
|
||||
#[allow(dead_code)] // read by the window through state; kept for the next settings
|
||||
pub settings: Mutex<Settings>,
|
||||
pub paths: Paths,
|
||||
pub packaged: Packaged,
|
||||
pub machine_id: String,
|
||||
cmd_tx: Mutex<Sender<Cmd>>,
|
||||
pub started: Instant,
|
||||
engine_log: Mutex<Option<std::fs::File>>,
|
||||
#[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<Option<Secret>>,
|
||||
/// words shown on the create screen, waiting for the three-word confirmation
|
||||
pending_words: Mutex<Option<(String, String)>>, // (words, password)
|
||||
pub source: Mutex<Source>,
|
||||
pub evm: Mutex<Option<crate::evm::Evm>>,
|
||||
pub verified: Mutex<Option<VerifiedCheckpoint>>,
|
||||
pub history: Mutex<Option<History>>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub fn new(token: String, paths: Paths, packaged: Packaged, settings: Settings, machine_id: String, cmd_tx: Sender<Cmd>, engine_log: Option<std::fs::File>, 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=<v> machine=<id8> platform=<os> node=<source>: 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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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::<Vec<_>>()), "private_key": s.private_key, "address": v.address, "display": keys::checksum(&v.address) }))
|
||||
}
|
||||
|
||||
pub fn mark_backed_up(&self) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<crate::evm::Evm, String> {
|
||||
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<Quote, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Shared>,
|
||||
rx: Receiver<Cmd>,
|
||||
wrapper: bool,
|
||||
grpc: Option<Grpc>,
|
||||
own: Option<OwnNode>,
|
||||
own_plan: Option<crate::node::OwnNodePlan>,
|
||||
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<Entry>,
|
||||
scan_done_once: bool,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn new(shared: Arc<Shared>, rx: Receiver<Cmd>, 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<Result<VerifiedCheckpoint, (u64, String)>> = {
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
147
app/igneum-wallet/src/evm.rs
Normal file
147
app/igneum-wallet/src/evm.rs
Normal file
|
|
@ -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<u128> {
|
||||
let s = v.as_str()?;
|
||||
u128::from_str_radix(s.trim_start_matches("0x"), 16).ok()
|
||||
}
|
||||
|
||||
pub fn hexq(v: u128) -> String {
|
||||
format!("0x{v:x}")
|
||||
}
|
||||
|
||||
impl Evm {
|
||||
pub fn local(port: u16) -> Evm {
|
||||
Evm { endpoint: format!("127.0.0.1:{port}") }
|
||||
}
|
||||
|
||||
pub fn call(&self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||
let text = if self.endpoint.starts_with("https://") || self.endpoint.starts_with("http://") {
|
||||
let mut c = std::process::Command::new(igneum_common::platform::tool("curl"));
|
||||
c.args(["-sS", "--max-time", "20", "-X", "POST", &self.endpoint, "-H", "Content-Type: application/json", "-d", &body]);
|
||||
igneum_common::run::run_timeout(&mut c, None, Duration::from_secs(25)).ok_or("curl is not available")?
|
||||
} else {
|
||||
igneum_common::http::post_json(&self.endpoint, "/", &body, Duration::from_secs(20))?
|
||||
};
|
||||
let v: Value = serde_json::from_str(text.trim()).map_err(|_| format!("{method}: not JSON: {}", text.chars().take(120).collect::<String>()))?;
|
||||
if let Some(e) = v.get("error") {
|
||||
let msg = e.get("message").and_then(|m| m.as_str()).unwrap_or("error");
|
||||
return Err(format!("{method}: {msg}"));
|
||||
}
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn chain_id(&self) -> Result<u64, String> {
|
||||
q(&self.call("eth_chainId", json!([]))?).map(|v| v as u64).ok_or("eth_chainId: no number".into())
|
||||
}
|
||||
pub fn block_number(&self) -> Result<u64, String> {
|
||||
q(&self.call("eth_blockNumber", json!([]))?).map(|v| v as u64).ok_or("eth_blockNumber: no number".into())
|
||||
}
|
||||
pub fn balance(&self, address: &str) -> Result<u128, String> {
|
||||
q(&self.call("eth_getBalance", json!([address, "latest"]))?).ok_or("eth_getBalance: no number".into())
|
||||
}
|
||||
pub fn nonce(&self, address: &str) -> Result<u64, String> {
|
||||
q(&self.call("eth_getTransactionCount", json!([address, "pending"]))?).map(|v| v as u64).ok_or("nonce: no number".into())
|
||||
}
|
||||
/// (base fee per gas of the latest block, the node's suggested priority fee)
|
||||
pub fn fees(&self) -> Result<(u128, u128), String> {
|
||||
let b = self.call("eth_getBlockByNumber", json!(["latest", false]))?;
|
||||
let base = b.get("baseFeePerGas").and_then(q).unwrap_or(0);
|
||||
let tip = self.call("eth_maxPriorityFeePerGas", json!([])).ok().and_then(|v| q(&v)).unwrap_or(1_000_000_000);
|
||||
Ok((base, tip))
|
||||
}
|
||||
pub fn estimate_gas(&self, from: &str, to: &str, value: u128) -> Result<u64, String> {
|
||||
q(&self.call("eth_estimateGas", json!([{ "from": from, "to": to, "value": hexq(value) }]))?).map(|v| v as u64).ok_or("eth_estimateGas: no number".into())
|
||||
}
|
||||
pub fn send_raw(&self, raw: &[u8]) -> Result<String, String> {
|
||||
let v = self.call("eth_sendRawTransaction", json!([crate::tx::hex0x(raw)]))?;
|
||||
v.as_str().map(|s| s.to_string()).ok_or("eth_sendRawTransaction: no hash".into())
|
||||
}
|
||||
pub fn receipt(&self, hash: &str) -> Result<Option<Value>, String> {
|
||||
let v = self.call("eth_getTransactionReceipt", json!([hash]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
pub fn tx(&self, hash: &str) -> Result<Option<Value>, String> {
|
||||
let v = self.call("eth_getTransactionByHash", json!([hash]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
pub fn block(&self, number: u64, full: bool) -> Result<Option<Value>, String> {
|
||||
let v = self.call("eth_getBlockByNumber", json!([hexq(number as u128), full]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
pub fn block_by_hash(&self, hash: &str) -> Result<Option<Value>, String> {
|
||||
let v = self.call("eth_getBlockByHash", json!([hash, false]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
/// igneum_getSegment: the chain block's execution record (rewards per blue block's miner, proving pool credit).
|
||||
pub fn segment(&self, number: u64) -> Result<Option<Value>, String> {
|
||||
let v = self.call("igneum_getSegment", json!([hexq(number as u128)]))?;
|
||||
Ok(if v.is_null() { None } else { Some(v) })
|
||||
}
|
||||
pub fn tx_status(&self, hash: &str) -> Result<Value, String> {
|
||||
self.call("igneum_getTransactionStatus", json!([hash]))
|
||||
}
|
||||
}
|
||||
|
||||
/// wei to a decimal IGN string with up to `places` decimals, trailing zeros trimmed (never scientific, never rounded up).
|
||||
pub fn ign(wei: u128, places: usize) -> String {
|
||||
let whole = wei / 1_000_000_000_000_000_000;
|
||||
let frac = wei % 1_000_000_000_000_000_000;
|
||||
let mut f = format!("{frac:018}");
|
||||
f.truncate(places);
|
||||
let f = f.trim_end_matches('0');
|
||||
if f.is_empty() { format!("{whole}") } else { format!("{whole}.{f}") }
|
||||
}
|
||||
|
||||
/// A typed amount ("1.5", "0.001", "12") to wei; refuses more than 18 decimals and anything that is not a number.
|
||||
pub fn parse_ign(text: &str) -> Result<u128, String> {
|
||||
let t = text.trim().replace(',', "");
|
||||
if t.is_empty() {
|
||||
return Err("type an amount".into());
|
||||
}
|
||||
let (w, f) = match t.split_once('.') {
|
||||
Some((w, f)) => (w, f),
|
||||
None => (t.as_str(), ""),
|
||||
};
|
||||
if !w.chars().all(|c| c.is_ascii_digit()) || !f.chars().all(|c| c.is_ascii_digit()) || (w.is_empty() && f.is_empty()) {
|
||||
return Err("an amount is digits with one dot".into());
|
||||
}
|
||||
if f.len() > 18 {
|
||||
return Err("at most 18 decimals".into());
|
||||
}
|
||||
let whole: u128 = if w.is_empty() { 0 } else { w.parse().map_err(|_| "amount too large")? };
|
||||
let mut frac = f.to_string();
|
||||
while frac.len() < 18 {
|
||||
frac.push('0');
|
||||
}
|
||||
let frac: u128 = if frac.is_empty() { 0 } else { frac.parse().map_err(|_| "amount")? };
|
||||
whole.checked_mul(1_000_000_000_000_000_000).and_then(|v| v.checked_add(frac)).ok_or("amount too large".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn amounts() {
|
||||
assert_eq!(parse_ign("1.5").unwrap(), 1_500_000_000_000_000_000);
|
||||
assert_eq!(parse_ign("0.000000000000000001").unwrap(), 1);
|
||||
assert_eq!(parse_ign("12").unwrap(), 12_000_000_000_000_000_000);
|
||||
assert!(parse_ign("1e3").is_err());
|
||||
assert!(parse_ign("0.0000000000000000001").is_err());
|
||||
assert_eq!(ign(1_500_000_000_000_000_000, 6), "1.5");
|
||||
assert_eq!(ign(1, 18), "0.000000000000000001");
|
||||
assert_eq!(ign(1, 6), "0");
|
||||
assert_eq!(ign(42_000_000_000_000_000_000, 6), "42");
|
||||
assert_eq!(q(&json!("0x116f")), Some(4463));
|
||||
}
|
||||
}
|
||||
227
app/igneum-wallet/src/finality.rs
Normal file
227
app/igneum-wallet/src/finality.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
//! Finality the wallet checks itself. A transaction is "final" only when its block sits under a certified checkpoint
|
||||
//! whose BLS certificate this wallet verified with the node's own code (kaspa_consensus_core::finality), never from a
|
||||
//! confirmation count and never on the node's word alone. The steps are the browser verifier's (site/verify/core.js):
|
||||
//! 1. the certificate as a block carried it (the coinbase finality section of the blocks after the checkpoint)
|
||||
//! 2. the canonical voter list: every key above dust and not stripped, sorted by key hash, 48-byte G1 keys that
|
||||
//! hash (BLAKE2b-256 keyed "IgneumVoteKeyHash") to the key hashes the node names, as many as the certificate says
|
||||
//! 3. the aggregate G2 signature over `igneum-vote-v1/<chain id> 0x00 index_le64 checkpoint` by the bitmap's keys
|
||||
//! 4. the rule: signed weight at least 2/3 of the active weight and at least 2/3 of the total weight (the floor
|
||||
//! decided 4 October 2026, O-3.15; stricter than the 17/30 this node line still carries)
|
||||
//! Then "under": the checkpoint block's selected-chain height from the Ethereum RPC; a transaction's block is under
|
||||
//! it when its number is at most that height and its hash is still the chain's hash at that number.
|
||||
|
||||
use kaspa_consensus_core::finality::{block_finality_content, verify_aggregate, vote_key_hash, Certificate, FinalityItem, PUBKEY_LEN};
|
||||
use kaspa_hashes::Hash;
|
||||
use kaspa_rpc_core::model::{GetFinalityWeightsResponse, RpcCheckpoint, RpcKeyWeight};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct VerifiedCheckpoint {
|
||||
pub index: u64,
|
||||
pub hash: String,
|
||||
/// the checkpoint block's selected-chain height on the execution side (None until the Ethereum RPC names it)
|
||||
pub chain_number: Option<u64>,
|
||||
pub signers: usize,
|
||||
pub voters: usize,
|
||||
pub signed_weight: u64,
|
||||
pub total_weight: u64,
|
||||
pub active_weight: f64,
|
||||
pub fraction_total: f64,
|
||||
pub fraction_active: f64,
|
||||
/// the checkpoint index the node's weight table was taken at (equal to `index` when exact)
|
||||
pub weights_at_index: u64,
|
||||
pub carrier: String,
|
||||
pub verified_at: f64,
|
||||
}
|
||||
|
||||
/// What the window shows for one transaction.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
/// not in any block the node knows
|
||||
Pending,
|
||||
/// in chain block `number`; no verified checkpoint covers it yet
|
||||
InBlock { number: u64 },
|
||||
/// under verified checkpoint `index`
|
||||
Final { number: u64, index: u64 },
|
||||
/// the receipt says the execution failed; still subject to the same finality
|
||||
Failed { number: u64, reason: String },
|
||||
}
|
||||
|
||||
/// The state machine, pure: receipt (block number, block hash), the chain's hash at that number now, the newest
|
||||
/// verified checkpoint. Tested below.
|
||||
pub fn status(receipt: Option<(u64, &str, bool)>, canonical_hash: Option<&str>, verified: Option<&VerifiedCheckpoint>) -> Status {
|
||||
let Some((number, hash, ok)) = receipt else { return Status::Pending };
|
||||
// the block the receipt names must still be the chain's block at that height
|
||||
match canonical_hash {
|
||||
Some(h) if h.eq_ignore_ascii_case(hash) => {}
|
||||
_ => return Status::Pending,
|
||||
}
|
||||
if !ok {
|
||||
return Status::Failed { number, reason: "the execution failed (reverted or out of gas)".into() };
|
||||
}
|
||||
match verified.and_then(|v| v.chain_number.map(|n| (n, v.index))) {
|
||||
Some((cp_number, index)) if number <= cp_number => Status::Final { number, index },
|
||||
_ => Status::InBlock { number },
|
||||
}
|
||||
}
|
||||
|
||||
/// The certificate for `cp` as a block after it carried it, scanning up to `pages` pages of getBlocks.
|
||||
pub fn find_certificate(grpc: &crate::node::Grpc, cp: &RpcCheckpoint, pages: usize) -> Result<(Certificate, String), String> {
|
||||
let mut low: Hash = cp.hash;
|
||||
let mut seen = 0usize;
|
||||
for _ in 0..pages {
|
||||
let blocks = grpc.blocks_after(low)?;
|
||||
if blocks.is_empty() {
|
||||
break;
|
||||
}
|
||||
for b in &blocks {
|
||||
seen += 1;
|
||||
if let Some(tx) = b.transactions.first() {
|
||||
let (_, items) = block_finality_content(&tx.payload);
|
||||
for it in items {
|
||||
if let FinalityItem::Certificate(c) = it {
|
||||
if c.index == cp.index && c.checkpoint == cp.hash {
|
||||
return Ok((c, b.header.hash.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let last = blocks.last().unwrap().header.hash;
|
||||
if last == low {
|
||||
break;
|
||||
}
|
||||
low = last;
|
||||
}
|
||||
Err(format!("no block within {seen} of checkpoint {} carries its certificate yet", cp.index))
|
||||
}
|
||||
|
||||
/// Steps 2 to 4 over a certificate and the node's weight table.
|
||||
pub fn verify(chain_id: &str, cp: &RpcCheckpoint, cert: &Certificate, carrier: &str, w: &GetFinalityWeightsResponse) -> Result<VerifiedCheckpoint, String> {
|
||||
let mut voters: Vec<&RpcKeyWeight> = w.keys.iter().filter(|k| k.voter).collect();
|
||||
voters.sort_by(|a, b| a.key_hash.cmp(&b.key_hash));
|
||||
if voters.len() != cert.voter_count as usize {
|
||||
return Err(format!("certificate names {} voters, the node's table at checkpoint {} has {}", cert.voter_count, w.checkpoint_index, voters.len()));
|
||||
}
|
||||
let mut pubkeys: Vec<[u8; PUBKEY_LEN]> = Vec::with_capacity(voters.len());
|
||||
for (i, v) in voters.iter().enumerate() {
|
||||
let raw = igneum_common::keys::unhex(&v.pubkey).ok_or(format!("voter {i} key is not hex"))?;
|
||||
let pk: [u8; PUBKEY_LEN] = raw.try_into().map_err(|_| format!("voter {i} key is not 48 bytes"))?;
|
||||
if vote_key_hash(&pk) != v.key_hash {
|
||||
return Err(format!("voter {i} key does not hash to its key hash"));
|
||||
}
|
||||
pubkeys.push(pk);
|
||||
}
|
||||
let positions = cert.signer_positions();
|
||||
if positions.is_empty() {
|
||||
return Err("the certificate has no signers".into());
|
||||
}
|
||||
let signing: Vec<[u8; PUBKEY_LEN]> = positions.iter().map(|&p| pubkeys[p]).collect();
|
||||
if !verify_aggregate(chain_id, cert.index, cert.checkpoint, &signing, &cert.signature) {
|
||||
return Err("the aggregate signature does not verify".into());
|
||||
}
|
||||
let total: u64 = voters.iter().map(|v| v.blocks).sum();
|
||||
let active: f64 = voters.iter().map(|v| v.blocks as f64 * v.participation).sum();
|
||||
let signed: u64 = positions.iter().map(|&p| voters[p].blocks).sum();
|
||||
if total == 0 {
|
||||
return Err("total weight is zero".into());
|
||||
}
|
||||
let fraction_total = signed as f64 / total as f64;
|
||||
let fraction_active = if active > 0.0 { signed as f64 / active } else { 0.0 };
|
||||
if (3 * signed as u128) < (2 * active.round() as u128) {
|
||||
return Err(format!("signed weight is {:.1}% of active, below 2/3", fraction_active * 100.0));
|
||||
}
|
||||
if 3 * (signed as u128) < 2 * (total as u128) {
|
||||
return Err(format!("signed weight is {:.1}% of total, below 2/3", fraction_total * 100.0));
|
||||
}
|
||||
Ok(VerifiedCheckpoint {
|
||||
index: cp.index,
|
||||
hash: cp.hash.to_string(),
|
||||
chain_number: None,
|
||||
signers: positions.len(),
|
||||
voters: voters.len(),
|
||||
signed_weight: signed,
|
||||
total_weight: total,
|
||||
active_weight: active,
|
||||
fraction_total,
|
||||
fraction_active,
|
||||
weights_at_index: w.checkpoint_index,
|
||||
carrier: carrier.to_string(),
|
||||
verified_at: igneum_common::platform::unix_now_f(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cp(chain_number: Option<u64>) -> VerifiedCheckpoint {
|
||||
VerifiedCheckpoint { index: 536, hash: "ac08".into(), chain_number, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine() {
|
||||
// no receipt: pending, whatever the checkpoint says
|
||||
assert_eq!(status(None, None, Some(&cp(Some(100)))), Status::Pending);
|
||||
// in a block, no verified checkpoint
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), None), Status::InBlock { number: 10 });
|
||||
// the checkpoint is below the block: still in a block
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(Some(9)))), Status::InBlock { number: 10 });
|
||||
// the checkpoint's chain height is unknown yet
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(None))), Status::InBlock { number: 10 });
|
||||
// under the checkpoint: final, with the index
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xaa"), Some(&cp(Some(10)))), Status::Final { number: 10, index: 536 });
|
||||
assert_eq!(status(Some((3, "0xaa", true)), Some("0xAA"), Some(&cp(Some(10)))), Status::Final { number: 3, index: 536 });
|
||||
// the chain moved away from the receipt's block: back to pending
|
||||
assert_eq!(status(Some((10, "0xaa", true)), Some("0xbb"), Some(&cp(Some(10)))), Status::Pending);
|
||||
assert_eq!(status(Some((10, "0xaa", true)), None, Some(&cp(Some(10)))), Status::Pending);
|
||||
// a failed execution is reported as failed, never final
|
||||
assert!(matches!(status(Some((10, "0xaa", false)), Some("0xaa"), Some(&cp(Some(10)))), Status::Failed { number: 10, .. }));
|
||||
}
|
||||
|
||||
/// A certificate made with real BLS keys verifies; a flipped bit, a missing voter or a thin quorum does not.
|
||||
#[test]
|
||||
fn certificate_rule() {
|
||||
use kaspa_consensus_core::finality::{aggregate_signatures, VoteSecretKey};
|
||||
use kaspa_rpc_core::model::RpcFinalityParams;
|
||||
let chain = "igneum-devnet-955";
|
||||
let checkpoint = Hash::from_slice(&[7u8; 32]);
|
||||
let keys: Vec<VoteSecretKey> = (0..3).map(|i| VoteSecretKey::from_label(&format!("wallet-test-{i}"))).collect();
|
||||
let mut table: Vec<RpcKeyWeight> = keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, k)| RpcKeyWeight { key_hash: k.key_hash(), pubkey: igneum_common::keys::hex(&k.public_key()), blocks: [50, 30, 20][i], voter: true, participation: 1.0, stripped_until_daa: 0 })
|
||||
.collect();
|
||||
table.sort_by(|a, b| a.key_hash.cmp(&b.key_hash));
|
||||
let by_hash = |h: Hash| keys.iter().find(|k| k.key_hash() == h).unwrap();
|
||||
// the two heaviest keys sign (80 of 100)
|
||||
let mut signers: Vec<usize> = (0..3).filter(|&p| table[p].blocks >= 30).collect();
|
||||
signers.sort();
|
||||
let sigs: Vec<[u8; 96]> = signers.iter().map(|&p| by_hash(table[p].key_hash).sign_vote(chain, 536, checkpoint)).collect();
|
||||
let agg = aggregate_signatures(&sigs).unwrap();
|
||||
let cert = Certificate { index: 536, checkpoint, voter_count: 3, bitmap: Certificate::bitmap_from_positions(3, &signers), signature: agg, aggregator: Hash::from_slice(&[0u8; 32]), aggregator_proof: [0u8; 96] };
|
||||
let rcp = RpcCheckpoint { index: 536, hash: checkpoint, blue_score: 0, daa_score: 0, state: "locked".into(), signed_weight: 80, active_weight: 100, total_weight: 100, fraction_active: 0.8, fraction_total: 0.8, votes_seen: 2, voters: 3, aggregators: vec![], locked_at_daa: 1, certificate_aggregator: Hash::from_slice(&[0u8; 32]) };
|
||||
let params = RpcFinalityParams { checkpoint_interval: 30, checkpoint_depth: 20, weight_window: 120, dust: 5, presence_window: 1, aggregators: 8, equivocation_ban: 120, min_daa: 120 };
|
||||
let w = GetFinalityWeightsResponse { params, checkpoint_index: 536, checkpoint_hash: checkpoint, daa_score: 0, total_weight: 100, active_weight: 100.0, voters: 3, keys: table.clone() };
|
||||
let v = verify(chain, &rcp, &cert, "carrier", &w).unwrap();
|
||||
assert_eq!(v.signers, 2);
|
||||
assert_eq!(v.signed_weight, 80);
|
||||
assert!((v.fraction_total - 0.8).abs() < 1e-9);
|
||||
// wrong chain id: the message differs
|
||||
assert!(verify("igneum-devnet-1", &rcp, &cert, "c", &w).is_err());
|
||||
// a flipped signature byte
|
||||
let mut bad = cert.clone();
|
||||
bad.signature[5] ^= 1;
|
||||
assert!(verify(chain, &rcp, &bad, "c", &w).unwrap_err().contains("aggregate signature"));
|
||||
// only the lightest key signs: 20 of 100, below 2/3
|
||||
let p = (0..3).find(|&p| table[p].blocks == 20).unwrap();
|
||||
let s = by_hash(table[p].key_hash).sign_vote(chain, 536, checkpoint);
|
||||
let thin = Certificate { bitmap: Certificate::bitmap_from_positions(3, &[p]), signature: aggregate_signatures(&[s]).unwrap(), ..cert.clone() };
|
||||
assert!(verify(chain, &rcp, &thin, "c", &w).unwrap_err().contains("below 2/3"));
|
||||
// a voter list that does not match the certificate's count
|
||||
let mut w2 = w.clone();
|
||||
w2.keys.pop();
|
||||
assert!(verify(chain, &rcp, &cert, "c", &w2).unwrap_err().contains("voters"));
|
||||
}
|
||||
}
|
||||
99
app/igneum-wallet/src/hd.rs
Normal file
99
app/igneum-wallet/src/hd.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! Keys from words: BIP-39 (24 English words, 256 bits of entropy from the OS) and BIP-32/44 at m/44'/60'/0'/0/0, the
|
||||
//! Ethereum path, so the same words open the same account in MetaMask. A raw private key import skips the words.
|
||||
|
||||
use bip32::{DerivationPath, XPrv};
|
||||
use bip39::{Language, Mnemonic};
|
||||
use igneum_common::keys;
|
||||
|
||||
pub const PATH: &str = "m/44'/60'/0'/0/0";
|
||||
|
||||
pub struct Derived {
|
||||
pub private_key: String, // 0x + 64 hex
|
||||
pub address: String, // 0x + 40 lower-case hex
|
||||
}
|
||||
|
||||
/// 24 new words from 256 bits of OS randomness.
|
||||
pub fn new_words() -> Result<String, String> {
|
||||
let mut entropy = [0u8; 32];
|
||||
getrandom::getrandom(&mut entropy).map_err(|e| e.to_string())?;
|
||||
let m = Mnemonic::from_entropy_in(Language::English, &entropy).map_err(|e| e.to_string())?;
|
||||
Ok(m.words().collect::<Vec<_>>().join(" "))
|
||||
}
|
||||
|
||||
/// Normalises a typed phrase: lower case, single spaces. Accepts 12, 15, 18, 21 or 24 words; checks the checksum.
|
||||
pub fn parse_words(text: &str) -> Result<String, String> {
|
||||
let words: Vec<String> = text.split_whitespace().map(|w| w.to_lowercase()).collect();
|
||||
if ![12, 15, 18, 21, 24].contains(&words.len()) {
|
||||
return Err(format!("{} words: a phrase has 12 or 24 words", words.len()));
|
||||
}
|
||||
let joined = words.join(" ");
|
||||
Mnemonic::parse_in_normalized(Language::English, &joined).map_err(|e| match e {
|
||||
bip39::Error::UnknownWord(i) => format!("word {} is not in the word list: {}", i + 1, words[i]),
|
||||
bip39::Error::InvalidChecksum => "the words do not check out (one is wrong or out of order)".to_string(),
|
||||
other => other.to_string(),
|
||||
})?;
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
/// The Ethereum account at m/44'/60'/0'/0/0 of a phrase (no BIP-39 passphrase).
|
||||
pub fn derive(words: &str) -> Result<Derived, String> {
|
||||
let m = Mnemonic::parse_in_normalized(Language::English, words).map_err(|e| e.to_string())?;
|
||||
let seed = m.to_seed("");
|
||||
let path: DerivationPath = PATH.parse().map_err(|_| "bad path".to_string())?;
|
||||
let xprv = XPrv::derive_from_path(seed, &path).map_err(|e| e.to_string())?;
|
||||
let raw: [u8; 32] = xprv.private_key().to_bytes().into();
|
||||
let address = keys::address_of_raw(&raw).ok_or("the derived key is invalid")?;
|
||||
Ok(Derived { private_key: format!("0x{}", keys::hex(&raw)), address })
|
||||
}
|
||||
|
||||
/// A raw private key, typed or pasted: 64 hex with or without 0x.
|
||||
pub fn parse_private_key(text: &str) -> Result<Derived, String> {
|
||||
let raw = keys::unhex(text).ok_or("a private key is 64 hex characters")?;
|
||||
if raw.len() != 32 {
|
||||
return Err(format!("a private key is 32 bytes, this is {}", raw.len()));
|
||||
}
|
||||
let arr: [u8; 32] = raw.try_into().unwrap();
|
||||
let address = keys::address_of_raw(&arr).ok_or("this is not a valid secp256k1 key")?;
|
||||
Ok(Derived { private_key: format!("0x{}", keys::hex(&arr)), address })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The reference phrase every Ethereum tool ships with (Hardhat account 0).
|
||||
#[test]
|
||||
fn hardhat_vector() {
|
||||
let d = derive("test test test test test test test test test test test junk").unwrap();
|
||||
assert_eq!(d.private_key, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
|
||||
assert_eq!(keys::checksum(&d.address), "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
|
||||
}
|
||||
|
||||
/// BIP-39 vector 1 (entropy all zero): the words and, with the TREZOR passphrase, the published seed.
|
||||
#[test]
|
||||
fn bip39_vector_one() {
|
||||
let m = Mnemonic::from_entropy_in(Language::English, &[0u8; 16]).unwrap();
|
||||
assert_eq!(m.to_string(), "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about");
|
||||
let seed = m.to_seed("TREZOR");
|
||||
assert_eq!(keys::hex(&seed), "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_are_24_and_parse() {
|
||||
let w = new_words().unwrap();
|
||||
assert_eq!(w.split(' ').count(), 24);
|
||||
assert_eq!(parse_words(&w.to_uppercase()).unwrap(), w);
|
||||
let mut broken: Vec<&str> = w.split(' ').collect();
|
||||
broken[0] = "zzzz";
|
||||
assert!(parse_words(&broken.join(" ")).unwrap_err().contains("word 1"));
|
||||
assert!(parse_words("abandon abandon").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_key_import() {
|
||||
let d = parse_private_key("0000000000000000000000000000000000000000000000000000000000000001").unwrap();
|
||||
assert_eq!(keys::checksum(&d.address), "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
|
||||
assert!(parse_private_key("0x01").is_err());
|
||||
assert!(parse_private_key(&"ff".repeat(32)).is_err()); // above the curve order
|
||||
}
|
||||
}
|
||||
137
app/igneum-wallet/src/history.rs
Normal file
137
app/igneum-wallet/src/history.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! What happened to this address: transfers in and out from the chain's blocks, block rewards from the execution
|
||||
//! records (igneum_getSegment: one reward per blue block, paid to the miner the block named), proving payouts when a
|
||||
//! segment carries a proof record (none on this node line yet; the label is ready). Scanned forward from the last
|
||||
//! block seen and kept in `<data>/wallet/history-<chain id>-<address>.json`.
|
||||
|
||||
use crate::evm::{q, Evm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
pub hash: String,
|
||||
/// sent | received | self | reward | proving
|
||||
pub kind: String,
|
||||
pub block: u64,
|
||||
pub block_hash: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// wei, as a decimal string (u128 is wider than JSON numbers)
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub fee: String,
|
||||
pub ok: bool,
|
||||
pub time: u64,
|
||||
/// pending | in_block | final | failed, with the checkpoint index when final
|
||||
#[serde(default)]
|
||||
pub finality: String,
|
||||
#[serde(default)]
|
||||
pub checkpoint: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct History {
|
||||
pub chain_id: u64,
|
||||
pub address: String,
|
||||
/// every block up to and including this one was read
|
||||
pub scanned_to: Option<u64>,
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
pub fn load(path: &Path, chain_id: u64, address: &str) -> History {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<History>(&t).ok())
|
||||
.filter(|h| h.chain_id == chain_id && h.address.eq_ignore_ascii_case(address))
|
||||
.unwrap_or(History { chain_id, address: address.to_ascii_lowercase(), scanned_to: None, entries: vec![] })
|
||||
}
|
||||
pub fn save(&self, path: &Path) {
|
||||
if let Some(d) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
if let Ok(t) = serde_json::to_string(self) {
|
||||
let _ = std::fs::write(path, t);
|
||||
igneum_common::platform::lock_permissions(path, false);
|
||||
}
|
||||
}
|
||||
pub fn has(&self, hash: &str) -> bool {
|
||||
self.entries.iter().any(|e| e.hash.eq_ignore_ascii_case(hash))
|
||||
}
|
||||
/// Adds or replaces by hash, newest block first.
|
||||
pub fn put(&mut self, e: Entry) {
|
||||
self.entries.retain(|x| !x.hash.eq_ignore_ascii_case(&e.hash));
|
||||
self.entries.push(e);
|
||||
self.entries.sort_by(|a, b| b.block.cmp(&a.block).then(b.time.cmp(&a.time)));
|
||||
if self.entries.len() > 2000 {
|
||||
self.entries.truncate(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn s(v: &Value, k: &str) -> String {
|
||||
v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// Reads blocks `from..=to` and returns the entries that touch `me` (lower-case 0x address).
|
||||
pub fn scan(evm: &Evm, me: &str, from: u64, to: u64) -> Result<Vec<Entry>, String> {
|
||||
let mut out = Vec::new();
|
||||
for n in from..=to {
|
||||
let Some(b) = evm.block(n, true)? else { break };
|
||||
let bh = s(&b, "hash");
|
||||
let time = b.get("timestamp").and_then(q).unwrap_or(0) as u64;
|
||||
if let Some(txs) = b.get("transactions").and_then(|t| t.as_array()) {
|
||||
for t in txs {
|
||||
let from = s(t, "from").to_ascii_lowercase();
|
||||
let to = s(t, "to").to_ascii_lowercase();
|
||||
if from != me && to != me {
|
||||
continue;
|
||||
}
|
||||
let hash = s(t, "hash");
|
||||
let value = t.get("value").and_then(q).unwrap_or(0);
|
||||
let kind = if from == me && to == me { "self" } else if from == me { "sent" } else { "received" };
|
||||
// the receipt: status and the fee actually paid
|
||||
let (ok, fee) = match evm.receipt(&hash)? {
|
||||
Some(r) => {
|
||||
let ok = r.get("status").and_then(q).unwrap_or(1) == 1;
|
||||
let gas = r.get("gasUsed").and_then(q).unwrap_or(0);
|
||||
let price = r.get("effectiveGasPrice").and_then(q).unwrap_or(0);
|
||||
(ok, gas * price)
|
||||
}
|
||||
None => (true, 0),
|
||||
};
|
||||
out.push(Entry { hash, kind: kind.into(), block: n, block_hash: bh.clone(), from, to, value: value.to_string(), fee: fee.to_string(), ok, time, finality: "in_block".into(), checkpoint: None, note: String::new() });
|
||||
}
|
||||
}
|
||||
// rewards: the segment names every blue block's miner and what it was paid
|
||||
if let Some(seg) = evm.segment(n)? {
|
||||
if let Some(rs) = seg.get("rewards").and_then(|r| r.as_array()) {
|
||||
for (i, r) in rs.iter().enumerate() {
|
||||
let miner = s(r, "miner").to_ascii_lowercase();
|
||||
if miner != me {
|
||||
continue;
|
||||
}
|
||||
let wei = r.get("wei").and_then(q).unwrap_or(0);
|
||||
if wei == 0 {
|
||||
continue;
|
||||
}
|
||||
out.push(Entry { hash: format!("reward-{n}-{i}"), kind: "reward".into(), block: n, block_hash: bh.clone(), from: String::new(), to: me.to_string(), value: wei.to_string(), fee: "0".into(), ok: true, time, finality: "in_block".into(), checkpoint: None, note: "block reward".into() });
|
||||
}
|
||||
}
|
||||
if let Some(pr) = seg.get("proofRecord").filter(|v| !v.is_null()) {
|
||||
if let Some(ps) = pr.get("payouts").and_then(|p| p.as_array()) {
|
||||
for (i, p) in ps.iter().enumerate() {
|
||||
if s(p, "address").eq_ignore_ascii_case(me) {
|
||||
let wei = p.get("wei").and_then(q).unwrap_or(0);
|
||||
out.push(Entry { hash: format!("proving-{n}-{i}"), kind: "proving".into(), block: n, block_hash: bh.clone(), from: String::new(), to: me.to_string(), value: wei.to_string(), fee: "0".into(), ok: true, time, finality: "in_block".into(), checkpoint: None, note: "shard payout".into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
118
app/igneum-wallet/src/main.rs
Normal file
118
app/igneum-wallet/src/main.rs
Normal file
|
|
@ -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:<random port>/t/<token>/. The window host (macOS: app/mac/IgneumWallet.swift, Windows: the WebView2
|
||||
//! host) starts it with --wrapper, reads `URL ...` and `STATE {...}` lines from its stdout and writes `quit` on its
|
||||
//! stdin. Without a host (--open) the window opens in the default browser.
|
||||
//!
|
||||
//! 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<String> = std::env::args().skip(1).collect();
|
||||
let wrapper = args.iter().any(|a| a == "--wrapper");
|
||||
let no_open = wrapper || args.iter().any(|a| a == "--no-open");
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("igneum-wallet {}", engine::VERSION);
|
||||
return;
|
||||
}
|
||||
if args.iter().any(|a| a == "--launch") {
|
||||
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
let host = dir.join(engine::APP.host_exe);
|
||||
if host.exists() {
|
||||
let mut c = std::process::Command::new(&host);
|
||||
c.current_dir(&dir);
|
||||
if c.spawn().is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
platform::clear_quarantine();
|
||||
let root = platform::data_root();
|
||||
let app_dir = root.join(engine::APP.data_sub);
|
||||
let log_dir = platform::log_root();
|
||||
let _ = std::fs::create_dir_all(&app_dir);
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
platform::lock_permissions(&app_dir, true);
|
||||
let paths = engine::Paths {
|
||||
vault: app_dir.join("vault.json"),
|
||||
settings: app_dir.join("settings.json"),
|
||||
miner_wallet: root.join(igneum_common::MINER.data_sub).join("wallet.json"),
|
||||
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)
|
||||
}
|
||||
223
app/igneum-wallet/src/node.rs
Normal file
223
app/igneum-wallet/src/node.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! Where the chain comes from, in this order:
|
||||
//! 1. the miner app's node on this machine (gRPC 127.0.0.1:26610, Ethereum RPC 26790): used as it is, nothing started
|
||||
//! 2. the seed's public Ethereum RPC when the package names one (`public_rpc` in igneum-wallet.json): balances,
|
||||
//! sending and history work; finality verification needs a node's gRPC and is reported as "no node", so
|
||||
//! transactions stay "in a block" until a node is there
|
||||
//! 3. the bundled igneumd next to the app, started by the wallet on its own ports (gRPC 26620, Ethereum 26800, p2p
|
||||
//! 26621) with the same arguments the miner uses, no mining, stopped when the wallet quits
|
||||
//! The choice is made at start and whenever the source goes away; `State.node.source` says which.
|
||||
//! Environment (tests): IGNEUM_WALLET_GRPC_PORT, IGNEUM_WALLET_EVM_PORT (an external node to use, no probe of the
|
||||
//! miner's ports), IGNEUM_WALLET_NO_NODE=1 (never start one), IGNEUM_WALLET_NETWORK, IGNEUM_WALLET_DEVNET_SUFFIX,
|
||||
//! IGNEUM_WALLET_PEERS, IGNEUM_WALLET_NODE_DIR, IGNEUM_WALLET_OVERRIDE_PARAMS (a file for --override-params-file).
|
||||
|
||||
use kaspa_grpc_client::GrpcClient;
|
||||
use kaspa_rpc_core::api::rpc::RpcApi;
|
||||
use kaspa_rpc_core::error::RpcError;
|
||||
use kaspa_rpc_core::model::{GetBlockDagInfoResponse, GetFinalityCheckpointsResponse, GetFinalityWeightsResponse, RpcBlock, RpcHash};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
pub const MINER_GRPC: u16 = 26610;
|
||||
pub const MINER_EVM: u16 = 26790;
|
||||
pub const OWN_GRPC: u16 = 26620;
|
||||
pub const OWN_EVM: u16 = 26800;
|
||||
pub const OWN_P2P: u16 = 26621;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// the miner app's node on this machine
|
||||
Miner { grpc: u16, evm: u16 },
|
||||
/// a node named by the environment (tests)
|
||||
External { grpc: u16, evm: u16 },
|
||||
/// the seed's public Ethereum RPC; no gRPC, so no certificate verification
|
||||
Public { url: String },
|
||||
/// the bundled node, started by the wallet
|
||||
Own { grpc: u16, evm: u16 },
|
||||
None,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Source::Miner { .. } => "miner",
|
||||
Source::External { .. } => "external",
|
||||
Source::Public { .. } => "public",
|
||||
Source::Own { .. } => "own",
|
||||
Source::None => "none",
|
||||
}
|
||||
}
|
||||
pub fn grpc_port(&self) -> Option<u16> {
|
||||
match self {
|
||||
Source::Miner { grpc, .. } | Source::External { grpc, .. } | Source::Own { grpc, .. } => Some(*grpc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn evm(&self) -> Option<crate::evm::Evm> {
|
||||
match self {
|
||||
Source::Miner { evm, .. } | Source::External { evm, .. } | Source::Own { evm, .. } => Some(crate::evm::Evm::local(*evm)),
|
||||
Source::Public { url } => Some(crate::evm::Evm { endpoint: url.clone() }),
|
||||
Source::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's Ethereum RPC answers eth_chainId on this port.
|
||||
pub fn evm_alive(port: u16) -> bool {
|
||||
crate::evm::Evm::local(port).chain_id().is_ok()
|
||||
}
|
||||
|
||||
/// The environment's external node, when set.
|
||||
pub fn external_from_env() -> Option<Source> {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let grpc = env("IGNEUM_WALLET_GRPC_PORT")?.parse().ok()?;
|
||||
let evm = env("IGNEUM_WALLET_EVM_PORT")?.parse().ok()?;
|
||||
Some(Source::External { grpc, evm })
|
||||
}
|
||||
|
||||
// ---- gRPC, the node's own client, on a private tokio runtime -----------------------------------------------------
|
||||
|
||||
pub struct Grpc {
|
||||
rt: Runtime,
|
||||
client: Arc<GrpcClient>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl Grpc {
|
||||
pub fn connect(port: u16) -> Result<Grpc, String> {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().map_err(|e| e.to_string())?;
|
||||
let url = format!("grpc://127.0.0.1:{port}");
|
||||
let client = rt.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(8), GrpcClient::connect(url.clone())).await.map_err(|_| "gRPC connect timed out".to_string())?.map_err(|e| e.to_string())
|
||||
})?;
|
||||
Ok(Grpc { rt, client: Arc::new(client), url })
|
||||
}
|
||||
fn run<F, T>(&self, f: F) -> Result<T, String>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, RpcError>>,
|
||||
{
|
||||
self.rt.block_on(async { tokio::time::timeout(Duration::from_secs(20), f).await.map_err(|_| "rpc timed out".to_string())?.map_err(|e| e.to_string()) })
|
||||
}
|
||||
pub fn checkpoints(&self, last: u32) -> Result<GetFinalityCheckpointsResponse, String> {
|
||||
self.run(self.client.get_finality_checkpoints(last))
|
||||
}
|
||||
pub fn weights(&self) -> Result<GetFinalityWeightsResponse, String> {
|
||||
self.run(self.client.get_finality_weights())
|
||||
}
|
||||
/// Blocks from `low` onward (the node's own page size), with transactions.
|
||||
pub fn blocks_after(&self, low: RpcHash) -> Result<Vec<RpcBlock>, String> {
|
||||
let r = self.run(self.client.get_blocks(Some(low), true, true))?;
|
||||
Ok(r.blocks)
|
||||
}
|
||||
pub fn dag_info(&self) -> Result<GetBlockDagInfoResponse, String> {
|
||||
self.run(self.client.get_block_dag_info())
|
||||
}
|
||||
pub fn disconnect(&self) {
|
||||
let c = self.client.clone();
|
||||
let _ = self.rt.block_on(async { c.disconnect().await });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the bundled node ---------------------------------------------------------------------------------------------
|
||||
|
||||
pub struct OwnNode {
|
||||
pub child: Child,
|
||||
}
|
||||
|
||||
pub struct OwnNodePlan {
|
||||
pub bin: PathBuf,
|
||||
pub args: Vec<String>,
|
||||
pub dir: PathBuf,
|
||||
pub log: PathBuf,
|
||||
}
|
||||
|
||||
/// Finds igneumd next to the engine (Windows), in bin/, or in Contents/Resources/bin (macOS bundle).
|
||||
pub fn find_igneumd() -> Option<PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let here = exe.parent()?.to_path_buf();
|
||||
let mut candidates = vec![];
|
||||
if let Some(d) = std::env::var_os("IGNEUM_APP_BIN") {
|
||||
candidates.push(PathBuf::from(d));
|
||||
}
|
||||
candidates.push(here.clone());
|
||||
candidates.push(here.join("bin"));
|
||||
if let Some(c) = here.parent() {
|
||||
candidates.push(c.join("Resources").join("bin"));
|
||||
}
|
||||
let name = if cfg!(windows) { "igneumd.exe" } else { "igneumd" };
|
||||
candidates.into_iter().map(|d| d.join(name)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
pub fn plan_own_node(app_dir: &std::path::Path, log_dir: &std::path::Path, packaged: &igneum_common::config::Packaged) -> Result<OwnNodePlan, String> {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let bin = find_igneumd().ok_or("igneumd is not next to the wallet (the package is incomplete)")?;
|
||||
let network = env("IGNEUM_WALLET_NETWORK").unwrap_or_else(|| "devnet".into());
|
||||
let suffix: Option<u32> = env("IGNEUM_WALLET_DEVNET_SUFFIX").and_then(|v| v.parse().ok());
|
||||
let root = igneum_common::platform::data_root();
|
||||
let dir = match env("IGNEUM_WALLET_NODE_DIR") {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => root.join(match suffix {
|
||||
Some(n) => format!("wallet-node-devnet-{n}"),
|
||||
None => format!("wallet-node-{network}"),
|
||||
}),
|
||||
};
|
||||
let peers: Vec<String> = match std::env::var("IGNEUM_WALLET_PEERS") {
|
||||
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
||||
Err(_) if network == "devnet" && suffix.is_none() => vec!["188.245.5.161:26611".into()],
|
||||
Err(_) => vec![],
|
||||
};
|
||||
let mut args = vec![
|
||||
format!("--{network}"),
|
||||
format!("--appdir={}", dir.display()),
|
||||
format!("--rpclisten=127.0.0.1:{OWN_GRPC}"),
|
||||
format!("--evm-rpclisten=127.0.0.1:{OWN_EVM}"),
|
||||
format!("--listen=0.0.0.0:{OWN_P2P}"),
|
||||
];
|
||||
if let Some(n) = suffix {
|
||||
args.push(format!("--devnet-suffix={n}"));
|
||||
}
|
||||
for p in &peers {
|
||||
args.push(format!("--addpeer={p}"));
|
||||
}
|
||||
// the packager's consensus pin, as the miner does it (igneum-wallet.json node_override_params)
|
||||
if let Some(file) = env("IGNEUM_WALLET_OVERRIDE_PARAMS") {
|
||||
args.push(format!("--override-params-file={file}"));
|
||||
} else if let Some(v) = packaged.node_override_params.as_ref().filter(|v| v.as_object().map(|o| !o.is_empty()).unwrap_or(false)) {
|
||||
let path = app_dir.join("override-params.json");
|
||||
std::fs::write(&path, v.to_string()).map_err(|e| e.to_string())?;
|
||||
args.push(format!("--override-params-file={}", path.display()));
|
||||
}
|
||||
args.extend(["--nodnsseed", "--disable-upnp", "--nologfiles", "--yes"].iter().map(|s| s.to_string()));
|
||||
let log = log_dir.join("wallet-node.log");
|
||||
Ok(OwnNodePlan { bin, args, dir, log })
|
||||
}
|
||||
|
||||
pub fn start_own_node(plan: &OwnNodePlan) -> Result<OwnNode, String> {
|
||||
let _ = std::fs::create_dir_all(&plan.dir);
|
||||
let out = std::fs::OpenOptions::new().create(true).append(true).open(&plan.log).map_err(|e| e.to_string())?;
|
||||
let err = out.try_clone().map_err(|e| e.to_string())?;
|
||||
let mut c = Command::new(&plan.bin);
|
||||
c.args(&plan.args).stdin(Stdio::null()).stdout(Stdio::from(out)).stderr(Stdio::from(err));
|
||||
igneum_common::platform::quiet(&mut c);
|
||||
let child = c.spawn().map_err(|e| format!("igneumd did not start: {e}"))?;
|
||||
Ok(OwnNode { child })
|
||||
}
|
||||
|
||||
impl OwnNode {
|
||||
pub fn stop(&mut self) {
|
||||
igneum_common::platform::terminate(&mut self.child);
|
||||
for _ in 0..300 {
|
||||
if let Ok(Some(_)) = self.child.try_wait() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
pub fn alive(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(None))
|
||||
}
|
||||
}
|
||||
31
app/igneum-wallet/src/qr.rs
Normal file
31
app/igneum-wallet/src/qr.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! The receive QR code, drawn here (no image service, no library call across the network): qrcodegen to an SVG.
|
||||
|
||||
use qrcodegen::{QrCode, QrCodeEcc};
|
||||
|
||||
pub fn svg(text: &str) -> Result<String, String> {
|
||||
let qr = QrCode::encode_text(text, QrCodeEcc::Medium).map_err(|e| format!("{e:?}"))?;
|
||||
let n = qr.size();
|
||||
let border = 2;
|
||||
let dim = n + 2 * border;
|
||||
let mut path = String::new();
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
if qr.get_module(x, y) {
|
||||
path.push_str(&format!("M{} {}h1v1h-1z", x + border, y + border));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {dim} {dim}\" shape-rendering=\"crispEdges\" role=\"img\" aria-label=\"QR code\"><rect width=\"{dim}\" height=\"{dim}\" fill=\"#F4F1EC\"/><path d=\"{path}\" fill=\"#0C0C0E\"/></svg>"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn draws() {
|
||||
let s = super::svg("ethereum:0x7e5f4552091a69125d5dfcb7b8c2659029395bdf").unwrap();
|
||||
assert!(s.starts_with("<svg"));
|
||||
assert!(s.contains("<path d=\"M"));
|
||||
}
|
||||
}
|
||||
163
app/igneum-wallet/src/server.rs
Normal file
163
app/igneum-wallet/src/server.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
//! The local web server: the window's files from the binary and the JSON API, on 127.0.0.1 with a random port, every
|
||||
//! path under `/t/<token>/` (igneum_common::http). Mutating calls must come from the window itself (same origin).
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use igneum_common::http::{from_dashboard, json_resp, query_param, read_request, respond};
|
||||
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<Shared>) -> std::io::Result<u16> {
|
||||
let s = shared.clone();
|
||||
let port = igneum_common::http::serve(move |stream| handle(stream, s.clone()))?;
|
||||
shared.set_port(port);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
||||
let Some(req) = read_request(&mut stream) else { return };
|
||||
if req.path == "/" {
|
||||
respond(&mut stream, 404, "text/plain", b"Igneum Wallet: open the app window.", false);
|
||||
return;
|
||||
}
|
||||
let prefix = format!("/t/{}", shared.token);
|
||||
let Some(rest) = req.path.strip_prefix(&prefix) else {
|
||||
respond(&mut stream, 404, "text/plain", b"not found", false);
|
||||
return;
|
||||
};
|
||||
let rest = if rest.is_empty() { "/" } else { rest };
|
||||
match (req.method.as_str(), rest) {
|
||||
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
|
||||
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
|
||||
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
|
||||
("GET", "/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<Shared>, path: &str, body: Value) -> Result<Value, String> {
|
||||
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::<u128>().unwrap_or(0) * 1_000_000_000, 3));
|
||||
v["tip_gwei"] = json!(crate::evm::ign(q.tip.parse::<u128>().unwrap_or(0) * 1_000_000_000, 3));
|
||||
v["display_to"] = json!(igneum_common::keys::checksum(&q.to));
|
||||
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()),
|
||||
}
|
||||
}
|
||||
132
app/igneum-wallet/src/state.rs
Normal file
132
app/igneum-wallet/src/state.rs
Normal file
|
|
@ -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<u64>,
|
||||
pub signers: usize,
|
||||
pub voters: usize,
|
||||
pub fraction_total: f64,
|
||||
pub fraction_active: f64,
|
||||
pub weights_exact: bool,
|
||||
pub verified_at: f64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[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<crate::history::Entry>,
|
||||
pub scanning: bool,
|
||||
pub scanned_to: Option<u64>,
|
||||
pub update: UpdateState,
|
||||
pub settings: SettingsState,
|
||||
pub events: Vec<Event>,
|
||||
pub miner_wallet_file: String,
|
||||
pub miner_wallet_present: bool,
|
||||
pub add_network_page: String,
|
||||
pub public_rpc: String,
|
||||
pub machine_id: String,
|
||||
pub host: String,
|
||||
pub log_dir: String,
|
||||
pub uptime_s: u64,
|
||||
pub now: f64,
|
||||
pub quitting: bool,
|
||||
}
|
||||
|
||||
pub struct Rings {
|
||||
pub events: VecDeque<Event>,
|
||||
pub log: VecDeque<LogLine>,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
impl Rings {
|
||||
pub fn new() -> Rings {
|
||||
Rings { events: VecDeque::new(), log: VecDeque::new(), seq: 0 }
|
||||
}
|
||||
pub fn event(&mut self, kind: &str, text: &str) {
|
||||
self.events.push_front(Event { t: igneum_common::platform::unix_now_f(), kind: kind.into(), text: text.into() });
|
||||
while self.events.len() > 40 {
|
||||
self.events.pop_back();
|
||||
}
|
||||
}
|
||||
pub fn log(&mut self, src: &str, err: bool, text: &str) {
|
||||
self.seq += 1;
|
||||
self.log.push_back(LogLine { seq: self.seq, t: igneum_common::platform::unix_now_f(), src: src.into(), err, text: text.into() });
|
||||
while self.log.len() > 3000 {
|
||||
self.log.pop_front();
|
||||
}
|
||||
}
|
||||
pub fn since(&self, after: u64, limit: usize) -> Vec<LogLine> {
|
||||
let mut out: Vec<LogLine> = self.log.iter().filter(|l| l.seq > after).cloned().collect();
|
||||
if out.len() > limit {
|
||||
out = out.split_off(out.len() - limit);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
261
app/igneum-wallet/src/tx.rs
Normal file
261
app/igneum-wallet/src/tx.rs
Normal file
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
// ---- RLP --------------------------------------------------------------------------------------------------
|
||||
|
||||
fn rlp_len(len: usize, offset: u8, out: &mut Vec<u8>) {
|
||||
if len < 56 {
|
||||
out.push(offset + len as u8);
|
||||
} else {
|
||||
let be = (len as u64).to_be_bytes();
|
||||
let first = be.iter().position(|b| *b != 0).unwrap_or(7);
|
||||
out.push(offset + 55 + (8 - first) as u8);
|
||||
out.extend_from_slice(&be[first..]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rlp_bytes(b: &[u8], out: &mut Vec<u8>) {
|
||||
if b.len() == 1 && b[0] < 0x80 {
|
||||
out.push(b[0]);
|
||||
} else {
|
||||
rlp_len(b.len(), 0x80, out);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rlp_uint(v: u128, out: &mut Vec<u8>) {
|
||||
if v == 0 {
|
||||
out.push(0x80);
|
||||
return;
|
||||
}
|
||||
let be = v.to_be_bytes();
|
||||
let first = be.iter().position(|b| *b != 0).unwrap();
|
||||
rlp_bytes(&be[first..], out);
|
||||
}
|
||||
|
||||
pub fn rlp_list(items: &[u8], out: &mut Vec<u8>) {
|
||||
rlp_len(items.len(), 0xc0, out);
|
||||
out.extend_from_slice(items);
|
||||
}
|
||||
|
||||
fn fields(t: &Transfer, out: &mut Vec<u8>) {
|
||||
rlp_uint(t.chain_id as u128, out);
|
||||
rlp_uint(t.nonce as u128, out);
|
||||
rlp_uint(t.max_priority_fee, out);
|
||||
rlp_uint(t.max_fee, out);
|
||||
rlp_uint(t.gas_limit as u128, out);
|
||||
rlp_bytes(&t.to, out);
|
||||
rlp_uint(t.value, out);
|
||||
rlp_bytes(&t.data, out);
|
||||
out.push(0xc0); // empty access list
|
||||
}
|
||||
|
||||
/// 0x02 || rlp([chainId, nonce, maxPriorityFee, maxFee, gasLimit, to, value, data, accessList])
|
||||
pub fn unsigned_bytes(t: &Transfer) -> Vec<u8> {
|
||||
let mut items = Vec::new();
|
||||
fields(t, &mut items);
|
||||
let mut out = vec![0x02];
|
||||
rlp_list(&items, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn signing_hash(t: &Transfer) -> [u8; 32] {
|
||||
Keccak256::digest(unsigned_bytes(t)).into()
|
||||
}
|
||||
|
||||
pub struct Signed {
|
||||
pub raw: Vec<u8>,
|
||||
pub hash: [u8; 32],
|
||||
}
|
||||
|
||||
/// Signs with the raw private key. The signature is normalised to low s (the EVM rejects high s) and the recovery bit
|
||||
/// flipped with it; the signer's address is recovered from the result and checked before anything is returned.
|
||||
pub fn sign(t: &Transfer, private_key: &[u8; 32]) -> Result<Signed, String> {
|
||||
let sk = SigningKey::from_bytes(private_key.into()).map_err(|_| "invalid private key")?;
|
||||
let h = signing_hash(t);
|
||||
let (sig, rec): (Signature, RecoveryId) = sk.sign_prehash_recoverable(&h).map_err(|e| e.to_string())?;
|
||||
let (sig, rec) = match sig.normalize_s() {
|
||||
Some(low) => (low, RecoveryId::from_byte(rec.to_byte() ^ 1).ok_or("recovery id")?),
|
||||
None => (sig, rec),
|
||||
};
|
||||
// the recovered key must be ours
|
||||
let vk = VerifyingKey::recover_from_prehash(&h, &sig, rec).map_err(|e| format!("recovery check: {e}"))?;
|
||||
if vk != *sk.verifying_key() {
|
||||
return Err("the signature does not recover to the signing key".into());
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
fields(t, &mut items);
|
||||
rlp_uint(rec.to_byte() as u128, &mut items);
|
||||
rlp_scalar(&sig.r().to_bytes(), &mut items);
|
||||
rlp_scalar(&sig.s().to_bytes(), &mut items);
|
||||
let mut raw = vec![0x02];
|
||||
rlp_list(&items, &mut raw);
|
||||
let hash: [u8; 32] = Keccak256::digest(&raw).into();
|
||||
Ok(Signed { raw, hash })
|
||||
}
|
||||
|
||||
/// A big-endian scalar (r, s: 32 bytes) as an RLP integer: leading zeros stripped, zero is the empty string.
|
||||
pub fn rlp_scalar(b: &[u8], out: &mut Vec<u8>) {
|
||||
let first = b.iter().position(|x| *x != 0);
|
||||
match first {
|
||||
None => out.push(0x80),
|
||||
Some(i) => rlp_bytes(&b[i..], out),
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed transaction's `from`, recovered from its raw bytes: what the node will see.
|
||||
pub fn recover_from(raw: &[u8]) -> Option<String> {
|
||||
// decode the outer list, pull the last three items (v, r, s), rebuild the signing payload
|
||||
let (items, _) = rlp_decode_list(&raw[1..])?;
|
||||
if items.len() != 12 {
|
||||
return None;
|
||||
}
|
||||
let mut payload = Vec::new();
|
||||
for it in &items[..9] {
|
||||
payload.extend_from_slice(it);
|
||||
}
|
||||
let mut unsigned = vec![0x02];
|
||||
rlp_list(&payload, &mut unsigned);
|
||||
let h: [u8; 32] = Keccak256::digest(&unsigned).into();
|
||||
let v = rlp_item_bytes(&items[9])?;
|
||||
let r = rlp_item_bytes(&items[10])?;
|
||||
let s = rlp_item_bytes(&items[11])?;
|
||||
let rec = RecoveryId::from_byte(if v.is_empty() { 0 } else { v[0] })?;
|
||||
let mut rs = [0u8; 64];
|
||||
rs[32 - r.len()..32].copy_from_slice(r);
|
||||
rs[64 - s.len()..].copy_from_slice(s);
|
||||
let sig = Signature::from_slice(&rs).ok()?;
|
||||
let vk = VerifyingKey::recover_from_prehash(&h, &sig, rec).ok()?;
|
||||
let point = vk.to_encoded_point(false);
|
||||
let hh = Keccak256::digest(&point.as_bytes()[1..]);
|
||||
Some(format!("0x{}", igneum_common::keys::hex(&hh[12..])))
|
||||
}
|
||||
|
||||
/// Minimal RLP decoding for the recovery check: returns the encoded items (bytes of each item, prefix included).
|
||||
fn rlp_decode_list(b: &[u8]) -> Option<(Vec<&[u8]>, usize)> {
|
||||
let (payload_start, payload_len) = rlp_head(b)?;
|
||||
if b[0] < 0xc0 {
|
||||
return None;
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut o = payload_start;
|
||||
let end = payload_start + payload_len;
|
||||
while o < end {
|
||||
let (ps, pl) = rlp_head(&b[o..])?;
|
||||
items.push(&b[o..o + ps + pl]);
|
||||
o += ps + pl;
|
||||
}
|
||||
Some((items, end))
|
||||
}
|
||||
|
||||
fn rlp_head(b: &[u8]) -> Option<(usize, usize)> {
|
||||
let f = *b.first()?;
|
||||
Some(match f {
|
||||
0..=0x7f => (0, 1),
|
||||
0x80..=0xb7 => (1, (f - 0x80) as usize),
|
||||
0xb8..=0xbf => {
|
||||
let n = (f - 0xb7) as usize;
|
||||
(1 + n, be_len(b.get(1..1 + n)?))
|
||||
}
|
||||
0xc0..=0xf7 => (1, (f - 0xc0) as usize),
|
||||
_ => {
|
||||
let n = (f - 0xf7) as usize;
|
||||
(1 + n, be_len(b.get(1..1 + n)?))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn be_len(b: &[u8]) -> usize {
|
||||
b.iter().fold(0usize, |a, x| (a << 8) | *x as usize)
|
||||
}
|
||||
|
||||
fn rlp_item_bytes(it: &[u8]) -> Option<&[u8]> {
|
||||
let (ps, pl) = rlp_head(it)?;
|
||||
if it[0] < 0x80 {
|
||||
return Some(&it[..1]);
|
||||
}
|
||||
it.get(ps..ps + pl)
|
||||
}
|
||||
|
||||
pub fn hex0x(b: &[u8]) -> String {
|
||||
format!("0x{}", igneum_common::keys::hex(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
90
app/igneum-wallet/src/updater.rs
Normal file
90
app/igneum-wallet/src/updater.rs
Normal file
|
|
@ -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<Manifest>,
|
||||
pub entry: Option<PlatformEntry>,
|
||||
pub file: Option<PathBuf>,
|
||||
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<String, String> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
153
app/igneum-wallet/src/vault.rs
Normal file
153
app/igneum-wallet/src/vault.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! The encrypted key file: `<data root>/wallet/vault.json`. The secret (the 32-byte private key and, when the wallet
|
||||
//! was made or imported from words, the 24-word phrase) is sealed with XChaCha20-Poly1305 under a key derived from
|
||||
//! the password with Argon2id (64 MiB, 3 passes). The password is never written anywhere; the plaintext only ever
|
||||
//! exists in the engine's memory and is zeroed when the wallet locks.
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
||||
|
||||
pub const M_KIB: u32 = 65_536;
|
||||
pub const T_COST: u32 = 3;
|
||||
pub const P_COST: u32 = 1;
|
||||
|
||||
/// What the vault seals. Zeroed on drop.
|
||||
#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
|
||||
pub struct Secret {
|
||||
/// 0x + 64 hex, secp256k1
|
||||
pub private_key: String,
|
||||
/// the 24 words, space separated; None for a raw key import
|
||||
pub mnemonic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Kdf {
|
||||
pub algo: String,
|
||||
pub m_kib: u32,
|
||||
pub t: u32,
|
||||
pub p: u32,
|
||||
pub salt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Vault {
|
||||
pub version: u32,
|
||||
pub address: String, // 0x + 40 lower-case hex, in the clear: the window shows it while locked
|
||||
pub source: String, // created | seed | key | miner
|
||||
pub created: u64,
|
||||
pub kdf: Kdf,
|
||||
pub cipher: String,
|
||||
pub nonce: String,
|
||||
pub ciphertext: String,
|
||||
/// The one-time backup sheet (the words or the key) was confirmed.
|
||||
#[serde(default)]
|
||||
pub backed_up: bool,
|
||||
}
|
||||
|
||||
fn derive(password: &str, salt: &[u8], kdf: &Kdf) -> Result<[u8; 32], String> {
|
||||
let params = Params::new(kdf.m_kib, kdf.t, kdf.p, Some(32)).map_err(|e| e.to_string())?;
|
||||
let a = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut key = [0u8; 32];
|
||||
a.hash_password_into(password.as_bytes(), salt, &mut key).map_err(|e| e.to_string())?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub fn seal(secret: &Secret, password: &str, address: &str, source: &str) -> Result<Vault, String> {
|
||||
if password.len() < 8 {
|
||||
return Err("the password needs at least 8 characters".into());
|
||||
}
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce = [0u8; 24];
|
||||
getrandom::getrandom(&mut salt).map_err(|e| e.to_string())?;
|
||||
getrandom::getrandom(&mut nonce).map_err(|e| e.to_string())?;
|
||||
let kdf = Kdf { algo: "argon2id".into(), m_kib: M_KIB, t: T_COST, p: P_COST, salt: igneum_common::keys::hex(&salt) };
|
||||
let mut key = derive(password, &salt, &kdf)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let mut plain = serde_json::to_vec(secret).map_err(|e| e.to_string())?;
|
||||
let ct = cipher.encrypt(XNonce::from_slice(&nonce), plain.as_ref()).map_err(|_| "encryption failed".to_string());
|
||||
plain.zeroize();
|
||||
key.zeroize();
|
||||
let ct = ct?;
|
||||
Ok(Vault {
|
||||
version: 1,
|
||||
address: address.to_ascii_lowercase(),
|
||||
source: source.into(),
|
||||
created: igneum_common::platform::unix_now(),
|
||||
kdf,
|
||||
cipher: "xchacha20poly1305".into(),
|
||||
nonce: igneum_common::keys::hex(&nonce),
|
||||
ciphertext: igneum_common::keys::hex(&ct),
|
||||
backed_up: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open(v: &Vault, password: &str) -> Result<Secret, String> {
|
||||
if v.kdf.algo != "argon2id" || v.cipher != "xchacha20poly1305" {
|
||||
return Err("this vault was written by a newer wallet".into());
|
||||
}
|
||||
let salt = igneum_common::keys::unhex(&v.kdf.salt).ok_or("vault salt is not hex")?;
|
||||
let nonce = igneum_common::keys::unhex(&v.nonce).ok_or("vault nonce is not hex")?;
|
||||
let ct = igneum_common::keys::unhex(&v.ciphertext).ok_or("vault ciphertext is not hex")?;
|
||||
let mut key = derive(password, &salt, &v.kdf)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&key));
|
||||
let plain = cipher.decrypt(XNonce::from_slice(&nonce), ct.as_ref());
|
||||
key.zeroize();
|
||||
let mut plain = plain.map_err(|_| "wrong password".to_string())?;
|
||||
let s: Result<Secret, _> = serde_json::from_slice(&plain);
|
||||
plain.zeroize();
|
||||
s.map_err(|_| "the vault did not decode".to_string())
|
||||
}
|
||||
|
||||
pub fn save(path: &Path, v: &Vault) -> Result<(), String> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
igneum_common::platform::lock_permissions(dir, true);
|
||||
}
|
||||
let text = serde_json::to_string_pretty(v).map_err(|e| e.to_string())?;
|
||||
let tmp = path.with_extension("json.new");
|
||||
std::fs::write(&tmp, text).map_err(|e| e.to_string())?;
|
||||
igneum_common::platform::lock_permissions(&tmp, false);
|
||||
std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
|
||||
igneum_common::platform::lock_permissions(path, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Option<Vault> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_and_wrong_password() {
|
||||
let s = Secret { private_key: "0x0000000000000000000000000000000000000000000000000000000000000001".into(), mnemonic: Some("abandon abandon about".into()) };
|
||||
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());
|
||||
}
|
||||
}
|
||||
403
app/igneum-wallet/ui/app.css
Normal file
403
app/igneum-wallet/ui/app.css
Normal file
|
|
@ -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}
|
||||
267
app/igneum-wallet/ui/app.js
Normal file
267
app/igneum-wallet/ui/app.js
Normal file
|
|
@ -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]) => `<span>${k}</span><b class="${cls || ''}">${v}</b>`).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 = `<div class="empty">${state.scanning ? 'Reading the chain.' : 'Nothing yet. Receive IGN, or point the miner app at this address.'}</div>`; 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 `<div class="row" data-hash="${esc(e.hash)}"><span class="kind">${esc(e.kind)}</span><span class="who">${esc(who)}<span class="dim"> · ${when(e.time)}</span></span><span class="amt ${incoming ? 'in' : ''}">${incoming ? '+' : '−'}${ign(e.value)} IGN</span><span class="fin ${esc(e.finality)}">${esc(fin)}</span></div>`;
|
||||
}).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 => `<li>${esc(w)}</li>`).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 => `<label>word ${p}<input type="text" data-pos="${p}" autocomplete="off" autocapitalize="none" spellcheck="false"></label>`).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 => `<li>${esc(w)}</li>`).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);
|
||||
263
app/igneum-wallet/ui/index.html
Normal file
263
app/igneum-wallet/ui/index.html
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Igneum Wallet</title>
|
||||
<meta name="color-scheme" content="dark">
|
||||
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body data-phase="welcome" data-view="overview">
|
||||
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<img src="mark.svg" width="30" height="30" alt="">
|
||||
<span class="word">IGNEUM</span><span class="miner">WALLET</span>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
|
||||
<button class="btn small ghost" id="btn-lock" hidden>Lock</button>
|
||||
<button class="icon-btn" id="btn-settings" title="Settings" aria-label="Settings" hidden>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="banner update" id="update-banner" hidden>
|
||||
<span id="update-text">A new version of Igneum Wallet is ready.</span>
|
||||
<button class="btn small primary" id="update-open">Open the download</button>
|
||||
<button class="btn small ghost" id="update-later">Later</button>
|
||||
</div>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<!-- welcome -->
|
||||
<section class="screen" id="screen-welcome">
|
||||
<div class="hero">
|
||||
<div class="coin-wrap"><img class="coin" src="coin.png" width="148" height="148" alt=""></div>
|
||||
<div class="eyebrow ember" id="welcome-eyebrow">devnet v4 · nothing is bought or sold</div>
|
||||
<h1>Igneum Wallet</h1>
|
||||
<p class="lead">Your IGN, your key, on this machine. The key is made here and never leaves this app.</p>
|
||||
<div class="three">
|
||||
<div class="tile"><div class="k">01</div><div class="t">Your key stays here</div><div class="s">Encrypted with a password you choose. Signing happens inside the app.</div></div>
|
||||
<div class="tile"><div class="k">02</div><div class="t">Final means verified</div><div class="s">A payment is final when this wallet has checked the network's certificate itself.</div></div>
|
||||
<div class="tile"><div class="k">03</div><div class="t">Works with the miner</div><div class="s">Uses the miner app's node when it runs here. Imports the miner's key in one tap.</div></div>
|
||||
</div>
|
||||
<div class="cta">
|
||||
<button class="btn primary big" id="go-create">Create a wallet</button>
|
||||
<button class="btn big" id="go-import">Import</button>
|
||||
</div>
|
||||
<div class="seedline">Nobody from Igneum will ever ask for your words or your key.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- create -->
|
||||
<section class="screen" id="screen-create">
|
||||
<div class="step" id="create-1">
|
||||
<div class="eyebrow ember">step 1 of 3</div>
|
||||
<h2>Choose a password</h2>
|
||||
<p class="sub">It locks the key on this machine. Nobody can reset it for you. At least 8 characters.</p>
|
||||
<label class="field"><span>Password</span><input type="password" id="create-pw" autocomplete="new-password"></label>
|
||||
<label class="field"><span>Again</span><input type="password" id="create-pw2" autocomplete="new-password"></label>
|
||||
<div class="err" id="create-err"></div>
|
||||
<div class="cta"><button class="btn primary big" id="create-next">Make my words</button><button class="btn ghost" id="create-back">Back</button></div>
|
||||
</div>
|
||||
<div class="step" id="create-2" hidden>
|
||||
<div class="eyebrow ember">step 2 of 3</div>
|
||||
<h2>Write these 24 words down</h2>
|
||||
<p class="sub">They are the wallet. Anyone with them has your IGN. They are shown once.</p>
|
||||
<ol class="words" id="words"></ol>
|
||||
<div class="note">Address <span class="mono" id="create-address"></span></div>
|
||||
<div class="cta"><button class="btn primary big" id="create-written">I wrote them down</button></div>
|
||||
</div>
|
||||
<div class="step" id="create-3" hidden>
|
||||
<div class="eyebrow ember">step 3 of 3</div>
|
||||
<h2>Type three of them</h2>
|
||||
<p class="sub">So the paper is right before the words are gone from the screen.</p>
|
||||
<div class="checks" id="checks"></div>
|
||||
<div class="err" id="confirm-err"></div>
|
||||
<div class="cta"><button class="btn primary big" id="create-confirm">Open my wallet</button><button class="btn ghost" id="create-again">Show the words again</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- import -->
|
||||
<section class="screen" id="screen-import">
|
||||
<div class="step">
|
||||
<div class="eyebrow ember">import</div>
|
||||
<h2>Bring a key here</h2>
|
||||
<p class="sub">Words from any wallet, a raw private key, or the key the miner app made on this machine.</p>
|
||||
<div class="segs" id="import-mode">
|
||||
<button class="seg on" data-mode="seed">24 words</button>
|
||||
<button class="seg" data-mode="key">Private key</button>
|
||||
<button class="seg" data-mode="miner" id="seg-miner">Miner's key</button>
|
||||
</div>
|
||||
<label class="field" id="import-data-field"><span id="import-data-label">The words, in order</span><textarea id="import-data" rows="4" spellcheck="false" autocomplete="off"></textarea></label>
|
||||
<div class="note" id="import-miner-note" hidden>The miner app's key file on this machine will be read. Rewards keep going to the same address.</div>
|
||||
<label class="field"><span>New password for this machine</span><input type="password" id="import-pw" autocomplete="new-password"></label>
|
||||
<label class="field"><span>Again</span><input type="password" id="import-pw2" autocomplete="new-password"></label>
|
||||
<div class="err" id="import-err"></div>
|
||||
<div class="cta"><button class="btn primary big" id="import-go">Import</button><button class="btn ghost" id="import-back">Back</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- unlock -->
|
||||
<section class="screen" id="screen-unlock">
|
||||
<div class="hero">
|
||||
<div class="coin-wrap"><img class="coin" src="coin.png" width="148" height="148" alt=""></div>
|
||||
<h2>Unlock</h2>
|
||||
<p class="lead mono" id="unlock-address"></p>
|
||||
<form id="unlock-form" class="unlock">
|
||||
<input type="password" id="unlock-pw" placeholder="Password" autocomplete="current-password" autofocus>
|
||||
<button class="btn primary big" type="submit">Unlock</button>
|
||||
</form>
|
||||
<div class="err" id="unlock-err"></div>
|
||||
<div class="seedline">Forgot it? Only the 24 words or the key open this wallet again: Settings is locked too.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- home -->
|
||||
<section class="screen" id="screen-home">
|
||||
|
||||
<div class="view" id="view-overview">
|
||||
<div class="balance-card">
|
||||
<div class="eyebrow">balance</div>
|
||||
<div class="balance"><span id="balance"> </span><span class="unit">IGN</span></div>
|
||||
<div class="addr-row"><span class="mono" id="home-address"></span><button class="btn tiny" id="copy-address">Copy</button></div>
|
||||
<div class="actions">
|
||||
<button class="btn primary big" id="go-send">Send</button>
|
||||
<button class="btn big" id="go-receive">Receive</button>
|
||||
</div>
|
||||
<div class="note" id="backup-note" hidden>Your words have not been written down yet. <a href="#" id="backup-link">Back up now</a>.</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<div class="eyebrow">node</div>
|
||||
<div class="kv" id="node-kv"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="eyebrow">finality</div>
|
||||
<div class="kv" id="fin-kv"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="eyebrow">history <span class="dim" id="scan-note"></span></div>
|
||||
<div class="history" id="history"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-send">
|
||||
<div class="step">
|
||||
<div class="eyebrow ember">send</div>
|
||||
<h2>Send IGN</h2>
|
||||
<div id="send-form">
|
||||
<label class="field"><span>To</span><input type="text" id="send-to" class="mono" placeholder="0x..." spellcheck="false" autocomplete="off"></label>
|
||||
<label class="field"><span>Amount, IGN</span><input type="text" id="send-amount" class="mono" placeholder="0.0" inputmode="decimal" autocomplete="off"></label>
|
||||
<div class="err" id="send-err"></div>
|
||||
<div class="cta"><button class="btn primary big" id="send-quote">Review</button><button class="btn ghost" id="send-cancel">Cancel</button></div>
|
||||
</div>
|
||||
<div id="send-confirm" hidden>
|
||||
<div class="confirm">
|
||||
<div class="row"><span>Amount</span><b class="mono" id="c-amount"></b></div>
|
||||
<div class="row"><span>To</span><b class="mono small" id="c-to"></b></div>
|
||||
<div class="row"><span>Fee, at most</span><b class="mono" id="c-fee"></b></div>
|
||||
<div class="row total"><span>Leaves the wallet, at most</span><b class="mono" id="c-total"></b></div>
|
||||
</div>
|
||||
<p class="note" id="c-fee-note"></p>
|
||||
<div class="err" id="confirm-send-err"></div>
|
||||
<div class="cta"><button class="btn primary big" id="send-go">Send now</button><button class="btn ghost" id="send-edit">Edit</button></div>
|
||||
</div>
|
||||
<div id="send-done" hidden>
|
||||
<h3>Sent</h3>
|
||||
<p class="note">It is pending until a block carries it, then in a block, then final once this wallet verifies the checkpoint over it.</p>
|
||||
<p class="mono small" id="sent-hash"></p>
|
||||
<div class="cta"><button class="btn primary" id="sent-view">View</button><button class="btn ghost" id="sent-home">Done</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-receive">
|
||||
<div class="step">
|
||||
<div class="eyebrow ember">receive</div>
|
||||
<h2>Your address</h2>
|
||||
<div class="qr" id="qr"></div>
|
||||
<p class="mono addr-big" id="receive-address"></p>
|
||||
<div class="cta"><button class="btn primary" id="receive-copy">Copy address</button><button class="btn ghost" id="receive-back">Back</button></div>
|
||||
<p class="note">Only IGN on the Igneum network. Rewards from the miner app land here when the miner pays to this address.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-tx">
|
||||
<div class="step">
|
||||
<div class="eyebrow ember">transaction</div>
|
||||
<h2 id="tx-title">Transfer</h2>
|
||||
<div class="finality-line" id="tx-finality"></div>
|
||||
<div class="kv" id="tx-kv"></div>
|
||||
<div class="cta"><button class="btn ghost" id="tx-back">Back</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-settings">
|
||||
<div class="step">
|
||||
<div class="eyebrow ember">settings</div>
|
||||
<h2>Settings</h2>
|
||||
|
||||
<div class="card"><h3>Backup</h3>
|
||||
<p class="note">Show the 24 words (or the key) again. Needs the password.</p>
|
||||
<div class="inline"><input type="password" id="backup-pw" placeholder="Password" autocomplete="current-password"><button class="btn small" id="backup-show">Show</button></div>
|
||||
<div class="err" id="backup-err"></div>
|
||||
<div id="backup-out" hidden>
|
||||
<ol class="words small" id="backup-words"></ol>
|
||||
<p class="note small">Private key <span class="mono" id="backup-key"></span></p>
|
||||
<button class="btn small ghost" id="backup-hide">Hide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h3>Password</h3>
|
||||
<div class="inline"><input type="password" id="pw-old" placeholder="Current" autocomplete="current-password"><input type="password" id="pw-new" placeholder="New, 8 or more" autocomplete="new-password"><button class="btn small" id="pw-change">Change</button></div>
|
||||
<div class="err" id="pw-err"></div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h3>Export to MetaMask</h3>
|
||||
<p class="note">Add the network, then import the private key. The key leaves this app only when you copy it here.</p>
|
||||
<div class="kv" id="net-kv"></div>
|
||||
<div class="inline">
|
||||
<button class="btn small" id="net-add">Add to MetaMask</button>
|
||||
<button class="btn small" id="net-copy">Copy network settings</button>
|
||||
</div>
|
||||
<div class="warn-box">
|
||||
<b>Export the private key</b>
|
||||
<p class="note">A copied key can be stolen from the clipboard, a screenshot or a notes app. Paste it straight into MetaMask and clear the clipboard.</p>
|
||||
<div class="inline"><input type="password" id="export-pw" placeholder="Password" autocomplete="current-password"><button class="btn small danger" id="export-show">Show the key</button></div>
|
||||
<div class="err" id="export-err"></div>
|
||||
<div id="export-out" hidden><p class="mono small" id="export-key"></p><button class="btn tiny" id="export-copy">Copy key</button> <button class="btn tiny ghost" id="export-hide">Hide</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h3>Network</h3>
|
||||
<div class="kv" id="settings-node-kv"></div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h3>This machine</h3>
|
||||
<label class="switch"><input type="checkbox" id="start-login"><span>Start Igneum Wallet at login</span></label>
|
||||
<div class="kv" id="machine-kv"></div>
|
||||
<div class="inline"><button class="btn small" id="update-check">Check for updates</button><span class="note" id="update-note"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="card danger-card"><h3>Remove this wallet from this machine</h3>
|
||||
<p class="note">The vault file is deleted. Only your 24 words or the key bring it back.</p>
|
||||
<div class="inline"><input type="password" id="remove-pw" placeholder="Password" autocomplete="current-password"><button class="btn small danger" id="remove-go">Remove</button></div>
|
||||
<div class="err" id="remove-err"></div>
|
||||
</div>
|
||||
|
||||
<div class="cta"><button class="btn ghost" id="settings-back">Back</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
app/igneum-wallet/ui/mark.svg
Normal file
1
app/igneum-wallet/ui/mark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"/><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"/></svg>
|
||||
|
After Width: | Height: | Size: 214 B |
360
app/mac/IgneumWallet.swift
Normal file
360
app/mac/IgneumWallet.swift
Normal file
|
|
@ -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 <out.png> --url <window url> [--size 1120x780]
|
||||
|
||||
import Cocoa
|
||||
import WebKit
|
||||
|
||||
let obsidian = NSColor(srgbRed: 12 / 255, green: 12 / 255, blue: 14 / 255, alpha: 1)
|
||||
|
||||
func flameImage(size: CGFloat) -> NSImage {
|
||||
// the brand mark's flame as a template image for the menu bar
|
||||
let img = NSImage(size: NSSize(width: size, height: size), flipped: true) { rect in
|
||||
let s = rect.width / 100
|
||||
let outer = NSBezierPath()
|
||||
let pts: [(CGFloat, CGFloat)] = [(50, 4), (74, 34), (67, 58), (80, 54), (61, 96), (39, 96), (20, 54), (33, 58), (26, 34)]
|
||||
outer.move(to: NSPoint(x: pts[0].0 * s, y: pts[0].1 * s))
|
||||
for p in pts.dropFirst() { outer.line(to: NSPoint(x: p.0 * s, y: p.1 * s)) }
|
||||
outer.close()
|
||||
let inner = NSBezierPath()
|
||||
let ip: [(CGFloat, CGFloat)] = [(50, 42), (59, 58), (50, 82), (41, 58)]
|
||||
inner.move(to: NSPoint(x: ip[0].0 * s, y: ip[0].1 * s))
|
||||
for p in ip.dropFirst() { inner.line(to: NSPoint(x: p.0 * s, y: p.1 * s)) }
|
||||
inner.close()
|
||||
outer.append(inner)
|
||||
outer.windingRule = .evenOdd
|
||||
NSColor.black.setFill()
|
||||
outer.fill()
|
||||
return true
|
||||
}
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}
|
||||
|
||||
/// A clear strip over the top band of the web view: WKWebView swallows window drags, this view lets the window move.
|
||||
final class DragStrip: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { true }
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil }
|
||||
}
|
||||
|
||||
final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
|
||||
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..<nl)
|
||||
buffer.removeSubrange(0...nl)
|
||||
guard let line = String(data: lineData, encoding: .utf8) else { continue }
|
||||
if line.hasPrefix("URL ") {
|
||||
let u = String(line.dropFirst(4)).trimmingCharacters(in: .whitespaces)
|
||||
if let url = URL(string: u + "?host=mac") {
|
||||
self.url = url
|
||||
placeholder.isHidden = true
|
||||
web.load(URLRequest(url: url))
|
||||
}
|
||||
} else if line.hasPrefix("STATE ") {
|
||||
applyState(String(line.dropFirst(6)))
|
||||
} else if line.hasPrefix("FATAL ") {
|
||||
fail(String(line.dropFirst(6)))
|
||||
} else if line == "EXIT" {
|
||||
exited = true
|
||||
if quitting { NSApp.reply(toApplicationShouldTerminate: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyState(_ json: String) {
|
||||
guard let data = json.data(using: .utf8), let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
|
||||
let node = o["node"] as? String ?? ""
|
||||
let source = o["source"] as? String ?? ""
|
||||
let block = o["block"] as? Double ?? 0
|
||||
let unlocked = o["unlocked"] as? Bool ?? false
|
||||
let balance = o["balance"] as? String ?? ""
|
||||
let phase = o["phase"] as? String ?? ""
|
||||
var title = ""
|
||||
if o["quitting"] as? Bool == true { title = "stopping" }
|
||||
else if phase != "home" { title = "" }
|
||||
else if unlocked && !balance.isEmpty { title = "\(balance) IGN" }
|
||||
else if !unlocked { title = "locked" }
|
||||
status.button?.title = title.isEmpty ? "" : " " + title
|
||||
menuPause.title = unlocked ? "Lock" : "Locked"
|
||||
menuPause.isEnabled = unlocked
|
||||
let nf = NumberFormatter(); nf.numberStyle = .decimal
|
||||
menuNode.title = "Node: \(source) \(node), block \(nf.string(from: NSNumber(value: block)) ?? "0")"
|
||||
menuBlocks.title = unlocked ? "Balance: \(balance) IGN" : "Balance: locked"
|
||||
}
|
||||
|
||||
func engineEnded() {
|
||||
exited = true
|
||||
if quitting { NSApp.reply(toApplicationShouldTerminate: true); return }
|
||||
placeholder.stringValue = "The engine stopped. Quit and open Igneum Wallet again."
|
||||
placeholder.isHidden = false
|
||||
status?.button?.title = " stopped"
|
||||
}
|
||||
|
||||
func fail(_ text: String) {
|
||||
placeholder.stringValue = text
|
||||
placeholder.isHidden = false
|
||||
let a = NSAlert()
|
||||
a.messageText = "Igneum Wallet"
|
||||
a.informativeText = text
|
||||
a.runModal()
|
||||
}
|
||||
|
||||
@objc func showWindow() {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc func lockWallet() {
|
||||
send("lock")
|
||||
}
|
||||
|
||||
func send(_ cmd: String) {
|
||||
if let d = (cmd + "\n").data(using: .utf8) { engineIn?.write(d) }
|
||||
}
|
||||
|
||||
// ---- quit: miners first, then the node ----
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
if snapshotPath != nil { return .terminateNow }
|
||||
guard let e = engine, e.isRunning, !exited else { return .terminateNow }
|
||||
quitting = true
|
||||
status?.button?.title = " stopping"
|
||||
web.evaluateJavaScript("document.getElementById('pill-text').textContent='stopping'", completionHandler: nil)
|
||||
send("quit")
|
||||
// 45 s is the engine's own worst case (30 s for the bundled node to close its database); then force
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 45) {
|
||||
if self.engine?.isRunning == true { self.engine?.terminate() }
|
||||
NSApp.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
return .terminateLater
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
showWindow()
|
||||
return true
|
||||
}
|
||||
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
// the window hides; the miner keeps running in the menu bar
|
||||
window.orderOut(nil)
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- 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()
|
||||
61
app/windows/BUILD-WALLET-APP.bat
Normal file
61
app/windows/BUILD-WALLET-APP.bat
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
@echo off
|
||||
rem Builds "Igneum Wallet.exe" (the WebView2 window host) on a Windows PC. Double-click this file.
|
||||
rem Needs Visual Studio with the MSVC v143 x64 component (cl.exe, rc.exe) 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
|
||||
457
app/windows/wallet-host.cpp
Normal file
457
app/windows/wallet-host.cpp
Normal file
|
|
@ -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 <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <wrl.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#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<ICoreWebView2Controller> g_controller;
|
||||
static ComPtr<ICoreWebView2> 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 <line> as administrator, waits, and answers on the engine's stdin.
|
||||
static void runElevated(std::wstring line) {
|
||||
std::thread([line] {
|
||||
std::wstring params = L"/c " + line;
|
||||
wchar_t sysdir[MAX_PATH];
|
||||
GetSystemDirectoryW(sysdir, MAX_PATH);
|
||||
std::wstring cmdExe = std::wstring(sysdir) + L"\\cmd.exe"; // the absolute path, never a bare name (R4.3.3)
|
||||
SHELLEXECUTEINFOW sei = { sizeof(sei) };
|
||||
sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI;
|
||||
sei.lpVerb = L"runas";
|
||||
sei.lpFile = cmdExe.c_str();
|
||||
sei.lpParameters = params.c_str();
|
||||
sei.nShow = SW_HIDE;
|
||||
if (!ShellExecuteExW(&sei) || !sei.hProcess) {
|
||||
DWORD err = GetLastError();
|
||||
sendEngine(err == ERROR_CANCELLED ? "elevated fail: the administrator prompt was cancelled" : "elevated fail: could not start the elevated step");
|
||||
return;
|
||||
}
|
||||
WaitForSingleObject(sei.hProcess, 120000);
|
||||
DWORD code = 1;
|
||||
GetExitCodeProcess(sei.hProcess, &code);
|
||||
CloseHandle(sei.hProcess);
|
||||
if (code == 0) sendEngine("elevated ok");
|
||||
else { char buf[64]; sprintf_s(buf, "elevated fail: exit code %lu", code); sendEngine(buf); }
|
||||
}).detach();
|
||||
}
|
||||
|
||||
static void openInBrowser(const std::wstring& url) {
|
||||
ShellExecuteW(nullptr, L"open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
}
|
||||
|
||||
static void navigate() {
|
||||
if (g_webview && !g_url.empty()) g_webview->Navigate((g_url + L"?host=windows").c_str());
|
||||
}
|
||||
|
||||
static void initWebView() {
|
||||
std::wstring data = L"";
|
||||
wchar_t* local = nullptr;
|
||||
size_t len = 0;
|
||||
if (_wdupenv_s(&local, &len, L"LOCALAPPDATA") == 0 && local) { data = std::wstring(local) + L"\\igneum\\webview2"; free(local); }
|
||||
HRESULT hr = CreateCoreWebView2EnvironmentWithOptions(nullptr, data.empty() ? nullptr : data.c_str(), nullptr,
|
||||
Callback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler>([](HRESULT result, ICoreWebView2Environment* env) -> HRESULT {
|
||||
if (FAILED(result) || !env) {
|
||||
g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window.";
|
||||
repaintStatus();
|
||||
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
|
||||
return S_OK;
|
||||
}
|
||||
env->CreateCoreWebView2Controller(g_hwnd, Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>([](HRESULT result, ICoreWebView2Controller* controller) -> HRESULT {
|
||||
if (FAILED(result) || !controller) {
|
||||
g_status = L"The app window could not start WebView2. The dashboard opens in your browser.";
|
||||
repaintStatus();
|
||||
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
|
||||
return S_OK;
|
||||
}
|
||||
g_controller = controller;
|
||||
g_controller->get_CoreWebView2(&g_webview);
|
||||
g_webviewOk = true;
|
||||
ComPtr<ICoreWebView2Settings> settings;
|
||||
if (g_webview && SUCCEEDED(g_webview->get_Settings(&settings)) && settings) {
|
||||
settings->put_AreDefaultContextMenusEnabled(FALSE);
|
||||
settings->put_IsStatusBarEnabled(FALSE);
|
||||
settings->put_AreDevToolsEnabled(FALSE);
|
||||
}
|
||||
// links off 127.0.0.1 open in the default browser
|
||||
g_webview->add_NewWindowRequested(Callback<ICoreWebView2NewWindowRequestedEventHandler>([](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {
|
||||
LPWSTR uri = nullptr;
|
||||
if (SUCCEEDED(args->get_Uri(&uri)) && uri) { openInBrowser(uri); CoTaskMemFree(uri); }
|
||||
args->put_Handled(TRUE);
|
||||
return S_OK;
|
||||
}).Get(), nullptr);
|
||||
g_webview->add_NavigationStarting(Callback<ICoreWebView2NavigationStartingEventHandler>([](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT {
|
||||
LPWSTR uri = nullptr;
|
||||
if (SUCCEEDED(args->get_Uri(&uri)) && uri) {
|
||||
std::wstring u(uri);
|
||||
CoTaskMemFree(uri);
|
||||
if (u.rfind(L"http", 0) == 0 && u.find(L"127.0.0.1") == std::wstring::npos) { args->put_Cancel(TRUE); openInBrowser(u); }
|
||||
}
|
||||
return S_OK;
|
||||
}).Get(), nullptr);
|
||||
RECT rc; GetClientRect(g_hwnd, &rc);
|
||||
g_controller->put_Bounds(rc);
|
||||
COREWEBVIEW2_COLOR dark = { 255, 12, 12, 14 };
|
||||
ComPtr<ICoreWebView2Controller2> c2;
|
||||
if (SUCCEEDED(g_controller.As(&c2)) && c2) c2->put_DefaultBackgroundColor(dark);
|
||||
g_status = L"";
|
||||
repaintStatus();
|
||||
navigate();
|
||||
return S_OK;
|
||||
}).Get());
|
||||
return S_OK;
|
||||
}).Get());
|
||||
if (FAILED(hr)) {
|
||||
g_status = L"The WebView2 runtime is not installed. The dashboard opens in your browser; install the runtime from microsoft.com for the app window.";
|
||||
repaintStatus();
|
||||
if (!g_url.empty()) { openInBrowser(g_url); g_hintShown = true; }
|
||||
}
|
||||
}
|
||||
|
||||
static bool startEngine() {
|
||||
SECURITY_ATTRIBUTES sa = { sizeof(sa), nullptr, TRUE };
|
||||
HANDLE outR, outW, inR, inW;
|
||||
if (!CreatePipe(&outR, &outW, &sa, 0) || !CreatePipe(&inR, &inW, &sa, 0)) return false;
|
||||
SetHandleInformation(outR, HANDLE_FLAG_INHERIT, 0);
|
||||
SetHandleInformation(inW, HANDLE_FLAG_INHERIT, 0);
|
||||
STARTUPINFOW si = { sizeof(si) };
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdOutput = outW;
|
||||
si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
|
||||
si.hStdInput = inR;
|
||||
PROCESS_INFORMATION pi = {};
|
||||
std::wstring exe = exeDir() + L"\\igneum-wallet.exe";
|
||||
std::wstring cmd = L"\"" + exe + L"\" --wrapper";
|
||||
std::vector<wchar_t> buf(cmd.begin(), cmd.end());
|
||||
buf.push_back(0);
|
||||
BOOL ok = CreateProcessW(exe.c_str(), buf.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, exeDir().c_str(), &si, &pi);
|
||||
CloseHandle(outW);
|
||||
CloseHandle(inR);
|
||||
if (!ok) { CloseHandle(outR); CloseHandle(inW); return false; }
|
||||
CloseHandle(pi.hThread);
|
||||
g_engine = pi.hProcess;
|
||||
g_engineIn = inW;
|
||||
g_engineOut = outR;
|
||||
std::thread([] {
|
||||
std::string acc;
|
||||
char chunk[4096];
|
||||
DWORD n;
|
||||
while (ReadFile(g_engineOut, chunk, sizeof(chunk), &n, nullptr) && n > 0) {
|
||||
acc.append(chunk, n);
|
||||
size_t nl;
|
||||
while ((nl = acc.find('\n')) != std::string::npos) {
|
||||
std::string line = acc.substr(0, nl);
|
||||
acc.erase(0, nl + 1);
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
PostMessageW(g_hwnd, WM_ENGINE_LINE, 0, (LPARAM) new std::string(line));
|
||||
}
|
||||
}
|
||||
PostMessageW(g_hwnd, WM_ENGINE_LINE, 1, 0);
|
||||
}).detach();
|
||||
return true;
|
||||
}
|
||||
|
||||
static void showTrayMenu() {
|
||||
HMENU m = CreatePopupMenu();
|
||||
AppendMenuW(m, MF_STRING, ID_TRAY_OPEN, L"Open Igneum Wallet");
|
||||
AppendMenuW(m, MF_STRING, ID_TRAY_PAUSE, g_paused ? L"Lock" : L"Lock");
|
||||
AppendMenuW(m, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(m, MF_STRING | MF_GRAYED, 0, g_trayTitle.c_str());
|
||||
AppendMenuW(m, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(m, MF_STRING, ID_TRAY_QUIT, L"Quit Igneum Wallet");
|
||||
POINT pt; GetCursorPos(&pt);
|
||||
SetForegroundWindow(g_hwnd);
|
||||
TrackPopupMenu(m, TPM_RIGHTBUTTON | TPM_BOTTOMALIGN, pt.x, pt.y, 0, g_hwnd, nullptr);
|
||||
DestroyMenu(m);
|
||||
}
|
||||
|
||||
static void beginQuit() {
|
||||
if (g_quitting) return;
|
||||
g_quitting = true;
|
||||
g_quitStarted = GetTickCount64();
|
||||
g_status = L"Stopping: the miner first, then the node";
|
||||
setTray(L"Igneum Wallet: stopping");
|
||||
if (g_webview) g_webview->ExecuteScript(L"document.getElementById('pill-text').textContent='stopping'", nullptr);
|
||||
if (g_engine && !g_exited) {
|
||||
sendEngine("quit");
|
||||
SetTimer(g_hwnd, ID_QUIT_TIMER, 500, nullptr);
|
||||
} else {
|
||||
DestroyWindow(g_hwnd);
|
||||
}
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
switch (msg) {
|
||||
case WM_SIZE:
|
||||
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
|
||||
return 0;
|
||||
case WM_ENGINE_LINE: {
|
||||
if (wp == 1) {
|
||||
g_exited = true;
|
||||
if (g_quitting) { DestroyWindow(hwnd); return 0; }
|
||||
g_status = L"The engine stopped. Close this window and open Igneum Wallet again.";
|
||||
setTray(L"Igneum Wallet: stopped");
|
||||
repaintStatus();
|
||||
return 0;
|
||||
}
|
||||
std::string* line = (std::string*)lp;
|
||||
if (line->rfind("URL ", 0) == 0) {
|
||||
g_url = widen(line->substr(4));
|
||||
if (g_webviewOk) navigate();
|
||||
else if (!g_webview && g_status.find(L"WebView2") != std::wstring::npos && !g_hintShown) { openInBrowser(g_url); g_hintShown = true; }
|
||||
} else if (line->rfind("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;
|
||||
}
|
||||
36
app/windows/wallet-host.rc
Normal file
36
app/windows/wallet-host.rc
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Resources for Igneum Wallet.exe (the window host): the coin icon and the version block.
|
||||
// Compiled by BUILD-WALLET-APP.bat with rc.exe; the icon path is relative to brand\icons (passed with /i). The version comes
|
||||
// from wallet-version.h, shared with wallet-host.cpp.
|
||||
#include <winver.h>
|
||||
#include "wallet-version.h"
|
||||
|
||||
1 ICON "igneum.ico"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION IGNEUM_HOST_VERSION_RC
|
||||
PRODUCTVERSION IGNEUM_HOST_VERSION_RC
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
FILETYPE VFT_APP
|
||||
FILESUBTYPE VFT2_UNKNOWN
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904B0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Igneum"
|
||||
VALUE "FileDescription", "Igneum Wallet"
|
||||
VALUE "FileVersion", IGNEUM_HOST_VERSION_STR
|
||||
VALUE "InternalName", "Igneum Wallet"
|
||||
VALUE "LegalCopyright", "Igneum. Nothing is bought or sold."
|
||||
VALUE "OriginalFilename", "Igneum Wallet.exe"
|
||||
VALUE "ProductName", "Igneum Wallet"
|
||||
VALUE "ProductVersion", IGNEUM_HOST_VERSION_STR
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
7
app/windows/wallet-version.h
Normal file
7
app/windows/wallet-version.h
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// The version of "Igneum Wallet.exe" (the window host). One place for wallet-host.rc and wallet-host.cpp.
|
||||
// Keep it equal to app/igneum-wallet/Cargo.toml and the AppVersion default in packaging/windows/Igneum-Wallet.iss.
|
||||
#ifndef IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_STR "0.1.0"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,1,0,0
|
||||
#endif
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
45
packaging/mac/app/IgneumWallet-Info.plist
Normal file
45
packaging/mac/app/IgneumWallet-Info.plist
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Igneum Wallet</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Igneum Wallet</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>network.igneum.wallet</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>VERSION_STAMP</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Igneum Wallet</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>igneum</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>LSArchitecturePriority</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>LSRequiresNativeExecution</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Igneum. Nothing is bought or sold.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
91
packaging/mac/build-wallet-dmg.sh
Executable file
91
packaging/mac/build-wallet-dmg.sh
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
#!/bin/bash
|
||||
# Builds packaging/mac/dist/Igneum-Wallet-<version>.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 <v> --mac dist/Igneum-Wallet-<v>.dmg --notes "..."
|
||||
# NODE=<path> ENGINE=<path> 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)"
|
||||
15
packaging/mac/dmg/README-wallet.txt
Normal file
15
packaging/mac/dmg/README-wallet.txt
Normal file
|
|
@ -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.
|
||||
38
packaging/mac/dmg/wallet-settings.py
Normal file
38
packaging/mac/dmg/wallet-settings.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# dmgbuild settings for Igneum-Wallet-<version>.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),
|
||||
}
|
||||
|
|
@ -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" <<JSON
|
||||
{
|
||||
$override_line
|
||||
"update_manifest": "$manifest",
|
||||
"log_intake_url": "$LOG_URL",
|
||||
"log_intake_key": "$LOG_KEY",
|
||||
"live_page": "$LIVE_PAGE",
|
||||
"download_page": "$DOWNLOAD_PAGE",
|
||||
"public_rpc": "$WALLET_PUBLIC_RPC",
|
||||
"add_network_page": "$WALLET_ADD_NETWORK_PAGE"
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
# packaging/ota/publish-manifest.sh --version 0.3.1 --mac packaging/mac/dist/Igneum-Miner-0.3.1.dmg \
|
||||
# [--win packaging/windows/dist/Igneum-Miner-Setup-0.3.1.exe] --notes "one line of what changed" \
|
||||
# [--activation-height 120000 --deadline-note "difficulty v2"] [--min-supported 0.3.0] [--channel devnet] [--deploy]
|
||||
# [--product miner|wallet] wallet (4 October 2026): the same signer and key, the manifest is igneum-wallet-latest.json
|
||||
# (and .sig) next to the miner's; without the flag everything is the miner's, unchanged
|
||||
#
|
||||
# A platform you do not pass is carried over from the manifest already in the folder when that one has the same
|
||||
# version (the Windows build lands later than the Mac one: publish the Mac entry first, add the Windows entry when
|
||||
|
|
@ -28,7 +30,7 @@ PUB="$HOME/.config/igneum/ota-signing-key.pub"
|
|||
TOKEN_FILE="$HOME/.config/igneum/dl-token"
|
||||
SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign"
|
||||
|
||||
VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0
|
||||
VERSION="" MAC="" WIN="" NOTES="" ACTIVATION="" DEADLINE="" MIN_SUPPORTED="" CHANNEL="devnet" BASE="" DEST="" DEPLOY=0 PRODUCT="miner"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
|
|
@ -43,9 +45,11 @@ while [ $# -gt 0 ]; do
|
|||
--dest) DEST="$2"; shift 2 ;;
|
||||
--deploy) DEPLOY=1; shift ;;
|
||||
--no-deploy) DEPLOY=0; shift ;;
|
||||
--product) PRODUCT="$2"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&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() { # <file> -> "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
|
||||
|
|
|
|||
87
packaging/windows/Igneum-Wallet.iss
Normal file
87
packaging/windows/Igneum-Wallet.iss
Normal file
|
|
@ -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;
|
||||
178
tools/wallet-testnet/run.mjs
Normal file
178
tools/wallet-testnet/run.mjs
Normal file
|
|
@ -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/<token>')); });
|
||||
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/<token>/`);
|
||||
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);
|
||||
Loading…
Reference in a new issue