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:
igneum-labs 2026-10-05 01:47:28 +00:00
parent 81934e51e7
commit b03bf694e4
44 changed files with 12431 additions and 12 deletions

4
.gitignore vendored
View file

@ -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
View 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"

View 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"

View 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());
}
}

View 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)
}

View 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)
}

View 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");
}
}

View 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" };

View 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"), "");
}
}

View 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('&', "&amp;").replace('<', "&lt;")))
.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");
}
}

View 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

File diff suppressed because it is too large Load diff

View 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

View 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 &current {
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}")),
}
}
}

View 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));
}
}

View 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"));
}
}

View 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
}
}

View 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)
}

View 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)
}

View 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))
}
}

View 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"));
}
}

View 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()),
}
}

View 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
View 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");
}
}

View 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())
}
}

View 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());
}
}

View 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
View 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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);

View 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 &middot; 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">&nbsp;</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>

View 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
View 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()

View 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
View 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;
}

View 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

View 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

View file

@ -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.

View 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>

View 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)"

View 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.

View 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),
}

View file

@ -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
}

View file

@ -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

View 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;

View 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);