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

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

+
+
Testnet terms
+

What you agree to when you run the testnet miner

+
+
+

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

+
+
+

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

+
+
+

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

+
+
+

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

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

Add Igneum to MetaMask

+
+

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

+ +
+
+ Public testnet · coming +

Igneum testnet

+

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

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

The button switches on when the public RPC is live.

+
+
+ Devnet · live now +

Igneum devnet, through your own node

+

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

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

+
+
+ +

Add it by hand

+

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

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

For builders

+

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

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

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

+ +

The app and the Igneum Wallet

+

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

+ +

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

+
+ + + + + + diff --git a/tools/ci/check-workflow-shell.mjs b/tools/ci/check-workflow-shell.mjs new file mode 100644 index 00000000..3ca14773 --- /dev/null +++ b/tools/ci/check-workflow-shell.mjs @@ -0,0 +1,101 @@ +// Mac-side (and CI) parse check of the shell inside .github/workflows/*.yml, so a broken `run:` block is caught before +// a Windows runner spends twenty minutes on it (4 October 2026, the signed-inputs step of windows.yml). +// +// node tools/ci/check-workflow-shell.mjs [workflow.yml ...] default: every workflow under .github/workflows +// +// For every step with a `run: |` block: `shell: bash` (or no shell on an ubuntu job) goes through `bash -n`; +// `shell: powershell` and `shell: pwsh` blocks, and every .ps1 the Windows folders hold, are checked against the one +// rule Windows PowerShell 5.1 enforces that newer parsers may not: a drive-qualified variable reference "$name: text" +// inside a double-quoted string (tools/ci/windows/check-ps51.ps1 runs the real 5.1 parser on the runner; this is the +// Mac approximation of its rule, with the same negative fixture). `shell: cmd` blocks are checked for the bare ")" +// class only when they span more than one line. Exit 1 on any finding, with file:line. +import { readFileSync, readdirSync, writeFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = join(here, '..', '..'); +const wfDir = join(repo, '.github', 'workflows'); +const files = process.argv.length > 2 ? process.argv.slice(2) : readdirSync(wfDir).filter(f => /\.ya?ml$/.test(f)).map(f => join(wfDir, f)); +const tmp = mkdtempSync(join(tmpdir(), 'wf-shell-')); +let findings = 0, blocks = 0, ps1 = 0; +const say = (file, line, msg) => { findings++; console.log(`${file}:${line}: ${msg}`); }; + +// The 5.1 rule: inside a double-quoted string, `$identifier:` is read as a drive-qualified variable reference +// (`$env:PATH`, `$script:node`), so when the character after the colon cannot start a variable name (a space, a +// `$`, punctuation or the closing quote) 5.1 fails with "Variable reference is not valid. ':' was not followed by a +// valid variable name character". `$env:PATH`, `$script:x`, `${name}:` and `$($name):` are fine. +const DRIVE_REF = /"(?:[^"\\]|\\.|`")*?\$[A-Za-z_][A-Za-z0-9_]*:(?![A-Za-z0-9_])(?:[^"\\]|\\.|`")*"/; +function checkPowerShell(text, file, firstLine) { + const lines = text.split('\n'); + lines.forEach((l, i) => { + const noComment = l.replace(/^\s*#.*$/, ''); + if (DRIVE_REF.test(noComment) && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}:/.test(noComment)) say(file, firstLine + i, `PowerShell 5.1 rejects "$name: text" (drive-qualified variable reference): ${l.trim().slice(0, 100)}`); + }); +} +// the same negative fixture check-ps51.ps1 uses: the Mac rule must bite on it or it proves nothing +const fixture = join(repo, 'tools', 'ci', 'windows', 'fixtures', 'bad-drive-ref.ps1.txt'); +if (existsSync(fixture)) { + const before = findings; + checkPowerShell(readFileSync(fixture, 'utf8'), 'fixture', 1); + if (findings === before) { console.log('self-test failed: the Mac rule does not fire on tools/ci/windows/fixtures/bad-drive-ref.ps1.txt'); process.exit(2); } + findings = before; console.log('self-test: the 5.1 drive-reference rule fires on the fixture'); +} + +function checkBash(text, file, firstLine) { + const p = join(tmp, `block-${blocks}.sh`); + writeFileSync(p, text); + const r = spawnSync('bash', ['-n', p], { encoding: 'utf8' }); + if (r.status !== 0) say(file, firstLine, `bash -n: ${(r.stderr || '').trim().replace(p, 'block').split('\n')[0]}`); +} +function checkCmd(text, file, firstLine) { + text.split('\n').forEach((l, i) => { if (/^\s*\)\s*$/.test(l)) say(file, firstLine + i, `a bare ")" line in a cmd block (the 3 October class)`); }); +} + +for (const file of files) { + const rel = file.startsWith(repo) ? file.slice(repo.length + 1) : file; + const lines = readFileSync(file, 'utf8').split('\n'); + let runsOn = ''; + for (let i = 0; i < lines.length; i++) { + const m = /^(\s*)runs-on:\s*(\S+)/.exec(lines[i]); if (m) runsOn = m[2]; + const r = /^(\s*)run:\s*\|\s*$/.exec(lines[i]); + if (!r) continue; + const indent = r[1].length; + // the step's shell: look back to the step's "- name:" for a `shell:` key at the same indent as `run:` + let shell = ''; + for (let k = i - 1; k >= 0; k--) { + const s = /^(\s*)shell:\s*(\S+)/.exec(lines[k]); + if (s && s[1].length === indent) { shell = s[2]; break; } + if (/^\s*-\s+(name|uses|run):/.test(lines[k]) && /^\s*-/.test(lines[k]) && lines[k].search(/\S/) < indent) break; + } + // the block: every following line indented deeper than `run:` + const body = []; + let j = i + 1; + while (j < lines.length && (lines[j].trim() === '' || lines[j].search(/\S/) > indent)) { body.push(lines[j]); j++; } + while (body.length && body[body.length - 1].trim() === '') body.pop(); + const bodyIndent = Math.min(...body.filter(l => l.trim()).map(l => l.search(/\S/))); + const text = body.map(l => l.slice(bodyIndent)).join('\n') + '\n'; + const firstLine = i + 2; + blocks++; + const kind = shell || (runsOn.startsWith('windows') ? 'pwsh' : 'bash'); + if (kind === 'bash') checkBash(text, rel, firstLine); + else if (kind === 'powershell' || kind === 'pwsh') checkPowerShell(text, rel, firstLine); + else if (kind === 'cmd') checkCmd(text, rel, firstLine); + i = j - 1; + } +} +// every .ps1 the Windows folders hold, the same rule +const folders = ['proto-cuda/windows-app', 'proto-cuda/windows-miner', 'proto-cuda/windows-node', 'proving/windows-wsl2', 'relay/clients', 'relay/playbooks', 'packaging/windows', 'app/windows', 'tools/ci/windows']; +function walk(d) { if (!existsSync(d)) return []; return readdirSync(d).flatMap(f => { const p = join(d, f); return statSync(p).isDirectory() ? walk(p) : (f.endsWith('.ps1') ? [p] : []); }); } +for (const f of folders) for (const p of walk(join(repo, f))) { ps1++; checkPowerShell(readFileSync(p, 'utf8'), p.slice(repo.length + 1), 1); } +// the shell scripts the workflow and the Mac side run +for (const f of ['packaging/windows/push-inputs.sh', 'packaging/windows/fetch-ci-artifacts.sh', 'packaging/windows/inputs-manifest.sh', 'packaging/windows/test-inputs-signing.sh', 'packaging/ota/publish-manifest.sh', 'packaging/windows/make-payload.sh']) { + const p = join(repo, f); if (!existsSync(p)) continue; + const r = spawnSync('bash', ['-n', p], { encoding: 'utf8' }); + if (r.status !== 0) say(f, 1, `bash -n: ${(r.stderr || '').trim().split('\n')[0]}`); +} +rmSync(tmp, { recursive: true, force: true }); +console.log(`workflow shell: ${blocks} run blocks in ${files.length} workflow(s), ${ps1} .ps1 files, ${findings} finding(s)`); +process.exit(findings ? 1 : 0); From b27f732533cdbb3eb05cb17ae3319178bd8f1d24 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:38:51 +0000 Subject: [PATCH 02/31] fast-time: the override file carries the fee set (Params.fees on the fork's testnet-params branch; the fork's fast-time test wants every field present) Co-Authored-By: Claude Fable 5.1 --- infra/fast-time/override-60x.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json index 850d7f9f..0ca483cf 100644 --- a/infra/fast-time/override-60x.json +++ b/infra/fast-time/override-60x.json @@ -53,5 +53,6 @@ "pow_day_ms": 1440000, "difficulty_v2_activation_daa": 18446744073709551615, "proving_v0_activation_daa": 18446744073709551615, - "finality_v3_activation_daa": 18446744073709551615 + "finality_v3_activation_daa": 18446744073709551615, + "fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8} } From 28f6cccbf7bd4481739555ec533bfbd9637c6b3a Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:49:22 +0000 Subject: [PATCH 03/31] testnet README: the genesis hash as test_genesis_hashes pins it on the fork branch Co-Authored-By: Claude Fable 5.1 --- docs/testnet/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/testnet/README.md b/docs/testnet/README.md index 6856c8db..8ee22b2b 100644 --- a/docs/testnet/README.md +++ b/docs/testnet/README.md @@ -34,7 +34,7 @@ one line and waits for the sign-off). | Nonce, DAA score | 0, 0 | | | UTXO commitment | empty | | | Coinbase payload message | `igneum-testnet-1 \| 2026-10-05 \| proposed, not final \| coins here have no value \| resets are announced` | after the OP-FALSE script, as the devnet's `igneum-devnet` | -| Hash | `0b2535708cad69211abb82383e002118c1c7a58f2d70cc966e520f8cfae10f79` | computed 4 October 2026 by `print_genesis_hashes` on the branch; changes with any field above | +| Hash | `52a3e6a9ddd79d603ff9e3a27487fb5d0ca5ca6f633fe20ca727e1faa355fc7e` | computed 4 October 2026 by `print_genesis_hashes` on the branch (pinned by `test_genesis_hashes`); changes with any field above | | Merkle root | `3060aab78494de3cbda68619c0132300aa486dc3153dbb78b50a430f053b3a84` | same | ## 3. Consensus parameters From 7c9a9392605f00c2420131f8b7fa709f17eb5174 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:43:44 +0000 Subject: [PATCH 04/31] Rotation phase 2: packagers read the intake key and the downloads token from files (IGNEUM_INTAKE_KEY_FILE, IGNEUM_DL_TOKEN_FILE, .next by default), no key literal in the tree, app header line with fingerprints, ship-app --dl-both, logs --rotation, tools/repo/fresh-repo.sh with the dry run, docs/plans/rotation-phase-2.md Co-Authored-By: Claude Fable 5.1 --- .github/workflows/windows.yml | 42 +++- app/igneum-app/src/config.rs | 189 +++++++++++++++- app/igneum-app/src/engine.rs | 3 + app/igneum-app/src/main.rs | 2 +- docs/plans/history-rewrite.md | 121 +++++++++++ docs/plans/morning-2026-10-04.md | 2 +- docs/plans/rotation-phase-2.md | 273 ++++++++++++++++++++++++ infra/gpu-bench/upload.sh | 9 +- packaging/mac/packaged-config.sh | 123 +++++++++-- packaging/windows/make-payload.sh | 31 +-- proto-cuda/windows-app/upload-log.bat | 10 +- proto-cuda/windows-miner/upload-log.bat | 88 ++++---- proving/windows-wsl2/prove-block.sh | 15 +- proving/windows-wsl2/prove-shard.sh | 15 +- tools/logs.mjs | 75 ++++++- tools/repo/fresh-repo.sh | 253 ++++++++++++++++++++++ tools/ship-app.mjs | 169 ++++++++++++--- 17 files changed, 1296 insertions(+), 124 deletions(-) create mode 100644 docs/plans/history-rewrite.md create mode 100644 docs/plans/rotation-phase-2.md create mode 100755 tools/repo/fresh-repo.sh diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index ef8b57bd..821da286 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -14,6 +14,15 @@ # 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 packaged configuration (rotation phase 2, 5 October 2026; docs/plans/rotation-phase-2.md): the runner writes the +# repository secrets to the same files the Mac keeps under ~/.config/igneum, and make-payload.sh picks them exactly as +# on the Mac (packaging/mac/packaged-config.sh: a .next file wins when present). +# LOG_INTAKE_KEY required: the intake key the payload ships (gh secret set LOG_INTAKE_KEY < ~/.config/igneum/log-intake-key) +# LOG_INTAKE_KEY_NEXT optional, during a rotation: the next key; when set it is the one the payload ships +# DL_TOKEN required: the folder the inputs come from, and the manifest folder when no DL_TOKEN_NEXT +# DL_TOKEN_NEXT optional, during a rotation: the manifest folder the payload checks +# After a rotation the owner sets LOG_INTAKE_KEY and DL_TOKEN to the new values and deletes the two _NEXT secrets. name: windows-ci on: push: @@ -110,19 +119,40 @@ jobs: cargo build --release --locked ls -la target/release/igneum-app.exe + - name: packaged configuration (the secrets as the files packaged-config.sh reads; values never echoed) + shell: bash + env: + DL_TOKEN: ${{ secrets.DL_TOKEN }} + DL_TOKEN_NEXT: ${{ secrets.DL_TOKEN_NEXT }} + LOG_INTAKE_KEY: ${{ secrets.LOG_INTAKE_KEY }} + LOG_INTAKE_KEY_NEXT: ${{ secrets.LOG_INTAKE_KEY_NEXT }} + run: | + set -euo pipefail + mkdir -p "$HOME/.config/igneum" + if [ -z "${DL_TOKEN:-}" ]; then + echo "::error::the DL_TOKEN repository secret is not set. On the Mac: tr -d '[:space:]' < ~/.config/igneum/dl-token | gh secret set DL_TOKEN --repo igneum-network/igneum" + exit 1 + fi + if [ -z "${LOG_INTAKE_KEY:-}" ] && [ -z "${LOG_INTAKE_KEY_NEXT:-}" ]; then + echo "::error::neither LOG_INTAKE_KEY nor LOG_INTAKE_KEY_NEXT is set; the payload would ship without an intake key. On the Mac: tr -d '[:space:]' < ~/.config/igneum/log-intake-key | gh secret set LOG_INTAKE_KEY --repo igneum-network/igneum" + exit 1 + fi + printf '%s' "$DL_TOKEN" > "$HOME/.config/igneum/dl-token" + [ -n "${DL_TOKEN_NEXT:-}" ] && printf '%s' "$DL_TOKEN_NEXT" > "$HOME/.config/igneum/dl-token.next" + [ -n "${LOG_INTAKE_KEY:-}" ] && printf '%s' "$LOG_INTAKE_KEY" > "$HOME/.config/igneum/log-intake-key" + [ -n "${LOG_INTAKE_KEY_NEXT:-}" ] && printf '%s' "$LOG_INTAKE_KEY_NEXT" > "$HOME/.config/igneum/log-intake-key.next" + chmod 600 "$HOME"/.config/igneum/* + echo "files: $(ls "$HOME/.config/igneum" | tr '\n' ' ')" + bash packaging/mac/packaged-config.sh --test + - name: payload inputs (payload-inputs.zip from the downloads host, sha256 checked) shell: bash env: DL_TOKEN: ${{ secrets.DL_TOKEN }} run: | set -euo pipefail - if [ -z "${DL_TOKEN:-}" ]; then - echo "::error::the DL_TOKEN repository secret is not set. On the Mac: tr -d '[:space:]' < ~/.config/igneum/dl-token | gh secret set DL_TOKEN --repo igneum-network/igneum" - exit 1 - fi base="https://dl.igneum.network/dl/$DL_TOKEN" - 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 + mkdir -p build/inputs 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.zip "$base/payload-inputs.zip" diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 1b36edb5..30f7ef19 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -154,16 +154,100 @@ pub struct Packaged { /// height, 4 October 2026: `{"difficulty_v2_activation_daa": N}`). Absent or empty = no override file. #[serde(default)] pub node_override_params: Option, + /// Where the key and the manifest came from, for the log header: "packaged", "file " or "none". Never + /// serialised (the packaged file does not carry them; nothing sends this struct to the UI). + #[serde(skip)] + pub key_source: String, + #[serde(skip)] + pub manifest_source: String, +} + +/// The downloads host; the manifest of a folder is `/dl//igneum-app-latest.json` +/// (packaging/mac/packaged-config.sh builds the same URL). +pub const DL_HOST: &str = "https://dl.igneum.network"; +/// The intake a key file points at when the packaged file names no intake (a developer run). +pub const DEFAULT_INTAKE_URL: &str = "https://igneum-six.vercel.app/api/log"; + +/// The manifest URL for a downloads token; empty for an empty token. +pub fn manifest_url_for_token(token: &str) -> String { + let t = token.trim(); + if t.is_empty() { + String::new() + } else { + format!("{DL_HOST}/dl/{t}/igneum-app-latest.json") + } +} + +/// The downloads token inside a manifest URL of the standard shape, or None. +pub fn token_of_manifest_url(url: &str) -> Option<&str> { + let rest = url.strip_prefix(DL_HOST)?.strip_prefix("/dl/")?; + let (token, file) = rest.split_once('/')?; + (file == "igneum-app-latest.json" && !token.is_empty()).then_some(token) +} + +/// A secret from a file: trimmed, None when the file is missing or blank. +pub fn read_secret_file(path: &Path) -> Option { + let t = std::fs::read_to_string(path).ok()?; + let t = t.trim(); + (!t.is_empty()).then(|| t.to_string()) +} + +/// The first 8 hex of sha256 over a value: what logs and the console show instead of the value +/// (`tr -d '[:space:]' < file | shasum -a 256 | cut -c1-8` gives the same on the Mac). +pub fn fingerprint8(value: &str) -> String { + use sha2::Digest; + crate::manifest::hex_encode(&sha2::Sha256::digest(value.as_bytes()))[..8].to_string() } impl Packaged { pub fn load(candidates: &[PathBuf]) -> Packaged { for c in candidates { - if let Some(p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::(&t).ok()) { + if let Some(mut p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::(&t).ok()) { + p.key_source = if p.log_intake_key.is_empty() { "none".into() } else { "packaged".into() }; + p.manifest_source = if p.update_manifest.is_empty() { "none".into() } else { "packaged".into() }; return p; } } - Packaged::default() + Packaged { key_source: "none".into(), manifest_source: "none".into(), ..Packaged::default() } + } + + /// Rotation phase 2 (5 October 2026, docs/plans/rotation-phase-2.md): the same two variables the packagers honour + /// (packaging/mac/packaged-config.sh) work on a running engine, so a developer run or a build that was packaged + /// with the old values can report to the rotated intake and check the rotated folder without a repackage: + /// IGNEUM_INTAKE_KEY_FILE names a file holding the key, IGNEUM_DL_TOKEN_FILE a file holding the downloads token. + /// A variable that is unset, or names a missing or blank file, changes nothing. + pub fn with_env_overrides(self) -> Packaged { + let file = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()).map(PathBuf::from); + self.with_file_overrides(file("IGNEUM_INTAKE_KEY_FILE").as_deref(), file("IGNEUM_DL_TOKEN_FILE").as_deref()) + } + + pub fn with_file_overrides(mut self, key_file: Option<&Path>, token_file: Option<&Path>) -> Packaged { + let name = |p: &Path| p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.display().to_string()); + if let Some(key) = key_file.and_then(read_secret_file) { + self.log_intake_key = key; + if self.log_intake_url.is_empty() { + self.log_intake_url = DEFAULT_INTAKE_URL.into(); + } + self.key_source = format!("file {}", name(key_file.unwrap())); + } + if let Some(token) = token_file.and_then(read_secret_file) { + self.update_manifest = manifest_url_for_token(&token); + self.manifest_source = format!("file {}", name(token_file.unwrap())); + } + self + } + + /// The log header line: the intake URL with the key's fingerprint and the manifest URL with the folder's + /// fingerprint, each with its source; the values themselves never appear (the log is uploaded). + pub fn describe(&self) -> String { + let key = if self.log_intake_key.is_empty() { "no key".to_string() } else { format!("key {}", fingerprint8(&self.log_intake_key)) }; + let intake = if self.log_intake_url.is_empty() { "none".to_string() } else { self.log_intake_url.clone() }; + let (manifest, folder) = match token_of_manifest_url(&self.update_manifest) { + Some(t) => (self.update_manifest.replace(t, ""), format!("folder {}", fingerprint8(t))), + None if self.update_manifest.is_empty() => ("none".to_string(), "no folder".to_string()), + None => (self.update_manifest.clone(), "custom".to_string()), + }; + format!("config: intake {intake} {key} ({}); manifest {manifest} {folder} ({})", self.key_source, self.manifest_source) } } @@ -238,6 +322,107 @@ impl Runtime { mod tests { use super::*; + fn tmp(name: &str, content: &str) -> PathBuf { + // tests run in parallel: every file name is unique to its call + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("igneum-config-test-{}-{n}-{}", std::process::id(), name)); + std::fs::write(&d, content).unwrap(); + d + } + + fn packaged(key: &str, token: &str) -> Packaged { + let json = format!(r#"{{"update_manifest":"{}","log_intake_url":"https://igneum-six.vercel.app/api/log","log_intake_key":"{}"}}"#, manifest_url_for_token(token), key); + let p = tmp("packaged.json", &json); + let out = Packaged::load(&[p.clone()]); + let _ = std::fs::remove_file(p); + out + } + + #[test] + fn manifest_url_round_trips_through_the_token() { + assert_eq!(manifest_url_for_token("abc123"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json"); + assert_eq!(manifest_url_for_token(" abc123\n"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json"); + assert_eq!(manifest_url_for_token(""), ""); + assert_eq!(token_of_manifest_url("https://dl.igneum.network/dl/abc123/igneum-app-latest.json"), Some("abc123")); + assert_eq!(token_of_manifest_url("https://dl.igneum.network/dl/abc123/other.json"), None); + assert_eq!(token_of_manifest_url("http://127.0.0.1:8080/dl/t/igneum-app-latest.json"), None); + assert_eq!(token_of_manifest_url(""), None); + } + + #[test] + fn secret_files_are_trimmed_and_blank_means_none() { + let f = tmp("key", " thekey0123456789abcdef \n"); + assert_eq!(read_secret_file(&f).as_deref(), Some("thekey0123456789abcdef")); + std::fs::write(&f, " \n").unwrap(); + assert_eq!(read_secret_file(&f), None); + let _ = std::fs::remove_file(&f); + assert_eq!(read_secret_file(Path::new("/nonexistent/igneum/key")), None); + } + + #[test] + fn fingerprint_matches_shasum() { + // printf abc | shasum -a 256 | cut -c1-8 + assert_eq!(fingerprint8("abc"), "ba7816bf"); + assert_eq!(fingerprint8("").len(), 8); + } + + #[test] + fn load_records_the_sources() { + let p = packaged("oldkey0123456789abcdef", "oldtok"); + assert_eq!(p.key_source, "packaged"); + assert_eq!(p.manifest_source, "packaged"); + let none = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]); + assert_eq!(none.key_source, "none"); + assert_eq!(none.manifest_source, "none"); + assert!(none.update_manifest.is_empty() && none.log_intake_key.is_empty()); + } + + #[test] + fn file_overrides_replace_the_key_and_the_folder() { + let key = tmp("log-intake-key.next", "newkey0123456789abcdef\n"); + let tok = tmp("dl-token.next", "newtok\n"); + let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&key), Some(&tok)); + assert_eq!(p.log_intake_key, "newkey0123456789abcdef"); + assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/newtok/igneum-app-latest.json"); + assert_eq!(p.log_intake_url, "https://igneum-six.vercel.app/api/log"); + assert!(p.key_source.starts_with("file ") && p.key_source.ends_with("log-intake-key.next"), "{}", p.key_source); + assert!(p.manifest_source.ends_with("dl-token.next"), "{}", p.manifest_source); + // only the key: the folder stays packaged + let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&key), None); + assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/oldtok/igneum-app-latest.json"); + assert_eq!(p.manifest_source, "packaged"); + // a missing or blank file changes nothing + let blank = tmp("blank", "\n"); + let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&blank), Some(Path::new("/nonexistent/dl-token"))); + assert_eq!(p.log_intake_key, "oldkey0123456789abcdef"); + assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/oldtok/igneum-app-latest.json"); + assert_eq!(p.key_source, "packaged"); + // a developer run with no packaged file at all: the key file brings the default intake + let p = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]).with_file_overrides(Some(&key), Some(&tok)); + assert_eq!(p.log_intake_url, DEFAULT_INTAKE_URL); + assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/newtok/igneum-app-latest.json"); + for f in [key, tok, blank] { + let _ = std::fs::remove_file(f); + } + } + + #[test] + fn describe_never_carries_the_values() { + let p = packaged("oldkey0123456789abcdef", "oldtok"); + let d = p.describe(); + assert!(!d.contains("oldkey"), "{d}"); + assert!(!d.contains("oldtok"), "{d}"); + assert!(d.contains("/igneum-app-latest.json"), "{d}"); + assert!(d.contains(&format!("key {}", fingerprint8("oldkey0123456789abcdef"))), "{d}"); + assert!(d.contains(&format!("folder {}", fingerprint8("oldtok"))), "{d}"); + assert!(d.contains("(packaged)"), "{d}"); + let none = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]).describe(); + assert!(none.contains("no key") && none.contains("no folder") && none.contains("(none)"), "{none}"); + let custom = Packaged { update_manifest: "http://127.0.0.1:9/dl/t/igneum-app-latest.json".into(), ..Packaged::default() }.describe(); + assert!(custom.contains("custom"), "{custom}"); + } + #[test] fn packaged_carries_the_node_override_params() { let p: Packaged = serde_json::from_str(r#"{"update_manifest":"","node_override_params":{"difficulty_v2_activation_daa":123456}}"#).unwrap(); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index cd6cfa4c..e73476aa 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -576,6 +576,9 @@ impl Engine { let v = crate::detect::node_version(&self.bins.node); self.st().node.version = v.clone(); self.shared.log(&self.shared.upload_header()); + // which intake and which downloads folder this build reports to and checks (fingerprints, never the values; + // rotation phase 2 reads this line from every machine's upload: docs/plans/rotation-phase-2.md) + self.shared.log(&self.shared.packaged.describe()); // the prover service (proving v0): its own thread, idle until the setting is on crate::prover::start(self.shared.clone(), self.bins.dir.clone()); self.shared.log(&format!("node binary: {} ({v})", self.bins.node.display())); diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 41e7d9ce..edaedfc9 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -86,7 +86,7 @@ fn main() { } } } - let packaged = config::Packaged::load(&candidates); + let packaged = config::Packaged::load(&candidates).with_env_overrides(); let settings = config::Settings::load(&runtime.app_dir.join("settings.json")); // the per-launch token: 32 hex characters from the OS diff --git a/docs/plans/history-rewrite.md b/docs/plans/history-rewrite.md new file mode 100644 index 00000000..2133c8a3 --- /dev/null +++ b/docs/plans/history-rewrite.md @@ -0,0 +1,121 @@ +# G14: the history rewrite, exact plan and dry-run result (4 October 2026, night) + +Internal. Extends `docs/fud-fixes.md` section 5 (step 4) with the exact commands, what the dry run showed, what +breaks, and the order for the morning. Nothing here has touched the real repository: the dry run ran on a throwaway +mirror clone under the session scratchpad and nothing was pushed. The owner is not named in this file; "the first +name" and "the login" stand for the values the script reads from the history itself. + +## 1. What the history holds today (counts from the real repository, 4 October 2026, 22:30 UTC) + +| Item | Count | Where | +|---|---|---| +| Commits | 363 on all branches | | +| Commits stamped `+0100` (author or committer) | 291 of 363 | the UK or Irish summer offset; 72 are `+0000` | +| Commits authored with the personal name | 40 (31 on the old GitHub noreply address, 9 on the personal address) | the commits before the 3 October identity rule | +| Commits as the standing login `igneum-labs` | 323 | | +| The intake key | 6 tracked files, 8 commits (`78df757` to `4c9810f`) | `packaging/mac/packaged-config.sh`, `infra/gpu-bench/upload.sh`, `proving/windows-wsl2/prove-block.sh`, `prove-shard.sh`, `proto-cuda/windows-miner/upload-log.bat`, `proto-cuda/windows-app/upload-log.bat` | +| The dl token | 1 tracked file, 1 commit (`c47ff03`) | `docs/plans/morning-2026-10-04.md` | +| The `.next` rotations of both | 0 files, 0 commits | `~/.config/igneum/log-intake-key.next`, `dl-token.next` (4 October 19:25) are not in the tree | +| The relay key and token (current and old) | 0 files, 0 commits | | +| The review files | `docs/fud-ledger.md` (36 commits from `39c20b7`), `docs/fud-fixes.md` (6 from `e7545d5`), `docs/review/` (4 from `5ab296c`), `site/ledger.html` (5 from `0ec11be`) | tracked, not ignored | +| Tracked files carrying the first name (case-insensitive) | 71 at HEAD; 93 commits touch such content; 10 commit messages carry it | `CLAUDE.md`, the agent file, plans, packaging, the app's WSL paths, the Chrome profile rule | +| The surname | 4 files at HEAD | | +| The other businesses' names, the registrar, the database id, home paths | [other-business] 6, [other-business] 5, [other-business] 4, godaddy 7, soft-voice 3, `/Users/` 22, quantum 4 | identity terms are rewritten by the history pass below; providers and paths are the public-export scrub's job (`tools/ci/forbidden-strings.txt`), not this pass | + +## 2. The rewrite, exactly + +Tool: `git-filter-repo` 2.47.0 (not installed on the Mac; the dry run used a pip install into the scratchpad, +`python3 -m pip install --target git-filter-repo`, run as `python3 /git_filter_repo.py`). It refuses to +run on anything but a fresh clone, which is the safety the plan relies on. + +The script is `dryrun.sh` in the scratchpad (`rewrite/`); it reads every value from the history and from +`~/.config/igneum` at run time and writes the replacement files with mode 0600, then deletes them. The one +invocation, with the files it writes: + +``` +git clone --mirror clone && cd clone +python3 git_filter_repo.py --force \ + --invert-paths --path docs/fud-ledger.md --path docs/fud-fixes.md --path docs/review --path site/ledger.html \ + --replace-text replace.txt \ + --replace-message messages.txt \ + --mailmap mailmap \ + --commit-callback ' +for attr in ("author_date", "committer_date"): + d = getattr(commit, attr); parts = d.split(b" ") + if len(parts) == 2 and parts[1] != b"+0000": + setattr(commit, attr, parts[0] + b" +0000") +' +``` + +| File | Lines (values never written in this plan) | +|---|---| +| `replace.txt` (blob text) | `literal:==>***INTAKE-KEY-REMOVED***`; `literal:
==>***DL-TOKEN-REMOVED***`; the two personal `Name ` strings to the standing login string; the personal email and the old noreply address to `[removed]`; `regex:\bFirst's\b==>the project lead's`; `regex:\bFirst\s+Last\b==>the project lead`; `regex:\bFirst\b==>the project lead`; `regex:\bLast\b==>[removed]`; `regex:(?i)(?[user]` (the lowercase user-name form in Windows and WSL paths, added after dry run 1 left 20 lines); `regex:(?i)\b\b==>[second-owner-login]`; `regex:(?i)\b([other-business]\|[other-business]\|[other-business]\|[other-business]\|[other-business])\b==>[other-business]` | +| `messages.txt` (commit messages) | the first-name rules and the second-login rule | +| `mailmap` | both personal identities to `igneum-labs <337424239+[removed]>` | + +The date callback keeps the instant and rewrites the offset to `+0000`, so no commit moves in time; only the +`+0100` fingerprint goes. `--invert-paths` drops the four internal files from every commit, which empties the +commits that touched nothing else; filter-repo prunes those. + +## 3. The dry run (two passes on the mirror clone, 4 October 2026, 22:35 to 22:55 UTC) + +| Check | Before | After pass 2 | +|---|---|---| +| Commits | 364 in the mirror (363 plus the in-progress branch head) | 312: the 52 commits that only touched the dropped files are gone | +| Author and committer identities | 3 | 1: the standing login on all 312 | +| Timezone offsets (author and committer, 624 stamps) | 291 x 2 `+0100` | 624 `+0000` | +| `git log -S` | 8 commits | 0 | +| `git log -S
` | 1 commit | 0 | +| Commits touching the four dropped files | 51 | 0 | +| Identity grep over every blob in the history (first name outside the login, surname, second login, personal addresses, the other businesses; case-insensitive) | thousands of lines | 0 lines | +| Identity grep over commit metadata (names, addresses, subjects, bodies) | | 0 lines | +| `CLAUDE.md` line 4 after the pass | the full name | "the project lead's project, started 3 October 2026" | +| Runtime | | 2 min 58 s for the filter, 3 min 15 s with the greps | + +Pass 1 (case-sensitive name rules only) left 20 blob lines and 2 message lines: the lowercase user-name form in +`C:\Users\` and WSL paths in `app/igneum-app/src/jobrun.rs`, `prover.rs`, `docs/plans/shard-test-pc2.md`, +`packaging/README-ship.md`, `packaging/ota/publish-jobs.sh`, `relay/playbooks/shard-test.ps1` and the Chrome-profile +line of `CLAUDE.md`. The `(?i)(? <337424239+@...> <337424239+igneum-labs@...>`) and one more replace rule (`igneum-labs` to the new login) go into the same pass | the owner (rename), then the script | +| `CLAUDE.md` as a public file (section 5 step 2 of `docs/fud-fixes.md`: the registrar, the database id, the browser-profile section, the tooling links) | The pass replaces names; it does not rewrite paragraphs. The scrubbed `CLAUDE.md` of step 2 replaces the file in every commit with `--path-rename` or a blob callback once it exists | Claude, after the owner approves the public text | +| The second owner login is still an organisation owner | GitHub setting (decision e: one anonymous owner) | the owner | +| Providers, hosts, home paths, machine names | the public-export scrub (`tools/ci/forbidden-strings.txt`, `igneum-public/tools/sync.sh`); the private repository keeps them until the public date | the export | + +## 4. What breaks when the rewrite is applied for real + +| What | Why | Recovery | +|---|---|---| +| Every worktree of the main checkout (16 today: `igneum-wt-appui`, `bughunt`, `buildjob`, `devfee`, `eff`, `finality`, `latency`, `perf`, `redteam`, `release`, `reliability`, `ship`, `site`, `wallet`, `testnet`, plus two under the scratchpad) | Their HEADs point at old commit ids that no longer exist in the rewritten history; `git status` still works on the old objects, `git pull` and `git rebase` do not | Each agent commits and pushes its branch before the freeze; after the rewrite every branch is re-created from the rewritten refs: `git worktree remove`, `git worktree add ../igneum-wt- ` | +| Agents' branches (15 local, 11 on origin) | Rewritten with everything else (the mirror clone carries every ref), so the branch names survive with new ids; an agent that keeps an old local branch will have diverged from its rewritten twin by every commit | No agent commits during the freeze; after it, every agent re-creates its worktree, never merges an old-id branch into a new one | +| Open pull requests, if any | Their base and head ids vanish | None open today (the project merges by hand); check `gh pr list` before the freeze | +| The Vercel GitHub integration (`igneum` project, deploys on push to master) | The integration links by repository id, not by commit, so it survives a force-push; the first push of the rewritten `master` triggers one deploy of the same site (the public tree is unchanged by the pass except the dropped `site/ledger.html`, already a 307 redirect) | Watch the deploy; nothing to relink. If the repository is re-created instead (section 5, option B), the integration is re-linked once in the Vercel project settings | +| The `windows-ci` and `ci` workflows | Run on the rewritten push like any push; the DL_TOKEN secret is a repository setting and survives | Re-set the secrets if the repository is re-created | +| Old commit ids in documents (`docs/bench-log.md`, plans, the ledger) and in the public export | They name commits that will not exist; filter-repo writes `commit-map` (old id to new id) in `.git/filter-repo/` and rewrites ids it finds in commit messages, not in files | Keep `commit-map` with the private notes; the bench log keeps its short ids as historical labels (the public export already strips the history) | +| GitHub's copies of the old objects | A force-push does not delete them from GitHub's object store; cached PR views, old commit URLs and forks keep serving them until GitHub runs a garbage collection, which support can be asked to do | Option B below removes the question | +| The fork worktrees under `vendor/` | Separate repositories (`vendor/` is gitignored); untouched | Nothing | +| The intake key and the dl token | Removing them from the history does not revoke them; every shipped package and every installed app carries the current key | Rotate first (the `.next` values exist since 4 October 19:25): new key in `relay/` and in `packaging/mac/packaged-config.sh`, repackage, republish; the old key keeps working for installed apps until they update, then dies | + +## 5. The order of operations for the morning + +1. Rotate the secrets: switch the relay and the intake to `log-intake-key.next`, the downloads folder to `dl-token.next`, repackage the Mac and Windows apps with the new values, publish, confirm an upload lands under the new key. Then the old values in the history are dead values. +2. The owner renames the login `igneum-labs` (GitHub settings; the noreply id 337424239 stays), confirms the second login is no longer an organisation owner, and approves the public `CLAUDE.md` text (section 5 step 2). +3. Freeze: every agent commits and pushes its branch, then stops; `gh pr list` must be empty; `git worktree list` is recorded. +4. Mirror clone, run the pass (section 2) with the two extra lines from step 2 and the scrubbed `CLAUDE.md` blob; the greps of section 3 must all read 0; keep `commit-map`. +5. Choose A or B. A: `git push --mirror` from the clone to the existing repository, then ask GitHub support to purge the unreachable objects. B (the route `docs/fud-fixes.md` step 4 prefers): create a fresh repository under the organisation, push the rewritten refs there, re-link Vercel and re-set the two secrets, archive the old repository private. B leaves no old object anywhere. +6. Re-clone the main checkout from the new history; every agent re-creates its worktree from its rewritten branch. +7. `TZ=UTC` on every path that commits: the agents' shells, the ship scripts, the relay; and `git config --global` cannot set a timezone, so the rule is in the environment. The CI identity grep and `git log --format='%ad' --date=raw | grep -c +0100` become the daily check (0 is the goal). +8. The public export (`igneum-network/spec`) is unaffected: it carries no history from this repository. + +## 6. What waits for the owner + +| Decision | Options | +|---|---| +| The new login name | any handle without a name | +| A or B in step 5 | B recommended | +| The public `CLAUDE.md` text | section 5 step 2 of `docs/fud-fixes.md` | +| The day | after step 1; before the public date in every case | diff --git a/docs/plans/morning-2026-10-04.md b/docs/plans/morning-2026-10-04.md index aae69be6..54458960 100644 --- a/docs/plans/morning-2026-10-04.md +++ b/docs/plans/morning-2026-10-04.md @@ -46,7 +46,7 @@ v4 changes the chain's formats, so it starts fresh from genesis and every node m 4. You extract `igneum-windows-v4.zip` to a fresh folder and start it. 5. We watch the live page: blocks, then the first lock after the window fills. -Packages (all three rebuilt 08:56 BST with generator v2 and the 2/3 floor, hosted): `https://dl.igneum.network/dl/***DL-TOKEN-REMOVED***/igneum-windows-v4.zip` (your PC, node and miners), `igneum-node-windows-v4.zip`, and `Igneum-Miner-0.2.0.dmg` for Sam, which dials the seed node first. The proof run for your 5090: `igneum-prove-wsl2.zip`, same folder; needs a reboot for WSL2 and twenty minutes of setup. +Packages (all three rebuilt 08:56 BST with generator v2 and the 2/3 floor, hosted): `https://dl.igneum.network/dl//igneum-windows-v4.zip` (your PC, node and miners), `igneum-node-windows-v4.zip`, and `Igneum-Miner-0.2.0.dmg` for Sam, which dials the seed node first. The proof run for your 5090: `igneum-prove-wsl2.zip`, same folder; needs a reboot for WSL2 and twenty minutes of setup. ## What went wrong tonight, plainly diff --git a/docs/plans/rotation-phase-2.md b/docs/plans/rotation-phase-2.md new file mode 100644 index 00000000..123ede9e --- /dev/null +++ b/docs/plans/rotation-phase-2.md @@ -0,0 +1,273 @@ +# Rotation phase 2: the 0.3.6 handover, the deletion of the old folder and key, the fresh repository (5 October 2026) + +Internal. Phase 1 (4 October, 19:25 UTC) generated the NEXT intake key and the NEXT downloads token into +`~/.config/igneum/log-intake-key.next` and `~/.config/igneum/dl-token.next`, taught `site/api/log.mjs` to accept +`LOG_INTAKE_KEY_NEXT` next to `LOG_INTAKE_KEY`, and staged a second downloads folder `dl//` with the +installers of the day. Phase 2 is this file: the 0.3.6 build carries the new values, every installed 0.3.5 is carried +across while the old folder still serves, then the old folder and the old key die, and only then the history is +rewritten into a fresh repository (owner's decision, 5 October 2026; `docs/plans/history-rewrite.md`, option B). + +No value is written here. Each is named by its fingerprint, the first 8 hex of sha256 over the trimmed value +(`tr -d '[:space:]' < ~/.config/igneum/ | shasum -a 256 | cut -c1-8`; the app logs the same 8 characters): + +| Value | File | Fingerprint | Where it lives today | +|---|---|---|---| +| old intake key | `~/.config/igneum/log-intake-key` | `e2005de8` | every installed app's `igneum-app.json` (0.3.0 to 0.3.5); the site project's `LOG_INTAKE_KEY`; 6 tracked files until this branch, 8 commits of the history | +| new intake key | `~/.config/igneum/log-intake-key.next` | `477bb0ef` | nowhere yet (the site accepts it once `LOG_INTAKE_KEY_NEXT` is set) | +| old downloads token | `~/.config/igneum/dl-token` | `df66a82c` | every installed app's manifest URL; `dl//` holds every version 0.1.0 to 0.3.5, the jobs file, the CI inputs; the `DL_TOKEN` repository secret; 1 commit of the history (`docs/plans/morning-2026-10-04.md`, masked on this branch) | +| new downloads token | `~/.config/igneum/dl-token.next` | `ed9c4d2e` | `dl//` with the 0.3.3 installers and the WSL2 zip only, no manifest, no jobs file, no CI inputs | + +## 1. What this branch changes (`rotation-2`) + +| File | Change | +|---|---| +| `packaging/mac/packaged-config.sh` | no key literal any more. `IGNEUM_INTAKE_KEY_FILE` and `IGNEUM_DL_TOKEN_FILE` name the files; each defaults to the `.next` file when it exists, else the plain file. Prints file names, lengths and fingerprints, never values. `--test` runs its 23 checks on temporary files | +| `packaging/windows/make-payload.sh` | sources `packaged-config.sh` and calls `write_packaged_config` (it used to `sed` the key out of that file) | +| `.github/workflows/windows.yml` | a "packaged configuration" step writes the repository secrets `DL_TOKEN`, `DL_TOKEN_NEXT`, `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT` to the same files under `~/.config/igneum` on the runner, so `make-payload.sh` picks them exactly as on the Mac; `LOG_INTAKE_KEY` or `LOG_INTAKE_KEY_NEXT` is now required (the key no longer comes from the tree) | +| `app/igneum-app/src/config.rs` | `Packaged::with_env_overrides()` honours the same two variables on a running engine (a developer run, or a package built with the old values); `describe()` is the new header line `config: intake key (); manifest folder ()`; `fingerprint8`, `manifest_url_for_token`, `token_of_manifest_url`, `read_secret_file`; 6 new unit tests | +| `app/igneum-app/src/main.rs`, `engine.rs` | the overrides applied at load; the `config:` line logged right after the `IGNEUM-APP` header at every engine start (so every upload carries it) | +| `tools/ship-app.mjs` | `--dl-both`: a `mirror` step copies the version's files and the folder-level files into `dl//`, the `manifest` step publishes a second manifest there (`--dest`, `--base-url`, carrying the first manifest's `override`, `tuning` and `min_supported_version`, compared field by field), one deploy, `verify` checks both folders; self-test covers the three helpers | +| `tools/logs.mjs` | `--rotation`: every app machine's version and header fingerprints against the `.next` files, exit 1 while any machine is behind; `--self-test` | +| `infra/gpu-bench/upload.sh`, `proving/windows-wsl2/prove-block.sh`, `prove-shard.sh`, `proto-cuda/windows-app/upload-log.bat`, `proto-cuda/windows-miner/upload-log.bat` | the key literal removed: environment (`IGNEUM_LOG_KEY` or `IGNEUM_INTAKE_KEY`, which the app's job runner already sets), `IGNEUM_INTAKE_KEY_FILE`, or `igneum-log-key.txt` next to the .bat; the tree carries neither value now (`git grep` of both reads 0 files) | +| `tools/repo/fresh-repo.sh` | the history rewrite of `docs/plans/history-rewrite.md` section 2 as one script with the verification greps and the printed push commands (section 6 below) | +| `docs/plans/history-rewrite.md` | brought over from `testnet-prep` unchanged, so this branch carries the plan it executes | + +## 2. The handover, as designed + +An installed app reads `igneum-app.json` next to its engine (macOS `Contents/Resources`, Windows the install folder): +the manifest URL and the intake key. The OTA path replaces the whole bundle or runs the whole installer, and both +carry a new `igneum-app.json`, so the values travel with the version. Nothing is cached in the app data folder. + +| Step | 0.3.5 on a machine (old folder, old key) | 0.3.6 (new folder, new key) | +|---|---|---| +| hourly check | fetches `dl//igneum-app-latest.json`: 0.3.6 is there (published in BOTH folders), signed by the same key | fetches `dl//igneum-app-latest.json`: itself | +| download | the URL inside the old folder's manifest, `dl//Igneum-Miner-0.3.6.dmg` or `-Setup-0.3.6.exe` (byte-identical to the new folder's copy) | nothing | +| apply | the new bundle or installer brings `igneum-app.json` with the NEW manifest URL and the NEW key | | +| after restart | reports to the intake with the new key (`LOG_INTAKE_KEY_NEXT` accepts it); its header reads `key 477bb0ef` and `folder ed9c4d2e`; next check hits the NEW folder | the same | +| jobs | `igneum-jobs.json` is read next to the manifest, so the mirror step copies the jobs file and its signature into the new folder; any job published while both folders live goes to both (section 3d) | | + +The window: every 0.3.5 machine must apply 0.3.6 before the old folder goes. Machines apply in their own minute of the +hour and only when the node is synced, so the window is hours, not minutes. The old folder and the old key stay until +`node tools/logs.mjs --rotation` reads 0 behind (section 4). Nothing is deleted on a schedule. + +## 3. The publish, exactly + +Everything below runs from the main checkout after this branch is merged to master. `DLSITE` is the downloads folder +(`~/.config/igneum/dlsite-dir`), `OLD` and `NEW` the two tokens read from their files; neither is ever typed. + +### 3a. Before the cut (by hand, once) + +``` +# the site must accept both keys (names only are listed; the value is piped from the file) +cd site && npx --yes vercel@latest --global-config ~/.config/igneum/vercel link --scope igneum --project igneum --yes +npx --yes vercel@latest --global-config ~/.config/igneum/vercel env ls --scope igneum # LOG_INTAKE_KEY must be there; is LOG_INTAKE_KEY_NEXT? +tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next | npx --yes vercel@latest --global-config ~/.config/igneum/vercel env add LOG_INTAKE_KEY_NEXT production --scope igneum +cd .. && git push origin master # or any production deploy: the function reads the variable at the next deploy +# confirm: a POST with the NEXT key is accepted (200 with an id), the old one still is too +curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next)" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"next key accepted"}' https://igneum-six.vercel.app/api/log + +# the Windows build on GitHub needs the same files (the runner writes the secrets to ~/.config/igneum; windows.yml) +gh auth switch --user igneum-labs +tr -d '[:space:]' < ~/.config/igneum/log-intake-key | gh secret set LOG_INTAKE_KEY --repo igneum-network/igneum +tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next | gh secret set LOG_INTAKE_KEY_NEXT --repo igneum-network/igneum +tr -d '[:space:]' < ~/.config/igneum/dl-token.next | gh secret set DL_TOKEN_NEXT --repo igneum-network/igneum +gh secret list --repo igneum-network/igneum # DL_TOKEN, DL_TOKEN_NEXT, LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT + +# the new folder exists and is empty of a manifest (the mirror step fills it) +DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)" +ls "$DLSITE/dl/$NEW" +packaging/mac/packaged-config.sh --test # 23 checks +``` + +### 3b. The cut: one command, both folders + +``` +node tools/ship-app.mjs 0.3.6 --node --notes "" --dl-both --dry-run # the plan, nothing written +node tools/ship-app.mjs 0.3.6 --node --notes "" --dl-both +``` + +With `--dl-both` the steps are: preflight (also: `dl-token.next` present and different, the folder exists) > bump > +inputs > commit and push (the Windows build starts; its payload step runs `packaged-config.sh --test` and packages +the `.next` values because the `_NEXT` secrets are set) > ci > fetch (installer and zip into the OLD folder) > dmg +(`build-dmg.sh` packages `igneum-app.json` from the `.next` files by default) > copy (DMG into the OLD folder) > +mirror (DMG, installer, zip, `igneum-windows-ci.json`, `igneum-jobs.json` and `.sig`, `payload-inputs.{zip,json,sha256}`, +`igneum-prove-wsl2.zip` into the NEW folder, sha256-checked) > manifest (section 3c, both) > deploy (one) > verify +(both folders: HEAD and GET of every file, both manifests byte-identical to the local ones and verifying, every +platform URL inside its own folder) > console. + +The build itself prints which files it packaged, for example `intake key: ~/.config/igneum/log-intake-key.next (31 chars, +fingerprint 477bb0ef)` and `manifest: ~/.config/igneum/dl-token.next (10 chars, fingerprint ed9c4d2e) -> https://dl.igneum.network/dl//igneum-app-latest.json`. +A build that says `e2005de8` or `df66a82c` packaged the old values: stop, the `.next` files were not found. + +### 3c. The same by hand with `packaging/ota/publish-manifest.sh` (what the manifest step runs) + +`publish-manifest.sh` writes the OLD folder by default (it reads `~/.config/igneum/dl-token`); `--dest ` and +`--base-url ` aim it at the NEW folder. With `--dest` it carries `consensus.override`, `tuning` and +`min_supported_version` over from the manifest already in THAT folder, which is none, so the second call must pass +what the first manifest carries. `--deploy` is refused with `--dest`; one deploy of the whole folder follows. +Run by hand, `publish-manifest.sh` prints the manifest it wrote, URLs included, so the terminal shows the token +path (it always has); `ship-app.mjs` scrubs both tokens from every line, which is the reason to prefer 3b. + +``` +DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)" +OLD="$(tr -d '[:space:]' < ~/.config/igneum/dl-token)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)" +V=0.3.6; NOTES="" + +# 1. the OLD folder (default dest and base): the files are there from fetch and copy +packaging/ota/publish-manifest.sh --version $V --notes "$NOTES" --no-deploy \ + --mac "$DLSITE/dl/$OLD/Igneum-Miner-$V.dmg" --win "$DLSITE/dl/$OLD/Igneum-Miner-Setup-$V.exe" + +# 2. the NEW folder: the same bytes copied in, the same override, tuning and min_supported read from the first manifest +cp "$DLSITE/dl/$OLD/Igneum-Miner-$V.dmg" "$DLSITE/dl/$OLD/Igneum-Miner-Setup-$V.exe" "$DLSITE/dl/$OLD/igneum-windows-app.zip" \ + "$DLSITE/dl/$OLD/igneum-windows-ci.json" "$DLSITE/dl/$OLD/igneum-jobs.json" "$DLSITE/dl/$OLD/igneum-jobs.json.sig" \ + "$DLSITE/dl/$OLD/payload-inputs.zip" "$DLSITE/dl/$OLD/payload-inputs.json" "$DLSITE/dl/$OLD/payload-inputs.sha256" \ + "$DLSITE/dl/$OLD/igneum-prove-wsl2.zip" "$DLSITE/dl/$NEW/" +M="$DLSITE/dl/$OLD/igneum-app-latest.json" +OVERRIDE="$(python3 -c 'import json,sys; o=json.load(open(sys.argv[1])).get("consensus",{}).get("override"); print(json.dumps(o,sort_keys=True,separators=(",",":")) if o else "")' "$M")" +MINSUP="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("min_supported_version",""))' "$M")" +python3 -c 'import json,sys; t=json.load(open(sys.argv[1])).get("tuning"); open(sys.argv[2],"w").write(json.dumps(t)) if t else None' "$M" /tmp/tuning-$V.json +packaging/ota/publish-manifest.sh --version $V --notes "$NOTES" --no-deploy \ + --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW" \ + --mac "$DLSITE/dl/$NEW/Igneum-Miner-$V.dmg" --win "$DLSITE/dl/$NEW/Igneum-Miner-Setup-$V.exe" \ + ${OVERRIDE:+--override "$OVERRIDE"} ${MINSUP:+--min-supported "$MINSUP"} $([ -s /tmp/tuning-$V.json ] && echo --tuning /tmp/tuning-$V.json || echo --no-tuning) +rm -f /tmp/tuning-$V.json +# the two manifests must differ only in published_at and the folder inside the URLs +diff <(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); m.pop("published_at"); print(json.dumps(m,sort_keys=True,indent=1).replace(sys.argv[2],"T"))' "$M" "$OLD") \ + <(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); m.pop("published_at"); print(json.dumps(m,sort_keys=True,indent=1).replace(sys.argv[2],"T"))' "$DLSITE/dl/$NEW/igneum-app-latest.json" "$NEW") && echo "same fields" + +# 3. one deploy, then both live checks (each: reachable, byte-identical to the local file, signature verifies) +(cd "$DLSITE" && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes) +packaging/ota/publish-manifest.sh --verify-only +packaging/ota/publish-manifest.sh --verify-only --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW" +``` + +### 3d. Jobs while both folders live + +`packaging/ota/publish-jobs.sh` writes `dl//igneum-jobs.json` by default and takes the same `--dest` and +`--base-url`. Until the old folder is deleted, every `add` or `expire` is published twice, the second time with +`--dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW"`, then one deploy. A job whose +`zip_url` names the old folder keeps working until that folder goes; publish new jobs with URLs in the new folder. + +## 4. Verification: every machine's header shows the new intake path + +The engine logs two header lines at every start, and the restart after an OTA apply logs them again, so the latest +upload of every machine carries them: + +``` +IGNEUM-APP version=0.3.6 machine= platform= node= +config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (packaged); manifest https://dl.igneum.network/dl//igneum-app-latest.json folder ed9c4d2e (packaged) +``` + +``` +node tools/logs.mjs --rotation # one row per app machine: version, key fp, folder fp, sources, state; exit 1 while any is behind +node tools/logs.mjs | grep -E 'IGNEUM-APP|config: intake' # one machine in full +``` + +Done means: every row `moved` (key `477bb0ef`, folder `ed9c4d2e`, version 0.3.6), none `OLD`, and the `unknown` rows +(a machine whose last upload predates this header, or a machine that has stopped for good) accounted for by name. +Today the table shows 6 app machines (three `win-`, three `mac-`), all 0.3.5 or older, all `unknown` because 0.3.5 +has no `config:` line. The console's Machines tab (`relay/`) shows the versions the same way. + +Also check, once, that the intake stores an upload under the new key from a real machine (the row's `last_received` +moves after the restart), and that `node tools/logs.mjs` lists no new `rotation-check` rows beyond the one from 3a. + +## 5. The deletion, after section 4 reads 0 behind + +In this order, each step checked before the next: + +``` +DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)" +OLD="$(tr -d '[:space:]' < ~/.config/igneum/dl-token)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)" +node tools/logs.mjs --rotation || { echo "machines still behind"; false; } + +# 1. the old folder: gone from the downloads host (one deploy); the new one still serves +mv "$DLSITE/dl/$OLD" "$HOME/igneum-dl-old-$(date -u +%Y%m%d)" # kept outside the site for a week, then rm -rf +(cd "$DLSITE" && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes) +curl -s -o /dev/null -w '%{http_code}\n' "https://dl.igneum.network/dl/$OLD/igneum-app-latest.json" # 404 +packaging/ota/publish-manifest.sh --verify-only --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW" # still live + +# 2. the local files: the NEXT values become the plain ones (every script's default), the old ones kept dated +mv ~/.config/igneum/log-intake-key ~/.config/igneum/log-intake-key.old-$(date -u +%Y%m%d) +mv ~/.config/igneum/log-intake-key.next ~/.config/igneum/log-intake-key +mv ~/.config/igneum/dl-token ~/.config/igneum/dl-token.old-$(date -u +%Y%m%d) +mv ~/.config/igneum/dl-token.next ~/.config/igneum/dl-token +packaging/ota/publish-manifest.sh --verify-only # now reads the new token by default: live, verified + +# 3. the intake: the old key dropped (LOG_INTAKE_KEY becomes the new value, LOG_INTAKE_KEY_NEXT removed), redeployed +cd site +npx --yes vercel@latest --global-config ~/.config/igneum/vercel env rm LOG_INTAKE_KEY production --scope igneum --yes +tr -d '[:space:]' < ~/.config/igneum/log-intake-key | npx --yes vercel@latest --global-config ~/.config/igneum/vercel env add LOG_INTAKE_KEY production --scope igneum +npx --yes vercel@latest --global-config ~/.config/igneum/vercel env rm LOG_INTAKE_KEY_NEXT production --scope igneum --yes +cd .. && git push origin master # or a production deploy +# the old key is dead (401), the new one lives (200) +curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key.old-$(date -u +%Y%m%d))" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"old key must be refused"}' https://igneum-six.vercel.app/api/log +curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key)" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"new key accepted"}' https://igneum-six.vercel.app/api/log + +# 4. the GitHub secrets: the plain names carry the new values, the _NEXT names go +tr -d '[:space:]' < ~/.config/igneum/dl-token | gh secret set DL_TOKEN --repo igneum-network/igneum +tr -d '[:space:]' < ~/.config/igneum/log-intake-key | gh secret set LOG_INTAKE_KEY --repo igneum-network/igneum +gh secret delete DL_TOKEN_NEXT --repo igneum-network/igneum; gh secret delete LOG_INTAKE_KEY_NEXT --repo igneum-network/igneum + +# 5. the app machines keep reporting (a last look, an hour later) +node tools/logs.mjs --rotation +``` + +The relay is not involved: since round 4 (X23) it has its own key (`~/.config/igneum/relay-key`, `RELAY_KEY`), and +the intake key only reports. The old launcher packages under `proto-cuda/windows-app` and `windows-miner` (0.2.0) +carried the old key in `upload-log.bat`; those machines are the `unknown` rows of section 4 and upload nothing after +step 3, which is the intent. + +## 6. The fresh repository, after section 5 + +The old values are dead values once section 5 is done; only then is the rewrite worth running. The owner's two +settings come first: rename the GitHub login `igneum-labs` to a neutral handle (the numeric noreply id 337424239 +stays, so the rewritten author line is ` <337424239+@users.noreply.github.com>`), and remove the second +login from the organisation's owners. Then, from the main checkout with every agent frozen: + +``` +gh pr list --repo igneum-network/igneum # must be empty +git worktree list > ~/igneum-worktrees-$(date -u +%Y%m%d).txt +IGNEUM_FILTER_REPO=/git_filter_repo.py tools/repo/fresh-repo.sh --new-login --new-repo igneum-network/ \ + [--public-claude-md ] --work ~/igneum-rewrite +``` + +The script clones `origin` afresh (mirror, no hardlinks), reads the personal identities and the second login from +the history and the four secret values from `~/.config/igneum`, writes the rule files 0600 and removes them after +the pass, runs the one `git-filter-repo` invocation of `docs/plans/history-rewrite.md` section 2 (drop the four +internal files, replace the secrets and the names, mailmap both personal identities to the login, every offset to +`+0000`, optionally the public `CLAUDE.md` in every commit), then demands zero for: secret lines in any blob, +identity lines in any blob, identity lines in commit metadata, stamps not `+0000`, commits touching the dropped +files, identities other than the login, the old login in any blob (with `--new-login`). It prints the push commands +and runs none of them: `gh repo create igneum-network/ --private`, `git remote add origin` in the clone, +`git push --mirror origin`, then the GitHub secrets (section 3a), the Vercel Git connection moved to the new +repository, the old repository archived, the main checkout re-cloned and every worktree re-created from its +rewritten branch. + +### Dry run of 5 October 2026 (throwaway mirror clone of the main checkout under the session scratchpad, nothing pushed) + +| Count | Before | After | +|---|---|---| +| commits (all refs) | 410 | 353 (57 commits that only touched the four dropped files are gone) | +| refs | 49 | 29 (filter-repo drops the remote-tracking refs of the mirror) | +| author and committer identities | 3 | 1 (`igneum-labs <337424239+[removed]>`) | +| stamps not +0000 | 660 of 820 | 0 of 706 | +| commits touching the four dropped files | 53 | 0 | +| secret lines in any blob (old key, new key, old token, new token) | 21 | 0 | +| identity lines in any blob (first name outside the login, surname, personal addresses, second login, the other businesses) | 1839 | 0 | +| identity lines in commit metadata | 171 | 0 | +| standing login lines in any blob | 94 | 61 (0 with `--new-login` after the rename) | +| pass run time | | 67 s; 4 min with the clone and the greps | + +The report sits next to the clone (`/report.txt`, with `commit-map`, 411 lines). The throwaway clone was +removed after the run; nothing left the Mac. + +## 7. The order for the afternoon + +1. Merge `rotation-2` into master (the Windows build on that push packages with the `_NEXT` secrets only when they + exist: set them first, section 3a, or expect the payload step to fail on the missing `LOG_INTAKE_KEY`). +2. Section 3a: the site's `LOG_INTAKE_KEY_NEXT`, the four repository secrets, the curl check. +3. Section 3b: `--dry-run`, then the cut with `--dl-both`. +4. Section 4 through the afternoon: `node tools/logs.mjs --rotation` until 0 behind (the slot rule means an hour or + two for a synced fleet; a machine that is off waits for its owner). +5. Section 5: the deletion, in order. +6. The owner's two GitHub settings (login rename, one owner); then section 6, the fresh repository, from a frozen tree. diff --git a/infra/gpu-bench/upload.sh b/infra/gpu-bench/upload.sh index 7d416f2e..eaf3dd6c 100755 --- a/infra/gpu-bench/upload.sh +++ b/infra/gpu-bench/upload.sh @@ -5,7 +5,14 @@ # ./upload.sh