diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index ef8b57bd4..4ab49c49e 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 87f71c6a2..436121a93 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 000000000..08ada0899 --- /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 000000000..61d99e658 --- /dev/null +++ b/docs/analysis/base-fee-floor.md @@ -0,0 +1,139 @@ +# Base-fee floors and the prover-gas table: the model (ADOPTED 5 October 2026) + +Status: every number below was proposed on the night of 4 October 2026 and ADOPTED by the owner on 5 October 2026, +as proposed (sign-off recorded in `docs/plans/release-0.3.6.md`). The parameters live in the node fork +(`consensus/core/src/fees.rs`, `FeeParams::CALIBRATED_V1`; written on branch `testnet-params`, merged into +`release-0.3.6` on 5 October 2026). Which network runs them: the testnet and the mainnet from genesis +(`Params.fees` = v1, `fees_v1_activation_daa` = 0); the devnet and the simnet keep `FeeParams::PROTOTYPE` until the +override file carries a `fees` object or the `fees_v1_activation_daa` height switch (section 4, "the devnet +rollout"), so the live devnet does not change rules between the 0.3.5 and 0.3.6 node builds. 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 | done as a height switch (5 October 2026): `Params.fees_v1_activation_daa` (override file, default never on devnet and simnet, 0 on testnet and mainnet, in the consensus digest). Chain blocks at or above the switch meter with v1 (every metering site in `igneum/exec` takes the block's DAA score, `fees::fee_params_at`); the first such block raises both base fees to the v1 floors. The live devnet keeps its history and its prototype rules until the switch is published with the 0.3.6 update (`docs/plans/release-0.3.6.md`, section 5). A fresh chain can instead carry `fees` in the override file (the fast-time profile does) | +| 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` and `Params.fees_v1_activation_daa` (`FeeParams::TESTNET` and `MAINNET` = `CALIBRATED_V1` with the switch at 0; `FeeParams::DEVNET` and `SIMNET` = `PROTOTYPE` with the switch never, both movable through the override file) | +| The execution layer's readers | `igneum/exec/src/config.rs` (`block_proving_gas_limit(daa)`, `intrinsic_pgas_per_tx(daa)`, the floor and initial readers, every one at a DAA score), `pgas.rs` (the inspector carries the block's table; the modexp entry reads it), `executor.rs` (`execute_segment` picks the set by the block's DAA score and raises the carried base fees to its floors; `next_base_fee` takes the floor and the denominator) | +| Installed at start | `kaspad/src/daemon.rs` (`install_fee_params`: the base set and the switch, printed after 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 000000000..2a57148f5 --- /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-josh` | 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 | vivanmn 6, peasehill 5, thrsty 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(vivanmn\|peasehill\|thrsty\|gemven\|jbm exec)\b==>[other-business]` | +| `messages.txt` (commit messages) | the first-name rules and the second-login rule | +| `mailmap` | both personal identities to `igneum-josh <337424239+igneum-josh@users.noreply.github.com>` | + +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-josh@...>`) and one more replace rule (`igneum-josh` 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-josh` (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/plans/release-0.3.6.md b/docs/plans/release-0.3.6.md new file mode 100644 index 000000000..ee307bfc2 --- /dev/null +++ b/docs/plans/release-0.3.6.md @@ -0,0 +1,170 @@ +# Igneum Miner 0.3.6: the testnet adoption, staged 5 October 2026 + +Prepared by the integration agent on the morning of 5 October 2026 after the owner's decision. Nothing was deployed, +published or merged to master. The app and document work sits on branch `testnet-adopt` (pushed to origin), worktree +`/Users/joshm/Projects/igneum-wt-adopt`; the node work sits on the fork branch `release-0.3.6` (local only; the fork +has no remote), worktree `vendor/igneum-node-036`. The cut is one command (section 4) after the inputs push of +section 3, which must come first. + +## 0. The decision (owner, 5 October 2026) + +The proposed testnet identity (`docs/testnet/README.md`) and the proposed fee floors and prover-gas table +(`docs/analysis/base-fee-floor.md`, spec 05 section 5.10) are ADOPTED as proposed. The three documents now say so; +the fork's `FeeParams::CALIBRATED_V1` and `TESTNET_PARAMS` are unchanged from the proposal. + +One rule the adoption added, so the live devnet is never forked by a node update: the devnet keeps its fee rules +until a height switch says otherwise (section 5). + +## 1. What is in + +### 1a. The app repository (branch `testnet-adopt`, from origin/master 2054ae3 = the 0.3.5 cut) + +| Merged | Branch head | What it carries | Conflicts and how they were resolved | +|---|---|---|---| +| `origin/testnet-prep` | beed743 (3 commits over e22068f) | `docs/testnet/README.md`, `docs/analysis/base-fee-floor.md`, spec 05 section 5.10, `docs/plans/history-rewrite.md` (G14, not acted on here), G13 signed build inputs (`app/igneum-app/src/inputs.rs`, `igneum-ota-sign sign-inputs` / `verify-inputs`, `packaging/windows/push-inputs.sh`, `inputs-manifest.sh`, `node-source.pin`, `test-inputs-signing.sh`, `fetch-ci-artifacts.sh --sign-manifest`, `.github/workflows/windows.yml`), `tools/ci/check-workflow-shell.mjs`, the testnet terms on the download section, `site/wallet.html`, the litepaper's app paragraph, the fast-time profile's `fees` object | four generated site files. `site/index.html`: the dev-fee sentence of 0.3.5 kept, testnet-prep's `#testnet-terms` card and wallet link kept (both sides of the download section); the inlined journey block is regenerated. `site/litepaper.html`: 0.3.5's two-paragraph dev-fee text kept (it is the fuller one; testnet-prep's one-sentence version dropped), testnet-prep's MetaMask and Igneum Wallet paragraph kept. `site/journey.json`: 0.3.5's log kept (the feed is the newest 40 entries; testnet-prep's older entries had already aged out of it). `site/sitemap.xml`: both `/miners` and `/wallet`. Then `node site/build.mjs` | + +On top of the merge, in the same branch: + +| Change | Where | +|---|---| +| "proposed" to "adopted 5 October 2026" with the sign-off noted, and the per-network fee rule written in | `docs/testnet/README.md`, `docs/analysis/base-fee-floor.md`, `docs/spec/05-fees-and-economics.md` (section 5.10 and the parameter table) | +| `fees_v1_activation_daa: 0` added to the 60x fast-time profile (the fork's `fast_time_60x_file_is_the_devnet_at_60x` test wants every override field present) | `infra/fast-time/override-60x.json` | +| this file | `docs/plans/release-0.3.6.md` | + +### 1b. The node fork (branch `release-0.3.6` in `vendor/igneum-node-036`, from `release-0.3.5` 20139145) + +| Fork merge | Head | What it carries | Conflicts | +|---|---|---|---| +| `testnet-params` (worktree `vendor/igneum-node-testnet`, forked from `finality-fixes` 6aa69a45) | 11e86144 | `consensus/core/src/fees.rs` (`FeeParams`, `PgasTable`, `CALIBRATED_V1`, `PROTOTYPE`), `Params.fees` and the override file's `fees` object, the testnet identity (`igneum-testnet-1`, chain id 4462, ports 268xx, the frozen genesis, `FinalityParams::MAINNET`, every switch at 0), the override file refused on the testnet, the execution layer reading the installed set (`B_p`, `S_p`, the intrinsic, modexp, the floors) | `consensus/core/src/config/params.rs`: both methods kept (`apply_env_pow_schedule` from G12 and `install_fee_params`); the M31 comment on `max_coinbase_payload_len` kept. `kaspad/src/daemon.rs`: the 0.3.5 start-up block kept (environment schedule on devnet and simnet, `install_pow_schedule`, the digest line) and `install_fee_params` with its own print added after it | + +On top of the merge, the fee height switch (section 5), in the same branch: + +| Change | Where | +|---|---| +| `FeeParams::DEVNET` and `SIMNET` = `PROTOTYPE`; `TESTNET` and `MAINNET` = `CALIBRATED_V1`. `FeeSchedule { base, v1_activation_daa }` with `at(daa)`; `install_fee_params(base, switch)`; `fee_params_at(daa)` replaces `fee_params()` | `consensus/core/src/fees.rs` | +| `Params.fees_v1_activation_daa` (devnet and simnet `u64::MAX`, testnet and mainnet 0), `OverrideParams.fees_v1_activation_daa`, the digest rule of section 5, four new or changed tests | `consensus/core/src/config/params.rs` | +| the switch and a `fees` object printed from the override file; the installed schedule printed with the network | `kaspad/src/daemon.rs` | +| `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` | + +### 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 | + +## 2. The miner-latency decision + +(filled in below once the merged tree's miner tests and the 3-node fast-time run have been read) + +## 3. Test results, with the command + +Every build ran through the main checkout's lock, `/Users/joshm/Projects/igneum/tools/lock/with-lock.sh build`, at +`nice -n 19` with `-j 4`. The fork's builds used `CARGO_TARGET_DIR=vendor/igneum-node/target-036`, an APFS clone of +`target-release` (48 s to clone). The rustup cargo (1.99.0) on `~/.cargo/bin`. + +### 3a. The app repository (worktree `igneum-wt-adopt`) + +| Check | Command | Result | +|---|---|---| +| Site | `node site/build.mjs` | built: bench, journey (40 entries), index, litepaper, live, evidence, wallet, 404, miners (6 rows) | +| Links | `node tools/ci/link-check.mjs` | 324 internal links across 8 pages, 0 broken | +| Workflow shell | `node tools/ci/check-workflow-shell.mjs` | self-test fires on the fixture; 11 run blocks in 2 workflows, 25 `.ps1` files, 0 findings | +| Signed inputs | `packaging/windows/test-inputs-signing.sh` with `igneum-ota-sign` built from this tree (`cargo build --release -j 4 --bin igneum-ota-sign`, 8 s with the cloned target) | 16 passed, 0 failed: the four positive cases, the ten refusals (changed zip byte, changed manifest byte, changed unpacked file, unlisted file, missing file, wrong pin, short pin, another key, the embedded key against the throwaway signature, the two sign-time refusals), and the Mac's real OTA key verified by the key compiled into the app | +| Shell parses | `bash -n` on `push-inputs.sh`, `fetch-ci-artifacts.sh`, `inputs-manifest.sh`, `test-inputs-signing.sh` | ok | +| JavaScript parses | `node --check` on `tools/ci/check-workflow-shell.mjs`, `tools/ci/link-check.mjs`, `site/build.mjs` | ok | +| PowerShell parse rule (no `$var:` inside double quotes) | the 0.3.5 scanner over every tracked `.ps1` and the PowerShell strings in `ota.rs`, `jobrun.rs`, `jobbuild.rs` | the 3 baseline hits of 0.3.5 only (two comments in `check-ps51.ps1` that quote the rule; `ota.rs` line 1190, the Mac helper's bash); testnet-prep changed no `.ps1` | + +### 3b. The node fork (worktree `vendor/igneum-node-036`) + +(filled in below) + +## 4. The morning cut, in order + +### 4a. The signed inputs come first (G13) + +The workflow and `push-inputs.sh` changed as a pair. From this tree on, `.github/workflows/windows.yml` fetches +`payload-inputs.json.sig` from the downloads host and refuses to build until `igneum-ota-sign verify-inputs embedded` +accepts the manifest's signature, the zip's sha256, every unpacked file and the node commit pinned in +`packaging/windows/node-source.pin` in the commit it builds. The downloads host holds no signature today (0.3.5 +pushed an unsigned `inputs.json` and a bare `.sha256`), and the pin in this tree still reads 6aa69a45 (the 0.3.4 +node, written by the old script). So the FIRST push of the new workflow would fail at "payload inputs" unless the +inputs are pushed from this tree before the workflow runs: + +``` +cd /Users/joshm/Projects/igneum-wt-adopt +gh auth switch --user igneum-josh +packaging/windows/test-inputs-signing.sh # 16 of 16, with the real key at the end +IGNEUM_NODE_SRC=vendor/igneum-node-036 IGNEUM_WIN_RELEASE= \ + packaging/windows/push-inputs.sh --deploy # signs payload-inputs.json, writes node-source.pin +git add packaging/windows/node-source.pin && git commit -m "inputs: pin node for 0.3.6" +``` + +What the workflow needs, and where it comes from: + +| Need | Source | +|---|---| +| `DL_TOKEN` repository secret | already set for 0.3.5 (`gh secret set DL_TOKEN < ~/.config/igneum/dl-token`); the workflow fetches the zip, the manifest and the signature with it | +| the embedded public key | compiled into the app (`app/igneum-app/src/manifest.rs`); `igneum-ota-sign embedded` prints it; `push-inputs.sh` refuses to sign if `~/.config/igneum/ota-signing-key.pub` is not that key | +| `~/.config/igneum/ota-signing-key` (0600) and `.pub` | the OTA key the apps already trust; signs the manifest on the Mac | +| `packaging/windows/node-source.pin` | written by `push-inputs.sh`, committed with the push; the runner compares it with the manifest's `node_source_commit` | +| the Windows node exes | `IGNEUM_WIN_RELEASE` names the folder with `igneumd.exe` and `igneum-miner.exe` built from the fork commit the pin names (the 0.3.6 cross-build; `proto-cuda/windows-node/cross-build.sh` as in 0.3.5) | + +Then the update manifest is signed only on request: `packaging/windows/fetch-ci-artifacts.sh --sign-manifest +--deploy` after the run is green, which re-verifies the run's inputs artifact against the key and the pin at the +run's commit before `publish-manifest.sh` runs. + +### 4b. The merge and the ship + +From the MAIN checkout, on master, after 4a's pin commit is on `testnet-adopt`: + +``` +cd /Users/joshm/Projects/igneum +gh auth switch --user igneum-josh +git fetch origin && git checkout master && git pull --ff-only +git merge --ff-only origin/testnet-adopt # testnet-adopt contains master 2054ae3 +node tools/ship-app.mjs --check # 0.3.5 in all 6 files +node tools/ship-app.mjs 0.3.6 \ + --node vendor/igneum-node-036 \ + --win-release \ + --mac-release \ + --notes "Testnet identity and the adopted fee table in the node (devnet unchanged until the fee switch), signed build inputs" +``` + +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 +`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. + +## 5. The devnet rollout of the fee floor: a height switch + +The live devnet runs the prototype fee set (`B_p` 30,000,000, 1 gwei floors, intrinsic 200). A 0.3.6 node on the +devnet runs exactly that until told otherwise, so 0.3.5 and 0.3.6 nodes build and accept the same segments through +the whole rolling update. The adopted set reaches the devnet by `fees_v1_activation_daa`, the same pattern as +`difficulty_v2_activation_daa` (33,000, 4 October 2026): + +| Step | What | Check | +|---|---|---| +| 1 | Ship 0.3.6 (section 4). Every node updates with the switch at its default, never. The consensus digest does not move (the fee fields enter the digest only once the switch or the set leaves the 0.3.5 state), so 0.3.5 and 0.3.6 peers keep handshaking | `Consensus params digest: 9409dedac4bf...` on a node with no override file, the same line 0.3.5 printed; the live devnet nodes (override file with the difficulty and finality switches) print their own unchanged value | +| 2 | Wait until every devnet node is on 0.3.6: the console's Machines card and the user agents in the peer list (`igneumd/2.1.0-<0.3.6 fork commit>`) | no `-20139145` user agent left | +| 3 | Pick the switch height: a DAA score at least 24 hours ahead (86,400 blocks), on a round number, announced in the engineering log and the app's update note | | +| 4 | Publish the switch with the other switches in the update manifest's `consensus.override` (`packaging/ota/publish-manifest.sh --override '{"difficulty_v2_activation_daa": 33000, ..., "fees_v1_activation_daa": N}' --deploy`), and add the line to every hand-run node's override file. The digest moves the moment a node restarts with it; a node that has not restarted is refused by those that have, which is why the restart must sweep every node before N | `Calibrated v1 fees from the override file: ... from DAA score N` in every node's start-up log; one digest across the peer list | +| 5 | At N: the first chain block at or above N meters with v1 (intrinsic 300, `B_p` 120,000, `S_p` 30,000, modexp 10 + 1 per 10 bytes) and its base fees jump to 100 gwei per gas and 10,000 gwei per pgas. Nothing resets; history stays | `igneum_getBudgets` returns `provingGasLimit` 120,000 and the two base fees at the floors; `eth_getBlockByNumber` of the switch block shows `provingBaseFeePerGas` 0x9184e72a000 | +| 6 | The prover's mirror of the table (`proving/igneum-prove/core/src/config.rs`, `pgas.rs`: prototype values at b7fca5a0) must carry v1 before N, or every shard statement after N differs from the node's plan. This is the one change this plan does not make; it re-cuts every fixture at `S_p` 30,000 and changes the guest program id | the fixtures and the guest id in `docs/analysis/base-fee-floor.md` section 4 | + +Why a switch and not a fresh chain: the devnet has 12 cloud nodes, three PCs and outside machines on it, with the +difficulty v2 rollout as the precedent that a height switch over a live chain works. Why not the override file's +`fees` object: that changes the rules of every block including the past, so a node restarted with it could not +replay its own history. + +## 6. Open after this plan + +| Item | State | +|---|---| +| `--netsuffix` default 1 under `--testnet` | one line in `kaspad/src/args.rs`, not done; `igneumd --testnet --netsuffix 1` until then | +| the testnet genesis message | reads `proposed, not final` (the proposal's words, now part of the hashed genesis 52a3e6a9...). Adopted as computed. If the owner wants the words changed, it is one `print_genesis_hashes` run, new hash and merkle root in `genesis.rs`, `test_genesis_hashes` and the README, and it must happen before the first public node, never after | +| the prover's table mirror | section 5 step 6 | +| seed nodes, public RPC, the explorer, the app's testnet build | `docs/testnet/README.md` section 5, unchanged | +| G14 history rewrite | `docs/plans/history-rewrite.md`, merged as a plan, not acted on | +| the inputs push (4a) and the 0.3.6 binaries | not done here | diff --git a/docs/spec/05-fees-and-economics.md b/docs/spec/05-fees-and-economics.md index 70fede807..2660ecd49 100644 --- a/docs/spec/05-fees-and-economics.md +++ b/docs/spec/05-fees-and-economics.md @@ -82,6 +82,37 @@ 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) | Adopted (5 October 2026, as proposed on 4 October 2026; was Target) | +| Shard budget `S_p` | 30,000 pgas (section 5.10) | Adopted (5 October 2026, as proposed on 4 October 2026) | +| Base-fee floors `f_e`, `f_p` | 100 gwei per gas, 10,000 gwei per pgas (section 5.10) | Adopted (5 October 2026, as proposed on 4 October 2026) | +| pgas table | version 1: intrinsic 300, modexp 10 + 1 per 10 bytes, other entries prototype (section 5.10) | Adopted (5 October 2026, as proposed on 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 (Adopted 5 October 2026) + +Proposed on 4 October 2026 and adopted by the owner on 5 October 2026, as proposed (the sign-off is recorded in +`docs/plans/release-0.3.6.md`). The arithmetic is in `docs/analysis/base-fee-floor.md`; the values are implemented +in the node fork (`consensus/core/src/fees.rs`, `FeeParams::CALIBRATED_V1`, carried by `Params.fees` per network +and by the override file; merged into the fork's `release-0.3.6`). The testnet and the mainnet run them from +genesis. The devnet and the simnet keep the prototype values (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; `FeeParams::PROTOTYPE`) until the +`fees_v1_activation_daa` height switch, carried by the override file and the consensus digest, moves them: chain +blocks at or above that DAA score meter with the adopted table, budgets and floors, and the first such block raises +both base fees to the floors. + +| Parameter | Adopted | 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 000000000..a2fd00d31 --- /dev/null +++ b/docs/testnet/README.md @@ -0,0 +1,83 @@ +# Igneum public testnet: identity, parameters and reset policy (ADOPTED 5 October 2026) + +Every value in this file was proposed on the night of 4 October 2026 and ADOPTED by the owner on 5 October 2026, +as proposed (sign-off recorded in `docs/plans/release-0.3.6.md`). The genesis below is the one the proposal +computed; its values did not change at the sign-off, so the hash stands. The code was written on the node fork's +branch `testnet-params` (worktree `vendor/igneum-node-testnet`, forked from `finality-fixes` at `6aa69a45`) and +merged on 5 October 2026 into the fork's `release-0.3.6` (worktree `vendor/igneum-node-036`), with one change the +sign-off added: the devnet and the simnet keep the prototype fee set until a height switch +(`fees_v1_activation_daa`) or a `fees` object in the override file moves them; the testnet and the mainnet carry +calibrated v1 from genesis (section 3). Nothing is deployed; the testnet has no seed nodes yet (section 5). + +## 1. Identity + +| Field | Devnet (live today) | Testnet (adopted) | 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; adopted 5 October 2026 | +| 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`. The words "proposed, not final" are the proposal's and are part of the hashed genesis; the sign-off adopted the genesis as computed. Changing the message is one `print_genesis_hashes` run and a new hash, and must happen before the first public node starts, never after (`docs/plans/release-0.3.6.md`, section 6) | +| Hash | `52a3e6a9ddd79d603ff9e3a27487fb5d0ca5ca6f633fe20ca727e1faa355fc7e` | computed 4 October 2026 by `print_genesis_hashes` on the branch (pinned by `test_genesis_hashes`, re-run green on `release-0.3.6` on 5 October 2026); changes with any field above | +| Merkle root | `3060aab78494de3cbda68619c0132300aa486dc3153dbb78b50a430f053b3a84` | same | + +## 3. Consensus parameters + +| Parameter | Testnet (adopted) | 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` from genesis (`fees_v1_activation_daa` 0; `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 | `FeeParams::PROTOTYPE` (`B_p` 30 M, 1 gwei), kept on the 0.3.6 node so the live chain does not change rules between builds; moves to v1 by the `fees_v1_activation_daa` height switch in the override file (`docs/plans/release-0.3.6.md`, section 5) | 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 | Adopted 5 October 2026 | +|---|---| +| 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 | done: adopted by the owner on 5 October 2026, as proposed | +| `--netsuffix` default 1 under `--testnet` | one line in `kaspad/src/args.rs`, still to do (not in the 0.3.6 merge; `--netsuffix 1` must be passed until then) | +| 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/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json index 850d7f9f5..d5db07547 100644 --- a/infra/fast-time/override-60x.json +++ b/infra/fast-time/override-60x.json @@ -53,5 +53,7 @@ "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_v1_activation_daa": 0, + "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} } diff --git a/packaging/windows/fetch-ci-artifacts.sh b/packaging/windows/fetch-ci-artifacts.sh index 08a71a997..f7d12d6d3 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-josh (gh auth switch --user igneum-josh). 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 000000000..20885cd27 --- /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 000000000..0551687d0 --- /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 6f9b8d6e1..3676044a0 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 000000000..bb4ba7dc6 --- /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 bca43946f..38d9d3b93 100644 --- a/site/404.html +++ b/site/404.html @@ -163,6 +163,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 fc9a8b1d8..2cce81fb6 100644 --- a/site/bench.html +++ b/site/bench.html @@ -170,12 +170,12 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
-
61 entries, newest at the bottom
+
63 entries, newest at the bottom

Engineering log

Every measurement the project has made, newest at the bottom, written by the people and agents who ran it, with the commands and hardware. Prototype numbers are not mining numbers and say so.

- +

Igneum bench log

Append-only. Every number here was measured on the machine named, on the date given.

2026-10-03 proto-metal / igneum-bench, first run

@@ -450,7 +450,39 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:

dev fee 1% (1 block in 100) to 0xdfaea67368f3e3753397d878f97efe6aa8020c2e; --dev-fee 0 turns it off (a, b) dev fee off (--dev-fee 0); the default is 1% (1 block in 100) to 0xdfaea67368f3e3753397d878f97efe6aa8020c2e (c)

Miner--dev-feeBlocks found (its summary)Rejectedfee= in its summarydev-fee block linesBlocks to its own address on the chain
a1 (default)385055380
b1400044396
c0 (control)397000397

The chain (payouts, identical on node 0, the miners' node, and node 1, its peer): 1,183 blocks; 380 + 396 + 397 to the three user addresses, 9 to the dev-fee address 0xdfaea6...0c2e (0.761% of all blocks, 1.146% of the 785 blocks of the two fee-paying miners), and 1 block with no IGNA field, the genesis block. Found per miner = own blocks + fee blocks exactly (380 + 5, 396 + 4, 397 + 0), so every fee block the miners logged is on the chain and no other block went to the dev address. The control miner paid nothing.

-

Reading. The expectation is 1 in 100 templates; the stub miner found a block on most but not every template once the difficulty had risen on three miners at about 2 blocks/s against the 1 block/s target (a and b held about 500 templates each, 5 and 4 of them fee templates at positions 99, 199, ..., and converted 77 to 80% of templates into blocks), so the block share lands near 1% with the sampling noise of 9 events (1.15% here). The exactness claim is on the template side and is the unit test (dev_fee_tests: 100 of 10,000 at positions 99, 199, ...; 0 at --dev-fee 0; exact at 2 to 100%); the chain test shows the fee blocks reach the chain and are counted the same by the miner and by both nodes. Not measured: worker (GPU) mode, where the fee template is one of the templates the background fetcher rotates through once a second per identity, so the share is 1 in 100 templates by time, not by job; and nothing on Windows.

+

Reading. The expectation is 1 in 100 templates; the stub miner found a block on most but not every template once the difficulty had risen on three miners at about 2 blocks/s against the 1 block/s target (a and b held about 500 templates each, 5 and 4 of them fee templates at positions 99, 199, ..., and converted 77 to 80% of templates into blocks), so the block share lands near 1% with the sampling noise of 9 events (1.15% here). The exactness claim is on the template side and is the unit test (dev_fee_tests: 100 of 10,000 at positions 99, 199, ...; 0 at --dev-fee 0; exact at 2 to 100%); the chain test shows the fee blocks reach the chain and are counted the same by the miner and by both nodes. Not measured: worker (GPU) mode, where the fee template is one of the templates the background fetcher rotates through once a second per identity, so the share is 1 in 100 templates by time, not by job; and nothing on Windows.

+

4 October 2026 (night), ledger M30: the block and transaction floods grew the node by 256 MiB per epoch roll, fixed by sharing the PoW cache across the epochs of a day (memory engineer)

+

Machine: Apple M5 Max, 64 GB, load averages 126 to 146 for the whole session (ten or more agents building and running at once). Every count here (blocks accepted, cache builds, RSS before and after) is valid under that load; every latency is an upper bound and is not a number. Private test network of two igneumd on 127.0.0.1 ports 29500+ (node A on 29500/29501/29502, node B on 29510/29511/29512), data under /tmp/igneum-fud-mem/{baseline,after,after2} (the second is pass 1, the third the committed build), the 60x fast-time profile (infra/fast-time/override-60x.json, skip_proof_of_work on, pow_epoch_blocks 60, pow_day_ms 1,440,000) exactly as the red team ran it. The live devnet and other agents' ports were not touched. Every run went through tools/lock/with-lock.sh run, every build and the unit tests through with-lock.sh build.

+

What the red team saw (docs/review/redteam-2026-10-04.md rows 5 and 8, ledger M30): on the 0.3.4 build the s6 submit flood grew RSS by +269 MB, the mempool flood by +270 MB, and the s7 block flood took both nodes from 302 to 1,082 MB. The s6 figures were cumulative from one baseline taken before all three loads (rss_peak - rss_baseline in s6-exhaustion.mjs), so the mempool flood's "+270 MB" was the submit flood's growth carried forward; its own cost was 1 MB. The red team's guess (execution-layer records, rejected transactions retained) did not hold: the mempool flood retains nothing measurable.

+

Cause, measured: every RSS step is one PoW cache built line in the node log (consensus/src/pipeline/header_processor/pre_ghostdag_validation.rs:136), 256 MiB each. The lottery engine (consensus/pow/src/igneum.rs, IgneumEngine::epoch_for_impl) keyed its resident entries by (epoch seed, day) and built a full igneum_pow::Epoch (program plus the 256 MiB ChaCha12 cache) per entry, keeping KEEP = 4 of them, although the cache is a function of the day seed alone (igneum_pow::Epoch::from_seed_bytes: seed_words_from_bytes(day_bytes); the epoch seed only feeds the program). On the 60x profile an epoch is 60 DAA, so the honest chain rolls an epoch every minute and the 50x block flood every 10 to 20 s; each roll cost a cache build and 256 MiB until the fourth entry, then evictions. The engine runs under skip_proof_of_work too (check_pow_and_calc_block_level always calls it and forces the pass afterwards), so the harness exercised it. The 3 October harness run (this file, "2026-10-03, consensus attack harness") was on the plain devnet profile (3,600-DAA epochs), where no roll falls inside a 60 s flood, which is why it grew 11 to 33 MB; the profile change and the build change were conflated in M30. On the live devnet the same rule means 256 MiB per hour until 1 GiB resident, and a 50x fast miner reaches that in minutes.

+

Fix (node fork branch fud-memory, from finality-fixes 6aa69a45): the engine now holds the 256 MiB caches keyed by day (IgneumEngine::KEEP_DAYS = 3: the chain's current day, the next day, one slot for a late block of the previous day or an off-day header; the live pair is never evicted while another day's cache exists; bound 3 x 256 MiB = 768 MiB resident plus MAX_INFLIGHT_BUILDS = 2 x 256 MiB while builds run) and the programs keyed by (epoch seed, day) (IgneumEngine::KEEP = 8, a few KB each, LRU). An epoch roll on the same day generates a program and builds no cache; a BuildReport (the p2p off-day strike and the "PoW cache built" line) is produced only for a cache build. EpochRef { program, dataset: Arc<DatasetSource> } replaces Arc<igneum_pow::Epoch> for the node and the miner and computes the identical hash (interpret_warp_init on block_init_words, as Epoch::pow_bound does); the unit test epoch_rolls_share_the_day_cache checks the engine's pow against a standalone igneum_pow::Epoch for two epochs of one day. Programs whose day cache was evicted are dropped with it, so no entry pins a cache. No consensus rule changed: the hash, the seeds and the acceptance are as before. Miner cost: the GPU prepare path (--prepare-packs) exports a pack per epoch roll from a standalone epoch (its own transient fill), because igneum_pow::emit::export_pack wants the crate's own Epoch and DatasetSource is not shareable without a change to the igneum-pow crate.

+

Commands (run from igneum-wt-fud-memory; the "before" binary is the shipping vendor/igneum-node/target-finality/release/igneumd, the "after" binary is vendor/igneum-node-fud-mem/target/release/igneumd from the commit below):

+

` IGNEUMD=<binary> IGNEUM_HARNESS_BASE_PORT=29500 IGNEUM_HARNESS_TMP=/tmp/igneum-fud-mem/<run> \ tools/lock/with-lock.sh run node tools/harness/run.mjs s6 s7 --quick --fast-time --live-only --no-bench-log cd vendor/igneum-node-fud-mem && tools/lock/with-lock.sh build nice -n 19 cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow cd vendor/igneum-node-fud-mem && tools/lock/with-lock.sh build nice -n 19 cargo test --release -j 4 -p kaspa-pow --features kaspa-pow/igneum-pow -p igneum-exec `

+

RSS per load, node A / node B, MB (start of the load to its peak; "builds" = PoW cache built lines during the load). s6 loads are 30 s each in --quick; s7 is the vmine miner at 50x for 60 s.

+
LoadBefore: start to peak A / BBefore: builds A / BAfter (pass 1, build without the insert guard): start to peak A / BAfter: builds A / B
s6 warm-up baseline (RSS before any load)303 / 3041 / 1 (startup)305 / 3071 / 1 (startup)304 / 304
s6 template flood, 500/s, 14,995 and 14,999 sent, all answered304 to 309 / 304 to 309 (+5 / +5)0 / 0305 to 311 / 307 to 310 (+6 / +3)0 / 0304 to 310 / 304 to 308 (+6 / +4)
s6 submit flood, 50/s, 1,499 known-block submits, all answered; the 60-DAA epoch rolled during it309 to 572 / 309 to 566 (+263 / +257)1 / 1311 to 314 / 310 to 312 (+3 / +2)0 / 0310 to 319 / 308 to 310 (+9 / +2)
s6 mempool flood, 500/s, 14,993 and 14,995 unknown-outpoint transactions, all rejected572 to 573 / 566 to 567 (+1 / +1)0 / 0315 to 316 / 312 to 313 (+1 / +1)0 / 0319 to 320 / 310 to 310 (+1 / +0)
s7 block flood, vmine at 50x for 60 s: 198 and 202 blocks accepted (3.3 and 3.4 per s), epochs 0 to 3 rolled in every run302 to 1,085 / 303 to 1,083 (+783 / +780); steps at 10 s 569, 20 s 827, 40 s 1,0843 / 3302 to 318 / 302 to 315 (+16 / +13)0 / 0300 to 315 / 302 to 315 (+15 / +13)
+

Honest template p95 (upper bounds; the before run and pass 1 ran at load over 100, the committed-build run at load under 5 for s6 and about 10 for s7): before 130.8 / 86.7 / 104.7 ms per s6 load against a baseline of 84.7, s7 91.5 against 92.8; committed build 0.5 / 0.6 / 78.7 against 0.7, s7 81.3 against 104.6. Both nodes alive in every run. The s6 row's one-instant sink check failed in the before run (120 against 121 blocks) and in the committed-build run (121 against 122) while the honest miner was mid-submit, and passed in pass 1; the s7 sinks agreed in every run. That check reads the two sinks once without waiting (waitSameSink exists in lib/net.mjs and s6 does not use it), so it is a harness flake, not a node finding; left as is tonight. The red team's harness criterion (baseline + 512 MB) still passes before and after; the 50 MB-per-load target holds after.

+

Harness changes (this repo): IGNEUM_HARNESS_BASE_PORT and IGNEUM_HARNESS_TMP (ports and data directory, so two agents can run the harness at once), the u64 sentinel round-trip fixed with the BigInt reviver from tools/finality-attacks/lib/net.mjs (ledger F25), s6 records rss_start, rss_delta and cache_builds per load and its row reports per-load growth, s7 records cache_builds beside every RSS sample, and --live-only skips the s7 simulator part. Result JSON: docs/benchmarks/memory-floods-2026-10-04/{before,after,after-pass1}-{s6-exhaustion,s7-flood}.json (after is the committed build, after-pass1 the build before its last three-line guard: insert_program skips a program whose day cache was evicted during its generation, which cannot fire in these single-day runs). Data directories /tmp/igneum-fud-mem/{baseline,after-pass1 as after,after2}.

+

Not covered tonight: the execution layer's ExecState.records (igneum/exec/src/service.rs:31, pushed per chain block, never truncated) is a slow growth, not a flood effect: 197 chain blocks cost under 1 MB in these runs, and a record on an empty devnet is roughly 1 to 2 KB (approximate, from the struct), so about 100 to 170 MB per day at 1 block/s; bounding it needs a window at least as long as the proving sortition window (proving.rs:200) plus the RPC's by-number history, which is a design choice, not a cache. The snapshot ring (SNAPSHOT_RING = 64 full IgneumDb clones) is bounded in count but scales with the state size. The finality store trims votes, checkpoints, certificates and locks every index (processes/finality.rs:531); its keys and stripped maps grow with distinct vote keys (about 150 bytes per key, approximate). The proof pool keeps RECORD_WINDOW_CHAIN_BLOCKS of entries. None of these moved in these floods.

+

Steady state, no flood (5 October 2026, 01:20 to 01:48 UTC, asked for after the live app node on this Mac was reported at 1,081 MB at 27 min, 1,193 MB at 79 min and 2,258 MB at 4 h 14 min on the devnet profile): the new scenario tools/harness/scenarios/s8-steady.mjs, two nodes on the 60x profile (60-DAA epochs, a 24-minute day), one honest vmine miner at 1 block/s on node A, node B following, 1,500 blocks, RSS and the PoW cache built count every 60 s, vmmap -summary of both nodes at 0, 500, 1,000 and 1,500 blocks. Both builds ran at the same time on their own run slots (the lock script's three run slots) and port ranges. The first 8 minutes ran at load 60 to 85, the rest at load 2 to 8; the counts do not depend on it.

+

` IGNEUMD=<binary> IGNEUM_FAST_TIME=1 IGNEUM_HARNESS_BASE_PORT=<29500|29600> IGNEUM_HARNESS_TMP=/tmp/igneum-fud-mem/steady-<before|after> \ IGNEUM_STEADY_BLOCKS=1500 IGNEUM_STEADY_MAX_MIN=40 tools/lock/with-lock.sh run node tools/harness/scenarios/s8-steady.mjs `

+
Blocks (node A / B, both equal)Before (shipping igneumd, 29500+): RSS A / B MB, cache buildsAfter (796f758d, 29600+): RSS A / B MB, cache builds
0 (nodes up, miner not started)41 / 42, 041 / 42, 0
514 and 5101,342 / 1,342, 9319 / 317, 1
1,008 and 1,0291,355 / 1,355, 18589 / 588, 2
1,529 and 1,526 (end, 1,527 and 1,521 s)1,371 / 1,372, 27603 / 602, 2
Slope from 1,000 blocks to the end30.7 MB per 1,000 blocks30.2 MB per 1,000 blocks
+

Reading. Before: one cache build per epoch roll (25 rolls in 1,529 blocks, plus the two days) and the RSS steps with them up to five chunks: KEEP = 4 plus one evicted 256 MiB chunk the allocator keeps and never returns to the OS (vmmap at 500, 1,000 and 1,500 blocks: 1.3 G resident in the region vmmap labels IOAccelerator, which held exactly the cache chunks; the malloc zones hold 9 to 22 MB). It stays at five: the before node is flat at 1,34x to 1,37x from 514 blocks on. After: one cache for the first day (319 MB at 510 blocks), a second when the fast-time day rolled at 01:36 UTC (both runs; the day cache is per day by design, KEEP_DAYS = 3), 2 of 2 builds in 1,526 blocks against 27 of 27 before; on the devnet profile (24-hour day, 1-hour epochs) that is 256 MiB flat, 512 MiB around midnight UTC, 768 MiB worst case, against 256 MiB per hour up to 1.3 GB before. So per 1,000 blocks: before, 1,300 MB in the first 500 blocks (the four caches plus the kept chunk) then 31 MB; after, 30 MB, plus 256 MiB once per day. The 30 MB per 1,000 blocks is the same on both builds and is not the PoW cache: at 1,526 blocks the after node's non-cache footprint is 584.0 M physical minus 2 x 256 MiB = 72 MB against 23 MB at 0 blocks, and the malloc zones account for 22 MB of it, so most of it is in large vm_allocate regions, which on this node means the consensus database's write buffers and block cache and the consensus in-memory caches filling toward their fixed sizes (rusty-kaspa sizes them in entries for mainnet), not the execution layer (1,526 chain-block records on an empty chain are about 2 to 3 MB, approximate) and not the finality key maps (1 voter here). That is a reading, not a measurement: it needs a longer run to see the plateau (the live node's own figures fit it: 1,081 to 1,193 MB over 52 minutes with no epoch roll is 36 MB per 1,000 blocks). The live app node's 2,258 MB at 4 h 14 min is more than the before build can hold on its own (five chunks plus 30 MB per 1,000 blocks gives about 1.7 GB at 15,000 blocks); the app runs a GPU worker beside the node (Metal buffers and a kernel per epoch), which this harness does not cover, so that node wants its own vmmap -summary. Result JSON docs/benchmarks/memory-floods-2026-10-04/{before,after}-s8-steady.json, vmmap summaries under .../vmmap/.

+

4 October 2026 (night), round-4 consensus items F23, F24, G12, X18 and M31: unit tests and fast-time 3-node runs against the control (consensus engineer)

+

Branches: node fork fud-consensus (worktree vendor/igneum-node-fud, from finality-fixes 6aa69a45, no remote; commits 9f738e2e the four items, ae9df8a3 pending certificates at fresh determinations, b755f43d merge of fud-memory, then M31 and the un-determination rule), main repo fud-consensus. Machine shared with the red team, the release builds and the memory runs all night (load average 100 to 146 until about 00:00 UTC, under 20 after); every number here is a count, a lock or an index, not a timing. Runner tools/finality-attacks/fud.mjs (scenarios digest, ban, reorg; fast-time 60x profile with finality_v3_activation_daa 0 merged by the BigInt-safe overrideParams, ports 29400+, suffix 940, /tmp/igneum-fin-fud, node logs kept per scenario); the control is the shipping finality-fixes build vendor/igneum-node/target-finality/release/igneumd driven by the same miner. Raw result files in docs/benchmarks/round4-consensus-2026-10-04/.

+

Builds. with-lock.sh build nice -n 19 cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow on a target directory cloned from target-finality with cp -Rc (APFS clonefile, 1 min, no disk): 17 min 53 s the first time under load 140, 8 min 58 s the second. Unit tests in the release profile (cargo test --release -j 4 -p <crate> --lib): consensus-core config::params::tests and igneum 20 of 20 (new: consensus_digest_covers_every_consensus_field_and_nothing_else, env_pow_schedule_is_devnet_and_simnet_only), consensus processes::finality 6 of 6 (new: ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list with three TestConsensus nodes on one chain, reorg_past_an_unlocked_checkpoint_re_determines_it_and_verifies_the_pending_certificate, a_locked_checkpoint_pins_the_chain_and_a_certificate_against_it_conflicts). After M31 and the un-determination rule (fork 977db931, with the fud-memory merge): consensus-core params and igneum tests 28 of 28 then params 12 of 12 (new: largest_coinbase_fits_on_every_network), consensus processes::finality 7 of 7 (new: a_shallower_sink_un_determines_the_indices_it_cannot_reach), kaspa-pow with igneum-pow 12 of 12; the second rebuild took 15 min 18 s under load 110 to 134.

+

X18, the params digest (fud.mjs digest: n1 listens on the shared override, n0 dials it with finality.weight_window 121 instead of 120, then n2 dials with the shared override).

+
Buildn0's digestn1's digestmismatch lines n0 / n1 / n2peers on n1 after 25 s, then after n2 dialledn2 connected
fud-consensus (9f738e2e and the final pass)4bf763ba...7a40cc3b...1 to 2 / 2 / 00, then 1after 1 s
control, finality-fixes 6aa69a45none printednone printed0 / 0 / 01, then 2after 1 s
+

The listener's line: Refusing peer 127.0.0.1:...: consensus params digest mismatch, local 7a40cc3b... remote 4bf763ba... (the peer's override file, environment or build differs); the dialler sees the reject message with both digests. Two lines on the listener per pass because the dialler redials once within 25 s. The control connects the mismatched node and says nothing.

+

F23, the ban decided by the carrier (fud.mjs ban: six voters at 1/6 of 1 block/s, two per node; a0 on n0 equivocates once at index 9 (vmine --equivocate-at 9, the second vote reaches n0 over RPC only); P2 cut when n0's next index reaches 9 (252 to 259 s) and healed 45 s later, so n2 learns the evidence from the carrier block after the heal; 480 s).

+
Build, passEQUIVOCATION lines n0 / n1 / n2 (carried by block)refused "names N voters"CONFLICTINGindices with 5 voters, per nodevoter counts agree / differ (indices with lines on 2+ nodes)disagreeing locked indicesmax locked
fud-consensus, first pass (9f738e2e)2 (1) / 1 (1) / 1 (1)0 / 0 / 00 / 0 / 010..13 on all three11 / 0014 / 14 / 14
fud-consensus, final pass (ae9df8a3)2 (1) / 1 (1) / 1 (1)0 / 0 / 00 / 0 / 010..12 on all three10 / 0015 / 15 / 15
control, finality-fixes2 (0) / 1 (0) / 1 (0)2 / 0 / 00 / 0 / 010..13 on all three9 / 0014 / 14 / 14
+

On the new build n2's one EQUIVOCATION line is the "carried by block" variety (it never saw the vote), and the stripped range is the same on the node that detected over RPC, the node that saw the carrier at once and the node that saw it 45 s late. The control refused two of the other nodes' certificates on n0 with "names N voters, this node counts M"; the red team's stock s1 (two keys equivocating at every index) gave 9 / 3 / 4 refusals on the same build. Locks still agreed on the control because each node could build its own certificate from the votes it held; the refusal is the defect, the disagreement would follow on a network where one node depends on another's certificate.

+

F24, re-determination after a deep reorg (fud.mjs reorg: n0 holds q0, q1 at 0.15 each, n1 and n2 hold p0 to p3 at 0.175 each, so the n1/n2 side has 70% of the weight; 230 s warm, P0 cut 180 s, healed, 150 s heal window).

+
Build, passn0 determined on its own chain during the splitre-determined lines on n0pending kept / verified on n0CONFLICTINGrefused "is for X, this node's checkpoint is Y" (pre-F24 wording)indices the majority locked that n0 did notdisagreeing locked indicesmax locked at the end
fud-consensus, first pass (9f738e2e)1 (index 8)24 / not re-read at fresh determinations (the gap fixed in ae9df8a3)0 / 0 / 008 and 9 (no certificate was verified at them)015 / 15 / 15
fud-consensus, final pass (ae9df8a3)2 (8, 9)22 / 20 / 0 / 00none (the majority locked 10 and 11 during the split, not 8 and 9: 70% nominal is Poisson noise away from the floor)016 / 16 / 16
control, finality-fixes, two passes2 then 100 / 00 / 0 / 03 (second pass)8 and 9 (second pass): the permanent hole017 then 15
fud-consensus after the un-determination rule (977db931, reorg-final2)1 (index 9)11 / 10 / 0 / 00none (the majority locked nothing during the split this time: 8 at the cut, 8 at the heal, 16 at the end on all three)016 / 16 / 16; no record below its target
+

What the final pass found: n0's index 9 was re-determined at a sink of blue score 263 to a block of blue score 263, below the index's target 270, because the majority chain became the sink by blue work (its difficulty drifted less than n0's during the split) before it had reached index 9's depth; a record never names a block below its target, so the rule now un-determines an index the new chain has not reached and determines it again when it has (fork commit after ae9df8a3, unit test a_shallower_sink_un_determines_the_indices_it_cannot_reach). The control's first pass logged no CONFLICTING because that build's refusal used other words ("certificate at index 8 is for X, this node's checkpoint is Y"), counted in the second pass.

+

The red team's own reproductions (tools/finality-attacks/redteam/rtfin.mjs, fin-attacks miner at 6 blocks/s, run on the fud-consensus build ae9df8a3 through the main worktree's env-aware harness lib on ports 29550+; result files in docs/benchmarks/round4-consensus-2026-10-04/redteam-repro/):

+
ScenarioBuildResult
f23: two keys equivocating at every index, four honest voters, three nodes, 105 s (the evening's 9 / 3 / 4 refusals)ae9df8a3PASS: equivocation detections 14 / 7 / 7, voter-count refusals 0 / 0 / 0, CONFLICTING 0 / 0 / 0, disagreeing locked indices 0, max locked 61 / 61 / 61
f24c: 3/3 split, 16 s cut (96 DAA at 6 blocks/s), healae9df8a3PASS: n1 determined 31..32 during the cut; after the heal 0 refusals, 0 CONFLICTING, 0 stuck indices, 0 disagreeing, max locked 61 / 61
f24b: 4/2 split, 24 s cut, healae9df8a3 and 977db931FAIL on both, outside F24: 24 s at 6 blocks/s is 144 DAA, longer than the 120-DAA window and the 60-DAA merge depth, so the chains never merge and each side locks its own chain alone ("100.0% of total, 100.0% of the table frozen at lock 39" on n1): the partition longer than a window of spec 3.7 item 9 (F21), mis-scaled by the scenario's assumption of 1 DAA a second. The 1,668 and 2,025 "PoW rejected" lines are the INFO line of pre_ghostdag_validation.rs:158 for nonce-1 blocks, which skip_proof_of_work then accepts; no block was refused for them
+

G12 is covered by the digest run (the environment's schedule is part of the digest, so a devnet node with IGNEUM_POW_EPOCH_BLOCKS set cannot connect to one without it) and by env_pow_schedule_is_devnet_and_simnet_only; no mainnet node was started tonight. M31 is covered by largest_coinbase_fits_on_every_network; no simnet network was started tonight (the red team's tools/exec-attacks/net.sh is the run that would show templates on simnet without an override).

+

Uncertain. (1) Every run is fast time (W = 120 DAA, ban 120, depth 20) on three nodes with 100-ms links; the mainnet values are 30 days, 30 days and 60 blocks. (2) The ban run shows one equivocation at one index; the red team's s1 (equivocation at every index, two keys) was re-run on the new build only through the red team's f23 above (0 refusals where the evening had 9 / 3 / 4). (3) The digest-less allowance on devnet and simnet is deliberate for the rollout and is a hole until removed. (4) The reorg run's final pass had the majority lock no index during the first 60 s of the split, so the re-determination at 8 and 9 was exercised, the pending-certificate path only at 10 and 11; the first pass exercised the opposite. (5) The un-determination rule has a unit test and one network pass (reorg-final2) in which the shallow-sink case did not recur, so the rule is exercised by the test, not by a run; the case needs a split whose difficulty drifts enough for blue work to overtake blue score, which happened once in four runs. (6) The red team's f24b is a window-length partition at 6 blocks/s, so it measures F21's stated limit, not F24; a 4/2 cut under 20 s at that rate would be the F24 case.

Generated from the repository at build time. Times are UTC. Machine names are model names.

@@ -475,6 +507,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 e0f6a091c..5816080b2 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 11023a082..264457ffb 100644 --- a/site/evidence.html +++ b/site/evidence.html @@ -252,6 +252,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 cbd19cff0..9f11af39f 100644 --- a/site/index.html +++ b/site/index.html @@ -470,6 +470,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.
The protocol carries no fee. The Igneum Miner software takes an optional 1% dev fee, like other GPU miners, off with one flag.
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 @@ -548,6 +564,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 @@ -561,7 +578,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 000000000..3ca147738 --- /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);