From 7210fd46834a84e19224af2221717339cec1c147 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:54:56 +0000 Subject: [PATCH 1/4] Public-testnet readiness: fee floors and pgas table analysis, testnet identity doc, G14 rewrite plan with dry run, G13 signed build inputs, testnet terms, MetaMask page - docs/analysis/base-fee-floor.md: the base-fee floors (100 gwei per gas, 10,000 gwei per pgas), B_p 120,000 and S_p 30,000 pgas, the calibrated v1 pgas table (intrinsic 300, modexp 10 + 1 per 10 bytes) from the measured 44 cycles per EVM gas and 9 cycles per pgas, with the arithmetic and a stated price assumption; spec 05 section 5.10. The parameters are implemented on the node fork branch testnet-params (vendor, not in this repository). - docs/testnet/README.md: igneum-testnet-1 (chain id 4462, ports 268xx, frozen genesis 2026-10-05T00:00:00Z with its message and hash, mainnet finality window, every switch from genesis, no override file) and the reset policy. Every value proposed, for the morning sign-off. - docs/plans/history-rewrite.md: G14, the exact git-filter-repo pass, the dry run on a throwaway mirror clone (0 identity hits, 0 secrets, every stamp +0000, 312 commits), what breaks and the order for the morning. - G13: app/igneum-app/src/inputs.rs and igneum-ota-sign sign-inputs / verify-inputs; push-inputs.sh signs payload-inputs.json with the OTA key and pins the node commit (packaging/windows/node-source.pin); windows.yml verifies the signature with the embedded key, the zip, every file and the pin before building and uploads the verified record; fetch-ci-artifacts.sh signs the update manifest only with --sign-manifest after re-verifying that run's inputs. test-inputs-signing.sh (16 cases) and tools/ci/check-workflow-shell.mjs. - site: testnet terms on the download section, wallet.html (wallet_addEthereumChain, chain ids 4462/4463, IGN, 18 decimals), the litepaper's app paragraph (MetaMask and the coming Igneum Wallet, no hardware wallet) and the miner fee sentence (no protocol fee; optional 1% in the miner software, off with one flag). node site/build.mjs and the link check pass. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/windows.yml | 42 +++- app/igneum-app/src/bin/ota-sign.rs | 55 ++++- app/igneum-app/src/inputs.rs | 281 ++++++++++++++++++++++ docs/analysis/base-fee-floor.md | 135 +++++++++++ docs/plans/history-rewrite.md | 121 ++++++++++ docs/spec/05-fees-and-economics.md | 29 ++- docs/testnet/README.md | 79 +++++++ packaging/windows/fetch-ci-artifacts.sh | 68 +++++- packaging/windows/inputs-manifest.sh | 44 ++++ packaging/windows/node-source.pin | 1 + packaging/windows/push-inputs.sh | 71 +++--- packaging/windows/test-inputs-signing.sh | 85 +++++++ site/404.html | 1 + site/bench.html | 1 + site/build.mjs | 2 +- site/evidence.html | 1 + site/index.html | 19 +- site/journey.json | 20 +- site/litepaper.html | 5 +- site/live.html | 1 + site/partials/footer.html | 1 + site/sitemap.xml | 1 + site/wallet.html | 286 +++++++++++++++++++++++ tools/ci/check-workflow-shell.mjs | 101 ++++++++ 24 files changed, 1389 insertions(+), 61 deletions(-) create mode 100644 app/igneum-app/src/inputs.rs create mode 100644 docs/analysis/base-fee-floor.md create mode 100644 docs/plans/history-rewrite.md create mode 100644 docs/testnet/README.md create mode 100755 packaging/windows/inputs-manifest.sh create mode 100644 packaging/windows/node-source.pin create mode 100755 packaging/windows/test-inputs-signing.sh create mode 100644 site/wallet.html create mode 100644 tools/ci/check-workflow-shell.mjs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index ef8b57bd..4ab49c49 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -13,7 +13,13 @@ # Inputs that are not in git (igneumd.exe, igneum-miner.exe from the node fork; the prebuilt GPU workers with NVIDIA's # NVRTC DLLs) come from payload-inputs.zip on the downloads host, published by packaging/windows/push-inputs.sh on the # Mac; the DL_TOKEN repository secret is the path token (gh secret set DL_TOKEN < ~/.config/igneum/dl-token). -# The Mac side of the loop is packaging/windows/fetch-ci-artifacts.sh (gh run download into the downloads folder). +# The zip is trusted only through payload-inputs.json and its detached Ed25519 signature, made on the Mac with the +# OTA key: the step "payload inputs" verifies the signature with the public key compiled into the app +# (igneum-ota-sign verify-inputs embedded, built by the engine step), checks the zip's sha256 and every unpacked +# file against the manifest, and checks the manifest's node commit against packaging/windows/node-source.pin in +# this checkout, all before anything is built from them (review round 4, R4.5.2, ledger G13). The verified +# manifest, its signature and the runner's record go up as the igneum-windows-inputs artifact, which +# packaging/windows/fetch-ci-artifacts.sh re-verifies on the Mac before it will sign an update manifest. name: windows-ci on: push: @@ -110,7 +116,7 @@ jobs: cargo build --release --locked ls -la target/release/igneum-app.exe - - name: payload inputs (payload-inputs.zip from the downloads host, sha256 checked) + - name: payload inputs (payload-inputs.zip from the downloads host, signature, hashes and node commit verified) shell: bash env: DL_TOKEN: ${{ secrets.DL_TOKEN }} @@ -121,17 +127,33 @@ jobs: exit 1 fi base="https://dl.igneum.network/dl/$DL_TOKEN" + signer="app/igneum-app/target/release/igneum-ota-sign.exe" + [ -x "$signer" ] || { echo "::error::$signer was not built by the engine step"; exit 1; } + pin="packaging/windows/node-source.pin" + [ -s "$pin" ] || { echo "::error::$pin is missing: push-inputs.sh writes it, commit it with the inputs push"; exit 1; } mkdir -p build/inputs "$HOME/.config/igneum" printf '%s' "$DL_TOKEN" > "$HOME/.config/igneum/dl-token" # make-payload.sh reads it for the update manifest URL curl -fsSL --retry 3 -o build/payload-inputs.json "$base/payload-inputs.json" - curl -fsSL --retry 3 -o build/payload-inputs.sha256 "$base/payload-inputs.sha256" + curl -fsSL --retry 3 -o build/payload-inputs.json.sig "$base/payload-inputs.json.sig" curl -fsSL --retry 3 -o build/payload-inputs.zip "$base/payload-inputs.zip" - echo "$(tr -d '[:space:]' < build/payload-inputs.sha256) build/payload-inputs.zip" | sha256sum -c - + echo "inputs manifest:"; cat build/payload-inputs.json + # 1. the signature (the key compiled into the app), the zip's sha256 and size, the pinned node commit: all before unpacking + "$signer" verify-inputs embedded build/payload-inputs.json build/payload-inputs.json.sig --zip build/payload-inputs.zip --node-commit "$pin" 7z x -y -bso0 -bsp0 -obuild/inputs-unpacked build/payload-inputs.zip mv build/inputs-unpacked/payload-inputs/* build/inputs/ - echo "inputs manifest:"; cat build/payload-inputs.json + # 2. every unpacked file by sha256 and size, and nothing in the folder the manifest does not name + "$signer" verify-inputs embedded build/payload-inputs.json build/payload-inputs.json.sig --dir build/inputs echo "inputs:"; ls -la build/inputs for f in igneumd.exe igneum-miner.exe; do [ -f "build/inputs/$f" ] || { echo "::error::payload-inputs.zip has no $f"; exit 1; }; done + # 3. the runner's record for fetch-ci-artifacts.sh, which re-verifies the signature and the pin on the Mac + fp="$("$signer" embedded | sed -n 2p)" + node_commit="$(jq -r .node_source_commit build/payload-inputs.json)" + zip_sha="$(jq -r .zip.sha256 build/payload-inputs.json)" + mkdir -p build/inputs-artifact + cp build/payload-inputs.json build/payload-inputs.json.sig build/inputs-artifact/ + printf '{ "run_id": "%s", "run_attempt": "%s", "head_sha": "%s", "key_fingerprint": "%s", "node_commit": "%s", "zip_sha256": "%s", "verified_at": "%s" }\n' \ + "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_SHA" "$fp" "$node_commit" "$zip_sha" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > build/inputs-artifact/inputs-verified.json + cat build/inputs-artifact/inputs-verified.json - name: window host (app\windows\BUILD-APP.bat, exactly as on the PC) shell: cmd @@ -211,7 +233,9 @@ jobs: printf '| %s | %s |\n' "$(basename "$f")" "$(stat -c %s "$f")" done echo - echo "inputs: $(tr -d '\n' < build/payload-inputs.json | head -c 400)" + echo "inputs (signature, hashes and node commit verified): $(tr -d '\n' < build/payload-inputs.json | head -c 400)" + echo + echo "verified: $(cat build/inputs-artifact/inputs-verified.json)" } | tee -a "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v4 @@ -232,3 +256,9 @@ jobs: path: app/windows/dist/Igneum Miner.exe retention-days: 90 if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: igneum-windows-inputs + path: build/inputs-artifact/ + retention-days: 90 + if-no-files-found: error diff --git a/app/igneum-app/src/bin/ota-sign.rs b/app/igneum-app/src/bin/ota-sign.rs index 87f71c6a..436121a9 100644 --- a/app/igneum-app/src/bin/ota-sign.rs +++ b/app/igneum-app/src/bin/ota-sign.rs @@ -9,11 +9,18 @@ //! igneum-ota-sign sha256 the file's sha256 and size, for the manifest //! igneum-ota-sign sign-jobs the remote-jobs file (src/jobs.rs), same key //! igneum-ota-sign verify-jobs +//! igneum-ota-sign sign-inputs the Windows build inputs (src/inputs.rs), same key +//! igneum-ota-sign verify-inputs +//! [--zip ] [--dir ] [--node-commit <40 hex>] +//! exit 0 only when the signature, the zip, every +//! unpacked file and the pinned commit all check #[path = "../manifest.rs"] mod manifest; #[path = "../jobs.rs"] mod jobs; +#[path = "../inputs.rs"] +mod inputs; use ed25519_dalek::{Signer, SigningKey}; use std::path::Path; @@ -115,8 +122,54 @@ fn main() { Err(e) => die(&e), } } + Some("sign-inputs") if args.len() == 3 => { + let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex")); + let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes")); + let sk = SigningKey::from_bytes(&seed); + let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); + let text = std::str::from_utf8(&bytes).unwrap_or_else(|_| die("inputs manifest is not UTF-8")); + let m = inputs::parse(text).unwrap_or_else(|e| die(&format!("refusing to sign: {e}"))); + eprintln!( + "signing inputs built {} from node commit {} ({}): zip {} bytes, {} file(s)", + m.built_at, + &m.node_source_commit[..12], + m.node_source_branch, + m.zip.bytes, + m.files.len() + ); + println!("{}", manifest::hex_encode(&sk.sign(&bytes).to_bytes())); + } + Some("verify-inputs") if args.len() >= 4 => { + let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) }; + let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); + let sig_text = std::fs::read_to_string(&args[3]).unwrap_or_else(|e| die(&format!("{}: {e}", args[3]))); + let sig = inputs::read_signature(&sig_text).unwrap_or_else(|e| die(&e)); + let m = inputs::verify_and_parse(&bytes, &sig, &pk).unwrap_or_else(|e| die(&format!("inputs signature: {e}"))); + let mut i = 4; + let mut checked: Vec = vec![format!("signature by {}", manifest::fingerprint(&pk))]; + while i < args.len() { + match (args[i].as_str(), args.get(i + 1)) { + ("--zip", Some(z)) => { + inputs::check_zip(&m, Path::new(z)).unwrap_or_else(|e| die(&e)); + checked.push(format!("zip {} ({} bytes)", m.zip.sha256, m.zip.bytes)); + } + ("--dir", Some(d)) => { + inputs::check_dir(&m, Path::new(d)).unwrap_or_else(|e| die(&e)); + checked.push(format!("{} unpacked file(s)", m.files.len())); + } + ("--node-commit", Some(c)) => { + let c = if Path::new(c).is_file() { std::fs::read_to_string(c).unwrap_or_default() } else { c.to_string() }; + inputs::check_node_commit(&m, &c).unwrap_or_else(|e| die(&e)); + checked.push(format!("node commit {}", m.node_source_commit)); + } + (flag, _) => die(&format!("unknown or incomplete argument {flag}")), + } + i += 2; + } + println!("ok: inputs built {} from node commit {} ({}); checked: {}", m.built_at, m.node_source_commit, m.node_source_branch, checked.join(", ")); + } _ => { - eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 | sign-jobs | verify-jobs "); + eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 | sign-jobs | verify-jobs | sign-inputs | verify-inputs [--zip z] [--dir d] [--node-commit c]"); std::process::exit(2); } } diff --git a/app/igneum-app/src/inputs.rs b/app/igneum-app/src/inputs.rs new file mode 100644 index 00000000..08ada089 --- /dev/null +++ b/app/igneum-app/src/inputs.rs @@ -0,0 +1,281 @@ +//! The signed payload-inputs manifest (review round 4, R4.5.2, ledger G13). +//! +//! The Windows build on GitHub's runner cannot make the node, the miner or the GPU workers (they come from the +//! node fork, which is not in the repository, and from NVIDIA's redistributables). Those files travel as +//! `payload-inputs.zip` on the downloads host. Before 4 October 2026 the runner checked the zip against a sha256 +//! served beside it, which is a transfer check, not an authentication: whoever controls the host controls the +//! binaries, and the Mac then signed the update manifest over whatever the run produced. +//! +//! Now `packaging/windows/push-inputs.sh` writes `payload-inputs.json` (this format), signs it on the Mac with the +//! OTA key (`igneum-ota-sign sign-inputs`) and uploads the signature beside it. The workflow verifies the signature +//! with the public key compiled into the app (`manifest::OTA_PUBLIC_KEY_HEX`) before it builds anything, checks +//! the zip's sha256 and every unpacked file against the manifest, and checks the pinned node source commit +//! against `packaging/windows/node-source.pin` in the commit it builds. `fetch-ci-artifacts.sh` refuses to sign an +//! update manifest unless the run's verified inputs manifest re-verifies on the Mac. +//! +//! The bytes signed are the file as uploaded. `parse` refuses anything it does not understand, so a manifest the +//! signer would not sign is also one the verifier would not accept. + +use crate::manifest::{hex_decode, sha256_file, verify_signature}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; + +/// The format tag every manifest must carry. +pub const FORMAT: &str = "igneum-payload-inputs/1"; + +/// Files the payload cannot do without; the verifier refuses a manifest that omits one. +pub const REQUIRED_FILES: &[&str] = &["igneumd.exe", "igneum-miner.exe"]; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FileEntry { + pub sha256: String, + pub bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputsManifest { + pub format: String, + /// When the zip was built, UTC, `YYYY-MM-DDTHH:MM:SSZ`. + pub built_at: String, + /// The node fork commit the exes were built from (40 hex), and its branch (informational). + pub node_source_commit: String, + pub node_source_branch: String, + /// The main repository commit `push-inputs.sh` ran at (40 hex; informational). + pub repo_commit: String, + /// The zip as uploaded. + pub zip: FileEntry, + /// Every file inside the zip's `payload-inputs/` folder, by name. + pub files: BTreeMap, +} + +fn is_hex(s: &str, len: usize) -> bool { + s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit()) && s.bytes().all(|b| !b.is_ascii_uppercase()) +} + +fn check_entry(name: &str, e: &FileEntry) -> Result<(), String> { + if !is_hex(&e.sha256, 64) { + return Err(format!("{name}: sha256 is not 64 lowercase hex characters")); + } + if e.bytes == 0 { + return Err(format!("{name}: bytes is 0")); + } + Ok(()) +} + +/// Parses and validates a manifest. Unknown fields, missing fields, a wrong format tag, a malformed hash or +/// commit, an empty file list or a missing required file are all refused. +pub fn parse(text: &str) -> Result { + let m: InputsManifest = serde_json::from_str(text).map_err(|e| format!("inputs manifest: {e}"))?; + if m.format != FORMAT { + return Err(format!("inputs manifest: format is {:?}, this build understands {FORMAT:?}", m.format)); + } + if m.built_at.len() != 20 || !m.built_at.ends_with('Z') || m.built_at.as_bytes()[10] != b'T' { + return Err("inputs manifest: built_at is not YYYY-MM-DDTHH:MM:SSZ".into()); + } + if !is_hex(&m.node_source_commit, 40) { + return Err("inputs manifest: node_source_commit is not a 40-character lowercase hex commit".into()); + } + if !is_hex(&m.repo_commit, 40) { + return Err("inputs manifest: repo_commit is not a 40-character lowercase hex commit".into()); + } + if m.node_source_branch.trim().is_empty() { + return Err("inputs manifest: node_source_branch is empty".into()); + } + check_entry("zip", &m.zip)?; + if m.files.is_empty() { + return Err("inputs manifest: files is empty".into()); + } + for (name, e) in &m.files { + if name.is_empty() || name.contains('/') || name.contains('\\') || name == "." || name == ".." { + return Err(format!("inputs manifest: {name:?} is not a plain file name")); + } + check_entry(name, e)?; + } + for r in REQUIRED_FILES { + if !m.files.contains_key(*r) { + return Err(format!("inputs manifest: no {r} in files")); + } + } + Ok(m) +} + +/// Verifies the detached signature over the exact bytes, then parses. +pub fn verify_and_parse(bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result { + verify_signature(bytes, sig_hex, pub_hex)?; + let text = std::str::from_utf8(bytes).map_err(|_| "inputs manifest is not UTF-8")?; + parse(text) +} + +/// The zip on disk must be the one the manifest names: same sha256, same size. +pub fn check_zip(m: &InputsManifest, zip: &Path) -> Result<(), String> { + let sum = sha256_file(zip).map_err(|e| format!("{}: {e}", zip.display()))?; + let size = std::fs::metadata(zip).map(|md| md.len()).unwrap_or(0); + if sum != m.zip.sha256 { + return Err(format!("{}: sha256 {sum} is not the manifest's {}", zip.display(), m.zip.sha256)); + } + if size != m.zip.bytes { + return Err(format!("{}: {size} bytes, the manifest says {}", zip.display(), m.zip.bytes)); + } + Ok(()) +} + +/// The unpacked folder must hold exactly the manifest's files, each with its sha256 and size. A file the manifest +/// does not name is refused too: nothing rides into the payload unsigned. +pub fn check_dir(m: &InputsManifest, dir: &Path) -> Result<(), String> { + let mut seen = 0usize; + let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name().to_string_lossy().to_string(); + if name == ".DS_Store" { + continue; + } + let Some(want) = m.files.get(&name) else { + return Err(format!("{name}: in the folder but not in the signed manifest")); + }; + let p = entry.path(); + let sum = sha256_file(&p).map_err(|e| format!("{name}: {e}"))?; + let size = std::fs::metadata(&p).map(|md| md.len()).unwrap_or(0); + if sum != want.sha256 || size != want.bytes { + return Err(format!("{name}: sha256 {sum} ({size} bytes) is not the manifest's {} ({} bytes)", want.sha256, want.bytes)); + } + seen += 1; + } + if seen != m.files.len() { + let missing: Vec<&String> = m.files.keys().filter(|k| !dir.join(k).is_file()).collect(); + return Err(format!("the folder holds {seen} of the manifest's {} files; missing {:?}", m.files.len(), missing)); + } + Ok(()) +} + +/// The commit the manifest pins must be the commit the repository expects (`packaging/windows/node-source.pin`). +pub fn check_node_commit(m: &InputsManifest, expected: &str) -> Result<(), String> { + let expected = expected.trim(); + if !is_hex(expected, 40) { + return Err(format!("expected node commit {expected:?} is not a 40-character lowercase hex commit")); + } + if m.node_source_commit != expected { + return Err(format!("the manifest pins node commit {} but the repository expects {expected}", m.node_source_commit)); + } + Ok(()) +} + +/// A signature file holds 128 hex characters and nothing else of substance. +pub fn read_signature(text: &str) -> Result { + let s = text.trim(); + match hex_decode(s) { + Some(b) if b.len() == 64 => Ok(s.to_string()), + _ => Err("signature is not 128 hex characters".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::hex_encode; + use ed25519_dalek::{Signer, SigningKey}; + + const SHA: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const COMMIT: &str = "6aa69a45364b9b30a32695e33eb66f100c9be85f"; + + fn sample() -> String { + format!( + r#"{{"format":"{FORMAT}","built_at":"2026-10-04T20:07:21Z","node_source_commit":"{COMMIT}","node_source_branch":"finality-fixes","repo_commit":"{COMMIT}","zip":{{"sha256":"{SHA}","bytes":123}},"files":{{"igneumd.exe":{{"sha256":"{SHA}","bytes":1}},"igneum-miner.exe":{{"sha256":"{SHA}","bytes":2}}}}}}"# + ) + } + + 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_a_good_manifest() { + let m = parse(&sample()).unwrap(); + assert_eq!(m.node_source_commit, COMMIT); + assert_eq!(m.files.len(), 2); + assert_eq!(m.zip.bytes, 123); + check_node_commit(&m, COMMIT).unwrap(); + assert!(check_node_commit(&m, &COMMIT.replace('6', "7")).unwrap_err().contains("expects")); + assert!(check_node_commit(&m, "6aa69a45").unwrap_err().contains("40-character")); + } + + #[test] + fn refuses_what_the_signer_would_not_sign() { + let good = sample(); + let cases = [ + (good.replace(FORMAT, "igneum-payload-inputs/2"), "format"), + (good.replace("\"node_source_branch\":\"finality-fixes\",", ""), "missing field"), + (good.replace("\"zip\":", "\"extra\":1,\"zip\":"), "unknown field"), + (good.replace(&format!("\"node_source_commit\":\"{COMMIT}\""), "\"node_source_commit\":\"6aa69a45\""), "node_source_commit"), + (good.replace("2026-10-04T20:07:21Z", "2026-10-04 20:07:21"), "built_at"), + (good.replace("\"bytes\":123", "\"bytes\":0"), "bytes is 0"), + (good.replace("\"igneum-miner.exe\"", "\"igneum-miner.exe.bak\""), "no igneum-miner.exe"), + (good.replace("\"igneumd.exe\"", "\"../igneumd.exe\""), "plain file name"), + (good.replace(SHA, &SHA.to_uppercase()), "lowercase hex"), + ]; + for (text, why) in cases { + let err = parse(&text).unwrap_err(); + assert!(err.contains(why), "{why}: {err}"); + } + } + + #[test] + fn sign_verify_and_tamper() { + let (sk, pk) = key(); + let bytes = sample().into_bytes(); + let sig = hex_encode(&sk.sign(&bytes).to_bytes()); + assert_eq!(read_signature(&format!("{sig}\n")).unwrap(), sig); + assert!(read_signature("abc").is_err()); + let m = verify_and_parse(&bytes, &sig, &pk).unwrap(); + assert_eq!(m.node_source_commit, COMMIT); + // one byte changed anywhere: the signature no longer verifies + let mut tampered = bytes.clone(); + let i = tampered.iter().position(|b| *b == b'1').unwrap(); + tampered[i] = b'2'; + assert!(verify_and_parse(&tampered, &sig, &pk).is_err()); + // a different key: refused + let other = hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes()); + assert!(verify_and_parse(&bytes, &sig, &other).is_err()); + // the embedded OTA key refuses a signature from this test key + assert!(verify_and_parse(&bytes, &sig, crate::manifest::OTA_PUBLIC_KEY_HEX).is_err()); + } + + #[test] + fn zip_and_folder_checks() { + let dir = std::env::temp_dir().join(format!("igneum-inputs-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("unpacked")).unwrap(); + std::fs::write(dir.join("unpacked/igneumd.exe"), b"node").unwrap(); + std::fs::write(dir.join("unpacked/igneum-miner.exe"), b"miner!").unwrap(); + std::fs::write(dir.join("payload-inputs.zip"), b"zipzipzip").unwrap(); + let sha = |p: &Path| sha256_file(p).unwrap(); + let text = format!( + r#"{{"format":"{FORMAT}","built_at":"2026-10-04T20:07:21Z","node_source_commit":"{COMMIT}","node_source_branch":"finality-fixes","repo_commit":"{COMMIT}","zip":{{"sha256":"{}","bytes":9}},"files":{{"igneumd.exe":{{"sha256":"{}","bytes":4}},"igneum-miner.exe":{{"sha256":"{}","bytes":6}}}}}}"#, + sha(&dir.join("payload-inputs.zip")), + sha(&dir.join("unpacked/igneumd.exe")), + sha(&dir.join("unpacked/igneum-miner.exe")) + ); + let m = parse(&text).unwrap(); + check_zip(&m, &dir.join("payload-inputs.zip")).unwrap(); + check_dir(&m, &dir.join("unpacked")).unwrap(); + // a changed byte in the zip + std::fs::write(dir.join("payload-inputs.zip"), b"zipzipzip!").unwrap(); + assert!(check_zip(&m, &dir.join("payload-inputs.zip")).unwrap_err().contains("sha256")); + // an unlisted file in the folder + std::fs::write(dir.join("unpacked/extra.dll"), b"x").unwrap(); + assert!(check_dir(&m, &dir.join("unpacked")).unwrap_err().contains("not in the signed manifest")); + std::fs::remove_file(dir.join("unpacked/extra.dll")).unwrap(); + // a changed file + std::fs::write(dir.join("unpacked/igneumd.exe"), b"nodE").unwrap(); + assert!(check_dir(&m, &dir.join("unpacked")).unwrap_err().contains("igneumd.exe")); + // a missing file + std::fs::remove_file(dir.join("unpacked/igneumd.exe")).unwrap(); + assert!(check_dir(&m, &dir.join("unpacked")).unwrap_err().contains("missing")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/docs/analysis/base-fee-floor.md b/docs/analysis/base-fee-floor.md new file mode 100644 index 00000000..2632aab7 --- /dev/null +++ b/docs/analysis/base-fee-floor.md @@ -0,0 +1,135 @@ +# Base-fee floors and the prover-gas table: the model (PROPOSED, 4 October 2026, night) + +Status: every number below is proposed until the project signs it off in the morning. The parameters are written into +the node fork on branch `testnet-params` (worktree `vendor/igneum-node-testnet`, `consensus/core/src/fees.rs`, +`FeeParams::CALIBRATED_V1`, carried by `Params.fees` for devnet, testnet, simnet and mainnet and by the override +file) and unit-tested there. Spec 05 section 5.10 carries the summary. Nothing is deployed. + +Inputs the model takes from the repository, with their status: + +| Input | Value | Status, source | +|---|---|---| +| Block rate | 1 block per second | Designed (spec 02; `BlockrateParams::new::<1>()`) | +| Execution gas per block `B_e` | 30,000,000 | Implemented, devnet v3 value (`consensus/core/src/evm.rs`, `BLOCK_EXECUTION_GAS_LIMIT`) | +| Plain transfer, execution gas | 21,000 | Ethereum's rule | +| Year-one block subsidy | 31.69 IGN (3,168,808,781 sompi per second) | Implemented (`consensus/core/src/igneum.rs`) | +| SP1 cycles per EVM gas, modexp-heavy shard | 44 | Measured (bench-log, "shard proving on the RTX 5090", run-20261004-173115) | +| SP1 cycles per prototype pgas, same shard | 9 | Measured (same) | +| SP1 cycles per prototype pgas, plain-transfer shard | 1,400 to 1,600 | Measured (bench-log, 4 October, "proving: devnet v4 shards") | +| Compressed proof of a 60 M-cycle shard, one RTX 5090 | 10.9 s | Measured (same run) | +| Aggregation of a block's shards | 2.2 to 2.5 s | Measured (same run) | +| Token price | $0.10 per IGN | ASSUMPTION for the arithmetic only; sensitivities at $0.01, $1 and $2 below. Not a forecast, not a claim | +| Electricity | $0.15 per kWh; RTX 5090 at 575 W while proving | Approximate (the economy analysis used $0.02 to $0.40; 575 W is the card's rated draw, not measured here) | + +## 1. The prover-gas table, calibrated v1 + +The unit is unchanged: 1 pgas stands for 1,000 reference SP1 cycles. The prototype table of 3 October charged every +opcode and precompile by shape with magnitudes nobody had measured. Two of its entries are now measured. + +| Entry | Prototype (3 October) | Measurement | Calibrated v1 | +|---|---|---|---| +| modexp (0x05) | 1,000 + 10 per input byte | the modexp-dominated shard ran 60.76 M cycles for 6.75 M prototype pgas: 9 cycles per pgas against the unit's 1,000, so the entry is about 111x its cost | 10 + 1 per 10 input bytes (the prototype over 100) | +| Intrinsic per transaction | 200 | the plain-transfer shard ran 1,400 to 1,600 cycles per prototype pgas: 200 x 1,500 = 300,000 cycles per transaction, which includes the shard's fixed witness check and root computations, so it is an upper bound | 300 | +| Every other opcode and precompile | prototype shape | not measured | prototype shape, unchanged, table version 1 | + +What the two constants say about a transaction: the modexp shard metered 1,390,773 EVM gas for 60.76 M cycles, 44 +cycles per gas, which is 0.044 pgas per gas at the unit; a plain transfer is 300 pgas for 21,000 gas, 0.014 pgas per +gas. The design expected a `pgas / gas` band of 0.1 to 10 (execution-layer design 4.3); the measured band is 0.01 to +0.05, so proving gas is cheaper per gas than the design guessed, by 10x, on the two workloads measured. The +remaining entries (ecrecover 3,000 pgas, ecpairing 45,000 per pair, the storage opcodes) are the next calibration; +each is one SP1 run of a fixture that isolates it. + +## 2. The shard and block budgets + +| Quantity | Value | Arithmetic | +|---|---|---| +| Shard budget `S_p` | 30,000 pgas | 30 M cycles: half the measured 60 M-cycle shard. One RTX 5090 compresses it in about 5.5 s (linear in cycles from 10.9 s, approximate); a 12 GB card in about 20 s (approximate: the economy simulation's shard shares put a 3060 at 3.7x the 5090's time; unmeasured, the phase 2 gate) | +| Block budget `B_p` | 120,000 pgas | 4 x `S_p`, the prototype's ratio (spec 7.4) | +| Transfers per block at `B_p` | 400 | 120,000 / 300 | +| Execution gas those use | 8,400,000 | 400 x 21,000, 28% of `B_e`: the proving dimension binds first for transfers | +| Block proof time, four RTX 5090s | about 8 s | 5.5 s per shard in parallel plus 2.5 s aggregation (approximate), inside the 20 to 60 s launch target | +| Block proof time, four 12 GB cards | about 23 s | 20 + 2.5 s (approximate) | +| Cards to keep pace at full blocks | 22 RTX 5090s, or about 80 12 GB cards | 4 shards x 5.5 s = 22 card-seconds per second; x 3.7 for the 12 GB class (approximate) | + +The prototype `B_p` of 30,000,000 pgas was "equal to `B_e`" and never a throughput number: at 9 cycles per prototype +pgas a full prototype block is 270 M cycles, 49 s on one 5090, and at the plain-transfer rate it is 45 G cycles. The +calibrated `B_p` is a throughput number: one block per second provable by a fleet the economy simulation already +models. Raising it is a parameter the genesis rules leave to miners (60% signalling, spec 5.5), and the economy +analysis of 4 October recommends tying it to the live proving fleet on the testnet. + +## 3. The base-fee floors + +Both base fees are burned in full (spec 5.1) and adjusted by EIP-1559 toward half the limit with a denominator of 8 +(1/8 per block at the extremes). The floor is the lowest value either fee can reach. It has three jobs: keep a plain +transfer cheap, make a full block cost real money from the first block, and price proving above the electricity it +burns so spam cannot be cheaper than the work it imposes. + +| Floor | Value | In IGN | +|---|---|---| +| Execution base fee `f_e` | 100 gwei per gas | 0.0000001 IGN per gas | +| Proving base fee `f_p` | 10,000 gwei per pgas | 0.00001 IGN per pgas | +| Initial base fees at genesis | the floors | | + +### A plain transfer at the floor + +| Term | Arithmetic | IGN | +|---|---|---| +| Execution | 21,000 x 100 gwei | 0.0021 | +| Proving | 300 x 10,000 gwei | 0.0030 | +| Total (tip excluded) | | 0.0051 | + +| Token price (assumption) | $0.01 | $0.10 | $1 | $2 | +|---|---|---|---|---| +| Transfer at the floor | $0.000051 | $0.00051 | $0.0051 | $0.0102 | + +The target "under $0.01 per simple transfer" holds up to $1.96 per IGN. Under load the fee leaves the floor: after +`n` consecutive full blocks the base fee is the floor times 1.125^n, which is 3.2x after 10 blocks, 34x after 30 and +about 1,170x after 60 blocks (one minute). The floor prices the quiet chain; the controller prices the busy one. + +### A full block at the floor, which is what spam costs + +| Case | Arithmetic | IGN per block | Per day (86,400 blocks) | At $0.10 per day | +|---|---|---|---|---| +| Execution dimension full (30 M gas of cheap-to-prove calls) | 30,000,000 x 100 gwei | 3.0 | 259,200 | $25,920 | +| Proving dimension full with transfers (400 transfers) | 120,000 x 10,000 gwei + 8,400,000 x 100 gwei | 1.2 + 0.84 = 2.04 | 176,256 | $17,626 | +| Both dimensions full (the worst mix) | | up to 4.2 | 362,880 | $36,288 | + +A self-paying spam loop (a miner filling its own blocks, or a contract that calls itself until the gas is gone) pays +the same: the base fee is burned, the tip returns to the miner and nets to zero, so a miner that fills its own block +burns 3.0 IGN against a subsidy of 31.69 IGN, 9.5% of its own reward per filled block, for nothing. And only at the +floor: after one minute of full blocks the controller has multiplied every number above by about 1,170. + +### Proving priced above its electricity + +| Quantity | Arithmetic | Value | +|---|---|---| +| Cycles per second, one RTX 5090 | 60 M cycles / 10.9 s | 5.5 M | +| Energy per pgas (1,000 cycles) | 575 W x 1,000 / 5.5 M | 0.105 J = 2.9 x 10^-8 kWh | +| Electricity per pgas at $0.15 per kWh | | $4.4 x 10^-9 | +| Floor per pgas at $0.10 per IGN | 0.00001 IGN | $1.0 x 10^-6 | +| Floor over electricity | | 230x at $0.10; 23x at $0.01; 1x at $0.00044 | + +The floor covers the physical cost of the proving it buys down to a token price of about $0.0004, which is where +this anchor would bind before the spam anchor does. The execution dimension has no such anchor: native execution of +a full block costs tens of milliseconds of CPU; its floor is set by the spam arithmetic alone, as Ethereum's is. + +## 4. What the table and the floors do not settle + +| Item | State | +|---|---| +| The other opcode and precompile entries | prototype shapes; calibrate per entry in SP1 with three input sizes (design R1) | +| The intrinsic 300 | an upper bound that includes the per-shard fixed cost; a shard with many transfers will show the marginal number | +| The 12 GB card time for `S_p` | approximate, from the simulation's share ratios; the phase 2 gate measures it | +| The prover's mirror of the table | `proving/igneum-prove/core/src/config.rs` and `pgas.rs` carry the prototype values at `b7fca5a0`; they must change in lockstep with the node (the guest program's id changes, every fixture is re-cut at the new `S_p`), or the shard statement differs from the node's. Not changed tonight | +| The devnet rollout | `Params.fees` has no height switch; the devnet moves to v1 either at a fresh chain (as the testnet does from genesis) or by a coordinated restart with the override file carrying `fees`. A `fees_v1_activation_daa` switch is the alternative if the live devnet must keep its history; it needs the DAA score at every metering site in `igneum/exec` | +| The price assumption | $0.10 is an arithmetic assumption. The floor is a parameter the genesis rules leave to miners (60% signalling) and can be moved by them | + +## 5. Where the numbers live + +| What | Where | +|---|---| +| The parameter set and its tests | `vendor/igneum-node-testnet/consensus/core/src/fees.rs` (branch `testnet-params`) | +| Per network | `consensus/core/src/config/params.rs`, `Params.fees` (`FeeParams::DEVNET`, `TESTNET`, `MAINNET` = `CALIBRATED_V1`; `FeeParams::PROTOTYPE` kept for the record and for the override file) | +| The execution layer's readers | `igneum/exec/src/config.rs` (`block_proving_gas_limit()`, `intrinsic_pgas_per_tx()`, the floor and initial readers), `pgas.rs` (the modexp entry), `executor.rs` (`next_base_fee` takes the dimension's floor) | +| Installed at start | `kaspad/src/daemon.rs` (`install_fee_params`, printed with the PoW schedule) | +| The specification | `docs/spec/05-fees-and-economics.md` section 5.10 | diff --git a/docs/plans/history-rewrite.md b/docs/plans/history-rewrite.md new file mode 100644 index 00000000..2133c8a3 --- /dev/null +++ b/docs/plans/history-rewrite.md @@ -0,0 +1,121 @@ +# G14: the history rewrite, exact plan and dry-run result (4 October 2026, night) + +Internal. Extends `docs/fud-fixes.md` section 5 (step 4) with the exact commands, what the dry run showed, what +breaks, and the order for the morning. Nothing here has touched the real repository: the dry run ran on a throwaway +mirror clone under the session scratchpad and nothing was pushed. The owner is not named in this file; "the first +name" and "the login" stand for the values the script reads from the history itself. + +## 1. What the history holds today (counts from the real repository, 4 October 2026, 22:30 UTC) + +| Item | Count | Where | +|---|---|---| +| Commits | 363 on all branches | | +| Commits stamped `+0100` (author or committer) | 291 of 363 | the UK or Irish summer offset; 72 are `+0000` | +| Commits authored with the personal name | 40 (31 on the old GitHub noreply address, 9 on the personal address) | the commits before the 3 October identity rule | +| Commits as the standing login `igneum-labs` | 323 | | +| The intake key | 6 tracked files, 8 commits (`78df757` to `4c9810f`) | `packaging/mac/packaged-config.sh`, `infra/gpu-bench/upload.sh`, `proving/windows-wsl2/prove-block.sh`, `prove-shard.sh`, `proto-cuda/windows-miner/upload-log.bat`, `proto-cuda/windows-app/upload-log.bat` | +| The dl token | 1 tracked file, 1 commit (`c47ff03`) | `docs/plans/morning-2026-10-04.md` | +| The `.next` rotations of both | 0 files, 0 commits | `~/.config/igneum/log-intake-key.next`, `dl-token.next` (4 October 19:25) are not in the tree | +| The relay key and token (current and old) | 0 files, 0 commits | | +| The review files | `docs/fud-ledger.md` (36 commits from `39c20b7`), `docs/fud-fixes.md` (6 from `e7545d5`), `docs/review/` (4 from `5ab296c`), `site/ledger.html` (5 from `0ec11be`) | tracked, not ignored | +| Tracked files carrying the first name (case-insensitive) | 71 at HEAD; 93 commits touch such content; 10 commit messages carry it | `CLAUDE.md`, the agent file, plans, packaging, the app's WSL paths, the Chrome profile rule | +| The surname | 4 files at HEAD | | +| The other businesses' names, the registrar, the database id, home paths | [other-business] 6, [other-business] 5, [other-business] 4, godaddy 7, soft-voice 3, `/Users/` 22, quantum 4 | identity terms are rewritten by the history pass below; providers and paths are the public-export scrub's job (`tools/ci/forbidden-strings.txt`), not this pass | + +## 2. The rewrite, exactly + +Tool: `git-filter-repo` 2.47.0 (not installed on the Mac; the dry run used a pip install into the scratchpad, +`python3 -m pip install --target git-filter-repo`, run as `python3 /git_filter_repo.py`). It refuses to +run on anything but a fresh clone, which is the safety the plan relies on. + +The script is `dryrun.sh` in the scratchpad (`rewrite/`); it reads every value from the history and from +`~/.config/igneum` at run time and writes the replacement files with mode 0600, then deletes them. The one +invocation, with the files it writes: + +``` +git clone --mirror clone && cd clone +python3 git_filter_repo.py --force \ + --invert-paths --path docs/fud-ledger.md --path docs/fud-fixes.md --path docs/review --path site/ledger.html \ + --replace-text replace.txt \ + --replace-message messages.txt \ + --mailmap mailmap \ + --commit-callback ' +for attr in ("author_date", "committer_date"): + d = getattr(commit, attr); parts = d.split(b" ") + if len(parts) == 2 and parts[1] != b"+0000": + setattr(commit, attr, parts[0] + b" +0000") +' +``` + +| File | Lines (values never written in this plan) | +|---|---| +| `replace.txt` (blob text) | `literal:==>***INTAKE-KEY-REMOVED***`; `literal:
==>***DL-TOKEN-REMOVED***`; the two personal `Name ` strings to the standing login string; the personal email and the old noreply address to `[removed]`; `regex:\bFirst's\b==>the project lead's`; `regex:\bFirst\s+Last\b==>the project lead`; `regex:\bFirst\b==>the project lead`; `regex:\bLast\b==>[removed]`; `regex:(?i)(?[user]` (the lowercase user-name form in Windows and WSL paths, added after dry run 1 left 20 lines); `regex:(?i)\b\b==>[second-owner-login]`; `regex:(?i)\b([other-business]\|[other-business]\|[other-business]\|[other-business]\|[other-business])\b==>[other-business]` | +| `messages.txt` (commit messages) | the first-name rules and the second-login rule | +| `mailmap` | both personal identities to `igneum-labs <337424239+[removed]>` | + +The date callback keeps the instant and rewrites the offset to `+0000`, so no commit moves in time; only the +`+0100` fingerprint goes. `--invert-paths` drops the four internal files from every commit, which empties the +commits that touched nothing else; filter-repo prunes those. + +## 3. The dry run (two passes on the mirror clone, 4 October 2026, 22:35 to 22:55 UTC) + +| Check | Before | After pass 2 | +|---|---|---| +| Commits | 364 in the mirror (363 plus the in-progress branch head) | 312: the 52 commits that only touched the dropped files are gone | +| Author and committer identities | 3 | 1: the standing login on all 312 | +| Timezone offsets (author and committer, 624 stamps) | 291 x 2 `+0100` | 624 `+0000` | +| `git log -S` | 8 commits | 0 | +| `git log -S
` | 1 commit | 0 | +| Commits touching the four dropped files | 51 | 0 | +| Identity grep over every blob in the history (first name outside the login, surname, second login, personal addresses, the other businesses; case-insensitive) | thousands of lines | 0 lines | +| Identity grep over commit metadata (names, addresses, subjects, bodies) | | 0 lines | +| `CLAUDE.md` line 4 after the pass | the full name | "the project lead's project, started 3 October 2026" | +| Runtime | | 2 min 58 s for the filter, 3 min 15 s with the greps | + +Pass 1 (case-sensitive name rules only) left 20 blob lines and 2 message lines: the lowercase user-name form in +`C:\Users\` and WSL paths in `app/igneum-app/src/jobrun.rs`, `prover.rs`, `docs/plans/shard-test-pc2.md`, +`packaging/README-ship.md`, `packaging/ota/publish-jobs.sh`, `relay/playbooks/shard-test.ps1` and the Chrome-profile +line of `CLAUDE.md`. The `(?i)(? <337424239+@...> <337424239+igneum-labs@...>`) and one more replace rule (`igneum-labs` to the new login) go into the same pass | the owner (rename), then the script | +| `CLAUDE.md` as a public file (section 5 step 2 of `docs/fud-fixes.md`: the registrar, the database id, the browser-profile section, the tooling links) | The pass replaces names; it does not rewrite paragraphs. The scrubbed `CLAUDE.md` of step 2 replaces the file in every commit with `--path-rename` or a blob callback once it exists | Claude, after the owner approves the public text | +| The second owner login is still an organisation owner | GitHub setting (decision e: one anonymous owner) | the owner | +| Providers, hosts, home paths, machine names | the public-export scrub (`tools/ci/forbidden-strings.txt`, `igneum-public/tools/sync.sh`); the private repository keeps them until the public date | the export | + +## 4. What breaks when the rewrite is applied for real + +| What | Why | Recovery | +|---|---|---| +| Every worktree of the main checkout (16 today: `igneum-wt-appui`, `bughunt`, `buildjob`, `devfee`, `eff`, `finality`, `latency`, `perf`, `redteam`, `release`, `reliability`, `ship`, `site`, `wallet`, `testnet`, plus two under the scratchpad) | Their HEADs point at old commit ids that no longer exist in the rewritten history; `git status` still works on the old objects, `git pull` and `git rebase` do not | Each agent commits and pushes its branch before the freeze; after the rewrite every branch is re-created from the rewritten refs: `git worktree remove`, `git worktree add ../igneum-wt- ` | +| Agents' branches (15 local, 11 on origin) | Rewritten with everything else (the mirror clone carries every ref), so the branch names survive with new ids; an agent that keeps an old local branch will have diverged from its rewritten twin by every commit | No agent commits during the freeze; after it, every agent re-creates its worktree, never merges an old-id branch into a new one | +| Open pull requests, if any | Their base and head ids vanish | None open today (the project merges by hand); check `gh pr list` before the freeze | +| The Vercel GitHub integration (`igneum` project, deploys on push to master) | The integration links by repository id, not by commit, so it survives a force-push; the first push of the rewritten `master` triggers one deploy of the same site (the public tree is unchanged by the pass except the dropped `site/ledger.html`, already a 307 redirect) | Watch the deploy; nothing to relink. If the repository is re-created instead (section 5, option B), the integration is re-linked once in the Vercel project settings | +| The `windows-ci` and `ci` workflows | Run on the rewritten push like any push; the DL_TOKEN secret is a repository setting and survives | Re-set the secrets if the repository is re-created | +| Old commit ids in documents (`docs/bench-log.md`, plans, the ledger) and in the public export | They name commits that will not exist; filter-repo writes `commit-map` (old id to new id) in `.git/filter-repo/` and rewrites ids it finds in commit messages, not in files | Keep `commit-map` with the private notes; the bench log keeps its short ids as historical labels (the public export already strips the history) | +| GitHub's copies of the old objects | A force-push does not delete them from GitHub's object store; cached PR views, old commit URLs and forks keep serving them until GitHub runs a garbage collection, which support can be asked to do | Option B below removes the question | +| The fork worktrees under `vendor/` | Separate repositories (`vendor/` is gitignored); untouched | Nothing | +| The intake key and the dl token | Removing them from the history does not revoke them; every shipped package and every installed app carries the current key | Rotate first (the `.next` values exist since 4 October 19:25): new key in `relay/` and in `packaging/mac/packaged-config.sh`, repackage, republish; the old key keeps working for installed apps until they update, then dies | + +## 5. The order of operations for the morning + +1. Rotate the secrets: switch the relay and the intake to `log-intake-key.next`, the downloads folder to `dl-token.next`, repackage the Mac and Windows apps with the new values, publish, confirm an upload lands under the new key. Then the old values in the history are dead values. +2. The owner renames the login `igneum-labs` (GitHub settings; the noreply id 337424239 stays), confirms the second login is no longer an organisation owner, and approves the public `CLAUDE.md` text (section 5 step 2). +3. Freeze: every agent commits and pushes its branch, then stops; `gh pr list` must be empty; `git worktree list` is recorded. +4. Mirror clone, run the pass (section 2) with the two extra lines from step 2 and the scrubbed `CLAUDE.md` blob; the greps of section 3 must all read 0; keep `commit-map`. +5. Choose A or B. A: `git push --mirror` from the clone to the existing repository, then ask GitHub support to purge the unreachable objects. B (the route `docs/fud-fixes.md` step 4 prefers): create a fresh repository under the organisation, push the rewritten refs there, re-link Vercel and re-set the two secrets, archive the old repository private. B leaves no old object anywhere. +6. Re-clone the main checkout from the new history; every agent re-creates its worktree from its rewritten branch. +7. `TZ=UTC` on every path that commits: the agents' shells, the ship scripts, the relay; and `git config --global` cannot set a timezone, so the rule is in the environment. The CI identity grep and `git log --format='%ad' --date=raw | grep -c +0100` become the daily check (0 is the goal). +8. The public export (`igneum-network/spec`) is unaffected: it carries no history from this repository. + +## 6. What waits for the owner + +| Decision | Options | +|---|---| +| The new login name | any handle without a name | +| A or B in step 5 | B recommended | +| The public `CLAUDE.md` text | section 5 step 2 of `docs/fud-fixes.md` | +| The day | after step 1; before the public date in every case | diff --git a/docs/spec/05-fees-and-economics.md b/docs/spec/05-fees-and-economics.md index 70fede80..4f344008 100644 --- a/docs/spec/05-fees-and-economics.md +++ b/docs/spec/05-fees-and-economics.md @@ -82,6 +82,33 @@ Designed, Open (O-5.3). Each block carries a bitfield; bit b set means "this blo | Upgrade window, activation delay | not set | Open (O-5.3) | | Shard assignment | sortition, 8 provers, 10-s window, no bond (section 7.2) | Designed (3 October 2026) | | External job bond, claim timeout | not set | Open (O-5.6) | -| Proving-cost budget per block | from the phase 2 measurement | Target | +| Proving-cost budget per block `B_p` | 120,000 pgas (section 5.10) | Proposed (4 October 2026; was Target) | +| Shard budget `S_p` | 30,000 pgas (section 5.10) | Proposed (4 October 2026) | +| Base-fee floors `f_e`, `f_p` | 100 gwei per gas, 10,000 gwei per pgas (section 5.10) | Proposed (4 October 2026) | +| pgas table | version 1: intrinsic 300, modexp 10 + 1 per 10 bytes, other entries prototype (section 5.10) | Proposed (4 October 2026) | | Emission to any treasury | 0 | Designed | | Protocol fee to any team, foundation or fund | 0 | Designed (3 October 2026) | + +## 5.10 Base-fee floors, the proving budget and the pgas table (Proposed, 4 October 2026) + +Proposed, not yet signed off; the arithmetic is in `docs/analysis/base-fee-floor.md` and the values are implemented +on the node fork's branch `testnet-params` (`consensus/core/src/fees.rs`, carried by `Params.fees` per network and +by the override file; unit-tested, not merged). The prototype values the devnet ran before (both base fees 1 gwei +with a 1 gwei floor, `B_p` = `B_e` = 30,000,000, intrinsic 200, modexp 1,000 + 10 per byte) are kept as +`FeeParams::PROTOTYPE`. + +| Parameter | Proposed | Basis | +|---|---|---| +| pgas unit | 1 pgas = 1,000 reference SP1 cycles | unchanged | +| Intrinsic pgas per transaction | 300 | measured upper bound: 1,400 to 1,600 cycles per prototype pgas on a plain-transfer shard (bench-log, 4 October) | +| modexp entry | 10 + 1 per 10 input bytes | measured: 9 cycles per prototype pgas on a modexp-heavy shard, the prototype entry about 100x its cost | +| Other opcode and precompile entries | prototype shapes, table version 1 | not yet measured | +| Shard budget `S_p` | 30,000 pgas (30 M cycles) | about 5.5 s compressed on one RTX 5090 (half the measured 60 M-cycle shard at 10.9 s, approximate); about 20 s on a 12 GB card (approximate) | +| Block proving budget `B_p` | 120,000 pgas (4 x `S_p`) | 400 transfers per block; a four-shard block proves in about 8 s on four RTX 5090s (approximate) | +| Execution base-fee floor `f_e` | 100 gwei per gas | a full block burns 3 IGN, 9.5% of the year-one subsidy; 259,200 IGN per day | +| Proving base-fee floor `f_p` | 10,000 gwei per pgas | 230x the proving electricity per pgas at an assumed $0.10 per IGN and $0.15 per kWh | +| Initial base fees | the floors | | +| Adjustment | EIP-1559 toward half the limit, denominator 8, both dimensions | unchanged | +| A plain transfer at the floor | 21,000 x 100 gwei + 300 x 10,000 gwei = 0.0051 IGN | under $0.01 for any token price up to $1.96 (assumption, not a forecast) | + +The floors and `B_p` are parameters the genesis rules leave to miners (5.5): they move by 60% signalling. diff --git a/docs/testnet/README.md b/docs/testnet/README.md new file mode 100644 index 00000000..6856c8db --- /dev/null +++ b/docs/testnet/README.md @@ -0,0 +1,79 @@ +# Igneum public testnet: identity, parameters and reset policy (PROPOSED, 4 October 2026, night) + +Every value in this file is PROPOSED. The project signs the parameters off in the morning; the genesis is not final +until then, and a signed-off genesis is recomputed from the final values with the fork's `print_genesis_hashes` test. +The code lives on the node fork's branch `testnet-params` (worktree `vendor/igneum-node-testnet`, forked from +`finality-fixes` at `6aa69a45`), unit-tested, not merged, not deployed. + +## 1. Identity + +| Field | Devnet (live today) | Testnet (proposed) | Where | +|---|---|---|---| +| Network id (handshake string, data directory) | `igneum-devnet` | `igneum-testnet-1` | `consensus/core/src/network.rs`, `NetworkId::to_prefixed` | +| Network type and suffix | Devnet, none | Testnet, suffix 1 | `config/params.rs`, `From`: only suffix 1 resolves; any other suffix is refused | +| EVM chain id | 4463 | 4462 | `consensus/core/src/evm.rs`, `evm_chain_id` (mainnet 4461) | +| Address prefix | `igneumdev` | `igneumtest` | `crypto/addresses` | +| gRPC port | 26610 | 26810 | `network.rs`, `default_rpc_port` | +| P2P port | 26611 | 26811 | `network.rs`, `default_p2p_port` (a later suffix takes the next port) | +| wRPC Borsh, JSON | 27610, 28610 | 27810, 28810 | `network.rs` | +| EVM JSON-RPC port | 26790 | 26890 | `igneum/exec/src/config.rs`, `default_evm_rpc_port` | +| DNS seeders | none | none (the list is filled when the seed nodes exist, `docs/plans/seed-nodes.md`) | `TESTNET_PARAMS.dns_seeders` | +| Override file (`--override-params-file`) | allowed | refused, as on mainnet | `kaspad/src/daemon.rs` | +| `IGNEUM_POW_*` environment | ignored by the node from this branch (G12: the params' schedule is installed on every start) | ignored | `kaspad/src/daemon.rs` | + +Start a node on it: `igneumd --testnet --netsuffix 1` (the flag `--testnet` is "Use the Igneum test network"; +`--netsuffix` defaults to 10 in `kaspad/src/args.rs` and must be set to 1 until the default is changed, which is +one line and waits for the sign-off). + +## 2. Genesis + +| Field | Value | Note | +|---|---|---| +| Timestamp | 1,791,158,400,000 ms = 2026-10-05T00:00:00Z (`0x1a1095c3400`) | frozen; proposed | +| Bits | `0x1d100000` (2^28 expected hashes per block) | the devnet's launch difficulty, sized for a few hundred MH/s; the DAA takes over after 150 samples (600 blocks); re-size to the announced launch fleet | +| Nonce, DAA score | 0, 0 | | +| UTXO commitment | empty | | +| Coinbase payload message | `igneum-testnet-1 \| 2026-10-05 \| proposed, not final \| coins here have no value \| resets are announced` | after the OP-FALSE script, as the devnet's `igneum-devnet` | +| Hash | `0b2535708cad69211abb82383e002118c1c7a58f2d70cc966e520f8cfae10f79` | computed 4 October 2026 by `print_genesis_hashes` on the branch; changes with any field above | +| Merkle root | `3060aab78494de3cbda68619c0132300aa486dc3153dbb78b50a430f053b3a84` | same | + +## 3. Consensus parameters + +| Parameter | Testnet (proposed) | Devnet today | Why | +|---|---|---|---| +| Block rate | 1 per second | 1 per second | spec 02 | +| Difficulty rule | Igneum dual-lane, v2 from genesis | dual-lane, v2 from DAA 33,000 | a fresh chain has no pre-switch history | +| Finality parameters | `FinalityParams::MAINNET`: 30-day weight window (2,592,000 DAA), dust 100, presence 240, 8 aggregators, 30-day equivocation ban, min DAA 2,592,000, certificate fold 6 | `DEVNET`: 2-hour window, dust 5, presence 20, fold 3 | the testnet runs the rule the mainnet will run; the first lock needs 30 days of weight, which is the point of a testnet | +| Finality rule v3 | from genesis | from the override file | fresh chain | +| Proving v0 payouts | from genesis | from the override file | fresh chain | +| PoW schedule | epoch 3,600 DAA, lead 600, day 86,400,000 ms (the defaults) | same | | +| Coinbase payload limit | 16,384 (the finality section) | same | | +| Fees | `FeeParams::CALIBRATED_V1` (`docs/analysis/base-fee-floor.md`): `B_p` 120,000 pgas, `S_p` 30,000, intrinsic 300, modexp 10 + 1 per 10 bytes, floors 100 gwei per gas and 10,000 gwei per pgas | the same set on this branch; the live devnet runs the prototype (`B_p` 30 M, 1 gwei) | spec 05 section 5.10 | +| Emission | the mainnet schedule: 31.69 IGN per block in year one, halving every two years, cap 4 billion | same | the testnet coins have no value whatever the schedule says | + +Everything else (mass limits, GHOSTDAG k, merge depth, pruning) is the devnet's set, unchanged. + +## 4. Reset policy + +| Rule | Proposed | +|---|---| +| Coins | Testnet IGN has no value, cannot be bought, sold or redeemed, and is not a claim on anything at mainnet. Mainnet starts from an empty genesis. No airdrop, no points, no promise tied to a testnet balance | +| When the chain resets | When a consensus rule changes (the lottery hash, the difficulty rule, finality, the fee table, the execution rules) and a height switch is not the right tool; or when the chain is broken beyond a height switch | +| Notice | At least N = 7 days ahead, on igneum.network (the download page and the live page), in the app through the update manifest's note, and in the engineering log. A reset without notice is a bug report, not a policy | +| What carries over | Nothing. Balances, contracts, nonces and history start again. Addresses stay valid (an address is a key) | +| Identity after a reset | A consensus-changing reset takes the next suffix (`igneum-testnet-2`, chain id unchanged at 4462, P2P port 26812), so a node on the old rules never completes a handshake with the new chain | +| The devnet | Keeps resetting without notice; it is a developer network. Its chain id 4463 stays separate | +| Who decides | The project, by the sign-off that this file records; the parameters the genesis rules leave to miners (`B_p`, the floors) move by 60% signalling once the testnet has miners (spec 5.5) | + +## 5. What stands between this file and a public testnet + +| Item | State | +|---|---| +| Sign-off of every value above | waits for the morning | +| `--netsuffix` default 1 under `--testnet` | one line in `kaspad/src/args.rs`, after the sign-off | +| Seed nodes and the DNS seeder list | `docs/plans/seed-nodes.md`; the list in `TESTNET_PARAMS` is empty on purpose | +| The public RPC and explorer | `site/wallet.html` carries a placeholder RPC URL until they exist | +| The prover's table mirror and fixtures | `docs/analysis/base-fee-floor.md` section 4 | +| The app's testnet build | the app's packaged config names the network; `igneum-testnet-1` needs the network and ports there (`app/igneum-app/src/config.rs`, `Runtime.network`) | +| The params digest in the handshake (X18) | separate work, before the testnet | +| Terms on the download page | `site/index.html#testnet-terms`, on branch `testnet-prep` with this file | diff --git a/packaging/windows/fetch-ci-artifacts.sh b/packaging/windows/fetch-ci-artifacts.sh index abf810e9..756bd72d 100755 --- a/packaging/windows/fetch-ci-artifacts.sh +++ b/packaging/windows/fetch-ci-artifacts.sh @@ -1,22 +1,37 @@ #!/usr/bin/env bash -# Pulls the Windows installer and payload from the latest green run of .github/workflows/windows.yml on master and -# copies them into the downloads folder (dl//), where the other packages live. Run on the Mac: +# Pulls the Windows installer and payload from a green run of .github/workflows/windows.yml on master and copies +# them into the downloads folder (dl//), where the other packages live. Run on the Mac: # -# packaging/windows/fetch-ci-artifacts.sh [--deploy] [run-id] +# packaging/windows/fetch-ci-artifacts.sh [--deploy] [--sign-manifest] [run-id] # # Without --deploy it prints the deploy command for the main session to run; with --deploy it deploys the folder with # the Vercel CLI itself. A run id (gh run list) picks a specific run instead of the latest green one. -# The installer also goes into the over-the-air update manifest (packaging/ota/publish-manifest.sh, Windows entry; -# OTA_NOTES= for the changelog line, OTA_SKIP=1 to leave the manifest alone), so the deploy ships both. +# +# The over-the-air update manifest (packaging/ota/publish-manifest.sh, Windows entry) is NOT touched unless +# --sign-manifest is given (review round 4, R4.5.2, ledger G13: signing used to be automatic from "the latest green +# run"). --sign-manifest needs an explicit run id, and before it signs anything it downloads that run's +# igneum-windows-inputs artifact (the payload-inputs.json the runner verified, its signature and the runner's record) +# and re-verifies on this Mac: the Ed25519 signature against ~/.config/igneum/ota-signing-key.pub, the pinned node +# commit against packaging/windows/node-source.pin AT THE RUN'S COMMIT, the run's branch (master) and event (push or +# workflow_dispatch), and that the runner's record names this run, this commit and this key. Any failure stops +# before the signature. OTA_NOTES= sets the changelog line (default: the version and the run id). # Reads ~/.config/igneum/dl-token, ~/.config/igneum/dlsite-dir (IGNEUM_DLSITE overrides) and the gh login, which must # be igneum-labs (gh auth switch --user igneum-labs). set -euo pipefail REPO="igneum-network/igneum" +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" DEPLOY=0 +SIGN=0 RUN_ID="" for a in "$@"; do - case "$a" in --deploy) DEPLOY=1 ;; *) RUN_ID="$a" ;; esac + case "$a" in --deploy) DEPLOY=1 ;; --sign-manifest) SIGN=1 ;; --*) echo "unknown flag $a" >&2; exit 2 ;; *) RUN_ID="$a" ;; esac done +if [ "${OTA_SKIP:-}" = 0 ]; then SIGN=1; fi # the old spelling; OTA_SKIP=1 (the default now) leaves the manifest alone +if [ "$SIGN" = 1 ] && [ -z "$RUN_ID" ]; then + echo "--sign-manifest needs the run id it signs (gh run list --repo $REPO --workflow windows.yml); the latest green run is never signed by default" >&2 + exit 2 +fi TOKEN_FILE="$HOME/.config/igneum/dl-token" DLSITE="${IGNEUM_DLSITE:-}" [ -n "$DLSITE" ] || { [ -f "$HOME/.config/igneum/dlsite-dir" ] && DLSITE="$(tr -d '[:space:]' < "$HOME/.config/igneum/dlsite-dir")"; } || true @@ -32,6 +47,9 @@ if [ -z "$RUN_ID" ]; then [ -n "$RUN_ID" ] && [ "$RUN_ID" != "null" ] || { echo "no green windows.yml run on master yet" >&2; exit 1; } fi gh run view "$RUN_ID" --repo "$REPO" --json headSha,displayTitle,updatedAt,url,conclusion --jq '"run \(.url)\n\(.displayTitle)\n\(.headSha[0:12]) \(.updatedAt) \(.conclusion)"' +RUN_JSON="$(gh run view "$RUN_ID" --repo "$REPO" --json headSha,headBranch,event,conclusion,status)" +read -r HEAD_SHA HEAD_BRANCH RUN_EVENT RUN_CONCLUSION < <(printf '%s' "$RUN_JSON" | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["headSha"], r["headBranch"], r["event"], r["conclusion"])') +[ "$RUN_CONCLUSION" = success ] || { echo "run $RUN_ID concluded '$RUN_CONCLUSION', not success" >&2; exit 1; } TMP="$(mktemp -d)" gh run download "$RUN_ID" --repo "$REPO" --name igneum-windows-installer --name igneum-windows-payload --dir "$TMP" @@ -49,11 +67,41 @@ ls -la "$DEST/$(basename "$SETUP")" "$DEST/igneum-windows-app.zip" # the console's Builds tab (relay/): one build event per fetched CI run; never fatal. CONSOLE_SKIP=1 leaves it to the # caller (tools/ship-app.mjs posts one item for the whole cut). [ "${CONSOLE_SKIP:-0}" = 1 ] || node "$(dirname "$0")/../../tools/console.mjs" post --kind build --title "Windows CI $(basename "$SETUP") fetched (run $RUN_ID)" --body "https://github.com/$REPO/actions/runs/$RUN_ID" >/dev/null 2>&1 || true -# the over-the-air manifest (packaging/ota): the Windows entry for this installer; the Mac entry of the same version is -# carried over. OTA_NOTES= sets the changelog line; OTA_SKIP=1 leaves the manifest alone. -if [ "${OTA_SKIP:-0}" != 1 ]; then +# the over-the-air manifest (packaging/ota): only with --sign-manifest, only for the named run, and only after the +# run's verified inputs manifest re-verifies here (G13). The Mac entry of the same version is carried over. +if [ "$SIGN" = 1 ]; then + PUB="$HOME/.config/igneum/ota-signing-key.pub" + SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign" + [ -f "$PUB" ] || { echo "no $PUB" >&2; exit 1; } + [ -x "$SIGNER" ] || (cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet) + [ "$HEAD_BRANCH" = master ] || { echo "refusing to sign: run $RUN_ID is on branch '$HEAD_BRANCH', not master" >&2; exit 1; } + case "$RUN_EVENT" in push|workflow_dispatch) ;; *) echo "refusing to sign: run $RUN_ID was triggered by '$RUN_EVENT'" >&2; exit 1 ;; esac + INP="$(mktemp -d)" + gh run download "$RUN_ID" --repo "$REPO" --name igneum-windows-inputs --dir "$INP" || { echo "refusing to sign: run $RUN_ID has no igneum-windows-inputs artifact (the run verified no inputs manifest)" >&2; exit 1; } + IJSON="$(find "$INP" -name payload-inputs.json | head -1)"; ISIG="$(find "$INP" -name payload-inputs.json.sig | head -1)"; IREC="$(find "$INP" -name inputs-verified.json | head -1)" + [ -n "$IJSON" ] && [ -n "$ISIG" ] && [ -n "$IREC" ] || { echo "refusing to sign: the inputs artifact is incomplete" >&2; ls -R "$INP" >&2; exit 1; } + # the pin as it stood in the commit the runner built, from this clone (fetched if the commit is not here yet) + git -C "$ROOT" cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null || git -C "$ROOT" fetch --quiet origin "$HEAD_SHA" || true + PIN="$(git -C "$ROOT" show "$HEAD_SHA:packaging/windows/node-source.pin" 2>/dev/null | tr -d '[:space:]')" + [ ${#PIN} = 40 ] || { echo "refusing to sign: commit ${HEAD_SHA:0:12} carries no packaging/windows/node-source.pin" >&2; exit 1; } + "$SIGNER" verify-inputs "$PUB" "$IJSON" "$ISIG" --node-commit "$PIN" || { echo "refusing to sign: the run's inputs manifest does not verify against $PUB and the pin at ${HEAD_SHA:0:12}" >&2; exit 1; } + FP="$("$SIGNER" fingerprint "$PUB" | sed -n 2p)" + python3 - "$IREC" "$RUN_ID" "$HEAD_SHA" "$FP" <<'PYREC' +import json, sys +rec = json.load(open(sys.argv[1])) +want = {"run_id": sys.argv[2], "head_sha": sys.argv[3], "key_fingerprint": sys.argv[4]} +bad = [k for k, v in want.items() if str(rec.get(k, "")) != v] +if bad: + print("refusing to sign: the runner's record disagrees on " + ", ".join(f"{k} (record {rec.get(k)!r}, expected {want[k]!r})" for k in bad), file=sys.stderr) + sys.exit(1) +print(f"inputs verified by the runner and again here: node commit {rec.get('node_commit')}, zip {rec.get('zip_sha256')}, key {rec.get('key_fingerprint')}") +PYREC + rm -rf "$INP" SETUP_VERSION="$(basename "$SETUP" | sed -n 's/^Igneum-Miner-Setup-\(.*\)\.exe$/\1/p')" - "$(dirname "$0")/../ota/publish-manifest.sh" --version "$SETUP_VERSION" --win "$DEST/$(basename "$SETUP")" --notes "${OTA_NOTES:-Windows build $SETUP_VERSION from CI run $RUN_ID}" --no-deploy + echo "signing the update manifest for Windows $SETUP_VERSION over run $RUN_ID (${HEAD_SHA:0:12})" + "$HERE/../ota/publish-manifest.sh" --version "$SETUP_VERSION" --win "$DEST/$(basename "$SETUP")" --notes "${OTA_NOTES:-Windows build $SETUP_VERSION from CI run $RUN_ID}" --no-deploy +else + echo "update manifest untouched (pass --sign-manifest to sign it after the inputs check)" fi if [ "$DEPLOY" = 1 ]; then (cd "$DLSITE" && npx vercel@latest --global-config "$HOME/.config/igneum/vercel" deploy --prod --yes 2>&1 | grep -v "$TOKEN" || true) diff --git a/packaging/windows/inputs-manifest.sh b/packaging/windows/inputs-manifest.sh new file mode 100755 index 00000000..20885cd2 --- /dev/null +++ b/packaging/windows/inputs-manifest.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# The payload-inputs manifest writer, shared by push-inputs.sh (the real thing) and test-inputs-signing.sh (the +# Mac-side test), so the test signs and verifies exactly the shape the runner sees. Format: app/igneum-app/src/inputs.rs +# (`igneum-payload-inputs/1`): the zip's sha256 and size, every file inside the zip's folder with its sha256 and +# size, the node fork commit (40 hex) and branch the exes came from, the main repository commit, the build time. +# +# source packaging/windows/inputs-manifest.sh +# write_inputs_manifest +# +# Sorted file names, two-space indentation, one entry per line: the bytes are what gets signed, so the writer is +# deterministic for the same inputs. + +inputs_sha256() { shasum -a 256 "$1" | cut -d' ' -f1; } +inputs_size() { stat -f %z "$1" 2>/dev/null || stat -c %s "$1"; } + +write_inputs_manifest() { + local stage="$1" zip="$2" node_commit="$3" node_branch="$4" repo_commit="$5" out="$6" + [ -d "$stage" ] || { echo "write_inputs_manifest: no stage folder $stage" >&2; return 1; } + [ -f "$zip" ] || { echo "write_inputs_manifest: no zip $zip" >&2; return 1; } + case "$node_commit" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*) [ ${#node_commit} = 40 ] || { echo "write_inputs_manifest: node commit is not 40 hex" >&2; return 1; } ;; *) echo "write_inputs_manifest: node commit is not 40 hex" >&2; return 1 ;; esac + [ ${#repo_commit} = 40 ] || { echo "write_inputs_manifest: repo commit is not 40 hex" >&2; return 1; } + { + echo '{' + echo ' "format": "igneum-payload-inputs/1",' + echo " \"built_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," + echo " \"node_source_commit\": \"$node_commit\"," + echo " \"node_source_branch\": \"$node_branch\"," + echo " \"repo_commit\": \"$repo_commit\"," + echo " \"zip\": { \"sha256\": \"$(inputs_sha256 "$zip")\", \"bytes\": $(inputs_size "$zip") }," + echo ' "files": {' + local first=1 f name + while IFS= read -r f; do + [ -n "$f" ] || continue + name="$(basename "$f")" + [ "$name" = ".DS_Store" ] && continue + [ $first = 1 ] || echo ',' + first=0 + printf ' "%s": { "sha256": "%s", "bytes": %s }' "$name" "$(inputs_sha256 "$f")" "$(inputs_size "$f")" + done < <(find "$stage" -maxdepth 1 -type f | LC_ALL=C sort) + echo + echo ' }' + echo '}' + } > "$out" +} diff --git a/packaging/windows/node-source.pin b/packaging/windows/node-source.pin new file mode 100644 index 00000000..0551687d --- /dev/null +++ b/packaging/windows/node-source.pin @@ -0,0 +1 @@ +6aa69a45364b9b30a32695e33eb66f100c9be85f diff --git a/packaging/windows/push-inputs.sh b/packaging/windows/push-inputs.sh index 6f9b8d6e..3676044a 100755 --- a/packaging/windows/push-inputs.sh +++ b/packaging/windows/push-inputs.sh @@ -8,14 +8,19 @@ # What goes in (flat): igneumd.exe, igneum-miner.exe (vendor/igneum-node/target-integration/x86_64-pc-windows-gnu/ # release, IGNEUM_WIN_RELEASE overrides), the three mingw runtime DLLs, igneum-worker-cuda.exe with nvrtc64_*_0.dll, # nvrtc-builtins64_*.dll and the licence texts (proto-cuda/nvrtc, fetch-redist.sh + build-windows.sh), -# igneum-worker-opencl.exe (proto-opencl), and inputs.json (sha256 and size of each file, the commits, the date). -# The zip, its .sha256 and the .json land in the downloads folder (dl//) and the folder is deployed with the -# Vercel CLI, exactly as the other packages are. The workflow fetches them with the DL_TOKEN repository secret and -# refuses a zip whose sha256 does not match. +# igneum-worker-opencl.exe (proto-opencl). Beside the zip: payload-inputs.json (the signed manifest, format +# app/igneum-app/src/inputs.rs: the zip's sha256 and size, every file's sha256 and size, the node fork commit and +# branch, the repository commit, the time) and payload-inputs.json.sig, its detached Ed25519 signature made on this +# Mac with the OTA key (~/.config/igneum/ota-signing-key, the key the apps already trust). The workflow verifies the +# signature with the public key compiled into the app BEFORE it builds anything, checks the zip and every unpacked +# file against the manifest, and checks the node commit against packaging/windows/node-source.pin in the commit it +# builds (review round 4, R4.5.2, ledger G13). This script writes the pin; commit it with the push. +# The zip, the manifest, the signature and the pin's sibling payload-inputs.sha256 (kept for older checkouts of +# the workflow) land in the downloads folder (dl//) and the folder is deployed with the Vercel CLI. # # Where things are read from (never in the repo): the token in ~/.config/igneum/dl-token, the downloads folder in # ~/.config/igneum/dlsite-dir (one line, the path of the dlsite directory; IGNEUM_DLSITE overrides), the Vercel login -# in ~/.config/igneum/vercel. +# in ~/.config/igneum/vercel, the signing key in ~/.config/igneum/ota-signing-key (0600) and its public half .pub. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/../.." && pwd)" @@ -50,38 +55,46 @@ if [ -f "$NVRTC_DIR/igneum-worker-cuda.exe" ]; then else echo "warning: no $NVRTC_DIR/igneum-worker-cuda.exe (run $NVRTC_DIR/build-windows.sh); the app will build the CUDA worker on the PC"; fi [ -f "$CL_WORKER" ] && cp "$CL_WORKER" "$STAGE/" || echo "warning: no $CL_WORKER" -# the manifest: what is in the zip, from where, when -# IGNEUM_NODE_SRC names the worktree the exes were built from (default devnet-v4), for the manifest's commit field -NODE_COMMIT="$(git -C "${IGNEUM_NODE_SRC:-$ROOT/vendor/igneum-node-v4}" rev-parse --short HEAD 2>/dev/null || git -C "$ROOT/vendor/igneum-node" rev-parse --short HEAD 2>/dev/null || echo unknown)" -REPO_COMMIT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)" -{ - echo '{' - echo " \"built_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," - echo " \"node_source_commit\": \"$NODE_COMMIT\"," - echo " \"repo_commit\": \"$REPO_COMMIT\"," - echo ' "files": {' - first=1 - for f in "$STAGE"/*; do - name="$(basename "$f")" - sum="$(shasum -a 256 "$f" | cut -d' ' -f1)" - bytes="$(stat -f %z "$f")" - [ $first = 1 ] || echo ',' - first=0 - printf ' "%s": { "sha256": "%s", "bytes": %s }' "$name" "$sum" "$bytes" - done - echo - echo ' }' - echo '}' -} > "$STAGE/inputs.json" +# the signer, built from the app crate (it includes src/manifest.rs and src/inputs.rs, so it signs what the runner verifies) +KEY="$HOME/.config/igneum/ota-signing-key" +PUB="$HOME/.config/igneum/ota-signing-key.pub" +SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign" +[ -f "$KEY" ] && [ -f "$PUB" ] || { echo "no $KEY or $PUB (the OTA signing key; packaging/ota/publish-manifest.sh explains keygen)" >&2; exit 1; } +if [ ! -x "$SIGNER" ]; then + echo "building igneum-ota-sign" + (cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet) +fi +EMBEDDED="$("$SIGNER" embedded | head -1)" +[ "$EMBEDDED" = "$(tr -d '[:space:]' < "$PUB")" ] || { echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB; the runner would refuse this signature" >&2; exit 1; } + +# the manifest: what is in the zip, from where, when. IGNEUM_NODE_SRC names the worktree the exes were built from +# (default devnet-v4); its full commit is pinned in the manifest and in packaging/windows/node-source.pin. +NODE_SRC="${IGNEUM_NODE_SRC:-$ROOT/vendor/igneum-node-v4}" +NODE_COMMIT="$(git -C "$NODE_SRC" rev-parse HEAD 2>/dev/null || true)" +[ ${#NODE_COMMIT} = 40 ] || { echo "cannot read the node source commit from $NODE_SRC (set IGNEUM_NODE_SRC to the worktree the exes were built from)" >&2; exit 1; } +NODE_BRANCH="$(git -C "$NODE_SRC" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)" +if [ -n "$(git -C "$NODE_SRC" status --porcelain --untracked-files=no 2>/dev/null)" ]; then + echo "warning: $NODE_SRC has uncommitted changes; the pinned commit $NODE_COMMIT does not describe these exes exactly" >&2 +fi +REPO_COMMIT="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" +[ ${#REPO_COMMIT} = 40 ] || { echo "cannot read the repository commit" >&2; exit 1; } DEST="$DLSITE/dl/$TOKEN" OUT="$DEST/payload-inputs.zip" rm -f "$OUT" (cd "$(dirname "$STAGE")" && zip -qr "$OUT" "payload-inputs" -x '*.DS_Store') +# shellcheck source=packaging/windows/inputs-manifest.sh +. "$HERE/inputs-manifest.sh" +write_inputs_manifest "$STAGE" "$OUT" "$NODE_COMMIT" "$NODE_BRANCH" "$REPO_COMMIT" "$DEST/payload-inputs.json" +"$SIGNER" sign-inputs "$KEY" "$DEST/payload-inputs.json" > "$DEST/payload-inputs.json.sig" +# what the runner will do, done here first: the signature, the zip and the folder against the manifest +"$SIGNER" verify-inputs "$PUB" "$DEST/payload-inputs.json" "$DEST/payload-inputs.json.sig" --zip "$OUT" --dir "$STAGE" --node-commit "$NODE_COMMIT" shasum -a 256 "$OUT" | awk '{print $1}' > "$DEST/payload-inputs.sha256" -cp "$STAGE/inputs.json" "$DEST/payload-inputs.json" +printf '%s\n' "$NODE_COMMIT" > "$HERE/node-source.pin" echo "payload-inputs.zip: $(stat -f %z "$OUT") bytes, sha256 $(cat "$DEST/payload-inputs.sha256")" cat "$DEST/payload-inputs.json" +echo "signature: $(cut -c1-16 "$DEST/payload-inputs.json.sig")... (payload-inputs.json.sig)" +echo "pinned node commit $NODE_COMMIT ($NODE_BRANCH) in packaging/windows/node-source.pin: commit it with this push, or the workflow refuses the manifest" rm -rf "$(dirname "$STAGE")" if [ "$DEPLOY" = 1 ]; then diff --git a/packaging/windows/test-inputs-signing.sh b/packaging/windows/test-inputs-signing.sh new file mode 100755 index 00000000..bb4ba7dc --- /dev/null +++ b/packaging/windows/test-inputs-signing.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Mac-side test of the signed payload-inputs chain (review round 4, R4.5.2, ledger G13), run before any push-inputs: +# +# packaging/windows/test-inputs-signing.sh +# +# What it proves, with a throwaway key made for the run: the manifest writer (inputs-manifest.sh) produces what the +# signer signs and the verifier accepts; the verifier then REFUSES a changed zip byte, a changed manifest byte, a +# changed unpacked file, an unlisted file in the folder, a missing file, a wrong pinned commit, a signature by another +# key, and the app's embedded key refuses the throwaway key. If ~/.config/igneum/ota-signing-key exists it also signs +# the test manifest with the real key and verifies it with `embedded`, which proves the key the Mac signs with is the +# key the runner trusts. Nothing is uploaded, deployed or written outside a temporary folder. +# +# A check is trusted only once it has been seen to fire on a known-good and a known-bad case (standing rule, 4 October +# 2026), so every negative case here must FAIL for the run to pass. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +SIGNER="${IGNEUM_OTA_SIGN:-$ROOT/app/igneum-app/target/release/igneum-ota-sign}" +[ -x "$SIGNER" ] || { echo "no $SIGNER: build it first (cd app/igneum-app && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign)" >&2; exit 1; } +# shellcheck source=packaging/windows/inputs-manifest.sh +. "$HERE/inputs-manifest.sh" + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +umask 077 +pass=0; fail=0 +ok() { pass=$((pass + 1)); echo " ok $1"; } +bad() { fail=$((fail + 1)); echo " FAIL $1"; } +expect_ok() { local what="$1"; shift; if "$@" > "$T/out" 2>&1; then ok "$what"; else bad "$what: $(tail -1 "$T/out")"; fi; } +expect_fail() { local what="$1"; shift; if "$@" > "$T/out" 2>&1; then bad "$what: accepted, must refuse"; else ok "$what: refused ($(tail -1 "$T/out" | cut -c1-110))"; fi; } + +# a stage folder shaped like push-inputs.sh's, a zip of it, the manifest, a throwaway key +mkdir -p "$T/payload-inputs" +printf 'not a real node\n' > "$T/payload-inputs/igneumd.exe" +printf 'not a real miner\n' > "$T/payload-inputs/igneum-miner.exe" +printf 'not a real worker\n' > "$T/payload-inputs/igneum-worker-cuda.exe" +printf 'dll\n' > "$T/payload-inputs/nvrtc64_120_0.dll" +(cd "$T" && zip -qr payload-inputs.zip payload-inputs) +NODE="6aa69a45364b9b30a32695e33eb66f100c9be85f" +REPO_C="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo 0000000000000000000000000000000000000000)" +write_inputs_manifest "$T/payload-inputs" "$T/payload-inputs.zip" "$NODE" "finality-fixes" "$REPO_C" "$T/payload-inputs.json" +"$SIGNER" keygen "$T/key" "$T/key.pub" > /dev/null +"$SIGNER" keygen "$T/other" "$T/other.pub" > /dev/null +printf '%s\n' "$NODE" > "$T/node-source.pin" + +echo "sign and verify (throwaway key)" +expect_ok "sign-inputs writes a 128-hex signature" bash -c "\"$SIGNER\" sign-inputs \"$T/key\" \"$T/payload-inputs.json\" > \"$T/payload-inputs.json.sig\" && [ \"\$(tr -d '[:space:]' < \"$T/payload-inputs.json.sig\" | wc -c | tr -d ' ')\" = 128 ]" +expect_ok "verify-inputs: signature, zip and pin" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --zip "$T/payload-inputs.zip" --node-commit "$T/node-source.pin" +expect_ok "verify-inputs: the unpacked folder" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --dir "$T/payload-inputs" +expect_ok "verify-inputs: the pin as a literal" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --node-commit "$NODE" + +echo "what must be refused" +cp "$T/payload-inputs.zip" "$T/zip.bak"; printf 'x' >> "$T/payload-inputs.zip" +expect_fail "one byte appended to the zip" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --zip "$T/payload-inputs.zip" +cp "$T/zip.bak" "$T/payload-inputs.zip" +sed 's/finality-fixes/finality-fixed/' "$T/payload-inputs.json" > "$T/tampered.json" +expect_fail "one byte changed in the manifest" "$SIGNER" verify-inputs "$T/key.pub" "$T/tampered.json" "$T/payload-inputs.json.sig" +printf 'tampered\n' > "$T/payload-inputs/igneumd.exe" +expect_fail "a changed unpacked file" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --dir "$T/payload-inputs" +printf 'not a real node\n' > "$T/payload-inputs/igneumd.exe" +printf 'extra\n' > "$T/payload-inputs/extra.dll" +expect_fail "an unlisted file in the folder" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --dir "$T/payload-inputs" +rm "$T/payload-inputs/extra.dll" +mv "$T/payload-inputs/nvrtc64_120_0.dll" "$T/dll.bak" +expect_fail "a missing file" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --dir "$T/payload-inputs" +mv "$T/dll.bak" "$T/payload-inputs/nvrtc64_120_0.dll" +expect_fail "a wrong pinned commit" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --node-commit "${NODE/6aa6/7aa6}" +expect_fail "a short pin" "$SIGNER" verify-inputs "$T/key.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" --node-commit "6aa69a45" +expect_fail "a signature by another key" "$SIGNER" verify-inputs "$T/other.pub" "$T/payload-inputs.json" "$T/payload-inputs.json.sig" +expect_fail "the app's embedded key against the throwaway signature" "$SIGNER" verify-inputs embedded "$T/payload-inputs.json" "$T/payload-inputs.json.sig" +sed 's/"igneum-miner.exe"/"igneum-miner.exe.bak"/' "$T/payload-inputs.json" > "$T/nominer.json" +expect_fail "sign-inputs refuses a manifest without igneum-miner.exe" "$SIGNER" sign-inputs "$T/key" "$T/nominer.json" +printf '{"format":"igneum-payload-inputs/1"}\n' > "$T/short.json" +expect_fail "sign-inputs refuses a truncated manifest" "$SIGNER" sign-inputs "$T/key" "$T/short.json" + +KEY="$HOME/.config/igneum/ota-signing-key" +if [ -f "$KEY" ]; then + echo "the real key (nothing leaves this folder)" + expect_ok "sign with the Mac's OTA key, verify with the key compiled into the app" bash -c "\"$SIGNER\" sign-inputs \"$KEY\" \"$T/payload-inputs.json\" > \"$T/real.sig\" && \"$SIGNER\" verify-inputs embedded \"$T/payload-inputs.json\" \"$T/real.sig\" --zip \"$T/payload-inputs.zip\" --dir \"$T/payload-inputs\" --node-commit \"$T/node-source.pin\"" +else + echo " skip the real-key case: no $KEY on this machine" +fi + +echo "$pass passed, $fail failed" +[ "$fail" = 0 ] diff --git a/site/404.html b/site/404.html index ee977c9f..1028a423 100644 --- a/site/404.html +++ b/site/404.html @@ -162,6 +162,7 @@ p{margin:0;color:var(--ink-2);max-width:52ch}
Follow
Live devnet Journey + Add Igneum to MetaMask GitHub, spec and vectors Get the miner diff --git a/site/bench.html b/site/bench.html index 1ce0ea16..c0ce766e 100644 --- a/site/bench.html +++ b/site/bench.html @@ -446,6 +446,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
Follow
Live devnet Journey + Add Igneum to MetaMask GitHub, spec and vectors Get the miner diff --git a/site/build.mjs b/site/build.mjs index 93ac85bd..2ff80802 100644 --- a/site/build.mjs +++ b/site/build.mjs @@ -244,7 +244,7 @@ if (existsSync(join(docs, 'bench-log.md'))) { // The hand-written pages: shared head, nav (with the active link marked) and footer injected in place; the homepage also // gets journey.json inlined so the phases and the log render without a fetch and without a layout shift. const journey = JSON.parse(readFileSync(join(here, 'journey.json'), 'utf8')); -const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['404.html', '']]; +const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['wallet.html', ''], ['404.html', '']]; for (const [file, active] of PAGES) { const p = join(here, file); if (!existsSync(p)) throw new Error(`missing page ${file}`); diff --git a/site/evidence.html b/site/evidence.html index 22e21967..9d5d7496 100644 --- a/site/evidence.html +++ b/site/evidence.html @@ -251,6 +251,7 @@ code{font-family:var(--f-mono);font-size:.92em;background:var(--obsidian);paddin
Follow
Live devnet Journey + Add Igneum to MetaMask GitHub, spec and vectors Get the miner diff --git a/site/index.html b/site/index.html index 4b63076d..ce868d77 100644 --- a/site/index.html +++ b/site/index.html @@ -469,6 +469,22 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var( HiveOS

One click: install, press start, the card mines and proves to a wallet it makes for you. Public testnet first.
Download only from this domain. Nobody from Igneum will ask for your seed.

+
+
Testnet terms
+

What you agree to when you run the testnet miner

+
+
+

No value. Testnet IGN cannot be sold, bought or redeemed, now or at mainnet. There is no airdrop, no points scheme and no promise tied to testnet balances. Mainnet starts from an empty genesis.

+
+
+

Resets. The chain restarts from a fresh genesis when a consensus rule changes. Every reset is announced at least seven days ahead on this page and in the app. Balances, contracts and history do not carry over. The devnet that runs today resets without notice.

+
+
+

What the app sends home. The app version, a random machine id made at install, your operating system, the node version, the hash rate, and the app, node and miner logs (which name the address the card mines to). They go to the project's log intake, a service Igneum runs on Vercel, and are read by the maintainers to find faults. Never your seed phrase, never a key, never a file you did not make with the app. Nothing is sold or shared.

+
+
+

Wallet set-up for MetaMask: chain id, RPC and the one-click button. The miner software takes an optional 1% fee, off with one flag; the protocol carries no fee to anyone.

+
Read more in the litepaper @@ -547,6 +563,7 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var(
Follow
Live devnet Journey + Add Igneum to MetaMask GitHub, spec and vectors Get the miner @@ -560,7 +577,7 @@ pre{margin:0;font-family:var(--f-mono);font-size:13px;line-height:1.6;color:var( - + + +
+
+
Wallets · chain id · RPC
+

Add Igneum to MetaMask

+
+

Igneum runs the Ethereum virtual machine, so MetaMask and every other Ethereum wallet work unchanged. The wallet needs four things: the chain id, an RPC URL, the symbol IGN and 18 decimals. The button below sends them to the wallet with the standard wallet_addEthereumChain request; the wallet shows them to you and asks before adding anything. Nothing on this page asks for a seed phrase or a key. Nobody from Igneum will ask for your seed.

+ +
+
+ Public testnet · coming +

Igneum testnet

+

The network the one-click miner app joins at public testnet. Coins on it have no value and the chain resets with notice (testnet terms).

+
+
Network name
Igneum Testnet
+
Chain id
4462 (0x116e)
+
RPC URL
https://rpc.testnet.igneum.network (published with the testnet)
+
Symbol
IGN
+
Decimals
18
+
Explorer
published with the testnet
+
+ +

The button switches on when the public RPC is live.

+
+
+ Devnet · live now +

Igneum devnet, through your own node

+

The Igneum Miner app runs a full node on your machine and serves the Ethereum RPC on it. Point the wallet at that node. The devnet is a developer network: it resets without notice and its coins have no value.

+
+
Network name
Igneum Devnet (local node)
+
Chain id
4463 (0x116f)
+
RPC URL
http://127.0.0.1:26790
+
Symbol
IGN
+
Decimals
18
+
Explorer
none yet
+
+ +

+
+
+ +

Add it by hand

+

If the wallet has no one-click support, or you prefer to type: MetaMask, Settings, Networks, Add a network manually. Enter the values from the card above. Any wallet that supports custom EVM networks takes the same four fields.

+
    +
  1. Network name: Igneum Testnet (or Igneum Devnet for your own node).
  2. +
  3. RPC URL: the one on the card.
  4. +
  5. Chain id: 4462 for the testnet, 4463 for the devnet.
  6. +
  7. Currency symbol: IGN. Decimals: 18.
  8. +
+ +

For builders

+

The same request from your own page or app, so your users land on the right chain:

+
await window.ethereum.request({
+  method: 'wallet_addEthereumChain',
+  params: [{
+    chainId: '0x116e',                      // 4462, the Igneum testnet (0x116f = 4463, the devnet)
+    chainName: 'Igneum Testnet',
+    nativeCurrency: { name: 'Igneum', symbol: 'IGN', decimals: 18 },
+    rpcUrls: ['https://rpc.testnet.igneum.network'],
+    blockExplorerUrls: []
+  }]
+});
+

Igneum signs transactions with the Ethereum rules (EIP-155, EIP-1559 and legacy envelopes). A transaction signed for another chain id is refused. Gas has two dimensions on Igneum, execution and proving, and the node folds the second into the price it quotes, so eth_gasPrice and eth_estimateGas work as they do on Ethereum. The litepaper has the differences.

+ +

The app and the Igneum Wallet

+

The Igneum Miner app makes an address for your earnings and shows you its seed phrase once. That address is an ordinary Ethereum account: import the seed into MetaMask and the balance is there. An Igneum Wallet with the Apps tab and the explorer built in is in the roadmap; until it ships, MetaMask is the wallet.

+ +

Chain ids 4461 (mainnet), 4462 (testnet) and 4463 (devnet) are fixed in the node. The testnet RPC URL above is a placeholder until the testnet opens; this page is updated the day it does.

+
+ + + + + + diff --git a/tools/ci/check-workflow-shell.mjs b/tools/ci/check-workflow-shell.mjs new file mode 100644 index 00000000..3ca14773 --- /dev/null +++ b/tools/ci/check-workflow-shell.mjs @@ -0,0 +1,101 @@ +// Mac-side (and CI) parse check of the shell inside .github/workflows/*.yml, so a broken `run:` block is caught before +// a Windows runner spends twenty minutes on it (4 October 2026, the signed-inputs step of windows.yml). +// +// node tools/ci/check-workflow-shell.mjs [workflow.yml ...] default: every workflow under .github/workflows +// +// For every step with a `run: |` block: `shell: bash` (or no shell on an ubuntu job) goes through `bash -n`; +// `shell: powershell` and `shell: pwsh` blocks, and every .ps1 the Windows folders hold, are checked against the one +// rule Windows PowerShell 5.1 enforces that newer parsers may not: a drive-qualified variable reference "$name: text" +// inside a double-quoted string (tools/ci/windows/check-ps51.ps1 runs the real 5.1 parser on the runner; this is the +// Mac approximation of its rule, with the same negative fixture). `shell: cmd` blocks are checked for the bare ")" +// class only when they span more than one line. Exit 1 on any finding, with file:line. +import { readFileSync, readdirSync, writeFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = join(here, '..', '..'); +const wfDir = join(repo, '.github', 'workflows'); +const files = process.argv.length > 2 ? process.argv.slice(2) : readdirSync(wfDir).filter(f => /\.ya?ml$/.test(f)).map(f => join(wfDir, f)); +const tmp = mkdtempSync(join(tmpdir(), 'wf-shell-')); +let findings = 0, blocks = 0, ps1 = 0; +const say = (file, line, msg) => { findings++; console.log(`${file}:${line}: ${msg}`); }; + +// The 5.1 rule: inside a double-quoted string, `$identifier:` is read as a drive-qualified variable reference +// (`$env:PATH`, `$script:node`), so when the character after the colon cannot start a variable name (a space, a +// `$`, punctuation or the closing quote) 5.1 fails with "Variable reference is not valid. ':' was not followed by a +// valid variable name character". `$env:PATH`, `$script:x`, `${name}:` and `$($name):` are fine. +const DRIVE_REF = /"(?:[^"\\]|\\.|`")*?\$[A-Za-z_][A-Za-z0-9_]*:(?![A-Za-z0-9_])(?:[^"\\]|\\.|`")*"/; +function checkPowerShell(text, file, firstLine) { + const lines = text.split('\n'); + lines.forEach((l, i) => { + const noComment = l.replace(/^\s*#.*$/, ''); + if (DRIVE_REF.test(noComment) && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}:/.test(noComment)) say(file, firstLine + i, `PowerShell 5.1 rejects "$name: text" (drive-qualified variable reference): ${l.trim().slice(0, 100)}`); + }); +} +// the same negative fixture check-ps51.ps1 uses: the Mac rule must bite on it or it proves nothing +const fixture = join(repo, 'tools', 'ci', 'windows', 'fixtures', 'bad-drive-ref.ps1.txt'); +if (existsSync(fixture)) { + const before = findings; + checkPowerShell(readFileSync(fixture, 'utf8'), 'fixture', 1); + if (findings === before) { console.log('self-test failed: the Mac rule does not fire on tools/ci/windows/fixtures/bad-drive-ref.ps1.txt'); process.exit(2); } + findings = before; console.log('self-test: the 5.1 drive-reference rule fires on the fixture'); +} + +function checkBash(text, file, firstLine) { + const p = join(tmp, `block-${blocks}.sh`); + writeFileSync(p, text); + const r = spawnSync('bash', ['-n', p], { encoding: 'utf8' }); + if (r.status !== 0) say(file, firstLine, `bash -n: ${(r.stderr || '').trim().replace(p, 'block').split('\n')[0]}`); +} +function checkCmd(text, file, firstLine) { + text.split('\n').forEach((l, i) => { if (/^\s*\)\s*$/.test(l)) say(file, firstLine + i, `a bare ")" line in a cmd block (the 3 October class)`); }); +} + +for (const file of files) { + const rel = file.startsWith(repo) ? file.slice(repo.length + 1) : file; + const lines = readFileSync(file, 'utf8').split('\n'); + let runsOn = ''; + for (let i = 0; i < lines.length; i++) { + const m = /^(\s*)runs-on:\s*(\S+)/.exec(lines[i]); if (m) runsOn = m[2]; + const r = /^(\s*)run:\s*\|\s*$/.exec(lines[i]); + if (!r) continue; + const indent = r[1].length; + // the step's shell: look back to the step's "- name:" for a `shell:` key at the same indent as `run:` + let shell = ''; + for (let k = i - 1; k >= 0; k--) { + const s = /^(\s*)shell:\s*(\S+)/.exec(lines[k]); + if (s && s[1].length === indent) { shell = s[2]; break; } + if (/^\s*-\s+(name|uses|run):/.test(lines[k]) && /^\s*-/.test(lines[k]) && lines[k].search(/\S/) < indent) break; + } + // the block: every following line indented deeper than `run:` + const body = []; + let j = i + 1; + while (j < lines.length && (lines[j].trim() === '' || lines[j].search(/\S/) > indent)) { body.push(lines[j]); j++; } + while (body.length && body[body.length - 1].trim() === '') body.pop(); + const bodyIndent = Math.min(...body.filter(l => l.trim()).map(l => l.search(/\S/))); + const text = body.map(l => l.slice(bodyIndent)).join('\n') + '\n'; + const firstLine = i + 2; + blocks++; + const kind = shell || (runsOn.startsWith('windows') ? 'pwsh' : 'bash'); + if (kind === 'bash') checkBash(text, rel, firstLine); + else if (kind === 'powershell' || kind === 'pwsh') checkPowerShell(text, rel, firstLine); + else if (kind === 'cmd') checkCmd(text, rel, firstLine); + i = j - 1; + } +} +// every .ps1 the Windows folders hold, the same rule +const folders = ['proto-cuda/windows-app', 'proto-cuda/windows-miner', 'proto-cuda/windows-node', 'proving/windows-wsl2', 'relay/clients', 'relay/playbooks', 'packaging/windows', 'app/windows', 'tools/ci/windows']; +function walk(d) { if (!existsSync(d)) return []; return readdirSync(d).flatMap(f => { const p = join(d, f); return statSync(p).isDirectory() ? walk(p) : (f.endsWith('.ps1') ? [p] : []); }); } +for (const f of folders) for (const p of walk(join(repo, f))) { ps1++; checkPowerShell(readFileSync(p, 'utf8'), p.slice(repo.length + 1), 1); } +// the shell scripts the workflow and the Mac side run +for (const f of ['packaging/windows/push-inputs.sh', 'packaging/windows/fetch-ci-artifacts.sh', 'packaging/windows/inputs-manifest.sh', 'packaging/windows/test-inputs-signing.sh', 'packaging/ota/publish-manifest.sh', 'packaging/windows/make-payload.sh']) { + const p = join(repo, f); if (!existsSync(p)) continue; + const r = spawnSync('bash', ['-n', p], { encoding: 'utf8' }); + if (r.status !== 0) say(f, 1, `bash -n: ${(r.stderr || '').trim().split('\n')[0]}`); +} +rmSync(tmp, { recursive: true, force: true }); +console.log(`workflow shell: ${blocks} run blocks in ${files.length} workflow(s), ${ps1} .ps1 files, ${findings} finding(s)`); +process.exit(findings ? 1 : 0); From b27f732533cdbb3eb05cb17ae3319178bd8f1d24 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:38:51 +0000 Subject: [PATCH 2/4] fast-time: the override file carries the fee set (Params.fees on the fork's testnet-params branch; the fork's fast-time test wants every field present) Co-Authored-By: Claude Fable 5.1 --- infra/fast-time/override-60x.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json index 850d7f9f..0ca483cf 100644 --- a/infra/fast-time/override-60x.json +++ b/infra/fast-time/override-60x.json @@ -53,5 +53,6 @@ "pow_day_ms": 1440000, "difficulty_v2_activation_daa": 18446744073709551615, "proving_v0_activation_daa": 18446744073709551615, - "finality_v3_activation_daa": 18446744073709551615 + "finality_v3_activation_daa": 18446744073709551615, + "fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8} } From 28f6cccbf7bd4481739555ec533bfbd9637c6b3a Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:49:22 +0000 Subject: [PATCH 3/4] testnet README: the genesis hash as test_genesis_hashes pins it on the fork branch Co-Authored-By: Claude Fable 5.1 --- docs/testnet/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/testnet/README.md b/docs/testnet/README.md index 6856c8db..8ee22b2b 100644 --- a/docs/testnet/README.md +++ b/docs/testnet/README.md @@ -34,7 +34,7 @@ one line and waits for the sign-off). | Nonce, DAA score | 0, 0 | | | UTXO commitment | empty | | | Coinbase payload message | `igneum-testnet-1 \| 2026-10-05 \| proposed, not final \| coins here have no value \| resets are announced` | after the OP-FALSE script, as the devnet's `igneum-devnet` | -| Hash | `0b2535708cad69211abb82383e002118c1c7a58f2d70cc966e520f8cfae10f79` | computed 4 October 2026 by `print_genesis_hashes` on the branch; changes with any field above | +| Hash | `52a3e6a9ddd79d603ff9e3a27487fb5d0ca5ca6f633fe20ca727e1faa355fc7e` | computed 4 October 2026 by `print_genesis_hashes` on the branch (pinned by `test_genesis_hashes`); changes with any field above | | Merkle root | `3060aab78494de3cbda68619c0132300aa486dc3153dbb78b50a430f053b3a84` | same | ## 3. Consensus parameters From 3be4501c8dcae692b5ef770b680785d72026b9d1 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 08:21:00 +0000 Subject: [PATCH 4/4] release-0.3.6 plan: the fork results, miner-latency in after its three gates, the release dev-fee address Fork release-0.3.6 final tip 2b6d23ef = a11455e7 (testnet-params merged, the fee height switch) + cf369022 (the release dev-fee address 0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126, docs/design/miner-dev-fee.md updated here) + the miner-latency merge, which landed only after the miner suite (15 passed), the 3-node fast-time run (243 blocks, 0 rejected, 0 red, sinks agree, switched p50 46 to 52 ms) and the dev-fee harness (8 of 8 fee blocks on chain, control at 0) passed on the merged tree. Co-Authored-By: Claude Fable 5.1 --- docs/design/miner-dev-fee.md | 7 +++--- docs/plans/release-0.3.6.md | 49 +++++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/docs/design/miner-dev-fee.md b/docs/design/miner-dev-fee.md index d0ec4032..479be688 100644 --- a/docs/design/miner-dev-fee.md +++ b/docs/design/miner-dev-fee.md @@ -33,7 +33,7 @@ Source: `vendor/igneum-node-v4` branch `dev-fee`, `igneum/miner/src/main.rs`, se | `igneum-miner mine ... --dev-fee ` | whole percent of templates; default 1; `--dev-fee 0` turns it off | | Start line, on | `dev fee 1% (1 block in 100) to 0x
; --dev-fee 0 turns it off` | | Start line, off | `dev fee off (--dev-fee 0); the default is 1% (1 block in 100) to 0x
` | -| Start line, no release address | `dev fee off: no release address is set (DEV_FEE_ADDRESS placeholder in igneum/miner/src/main.rs)` | +| Start line, no release address (a build whose `DEV_FEE_ADDRESS` is not 40 hex; none since 5 October 2026) | `dev fee off: no release address is set (DEV_FEE_ADDRESS placeholder in igneum/miner/src/main.rs)` | | Per fee block accepted | `dev-fee block ` | | Status line (CPU and worker modes) and `MINER SUMMARY` | `fee=N` | | `igneum-miner payouts ` | blocks per `IGNA` payout address over every block the node holds, with the share; the dev address is tagged `(dev fee)` | @@ -47,7 +47,7 @@ extra config. | Constant (`igneum/miner/src/main.rs`) | Value | Network | |---|---|---| -| `DEV_FEE_ADDRESS` | the placeholder string `DEV_FEE_ADDRESS`; **the project lead fills it before any public release**. While it is not 40 hex the fee is off outside the devnet and the miner says so at start | mainnet, testnet | +| `DEV_FEE_ADDRESS` | `0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126`: the project lead's payout address from the Igneum Wallet, given 5 October 2026, EIP-55 checksum verified, on file at `~/.config/igneum/dev-fee-release.json`; set on the fork's `release-0.3.6` (commit cf369022). Were it ever not 40 hex the fee would be off outside the devnet and the miner would say so at start | mainnet, testnet | | `DEV_FEE_ADDRESS_DEVNET` | `0xdfaea67368f3e3753397d878f97efe6aa8020c2e` | devnet and simnet only | The devnet address was generated on 4 October 2026 with the app's own key derivation (secp256k1, keccak of the @@ -59,7 +59,8 @@ have no value and the devnet may be reset; this address is never a release addre - Unit tests (`cargo test --release -p igneum-miner dev_fee_tests`): exactly 100 fee templates in 10,000 at 1%, at positions 99, 199, ...; 0 at `--dev-fee 0`; 2, 3, 5, 10, 50 and 100% exact over 10,000; a fee template carries the - dev `IGNA` address and the user's unchanged key reveal; the release placeholder keeps the fee off outside the devnet. + dev `IGNA` address and the user's unchanged key reveal; the release address pays the fee on mainnet and testnet (and + never the devnet address), and a non-hex placeholder keeps the fee off. - Test network: `node tools/dev-fee/run.mjs` (two nodes on 29900+, fast-time profile, proof of work skipped and the genesis target at the floor, three CPU stub miners: two at the default fee and one control at `--dev-fee 0`), then `igneum-miner payouts` on the peer node. The numbers are in `docs/bench-log.md` under "the software dev fee measured". diff --git a/docs/plans/release-0.3.6.md b/docs/plans/release-0.3.6.md index 5494ac7d..30d49d8b 100644 --- a/docs/plans/release-0.3.6.md +++ b/docs/plans/release-0.3.6.md @@ -47,15 +47,39 @@ On top of the merge, the fee height switch (section 5), in the same branch: | `shard_proving_gas_budget_at(daa)` | `consensus/core/src/proving.rs` | | every reader takes a DAA score; `execute_segment` picks the set by the block's DAA score and raises the carried base fees to its floors; the inspector carries the block's pgas table (modexp reads it); `next_base_fee` takes floor and denominator; the pool's `add` and `select` and every RPC quote use the tip's DAA score plus one; the shard plan uses the segment's; one new executor test | `igneum/exec/src/{config,executor,pgas,pool,proving,rpc,service}.rs` | +On top of that, two more fork commits: + +| Commit | What | +|---|---| +| cf369022 | `DEV_FEE_ADDRESS` = `0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126`, the release dev-fee payout address (testnet and mainnet; the project lead, 5 October 2026, EIP-55 checksum verified, on file at `~/.config/igneum/dev-fee-release.json`); `DEV_FEE_ADDRESS_DEVNET` unchanged; the placeholder test replaced by `the_release_address_pays_the_fee_outside_the_devnet` (`cargo test -p igneum-miner dev_fee`: 5 passed). `docs/design/miner-dev-fee.md` updated on this branch | +| 2b6d23ef | the merge of fork `miner-latency` (section 2), the final tip of `release-0.3.6` | + ### 1c. What is out | Branch | Why | |---|---| -| fork `miner-latency` (0f88b6d6, `vendor/igneum-node-latency`, one commit over `devnet-v4`) and the app repository's local `miner-latency` (dd47ff3: `tools/miner-latency/run.mjs`, its plan and bench-log entries; never pushed to origin) | see section 2 | +| the app repository's local `miner-latency` (dd47ff3, worktree `igneum-wt-latency`: `tools/miner-latency/run.mjs`, `docs/plans/miner-latency.md`, two bench-log entries; never pushed to origin) | not part of this task; it conflicts with `testnet-adopt` only in `docs/bench-log.md` (both appended entries) and can be merged at any time. The harness was run from that worktree for section 2 | -## 2. The miner-latency decision +## 2. The miner-latency decision: IN -(filled in below once the merged tree's miner tests and the 3-node fast-time run have been read) +Fork `miner-latency` (0f88b6d6, `vendor/igneum-node-latency`, one commit over `devnet-v4`: the NewBlockTemplate +subscription in the miner, node-side template prewarm, workers switching without draining the batch, `--poll-templates` +and `--job-ms`) was merged on a side branch first (`release-0.3.6-latency`, worktree `vendor/igneum-node-036-lat`, +c07093ce) and landed on `release-0.3.6` (2b6d23ef) only after both gates passed on the merged tree: + +| Gate | Command | Result | +|---|---|---| +| Miner tests | `cargo test -p igneum-miner` on c07093ce | ok: 15 passed, 0 failed | +| 3-node fast-time run | `node tools/miner-latency/run.mjs --mode subscribe` (from `igneum-wt-latency`, `IGNEUMD` and `IGNEUM_MINER` = the release build of c07093ce in `vendor/igneum-node/target-036-lat/release`, under the `run` lock, 08:11 to 08:16Z) | 3 nodes up (peers 2/1/1 throughout), 3 CPU miners at 2 threads for 295 s: 82 + 81 + 80 blocks accepted, 0 rejected; chain blocks 228, merged blue 15, merged red 0, tips mean 1.03 max 2, sinks agree; 236 to 241 template switches per miner, notified p50 14 to 16 ms, template p50 20 to 24 ms, switched p50 46 to 52 ms (p90 118 to 130 ms, max 194 to 372 ms); node prewarm 52 to 56 builds a minute, mean 3 to 4.5 ms; 1 to 2 stale submits per miner; no error, panic or WORKER FAULT line in any miner log | +| The dev fee through the new feed | `node tools/dev-fee/run.mjs --secs 180` on the same binaries (two nodes, three stub miners, two at the default fee and one at `--dev-fee 0`, then `igneum-miner payouts`; 08:17 to 08:20Z) | pass: miner-a found=327 fee=4, miner-b found=301 fee=4, control miner-c found=309 fee=0 (0 `dev-fee block` lines); `payouts` on both nodes: 938 blocks, the devnet dev address 8 blocks = the miners' counters, 1.27% of the fee-paying miners' blocks against the 1% expectation | + +The conflicts (13 hunks in `igneum/miner/src/main.rs`, the 0.3.5 dev fee and X21 mismatch guard against the latency +rewrite of the template path) were resolved by keeping both sides: the feed takes the dev fee and requests one +template in 100 with it, as the 0.3.5 fetcher did; the continuous CPU loop carries the fee through `Work.dev_fee` +and counts fee blocks; the found path runs `check_found` into the mismatch guard; `WorkEngine::Real` holds the +day-keyed `EpochRef` of M30. The 3-node run above showed `fee=0` on every miner: 243 blocks found at 1 in 100 +templates expects about 2.4 fee blocks, and zero has a probability of about 9%, so the dev-fee harness was run as +the third gate; it showed the fee riding through the new feed (8 of 8 fee blocks on chain, the control at 0). ## 3. Test results, with the command @@ -77,7 +101,19 @@ Every build ran through the main checkout's lock, `/Users/joshm/Projects/igneum/ ### 3b. The node fork (worktree `vendor/igneum-node-036`) -(filled in below) +| Fork tip | Command | Result | +|---|---|---| +| a11455e7 (testnet-params merged, the fee switch) | `cargo test -p kaspa-consensus-core` with `IGNEUM_FAST_TIME_FILE=/infra/fast-time/override-60x.json` (the fast-time test wants every override field; the main checkout's file predates `fees`) | ok: 101 passed, 0 failed, 2 ignored (lib) + 7 (db_compat); among them `igneum_testnet_identity`, `override_params_carry_the_fees`, `override_params_carry_the_fees_v1_activation`, `consensus_digest_keeps_the_0_3_5_value_until_the_fee_switch_is_set` (pins 9409dedac4bf... for the devnet params), `consensus_digest_covers_every_consensus_field_and_nothing_else` (the fee switch in its list), `test_genesis_hashes`, `fast_time_60x_file_is_the_devnet_at_60x`; 07:41Z | +| a11455e7 | `cargo test -p igneum-exec` | ok: 11 passed, 0 failed; among them `the_fee_switch_meters_by_the_block_daa_score` (a block below the switch meters 200 intrinsic pgas and keeps its base fees; the block at the switch meters 300 and its base fees jump to the v1 floors) and the bomb tests re-sized to fit both tables; 07:43Z | +| a11455e7 | `cargo test -p kaspa-consensus --features igneum-pow -- finality` | ok: 8 passed, 0 failed (92 filtered), 125.9 s | +| a11455e7 | `cargo test -p kaspa-consensus --features igneum-pow -- difficulty` | ok: 15 passed, 0 failed | +| a11455e7 | `cargo test -p igneum-miner` | ok: 12 passed, 0 failed (the guard and dev-fee tests) | +| c07093ce (miner-latency merged on the side branch `release-0.3.6-latency`, worktree `vendor/igneum-node-036-lat`, `CARGO_TARGET_DIR=target-036-lat`) | `cargo test -p igneum-miner` | ok: 15 passed, 0 failed (the 12 above and `job_size_follows_the_target_ms`, `cpu_found_nonce_is_matched_to_its_own_work`, `worker_found_hash_is_matched_to_its_own_template`) | +| c07093ce | 3-node fast-time run (section 2) | pass (section 2) | +| cf369022 (the release dev-fee address) | `cargo test -p igneum-miner dev_fee` | ok: 5 passed | +| 2b6d23ef (the final tip: cf369022 plus the latency merge) | `cargo test -p igneum-miner` | ok: 15 passed, 0 failed | + +Not built here: the release binaries for the three platforms (section 4b). ## 4. The morning cut, in order @@ -132,8 +168,9 @@ node tools/ship-app.mjs 0.3.6 \ ``` The 0.3.6 binaries are NOT built yet (this plan stops at the suites; section 3b says what was and was not built). -Build them as 0.3.5's section 4a did: Mac `cargo build --release -j 4 -p kaspad -p igneum-miner --features -kaspad/igneum-pow` with `CARGO_TARGET_DIR=vendor/igneum-node/target-036`; Windows through +Build them from fork 2b6d23ef as 0.3.5's section 4a did: Mac `cargo build --release -j 4 -p kaspad -p igneum-miner +--features kaspad/igneum-pow` with `CARGO_TARGET_DIR=vendor/igneum-node/target-036` (the release build in +`target-036-lat` is of c07093ce, one commit short: no release dev-fee address); Windows through `proto-cuda/windows-node/cross-build.sh`; Linux through `infra/cross/build-linux.sh`. The push to master triggers `windows.yml`, which now verifies the inputs of 4a before it builds the app.