Merge release-0.3.6: Igneum Miner 0.3.6 and 0.3.7 (instant jobs, verifier on every node, one notice strip, latency, packaged config, hidden windows, WSL scripts from files, runtime DLL gate)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-05 11:31:04 +01:00
commit e2bf4d0a85
71 changed files with 4420 additions and 405 deletions

View file

@ -65,3 +65,5 @@ jobs:
run: bash tools/ci/copied-sources-check.sh
- name: relay unit tests (parsers, secret compare, the wake endpoint)
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs
- name: miner app notice strip (ordering, keys, wording, timers)
run: node --test app/igneum-app/ui/notices.test.mjs

View file

@ -13,7 +13,23 @@
# 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 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.
# 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,28 +126,65 @@ 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: 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, signature, hashes and node commit verified)
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
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 # ~/.config/igneum/dl-token was written by the packaged configuration step
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 +264,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 +287,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

View file

@ -219,7 +219,7 @@ dependencies = [
[[package]]
name = "igneum-app"
version = "0.3.5"
version = "0.3.7"
dependencies = [
"ed25519-dalek",
"getrandom",

View file

@ -1,6 +1,6 @@
[package]
name = "igneum-app"
version = "0.3.5"
version = "0.3.7"
edition = "2021"
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
license = "MIT"
@ -16,6 +16,12 @@ path = "src/main.rs"
name = "igneum-ota-sign"
path = "src/bin/ota-sign.rs"
# the Windows proof verifier wrapper (release 0.3.6): the node runs it as IGNEUM_PROOF_VERIFIER and it runs
# igneum-prove-host inside WSL2; shipped next to the engine by packaging/windows/make-payload.sh
[[bin]]
name = "igneum-prove-verify"
path = "src/bin/prove-verify.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -6,8 +6,8 @@
1 ICON "igneum.ico"
1 VERSIONINFO
FILEVERSION 0,3,5,0
PRODUCTVERSION 0,3,5,0
FILEVERSION 0,3,7,0
PRODUCTVERSION 0,3,7,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
@ -20,12 +20,12 @@ BEGIN
BEGIN
VALUE "CompanyName", "Igneum"
VALUE "FileDescription", "Igneum Miner engine"
VALUE "FileVersion", "0.3.5"
VALUE "FileVersion", "0.3.7"
VALUE "InternalName", "igneum-app"
VALUE "LegalCopyright", "Igneum contributors"
VALUE "OriginalFilename", "igneum-app.exe"
VALUE "ProductName", "Igneum Miner"
VALUE "ProductVersion", "0.3.5"
VALUE "ProductVersion", "0.3.7"
END
END
BLOCK "VarFileInfo"

View file

@ -9,11 +9,18 @@
//! igneum-ota-sign sha256 <file> the file's sha256 and size, for the manifest
//! igneum-ota-sign sign-jobs <private-key-file> <igneum-jobs.json> the remote-jobs file (src/jobs.rs), same key
//! igneum-ota-sign verify-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file>
//! igneum-ota-sign sign-inputs <private-key-file> <payload-inputs.json> the Windows build inputs (src/inputs.rs), same key
//! igneum-ota-sign verify-inputs <public-key-file|hex|embedded> <payload-inputs.json> <sig-file>
//! [--zip <payload-inputs.zip>] [--dir <unpacked folder>] [--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<String> = 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 <priv> <pub> | sign <priv> <manifest.json> | verify <pub> <manifest.json> <sig> | embedded | fingerprint <pub> | sha256 <file> | sign-jobs <priv> <jobs.json> | verify-jobs <pub> <jobs.json> <sig>");
eprintln!("usage: igneum-ota-sign keygen <priv> <pub> | sign <priv> <manifest.json> | verify <pub> <manifest.json> <sig> | embedded | fingerprint <pub> | sha256 <file> | sign-jobs <priv> <jobs.json> | verify-jobs <pub> <jobs.json> <sig> | sign-inputs <priv> <payload-inputs.json> | verify-inputs <pub|embedded> <payload-inputs.json> <sig> [--zip z] [--dir d] [--node-commit c]");
std::process::exit(2);
}
}

View file

@ -0,0 +1,157 @@
//! igneum-prove-verify: the Windows wrapper the node's proof pool verifier calls (spec 7.7 item 4, release 0.3.6).
//!
//! The node on a PC is a Windows exe; the SP1 host (`igneum-prove-host`) is Linux-only and lives inside WSL2.
//! The engine sets `IGNEUM_PROOF_VERIFIER=<this exe>` for its node, and the node runs
//! `igneum-prove-verify.exe --mode verify --proof <file> --statement 0x...`. This wrapper converts the proof
//! path with `wslpath -a` inside Ubuntu-24.04, finds the host in the same order the prover uses
//! (src/wslhost.rs: the payload's wsl2/bin, the setup-wsl.sh build, the old layout, /opt/igneum), runs it
//! there with the same arguments and exits with its exit code.
//!
//! igneum-prove-verify --probe prints `HOST <wsl path>` and exits 0 when a host is found; exits 2 otherwise
//! igneum-prove-verify <host args> runs the host; exit 2 when there is no host or WSL did not answer
//!
//! Exit 2 is reserved for "no host": the engine probes before it sets the variable, so a node never gets a
//! verifier that cannot run. The wrapper never trusts a proof it did not verify.
//!
//! No console of its own on Windows: the node that starts it has none (the engine starts igneumd with
//! CREATE_NO_WINDOW), so a console-subsystem wrapper would open a visible window on every verification
//! (5 October 2026: a console window on both PCs). Piped stdout and the exit code still reach the caller.
#![cfg_attr(windows, windows_subsystem = "windows")]
#[path = "../wslhost.rs"]
mod wslhost;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const NO_HOST: i32 = 2;
fn wsl_exe() -> PathBuf {
#[cfg(windows)]
{
let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
PathBuf::from(format!("{root}\\System32\\wsl.exe"))
}
#[cfg(not(windows))]
{
PathBuf::from("wsl")
}
}
fn quiet(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd
}
/// The drive-letter mapping (src/wslhost.rs). A `wslpath -a` round trip through wsl.exe was dropped on 5 October
/// 2026: a path with a space on that command line is split by the shell inside the distribution.
fn to_wsl(p: &Path) -> String {
wslhost::wsl_path(p)
}
/// The script file: runs the probe or the host (`write_script` under %LOCALAPPDATA%\igneum\wsl, removed after the
/// run); exits 2 when the file cannot be written.
fn script_file(stem: &str, body: &str) -> wslhost::ScriptFile {
match wslhost::write_script(stem, body) {
Ok(f) => f,
Err(e) => {
eprintln!("igneum-prove-verify: cannot write the {stem} script under {}: {e}", wslhost::script_dir().display());
std::process::exit(NO_HOST);
}
}
}
/// The host's arguments with `--proof <path>` rewritten for WSL. Pure, so it has a test.
pub fn rewrite_args<F: Fn(&Path) -> String>(args: &[String], to_wsl: F) -> Vec<String> {
let mut out = Vec::with_capacity(args.len());
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--proof" && i + 1 < args.len() {
out.push(a.clone());
out.push(to_wsl(Path::new(&args[i + 1])));
i += 2;
continue;
}
if let Some(v) = a.strip_prefix("--proof=") {
out.push(format!("--proof={}", to_wsl(Path::new(v))));
i += 1;
continue;
}
out.push(a.clone());
i += 1;
}
out
}
/// The script that finds the host and replaces the shell with it: the host's exit code is the script's. With no
/// host, a line on stderr naming the places looked at, and exit 2.
pub fn run_script(bin_dir: &Path) -> String {
let lookup = wslhost::lookup_script(bin_dir);
format!(
"h=$({lookup}); if [ -n \"$h\" ]; then exec \"$h\" \"$@\"; fi; echo 'igneum-prove-verify: no igneum-prove-host in WSL2 (looked at: {})' >&2; exit {NO_HOST}",
wslhost::candidates_text(bin_dir).replace('\'', "'\\''")
)
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let bin_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).unwrap_or_default();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("igneum-prove-verify {}", env!("CARGO_PKG_VERSION"));
return;
}
if args.iter().any(|a| a == "--probe") {
let file = script_file("verify-probe", &wslhost::lookup_script(&bin_dir));
let out = quiet(&mut wslhost::command(&wsl_exe(), wslhost::DISTRO, None, &file.path, true, &[])).stdin(Stdio::null()).output();
let host = out.ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
if host.is_empty() {
eprintln!("igneum-prove-verify: no igneum-prove-host in WSL2 ({}) (looked at: {})", wslhost::DISTRO, wslhost::candidates_text(&bin_dir));
std::process::exit(NO_HOST);
}
println!("HOST {host}");
return;
}
let host_args = rewrite_args(&args, to_wsl);
let file = script_file("verify-run", &run_script(&bin_dir));
let argv: Vec<&str> = host_args.iter().map(|a| a.as_str()).collect();
let status = quiet(&mut wslhost::command(&wsl_exe(), wslhost::DISTRO, None, &file.path, true, &argv)).stdin(Stdio::null()).status();
match status {
Ok(st) => std::process::exit(st.code().unwrap_or(1)),
Err(e) => {
eprintln!("igneum-prove-verify: WSL2 did not start ({}): {e}", wsl_exe().display());
std::process::exit(NO_HOST);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_proof_path_is_rewritten() {
let args: Vec<String> = ["--mode", "verify", "--proof", "C:\\Users\\x\\p.bin", "--statement", "0xab"].iter().map(|s| s.to_string()).collect();
let out = rewrite_args(&args, |p| wslhost::wsl_path(p));
assert_eq!(out, vec!["--mode", "verify", "--proof", "/mnt/c/Users/x/p.bin", "--statement", "0xab"]);
let args: Vec<String> = vec!["--proof=D:\\q.bin".into()];
assert_eq!(rewrite_args(&args, |p| wslhost::wsl_path(p)), vec!["--proof=/mnt/d/q.bin"]);
}
#[test]
fn the_script_execs_the_first_host_and_exits_2_without_one() {
let s = run_script(Path::new("C:\\Igneum"));
assert!(s.starts_with("h=$(for f in '/mnt/c/Igneum/wsl2/bin/igneum-prove-host' ~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host ~/igneum-prove/target/release/igneum-prove-host '/opt/igneum/igneum-prove-host'; do"), "{s}");
assert!(s.contains("exec \"$h\" \"$@\"; fi;"), "{s}");
// the arguments travel on the command line as $1, $2... with no double quote anywhere
let line = wslhost::bash_line(Path::new("C:\\Users\\x\\AppData\\Local\\igneum\\wsl\\verify-run-1-0.sh"), true, &["--mode", "verify", "--proof", "/mnt/c/Users/x/p.bin", "--statement", "0xab"]);
assert_eq!(line, "bash -l '/mnt/c/Users/x/AppData/Local/igneum/wsl/verify-run-1-0.sh' '--mode' 'verify' '--proof' '/mnt/c/Users/x/p.bin' '--statement' '0xab'");
assert!(s.ends_with("exit 2"));
assert!(s.contains("looked at: /mnt/c/Igneum/wsl2/bin/igneum-prove-host, ~/igneum-prove/proving/"));
}
}

View file

@ -83,6 +83,10 @@ pub struct Settings {
/// Lifetime dev-fee blocks this machine found (the miner's `dev-fee block` lines), carried across runs.
#[serde(default)]
pub fee_total: u64,
/// Devnet only: when no verifier is found next to the engine, start the node with `IGNEUM_PROOF_VERIFY=trust`
/// so it includes proof records it never verified (src/verifier.rs). Default off; a found verifier always wins.
#[serde(default)]
pub proof_verify_trust: bool,
}
fn one() -> u32 {
@ -94,7 +98,7 @@ fn yes() -> bool {
impl Default for Settings {
fn default() -> Settings {
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, installed_at: 0, dev_fee: true, fee_total: 0 }
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false }
}
}
@ -154,16 +158,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<serde_json::Value>,
/// Where the key and the manifest came from, for the log header: "packaged", "file <name>" 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 `<host>/dl/<token>/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<String> {
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::<Packaged>(&t).ok()) {
if let Some(mut p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::<Packaged>(&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, "<token>"), 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 +326,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("<token>/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();

View file

@ -70,6 +70,9 @@ pub enum Cmd {
SweepHelperDone(Result<(), String>),
/// a direct `nvidia-smi -pl` for the sweep finished: what it printed
SweepCapSet(String),
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the
/// prover found a host that was not there when the node started
RestartNode(String),
Quit,
}
@ -110,7 +113,7 @@ impl Shared {
st.mining.accepted_total = settings.accepted_total;
st.mining.fee_total = settings.fee_total;
st.address = address_state(&settings, &wallet_path);
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, dev_fee: settings.dev_fee };
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust };
st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() };
st.live_page = packaged.live_page.clone();
st.finality.message = "waiting for the miner".into();
@ -265,8 +268,9 @@ impl Shared {
Ok(json!({ "ok": true }))
}
pub fn apply_settings(&self, identities: Option<u32>, vote: Option<bool>, login: Option<bool>, address: Option<&str>, display_name: Option<&str>, dev_fee: Option<bool>) -> Result<Value, String> {
pub fn apply_settings(&self, identities: Option<u32>, vote: Option<bool>, login: Option<bool>, address: Option<&str>, display_name: Option<&str>, dev_fee: Option<bool>, proof_verify_trust: Option<bool>) -> Result<Value, String> {
let mut restart = Vec::new();
let mut restart_node: Option<String> = None;
{
let mut s = self.settings.lock().unwrap();
if let Some(n) = display_name {
@ -292,6 +296,12 @@ impl Shared {
restart.push(if v { "dev fee on (1 block in 100)".into() } else { "dev fee off".into() });
}
}
if let Some(v) = proof_verify_trust {
if v != s.proof_verify_trust {
s.proof_verify_trust = v;
restart_node = Some(if v { "proof trust mode on (devnet only)".into() } else { "proof trust mode off".into() });
}
}
if let Some(a) = address {
let a = a.trim().to_ascii_lowercase();
if !a.is_empty() && a != s.address {
@ -310,6 +320,7 @@ impl Shared {
st.settings.identities = s.identities;
st.settings.vote = s.vote;
st.settings.dev_fee = s.dev_fee;
st.settings.proof_verify_trust = s.proof_verify_trust;
st.dev_fee.on = s.dev_fee;
st.dev_fee.percent = if s.dev_fee { 1 } else { 0 };
st.address = address_state(&s, &self.wallet_path);
@ -322,7 +333,10 @@ impl Shared {
if !restart.is_empty() {
self.send(Cmd::RestartMiners(restart.join(", ")));
}
Ok(json!({ "ok": true, "restart": !restart.is_empty() }))
if let Some(why) = restart_node.clone() {
self.send(Cmd::RestartNode(why));
}
Ok(json!({ "ok": true, "restart": !restart.is_empty(), "restart_node": restart_node.is_some() }))
}
}
@ -397,6 +411,8 @@ pub struct Engine {
node_restarts: u32,
node_log: Option<PathBuf>,
node_last_reading: Option<Instant>,
/// the proof verifier decided for the node (src/verifier.rs); None = decide at the next node start
verifier: Option<crate::verifier::Verifier>,
sync_prev: Option<u64>,
sync_stable_since: Option<Instant>,
last_sync_check: Instant,
@ -491,6 +507,7 @@ impl Engine {
node_restarts: 0,
node_log: None,
node_last_reading: None,
verifier: None,
sync_prev: None,
sync_stable_since: None,
last_sync_check: now,
@ -576,6 +593,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()));
@ -710,6 +730,15 @@ impl Engine {
m.restart_at = Some(Instant::now());
}
}
Cmd::RestartNode(why) => {
self.verifier = None;
if self.node_external {
self.shared.event("info", &format!("{why}; the node is external, so the app cannot restart it"));
} else if self.node.is_some() || self.node_restart_at.is_some() {
self.shared.event("info", &format!("{why}; the node restarts"));
self.restart_node(&why, Duration::from_secs(2));
}
}
Cmd::CheckUpdate => self.ota.check_now(&self.shared),
Cmd::InstallUpdate => self.ota.install_now(&self.shared),
Cmd::AutoUpdate(on) => self.ota.set_auto(&self.shared, on),
@ -971,6 +1000,8 @@ impl Engine {
let mut st = self.st();
st.node.state = "syncing".into();
st.node.message = "external node".into();
st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into();
st.proving.verifier_note = crate::verifier::note("unknown", "", "", true);
} else {
self.start_node();
}
@ -1037,9 +1068,11 @@ impl Engine {
let seg = if self.node_starts > 1 { format!("-r{}", self.node_starts) } else { String::new() };
let log = self.shared.runtime.log_dir.join(format!("node-{}{seg}.log", self.stamp));
let args = self.node_args();
match procs::spawn(Source::Node, &self.bins.node, &args, None, &log, &self.lines_tx, &[]) {
let verifier = self.node_verifier();
match procs::spawn(Source::Node, &self.bins.node, &args, None, &log, &self.lines_tx, &verifier.env) {
Ok(p) => {
self.shared.log(&format!("igneumd started (pid {}): {}", p.pid(), p.cmdline));
self.shared.log(&format!("node proof verifier: {} ({})", verifier.mode, verifier.detail));
let mut st = self.st();
st.node.pid = p.pid();
st.node.state = "starting".into();
@ -1065,6 +1098,26 @@ impl Engine {
}
}
/// The proof verifier for this node start (spec 7.7 item 4; src/verifier.rs), decided once and kept across
/// restarts until a RestartNode command asks again. The Windows probe runs WSL, so the result is cached.
fn node_verifier(&mut self) -> crate::verifier::Verifier {
if self.verifier.is_none() {
let trust = self.shared.settings.lock().unwrap().proof_verify_trust;
let v = crate::verifier::resolve(&self.bins.dir, trust);
if v.mode == "trust" {
self.shared.event("info", "devnet only: the node trusts proof records without verifying them (Settings)");
}
self.verifier = Some(v);
}
let v = self.verifier.clone().unwrap();
let mut st = self.st();
st.proving.verifier_set = v.set_text();
st.proving.verifier_reason = if v.mode == "command" { String::new() } else { v.detail.clone() };
let (mode, set, reason) = (st.proving.verifier_mode.clone(), st.proving.verifier_set.clone(), st.proving.verifier_reason.clone());
st.proving.verifier_note = crate::verifier::note(&mode, &set, &reason, false);
v
}
fn stop_node(&mut self) {
if let Some(mut n) = self.node.take() {
self.shared.log("stopping the node");

View file

@ -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<String, FileEntry>,
}
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<InputsManifest, String> {
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<InputsManifest, String> {
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<String, String> {
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);
}
}

View file

@ -283,6 +283,11 @@ pub fn extract_script(p: &BuildParams, job_id: &str, zip_wsl: &str) -> String {
s.push_str("[ -f \"$ZIP\" ] || { echo \"RESULT extract zip missing at $ZIP\"; exit 2; }\n");
s.push_str("rm -rf \"$B/src\" && mkdir -p \"$B/src\" && cp \"$ZIP\" \"$B/inputs.zip\" || { echo \"RESULT extract cannot copy the zip into $B\"; exit 2; }\n");
s.push_str("unzip -q -o \"$B/inputs.zip\" -d \"$B/src\" || { echo \"RESULT extract unzip failed\"; exit 2; }\n");
// every unpacked file (the zip root and the sibling igneum-pow) is stamped now: the target dir persists between
// jobs and cargo judges freshness by mtime, so sources that keep the Mac's older mtimes would be taken as unchanged
// since the last build and new callers linked against stale crates (5 October 2026: the 0.3.6 job built kaspad
// against 0.3.5's consensus-core). The packer stamps too (push-build-inputs.sh); this guard holds if it regresses.
s.push_str("find \"$B/src\" -type f -exec touch {} + || { echo \"RESULT extract cannot stamp the sources\"; exit 2; }\n");
s.push_str("[ -f \"$SRC/manifest.json\" ] || { echo \"RESULT extract no manifest.json under $SRC\"; exit 2; }\n");
// the stale-build class (5 October 2026): the target dir persists and cargo rebuilds by mtime, so every extracted
// source is stamped now, else a file older than the last build links against the cached crate of the old version
@ -328,6 +333,17 @@ pub fn build_script(p: &BuildParams, job_id: &str, m: &Manifest, target: &str) -
s.push_str(&format!(" if [ -f \"$CARGO_TARGET_DIR/{rel}/{name}\" ]; then cp -f \"$CARGO_TARGET_DIR/{rel}/{name}\" \"$OUT/{target}/{name}\"; echo \"RESULT {target} {name} $(stat -c %s \"$OUT/{target}/{name}\") bytes sha256 $(sha256sum \"$OUT/{target}/{name}\" | cut -c1-64)\"; else echo \"RESULT {target} {name} missing after the build\"; rc=3; fi\n"));
}
s.push_str("fi\n");
if windows && u.bins.iter().any(|b| b == "igneumd") {
// the three mingw runtime DLLs from THIS toolchain go next to the exes (5 October 2026, 0.3.6: the PC's
// GCC 13 exes shipped with the Mac's GCC 16 libstdc++-6.dll, which lacks five codecvt symbols; the node
// did not start on either PC). make-payload.sh takes DLLs next to the exes first.
s.push_str("if [ \"$rc\" = 0 ]; then\n");
s.push_str(" gccdir=$(dirname \"$(x86_64-w64-mingw32-gcc-posix -print-file-name=libstdc++-6.dll)\")\n");
s.push_str(" for dll in \"$gccdir/libstdc++-6.dll\" \"$gccdir/libgcc_s_seh-1.dll\" /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll; do\n");
s.push_str(&format!(" if [ -f \"$dll\" ]; then cp -f \"$dll\" \"$OUT/{target}/\"; echo \"RESULT {target} $(basename \"$dll\") $(stat -c %s \"$dll\") bytes sha256 $(sha256sum \"$dll\" | cut -c1-64) from $(dirname \"$dll\")\"; else echo \"RESULT {target} runtime dll missing: $dll\"; rc_all=1; fi\n"));
s.push_str(" done\n");
s.push_str("fi\n");
}
if optional {
s.push_str(&format!("[ \"$rc\" = 0 ] || echo \"RESULT {target} {dir} optional on {target}: not fatal\"\n", dir = u.dir));
} else {
@ -541,6 +557,10 @@ mod tests {
assert!(lin.contains("cd \"$SRC/app/igneum-app\""));
assert!(lin.contains("optional on linux: not fatal"));
assert!(!lin.contains("--target x86_64-pc-windows-gnu"));
// the windows stage ships the runtime DLLs of its own toolchain next to the exes; the linux stage does not
let win = build_script(&p, id, &m, "windows");
assert!(win.contains("-print-file-name=libstdc++-6.dll") && win.contains("libgcc_s_seh-1.dll") && win.contains("libwinpthread-1.dll"), "{win}");
assert!(!lin.contains("libstdc++-6.dll"));
assert!(lin.contains("RESULT linux igneumd $(stat"));
let win = build_script(&p, id, &m, "windows");
assert!(win.contains("--target x86_64-pc-windows-gnu") && win.contains("x86_64-w64-mingw32-gcc-posix") && win.contains("link-arg=-static"));
@ -554,6 +574,11 @@ mod tests {
let ex = extract_script(&p, id, "/mnt/c/it's/build-inputs.zip");
assert!(ex.contains("ZIP='/mnt/c/it'\\''s/build-inputs.zip'"));
assert!(ex.contains("unzip -q -o"));
// the unpacked sources are stamped after the unzip and before the manifest check (cargo's mtime freshness)
let unzip_at = ex.find("unzip -q -o").unwrap();
let touch_at = ex.find("find \"$B/src\" -type f -exec touch {} +").expect("the extract stamps the sources");
let manifest_at = ex.find("manifest.json").unwrap();
assert!(unzip_at < touch_at && touch_at < manifest_at, "{ex}");
}
#[test]

View file

@ -833,12 +833,8 @@ fn probe(req: &str, wsl_user: &str) -> Result<String, String> {
users.push(DEFAULT_WSL_USER);
users.push("");
for u in users {
let mut c = Command::new(&wsl);
c.args(["-d", DEFAULT_DISTRO]);
if !u.is_empty() {
c.args(["-u", u]);
}
c.args(["--", "bash", "-lc", PROVER_PROBE]);
let file = crate::wslhost::write_script("prover-probe", PROVER_PROBE).map_err(|e| format!("cannot write the WSL probe script: {e}"))?;
let mut c = crate::wslhost::command(&wsl, DEFAULT_DISTRO, Some(u), &file.path, true, &[]);
let (code, out) = run_capture(&mut c, Duration::from_secs(90));
if code == Some(0) {
return Ok(format!("toolchain in {DEFAULT_DISTRO}{}: {}", if u.is_empty() { " (default user)".to_string() } else { format!(" as {u}") }, short_out(&out)));
@ -1387,7 +1383,8 @@ fn run_shard_benchmark(shared: &Arc<Shared>, job: &Job, sink: &Sink, data_root:
let shard = fixtures[0].clone();
let blocks = fixtures[1..].join(" ");
// what this run sees inside WSL: the account's own Ubuntu, so say who we are there
let (ccode, cout) = run_capture(Command::new(crate::platform::tool("wsl")).args(["-d", &distro, "-u", &user, "--", "bash", "-c", "echo \"wsl user $(id -un) uid $(id -u) home $HOME\"; nvidia-smi -L 2>&1 | head -1; ls -d \"$HOME/.sp1\" \"$HOME/igneum-prove\" 2>&1 | head -2"]), Duration::from_secs(60));
let ctx = crate::wslhost::write_script("wsl-context", "echo \"wsl user $(id -un) uid $(id -u) home $HOME\"; nvidia-smi -L 2>&1 | head -1; ls -d \"$HOME/.sp1\" \"$HOME/igneum-prove\" 2>&1 | head -2").map_err(|e| format!("cannot write the WSL context script: {e}"))?;
let (ccode, cout) = run_capture(&mut crate::wslhost::command(&crate::platform::tool("wsl"), &distro, Some(&user), &ctx.path, false, &[]), Duration::from_secs(60));
sink.line(&format!("wsl context (-u {user}): exit {ccode:?}: {}", short_out(&cout)));
// the payload ships the Linux host next to the app (wsl2\bin): no cargo, no toolchain, run it fixture by fixture;
// params.build = true forces the package's prove-shard.sh (cargo build inside the distro) instead
@ -1411,8 +1408,8 @@ fn run_shard_benchmark(shared: &Arc<Shared>, job: &Job, sink: &Sink, data_root:
}
let mode = if i == 0 { "shard --shard 0" } else { "block" };
let line = format!("export PATH=\"/usr/local/cuda/bin:$PATH\"; CUDA_DIR=$(ls -d /usr/local/cuda-12.* 2>/dev/null | sort -V | tail -1); export LD_LIBRARY_PATH=\"/usr/lib/wsl/lib:${{CUDA_DIR:+$CUDA_DIR/lib64:}}${{LD_LIBRARY_PATH:-}}\"; echo \"=== GPU run: {f} --mode {mode} (SP1_PROVER=cuda) ===\"; SP1_PROVER=cuda RUST_LOG=info '{host_wsl}' '{fixdir_wsl}/{f}.json' --mode {mode} --out '{results_wsl}/{f}-cuda-{started}.json'; rc=$?; echo \"run exit $rc at $(date -u +%FT%TZ)\"; exit $rc");
let mut cmd = Command::new(crate::platform::tool("wsl"));
cmd.args(["-d", &distro, "-u", &user, "--", "bash", "-c", &line]);
let run = crate::wslhost::write_script("gpu-run", &line).map_err(|e| format!("cannot write the WSL run script: {e}"))?;
let mut cmd = crate::wslhost::command(&crate::platform::tool("wsl"), &distro, Some(&user), &run.path, false, &[]);
cmd.current_dir(&pkg);
let r = run_streamed(&mut cmd, sink, ctl, left, shared, job, started, "shard benchmark running")?;
if r.code != Some(0) {
@ -1434,7 +1431,9 @@ fn run_shard_benchmark(shared: &Arc<Shared>, job: &Job, sink: &Sink, data_root:
};
if ran.code.is_none() {
// the Linux side outlives wsl.exe: end the prover there too
let _ = run_capture(Command::new(crate::platform::tool("wsl")).args(["-d", &distro, "-u", &user, "--", "bash", "-c", "pkill -f igneum-prove-host; pkill -f prove-shard.sh; true"]), Duration::from_secs(30));
if let Ok(kill) = crate::wslhost::write_script("prover-kill", "pkill -f igneum-prove-host; pkill -f prove-shard.sh; true") {
let _ = run_capture(&mut crate::wslhost::command(&crate::platform::tool("wsl"), &distro, Some(&user), &kill.path, false, &[]), Duration::from_secs(30));
}
}
sink.stage("uploading the results");
let mut uploaded = Vec::new();
@ -1485,7 +1484,9 @@ fn build_stage(shared: &Arc<Shared>, job: &Job, sink: &Sink, ctl: &Ctl, p: &jb::
cmd.current_dir(dir);
let ran = run_streamed(&mut cmd, sink, ctl, cap, shared, job, started, &format!("build: {stage}"))?;
if ran.code.is_none() {
let _ = run_capture(Command::new(crate::platform::tool("wsl")).args(["-d", &p.distro, "-u", &p.wsl_user, "--", "bash", "-c", jb::kill_script()]), Duration::from_secs(30));
if let Ok(kill) = crate::wslhost::write_script("build-kill", jb::kill_script()) {
let _ = run_capture(&mut crate::wslhost::command(&crate::platform::tool("wsl"), &p.distro, Some(&p.wsl_user), &kill.path, false, &[]), Duration::from_secs(30));
}
}
sink.line(&format!("STAGE {stage} {} {} {} s exit {}", if ran.timed_out { "timeout" } else { "end" }, jobs::format_time(crate::platform::unix_now()), t0.elapsed().as_secs(), ran.code.map(|c| c.to_string()).unwrap_or_else(|| "none".into())));
Ok(ran)
@ -1562,7 +1563,8 @@ fn run_build(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, ctl:
let ps = format!("[math]::Floor(([IO.DriveInfo]::new('{drive}')).AvailableFreeSpace/1GB)");
let (wc, wo) = run_capture(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]), Duration::from_secs(40));
let win_free = if wc == Some(0) { jb::parse_free_gb(&wo) } else { None };
let (lc, lo) = run_capture(Command::new(&wsl).args(["-d", &p.distro, "-u", &p.wsl_user, "--", "bash", "-c", jb::free_gb_script()]), Duration::from_secs(120));
let free = crate::wslhost::write_script("free-gb", jb::free_gb_script()).map_err(|e| format!("cannot write the WSL free-space script: {e}"))?;
let (lc, lo) = run_capture(&mut crate::wslhost::command(&wsl, &p.distro, Some(&p.wsl_user), &free.path, false, &[]), Duration::from_secs(120));
let wsl_free = if lc == Some(0) { jb::parse_free_gb(&lo) } else { None };
sink.line(&format!("RESULT check {} drive {drive}: {} GB free, {} /root: {} GB free, floor {} GB", now(), win_free.map(|g| g.to_string()).unwrap_or_else(|| format!("unknown (powershell exit {wc:?}: {})", short_out(&wo))), p.distro, wsl_free.map(|g| g.to_string()).unwrap_or_else(|| format!("unknown (wsl exit {lc:?}: {})", short_out(&lo))), p.min_free_gb));
let Some(wf) = win_free else { return Err("cannot read the free space of the Windows drive".into()) };

View file

@ -28,6 +28,8 @@ mod jobs;
mod jobrun;
mod jobbuild;
mod prover;
mod verifier;
mod wslhost;
mod sweep;
mod watchdog;
@ -54,6 +56,7 @@ fn main() {
if host.exists() {
let mut c = std::process::Command::new(&host);
c.current_dir(&dir);
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
if c.spawn().is_ok() {
return;
}
@ -86,7 +89,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

View file

@ -1107,7 +1107,7 @@ fn stage(e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result<Pa
if again != e.sha256 {
return Err("the download changed while it was being unpacked; discarded".into());
}
let _ = Command::new(crate::platform::tool("xattr")).args(["-dr", "com.apple.quarantine"]).arg(&staged).output();
let _ = crate::platform::quiet(&mut Command::new(crate::platform::tool("xattr"))).args(["-dr", "com.apple.quarantine"]).arg(&staged).output();
let v = crate::detect::run_timeout(Command::new(staged.join("Contents/MacOS/igneum-app")).arg("--version"), None, Duration::from_secs(20)).unwrap_or_default();
let want = format!("igneum-app {version}");
if v.trim() != want {
@ -1116,7 +1116,7 @@ fn stage(e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result<Pa
Ok(())
})();
if let Some(m) = mounted {
let _ = Command::new(crate::platform::tool("hdiutil")).args(["detach", "-force", &m.display().to_string()]).output();
let _ = crate::platform::quiet(&mut Command::new(crate::platform::tool("hdiutil"))).args(["detach", "-force", &m.display().to_string()]).output();
}
let _ = std::fs::remove_dir_all(&work);
if let Err(err) = r {

View file

@ -169,7 +169,7 @@ pub fn lock_permissions(path: &Path, dir: bool) {
let _ = dir;
let user = std::env::var("USERNAME").unwrap_or_default();
if !user.is_empty() {
let _ = Command::new(tool("icacls"))
let _ = quiet(&mut Command::new(tool("icacls")))
.arg(path)
.args(["/inheritance:r", "/grant:r", &format!("{user}:F")])
.output();
@ -314,9 +314,9 @@ pub fn set_start_at_login(on: bool) -> Result<(), String> {
let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
let out = if on {
let cmd = login_command().iter().map(|a| format!("\"{a}\"")).collect::<Vec<_>>().join(" ");
Command::new(tool("reg")).args(["add", key, "/v", "Igneum Miner", "/t", "REG_SZ", "/d", &cmd, "/f"]).output()
quiet(&mut Command::new(tool("reg"))).args(["add", key, "/v", "Igneum Miner", "/t", "REG_SZ", "/d", &cmd, "/f"]).output()
} else {
Command::new(tool("reg")).args(["delete", key, "/v", "Igneum Miner", "/f"]).output()
quiet(&mut Command::new(tool("reg"))).args(["delete", key, "/v", "Igneum Miner", "/f"]).output()
};
match out {
Ok(o) if o.status.success() || !on => Ok(()),
@ -338,7 +338,7 @@ pub fn start_at_login_is_on() -> bool {
}
#[cfg(windows)]
{
Command::new(tool("reg"))
quiet(&mut Command::new(tool("reg")))
.args(["query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", "Igneum Miner"])
.output()
.map(|o| o.status.success())

View file

@ -11,11 +11,17 @@
//! (`igneum_submitProofRecord`). The tile shows assigned, proving, submitted, paid.
//!
//! Where the prover runs: macOS runs the host next to the engine on the CPU (slow, shown as slow). Windows runs
//! it inside WSL2 (SP1's CUDA prover is Linux-only): the engine looks for `~/igneum-prove/target/release/
//! igneum-prove-host` in the Ubuntu-24.04 distribution; without it the tile says "proving needs the WSL2 setup,
//! 20 minutes, Set up" and Set up runs proving/windows-wsl2/setup-wsl.sh from the payload (`wsl2/` next to the
//! engine). Linux runs the host next to the engine. Everything the prover needs on a PC is in the payload or
//! installed by that script; there is no other channel.
//! it inside WSL2 (SP1's CUDA prover is Linux-only): the engine looks for `igneum-prove-host` in the Ubuntu-24.04
//! distribution in the order of src/wslhost.rs (the payload's wsl2/bin, the setup-wsl.sh build under
//! `~/igneum-prove/proving/igneum-prove/target/release`, the old `~/igneum-prove/target/release`, `/opt/igneum`);
//! without it the tile says "proving needs the WSL2 setup, 20 minutes, Set up" and names the paths it looked at,
//! and Set up runs proving/windows-wsl2/setup-wsl.sh from the payload (`wsl2/` next to the engine). Linux runs
//! the host next to the engine. Everything the prover needs on a PC is in the payload or installed by that
//! script; there is no other channel.
//!
//! The same thread reads the node's verifier state every 30 s (`igneum_getProvingStatus().verifier`, spec 7.7
//! item 4) whether proving is on or off, so the tile and `/api/state` say when this node relays proof records
//! but never includes them (src/verifier.rs decides what the node spawn sets).
use crate::engine::Shared;
use serde_json::{json, Value};
@ -80,15 +86,7 @@ pub fn choose(work: &[Work], attempted: &HashSet<(String, u32)>) -> Option<Work>
pick(true).or_else(|| pick(false))
}
/// A Windows path as WSL sees it: `C:\Users\x\f` -> `/mnt/c/Users/x/f`.
pub fn wsl_path(p: &Path) -> String {
let s = p.display().to_string().replace('\\', "/");
if s.len() > 2 && s.as_bytes()[1] == b':' {
format!("/mnt/{}{}", s[..1].to_ascii_lowercase(), &s[2..])
} else {
s
}
}
pub use crate::wslhost::wsl_path;
/// The identity labels this machine mines with (the vote keys the node assigns shards to): one per enabled
/// card, `<label_base>-<card n>`, and `-1..N` per identity when a card runs more than one.
@ -154,12 +152,14 @@ fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
let miner = bin_dir.join(if cfg!(windows) { "igneum-miner.exe" } else { "igneum-miner" });
if cfg!(windows) {
// the SP1 host runs inside WSL2 (Ubuntu-24.04): the Linux binaries the payload ships under wsl2\bin\ (seen
// from Ubuntu as /mnt/<drive>/.../wsl2/bin), else one built there by setup-wsl.sh
let shipped = wsl_path(&bin_dir.join("wsl2").join("bin").join("igneum-prove-host"));
let script = format!("for f in '{shipped}' ~/igneum-prove/target/release/igneum-prove-host /opt/igneum/igneum-prove-host; do [ -x \"$f\" ] && {{ echo \"$f\"; break; }}; done; command -v nvidia-smi >/dev/null && echo cuda");
let mut probe_cmd = Command::new(crate::platform::tool("wsl"));
probe_cmd.args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", &script]);
let probe = crate::platform::quiet(&mut probe_cmd).output(); // hidden: this probe opened a console window on PC 2 every minute (5 October 2026)
// from Ubuntu as /mnt/<drive>/.../wsl2/bin), else one built there by setup-wsl.sh, else a hand install
// (the order and the list are src/wslhost.rs, shared with igneum-prove-verify.exe)
// from a file, never inline (src/wslhost.rs: an inline script with double quotes and a path with a space
// reached bash mangled on 5 October 2026 and the app said the setup was missing)
let body = format!("{}\ncommand -v nvidia-smi >/dev/null && echo cuda\ntrue", crate::wslhost::lookup_script(bin_dir));
let file = crate::wslhost::write_script("prove-probe", &body).map_err(|e| format!("cannot write the WSL probe script: {e}"))?;
let probe = crate::platform::quiet(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[])).output();
let answered = probe.is_ok();
let text = probe.map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default();
let host = text.lines().find(|l| l.contains("igneum-prove-host")).map(|l| PathBuf::from(l.trim()));
let setup = bin_dir.join("wsl2").join("setup-wsl.sh");
@ -168,7 +168,7 @@ fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
let export = host.parent().map(|d| d.join("igneum-prove-export")).unwrap_or_default();
Ok(Tools { host, export, miner, wsl: true, setup_script: setup.exists().then_some(setup), cuda: text.contains("cuda") })
}
None => Err(format!("proving needs the WSL2 setup, 20 minutes, Set up{}", if setup.exists() { "" } else { " (setup script missing from the payload)" })),
None => Err(probe_message(bin_dir, answered, setup.exists())),
}
} else {
let host = bin_dir.join("igneum-prove-host");
@ -180,23 +180,66 @@ fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
}
}
/// The tile's message when no host is found inside WSL2: what to do, and the paths that were looked at.
pub fn probe_message(bin_dir: &Path, wsl_answered: bool, setup_present: bool) -> String {
let looked = crate::wslhost::candidates_text(bin_dir);
if !wsl_answered {
return format!("proving needs the WSL2 setup, 20 minutes, Set up (WSL2 with {} did not answer; no igneum-prove-host at {looked})", crate::wslhost::DISTRO);
}
format!("proving needs the WSL2 setup, 20 minutes, Set up (no igneum-prove-host at {looked}{})", if setup_present { "" } else { "; setup script missing from the payload" })
}
/// Reads the node's verifier state (`igneum_getProvingStatus`): the verifier word, the pool counts, the tile's
/// note. Nothing changes when the node does not answer (the mode stays as it was, "unknown" at first).
fn read_verifier(shared: &Shared) {
let external = shared.state.lock().unwrap().node.message == "external node";
match evm_rpc(shared, "igneum_getProvingStatus", json!([]), Duration::from_secs(5)) {
Ok(v) => {
let report = v["verifier"].as_str().unwrap_or("").to_string();
let mode = crate::verifier::mode_of_report(&report);
let count = |k: &str| v["pool"][k].as_u64().unwrap_or(0);
let (entries, verified, failed) = (count("entries"), count("verified"), count("failed"));
let mut st = shared.state.lock().unwrap();
let changed = st.proving.verifier_mode != mode;
st.proving.verifier = report;
st.proving.verifier_mode = mode.into();
st.proving.pool_entries = entries;
st.proving.pool_verified = verified;
st.proving.pool_failed = failed;
let (set, reason) = (st.proving.verifier_set.clone(), st.proving.verifier_reason.clone());
st.proving.verifier_note = crate::verifier::note(mode, &set, &reason, external);
drop(st);
if changed {
shared.log(&format!("node proof verifier reported: {mode}{}", if mode == "off" { " (this node relays proof records and never includes them)" } else { "" }));
}
}
Err(_) => {}
}
}
/// Runs the host or the exporter: directly, or through WSL on Windows. Output goes to `log`; the child is polled
/// every second and killed when the app quits, the setting goes off or `limit` passes (a proof must never outlive
/// the app). Returns (exit ok, output).
fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration, log: &Path) -> (bool, String) {
let mut cmd = if t.wsl {
let mut c = Command::new(crate::platform::tool("wsl"));
let envs: String = env.iter().map(|(k, v)| format!("{k}={v} ")).collect();
let line = format!("{envs}{} {}", exe.display(), args.iter().map(|a| format!("'{a}'")).collect::<Vec<_>>().join(" "));
c.args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", &line]);
c
// Windows: a script file exports the environment and execs the host; the arguments travel as $1, $2... (the
// single-quoted rule of src/wslhost.rs). The file lives until the run ends.
let (mut cmd, _script) = if t.wsl {
let mut body: String = env.iter().map(|(k, v)| format!("export {k}={}\n", crate::wslhost::sq(v))).collect();
body.push_str(&format!("exec {} \"$@\"", crate::wslhost::sq(&exe.display().to_string())));
let file = match crate::wslhost::write_script("prove-run", &body) {
Ok(f) => f,
Err(e) => return (false, format!("cannot write the WSL run script: {e}")),
};
let argv: Vec<&str> = args.iter().map(|a| a.as_str()).collect();
let c = crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &argv);
(c, Some(file))
} else {
let mut c = Command::new(exe);
c.args(args);
for (k, v) in env {
c.env(k, v);
}
c
(c, None)
};
crate::platform::quiet(&mut cmd);
let Ok(file) = std::fs::File::create(log) else { return (false, format!("cannot write {}", log.display())) };
@ -259,16 +302,23 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
let mut tools: Option<Tools> = None;
let mut last_probe = Instant::now() - Duration::from_secs(600);
let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new();
let mut last_verifier_read = Instant::now() - Duration::from_secs(600);
let mut asked_restart = false;
loop {
std::thread::sleep(Duration::from_secs(10));
let enabled = shared.settings.lock().unwrap().prove;
let (synced, quitting) = {
let (synced, quitting, node_up) = {
let st = shared.state.lock().unwrap();
(st.node.synced, st.quitting)
(st.node.synced, st.quitting, matches!(st.node.state.as_str(), "syncing" | "synced"))
};
if quitting {
return;
}
// the node's verifier state, proving on or off: a relaying-only node must say so on the tile
if node_up && last_verifier_read.elapsed() >= Duration::from_secs(30) {
last_verifier_read = Instant::now();
read_verifier(&shared);
}
if !enabled {
set(&shared, |p| {
p.enabled = false;
@ -286,6 +336,13 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
p.setup_hint = String::new();
p.backend = if t.cuda { "cuda".into() } else { "cpu".into() };
});
// Windows: the node was started before the WSL2 host existed (Set up ran since), so it verifies
// nothing; one restart lets src/verifier.rs find the host through igneum-prove-verify.exe
let node_has_none = shared.state.lock().unwrap().proving.verifier_set.is_empty();
if t.wsl && node_has_none && !asked_restart {
asked_restart = true;
shared.send(crate::engine::Cmd::RestartNode("the WSL2 prover is installed now; the node restarts to verify proof records".into()));
}
tools = Some(t);
}
Err(e) => {
@ -464,9 +521,18 @@ pub fn setup(shared: &Shared) -> Result<Value, String> {
if !script.exists() {
return Err(format!("setup script missing: {}", script.display()));
}
let line = format!("bash {}", wsl_path(&script));
// cmd's `start` opens the window; the tail after `--` follows the single-quoted rule of src/wslhost.rs (the
// payload path has a space) and goes on the line as written
let line = format!("/c start \"\" {} -d {} -- {}", crate::platform::tool("wsl").display(), crate::wslhost::DISTRO, crate::wslhost::bash_line(&script, true, &[]));
let mut c = Command::new(crate::platform::tool("cmd"));
c.args(["/c", "start", "", &crate::platform::tool("wsl").display().to_string(), "-d", "Ubuntu-24.04", "--", "bash", "-lc", &line]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.raw_arg(&line);
}
#[cfg(not(windows))]
c.arg(&line);
crate::platform::quiet(&mut c); // cmd itself hidden; `start` still opens the setup's own window
c.spawn().map_err(|e| e.to_string())?;
shared.event("proving", "WSL2 prover setup started in its own window");
Ok(json!({ "ok": true }))
@ -499,8 +565,10 @@ mod tests {
}
#[test]
fn wsl_paths() {
assert_eq!(wsl_path(Path::new("C:\\Users\\josh\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/josh/AppData/Local/igneum/app/proving/seq.json");
assert_eq!(wsl_path(Path::new("/tmp/x")), "/tmp/x");
fn the_probe_message_names_every_path_it_looked_at() {
let m = probe_message(Path::new("C:\\Igneum"), true, true);
assert!(m.starts_with("proving needs the WSL2 setup, 20 minutes, Set up (no igneum-prove-host at /mnt/c/Igneum/wsl2/bin/igneum-prove-host, ~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host, ~/igneum-prove/target/release/igneum-prove-host, /opt/igneum/igneum-prove-host)"), "{m}");
assert!(probe_message(Path::new("C:\\Igneum"), true, false).contains("setup script missing from the payload"));
assert!(probe_message(Path::new("C:\\Igneum"), false, true).contains("Ubuntu-24.04 did not answer"));
}
}

View file

@ -258,7 +258,8 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
let address = s("address");
let display_name = s("display_name");
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee)
let proof_verify_trust = body.get("proof_verify_trust").and_then(|v| v.as_bool());
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust)
}
"/api/prove" => shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
"/api/prove/setup" => crate::prover::setup(shared),

View file

@ -147,6 +147,21 @@ pub struct ProvingState {
pub last_prove_s: f64,
pub last_paid: String,
pub message: String,
// the node's proof verifier (src/verifier.rs; spec 7.7 item 4): a node without one relays and never includes
/// the node's own report, `igneum_getProvingStatus().verifier` (`Off`, `Trust`, `Command("...")`); empty until read
pub verifier: String,
/// off | trust | command | unknown, from the report
pub verifier_mode: String,
/// what the app passed its node: `command:<path>`, `trust`, or empty when it set nothing
pub verifier_set: String,
/// why the app set nothing (the paths it looked at), or the trust warning
pub verifier_reason: String,
/// the sentence on the tile for the state above
pub verifier_note: String,
/// the node's proof pool: records held, verified, rejected
pub pool_entries: u64,
pub pool_verified: u64,
pub pool_failed: u64,
}
#[derive(Clone, Serialize, Default)]
@ -194,6 +209,8 @@ pub struct SettingsState {
pub sweep: bool,
/// the miner software's dev fee switch (settings; `--dev-fee 0` when off)
pub dev_fee: bool,
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
pub proof_verify_trust: bool,
}
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.

View file

@ -0,0 +1,174 @@
//! The proof verifier the engine gives its node (spec 7.7 item 4, docs/plans/release-0.3.6.md item 1).
//!
//! The node keeps a proof pool and offers only verified records to its block templates; without a verifier it
//! relays and stores records and never includes one, so proofs are never paid. The node reads two variables
//! (`VerifyMode::from_env` in the node's exec/src/proving.rs): `IGNEUM_PROOF_VERIFIER=<exe>` runs
//! `<exe> --mode verify --proof <file> --statement 0x..` per proof; `IGNEUM_PROOF_VERIFY=trust` treats every
//! record as verified. This module decides which, once per node start:
//!
//! macOS, Linux `igneum-prove-host` next to the engine's binaries (the DMG ships it in Contents/Resources/bin)
//! Windows `igneum-prove-verify.exe` next to the engine (src/bin/prove-verify.rs), which runs the host
//! inside WSL2; it is set only when its `--probe` finds a host, so a node never gets a verifier
//! that cannot run
//! trust never by default; only the setting `proof_verify_trust` (shown as "devnet only") and only
//! when no verifier was found, so a real verifier always wins over trust
//!
//! What was decided is on the proving tile and in `/api/state` (`proving.verifier_set`, `proving.verifier_reason`);
//! the node's own report (`igneum_getProvingStatus().verifier`) is polled beside it (src/prover.rs).
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// What the engine passes to the node.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Verifier {
/// "command" | "trust" | "off"
pub mode: &'static str,
/// the environment for the node spawn (empty when off)
pub env: Vec<(String, String)>,
/// for the tile: the verifier path, or why there is none
pub detail: String,
}
impl Verifier {
/// `/api/state` `proving.verifier_set`: `command:<path>`, `trust`, or empty.
pub fn set_text(&self) -> String {
match self.mode {
"command" => format!("command:{}", self.detail),
"trust" => "trust".into(),
_ => String::new(),
}
}
}
/// How long the Windows probe may take: WSL's first start of the day can take several seconds.
const PROBE_LIMIT: Duration = Duration::from_secs(45);
/// Decides the verifier for one node start. `trust` is the `proof_verify_trust` setting.
pub fn resolve(bin_dir: &Path, trust: bool) -> Verifier {
let found = find(bin_dir);
match found {
Ok(path) => Verifier { mode: "command", env: vec![("IGNEUM_PROOF_VERIFIER".into(), path.display().to_string())], detail: path.display().to_string() },
Err(reason) if trust => Verifier { mode: "trust", env: vec![("IGNEUM_PROOF_VERIFY".into(), "trust".into())], detail: format!("devnet only: records are trusted without verification ({reason})") },
Err(reason) => Verifier { mode: "off", env: vec![], detail: reason },
}
}
/// The verifier executable, or why there is none.
fn find(bin_dir: &Path) -> Result<PathBuf, String> {
if cfg!(windows) {
let wrapper = bin_dir.join("igneum-prove-verify.exe");
if !wrapper.exists() {
return Err(format!("igneum-prove-verify.exe is not next to the engine ({})", bin_dir.display()));
}
let out = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&wrapper)).arg("--probe"), None, PROBE_LIMIT);
match out {
Some(text) => match text.lines().find(|l| l.starts_with("HOST ")) {
Some(_) => Ok(wrapper),
None => Err(format!("the WSL2 prover is not installed (looked at {}); Set up on the Proving tile installs it", crate::wslhost::candidates_text(bin_dir))),
},
None => Err(format!("igneum-prove-verify.exe --probe did not answer within {} s (is WSL2 with {} installed?)", PROBE_LIMIT.as_secs(), crate::wslhost::DISTRO)),
}
} else {
let host = bin_dir.join("igneum-prove-host");
if host.exists() {
Ok(host)
} else {
Err(format!("igneum-prove-host is not next to the engine ({})", bin_dir.display()))
}
}
}
/// The node's `igneum_getProvingStatus().verifier` text (`Off`, `Trust`, `Command("...")`) as a word.
pub fn mode_of_report(report: &str) -> &'static str {
let r = report.trim();
if r.eq_ignore_ascii_case("off") {
"off"
} else if r.eq_ignore_ascii_case("trust") {
"trust"
} else if r.starts_with("Command") {
"command"
} else {
"unknown"
}
}
/// The sentence on the proving tile for the node's reported mode and what the app set. `external` = the app did
/// not start this node.
pub fn note(report_mode: &str, set: &str, reason: &str, external: bool) -> String {
match report_mode {
"command" => "This node verifies proof records with igneum-prove-host and includes the verified ones in its blocks.".into(),
"trust" => "Devnet only: this node trusts proof records without verifying them and includes them in its blocks.".into(),
"off" => {
let why = if external {
"the app did not start this node, so it set no verifier".to_string()
} else if !set.is_empty() {
format!("the app set a verifier ({set}) but the node reports none; an older node build, or it has not restarted since")
} else if reason.is_empty() {
"no verifier was set".to_string()
} else {
reason.to_string()
};
format!("This node relays proofs but does not verify them, so it never includes a proof record in its blocks: {why}.")
}
_ => {
if set.is_empty() && !reason.is_empty() && !external {
format!("Verifier state not read yet. The app set no verifier: {reason}.")
} else {
"Verifier state not read yet (the node has not answered).".into()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_never_trusts_by_default_and_names_the_missing_host() {
let dir = std::env::temp_dir().join(format!("igneum-verifier-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let v = resolve(&dir, false);
assert_eq!(v.mode, "off");
assert!(v.env.is_empty());
assert!(v.detail.contains("is not next to the engine"), "{}", v.detail);
assert_eq!(v.set_text(), "");
let v = resolve(&dir, true);
assert_eq!(v.mode, "trust");
assert_eq!(v.env, vec![("IGNEUM_PROOF_VERIFY".to_string(), "trust".to_string())]);
assert!(v.detail.starts_with("devnet only"));
assert_eq!(v.set_text(), "trust");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(not(windows))]
#[test]
fn a_host_next_to_the_engine_wins_over_trust() {
let dir = std::env::temp_dir().join(format!("igneum-verifier-host-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("igneum-prove-host"), "#!/bin/sh\nexit 0\n").unwrap();
let v = resolve(&dir, true);
assert_eq!(v.mode, "command");
assert_eq!(v.env.len(), 1);
assert_eq!(v.env[0].0, "IGNEUM_PROOF_VERIFIER");
assert!(v.env[0].1.ends_with("igneum-prove-host"));
assert!(v.set_text().starts_with("command:"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn report_modes_and_notes() {
assert_eq!(mode_of_report("Off"), "off");
assert_eq!(mode_of_report("Trust"), "trust");
assert_eq!(mode_of_report("Command(\"/x/igneum-prove-host\")"), "command");
assert_eq!(mode_of_report(""), "unknown");
assert!(note("off", "", "igneum-prove-host is not next to the engine (/x)", false).ends_with("blocks: igneum-prove-host is not next to the engine (/x)."));
assert!(note("off", "", "", true).contains("the app did not start this node"));
assert!(note("off", "command:/x", "", false).contains("older node build"));
assert!(note("command", "command:/x", "", false).starts_with("This node verifies"));
assert!(note("trust", "trust", "", false).starts_with("Devnet only"));
assert!(note("unknown", "", "", false).starts_with("Verifier state not read yet"));
}
}

View file

@ -0,0 +1,251 @@
//! Where the Linux `igneum-prove-host` lives as seen from inside WSL2 (Ubuntu-24.04) on a PC. One list, used by
//! three callers: the prover's probe (src/prover.rs), the verifier lookup the node spawn uses (src/verifier.rs)
//! and the Windows wrapper `igneum-prove-verify.exe` (src/bin/prove-verify.rs, which includes this file by path
//! because the package has no library target).
//!
//! Lookup order, first executable wins:
//! 1. the payload's `wsl2\bin\igneum-prove-host` next to the engine (`/mnt/<drive>/.../wsl2/bin/...` from Ubuntu)
//! 2. `~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host`, what setup-wsl.sh builds
//! (it copies the package to `$HOME/igneum-prove` and builds in `proving/igneum-prove`)
//! 3. `~/igneum-prove/target/release/igneum-prove-host`, the layout before 5 October 2026
//! 4. `/opt/igneum/igneum-prove-host`, a hand install (the devnet jobs put the CUDA host there)
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
/// The WSL distribution the host runs in.
pub const DISTRO: &str = "Ubuntu-24.04";
/// A Windows path as WSL sees it: `C:\Users\x\f` -> `/mnt/c/Users/x/f`. A path without a drive letter is
/// returned with forward slashes only.
pub fn wsl_path(p: &Path) -> String {
let s = p.display().to_string().replace('\\', "/");
let s = s.strip_prefix("//?/").map(|x| x.to_string()).unwrap_or(s);
if s.len() > 2 && s.as_bytes()[1] == b':' {
format!("/mnt/{}{}", s[..1].to_ascii_lowercase(), &s[2..])
} else {
s
}
}
/// The candidates in lookup order. `bin_dir` is the engine's folder on Windows (the payload root); `~` is left
/// for the shell inside WSL to expand, so the list reads the same in a message.
pub fn candidates(bin_dir: &Path) -> Vec<String> {
vec![
wsl_path(&bin_dir.join("wsl2").join("bin").join("igneum-prove-host")),
"~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host".to_string(),
"~/igneum-prove/target/release/igneum-prove-host".to_string(),
"/opt/igneum/igneum-prove-host".to_string(),
]
}
/// The candidates as one line for a message.
pub fn candidates_text(bin_dir: &Path) -> String {
candidates(bin_dir).join(", ")
}
/// A `bash -lc` script that prints the first executable candidate (its path, one line) and nothing when there is
/// none. `~` expands in the shell; the shipped path is quoted.
pub fn lookup_script(bin_dir: &Path) -> String {
let list: Vec<String> = candidates(bin_dir).into_iter().map(|c| if c.starts_with('~') { c } else { format!("'{}'", c.replace('\'', "'\\''")) }).collect();
format!("for f in {}; do [ -x \"$f\" ] && {{ echo \"$f\"; break; }}; done", list.join(" "))
}
// ---- scripts run from a file, never inline on the command line ------------------------------------------------------
//
// 5 October 2026, PC 2 on 0.3.5: `wsl -d Ubuntu-24.04 -- bash -lc "<script>"` with `[ -x "$f" ]` and a path with a
// space ("Igneum Miner") printed nothing, so the app said "proving needs the WSL2 setup" while /opt/igneum held the
// host; the same script written to a file and run as `bash /mnt/c/.../probe.sh` found it. Rust's Command wraps the
// argument in double quotes and escapes the inner ones with backslashes; wsl.exe hands the line to the shell inside
// the distribution, which reads it differently. The rule from here: every script goes to a file (UTF-8, LF, no BOM),
// the command line after `--` is `bash [-l] '<file>' '<arg>'...` with every word single-quoted and never a double
// quote or a newline, and on Windows that tail is placed on the command line as written (CommandExt::raw_arg), so
// neither the C runtime's quoting nor the shell's re-reading can change it. Arguments reach the script as $1, $2...
/// Where the script files go: `%LOCALAPPDATA%\igneum\wsl` on Windows (`IGNEUM_APP_DATA` first, as the engine's data
/// root); a temp folder elsewhere (tests).
pub fn script_dir() -> PathBuf {
if let Some(p) = std::env::var_os("IGNEUM_APP_DATA") {
return PathBuf::from(p).join("wsl");
}
#[cfg(windows)]
{
std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir())
.join("igneum")
.join("wsl")
}
#[cfg(not(windows))]
{
std::env::temp_dir().join("igneum-wsl")
}
}
/// Single-quoted for a POSIX shell: `a b` becomes `'a b'`, a quote inside becomes `'\''`.
pub fn sq(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
/// A script file written for `bash <file>`; removed when dropped unless `keep()` was called.
pub struct ScriptFile {
pub path: PathBuf,
keep: bool,
}
impl ScriptFile {
/// Leaves the file in place (a run that outlives the engine, such as the setup window).
pub fn keep(mut self) -> PathBuf {
self.keep = true;
self.path.clone()
}
/// The file as the distribution sees it.
pub fn wsl_path(&self) -> String {
wsl_path(&self.path)
}
}
impl Drop for ScriptFile {
fn drop(&mut self) {
if !self.keep {
let _ = std::fs::remove_file(&self.path);
}
}
}
static SCRIPT_SEQ: AtomicU64 = AtomicU64::new(0);
/// Writes `body` as `<script_dir>/<stem>-<pid>-<n>.sh`: a `#!/bin/bash` first line, UTF-8, LF line endings, no BOM,
/// a final newline. The name is unique per process and call, so two runs never share a file.
pub fn write_script(stem: &str, body: &str) -> std::io::Result<ScriptFile> {
let dir = script_dir();
std::fs::create_dir_all(&dir)?;
let n = SCRIPT_SEQ.fetch_add(1, Ordering::Relaxed);
let path = dir.join(format!("{stem}-{}-{n}.sh", std::process::id()));
std::fs::write(&path, script_text(body).as_bytes())?;
Ok(ScriptFile { path, keep: false })
}
/// The bytes a script file holds: the shebang line, the body with LF endings, one final newline.
pub fn script_text(body: &str) -> String {
let body = body.replace("\r\n", "\n").replace('\r', "\n");
let body = body.trim_start_matches('\u{feff}');
format!("#!/bin/bash\n{}\n", body.trim_end_matches('\n'))
}
/// The words after `--`: `bash [-l] '<wsl path of file>' '<arg>'...`, every word single-quoted. No double quote and no
/// newline can appear, whatever the paths and arguments hold (a newline inside an argument is replaced by a space).
pub fn bash_line(file: &Path, login: bool, args: &[&str]) -> String {
let mut words = vec!["bash".to_string()];
if login {
words.push("-l".to_string());
}
words.push(sq(&wsl_path(file)));
for a in args {
words.push(sq(&a.replace(['\r', '\n'], " ")));
}
words.join(" ")
}
/// `wsl.exe -d <distro> [-u <user>] -- bash [-l] '<file>' '<arg>'...`. On Windows the tail after `--` goes on the
/// command line exactly as `bash_line` wrote it; elsewhere the words are ordinary arguments (nothing runs wsl there).
/// The caller adds stdio, the hidden-window flag and the timeout.
pub fn command(wsl_exe: &Path, distro: &str, user: Option<&str>, file: &Path, login: bool, args: &[&str]) -> Command {
let mut c = Command::new(wsl_exe);
c.args(["-d", distro]);
if let Some(u) = user.filter(|u| !u.is_empty()) {
c.args(["-u", u]);
}
c.arg("--");
let line = bash_line(file, login, args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
c.raw_arg(line);
}
#[cfg(not(windows))]
{
let _ = line;
c.arg("bash");
if login {
c.arg("-l");
}
c.arg(wsl_path(file));
c.args(args);
}
c
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_command_line_after_the_dashes_never_carries_a_double_quote_or_a_newline() {
let file = Path::new("C:\\Users\\Josh Malone\\AppData\\Local\\igneum\\wsl\\probe-12-3.sh");
let line = bash_line(file, true, &["--proof", "/mnt/c/Users/Josh Malone/AppData/Local/igneum/app/proving/p.bin", "--statement", "0xab", "it's", "two\nlines"]);
assert_eq!(
line,
"bash -l '/mnt/c/Users/Josh Malone/AppData/Local/igneum/wsl/probe-12-3.sh' '--proof' '/mnt/c/Users/Josh Malone/AppData/Local/igneum/app/proving/p.bin' '--statement' '0xab' 'it'\\''s' 'two lines'"
);
assert!(!line.contains('"') && !line.contains('\n'), "{line}");
// the lookup script's own double quotes live in the file, never on the line
let body = format!("{}\ncommand -v nvidia-smi >/dev/null && echo cuda", lookup_script(Path::new("C:\\Program Files\\Igneum Miner")));
assert!(body.contains('"'));
assert!(!bash_line(file, false, &[]).contains('"'));
}
#[test]
fn script_files_are_lf_utf8_without_bom_and_removed_after_use() {
let text = script_text("\u{feff}echo \"$1\"\r\nfor f in '/mnt/c/Program Files/x'; do [ -x \"$f\" ] && echo \"$f\"; done\r\n");
assert_eq!(text, "#!/bin/bash\necho \"$1\"\nfor f in '/mnt/c/Program Files/x'; do [ -x \"$f\" ] && echo \"$f\"; done\n");
assert!(!text.contains('\r') && !text.starts_with('\u{feff}'));
let f = write_script("unit", "echo \"$1\"").unwrap();
let bytes = std::fs::read(&f.path).unwrap();
assert!(bytes.starts_with(b"#!/bin/bash\necho \"$1\"\n"), "{:?}", String::from_utf8_lossy(&bytes));
assert!(!bytes.contains(&b'\r') && !bytes.starts_with(&[0xef, 0xbb, 0xbf]));
let path = f.path.clone();
drop(f);
assert!(!path.exists());
let kept = write_script("unit-keep", "true").unwrap().keep();
assert!(kept.exists());
let _ = std::fs::remove_file(kept);
}
#[test]
fn shell_quoting() {
assert_eq!(sq("a b"), "'a b'");
assert_eq!(sq("it's"), "'it'\\''s'");
assert_eq!(sq(""), "''");
}
#[test]
fn candidate_order_is_shipped_then_setup_build_then_old_layout_then_opt() {
let c = candidates(Path::new("C:\\Program Files\\Igneum Miner"));
assert_eq!(
c,
vec![
"/mnt/c/Program Files/Igneum Miner/wsl2/bin/igneum-prove-host",
"~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host",
"~/igneum-prove/target/release/igneum-prove-host",
"/opt/igneum/igneum-prove-host",
]
);
}
#[test]
fn lookup_script_quotes_the_shipped_path_and_leaves_tilde_to_the_shell() {
let s = lookup_script(Path::new("C:\\Program Files\\Igneum Miner"));
assert!(s.starts_with("for f in '/mnt/c/Program Files/Igneum Miner/wsl2/bin/igneum-prove-host' ~/igneum-prove/proving/"), "{s}");
assert!(s.contains("'/opt/igneum/igneum-prove-host'"));
assert!(s.ends_with("[ -x \"$f\" ] && { echo \"$f\"; break; }; done"));
}
#[test]
fn wsl_paths() {
assert_eq!(wsl_path(Path::new("C:\\Users\\josh\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/josh/AppData/Local/igneum/app/proving/seq.json");
assert_eq!(wsl_path(Path::new("\\\\?\\D:\\x")), "/mnt/d/x");
assert_eq!(wsl_path(Path::new("/tmp/x")), "/tmp/x");
}
}

View file

@ -78,18 +78,23 @@ body.mac .top{padding-left:92px}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}}
@media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}}
/* banners under the top bar: clock, update, job */
.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:var(--ember-12);border-bottom:1px solid var(--ember-40);font-size:var(--t-md);line-height:1.4}
.banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap}
.banner.clock.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
.banner .hint{font-size:var(--t-xs);color:var(--ash);flex-basis:100%;text-align:center}
.banner.update{flex-wrap:wrap;row-gap:var(--s-2)}
.banner.update.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
.banner .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-2px}
.banner .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
.banner.job{flex-wrap:wrap;row-gap:var(--s-2);background:var(--molten-10);border-bottom-color:var(--molten-40)}
.banner.job.failed{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5)}
.banner.job .job-results{flex-basis:100%;margin:0;font-size:var(--t-xs);line-height:1.5;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto}
/* the notice strip under the top bar (app.js, Notices): one notice at a time. It takes no room while empty; main's
top moves once per change with a 150 ms transition (layoutStrip), never per poll. Tones: default molten (running,
available, done), bad ember (failed, clock block, urgent). Nothing here is newer than 2022 CSS (WebView2 host). */
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:9px calc(var(--gutter) - 6px) 9px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-md);line-height:1.4;color:var(--bone)}
.notice.bad{background:var(--ember-12);border-bottom-color:var(--ember-40)}
.notice.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
.notice.update-urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
.notice-text{flex:1 1 320px;min-width:0}
.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
.notice-actions:empty{display:none}
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
.notice.update-urgent .notice-close{color:#fff}
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
.notice .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-3px}
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
.job-history table{margin-top:var(--s-2)}
.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0}
.job-history td{padding:5px 6px 5px 0;border-top:1px solid var(--line);vertical-align:top}
@ -101,7 +106,8 @@ body.mac .top{padding-left:92px}
.clock-card .note{margin-top:0}
/* screens */
main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain}
main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
@media (prefers-reduced-motion:reduce){main{transition:none}}
body.has-bottom main{bottom:var(--bottom)}
body.drawer-open main{bottom:calc(var(--bottom) + var(--drawer-h))}
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
@ -281,6 +287,7 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--
.kv>div:last-child{border-bottom:0}
.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
.kv .v.warn{color:var(--ember)}
.card .note{margin-top:10px}
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:300px;overflow:auto}
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline}

View file

@ -1,7 +1,120 @@
/* Igneum Miner dashboard. Talks to the engine on the same origin: api/state every second, api/log while the
drawer is open. Screens: welcome, cards, address (plus the one-time key sheet), dashboard. ?screen=<name> forces
a screen (screenshots). Everything is relative to the token path the page was opened on. */
(function () {
/* ---------- notices: what the one strip under the header says (pure; notices.test.mjs loads this block) ----------
The state (src/state.rs) becomes at most one notice at a time, the most important first. Each notice has a key;
closing it hides that key until the state moves on (a new status, version or job). Levels, lowest number first:
0 update installing, urgent or failed 1 job failed 2 clock 3 job running
4 update available, downloading, ready, waiting for permission, manual 5 job done 6 updated
Rules: a done job goes after 5 minutes; a failed job stays until closed; "updated" goes 60 s after the new
version started; the clock notice is for the setup screens (the node card carries it on the dashboard); the job
notice is for the dashboard. */
var Notices = (function () {
'use strict';
var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60;
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; }
function endDot(t) { t = (t || '').trim(); return !t || /[.!?]$/.test(t) ? t : t + '.'; }
function notice(kind, key, text, extra) {
var n = { kind: kind, level: LEVEL[kind], key: key, text: text, tone: '', detail: '', actions: [], progress: -1, title: '' };
if (extra) for (var k in extra) n[k] = extra[k];
return n;
}
var INSTALL = { act: 'install', label: 'Install now', primary: true }, LATER = { act: 'close', label: 'Later' };
var RETRY = { act: 'retry', label: 'Try again', primary: true }, OPEN = { act: 'open', label: 'Open the download', primary: true };
// u = state.update; s = { version, uptime_s, quitting }
function updateNotice(u, s) {
if (!u) return null;
s = s || {};
var ver = u.version || '', v = 'Igneum Miner ' + ver, pct = u.progress > 0 ? Math.round(u.progress * 100) : 0;
if (u.status === 'error') {
if (/no update manifest configured/.test(u.error || '')) return null; // a build without a manifest: nothing to try, Settings says so
var text = u.rolled_back ? (v + ' did not stay up and was rolled back.') : ('The update' + (ver ? ' to ' + ver : '') + ' failed.');
return notice('update-failed', 'update:error:' + ver, text, { tone: 'bad', detail: firstLine(u.error), actions: [RETRY], title: u.error || '' });
}
if (u.applying || u.status === 'applying' || (u.status === 'ready' && u.wait === 'installing now')) {
var t = s.quitting ? 'Installing ' + v + '. The app restarts itself in a moment.' : 'Installing ' + v + '. The app restarts itself. Mining continues until then.';
return notice('update-installing', 'update:applying:' + ver, t, { title: u.wait || '' });
}
if (u.urgent && u.urgent_text) {
var dl = u.status === 'downloading';
return notice('update-urgent', 'update:urgent:' + ver, u.urgent_text + (dl ? ' Downloading' + (pct ? ': ' + pct + '%' : '') + '.' : ''), { tone: 'bad', progress: dl ? (u.progress || 0) : -1 });
}
switch (u.status) {
case 'available': return notice('update-available', 'update:' + ver + ':pending', v + ' is available.', { actions: [INSTALL, LATER] });
case 'downloading': return notice('update-available', 'update:' + ver + ':pending', 'Downloading ' + v + (pct ? ': ' + pct + '%' : '') + '.', { progress: u.progress || 0, actions: [INSTALL, LATER], title: u.size ? Math.round(u.size / 1e6) + ' MB' : '' });
case 'staging': return notice('update-available', 'update:' + ver + ':pending', 'Checking ' + v + '.', { actions: [LATER] });
case 'ready':
var why = /failed to install before/.test(u.wait || '') ? ' It failed to install before.' : u.auto ? ' It installs by itself at a quiet moment.' : '';
return notice('update-available', 'update:' + ver + ':ready', v + ' is ready.' + why, { actions: [INSTALL, LATER], title: u.wait || '' });
case 'deferred': return notice('update-available', 'update:' + ver + ':deferred', v + ' is waiting for permission. It installs the next time someone is at this PC. Mining continues.', { actions: [INSTALL, LATER] });
case 'manual': return notice('update-available', 'update:' + ver + ':manual', v + ' is downloaded. Open it and drag the app over the old one.', { actions: [OPEN, LATER], title: u.wait || '' });
}
if (u.updated_from && s.uptime_s >= 0 && s.uptime_s < INSTALLED_S) {
var now = s.version || ver;
return notice('update-installed', 'update:installed:' + now, 'Updated to Igneum Miner ' + now + ' from ' + u.updated_from + '.');
}
return null;
}
function jobTitle(j) { return j.title && j.title !== j.kind ? j.title : (j.kind === 'shard-benchmark' ? 'shard benchmark' : j.kind === 'build' ? 'build (mining continues)' : j.kind); }
function firstErrorLine(results, summary) {
for (var i = 0; i < results.length; i++) if (/BUILD FAILED|error|failed|panic/i.test(results[i])) return firstLine(results[i]);
return firstLine(summary);
}
// j = state.jobs; now = state.now (unix seconds)
function jobNotice(j, now) {
if (!j) return null;
if (j.active) {
var m = Math.max(0, Math.floor((now - j.started_at) / 60));
var results = (j.results || []).filter(function (x) { return x.indexOf('RESULT') === 0; });
return notice('job-running', 'job:run:' + j.id, 'Job: ' + jobTitle(j) + ' running, ' + m + ' min.' + (j.stage ? ' ' + endDot(cap(j.stage)) : ''), { detail: results.length ? results[results.length - 1] : '', title: j.message || '' });
}
var l = j.last;
if (!l || !l.id) return null;
var mins = Math.max(0, Math.round((l.finished_at - l.started_at) / 60)), t = l.title || l.kind;
var report = l.uploaded ? 'Report uploaded.' : 'Report not uploaded.';
if (l.status === 'done') {
if (l.finished_at && now - l.finished_at > JOB_DONE_S) return null;
return notice('job-done', 'job:done:' + l.id, 'Job: ' + t + ' done after ' + mins + ' min. ' + report, { title: l.summary || '' });
}
var word = l.status === 'timeout' ? 'hit its time cap' : l.status === 'aborted' ? 'was stopped' : 'failed';
return notice('job-failed', 'job:failed:' + l.id, 'Job: ' + t + ' ' + word + ' after ' + mins + ' min, exit ' + l.exit + '. ' + report, { tone: 'bad', detail: firstErrorLine(j.last_results || [], l.summary), title: l.summary || '' });
}
// c = state.clock: the engine's words, Sync clock, the manual hint underneath
function clockNotice(c) {
if (!c || !(c.severity === 'block' || c.severity === 'warn')) return null;
return notice('clock', 'clock:' + c.severity, c.message || '', { tone: c.severity === 'block' ? 'bad' : 'warn', detail: c.hint || '', actions: [{ act: 'sync', label: c.syncing ? 'Syncing' : 'Sync clock', primary: true, disabled: !!c.syncing }] });
}
// every notice the state carries for this screen; pick() chooses the one to show
function gather(s, where) {
where = where || {};
var out = [], u = updateNotice(s.update, s);
if (u) out.push(u);
if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); }
else { var c = clockNotice(s.clock); if (c) out.push(c); }
return out;
}
// the lowest level wins; a dismissed key is skipped, so the next one shows in its place
function pick(list, dismissed) {
var best = null;
for (var i = 0; i < list.length; i++) {
var n = list[i];
if (!n || (dismissed && dismissed[n.key])) continue;
if (!best || n.level < best.level) best = n;
}
return best;
}
return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot };
})();
if (typeof module === 'object' && module && module.exports) module.exports = Notices;
if (typeof document !== 'undefined') (function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@ -10,6 +123,7 @@
var params = new URLSearchParams(location.search);
if (params.get('screen')) forced = params.get('screen');
var forcedUpdate = params.get('update'); // a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated
var forcedJob = params.get('job'); // a sample remote job for screenshots: running|done|failed
if (params.get('host') === 'mac') document.body.classList.add('mac');
// ?debug=1 prints a budget line every 5 s (ms of JS per second in the canvas, the state render and the log) and
// exposes the counters as window.__igneumPerf
@ -171,11 +285,8 @@
$('s-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); });
$('s-auto-update').addEventListener('change', function () { api('api/update/auto', { on: this.checked }); });
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
$('update-install').addEventListener('click', function () { api('api/update/install', {}); toast('Installing at once'); });
$('update-open').addEventListener('click', function () { api('api/update/open', {}); });
$('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = (state && state.update) ? (state.update.status + ':' + state.update.version) : '1'; layoutBanners(); });
$('job-hide').addEventListener('click', function () { $('job-banner').hidden = true; $('job-banner').dataset.dismissed = jobKey(state && state.jobs); layoutBanners(); });
$('s-prove').addEventListener('change', function () { api('api/prove', { on: this.checked }).then(function (r) { if (r.ok) toast(r.ok && $('s-prove').checked ? 'Proving on; the first shard arrives within a minute' : 'Proving off'); }); });
$('s-trust').addEventListener('change', function () { var on = this.checked; api('api/settings', { proof_verify_trust: on }).then(function (r) { if (r.ok) toast(on ? 'Trust mode on (devnet only); the node restarts' : 'Trust mode off; the node restarts'); else { toast(r.error || 'could not change'); $('s-trust').checked = !on; } }); });
$('pv-setup').addEventListener('click', function () { api('api/prove/setup', {}).then(function (r) { toast(r.ok ? 'Setup started in its own window' : (r.error || 'could not start')); }); });
$('s-jobs-allow').addEventListener('change', function () { api('api/jobs/allow', { on: this.checked }); setTimeout(fillSettings, 800); });
$('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Sweep on: once after install, then weekly' : 'Sweep off'); }); });
@ -641,7 +752,14 @@
$('pv-submitted').textContent = String(pv.submitted || 0);
$('pv-paid').textContent = String(pv.paid || 0) + (pv.paid_wei ? ' (' + (Number(pv.paid_wei) / 1e18).toFixed(4) + ' IGN)' : '');
$('pv-note').textContent = pv.enabled ? (pv.message || '') : 'Off. Settings switches it on: this machine proves the shards the chain assigns to its keys.';
$('pv-setup-row').hidden = !(pv.enabled && !pv.available && pv.setup_hint);
// the node's proof verifier (spec 7.7 item 4): a node without one relays records and never includes them
var vm = pv.verifier_mode || 'unknown';
var vWord = { command: 'verifying', trust: 'trust (devnet only)', off: 'off: relay only', unknown: 'not read yet' }[vm] || vm;
$('pv-verifier').textContent = vWord + (vm === 'command' && pv.pool_entries ? ' · pool ' + pv.pool_verified + '/' + pv.pool_entries + (pv.pool_failed ? ', ' + pv.pool_failed + ' rejected' : '') : '');
$('pv-verifier').classList.toggle('warn', vm === 'off' || vm === 'trust');
$('pv-verifier-note').textContent = pv.verifier_note || '';
$('pv-verifier-note').hidden = !pv.verifier_note;
$('pv-setup-row').hidden = !((pv.enabled && !pv.available && pv.setup_hint) || (vm === 'off' && /WSL2 prover is not installed/.test(pv.verifier_reason || '')));
$('f-votes').textContent = withCommas(f.votes);
// events
var key = s.events.length ? s.events[0].t + ':' + s.events.length : '';
@ -672,14 +790,9 @@
else text = s.mining.state;
$('pill-text').textContent = text;
}
// the clock on the dashboard: the node card (the strip carries it on the setup screens, see Notices)
function renderClock(s) {
var c = s.clock || { severity: 'none' }, bad = c.severity === 'block', warn = c.severity === 'warn';
var banner = $('clock-banner');
banner.hidden = !(bad || warn) || phase === 'dashboard';
banner.classList.toggle('warn', warn);
$('clock-banner-text').textContent = c.message || '';
$('clock-banner-hint').textContent = c.hint || '';
$('clock-sync').disabled = !!c.syncing; $('clock-sync').textContent = c.syncing ? 'Syncing' : 'Sync clock';
var card = $('n-clock');
card.hidden = !(bad || warn);
card.classList.toggle('warn', warn);
@ -691,60 +804,65 @@
$('start-blocked').hidden = !bad;
$('start-blocked').textContent = bad ? c.message + ' Use "Sync clock" above.' : '';
$('btn-key-done').disabled = bad || !$('key-ack').checked;
layoutBanners();
}
// fixed banners push the content down by their height
function layoutBanners() {
var h = 0;
['clock-banner', 'update-banner', 'job-banner'].forEach(function (id) { var b = $(id); if (!b.hidden) { b.style.top = (60 + h) + 'px'; h += b.offsetHeight; } });
$('main').style.top = (60 + h) + 'px';
}
window.addEventListener('resize', layoutBanners);
$('clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
$('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
// ---------- over-the-air updates (src/ota.rs): one banner, the settings note ----------
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function updateLine(u) {
var v = 'Igneum Miner ' + u.version;
if (u.urgent && u.urgent_text) return { text: u.urgent_text + (u.status === 'downloading' ? ' Downloading.' : ''), urgent: true, prog: u.status === 'downloading' };
switch (u.status) {
case 'available': return { text: v + ' is available. Downloading it.' };
case 'downloading': return { text: 'Downloading ' + v + (u.size ? ' (' + Math.round(u.size / 1e6) + ' MB)' : '') + ': ' + Math.round((u.progress || 0) * 100) + '%', prog: true };
case 'staging': return { text: v + ' downloaded and verified. Preparing it.' };
case 'ready': return { text: v + ' is ready. ' + (u.wait ? cap(u.wait) + '.' : 'It installs at the next safe moment.'), install: true };
case 'applying': return { text: 'Installing ' + v + ': ' + (u.wait ? u.wait + '.' : 'the miners stop, then the node, then the app opens again.') };
case 'deferred': return { text: v + ' is downloaded. Windows asked for permission and nobody answered; it installs the next time someone is at this PC. Mining continues.', install: true };
case 'manual': return { text: v + ' is downloaded. ' + cap(u.wait || 'open the download and drag the app over the old one.'), open: true };
case 'error': return { text: 'Update: ' + (u.error || 'failed') + '.', install: !!(u.ready || u.downloaded) };
default: return null;
// ---------- the notice strip (Notices, top of this file): one notice, its actions, the close control ----------
// dismissed: the keys closed this session; a notice with a closed key stays hidden until its key changes
var dismissed = {}, noticeSig = '', stripH = 0;
function renderNotices(s) {
var n = Notices.pick(Notices.gather(s, { dashboard: phase === 'dashboard' }), dismissed);
var strip = $('notices'), el = $('notice');
if (!n) {
if (noticeSig) { noticeSig = ''; strip.hidden = true; layoutStrip(); }
return;
}
var sig = [n.key, n.kind, n.tone, n.text, n.detail, n.progress >= 0 ? 'p' : '', JSON.stringify(n.actions)].join('|');
if (sig !== noticeSig) {
noticeSig = sig;
el.className = 'notice ' + n.kind + (n.tone ? ' ' + n.tone : '');
el.dataset.key = n.key;
$('notice-text').textContent = n.text; $('notice-text').title = n.title || '';
$('notice-detail').textContent = n.detail; $('notice-detail').hidden = !n.detail; $('notice-detail').title = n.detail;
$('notice-actions').innerHTML = n.actions.map(function (a) { return '<button class="btn small ' + (a.primary ? 'primary' : 'ghost') + '" data-act="' + esc(a.act) + '"' + (a.disabled ? ' disabled' : '') + '>' + esc(a.label) + '</button>'; }).join('');
$('notice-prog').hidden = !(n.progress >= 0);
strip.hidden = false;
}
if (n.progress >= 0) $('notice-prog').firstElementChild.style.width = Math.round(n.progress * 100) + '%';
layoutStrip();
}
// main sits under the strip: its top is set once per height change and moves with the 150 ms transition in the
// CSS; an empty strip reserves nothing
function layoutStrip() {
var strip = $('notices'), h = strip.hidden ? 0 : strip.offsetHeight;
if (h === stripH) return;
stripH = h;
var top = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--top'), 10) || 60;
$('main').style.top = h ? (top + h) + 'px' : '';
}
window.addEventListener('resize', layoutStrip);
$('notice').addEventListener('click', function (e) {
var b = e.target.closest('[data-act]'); if (!b || b.disabled) return;
var act = b.dataset.act, key = $('notice').dataset.key;
if (act === 'close') { dismissed[key] = true; renderNotices(state || {}); }
else if (act === 'install') { api('api/update/install', {}); toast('Installing at once'); }
else if (act === 'retry') { api('api/update/install', {}); toast('Trying the update again'); }
else if (act === 'open') api('api/update/open', {});
else if (act === 'sync') api('api/clock/sync', {});
});
// ---------- over-the-air updates (src/ota.rs): the settings note; the strip is Notices.updateNotice ----------
function settingsUpdateNote(u) {
var l = updateLine(u), parts = [];
var n = Notices.updateNotice(u, state || {}), parts = [];
if (u.updated_from) parts.push('Updated from ' + u.updated_from + '.');
if (u.rolled_back) parts.push('Rolled back: ' + u.rolled_back + '.');
if (l && !(u.rolled_back && u.status === 'error')) parts.push(l.text);
if (n && n.kind !== 'update-installed' && !(u.rolled_back && u.status === 'error')) parts.push(n.text + (n.detail ? ' ' + Notices.endDot(n.detail) : ''));
else if (u.status === 'checking') parts.push('Checking.');
else if (u.status === 'current') parts.push('This is the latest version' + (u.checked_at ? ' (checked ' + rel(state.now - u.checked_at) + ')' : '') + '.');
else if (u.error) parts.push(u.error);
if (u.activation_height && !u.urgent) parts.push('Consensus upgrade at height ' + withCommas(u.activation_height) + '.');
return parts.join(' ');
}
function renderUpdate(s) {
var u = s.update, b = $('update-banner'), l = updateLine(u);
var key = u.status + ':' + u.version;
var show = !!l && (u.urgent || u.applying || b.dataset.dismissed !== key);
if (show) {
$('update-text').textContent = l.text;
b.classList.toggle('urgent', !!l.urgent);
$('update-install').hidden = !l.install || u.applying;
$('update-open').hidden = !l.open;
$('update-later').hidden = !!l.urgent || !!u.applying;
$('update-prog').hidden = !l.prog;
$('update-prog').firstElementChild.style.width = Math.round((u.progress || 0) * 100) + '%';
}
if (b.hidden === show) { b.hidden = !show; layoutBanners(); }
}
function sampleUpdate(kind) {
var u = { available: true, version: '0.3.1', notes: 'difficulty v2, OTA updates', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20588331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, error: '', updated_from: '', rolled_back: '' };
if (kind === 'available') { u.status = 'available'; u.downloaded = false; u.ready = false; u.progress = 0; }
@ -758,38 +876,19 @@
if (kind === 'updated') { u.status = 'current'; u.available = false; u.ready = false; u.downloaded = false; u.updated_from = '0.3.0'; u.version = '0.3.1'; }
return u;
}
// ---------- remote jobs (src/jobrun.rs): one strip while a job runs and after it, the settings block ----------
function jobKey(j) { if (!j) return ''; return j.active ? ('run:' + j.id) : (j.last && j.last.id ? ('done:' + j.last.id + ':' + j.last.status) : ''); }
function jobTitle(j) { return j.title && j.title !== j.kind ? j.title : (j.kind === 'shard-benchmark' ? 'shard benchmark' : j.kind === 'build' ? 'build (node and app, mining continues)' : j.kind); }
function jobLine(j, now) {
if (j.active) {
var m = Math.max(0, Math.floor((now - j.started_at) / 60));
return { text: 'Job: ' + jobTitle(j) + ' running, ' + m + ' min' + (j.stage ? '. ' + cap(j.stage) + '.' : '.') + (j.message ? ' ' + j.message : '') + (j.account ? ' ' + j.account : ''), results: j.results || [] };
}
var l = j.last;
if (!l || !l.id) return null;
// the last outcome stays up for 5 minutes when it succeeded, 30 minutes when it failed (hide dismisses it at once)
if (l.finished_at && now - l.finished_at > (l.status === 'done' ? 300 : 1800)) return null;
var d = Math.max(0, Math.round((l.finished_at - l.started_at) / 60));
return { text: 'Job: ' + (l.title || l.kind) + ' ' + l.status + ' after ' + d + ' min, exit ' + l.exit + (l.uploaded ? ', report uploaded' : ', report not uploaded') + (l.summary ? '. ' + l.summary : ''), results: j.last_results || [], failed: l.status !== 'done' };
}
function renderJobs(s) {
var j = s.jobs || {}, b = $('job-banner'), l = jobLine(j, s.now || 0);
var key = jobKey(j);
var show = !!l && b.dataset.dismissed !== key && phase === 'dashboard';
if (show) {
$('job-text').textContent = l.text;
b.classList.toggle('failed', !!l.failed);
var r = (l.results || []).filter(function (x) { return x.indexOf('RESULT') === 0; }).slice(-6);
$('job-results').textContent = r.join('\n');
$('job-results').hidden = !r.length;
}
if (b.hidden === show) { b.hidden = !show; layoutBanners(); }
else if (show) layoutBanners();
// ---------- remote jobs (src/jobrun.rs): the settings block; the strip is Notices.jobNotice ----------
var jobTitle = Notices.jobTitle;
function sampleJob(kind, now) {
var j = { allowed: true, key_fingerprint: '8f186e37', url_set: true, checked_at: now - 30, error: '', queued: 0, account: '', active: false, id: '', kind: '', title: '', stage: '', message: '', started_at: 0, run_id: '', results: [], last: {}, last_results: [], history: [] };
if (kind === 'running') { j.active = true; j.id = 'job-2026-10-05-03'; j.kind = 'shard-benchmark'; j.title = 'shard benchmark'; j.stage = 'proving shard 3 of 8'; j.started_at = now - 400; j.results = ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8']; }
else if (kind === 'done') { j.last = { id: 'job-2026-10-05-02', kind: 'shard-benchmark', title: 'shard benchmark', status: 'done', started_at: now - 800, finished_at: now - 60, exit: 0, run_id: 'r1', uploaded: true, summary: 'shard benchmark finished, exit 0; 8 RESULT lines, 1 result file uploaded' }; }
else if (kind === 'failed') { j.last = { id: 'job-2026-10-05-01', kind: 'build', title: 'build', status: 'failed', started_at: now - 2000, finished_at: now - 900, exit: 1, run_id: 'r0', uploaded: false, summary: 'build exited with code 1' }; j.last_results = ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo` in this scope (src/engine.rs:2082)']; }
return j;
}
function fillJobsSettings(j) {
$('s-jobs-allow').checked = !!j.allowed;
$('s-prove').checked = !!(state.settings && state.settings.prove);
$('s-trust').checked = !!(state.settings && state.settings.proof_verify_trust);
$('s-sweep').checked = !!(state.settings && state.settings.sweep);
$('s-jobs-key').textContent = j.key_fingerprint ? 'signing key sha256:' + j.key_fingerprint : '';
var parts = [];
@ -807,11 +906,11 @@
}
function render(s) {
state = s; stateAt = performance.now();
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') s.version = '0.3.1'; }
if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.1'; s.uptime_s = 20; } }
if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0);
renderPill(s);
renderClock(s);
renderUpdate(s);
renderJobs(s);
renderNotices(s);
if (phase === 'cards') renderCards(s);
if (phase === 'dashboard') renderDashboard(s);
if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; }

View file

@ -23,22 +23,17 @@
</div>
</header>
<div class="banner clock" id="clock-banner" hidden>
<span id="clock-banner-text"></span>
<button class="btn small primary" id="clock-sync">Sync clock</button>
<span class="hint mono" id="clock-banner-hint"></span>
</div>
<div class="banner update" id="update-banner" hidden>
<span id="update-text">A new version of Igneum Miner is ready.</span>
<button class="btn small primary" id="update-install" hidden>Install now</button>
<button class="btn small primary" id="update-open" hidden>Open the download</button>
<button class="btn small ghost" id="update-later">Later</button>
<span class="prog" id="update-prog" hidden><i></i></span>
</div>
<div class="banner job" id="job-banner" hidden>
<span id="job-text"></span>
<button class="btn small ghost" id="job-hide">Hide</button>
<pre class="job-results mono" id="job-results" hidden></pre>
<!-- the notice strip: one notice at a time (updates, remote jobs, the clock), the most important first; app.js fills
it from the state and the content below moves once when it appears or goes. The close control hides a notice
until its state moves on. -->
<div class="notices" id="notices" hidden>
<div class="notice" id="notice" role="status" aria-live="polite">
<span class="notice-text" id="notice-text"></span>
<span class="notice-actions" id="notice-actions"></span>
<button class="notice-close" id="notice-close" data-act="close" title="Close" aria-label="Close">&times;</button>
<span class="notice-detail mono" id="notice-detail" hidden></span>
<span class="prog" id="notice-prog" hidden><i></i></span>
</div>
</div>
<main id="main">
@ -208,8 +203,10 @@
<div><span class="k">assigned</span><span class="v mono" id="pv-assigned">0</span></div>
<div><span class="k">submitted</span><span class="v mono" id="pv-submitted">0</span></div>
<div><span class="k">paid</span><span class="v mono" id="pv-paid">0</span></div>
<div><span class="k">verifier</span><span class="v mono" id="pv-verifier">not read yet</span></div>
</div>
<p class="note" id="pv-note">Off. Settings switches it on: this machine proves the shards the chain assigns to its keys.</p>
<p class="note" id="pv-verifier-note"></p>
<div class="row" id="pv-setup-row" hidden><button class="btn small" id="pv-setup">Set up</button></div>
</div>
<div class="card">
@ -278,6 +275,8 @@
<div class="field">
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span>Vote on finality checkpoints</span></label>
<label class="switch"><input type="checkbox" id="s-prove"><span class="track"></span><span>Prove assigned shards (proving v0; on a Mac the CPU prover is slow)</span></label>
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span>Trust proof records without verifying them (devnet only)</span></label>
<p class="note" id="s-trust-note">Only when no verifier is found next to the engine: the node then includes proof records it never checked. Never on a testnet. A found verifier always wins. Changing this restarts the node.</p>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
</div>
<div class="field">

View file

@ -0,0 +1,141 @@
// node --test app/igneum-app/ui/notices.test.mjs (no dependencies; CI runs it in the site job)
// Loads the Notices block at the top of app.js (plain browser JS: the file is run with `module` defined and no
// `document`, so only the pure block executes) and checks the ordering, the keys, the wording and the timers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
const mod = { exports: {} };
new Function('module', src)(mod);
const N = mod.exports;
const { updateNotice, jobNotice, clockNotice, gather, pick } = N;
const NOW = 1_800_000_000;
const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
const run = (over) => ({ allowed: true, active: true, id: 'job-7', kind: 'shard-benchmark', title: 'shard benchmark', stage: 'proving shard 3 of 8', message: 'some output line', started_at: NOW - 400, results: ['STAGE prove', 'RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8'], last: {}, last_results: [], history: [], ...over });
const fin = (status, over) => ({ allowed: true, active: false, id: '', last: { id: 'job-6', kind: 'build', title: 'build', status, started_at: NOW - 2000, finished_at: NOW - 60, exit: status === 'done' ? 0 : 1, run_id: 'r', uploaded: status === 'done', summary: status === 'done' ? 'build finished, exit 0' : 'build exited with code 1' }, last_results: status === 'done' ? ['RESULT ok'] : ['STAGE build node', 'BUILD FAILED: error[E0425]: cannot find value `foo`'], history: [], ...over });
const s = (over) => ({ version: '0.3.5', uptime_s: 5000, now: NOW, quitting: false, update: upd({ status: 'current', available: false, ready: false, downloaded: false }), jobs: { active: false, last: {} }, clock: { severity: 'none' }, ...over });
test('ordering: installing > job failed > clock > job running > update available > job done > updated', () => {
const all = [
updateNotice(upd({ status: 'applying', applying: true }), s()),
jobNotice(fin('failed'), NOW),
clockNotice({ severity: 'warn', message: 'Clock 40 s ahead.', hint: 'Settings' }),
jobNotice(run(), NOW),
updateNotice(upd(), s()),
jobNotice(fin('done'), NOW),
updateNotice(upd({ status: 'current', updated_from: '0.3.4' }), s({ uptime_s: 10 })),
];
const kinds = ['update-installing', 'job-failed', 'clock', 'job-running', 'update-available', 'job-done', 'update-installed'];
assert.deepEqual(all.map((n) => n.kind), kinds);
// the levels climb, and shuffling the list changes nothing: the lowest level wins
for (let i = 1; i < all.length; i++) assert.ok(all[i].level > all[i - 1].level, `${all[i].kind} after ${all[i - 1].kind}`);
const shuffled = [all[5], all[2], all[6], all[0], all[3], all[1], all[4]];
for (let i = 0; i < kinds.length; i++) {
const rest = shuffled.filter((n) => kinds.indexOf(n.kind) >= i);
assert.equal(pick(rest, {}).kind, kinds[i]);
}
// urgent and failed updates sit with installing at the top
assert.equal(updateNotice(upd({ urgent: true, urgent_text: 'Consensus upgrade at height 120000.' }), s()).level, 0);
assert.equal(updateNotice(upd({ status: 'error', error: 'sha256 mismatch' }), s()).level, 0);
});
test('a closed key hides that notice and the next one waits its turn', () => {
const list = [jobNotice(fin('failed'), NOW), jobNotice(run({ id: 'job-8' }), NOW)];
assert.equal(pick(list, {}).kind, 'job-failed');
const dismissed = { 'job:failed:job-6': true };
assert.equal(pick(list, dismissed).kind, 'job-running');
assert.equal(pick([list[0]], dismissed), null);
// a new job id is a new key, so it shows again
const again = jobNotice(fin('failed', { last: { ...fin('failed').last, id: 'job-9' } }), NOW);
assert.equal(pick([again], dismissed).kind, 'job-failed');
});
test('update keys: Later on the download hides it until it is ready; Later on ready hides that version', () => {
const avail = updateNotice(upd({ status: 'available', ready: false, downloaded: false, progress: 0 }), s());
const down = updateNotice(upd({ status: 'downloading', ready: false, downloaded: false, progress: 0.43 }), s());
const stage = updateNotice(upd({ status: 'staging', ready: false }), s());
const ready = updateNotice(upd(), s());
assert.equal(avail.key, down.key); assert.equal(down.key, stage.key); assert.notEqual(stage.key, ready.key);
const dismissed = { [avail.key]: true };
assert.equal(pick([down], dismissed), null);
assert.equal(pick([ready], dismissed).kind, 'update-available');
// a different version is a different key
assert.notEqual(updateNotice(upd({ version: '0.3.7' }), s()).key, ready.key);
// percent changes do not change the key
assert.equal(updateNotice(upd({ status: 'downloading', progress: 0.9 }), s()).key, down.key);
});
test('update wording: available, downloading with percent, installing, failed with one line of cause, updated', () => {
const avail = updateNotice(upd({ status: 'available' }), s());
assert.equal(avail.text, 'Igneum Miner 0.3.6 is available.');
assert.deepEqual(avail.actions.map((a) => a.label), ['Install now', 'Later']);
const down = updateNotice(upd({ status: 'downloading', progress: 0.43 }), s());
assert.equal(down.text, 'Downloading Igneum Miner 0.3.6: 43%.');
assert.equal(down.progress, 0.43);
assert.equal(updateNotice(upd({ status: 'downloading', progress: 0 }), s()).text, 'Downloading Igneum Miner 0.3.6.');
const inst = updateNotice(upd({ status: 'applying', applying: true }), s());
assert.equal(inst.text, 'Installing Igneum Miner 0.3.6. The app restarts itself. Mining continues until then.');
assert.equal(updateNotice(upd({ status: 'applying', applying: true }), s({ quitting: true })).text, 'Installing Igneum Miner 0.3.6. The app restarts itself in a moment.');
// Install now on a ready update: the engine says "installing now" for up to 3 s before it flips to applying
assert.equal(updateNotice(upd({ wait: 'installing now' }), s()).kind, 'update-installing');
const err = updateNotice(upd({ status: 'error', error: 'sha256 mismatch: the file is not what the manifest signed\nsecond line' }), s());
assert.equal(err.text, 'The update to 0.3.6 failed.');
assert.equal(err.detail, 'sha256 mismatch: the file is not what the manifest signed');
assert.deepEqual(err.actions.map((a) => a.label), ['Try again']);
assert.equal(err.tone, 'bad');
const rb = updateNotice(upd({ status: 'error', error: 'did not stay up', rolled_back: '0.3.6: did not stay up' }), s());
assert.equal(rb.text, 'Igneum Miner 0.3.6 did not stay up and was rolled back.');
const ready = updateNotice(upd(), s());
assert.equal(ready.text, 'Igneum Miner 0.3.6 is ready. It installs by itself at a quiet moment.');
assert.equal(updateNotice(upd({ auto: false, wait: 'waiting for Install now (automatic updates are off)' }), s()).text, 'Igneum Miner 0.3.6 is ready.');
assert.equal(updateNotice(upd({ status: 'manual', wait: '/Applications is not writable; open the downloaded disk image and drag the app over the old one' }), s()).actions[0].label, 'Open the download');
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
// updated: gone within 60 s of the new version starting
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 from 0.3.4.');
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
assert.equal(updateNotice(upd({ status: 'error', error: 'no update manifest configured in this build' }), s()), null);
assert.equal(updateNotice(upd({ status: 'checking', available: false }), s()), null);
});
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
const r = jobNotice(run(), NOW);
assert.equal(r.text, 'Job: shard benchmark running, 6 min. Proving shard 3 of 8.');
assert.equal(r.detail, 'RESULT shard=2 prove_s=39.8');
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'Job: shard benchmark running, 6 min.');
const d = jobNotice(fin('done'), NOW);
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
assert.equal(d.key, 'job:done:job-6');
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
const f = jobNotice(fin('failed'), NOW);
assert.equal(f.text, 'Job: build failed after 32 min, exit 1. Report not uploaded.');
assert.equal(f.detail, 'BUILD FAILED: error[E0425]: cannot find value `foo`');
assert.equal(f.tone, 'bad');
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
// no error line among the results: the summary is the cause
assert.equal(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, 'build exited with code 1');
assert.equal(jobNotice(fin('timeout'), NOW).text, 'Job: build hit its time cap after 32 min, exit 1. Report not uploaded.');
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
assert.equal(jobNotice(null, NOW), null);
});
test('clock: the engine words, Sync clock, the hint; gather() keeps it off the dashboard and the job off the setup screens', () => {
const c = clockNotice({ severity: 'block', message: 'This clock is 7 min behind.', hint: 'Date & Time', syncing: false });
assert.equal(c.text, 'This clock is 7 min behind.'); assert.equal(c.detail, 'Date & Time'); assert.equal(c.tone, 'bad');
assert.equal(c.actions[0].label, 'Sync clock'); assert.equal(c.actions[0].disabled, false);
assert.equal(clockNotice({ severity: 'warn', message: 'x', syncing: true }).actions[0].label, 'Syncing');
assert.equal(clockNotice({ severity: 'none' }), null);
const st = s({ clock: { severity: 'warn', message: 'Clock 40 s ahead.' }, jobs: run(), update: upd({ status: 'available' }) });
assert.deepEqual(gather(st, { dashboard: true }).map((n) => n.kind), ['update-available', 'job-running']);
assert.deepEqual(gather(st, { dashboard: false }).map((n) => n.kind), ['update-available', 'clock']);
assert.equal(pick(gather(st, { dashboard: true }), {}).kind, 'job-running');
assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock');
assert.deepEqual(gather({}, { dashboard: true }), []);
});

View file

@ -3,6 +3,6 @@
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
#ifndef IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_H
#define IGNEUM_HOST_VERSION_STR "0.3.5"
#define IGNEUM_HOST_VERSION_RC 0,3,5,0
#define IGNEUM_HOST_VERSION_STR "0.3.7"
#define IGNEUM_HOST_VERSION_RC 0,3,7,0
#endif

View file

@ -0,0 +1,139 @@
# Base-fee floors and the prover-gas table: the model (ADOPTED 5 October 2026)
Status: every number below was proposed on the night of 4 October 2026 and ADOPTED by the owner on 5 October 2026,
as proposed (sign-off recorded in `docs/plans/release-0.3.6.md`). The parameters live in the node fork
(`consensus/core/src/fees.rs`, `FeeParams::CALIBRATED_V1`; written on branch `testnet-params`, merged into
`release-0.3.6` on 5 October 2026). Which network runs them: the testnet and the mainnet from genesis
(`Params.fees` = v1, `fees_v1_activation_daa` = 0); the devnet and the simnet keep `FeeParams::PROTOTYPE` until the
override file carries a `fees` object or the `fees_v1_activation_daa` height switch (section 4, "the devnet
rollout"), so the live devnet does not change rules between the 0.3.5 and 0.3.6 node builds. Spec 05 section 5.11
carries the summary. Nothing is deployed.
Inputs the model takes from the repository, with their status:
| Input | Value | Status, source |
|---|---|---|
| Block rate | 1 block per second | Designed (spec 02; `BlockrateParams::new::<1>()`) |
| Execution gas per block `B_e` | 30,000,000 | Implemented, devnet v3 value (`consensus/core/src/evm.rs`, `BLOCK_EXECUTION_GAS_LIMIT`) |
| Plain transfer, execution gas | 21,000 | Ethereum's rule |
| Year-one block subsidy | 31.69 IGN (3,168,808,781 sompi per second) | Implemented (`consensus/core/src/igneum.rs`) |
| SP1 cycles per EVM gas, modexp-heavy shard | 44 | Measured (bench-log, "shard proving on the RTX 5090", run-20261004-173115) |
| SP1 cycles per prototype pgas, same shard | 9 | Measured (same) |
| SP1 cycles per prototype pgas, plain-transfer shard | 1,400 to 1,600 | Measured (bench-log, 4 October, "proving: devnet v4 shards") |
| Compressed proof of a 60 M-cycle shard, one RTX 5090 | 10.9 s | Measured (same run) |
| Aggregation of a block's shards | 2.2 to 2.5 s | Measured (same run) |
| Token price | $0.10 per IGN | ASSUMPTION for the arithmetic only; sensitivities at $0.01, $1 and $2 below. Not a forecast, not a claim |
| Electricity | $0.15 per kWh; RTX 5090 at 575 W while proving | Approximate (the economy analysis used $0.02 to $0.40; 575 W is the card's rated draw, not measured here) |
## 1. The prover-gas table, calibrated v1
The unit is unchanged: 1 pgas stands for 1,000 reference SP1 cycles. The prototype table of 3 October charged every
opcode and precompile by shape with magnitudes nobody had measured. Two of its entries are now measured.
| Entry | Prototype (3 October) | Measurement | Calibrated v1 |
|---|---|---|---|
| modexp (0x05) | 1,000 + 10 per input byte | the modexp-dominated shard ran 60.76 M cycles for 6.75 M prototype pgas: 9 cycles per pgas against the unit's 1,000, so the entry is about 111x its cost | 10 + 1 per 10 input bytes (the prototype over 100) |
| Intrinsic per transaction | 200 | the plain-transfer shard ran 1,400 to 1,600 cycles per prototype pgas: 200 x 1,500 = 300,000 cycles per transaction, which includes the shard's fixed witness check and root computations, so it is an upper bound | 300 |
| Every other opcode and precompile | prototype shape | not measured | prototype shape, unchanged, table version 1 |
What the two constants say about a transaction: the modexp shard metered 1,390,773 EVM gas for 60.76 M cycles, 44
cycles per gas, which is 0.044 pgas per gas at the unit; a plain transfer is 300 pgas for 21,000 gas, 0.014 pgas per
gas. The design expected a `pgas / gas` band of 0.1 to 10 (execution-layer design 4.3); the measured band is 0.01 to
0.05, so proving gas is cheaper per gas than the design guessed, by 10x, on the two workloads measured. The
remaining entries (ecrecover 3,000 pgas, ecpairing 45,000 per pair, the storage opcodes) are the next calibration;
each is one SP1 run of a fixture that isolates it.
## 2. The shard and block budgets
| Quantity | Value | Arithmetic |
|---|---|---|
| Shard budget `S_p` | 30,000 pgas | 30 M cycles: half the measured 60 M-cycle shard. One RTX 5090 compresses it in about 5.5 s (linear in cycles from 10.9 s, approximate); a 12 GB card in about 20 s (approximate: the economy simulation's shard shares put a 3060 at 3.7x the 5090's time; unmeasured, the phase 2 gate) |
| Block budget `B_p` | 120,000 pgas | 4 x `S_p`, the prototype's ratio (spec 7.4) |
| Transfers per block at `B_p` | 400 | 120,000 / 300 |
| Execution gas those use | 8,400,000 | 400 x 21,000, 28% of `B_e`: the proving dimension binds first for transfers |
| Block proof time, four RTX 5090s | about 8 s | 5.5 s per shard in parallel plus 2.5 s aggregation (approximate), inside the 20 to 60 s launch target |
| Block proof time, four 12 GB cards | about 23 s | 20 + 2.5 s (approximate) |
| Cards to keep pace at full blocks | 22 RTX 5090s, or about 80 12 GB cards | 4 shards x 5.5 s = 22 card-seconds per second; x 3.7 for the 12 GB class (approximate) |
The prototype `B_p` of 30,000,000 pgas was "equal to `B_e`" and never a throughput number: at 9 cycles per prototype
pgas a full prototype block is 270 M cycles, 49 s on one 5090, and at the plain-transfer rate it is 45 G cycles. The
calibrated `B_p` is a throughput number: one block per second provable by a fleet the economy simulation already
models. Raising it is a parameter the genesis rules leave to miners (60% signalling, spec 5.5), and the economy
analysis of 4 October recommends tying it to the live proving fleet on the testnet.
## 3. The base-fee floors
Both base fees are burned in full (spec 5.1) and adjusted by EIP-1559 toward half the limit with a denominator of 8
(1/8 per block at the extremes). The floor is the lowest value either fee can reach. It has three jobs: keep a plain
transfer cheap, make a full block cost real money from the first block, and price proving above the electricity it
burns so spam cannot be cheaper than the work it imposes.
| Floor | Value | In IGN |
|---|---|---|
| Execution base fee `f_e` | 100 gwei per gas | 0.0000001 IGN per gas |
| Proving base fee `f_p` | 10,000 gwei per pgas | 0.00001 IGN per pgas |
| Initial base fees at genesis | the floors | |
### A plain transfer at the floor
| Term | Arithmetic | IGN |
|---|---|---|
| Execution | 21,000 x 100 gwei | 0.0021 |
| Proving | 300 x 10,000 gwei | 0.0030 |
| Total (tip excluded) | | 0.0051 |
| Token price (assumption) | $0.01 | $0.10 | $1 | $2 |
|---|---|---|---|---|
| Transfer at the floor | $0.000051 | $0.00051 | $0.0051 | $0.0102 |
The target "under $0.01 per simple transfer" holds up to $1.96 per IGN. Under load the fee leaves the floor: after
`n` consecutive full blocks the base fee is the floor times 1.125^n, which is 3.2x after 10 blocks, 34x after 30 and
about 1,170x after 60 blocks (one minute). The floor prices the quiet chain; the controller prices the busy one.
### A full block at the floor, which is what spam costs
| Case | Arithmetic | IGN per block | Per day (86,400 blocks) | At $0.10 per day |
|---|---|---|---|---|
| Execution dimension full (30 M gas of cheap-to-prove calls) | 30,000,000 x 100 gwei | 3.0 | 259,200 | $25,920 |
| Proving dimension full with transfers (400 transfers) | 120,000 x 10,000 gwei + 8,400,000 x 100 gwei | 1.2 + 0.84 = 2.04 | 176,256 | $17,626 |
| Both dimensions full (the worst mix) | | up to 4.2 | 362,880 | $36,288 |
A self-paying spam loop (a miner filling its own blocks, or a contract that calls itself until the gas is gone) pays
the same: the base fee is burned, the tip returns to the miner and nets to zero, so a miner that fills its own block
burns 3.0 IGN against a subsidy of 31.69 IGN, 9.5% of its own reward per filled block, for nothing. And only at the
floor: after one minute of full blocks the controller has multiplied every number above by about 1,170.
### Proving priced above its electricity
| Quantity | Arithmetic | Value |
|---|---|---|
| Cycles per second, one RTX 5090 | 60 M cycles / 10.9 s | 5.5 M |
| Energy per pgas (1,000 cycles) | 575 W x 1,000 / 5.5 M | 0.105 J = 2.9 x 10^-8 kWh |
| Electricity per pgas at $0.15 per kWh | | $4.4 x 10^-9 |
| Floor per pgas at $0.10 per IGN | 0.00001 IGN | $1.0 x 10^-6 |
| Floor over electricity | | 230x at $0.10; 23x at $0.01; 1x at $0.00044 |
The floor covers the physical cost of the proving it buys down to a token price of about $0.0004, which is where
this anchor would bind before the spam anchor does. The execution dimension has no such anchor: native execution of
a full block costs tens of milliseconds of CPU; its floor is set by the spam arithmetic alone, as Ethereum's is.
## 4. What the table and the floors do not settle
| Item | State |
|---|---|
| The other opcode and precompile entries | prototype shapes; calibrate per entry in SP1 with three input sizes (design R1) |
| The intrinsic 300 | an upper bound that includes the per-shard fixed cost; a shard with many transfers will show the marginal number |
| The 12 GB card time for `S_p` | approximate, from the simulation's share ratios; the phase 2 gate measures it |
| The prover's mirror of the table | `proving/igneum-prove/core/src/config.rs` and `pgas.rs` carry the prototype values at `b7fca5a0`; they must change in lockstep with the node (the guest program's id changes, every fixture is re-cut at the new `S_p`), or the shard statement differs from the node's. Not changed tonight |
| The devnet rollout | done as a height switch (5 October 2026): `Params.fees_v1_activation_daa` (override file, default never on devnet and simnet, 0 on testnet and mainnet, in the consensus digest). Chain blocks at or above the switch meter with v1 (every metering site in `igneum/exec` takes the block's DAA score, `fees::fee_params_at`); the first such block raises both base fees to the v1 floors. The live devnet keeps its history and its prototype rules until the switch is published with the 0.3.6 update (`docs/plans/release-0.3.6.md`, section 5). A fresh chain can instead carry `fees` in the override file (the fast-time profile does) |
| The price assumption | $0.10 is an arithmetic assumption. The floor is a parameter the genesis rules leave to miners (60% signalling) and can be moved by them |
## 5. Where the numbers live
| What | Where |
|---|---|
| The parameter set and its tests | `vendor/igneum-node-testnet/consensus/core/src/fees.rs` (branch `testnet-params`) |
| Per network | `consensus/core/src/config/params.rs`, `Params.fees` and `Params.fees_v1_activation_daa` (`FeeParams::TESTNET` and `MAINNET` = `CALIBRATED_V1` with the switch at 0; `FeeParams::DEVNET` and `SIMNET` = `PROTOTYPE` with the switch never, both movable through the override file) |
| The execution layer's readers | `igneum/exec/src/config.rs` (`block_proving_gas_limit(daa)`, `intrinsic_pgas_per_tx(daa)`, the floor and initial readers, every one at a DAA score), `pgas.rs` (the inspector carries the block's table; the modexp entry reads it), `executor.rs` (`execute_segment` picks the set by the block's DAA score and raises the carried base fees to its floors; `next_base_fee` takes the floor and the denominator) |
| Installed at start | `kaspad/src/daemon.rs` (`install_fee_params`: the base set and the switch, printed after the PoW schedule) |
| The specification | `docs/spec/05-fees-and-economics.md` section 5.11 |

View file

@ -33,7 +33,7 @@ Source: `vendor/igneum-node-v4` branch `dev-fee`, `igneum/miner/src/main.rs`, se
| `igneum-miner mine ... --dev-fee <percent>` | whole percent of templates; default 1; `--dev-fee 0` turns it off |
| Start line, on | `dev fee 1% (1 block in 100) to 0x<address>; --dev-fee 0 turns it off` |
| Start line, off | `dev fee off (--dev-fee 0); the default is 1% (1 block in 100) to 0x<address>` |
| Start line, no release address | `dev fee off: no release address is set (DEV_FEE_ADDRESS placeholder in igneum/miner/src/main.rs)` |
| Start line, no release address (a build whose `DEV_FEE_ADDRESS` is not 40 hex; none since 5 October 2026) | `dev fee off: no release address is set (DEV_FEE_ADDRESS placeholder in igneum/miner/src/main.rs)` |
| Per fee block accepted | `dev-fee block <hash>` |
| Status line (CPU and worker modes) and `MINER SUMMARY` | `fee=N` |
| `igneum-miner payouts <grpc url>` | blocks per `IGNA` payout address over every block the node holds, with the share; the dev address is tagged `(dev fee)` |
@ -47,7 +47,7 @@ extra config.
| Constant (`igneum/miner/src/main.rs`) | Value | Network |
|---|---|---|
| `DEV_FEE_ADDRESS` | the placeholder string `DEV_FEE_ADDRESS`; **Josh fills it before any public release**. While it is not 40 hex the fee is off outside the devnet and the miner says so at start | mainnet, testnet |
| `DEV_FEE_ADDRESS` | `0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126`: Josh's payout address from the Igneum Wallet, given 5 October 2026, EIP-55 checksum verified, on file at `~/.config/igneum/dev-fee-release.json`; set on the fork's `release-0.3.6` (commit cf369022). Were it ever not 40 hex the fee would be off outside the devnet and the miner would say so at start | mainnet, testnet |
| `DEV_FEE_ADDRESS_DEVNET` | `0xdfaea67368f3e3753397d878f97efe6aa8020c2e` | devnet and simnet only |
The devnet address was generated on 4 October 2026 with the app's own key derivation (secp256k1, keccak of the
@ -59,7 +59,8 @@ have no value and the devnet may be reset; this address is never a release addre
- Unit tests (`cargo test --release -p igneum-miner dev_fee_tests`): exactly 100 fee templates in 10,000 at 1%, at
positions 99, 199, ...; 0 at `--dev-fee 0`; 2, 3, 5, 10, 50 and 100% exact over 10,000; a fee template carries the
dev `IGNA` address and the user's unchanged key reveal; the release placeholder keeps the fee off outside the devnet.
dev `IGNA` address and the user's unchanged key reveal; the release address pays the fee on mainnet and testnet (and
never the devnet address), and a non-hex placeholder keeps the fee off.
- Test network: `node tools/dev-fee/run.mjs` (two nodes on 29900+, fast-time profile, proof of work skipped and the
genesis target at the floor, three CPU stub miners: two at the default fee and one control at `--dev-fee 0`), then
`igneum-miner payouts` on the peer node. The numbers are in `docs/bench-log.md` under "the software dev fee measured".

View file

@ -0,0 +1,121 @@
# G14: the history rewrite, exact plan and dry-run result (4 October 2026, night)
Internal. Extends `docs/fud-fixes.md` section 5 (step 4) with the exact commands, what the dry run showed, what
breaks, and the order for the morning. Nothing here has touched the real repository: the dry run ran on a throwaway
mirror clone under the session scratchpad and nothing was pushed. The owner is not named in this file; "the first
name" and "the login" stand for the values the script reads from the history itself.
## 1. What the history holds today (counts from the real repository, 4 October 2026, 22:30 UTC)
| Item | Count | Where |
|---|---|---|
| Commits | 363 on all branches | |
| Commits stamped `+0100` (author or committer) | 291 of 363 | the UK or Irish summer offset; 72 are `+0000` |
| Commits authored with the personal name | 40 (31 on the old GitHub noreply address, 9 on the personal address) | the commits before the 3 October identity rule |
| Commits as the standing login `igneum-josh` | 323 | |
| The intake key | 6 tracked files, 8 commits (`78df757` to `4c9810f`) | `packaging/mac/packaged-config.sh`, `infra/gpu-bench/upload.sh`, `proving/windows-wsl2/prove-block.sh`, `prove-shard.sh`, `proto-cuda/windows-miner/upload-log.bat`, `proto-cuda/windows-app/upload-log.bat` |
| The dl token | 1 tracked file, 1 commit (`c47ff03`) | `docs/plans/morning-2026-10-04.md` |
| The `.next` rotations of both | 0 files, 0 commits | `~/.config/igneum/log-intake-key.next`, `dl-token.next` (4 October 19:25) are not in the tree |
| The relay key and token (current and old) | 0 files, 0 commits | |
| The review files | `docs/fud-ledger.md` (36 commits from `39c20b7`), `docs/fud-fixes.md` (6 from `e7545d5`), `docs/review/` (4 from `5ab296c`), `site/ledger.html` (5 from `0ec11be`) | tracked, not ignored |
| Tracked files carrying the first name (case-insensitive) | 71 at HEAD; 93 commits touch such content; 10 commit messages carry it | `CLAUDE.md`, the agent file, plans, packaging, the app's WSL paths, the Chrome profile rule |
| The surname | 4 files at HEAD | |
| The other businesses' names, the registrar, the database id, home paths | vivanmn 6, peasehill 5, thrsty 4, godaddy 7, soft-voice 3, `/Users/` 22, quantum 4 | identity terms are rewritten by the history pass below; providers and paths are the public-export scrub's job (`tools/ci/forbidden-strings.txt`), not this pass |
## 2. The rewrite, exactly
Tool: `git-filter-repo` 2.47.0 (not installed on the Mac; the dry run used a pip install into the scratchpad,
`python3 -m pip install --target <dir> git-filter-repo`, run as `python3 <dir>/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 <repo> 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>==>***INTAKE-KEY-REMOVED***`; `literal:<dl token>==>***DL-TOKEN-REMOVED***`; the two personal `Name <email>` 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)(?<!igneum-)\bfirst\b==>[user]` (the lowercase user-name form in Windows and WSL paths, added after dry run 1 left 20 lines); `regex:(?i)\b<second login>\b==>[second-owner-login]`; `regex:(?i)\b(vivanmn\|peasehill\|thrsty\|gemven\|jbm exec)\b==>[other-business]` |
| `messages.txt` (commit messages) | the first-name rules and the second-login rule |
| `mailmap` | both personal identities to `igneum-josh <337424239+igneum-josh@users.noreply.github.com>` |
The date callback keeps the instant and rewrites the offset to `+0000`, so no commit moves in time; only the
`+0100` fingerprint goes. `--invert-paths` drops the four internal files from every commit, which empties the
commits that touched nothing else; filter-repo prunes those.
## 3. The dry run (two passes on the mirror clone, 4 October 2026, 22:35 to 22:55 UTC)
| Check | Before | After pass 2 |
|---|---|---|
| Commits | 364 in the mirror (363 plus the in-progress branch head) | 312: the 52 commits that only touched the dropped files are gone |
| Author and committer identities | 3 | 1: the standing login on all 312 |
| Timezone offsets (author and committer, 624 stamps) | 291 x 2 `+0100` | 624 `+0000` |
| `git log -S<intake key>` | 8 commits | 0 |
| `git log -S<dl token>` | 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\<first>` 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)(?<!igneum-)` rule closed them in pass 2.
What the pass does NOT do, by design, and must be done by hand or by the owner:
| Gap | Why | Who |
|---|---|---|
| The standing login `igneum-josh` carries the first name inside it, in every commit's author line and in every file that names the login | A login is a GitHub setting, not a text rule: renaming it is one setting, the numeric noreply id stays, then one more mailmap line (`<new> <337424239+<new>@...> <337424239+igneum-josh@...>`) and one more replace rule (`igneum-josh` to the new login) go into the same pass | the owner (rename), then the script |
| `CLAUDE.md` as a public file (section 5 step 2 of `docs/fud-fixes.md`: the registrar, the database id, the browser-profile section, the tooling links) | The pass replaces names; it does not rewrite paragraphs. The scrubbed `CLAUDE.md` of step 2 replaces the file in every commit with `--path-rename` or a blob callback once it exists | Claude, after the owner approves the public text |
| The second owner login is still an organisation owner | GitHub setting (decision e: one anonymous owner) | the owner |
| Providers, hosts, home paths, machine names | the public-export scrub (`tools/ci/forbidden-strings.txt`, `igneum-public/tools/sync.sh`); the private repository keeps them until the public date | the export |
## 4. What breaks when the rewrite is applied for real
| What | Why | Recovery |
|---|---|---|
| Every worktree of the main checkout (16 today: `igneum-wt-appui`, `bughunt`, `buildjob`, `devfee`, `eff`, `finality`, `latency`, `perf`, `redteam`, `release`, `reliability`, `ship`, `site`, `wallet`, `testnet`, plus two under the scratchpad) | Their HEADs point at old commit ids that no longer exist in the rewritten history; `git status` still works on the old objects, `git pull` and `git rebase` do not | Each agent commits and pushes its branch before the freeze; after the rewrite every branch is re-created from the rewritten refs: `git worktree remove`, `git worktree add ../igneum-wt-<name> <branch>` |
| Agents' branches (15 local, 11 on origin) | Rewritten with everything else (the mirror clone carries every ref), so the branch names survive with new ids; an agent that keeps an old local branch will have diverged from its rewritten twin by every commit | No agent commits during the freeze; after it, every agent re-creates its worktree, never merges an old-id branch into a new one |
| Open pull requests, if any | Their base and head ids vanish | None open today (the project merges by hand); check `gh pr list` before the freeze |
| The Vercel GitHub integration (`igneum` project, deploys on push to master) | The integration links by repository id, not by commit, so it survives a force-push; the first push of the rewritten `master` triggers one deploy of the same site (the public tree is unchanged by the pass except the dropped `site/ledger.html`, already a 307 redirect) | Watch the deploy; nothing to relink. If the repository is re-created instead (section 5, option B), the integration is re-linked once in the Vercel project settings |
| The `windows-ci` and `ci` workflows | Run on the rewritten push like any push; the DL_TOKEN secret is a repository setting and survives | Re-set the secrets if the repository is re-created |
| Old commit ids in documents (`docs/bench-log.md`, plans, the ledger) and in the public export | They name commits that will not exist; filter-repo writes `commit-map` (old id to new id) in `.git/filter-repo/` and rewrites ids it finds in commit messages, not in files | Keep `commit-map` with the private notes; the bench log keeps its short ids as historical labels (the public export already strips the history) |
| GitHub's copies of the old objects | A force-push does not delete them from GitHub's object store; cached PR views, old commit URLs and forks keep serving them until GitHub runs a garbage collection, which support can be asked to do | Option B below removes the question |
| The fork worktrees under `vendor/` | Separate repositories (`vendor/` is gitignored); untouched | Nothing |
| The intake key and the dl token | Removing them from the history does not revoke them; every shipped package and every installed app carries the current key | Rotate first (the `.next` values exist since 4 October 19:25): new key in `relay/` and in `packaging/mac/packaged-config.sh`, repackage, republish; the old key keeps working for installed apps until they update, then dies |
## 5. The order of operations for the morning
1. Rotate the secrets: switch the relay and the intake to `log-intake-key.next`, the downloads folder to `dl-token.next`, repackage the Mac and Windows apps with the new values, publish, confirm an upload lands under the new key. Then the old values in the history are dead values.
2. The owner renames the login `igneum-josh` (GitHub settings; the noreply id 337424239 stays), confirms the second login is no longer an organisation owner, and approves the public `CLAUDE.md` text (section 5 step 2).
3. Freeze: every agent commits and pushes its branch, then stops; `gh pr list` must be empty; `git worktree list` is recorded.
4. Mirror clone, run the pass (section 2) with the two extra lines from step 2 and the scrubbed `CLAUDE.md` blob; the greps of section 3 must all read 0; keep `commit-map`.
5. Choose A or B. A: `git push --mirror` from the clone to the existing repository, then ask GitHub support to purge the unreachable objects. B (the route `docs/fud-fixes.md` step 4 prefers): create a fresh repository under the organisation, push the rewritten refs there, re-link Vercel and re-set the two secrets, archive the old repository private. B leaves no old object anywhere.
6. Re-clone the main checkout from the new history; every agent re-creates its worktree from its rewritten branch.
7. `TZ=UTC` on every path that commits: the agents' shells, the ship scripts, the relay; and `git config --global` cannot set a timezone, so the rule is in the environment. The CI identity grep and `git log --format='%ad' --date=raw | grep -c +0100` become the daily check (0 is the goal).
8. The public export (`igneum-network/spec`) is unaffected: it carries no history from this repository.
## 6. What waits for the owner
| Decision | Options |
|---|---|
| The new login name | any handle without a name |
| A or B in step 5 | B recommended |
| The public `CLAUDE.md` text | section 5 step 2 of `docs/fud-fixes.md` |
| The day | after step 1; before the public date in every case |

View file

@ -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/7imeuvhga10/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/<token>/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

View file

@ -1,8 +1,216 @@
# Release 0.3.6: what the proving activation taught us
# Igneum Miner 0.3.6: the testnet adoption, staged 5 October 2026
Prepared by the integration agent on the morning of 5 October 2026 after the owner's decision. Nothing was deployed,
published or merged to master. The app and document work sits on branch `testnet-adopt` (pushed to origin), worktree
`/Users/joshm/Projects/igneum-wt-adopt`; the node work sits on the fork branch `release-0.3.6` (local only; the fork
has no remote), worktree `vendor/igneum-node-036`. The cut is one command (section 4) after the inputs push of
section 3, which must come first.
## 0. The decision (owner, 5 October 2026)
The proposed testnet identity (`docs/testnet/README.md`) and the proposed fee floors and prover-gas table
(`docs/analysis/base-fee-floor.md`, spec 05 section 5.11) are ADOPTED as proposed. The three documents now say so;
the fork's `FeeParams::CALIBRATED_V1` and `TESTNET_PARAMS` are unchanged from the proposal.
One rule the adoption added, so the live devnet is never forked by a node update: the devnet keeps its fee rules
until a height switch says otherwise (section 5).
## 1. What is in
### 1a. The app repository (branch `testnet-adopt`, from origin/master 2054ae3 = the 0.3.5 cut)
| Merged | Branch head | What it carries | Conflicts and how they were resolved |
|---|---|---|---|
| `origin/testnet-prep` | beed743 (3 commits over e22068f) | `docs/testnet/README.md`, `docs/analysis/base-fee-floor.md`, spec 05 section 5.11, `docs/plans/history-rewrite.md` (G14, not acted on here), G13 signed build inputs (`app/igneum-app/src/inputs.rs`, `igneum-ota-sign sign-inputs` / `verify-inputs`, `packaging/windows/push-inputs.sh`, `inputs-manifest.sh`, `node-source.pin`, `test-inputs-signing.sh`, `fetch-ci-artifacts.sh --sign-manifest`, `.github/workflows/windows.yml`), `tools/ci/check-workflow-shell.mjs`, the testnet terms on the download section, `site/wallet.html`, the litepaper's app paragraph, the fast-time profile's `fees` object | four generated site files. `site/index.html`: the dev-fee sentence of 0.3.5 kept, testnet-prep's `#testnet-terms` card and wallet link kept (both sides of the download section); the inlined journey block is regenerated. `site/litepaper.html`: 0.3.5's two-paragraph dev-fee text kept (it is the fuller one; testnet-prep's one-sentence version dropped), testnet-prep's MetaMask and Igneum Wallet paragraph kept. `site/journey.json`: 0.3.5's log kept (the feed is the newest 40 entries; testnet-prep's older entries had already aged out of it). `site/sitemap.xml`: both `/miners` and `/wallet`. Then `node site/build.mjs` |
On top of the merge, in the same branch:
| Change | Where |
|---|---|
| "proposed" to "adopted 5 October 2026" with the sign-off noted, and the per-network fee rule written in | `docs/testnet/README.md`, `docs/analysis/base-fee-floor.md`, `docs/spec/05-fees-and-economics.md` (section 5.10 and the parameter table) |
| `fees_v1_activation_daa: 0` added to the 60x fast-time profile (the fork's `fast_time_60x_file_is_the_devnet_at_60x` test wants every override field present) | `infra/fast-time/override-60x.json` |
| this file | `docs/plans/release-0.3.6.md` |
### 1b. The node fork (branch `release-0.3.6` in `vendor/igneum-node-036`, from `release-0.3.5` 20139145)
| Fork merge | Head | What it carries | Conflicts |
|---|---|---|---|
| `testnet-params` (worktree `vendor/igneum-node-testnet`, forked from `finality-fixes` 6aa69a45) | 11e86144 | `consensus/core/src/fees.rs` (`FeeParams`, `PgasTable`, `CALIBRATED_V1`, `PROTOTYPE`), `Params.fees` and the override file's `fees` object, the testnet identity (`igneum-testnet-1`, chain id 4462, ports 268xx, the frozen genesis, `FinalityParams::MAINNET`, every switch at 0), the override file refused on the testnet, the execution layer reading the installed set (`B_p`, `S_p`, the intrinsic, modexp, the floors) | `consensus/core/src/config/params.rs`: both methods kept (`apply_env_pow_schedule` from G12 and `install_fee_params`); the M31 comment on `max_coinbase_payload_len` kept. `kaspad/src/daemon.rs`: the 0.3.5 start-up block kept (environment schedule on devnet and simnet, `install_pow_schedule`, the digest line) and `install_fee_params` with its own print added after it |
On top of the merge, the fee height switch (section 5), in the same branch:
| Change | Where |
|---|---|
| `FeeParams::DEVNET` and `SIMNET` = `PROTOTYPE`; `TESTNET` and `MAINNET` = `CALIBRATED_V1`. `FeeSchedule { base, v1_activation_daa }` with `at(daa)`; `install_fee_params(base, switch)`; `fee_params_at(daa)` replaces `fee_params()` | `consensus/core/src/fees.rs` |
| `Params.fees_v1_activation_daa` (devnet and simnet `u64::MAX`, testnet and mainnet 0), `OverrideParams.fees_v1_activation_daa`, the digest rule of section 5, four new or changed tests | `consensus/core/src/config/params.rs` |
| the switch and a `fees` object printed from the override file; the installed schedule printed with the network | `kaspad/src/daemon.rs` |
| `shard_proving_gas_budget_at(daa)` | `consensus/core/src/proving.rs` |
| every reader takes a DAA score; `execute_segment` picks the set by the block's DAA score and raises the carried base fees to its floors; the inspector carries the block's pgas table (modexp reads it); `next_base_fee` takes floor and denominator; the pool's `add` and `select` and every RPC quote use the tip's DAA score plus one; the shard plan uses the segment's; one new executor test | `igneum/exec/src/{config,executor,pgas,pool,proving,rpc,service}.rs` |
On top of that, two more fork commits:
| Commit | What |
|---|---|
| cf369022 | `DEV_FEE_ADDRESS` = `0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126`, the release dev-fee payout address (testnet and mainnet; Josh, 5 October 2026, EIP-55 checksum verified, on file at `~/.config/igneum/dev-fee-release.json`); `DEV_FEE_ADDRESS_DEVNET` unchanged; the placeholder test replaced by `the_release_address_pays_the_fee_outside_the_devnet` (`cargo test -p igneum-miner dev_fee`: 5 passed). `docs/design/miner-dev-fee.md` updated on this branch |
| 2b6d23ef | the merge of fork `miner-latency` (section 2), the final tip of `release-0.3.6` |
### 1c. What is out
| Branch | Why |
|---|---|
| the app repository's local `miner-latency` (dd47ff3, worktree `igneum-wt-latency`: `tools/miner-latency/run.mjs`, `docs/plans/miner-latency.md`, two bench-log entries; never pushed to origin) | not part of this task; it conflicts with `testnet-adopt` only in `docs/bench-log.md` (both appended entries) and can be merged at any time. The harness was run from that worktree for section 2 |
## 2. The miner-latency decision: IN
Fork `miner-latency` (0f88b6d6, `vendor/igneum-node-latency`, one commit over `devnet-v4`: the NewBlockTemplate
subscription in the miner, node-side template prewarm, workers switching without draining the batch, `--poll-templates`
and `--job-ms`) was merged on a side branch first (`release-0.3.6-latency`, worktree `vendor/igneum-node-036-lat`,
c07093ce) and landed on `release-0.3.6` (2b6d23ef) only after both gates passed on the merged tree:
| Gate | Command | Result |
|---|---|---|
| Miner tests | `cargo test -p igneum-miner` on c07093ce | ok: 15 passed, 0 failed |
| 3-node fast-time run | `node tools/miner-latency/run.mjs --mode subscribe` (from `igneum-wt-latency`, `IGNEUMD` and `IGNEUM_MINER` = the release build of c07093ce in `vendor/igneum-node/target-036-lat/release`, under the `run` lock, 08:11 to 08:16Z) | 3 nodes up (peers 2/1/1 throughout), 3 CPU miners at 2 threads for 295 s: 82 + 81 + 80 blocks accepted, 0 rejected; chain blocks 228, merged blue 15, merged red 0, tips mean 1.03 max 2, sinks agree; 236 to 241 template switches per miner, notified p50 14 to 16 ms, template p50 20 to 24 ms, switched p50 46 to 52 ms (p90 118 to 130 ms, max 194 to 372 ms); node prewarm 52 to 56 builds a minute, mean 3 to 4.5 ms; 1 to 2 stale submits per miner; no error, panic or WORKER FAULT line in any miner log |
| The dev fee through the new feed | `node tools/dev-fee/run.mjs --secs 180` on the same binaries (two nodes, three stub miners, two at the default fee and one at `--dev-fee 0`, then `igneum-miner payouts`; 08:17 to 08:20Z) | pass: miner-a found=327 fee=4, miner-b found=301 fee=4, control miner-c found=309 fee=0 (0 `dev-fee block` lines); `payouts` on both nodes: 938 blocks, the devnet dev address 8 blocks = the miners' counters, 1.27% of the fee-paying miners' blocks against the 1% expectation |
The conflicts (13 hunks in `igneum/miner/src/main.rs`, the 0.3.5 dev fee and X21 mismatch guard against the latency
rewrite of the template path) were resolved by keeping both sides: the feed takes the dev fee and requests one
template in 100 with it, as the 0.3.5 fetcher did; the continuous CPU loop carries the fee through `Work.dev_fee`
and counts fee blocks; the found path runs `check_found` into the mismatch guard; `WorkEngine::Real` holds the
day-keyed `EpochRef` of M30. The 3-node run above showed `fee=0` on every miner: 243 blocks found at 1 in 100
templates expects about 2.4 fee blocks, and zero has a probability of about 9%, so the dev-fee harness was run as
the third gate; it showed the fee riding through the new feed (8 of 8 fee blocks on chain, the control at 0).
## 3. Test results, with the command
Every build ran through the main checkout's lock, `/Users/joshm/Projects/igneum/tools/lock/with-lock.sh build`, at
`nice -n 19` with `-j 4`. The fork's builds used `CARGO_TARGET_DIR=vendor/igneum-node/target-036`, an APFS clone of
`target-release` (48 s to clone). The rustup cargo (1.99.0) on `~/.cargo/bin`.
### 3a. The app repository (worktree `igneum-wt-adopt`)
| Check | Command | Result |
|---|---|---|
| Site | `node site/build.mjs` | built: bench, journey (40 entries), index, litepaper, live, evidence, wallet, 404, miners (6 rows) |
| Links | `node tools/ci/link-check.mjs` | 324 internal links across 8 pages, 0 broken |
| Workflow shell | `node tools/ci/check-workflow-shell.mjs` | self-test fires on the fixture; 11 run blocks in 2 workflows, 25 `.ps1` files, 0 findings |
| Signed inputs | `packaging/windows/test-inputs-signing.sh` with `igneum-ota-sign` built from this tree (`cargo build --release -j 4 --bin igneum-ota-sign`, 8 s with the cloned target) | 16 passed, 0 failed: the four positive cases, the ten refusals (changed zip byte, changed manifest byte, changed unpacked file, unlisted file, missing file, wrong pin, short pin, another key, the embedded key against the throwaway signature, the two sign-time refusals), and the Mac's real OTA key verified by the key compiled into the app |
| Shell parses | `bash -n` on `push-inputs.sh`, `fetch-ci-artifacts.sh`, `inputs-manifest.sh`, `test-inputs-signing.sh` | ok |
| JavaScript parses | `node --check` on `tools/ci/check-workflow-shell.mjs`, `tools/ci/link-check.mjs`, `site/build.mjs` | ok |
| PowerShell parse rule (no `$var:` inside double quotes) | the 0.3.5 scanner over every tracked `.ps1` and the PowerShell strings in `ota.rs`, `jobrun.rs`, `jobbuild.rs` | the 3 baseline hits of 0.3.5 only (two comments in `check-ps51.ps1` that quote the rule; `ota.rs` line 1190, the Mac helper's bash); testnet-prep changed no `.ps1` |
### 3b. The node fork (worktree `vendor/igneum-node-036`)
| Fork tip | Command | Result |
|---|---|---|
| a11455e7 (testnet-params merged, the fee switch) | `cargo test -p kaspa-consensus-core` with `IGNEUM_FAST_TIME_FILE=<igneum-wt-adopt>/infra/fast-time/override-60x.json` (the fast-time test wants every override field; the main checkout's file predates `fees`) | ok: 101 passed, 0 failed, 2 ignored (lib) + 7 (db_compat); among them `igneum_testnet_identity`, `override_params_carry_the_fees`, `override_params_carry_the_fees_v1_activation`, `consensus_digest_keeps_the_0_3_5_value_until_the_fee_switch_is_set` (pins 9409dedac4bf... for the devnet params), `consensus_digest_covers_every_consensus_field_and_nothing_else` (the fee switch in its list), `test_genesis_hashes`, `fast_time_60x_file_is_the_devnet_at_60x`; 07:41Z |
| a11455e7 | `cargo test -p igneum-exec` | ok: 11 passed, 0 failed; among them `the_fee_switch_meters_by_the_block_daa_score` (a block below the switch meters 200 intrinsic pgas and keeps its base fees; the block at the switch meters 300 and its base fees jump to the v1 floors) and the bomb tests re-sized to fit both tables; 07:43Z |
| a11455e7 | `cargo test -p kaspa-consensus --features igneum-pow -- finality` | ok: 8 passed, 0 failed (92 filtered), 125.9 s |
| a11455e7 | `cargo test -p kaspa-consensus --features igneum-pow -- difficulty` | ok: 15 passed, 0 failed |
| a11455e7 | `cargo test -p igneum-miner` | ok: 12 passed, 0 failed (the guard and dev-fee tests) |
| c07093ce (miner-latency merged on the side branch `release-0.3.6-latency`, worktree `vendor/igneum-node-036-lat`, `CARGO_TARGET_DIR=target-036-lat`) | `cargo test -p igneum-miner` | ok: 15 passed, 0 failed (the 12 above and `job_size_follows_the_target_ms`, `cpu_found_nonce_is_matched_to_its_own_work`, `worker_found_hash_is_matched_to_its_own_template`) |
| c07093ce | 3-node fast-time run (section 2) | pass (section 2) |
| cf369022 (the release dev-fee address) | `cargo test -p igneum-miner dev_fee` | ok: 5 passed |
| 2b6d23ef (the final tip: cf369022 plus the latency merge) | `cargo test -p igneum-miner` | ok: 15 passed, 0 failed |
Not built here: the release binaries for the three platforms (section 4b).
## 4. The morning cut, in order
### 4a. The signed inputs come first (G13)
The workflow and `push-inputs.sh` changed as a pair. From this tree on, `.github/workflows/windows.yml` fetches
`payload-inputs.json.sig` from the downloads host and refuses to build until `igneum-ota-sign verify-inputs embedded`
accepts the manifest's signature, the zip's sha256, every unpacked file and the node commit pinned in
`packaging/windows/node-source.pin` in the commit it builds. The downloads host holds no signature today (0.3.5
pushed an unsigned `inputs.json` and a bare `.sha256`), and the pin in this tree still reads 6aa69a45 (the 0.3.4
node, written by the old script). So the FIRST push of the new workflow would fail at "payload inputs" unless the
inputs are pushed from this tree before the workflow runs:
```
cd /Users/joshm/Projects/igneum-wt-adopt
gh auth switch --user igneum-josh
packaging/windows/test-inputs-signing.sh # 16 of 16, with the real key at the end
IGNEUM_NODE_SRC=vendor/igneum-node-036 IGNEUM_WIN_RELEASE=<the 0.3.6 Windows release folder> \
packaging/windows/push-inputs.sh --deploy # signs payload-inputs.json, writes node-source.pin
git add packaging/windows/node-source.pin && git commit -m "inputs: pin node <commit> for 0.3.6"
```
What the workflow needs, and where it comes from:
| Need | Source |
|---|---|
| `DL_TOKEN` repository secret | already set for 0.3.5 (`gh secret set DL_TOKEN < ~/.config/igneum/dl-token`); the workflow fetches the zip, the manifest and the signature with it |
| the embedded public key | compiled into the app (`app/igneum-app/src/manifest.rs`); `igneum-ota-sign embedded` prints it; `push-inputs.sh` refuses to sign if `~/.config/igneum/ota-signing-key.pub` is not that key |
| `~/.config/igneum/ota-signing-key` (0600) and `.pub` | the OTA key the apps already trust; signs the manifest on the Mac |
| `packaging/windows/node-source.pin` | written by `push-inputs.sh`, committed with the push; the runner compares it with the manifest's `node_source_commit` |
| the Windows node exes | `IGNEUM_WIN_RELEASE` names the folder with `igneumd.exe` and `igneum-miner.exe` built from the fork commit the pin names (the 0.3.6 cross-build; `proto-cuda/windows-node/cross-build.sh` as in 0.3.5) |
Then the update manifest is signed only on request: `packaging/windows/fetch-ci-artifacts.sh --sign-manifest <run-id>
--deploy` after the run is green, which re-verifies the run's inputs artifact against the key and the pin at the
run's commit before `publish-manifest.sh` runs.
### 4b. The merge and the ship
From the MAIN checkout, on master, after 4a's pin commit is on `testnet-adopt`:
```
cd /Users/joshm/Projects/igneum
gh auth switch --user igneum-josh
git fetch origin && git checkout master && git pull --ff-only
git merge --ff-only origin/testnet-adopt # testnet-adopt contains master 2054ae3
node tools/ship-app.mjs --check # 0.3.5 in all 6 files
node tools/ship-app.mjs 0.3.6 \
--node vendor/igneum-node-036 \
--win-release <the 0.3.6 Windows release folder> \
--mac-release <the 0.3.6 Mac release folder> \
--notes "Testnet identity and the adopted fee table in the node (devnet unchanged until the fee switch), signed build inputs"
```
The 0.3.6 binaries are NOT built yet (this plan stops at the suites; section 3b says what was and was not built).
Build them from fork 2b6d23ef as 0.3.5's section 4a did: Mac `cargo build --release -j 4 -p kaspad -p igneum-miner
--features kaspad/igneum-pow` with `CARGO_TARGET_DIR=vendor/igneum-node/target-036` (the release build in
`target-036-lat` is of c07093ce, one commit short: no release dev-fee address); Windows through
`proto-cuda/windows-node/cross-build.sh`; Linux through `infra/cross/build-linux.sh`. The push to master triggers
`windows.yml`, which now verifies the inputs of 4a before it builds the app.
## 5. The devnet rollout of the fee floor: a height switch
The live devnet runs the prototype fee set (`B_p` 30,000,000, 1 gwei floors, intrinsic 200). A 0.3.6 node on the
devnet runs exactly that until told otherwise, so 0.3.5 and 0.3.6 nodes build and accept the same segments through
the whole rolling update. The adopted set reaches the devnet by `fees_v1_activation_daa`, the same pattern as
`difficulty_v2_activation_daa` (33,000, 4 October 2026):
| Step | What | Check |
|---|---|---|
| 1 | Ship 0.3.6 (section 4). Every node updates with the switch at its default, never. The consensus digest does not move (the fee fields enter the digest only once the switch or the set leaves the 0.3.5 state), so 0.3.5 and 0.3.6 peers keep handshaking | `Consensus params digest: 9409dedac4bf...` on a node with no override file, the same line 0.3.5 printed; the live devnet nodes (override file with the difficulty and finality switches) print their own unchanged value |
| 2 | Wait until every devnet node is on 0.3.6: the console's Machines card and the user agents in the peer list (`igneumd/2.1.0-<0.3.6 fork commit>`) | no `-20139145` user agent left |
| 3 | Pick the switch height: a DAA score at least 24 hours ahead (86,400 blocks), on a round number, announced in the engineering log and the app's update note | |
| 4 | Publish the switch with the other switches in the update manifest's `consensus.override` (`packaging/ota/publish-manifest.sh --override '{"difficulty_v2_activation_daa": 33000, ..., "fees_v1_activation_daa": N}' --deploy`), and add the line to every hand-run node's override file. The digest moves the moment a node restarts with it; a node that has not restarted is refused by those that have, which is why the restart must sweep every node before N | `Calibrated v1 fees from the override file: ... from DAA score N` in every node's start-up log; one digest across the peer list |
| 5 | At N: the first chain block at or above N meters with v1 (intrinsic 300, `B_p` 120,000, `S_p` 30,000, modexp 10 + 1 per 10 bytes) and its base fees jump to 100 gwei per gas and 10,000 gwei per pgas. Nothing resets; history stays | `igneum_getBudgets` returns `provingGasLimit` 120,000 and the two base fees at the floors; `eth_getBlockByNumber` of the switch block shows `provingBaseFeePerGas` 0x9184e72a000 |
| 6 | The prover's mirror of the table (`proving/igneum-prove/core/src/config.rs`, `pgas.rs`: prototype values at b7fca5a0) must carry v1 before N, or every shard statement after N differs from the node's plan. This is the one change this plan does not make; it re-cuts every fixture at `S_p` 30,000 and changes the guest program id | the fixtures and the guest id in `docs/analysis/base-fee-floor.md` section 4 |
Why a switch and not a fresh chain: the devnet has 12 cloud nodes, three PCs and outside machines on it, with the
difficulty v2 rollout as the precedent that a height switch over a live chain works. Why not the override file's
`fees` object: that changes the rules of every block including the past, so a node restarted with it could not
replay its own history.
## 6. Open after this plan
| Item | State |
|---|---|
| `--netsuffix` default 1 under `--testnet` | one line in `kaspad/src/args.rs`, not done; `igneumd --testnet --netsuffix 1` until then |
| the testnet genesis message | reads `proposed, not final` (the proposal's words, now part of the hashed genesis 52a3e6a9...). Adopted as computed. If the owner wants the words changed, it is one `print_genesis_hashes` run, new hash and merkle root in `genesis.rs`, `test_genesis_hashes` and the README, and it must happen before the first public node, never after |
| the prover's table mirror | section 5 step 6 |
| seed nodes, public RPC, the explorer, the app's testnet build | `docs/testnet/README.md` section 5, unchanged |
| G14 history rewrite | `docs/plans/history-rewrite.md`, merged as a plan, not acted on |
| the inputs push (4a) and the 0.3.6 binaries | not done here |
## 7. What the proving activation taught us (written on master, 5 October 2026, 08:45 BST)
Written 5 October 2026, 08:45 BST, while proving v0 went live on the devnet at DAA 84,100.
## Must ship in 0.3.6
### Must ship in 0.3.6
| Item | Why | Where |
|---|---|---|
@ -12,13 +220,23 @@ Written 5 October 2026, 08:45 BST, while proving v0 went live on the devnet at D
| Rotation phase 2 | Branch `rotation-2` (5317305): `--dl-both`, `tools/logs.mjs --rotation`, fresh-repo script. Plan: `docs/plans/rotation-phase-2.md`. | |
| Testnet parameters behind `fees_v1_activation_daa` | Branch `testnet-prep` and the fork's `testnet-params` (agent in progress). | |
## Operational lessons from the activation (5 October 2026)
### Done (5 October 2026, branch `proving-app`, app side only; the node is unchanged)
| Item | Done | Commit |
|---|---|---|
| The app sets `IGNEUM_PROOF_VERIFIER` for its node | `app/igneum-app/src/verifier.rs` decides once per node start and `engine.rs` passes it to the igneumd spawn. macOS and Linux: `igneum-prove-host` next to the engine's binaries (the DMG's Contents/Resources/bin). Windows: the new `igneum-prove-verify.exe` (`src/bin/prove-verify.rs`, a bin target of the app crate, shipped by `make-payload.sh` next to the engine) is set only when its `--probe` finds a host inside WSL2; it rewrites `--proof` with `wslpath -a`, runs the host in the order of `src/wslhost.rs` and returns its exit code, 2 when there is no host. Trust mode is never the default: the setting `proof_verify_trust` (Settings, "devnet only") sets `IGNEUM_PROOF_VERIFY=trust` only when no verifier was found, and changing it restarts the node. After the WSL2 setup runs on a PC, the prover thread asks for one node restart so the verifier is picked up. | 063a9e7 |
| The prover's WSL2 probe checks both layouts | Order: the payload's `wsl2/bin`, `~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host` (what setup-wsl.sh builds), `~/igneum-prove/target/release/igneum-prove-host`, `/opt/igneum/igneum-prove-host`; one list in `src/wslhost.rs`, shared with the wrapper. The tile's message names every path it looked at, and says when WSL2 did not answer. | 063a9e7 |
| The proving tile shows the verifier state | The prover thread reads `igneum_getProvingStatus().verifier` every 30 s whether proving is on or off; `/api/state` carries `proving.verifier` (the node's words), `verifier_mode` (off, trust, command, unknown), `verifier_set` (what the app passed), `verifier_reason`, `verifier_note` and the pool counts. The tile has a `verifier` row and a note: "This node relays proofs but does not verify them, so it never includes a proof record in its blocks: <why>", "Devnet only: this node trusts proof records without verifying them", or "This node verifies proof records with igneum-prove-host". `site/api/live.mjs` already carried `verifier`; untouched. | 063a9e7 |
The three items are one commit because they share `src/prover.rs` and `src/state.rs`. Not done here: the Windows payload's `wsl2/bin` host binaries (item 2 of the table above, needs the Linux cross-build), rotation phase 2, testnet parameters. The version in `app/igneum-app/Cargo.toml` is still 0.3.5; the ship script bumps it.
### Operational lessons from the activation (5 October 2026)
- Consensus override changes must land on every node at once: a hand node restarted early with a different `proving_v0_activation_daa` was refused by every peer (digest handshake) and sat isolated at a lower height for 20 minutes. Order that works: publish the manifest override, `update-now` to every app, wait for every app node to log the new parameters, then restart the hand nodes and the seed with the same file.
- `scratchpad/restart-hand-nodes.sh` died silently after `igneumd --version` (the 0.3.5 binary exits 1 after printing) under `set -e`; the restart it reported never happened. Every restart script ends by printing the new pids and their start times.
- Switching proving on needs no app restart: `POST <app.url>/api/prove {"on":true}` (the job `prove-on-pc2-84100` does this after installing the CUDA host into `/opt/igneum` for the app's WSL user).
## Instant jobs (5 October 2026)
### Instant jobs (5 October 2026)
Josh: "why is it taking so long for pc2 and pc1s tasks to spin up? can we speed it up?". Before 0.3.6 every app polled
`igneum-jobs.json` every 10 minutes (`CHECK_EVERY_S = 600`), so a job published from the Mac waited up to 10 minutes
@ -47,3 +265,323 @@ ceiling when the relay is down. Numbers below are measured, not estimated.
Not yet done on this branch: the relay deploy (`relay/`, by the owner; the `/wake` route and the 60 s `maxDuration`
go live with it, the `relay_wake` table appears on the first POST), the first live publish, and the Windows curl path
of the long-poll (curl.exe 8.x in System32; the 58 s `--max-time` was reviewed, not run).
## 8. The cut, 5 October 2026 (release engineer, from 09:30 BST)
Josh at 09:30 BST: "can we push 0.3.6 through?". Worktree `/Users/joshm/Projects/igneum-wt-ship036`, branch
`release-0.3.6` from master 31c1b34. Every build through `/Users/joshm/Projects/igneum/tools/lock/with-lock.sh build`
at `nice -n 19` with `-j 4`, the rustup cargo 1.99.0 on `~/.cargo/bin`. Times are UTC unless marked BST.
### 8a. The merges, in order
| Merge | Head | Commit | Conflicts and how they were resolved |
|---|---|---|---|
| `origin/testnet-adopt` | 09baf8e | c9bc6d4 | `docs/spec/05-fees-and-economics.md`: master's 5.10 (security budget, E15) kept as 5.10, the adopted fee table from testnet-adopt became 5.11; its cross references in `docs/analysis/base-fee-floor.md` and this file moved to 5.11. `docs/plans/release-0.3.6.md` (add/add): testnet-adopt's plan is the body, master's "what the proving activation taught us" follows as section 7. `site/index.html`, `site/journey.json`: generated, rebuilt with `node site/build.mjs` (bench, journey 40 entries, index, litepaper, live, evidence, wallet, 404, miners 6 rows) |
| `app-ui` | ce31cbb | 356b3e6 | `.github/workflows/ci.yml`: both test steps kept (the wake test from master, the notice-strip test from app-ui); `node --test app/igneum-app/ui/notices.test.mjs`: 6 pass |
| `proving-app` | 010a372 | ada90aa | `app/igneum-app/src/prover.rs`: proving-app's probe taken (the same hidden `wsl` call as master's 31c1b34, through the shared `wslhost::lookup_script`). This file: proving-app's Done table kept under section 7 |
| `rotation-2` | 5317305 | 50920da | `.github/workflows/windows.yml`: both header comments; rotation-2's "packaged configuration" step runs first, then master's G13 "payload inputs" step (signature, hashes, node commit); the duplicate `dl-token` write in the inputs step dropped (the configuration step writes it). `node tools/ci/check-workflow-shell.mjs`: 12 run blocks, 25 `.ps1`, 0 findings |
`engine.rs`, `state.rs` and `ui/app.js` auto-merged. job-wake was already in master.
### 8b. Changes on top of the merges
| Commit | What |
|---|---|
| 9541543 | Every process the engine starts on Windows is hidden. The four helpers (`detect::run_timeout`, `jobrun::run_capture`, `jobrun::run_streamed`, `procs::spawn`) already set `CREATE_NO_WINDOW`, which is why only 15 of the 89 `Command::new` sites were visibly wrapped; the direct `.output()`/`.spawn()` sites were not: the window host launch (`main.rs`), `icacls` and the three `reg` calls (`platform.rs`), the setup's `cmd /c start` (`prover.rs`), the Mac-only `xattr`/`hdiutil` calls (`ota.rs`, wrapped for a clean audit; `quiet` is a no-op off Windows). `igneum-prove-verify.exe` is now a windows-subsystem binary: the fork's `igneum/exec/src/proving.rs:498` spawns it with a bare `Command::new` from a node that has no console, so a console-subsystem wrapper opened a window on every verification. Sites left as they are: inside `#[cfg(target_os = "macos")]` or `#[cfg(target_os = "linux")]` blocks (scutil, caffeinate, osascript, open, pkexec, xdg-open) and the test-only bash in `manifest.rs` |
| 3811d8e | Every WSL script runs from a file (coordinator, 09:4x BST; PC 2 on 0.3.5 reported "proving needs the WSL2 setup" with `/opt/igneum/igneum-prove-host` present: the inline `bash -lc "<script>"` with `[ -x "$f" ]` and the "Igneum Miner" path reached bash mangled; the same script from a file found the host, job prove-install-pc2-3 09:12 BST). `src/wslhost.rs`: `script_dir()` (`%LOCALAPPDATA%\igneum\wsl`), `write_script` (UTF-8, LF, no BOM, `#!/bin/bash`, unique name per process and call, removed on drop), `bash_line` (`bash [-l] '<file>' '<arg>'...`, every word single-quoted, never a double quote or newline), `command` (on Windows the tail after `--` goes on the command line as written, `CommandExt::raw_arg`, so neither the C runtime's quoting nor the shell inside the distribution re-reads it; arguments reach the script as `$1`, `$2`). Converted: the prover probe and `run_tool` (`prover.rs`), the setup launch (`cmd /c start "" wsl -d <distro> -- bash -l '<setup-wsl.sh>'`), the wrapper's probe and run (`bin/prove-verify.rs`; its `wslpath -a` round trip dropped for the pure mapping, same exposure), jobrun's six inline scripts (prover-probe, wsl-context, gpu-run, prover-kill, build-kill, free-gb). Unit tests: the line never carries a double quote or a newline; the file has LF endings and no BOM and is removed after use; the wrapper's run script ends in `exec "$h" "$@"` |
| 7d7570a | `Igneum Miner 0.3.6: ...`, the six version files (bumped with the tool's own table, checked by `node tools/ship-app.mjs --check`: 0.3.6 in all 6) |
| a98be35 | `push-build-inputs.sh`: the live sha256 check retries for a minute (8c) |
Not changed: the fork's own `Command::new` for the verifier (no flags; the wrapper's subsystem makes it moot).
### 8c. Test results, with the command
| Tip | Command | Result |
|---|---|---|
| 9541543 (merges + hidden windows) | `cargo test -p igneum-app` | ok: 72 (lib) + 26 (bin) + 5 (prove-verify), 0 failed, 08:36Z |
| 9541543 | `cargo build --release -p igneum-app` | ok, 1 min 32 s (target cloned by APFS from the main checkout: 5.5 s) |
| 3811d8e (WSL scripts from files) | `cargo test -p igneum-app` | ok: 75 + 26 + 8, 0 failed, 08:47Z |
| 3811d8e | `cargo check --target x86_64-pc-windows-gnu -p igneum-app` | ok (`raw_arg` and the subsystem attribute compile for Windows) |
| 3811d8e | `cargo build --release -p igneum-app` | ok |
| a98be35 | `packaging/windows/test-inputs-signing.sh` (signer from this tree) | 16 passed, 0 failed, the Mac's real OTA key verified by the key compiled into the app |
| a98be35 | `node tools/ship-app.mjs 0.3.6 --node vendor/igneum-node-036 --branch release-0.3.6 --dl-both --dry-run` | preflight read everything; the only problems were the two Windows exes not yet built (8e) |
| a98be35 | `bash -n packaging/windows/push-build-inputs.sh` | ok |
### 8d. Secrets (values never shown)
| Where | Name | Set | Listing |
|---|---|---|---|
| GitHub repo `igneum-network/igneum` | `LOG_INTAKE_KEY` | 08:36:35Z from `~/.config/igneum/log-intake-key` (`tr -d '[:space:]' \| gh secret set`) | `gh secret list`: DL_TOKEN (04 Oct), DL_TOKEN_NEXT, LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT |
| GitHub | `LOG_INTAKE_KEY_NEXT` | 08:36:36Z from `log-intake-key.next` | same |
| GitHub | `DL_TOKEN_NEXT` | 08:36:37Z from `dl-token.next` | same |
| Vercel project `igneum` (team `igneum`, linked from `site/` in the shared checkout) | `LOG_INTAKE_KEY_NEXT` (production) | 08:3xZ from `log-intake-key.next` | `vercel env ls --scope igneum`: DATABASE_URL, LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT (the function reads it at the next production deploy, which the push to master is) |
### 8e. Node binaries from fork 2b6d23ef
| Platform | Path | sha256 | Size | Build |
|---|---|---|---|---|
| Mac arm64 igneumd | `vendor/igneum-node/target-036/release/igneumd` (copied to `vendor/igneum-node-036/target-integration/release/`) | 64138a1760ebcd5809582b26c7ba2c1e1c1f94f2a8e217287e2d71f97aa4a40b | 40,968,080 | `CARGO_TARGET_DIR=vendor/igneum-node/target-036 cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow`, 8 min 26 s, done 08:45:58Z; `strings`: 2b6d23ef present, 20139145 absent; `igneum-miner --help` lists `--dev-fee`, `--poll-templates`, `--job-ms` |
| Mac arm64 igneum-miner | same folder | ef438be2eaaaedfa767408a319ec5b9dd5178d51e264e618d5a8207f1044b195 | 8,514,864 | same |
| Windows x86-64 igneumd.exe, igneum-miner.exe; Linux igneumd, igneum-miner, igneum-app | PC 1 build job (8f) | see 8f | | |
### 8f. The PC build job (Windows and Linux)
`IGNEUM_WIN_RELEASE=<main checkout>/vendor/igneum-node-036/target-integration/x86_64-pc-windows-gnu/release node tools/build-job.mjs run --node vendor/igneum-node-036 --target ae432dc7 --targets linux,windows --budget-minutes 45 --title "0.3.6 build on PC 1"` from the worktree root (a `vendor` symlink to the main checkout's `vendor/` makes the relative paths resolve; `vendor/` is ignored by git). take3 (release-0.3.5, 20139145) had finished at 08:30:50Z: every stage ok, 6 files uploaded, 5 min of 45.
| Attempt | Published | What happened |
|---|---|---|
| 1 | 08:47:41Z | `push-build-inputs.sh` packed the fork (2b6d23ef) and the app (0.3.6), deployed the downloads folder (build-inputs.zip 8,206,587 bytes, e46ae8d74b892097296f3fc3b4a44ce2b0960f6d3578cf00eccb1fed0c82fcbc) and then read the live `build-inputs.sha256` once, straight after "Aliased": the edge still served the previous file, the check failed and nothing was published. A curl a minute later served e46ae8d7. Fix a98be35: six tries, 10 s apart (publish-jobs.sh already retried) |
| 2 | 08:51:35Z | `build-20261005-085135` published and the apps woken (stamp 2026-10-05T08:51:35Z.852c054f). PC 1 runs 0.3.5, which has no waker (job-wake landed after the 0.3.5 cut): the 10-minute poll applies unless Check now is pressed. Started 08:54:43Z. FAILED: linux node exit 101 after 112 s, windows node exit 101 after 129 s; the app built on both (5 s and 6 s), the test stage passed (igneum-miner, igneum-app), nothing uploaded; 4 min of 45. The uploaded report holds only STAGE and RESULT lines, so the full `app/jobs/build-20261005-085135/job.log` (98,325 bytes) was fetched with a collect job (`collect-20261005-090521`, published 09:05:21Z, run 09:08:57Z, woken +193 s). The errors: `kaspad/src/daemon.rs` `no field fees_v1_activation_daa on type OverrideParams`, `no field fees on type Params`, `no method install_fee_params` (8 errors); `kaspa-rpc-service` `no method prewarm_block_template on MiningManagerProxy`. The zip's sources are right (`params.rs` carries the field 26 times, `mining/src/manager.rs` `prewarm_block_template` 5 times). Root cause: PC 1 keeps `/root/igneum-build/target` between jobs, `unzip` restores the Mac's mtimes (params.rs 07:38Z, executor.rs 07:42Z), and take3 had built 0.3.5 at 08:25 to 08:30Z, so cargo judged `kaspa-consensus-core` and `igneum-exec` fresh (neither printed "Compiling") and linked the new `kaspad` and `kaspa-rpc-service` against the cached 0.3.5 crates. Fix: master ea9794d (`push-build-inputs.sh` stamps every staged file before zipping), cherry-picked as c26d6be; and the PC side, 021a715 (`jobbuild.rs` extract stage: `find "$B/src" -type f -exec touch {} +` after the unzip, the zip root and the sibling igneum-pow, with a unit test on the order unzip, stamp, manifest check), which the 0.3.6 app carries so a packer regression cannot repeat this |
| 3 | 09:12:54Z | `build-20261005-091254` (zip 7c63df808331de5893ef93e5d3f2be65d59998668d1f99325751c5fbed033066, 8,206,958 bytes, the stamped tree; the live sha256 check passed on try 1), budget 60 min for the full rebuild, apps woken (stamp 2026-10-05T09:12:54Z.4fa446f5). Started 09:14:00Z (the 10-minute poll), done 09:20:33Z, 393 s, every stage ok: linux 154 s, windows 187 s, test 20 s (igneum-miner and igneum-app, the app's 74 + 25 + 8), pack 2 s, 6 files uploaded (38 MB). `node tools/build-job.mjs run` kept printing "still running" after the SUMMARY said done (its watcher missed the final state; CLAUDE.md's rule on watchers), so the outputs were fetched with `node tools/build-job.mjs fetch build-20261005-091254`: 6 of 6 verified against the PC's sha256 lines, the three Windows PE headers checked, placed under `vendor/igneum-node-036/target-integration/x86_64-pc-windows-gnu/release/`, `app/igneum-app/target/x86_64-pc-windows-gnu/release/` and `infra/cross/out/` |
| Binary (fork 2b6d23ef, PC 1 job build-20261005-091254) | sha256 | Size |
|---|---|---|
| Windows x86-64 igneumd.exe | d08404c20397fefcc02cd56cad0dd4d29b427a468fd89e3189435f7c3431cd7a | 49,971,712 |
| Windows x86-64 igneum-miner.exe | 8bdb4c6e64190b73ae88cd893c3c2efd8fb0e226b43c0c240c1d545206ed7147 | 10,725,888 |
| Windows x86-64 igneum-app.exe (the PC's build; the installer's engine is the GitHub runner's) | 880e7c81acc1c1a532b8c1b244c70c40406f2b8c4e1420392a2dd8edd7f236ed | 2,834,944 |
| Linux x86-64 igneumd (`infra/cross/out/`, for the seed and HiveOS, not in the app) | c24fd2c5e8c4f976e2b3abc55873bca29ae6b97b47046c946f779d3a04947025 | 48,733,480 |
| Linux x86-64 igneum-miner | 038fcf6b133d0af36d17e28fe822c5bcc7055429958dc280e75459c4347f9a53 | 9,607,440 |
| Linux x86-64 igneum-app | db73814f87c07ee3cada1fec5d728062ae8a8fa3a318fdd94b99f1e6113134fb | 2,297,160 |
PC 2's cold build of the same source gave the same igneum-miner (038fcf6b...) and igneum-app (db73814f...) but another
igneumd (d9d227a9... against c24fd2c5...): the daemon's build is not reproducible across the two machines (unverified
why; build paths or timestamps are the usual causes). Not acted on.
Standing rule from Josh during the cut (CLAUDE.md f378aa0): builds and test suites run on the PCs, the Mac builds only
the macOS binaries. So the fork's suites and the app's tests went to PC 2 as a second build job:
`build-20261005-090600` (09:06:00Z, target 1ccfe586, `--targets linux`, budget 60 min, its own zip
`build-inputs-tests.zip` f60713b133ec7b1b61895bf51f2e82d765e96719c2c1b2bcf1e51aa79c52de45 packed with
`--node-tests "kaspa-consensus-core igneum-exec kaspa-pow kaspa-consensus igneum-miner" --app-tests "igneum-app"`; the
build job cannot skip the build, so PC 2 builds the Linux node first and then runs `cargo test --release` per package).
PC 2 has no build cache, so its zip did not need the stamp. The test stage cannot pass `--features igneum-pow` or a
filter, so `kaspa-consensus` runs its whole suite without the feature; the feature-gated finality and difficulty runs
are the adopt agent's results in 3b. The app tests had already run on this Mac before the rule arrived (8c).
PC 2 result (`build-20261005-090600`, started 09:09:09Z, 399 s): Linux node build ok in 219 s from a cold cache
(igneumd d9d227a93148cf766969cd2856a4b73d0daa13e855f3e4d02f5b06f33fe4428e 48,733,480; igneum-miner
038fcf6b133d0af36d17e28fe822c5bcc7055429958dc280e75459c4347f9a53 9,607,440), Linux app ok in 7 s. Tests, 141 s for
the node packages: `kaspa-consensus-core`, `igneum-exec`, `kaspa-pow`, `igneum-miner` all passed; `kaspa-consensus`
92 passed, 2 failed, 3 ignored. App tests ok: 74 (main) + 25 (ota-sign) + 8 (prove-verify), 0 failed, 5 s.
| Failing test | Where | Why |
|---|---|---|
| `processes::finality::tests::frozen_table_holds_a_side_without_the_other_keys_for_one_window` | the test helper `mine`, `finality.rs:1588`: `validate_and_insert_block(...).await.unwrap()` | `PowCacheQueueFull("pow cache build queue full (4 waiting, 2 building) for epoch edc4fa84... day 1243883")` |
| `processes::finality::tests::reorg_past_an_unlocked_checkpoint_re_determines_it_and_verifies_the_pending_certificate` | the same line | the same error |
The rejection is M30's PoW cache build queue (0.3.5, fud-consensus: at most 2 caches building and 4 waiting per
process). The whole `kaspa-consensus` suite runs its tests in parallel on PC 2 and more than six of them build a
cache at the same moment; the Mac runs of 3b filtered to the finality module (8 tests) and never queued that many.
Whether it is pre-existing is being settled the way the coordinator asked: two more PC 2 jobs with
`--node-tests kaspa-consensus` and nothing else, `build-20261005-091739` on release-0.3.5 20139145
(zip `build-inputs-t035.zip` d1dd841d...) and `build-20261005-091739-036` on 2b6d23ef (`build-inputs-t036.zip`
c01a7525...), published 09:17:39Z and 09:18:06Z (the second `add` within the same second had collided with the
first's generated id; `--id` fixed it).
| Job | Tree | Result |
|---|---|---|
| `build-20261005-091739` | release-0.3.5 20139145 (the SUMMARY names it) | started 09:19:09Z, 203 s: Linux node built in 78 s, `cargo test --release -p kaspa-consensus`: 94 passed, 0 failed, 3 ignored, 110 s. The whole suite alone passes on 0.3.5 |
| `build-20261005-091739-036` | 2b6d23ef | NOT a valid run: both zips were packed at 09:17Z, before the first job built 0.3.5 into PC 2's target dir at 09:20Z, so this job's stamps were older than that build and cargo kept the 0.3.5 crates (the same class as 8f attempt 2, now from the two zips of one pair): Linux node exit 101 after 16 s, and its "94 passed" in 2 s came from the cached 0.3.5 test binaries |
So what is known: the two finality tests pass on 0.3.5 with the suite alone, pass on 2b6d23ef when the finality
module runs alone (3b, the Mac), and fail on 2b6d23ef only in the five-package parallel run on a cold cache
(`build-20261005-090600`), where the error is M30's cache queue (`PowCacheQueueFull`, 4 waiting, 2 building), a
per-process limit that a parallel suite exceeds. The owner's call (coordinator, 09:2x BST): a test-isolation
problem, not a consensus regression; recorded here and in the ledger (tests get a per-process PoW cache
directory, 0.3.7); the ship is not blocked. A clean `kaspa-consensus`-alone run on 2b6d23ef is still owed and goes
after the cut (pack the zip after the previous PC 2 job has built, or wait for the 0.3.6 app's extract stamp).
### 8h2. The DMG
`NODE=<fork>/target-integration/release/igneumd MINER=.../igneum-miner tools/lock/with-lock.sh build packaging/mac/build-dmg.sh`
from the worktree, 09:14 to 09:16Z: `intake key: log-intake-key.next (32 chars, fingerprint 477bb0ef)`,
`manifest: dl-token.next (11 chars, fingerprint ed9c4d2e)`, the two fingerprints the rotation plan requires. That first
DMG was 21,038,829 bytes against 0.3.5's 40,027,384: the 0.3.5 DMG (mounted read-only) ships
`igneum-prove-host` (42,095,744) and `igneum-prove-export` (2,117,456) in `Contents/Resources/bin`, which the
worktree lacks (`proving/igneum-prove/target` is not in git; the main checkout's copy is another build, 55,286,800
bytes). Shipping the Mac node without its verifier would undo 0.3.6's first item, so the two binaries were taken
from the 0.3.5 DMG itself, the files the Mac node runs today (host
4dc6b1c44ccdc83079cbcc5408f9593de9af2680992fea25a00bb58d58c26b84, export
2d1d70f9dcb7724e3e91db55be401a81df7c459b370cc0f4450b178ab7720cd0), and the DMG rebuilt with `PROVE_HOST` and
`PROVE_EXPORT` pointing at them (09:18 to 09:19Z, the same two fingerprints printed). The ship tool's `dmg` step takes
a DMG newer than the bump as built.
| DMG | sha256 | Size |
|---|---|---|
| `packaging/mac/dist/Igneum-Miner-0.3.6.dmg` (build 202610050918) | fddf3580353d87e0b4f9a910894c507eb5b1cab7b500f4cb9d653db34a7af311 | 40,156,607 |
### 8g. The digest check (step 4)
`vendor/igneum-node/target-036/release/igneumd --devnet --override-params-file=/tmp/igneum-devnet/override-v3.json --appdir=<scratch>/digest-036/appdir --rpclisten=127.0.0.1:60985 --listen=127.0.0.1:60986 --nodnsseed --nologfiles --yes` for 20 s (08:48:06Z to 08:48:28Z, then killed; nothing else touched; the override file reads `{"difficulty_v2_activation_daa": 33000, "proving_v0_activation_daa": 84100}`):
```
Proving v0 from the override file: provers paid from DAA score 84100
Difficulty rule v2 from the override file: active from DAA score 33000
Fees on igneum-devnet: pgas table v0, B_p 30000000 pgas, S_p 7500000 pgas, floors 1000000000 wei per gas and 1000000000 wei per pgas; calibrated v1 from DAA score never
Consensus params digest: f10a4eab4b1f4f341d54b0b0221161d1122fac302bca90d6b61d14939b69fbd6 (exchanged in the p2p handshake; a peer with another digest is refused)
igneumd/2.1.0-2b6d23ef
```
The digest equals the live one (difficulty 33000, proving 84100); the fee switch stays "never". Two warnings in the log, neither about the node: UPnP found no free port, and the eth_ RPC port 26790 was held by the live node 1.
### 8h. The live manifest before the cut (read 08:3xZ from `dl/<token>/igneum-app-latest.json`)
| Field | Value |
|---|---|
| version, channel, published_at | 0.3.5, devnet, 2026-10-05T07:20:49Z |
| consensus.override | `{"difficulty_v2_activation_daa": 33000, "proving_v0_activation_daa": 84100}` |
| consensus.activation_height, deadline_note | 84100, "proving v0" |
| fees_v1_activation_daa | absent |
| tuning | absent |
| mac | dmg 2dad2b26..., 40,027,384 |
| windows | inno-setup 0184abec..., 44,447,899 |
`publish-manifest.sh` carries `consensus.override` and `tuning` over from the folder's manifest when not given; `activation_height` and `deadline_note` only come from flags, so the ship command passes `--activation-height 84100 --deadline-note "proving v0"` to keep the consensus object identical field by field.
### 8i. The ship
The push: Josh pushed `release-0.3.6` at 5483322 (09:29Z). `windows.yml` runs only on master pushes, so the run was
dispatched on the branch: `gh workflow run windows.yml --ref release-0.3.6` → run 37290313983, started 09:29:00Z,
green 09:33:40Z (both jobs: the PowerShell and batch parse checks; engine, window host, payload, installer, smoke
run; the G13 inputs step verified the signed payload inputs of 8f against the pin).
Preflight refused the real run on one count, "10 commits behind origin/master" (master had moved to 2766b26: site
pages, litepaper v0.2, the PCs build rule, the packer stamp), so origin/master was merged into the branch (8a79f35)
as the coordinator allowed: `site/build.mjs` takes master's PRODUCT table and PAGES; master's `/wallet` is the
product page and the testnet-prep MetaMask page moved to `/metamask` (in PAGES and the sitemap, the two links on the
home page retargeted); the testnet terms card stays on the home page under master's miner section; litepaper takes
master's Ember wording; `node site/build.mjs` then `node tools/ci/link-check.mjs`: 475 links across 10 pages, 0
broken. Those commits touch no app or packaging file, so the installer built at 5483322 is the release; the ship
state file (`~/.cache/igneum/ship/0.3.6.json`) got `sha` = 5483322, what the tool's own commit step would have
recorded, and the run resumed with `--from ci`.
```
node tools/ship-app.mjs 0.3.6 --node vendor/igneum-node-036 --branch release-0.3.6 --dl-both \
--activation-height 84100 --deadline-note "proving v0" --notes "Instant jobs, a proof verifier for the node, one notice strip, lower miner latency, packaged configuration, hidden helper windows; node 2b6d23ef: testnet identity and the fee table behind a height switch, signed build inputs" \
--from ci
```
| Step | Result | Time |
|---|---|---|
| preflight | ok (tree 8a79f35 clean, 0.3.6 in all 6, fork 2b6d23ef, both exes, both Mac binaries, both folders, gh igneum-josh, live inputs 2b6d23ef) | 09:31:48Z |
| ci | green after 2 min of polling | 09:34Z |
| fetch | Igneum-Miner-Setup-0.3.6.exe 18.6 MB, igneum-windows-app.zip 26.0 MB (`OTA_SKIP=1 CONSOLE_SKIP=1 fetch-ci-artifacts.sh 37290313983`) | |
| dmg | already (8h2) | |
| copy | the DMG into the OLD folder | |
| mirror | 10 files into the NEXT folder, sha256-checked | |
| manifest | `consensus.override` carried over from the folder's 0.3.5 manifest; both manifests signed (key 8f186e37...), verified locally, same fields | |
| deploy | one deploy of the downloads folder | 09:34Z |
The live manifests, read 09:35:00Z, compared field by field with the 0.3.5 record of 8h:
| Field | OLD folder | NEXT folder |
|---|---|---|
| version, channel, min_supported_version | 0.3.6, devnet, 0.3.0 | same |
| published_at | 2026-10-05T09:34:06Z | 2026-10-05T09:34:07Z |
| consensus | identical to the live 0.3.5 object: override {difficulty_v2_activation_daa 33000, proving_v0_activation_daa 84100}, activation_height 84100, deadline_note "proving v0" | identical |
| fees_v1_activation_daa | absent | absent |
| tuning | absent | absent |
| mac | dmg fddf3580353d87e0b4f9a910894c507eb5b1cab7b500f4cb9d653db34a7af311, 40,156,607, URL in its own folder | same bytes, URL in the NEXT folder |
| windows | inno-setup ca0fb9197bee6e3a867a33f4e6bcd2c570c9136444e3587ac311d23bfde61531, 19,536,899, URL in its own folder | same bytes, URL in the NEXT folder |
`igneum-windows-app.zip` cf91659a64afceb0fb4cdb29e7691b5057f1ec264d6fd6d07cd725b23e2cdddc. The 0.3.6 installer is
19.5 MB against 0.3.5's 44.4 MB: the payload no longer carries what the PC builds itself (to confirm from the CI
payload listing; noted, not verified here).
| verify | both folders: the three files HEAD 200 with the local sizes, GET sha256 ok, the manifest signature ok (the first HEAD of the DMG in the OLD folder answered 404 straight after the deploy; the retry 15 s later matched: edge propagation, the same class as 8f attempt 1) | 09:35Z |
| console | item #346 "Igneum Miner 0.3.6 shipped (mac+windows)", then sync-dl | 09:35Z |
The tool ended with exit 0 at 09:35:07Z: "manifest 0.3.6 published 2026-10-05T09:34:06Z; every app checks within the hour".
### 8j. The machines after the publish (manifest live 09:34:06Z)
Watch: `node tools/console.mjs machines` and `node tools/logs.mjs --rotation` every 2 minutes from 09:35Z.
Baseline 09:32Z: all five machines (Mac d937c69d, PC 1 ae432dc7, PC 2 1ccfe586, Sam's Mac 3a9bf309, PC 37ba0461)
on app 0.3.5, node 2.1.0-20139145; rotation 0 of 6 on the new key and folder.
| Machine | Seen on 0.3.6 | What its log shows |
|---|---|---|
| PC 1 ae432dc7 (Windows, the first over-the-air update through the fixed helper) | 09:37:31Z watch tick (the first tick after the restart); the 0.3.6 engine's first line is at 09:35:17Z, 71 s after the manifest went live | run `win-ae432dc7-20261005-093517`: `IGNEUM-APP version=0.3.6 machine=ae432dc7 platform=windows`; `[ok] updated to Igneum Miner 0.3.6 from 0.3.5`; `config: intake ... key 477bb0ef (packaged); manifest .../dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)` (the NEXT key and folder: rotation 1 of 6); `node proof verifier: command (...\igneum-prove-verify.exe)`; `igneumd started (pid 26572)`; `update check: 0.3.6 is current`; `wake: listening at https://relay.igneum.network/wake`, and at 09:36:47Z `update to 0.3.6 complete (from 0.3.5); keeping the previous version for a rollback`; 09:37:15Z a wake stamp change fetched the jobs file within seconds (the instant-jobs path live on Windows). The 0.3.5 engine's own download, stage and helper lines were not uploaded before it quit (its last app-log upload was 09:30:14Z; the node log kept uploading to 09:35:14Z): fetched afterwards with a collect job (below). Then the node: the console showed node 2.1.0-20139145 until 09:44Z, then `2.1.0` with no commit and 0.0 MH/s: the new igneumd.exe does not start (section 9) |
| PC 2 1ccfe586 (Windows) | 09:39:51Z watch tick; engine restart at 09:38:40Z (unix 1791193120: `[ok] updated to Igneum Miner 0.3.6 from 0.3.5`), 4 min 34 s after the manifest went live | run `win-1ccfe586-20261005-093840`: the same header and `config:` fingerprints (477bb0ef, ed9c4d2e); `igneumd started (pid 26476)` 09:38:53Z, then `[error] igneumd exited with code -1073741511 after 6 s; restarting` (0xC0000139, STATUS_ENTRYPOINT_NOT_FOUND), again after 3 s, again after 95 s, and so on: the watchdog restarts it every few seconds; `update to 0.3.6 complete (from 0.3.5); keeping the previous version for a rollback` 09:40:10Z. Mining 0.0 MH/s from the restart (the app marks the update complete on its own health, not the node's: a 0.3.7 item) |
| Mac d937c69d | 09:39:51Z watch tick; engine restart 09:37:35Z (unix 1791193055), 3 min 29 s after the publish | run `mac-d937c69d-20261005-093735`: `[ok] updated to Igneum Miner 0.3.6 from 0.3.5`, fingerprints 477bb0ef/ed9c4d2e, `igneumd started (pid 81496)` from the bundle's bin, `node proof verifier: command (.../igneum-prove-host)` and ten seconds later `node proof verifier reported: command` (the first app node on the network with a verifier), `update to 0.3.6 complete` 09:39:05Z; node 2.1.0-2b6d23ef on the console from 09:39:51Z, mining on |
| Sam's Mac 3a9bf309 | not by 09:48Z (silent 6 min at that tick; its 0.3.5 app checks on its own hourly slot) | |
| PC 37ba0461 | not by 09:48Z (0.3.5, hourly slot) | |
Rotation (`logs.mjs --rotation`): 3 of 6 on the new key and folder from 09:39:51Z (the three machines above), 3 not yet.
## 9. Incident 09:4x BST and the 0.3.7 hotfix
Both PCs took 0.3.6 over the air (the Windows OTA path works: PC 1's engine restarted 71 s after the manifest went live,
PC 2 by 09:39Z), and on both the new igneumd.exe refused to start: "Entry Point Not Found:
_ZNKSt25__codecvt_utf8_utf16_baseIwE10do_unshiftERiPcS2_RS2_ could not be located in igneumd.exe". Mining was down on
both PCs; the Mac (d937c69d) ran 0.3.6 with node 2b6d23ef from 09:39Z. The coordinator republished 0.3.5 in the OLD
folder (0.3.5 machines stay put; the NEXT folder kept 0.3.6) and Josh approved a DLL swap job (fix-runtime-036) and
0.3.7 through the pipeline.
Cause, read with `x86_64-w64-mingw32-objdump -p`: every igneumd.exe so far imports libstdc++-6.dll (0.3.5's Mac
cross-build, 197 symbols; the PC build, 198), `-C link-arg=-static` notwithstanding. 0.3.5 shipped Mac-built exes with
the Mac toolchain's DLL (GCC 16.2.0): a match. 0.3.6 shipped PC-built exes (Ubuntu 24.04's mingw, GCC 13) with the same
Mac DLL, and GCC 16's libstdc++ no longer exports seven symbols the GCC 13 exe imports (five
`std::__codecvt_utf8_utf16_base<wchar_t>` methods, `basic_stringbuf::seekpos`, and one more). The `-static` flag and
the toolchain rule were both assumed, neither checked: the lesson is the gate below.
0.3.7 = release-0.3.6 tip + (commit 7c... "Igneum Miner 0.3.7"):
| Change | Where | Check |
|---|---|---|
| The gate: every symbol an exe imports from a shipped lib*.dll must be exported by that DLL | `packaging/windows/check-runtime-dlls.sh`, run by `push-inputs.sh` before signing and by `make-payload.sh` before the installer is packed (skips with a note where objdump is missing, the runner) | known-bad (the 0.3.6 PC exes with the Mac DLL): FAILED, 7 missing, the morning's symbol first; known-good (0.3.5's Mac exes with the same DLL): 197 of 197 exported, pass |
| DLLs from the toolchain that linked the exes | `push-inputs.sh` takes lib*.dll next to the exes first, then the Mac toolchain (make-payload.sh already did); `jobbuild.rs` windows stage copies the PC toolchain's `libstdc++-6.dll`, `libgcc_s_seh-1.dll` (gcc `-print-file-name`) and `libwinpthread-1.dll` into the pack with RESULT lines; `build-job.mjs` accepts the small DLL PEs and places them next to the exes | unit test on the stage script; the PC path itself runs only once the 0.3.7 app is on PC 1 (the stage scripts come from the app on the PC) |
| `-static-libstdc++` tried | `proto-cuda/windows-node/cross-build.sh` | measured on the 0.3.7 cross-build below: does the import disappear? |
| version 0.3.7 | the six files | `ship-app.mjs --check` |
The 0.3.7 Windows exes come from the Mac cross-build (the 0.3.5 way, Homebrew mingw-w64 GCC 16.2.0, target dir
cloned from `target-release-win`), paired with the same toolchain's DLLs; the PC-built pairing would need the GCC 13
DLLs, which the 0.3.6 app's build job cannot upload. The DMG is rebuilt for the 0.3.7 engine with the same node
binaries (2b6d23ef) and the 0.3.5 prover. The ship publishes to BOTH folders (OLD folder too: the 0.3.5 machines go
straight to 0.3.7).
| 0.3.7 artefact | sha256 | Size | Built |
|---|---|---|---|
| `packaging/mac/dist/Igneum-Miner-0.3.7.dmg` (engine 0.3.7, node 2b6d23ef, the 0.3.5 prover; fingerprints 477bb0ef key, ed9c4d2e folder) | 8ee96b3b054f45721ea08d0e00dfae79fc127fdfa503e05565d117b2c71cd9e2 | 40,156,739 | 09:46 to 09:49Z under the lock |
| Windows igneumd.exe, igneum-miner.exe (Mac cross-build of 2b6d23ef with `-static-libstdc++` added, `CARGO_TARGET_DIR=vendor/igneum-node/target-036-win`, cloned from `target-release-win`; every crate rebuilt because the RUSTFLAGS changed) | see below | | from 09:43Z |
The helpers' own logs (`app/ota-apply.log`, collect job `collect-20261005-093948`, published 09:39:48Z, run by the
three 0.3.6 machines within four minutes; Sam's Mac had not fetched it by 09:50Z):
| Machine | Helper line | Then |
|---|---|---|
| PC 1 ae432dc7 | `2026-10-05T10:35:10 apply : engine 24000 installer '...\updates\Igneum-Miner-Setup-0.3.6.exe' version 0.3.6 (the engine keeps mining until the installer runs)`, `installer sha256 verified` (BST; 09:35:10Z, 64 s after the manifest went live) | the installer ran, the 0.3.6 engine's first line at 09:35:17Z: the whole Windows path, check to restart, took 71 s and the helper launched with CREATE_NO_WINDOW alone did run (the 0.3.0 to 0.3.4 stall is gone) |
| PC 2 1ccfe586 | `2026-10-05T10:38:34 apply : engine 14028 installer '...Igneum-Miner-Setup-0.3.6.exe' version 0.3.6`, `installer sha256 verified` (09:38:34Z; its log also holds the 0.3.4 attempt of 4 October: `sha256 mismatch`, the stall of that day) | engine restart 09:38:40Z |
| Mac d937c69d | `2026-10-05T09:37:30Z apply: engine 63267 host 63263 ... version 0.3.6`, `staged bundle digest verified`, `swapped; opening /Applications/Igneum Miner.app`, `0.3.6 is running` | engine restart 09:37:35Z |
Correction from the PCs (coordinator, 09:5x BST): with the matching GCC 13 DLLs swapped in, the PC-built igneumd.exe
(1dcac192...) prints `--version` and satisfies all 182 libstdc++ imports, yet exits within 2 s of "Logs to console
only" on PC 1 on the real database and on a scratch appdir (job probe-node-pc1-036), no stderr, empty exit code: a
loader or abort, not a Rust panic. So the PC-built Windows node is unusable at runtime, not only mislinked, and does
not ship. 0.3.7's igneumd.exe and igneum-miner.exe are the Mac cross-build (2b6d23ef, this Mac's mingw GCC 16.2.0)
with that toolchain's DLLs, packaged exactly as 0.3.5 was. Both PCs are being rolled back to 0.3.5 with the silent
installer (job rollback-035-pcs) and will read the OLD folder, so 0.3.7 goes to both folders.
Open item for 0.3.8: why the PC-built node dies at start. Reproduce under WSL with wine or on PC 2 with a scratch run;
compare Ubuntu's mingw (GCC 13, posix threads) with the Mac's (GCC 16); check `-static` against rocksdb's thread
model. Until then the PC build job builds Linux binaries and tests; the Windows node comes from the Mac cross-build.
Watch end, 09:59:38Z (every 2 minutes from 09:35:12Z): Mac d937c69d on 0.3.6 with node 2b6d23ef and mining
throughout from 09:39Z; PC 1 and PC 2 on app 0.3.6 from 09:37Z and 09:39Z with the node dead (section 9), 0.0 MH/s;
Sam's Mac and PC 37ba0461 never left 0.3.5 within the window (hourly slots; Sam's Mac silent from 09:41Z). Rotation
peaked at 3 of 6 (09:39 to 09:54Z) and read 1 of 6 at 09:56:30Z, when the PCs' latest uploads carried the old key and
folder again: the coordinator's 0.3.5 rollback (job rollback-035-pcs) landing. The console's app column for the PCs
lags their next report.
The 0.3.7 Windows exes (Mac cross-build, `proto-cuda/windows-node/cross-build.sh vendor/igneum-node-036 4`,
`CARGO_TARGET_DIR=vendor/igneum-node/target-036-win`, 25 min 32 s, 09:43 to 10:08Z, every crate rebuilt because the
RUSTFLAGS changed):
| File | sha256 | Size | lib*.dll imports |
|---|---|---|---|
| igneumd.exe | 98a40af986eb2b722f69203ca264ccc3720649682a3832672bf6a1beb70896a9 | 50,773,504 | libstdc++-6.dll (197 symbols); `-C link-arg=-static-libstdc++` changed nothing, as the script's own comment on `-static-libstdc++` warned: the gcc driver rustc links through ignores it. The flag stays as a record; the DLL pairing is the rule |
| igneum-miner.exe | eb77bf93d0949aa4e38cd2c428c562e991ab96302b6b7bcf8badc8f077d75ce0 | 10,778,624 | none |
Both embed 2b6d23ef. The gate with this Mac's toolchain DLLs (GCC 16.2.0): igneumd.exe, all 197 imported symbols
exported (5,994 exports), pass. The PC-built pair (d08404c2..., 8bdb4c6e...) is kept in the session scratchpad
(`pc-built-036/`) for the 0.3.8 item and is no longer under `target-integration`.

View file

@ -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/<new token>/` 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/<file> | 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/<old>/` 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/<new>/` 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 <url> key <fp> (<source>); manifest <url with the token masked> folder <fp> (<source>)`; `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/<dl-token.next>/`, 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/<old>/igneum-app-latest.json`: 0.3.6 is there (published in BOTH folders), signed by the same key | fetches `dl/<new>/igneum-app-latest.json`: itself |
| download | the URL inside the old folder's manifest, `dl/<old>/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-josh
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 <fork worktree> --notes "<one line>" --dl-both --dry-run # the plan, nothing written
node tools/ship-app.mjs 0.3.6 --node <fork worktree> --notes "<one line>" --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/<token>/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 <folder>` and
`--base-url <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="<one line>"
# 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/<old>/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=<id8> platform=<os> node=<igneumd version>
config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (packaged); manifest https://dl.igneum.network/dl/<token>/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 <run_id> | 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-josh` to a neutral handle (the numeric noreply id 337424239
stays, so the rewritten author line is `<new> <337424239+<new>@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=<dir>/git_filter_repo.py tools/repo/fresh-repo.sh --new-login <new handle> --new-repo igneum-network/<name> \
[--public-claude-md <scrubbed 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/<name> --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-josh <337424239+igneum-josh@users.noreply.github.com>`) |
| 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 (`<work>/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.

View file

@ -82,7 +82,10 @@ Designed, Open (O-5.3). Each block carries a bitfield; bit b set means "this blo
| Upgrade window, activation delay | not set | Open (O-5.3) |
| Shard assignment | sortition, 8 provers, 10-s window, no bond (section 7.2) | Designed (3 October 2026) |
| External job bond, claim timeout | not set | Open (O-5.6) |
| Proving-cost budget per block | from the phase 2 measurement | Target |
| Proving-cost budget per block `B_p` | 120,000 pgas (section 5.10) | Adopted (5 October 2026, as proposed on 4 October 2026; was Target) |
| Shard budget `S_p` | 30,000 pgas (section 5.10) | Adopted (5 October 2026, as proposed on 4 October 2026) |
| Base-fee floors `f_e`, `f_p` | 100 gwei per gas, 10,000 gwei per pgas (section 5.10) | Adopted (5 October 2026, as proposed on 4 October 2026) |
| pgas table | version 1: intrinsic 300, modexp 10 + 1 per 10 bytes, other entries prototype (section 5.10) | Adopted (5 October 2026, as proposed on 4 October 2026) |
| Emission to any treasury | 0 | Designed |
| Protocol fee to any team, foundation or fund | 0 | Designed (3 October 2026) |
| Tail emission | 0; the 4,000,000,000 IGN cap of section 2.5 is absolute | Decided (5 October 2026, section 5.10) |
@ -128,3 +131,31 @@ REVIEW TRIGGER (rule). If external proving revenue is under one fifth of the blo
- The reading is made by people from chain data and published. The chain computes nothing and changes nothing.
- The vote: a tail reward changes a consensus constant fixed at genesis, so it is an upgrade under 5.7 (90% of blue blocks over the window of O-5.3), not a 60% parameter. Anyone may register the proposal under 5.8 once the condition has held; a proposal that fails may be re-registered after the next qualifying window.
- Nothing in this rule obliges the vote to pass. The cap is the default; a tail reward is a change miners may adopt, and the protocol never adopts it by itself.
## 5.11 Base-fee floors, the proving budget and the pgas table (Adopted 5 October 2026)
Proposed on 4 October 2026 and adopted by the owner on 5 October 2026, as proposed (the sign-off is recorded in
`docs/plans/release-0.3.6.md`). The arithmetic is in `docs/analysis/base-fee-floor.md`; the values are implemented
in the node fork (`consensus/core/src/fees.rs`, `FeeParams::CALIBRATED_V1`, carried by `Params.fees` per network
and by the override file; merged into the fork's `release-0.3.6`). The testnet and the mainnet run them from
genesis. The devnet and the simnet keep the prototype values (both base fees 1 gwei with a 1 gwei floor, `B_p` =
`B_e` = 30,000,000, intrinsic 200, modexp 1,000 + 10 per byte; `FeeParams::PROTOTYPE`) until the
`fees_v1_activation_daa` height switch, carried by the override file and the consensus digest, moves them: chain
blocks at or above that DAA score meter with the adopted table, budgets and floors, and the first such block raises
both base fees to the floors.
| Parameter | Adopted | Basis |
|---|---|---|
| pgas unit | 1 pgas = 1,000 reference SP1 cycles | unchanged |
| Intrinsic pgas per transaction | 300 | measured upper bound: 1,400 to 1,600 cycles per prototype pgas on a plain-transfer shard (bench-log, 4 October) |
| modexp entry | 10 + 1 per 10 input bytes | measured: 9 cycles per prototype pgas on a modexp-heavy shard, the prototype entry about 100x its cost |
| Other opcode and precompile entries | prototype shapes, table version 1 | not yet measured |
| Shard budget `S_p` | 30,000 pgas (30 M cycles) | about 5.5 s compressed on one RTX 5090 (half the measured 60 M-cycle shard at 10.9 s, approximate); about 20 s on a 12 GB card (approximate) |
| Block proving budget `B_p` | 120,000 pgas (4 x `S_p`) | 400 transfers per block; a four-shard block proves in about 8 s on four RTX 5090s (approximate) |
| Execution base-fee floor `f_e` | 100 gwei per gas | a full block burns 3 IGN, 9.5% of the year-one subsidy; 259,200 IGN per day |
| Proving base-fee floor `f_p` | 10,000 gwei per pgas | 230x the proving electricity per pgas at an assumed $0.10 per IGN and $0.15 per kWh |
| Initial base fees | the floors | |
| Adjustment | EIP-1559 toward half the limit, denominator 8, both dimensions | unchanged |
| A plain transfer at the floor | 21,000 x 100 gwei + 300 x 10,000 gwei = 0.0051 IGN | under $0.01 for any token price up to $1.96 (assumption, not a forecast) |
The floors and `B_p` are parameters the genesis rules leave to miners (5.5): they move by 60% signalling.

83
docs/testnet/README.md Normal file
View file

@ -0,0 +1,83 @@
# Igneum public testnet: identity, parameters and reset policy (ADOPTED 5 October 2026)
Every value in this file was proposed on the night of 4 October 2026 and ADOPTED by the owner on 5 October 2026,
as proposed (sign-off recorded in `docs/plans/release-0.3.6.md`). The genesis below is the one the proposal
computed; its values did not change at the sign-off, so the hash stands. The code was written on the node fork's
branch `testnet-params` (worktree `vendor/igneum-node-testnet`, forked from `finality-fixes` at `6aa69a45`) and
merged on 5 October 2026 into the fork's `release-0.3.6` (worktree `vendor/igneum-node-036`), with one change the
sign-off added: the devnet and the simnet keep the prototype fee set until a height switch
(`fees_v1_activation_daa`) or a `fees` object in the override file moves them; the testnet and the mainnet carry
calibrated v1 from genesis (section 3). Nothing is deployed; the testnet has no seed nodes yet (section 5).
## 1. Identity
| Field | Devnet (live today) | Testnet (adopted) | Where |
|---|---|---|---|
| Network id (handshake string, data directory) | `igneum-devnet` | `igneum-testnet-1` | `consensus/core/src/network.rs`, `NetworkId::to_prefixed` |
| Network type and suffix | Devnet, none | Testnet, suffix 1 | `config/params.rs`, `From<NetworkId>`: only suffix 1 resolves; any other suffix is refused |
| EVM chain id | 4463 | 4462 | `consensus/core/src/evm.rs`, `evm_chain_id` (mainnet 4461) |
| Address prefix | `igneumdev` | `igneumtest` | `crypto/addresses` |
| gRPC port | 26610 | 26810 | `network.rs`, `default_rpc_port` |
| P2P port | 26611 | 26811 | `network.rs`, `default_p2p_port` (a later suffix takes the next port) |
| wRPC Borsh, JSON | 27610, 28610 | 27810, 28810 | `network.rs` |
| EVM JSON-RPC port | 26790 | 26890 | `igneum/exec/src/config.rs`, `default_evm_rpc_port` |
| DNS seeders | none | none (the list is filled when the seed nodes exist, `docs/plans/seed-nodes.md`) | `TESTNET_PARAMS.dns_seeders` |
| Override file (`--override-params-file`) | allowed | refused, as on mainnet | `kaspad/src/daemon.rs` |
| `IGNEUM_POW_*` environment | ignored by the node from this branch (G12: the params' schedule is installed on every start) | ignored | `kaspad/src/daemon.rs` |
Start a node on it: `igneumd --testnet --netsuffix 1` (the flag `--testnet` is "Use the Igneum test network";
`--netsuffix` defaults to 10 in `kaspad/src/args.rs` and must be set to 1 until the default is changed, which is
one line and waits for the sign-off).
## 2. Genesis
| Field | Value | Note |
|---|---|---|
| Timestamp | 1,791,158,400,000 ms = 2026-10-05T00:00:00Z (`0x1a1095c3400`) | frozen; adopted 5 October 2026 |
| Bits | `0x1d100000` (2^28 expected hashes per block) | the devnet's launch difficulty, sized for a few hundred MH/s; the DAA takes over after 150 samples (600 blocks); re-size to the announced launch fleet |
| Nonce, DAA score | 0, 0 | |
| UTXO commitment | empty | |
| Coinbase payload message | `igneum-testnet-1 \| 2026-10-05 \| proposed, not final \| coins here have no value \| resets are announced` | after the OP-FALSE script, as the devnet's `igneum-devnet`. The words "proposed, not final" are the proposal's and are part of the hashed genesis; the sign-off adopted the genesis as computed. Changing the message is one `print_genesis_hashes` run and a new hash, and must happen before the first public node starts, never after (`docs/plans/release-0.3.6.md`, section 6) |
| Hash | `52a3e6a9ddd79d603ff9e3a27487fb5d0ca5ca6f633fe20ca727e1faa355fc7e` | computed 4 October 2026 by `print_genesis_hashes` on the branch (pinned by `test_genesis_hashes`, re-run green on `release-0.3.6` on 5 October 2026); changes with any field above |
| Merkle root | `3060aab78494de3cbda68619c0132300aa486dc3153dbb78b50a430f053b3a84` | same |
## 3. Consensus parameters
| Parameter | Testnet (adopted) | Devnet today | Why |
|---|---|---|---|
| Block rate | 1 per second | 1 per second | spec 02 |
| Difficulty rule | Igneum dual-lane, v2 from genesis | dual-lane, v2 from DAA 33,000 | a fresh chain has no pre-switch history |
| Finality parameters | `FinalityParams::MAINNET`: 30-day weight window (2,592,000 DAA), dust 100, presence 240, 8 aggregators, 30-day equivocation ban, min DAA 2,592,000, certificate fold 6 | `DEVNET`: 2-hour window, dust 5, presence 20, fold 3 | the testnet runs the rule the mainnet will run; the first lock needs 30 days of weight, which is the point of a testnet |
| Finality rule v3 | from genesis | from the override file | fresh chain |
| Proving v0 payouts | from genesis | from the override file | fresh chain |
| PoW schedule | epoch 3,600 DAA, lead 600, day 86,400,000 ms (the defaults) | same | |
| Coinbase payload limit | 16,384 (the finality section) | same | |
| Fees | `FeeParams::CALIBRATED_V1` from genesis (`fees_v1_activation_daa` 0; `docs/analysis/base-fee-floor.md`): `B_p` 120,000 pgas, `S_p` 30,000, intrinsic 300, modexp 10 + 1 per 10 bytes, floors 100 gwei per gas and 10,000 gwei per pgas | `FeeParams::PROTOTYPE` (`B_p` 30 M, 1 gwei), kept on the 0.3.6 node so the live chain does not change rules between builds; moves to v1 by the `fees_v1_activation_daa` height switch in the override file (`docs/plans/release-0.3.6.md`, section 5) | spec 05 section 5.10 |
| Emission | the mainnet schedule: 31.69 IGN per block in year one, halving every two years, cap 4 billion | same | the testnet coins have no value whatever the schedule says |
Everything else (mass limits, GHOSTDAG k, merge depth, pruning) is the devnet's set, unchanged.
## 4. Reset policy
| Rule | Adopted 5 October 2026 |
|---|---|
| Coins | Testnet IGN has no value, cannot be bought, sold or redeemed, and is not a claim on anything at mainnet. Mainnet starts from an empty genesis. No airdrop, no points, no promise tied to a testnet balance |
| When the chain resets | When a consensus rule changes (the lottery hash, the difficulty rule, finality, the fee table, the execution rules) and a height switch is not the right tool; or when the chain is broken beyond a height switch |
| Notice | At least N = 7 days ahead, on igneum.network (the download page and the live page), in the app through the update manifest's note, and in the engineering log. A reset without notice is a bug report, not a policy |
| What carries over | Nothing. Balances, contracts, nonces and history start again. Addresses stay valid (an address is a key) |
| Identity after a reset | A consensus-changing reset takes the next suffix (`igneum-testnet-2`, chain id unchanged at 4462, P2P port 26812), so a node on the old rules never completes a handshake with the new chain |
| The devnet | Keeps resetting without notice; it is a developer network. Its chain id 4463 stays separate |
| Who decides | The project, by the sign-off that this file records; the parameters the genesis rules leave to miners (`B_p`, the floors) move by 60% signalling once the testnet has miners (spec 5.5) |
## 5. What stands between this file and a public testnet
| Item | State |
|---|---|
| Sign-off of every value above | done: adopted by the owner on 5 October 2026, as proposed |
| `--netsuffix` default 1 under `--testnet` | one line in `kaspad/src/args.rs`, still to do (not in the 0.3.6 merge; `--netsuffix 1` must be passed until then) |
| Seed nodes and the DNS seeder list | `docs/plans/seed-nodes.md`; the list in `TESTNET_PARAMS` is empty on purpose |
| The public RPC and explorer | `site/wallet.html` carries a placeholder RPC URL until they exist |
| The prover's table mirror and fixtures | `docs/analysis/base-fee-floor.md` section 4 |
| The app's testnet build | the app's packaged config names the network; `igneum-testnet-1` needs the network and ports there (`app/igneum-app/src/config.rs`, `Runtime.network`) |
| The params digest in the handshake (X18) | separate work, before the testnet |
| Terms on the download page | `site/index.html#testnet-terms`, on branch `testnet-prep` with this file |

View file

@ -53,5 +53,7 @@
"pow_day_ms": 1440000,
"difficulty_v2_activation_daa": 18446744073709551615,
"proving_v0_activation_daa": 18446744073709551615,
"finality_v3_activation_daa": 18446744073709551615
"finality_v3_activation_daa": 18446744073709551615,
"fees_v1_activation_daa": 0,
"fees": {"pgas": {"version": 1, "cycles_per_pgas": 1000, "intrinsic_pgas_per_tx": 300, "modexp_base": 10, "modexp_per_byte_numer": 1, "modexp_per_byte_denom": 10}, "block_proving_gas_limit": 120000, "shard_proving_gas_budget": 30000, "min_execution_base_fee_wei": 100000000000, "min_proving_base_fee_wei": 10000000000000, "initial_execution_base_fee_wei": 100000000000, "initial_proving_base_fee_wei": 10000000000000, "base_fee_change_denominator": 8}
}

View file

@ -5,7 +5,14 @@
# ./upload.sh <logfile> <label> [run_id]
set -euo pipefail
IGNEUM_LOG_URL="${IGNEUM_LOG_URL:-https://igneum-six.vercel.app/api/log}"
IGNEUM_LOG_KEY="${IGNEUM_LOG_KEY:-okLO0nuvYzKkgNCWL5WNW37e}"
# the key: IGNEUM_LOG_KEY, else the file named by IGNEUM_INTAKE_KEY_FILE, else ~/.config/igneum/log-intake-key.next when
# staged, else ~/.config/igneum/log-intake-key (rotation phase 2, 5 October 2026: no key literal in the repository)
if [ -z "${IGNEUM_LOG_KEY:-}" ]; then
kf="${IGNEUM_INTAKE_KEY_FILE:-}"
[ -n "$kf" ] || { [ -f "$HOME/.config/igneum/log-intake-key.next" ] && kf="$HOME/.config/igneum/log-intake-key.next" || kf="$HOME/.config/igneum/log-intake-key"; }
[ -f "$kf" ] && IGNEUM_LOG_KEY="$(tr -d '[:space:]' < "$kf")" || IGNEUM_LOG_KEY=""
fi
[ -n "$IGNEUM_LOG_KEY" ] || { echo "upload: no intake key (set IGNEUM_LOG_KEY or IGNEUM_INTAKE_KEY_FILE)"; exit 3; }
[ $# -ge 2 ] || { echo "usage: upload.sh <logfile> <label> [run_id]"; exit 1; }
file="$1"; label="$2"; run_id="${3:-${IGNEUM_RUN_ID:-$label-$(date -u +%Y%m%d-%H%M)}}"
[ -f "$file" ] || { echo "upload: file not found: $file"; exit 2; }

View file

@ -11,7 +11,7 @@
<key>CFBundleVersion</key>
<string>VERSION_STAMP</string>
<key>CFBundleShortVersionString</key>
<string>0.3.5</string>
<string>0.3.7</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>

View file

@ -1,27 +1,70 @@
#!/bin/bash
# The configuration the packagers write next to the engine (igneum-app.json): the update manifest URL (the download
# token is read from ~/.config/igneum/dl-token and never lives in the repo), the log intake (the key only authorises
# log uploads and is meant to ship, as the 0.2.0 launchers did), the live page. Sourced by build-dmg.sh; the Windows
# make-payload.sh reads LOG_KEY from this file.
LOG_URL="https://igneum-six.vercel.app/api/log"
LOG_KEY="okLO0nuvYzKkgNCWL5WNW37e"
# The configuration the packagers write next to the engine (igneum-app.json): the update manifest URL, the log intake,
# the live page. Sourced by packaging/mac/build-dmg.sh and packaging/windows/make-payload.sh (on the Mac and on the
# GitHub runner alike). Run on its own (`packaging/mac/packaged-config.sh --test`) it checks itself.
#
# No secret lives in this file (rotation phase 2, 5 October 2026: the intake key used to be a literal here and is in
# eight commits of the history; docs/plans/rotation-phase-2.md and docs/plans/history-rewrite.md). Both values come
# from files named by two environment variables, each defaulting to the NEXT value when one is staged:
#
# IGNEUM_INTAKE_KEY_FILE the log intake key (authorises log uploads only; it ships inside every package).
# Default: ~/.config/igneum/log-intake-key.next when that file exists, else
# ~/.config/igneum/log-intake-key.
# IGNEUM_DL_TOKEN_FILE the downloads path token: the manifest is https://dl.igneum.network/dl/<token>/igneum-app-latest.json.
# Default: ~/.config/igneum/dl-token.next when that file exists, else ~/.config/igneum/dl-token.
#
# So the 0.3.6 build carries the rotated key and checks the manifest in the NEW folder with no flag at all, while a
# build that must target the old folder says so: IGNEUM_DL_TOKEN_FILE=~/.config/igneum/dl-token. When the rotation is
# over, `mv log-intake-key.next log-intake-key` and `mv dl-token.next dl-token` make the defaults the plain files
# again. A missing or empty token file disables the update check (the note says so); a missing or empty key file
# disables log uploads. Values are never printed, only the file names and the values' lengths.
LOG_URL="${IGNEUM_INTAKE_URL:-https://igneum-six.vercel.app/api/log}"
LIVE_PAGE="https://igneum.network/live"
DOWNLOAD_PAGE="https://igneum.network/#mine"
TOKEN_FILE="$HOME/.config/igneum/dl-token"
DL_HOST="https://dl.igneum.network"
# Consensus parameters pinned into the package for the bundled node: igneum-app.json "node_override_params"; the engine
# writes them to <app data>/override-params.json and starts igneumd with --override-params-file. Empty = no override
# file, the node runs the network's defaults. Difficulty v2 (4 Oct 2026): the version that bundles igneumd v2 must
# carry the devnet's activation height here, the same N as every other devnet node, before it is cut (Mac and CI alike:
# make-payload.sh reads this line). Rule and order: docs/plans/difficulty-v2-rollout-devnet.md.
# make-payload.sh sources this file). Rule and order: docs/plans/difficulty-v2-rollout-devnet.md.
# Example: NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": 123456}'
NODE_OVERRIDE_PARAMS=''
# writes the JSON to $1
# igneum_secret_file <env var name> <base name> -> the file to read: the variable when set, else <base>.next when it
# exists, else <base>; IGNEUM_CONFIG_DIR (tests) replaces ~/.config/igneum
igneum_secret_file() {
local var="$1" base="$2" dir="${IGNEUM_CONFIG_DIR:-$HOME/.config/igneum}" set_value=""
set_value="${!var:-}"
if [ -n "$set_value" ]; then printf '%s' "$set_value"
elif [ -f "$dir/$base.next" ]; then printf '%s' "$dir/$base.next"
else printf '%s' "$dir/$base"
fi
}
# igneum_read_trimmed <file> -> the file's content without whitespace, or nothing when the file is missing or blank
igneum_read_trimmed() {
[ -f "$1" ] && tr -d '[:space:]' < "$1" || true
}
# igneum_manifest_url <token> -> the manifest URL for that downloads folder; nothing for an empty token
igneum_manifest_url() {
[ -n "$1" ] && printf '%s/dl/%s/igneum-app-latest.json' "$DL_HOST" "$1" || true
}
# igneum_fingerprint <value> -> the first 8 hex of sha256 over the value, for logs and the app's header (never the value)
igneum_fingerprint() {
printf '%s' "$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8
}
# writes the JSON to $1; prints which files were used (names), the lengths and the fingerprints, never the values
write_packaged_config() {
local out="$1" token="" manifest=""
[ -f "$TOKEN_FILE" ] && token="$(tr -d '[:space:]' < "$TOKEN_FILE")"
[ -n "$token" ] && manifest="https://dl.igneum.network/dl/$token/igneum-app-latest.json"
[ -n "$manifest" ] || echo "note: no $TOKEN_FILE; the update check is disabled in this build"
local out="$1" token="" manifest="" key="" key_file="" token_file=""
key_file="$(igneum_secret_file IGNEUM_INTAKE_KEY_FILE log-intake-key)"
token_file="$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)"
key="$(igneum_read_trimmed "$key_file")"
token="$(igneum_read_trimmed "$token_file")"
manifest="$(igneum_manifest_url "$token")"
if [ -n "$key" ]; then echo "intake key: $key_file (${#key} chars, fingerprint $(igneum_fingerprint "$key"))"
else echo "note: no key in $key_file; log uploads are disabled in this build"; fi
if [ -n "$manifest" ]; then echo "manifest: $token_file (${#token} chars, fingerprint $(igneum_fingerprint "$token")) -> ${manifest//$token/<token>}"
else echo "note: no token in $token_file; the update check is disabled in this build"; fi
local override_line=""
[ -n "$NODE_OVERRIDE_PARAMS" ] && override_line=" \"node_override_params\": $NODE_OVERRIDE_PARAMS,"
cat > "$out" <<JSON
@ -29,9 +72,61 @@ write_packaged_config() {
$override_line
"update_manifest": "$manifest",
"log_intake_url": "$LOG_URL",
"log_intake_key": "$LOG_KEY",
"log_intake_key": "$key",
"live_page": "$LIVE_PAGE",
"download_page": "$DOWNLOAD_PAGE"
}
JSON
}
# ---- self-test: packaging/mac/packaged-config.sh --test (temporary files only; nothing under ~/.config is read) -----
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -euo pipefail
[ "${1:-}" = "--test" ] || { echo "usage: $0 --test (otherwise source this file)" >&2; exit 2; }
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
PY="$(command -v python3 || command -v python)" # the GitHub Windows runner's Git Bash may only have python
fails=0
check() { if [ "$2" = "$3" ]; then echo " ok $1"; else echo " FAIL $1: got '$2', want '$3'"; fails=$((fails + 1)); fi; }
export IGNEUM_CONFIG_DIR="$T/cfg"; mkdir -p "$T/cfg"
unset IGNEUM_INTAKE_KEY_FILE IGNEUM_DL_TOKEN_FILE
# 1. nothing staged: the plain files
check "default key file is the plain one" "$(igneum_secret_file IGNEUM_INTAKE_KEY_FILE log-intake-key)" "$T/cfg/log-intake-key"
check "default token file is the plain one" "$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)" "$T/cfg/dl-token"
# 2. a .next file wins
printf 'nextkeyvalue0123456789abcdef\n' > "$T/cfg/log-intake-key.next"
printf 'plainkeyvalue0123456789abcdef\n' > "$T/cfg/log-intake-key"
printf 'tok2new\n' > "$T/cfg/dl-token.next"
printf 'tok1old\n' > "$T/cfg/dl-token"
check ".next key file wins when present" "$(igneum_secret_file IGNEUM_INTAKE_KEY_FILE log-intake-key)" "$T/cfg/log-intake-key.next"
check ".next token file wins when present" "$(igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)" "$T/cfg/dl-token.next"
# 3. the variable beats both
check "the variable names the file" "$(IGNEUM_DL_TOKEN_FILE="$T/cfg/dl-token" igneum_secret_file IGNEUM_DL_TOKEN_FILE dl-token)" "$T/cfg/dl-token"
# 4. reading trims; a missing file reads as nothing
check "trimmed read" "$(igneum_read_trimmed "$T/cfg/dl-token.next")" "tok2new"
check "missing file reads empty" "$(igneum_read_trimmed "$T/cfg/none")" ""
check "manifest url" "$(igneum_manifest_url tok2new)" "$DL_HOST/dl/tok2new/igneum-app-latest.json"
check "empty token gives no manifest" "$(igneum_manifest_url '')" ""
check "fingerprint is 8 hex" "$(igneum_fingerprint abc | grep -cE '^[0-9a-f]{8}$')" "1"
check "fingerprint of abc" "$(igneum_fingerprint abc)" "ba7816bf"
# 5. the written JSON: defaults pick the .next pair; the values land; nothing printed carries them
out="$(write_packaged_config "$T/a.json")"
check "output names the .next key file" "$(printf '%s' "$out" | grep -c 'log-intake-key.next')" "1"
check "output never carries the key" "$(printf '%s' "$out" | grep -c 'nextkeyvalue')" "0"
check "output never carries the token" "$(printf '%s' "$out" | grep -c 'tok2new')" "0"
check "json carries the .next key" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["log_intake_key"])' "$T/a.json")" "nextkeyvalue0123456789abcdef"
check "json manifest points at the .next folder" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["update_manifest"])' "$T/a.json")" "$DL_HOST/dl/tok2new/igneum-app-latest.json"
check "json has no override line" "$("$PY" -c 'import json,sys; print("node_override_params" in json.load(open(sys.argv[1])))' "$T/a.json")" "False"
# 6. the variables aim a build at the old folder and the old key
out="$(IGNEUM_INTAKE_KEY_FILE="$T/cfg/log-intake-key" IGNEUM_DL_TOKEN_FILE="$T/cfg/dl-token" write_packaged_config "$T/b.json")"
check "old-folder build: manifest" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["update_manifest"])' "$T/b.json")" "$DL_HOST/dl/tok1old/igneum-app-latest.json"
check "old-folder build: key" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["log_intake_key"])' "$T/b.json")" "plainkeyvalue0123456789abcdef"
# 7. missing files: notes, empty fields, valid JSON
out="$(IGNEUM_INTAKE_KEY_FILE="$T/cfg/nokey" IGNEUM_DL_TOKEN_FILE="$T/cfg/notoken" write_packaged_config "$T/c.json")"
check "missing key noted" "$(printf '%s' "$out" | grep -c 'log uploads are disabled')" "1"
check "missing token noted" "$(printf '%s' "$out" | grep -c 'update check is disabled')" "1"
check "missing files give empty fields" "$("$PY" -c 'import json,sys; j=json.load(open(sys.argv[1])); print(j["update_manifest"]+"|"+j["log_intake_key"])' "$T/c.json")" "|"
# 8. the override line
NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": 123456}' write_packaged_config "$T/d.json" >/dev/null
check "override params land" "$("$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))["node_override_params"]["difficulty_v2_activation_daa"])' "$T/d.json")" "123456"
if [ "$fails" = 0 ]; then echo "packaged-config: all checks passed"; else echo "packaged-config: $fails check(s) failed"; exit 1; fi
fi

View file

@ -114,7 +114,9 @@ Windows: the previous version's installer kept in `updates/`, so the FIRST updat
on Windows, said so in the state) and shows "rolled back" in Settings.
Settings: `auto_update` (default on). Off: downloads still happen, the banner waits for Install now. The forced
screenshots: `?update=available|downloading|ready|waiting|applying|urgent|manual|error|updated` on the dashboard URL.
screenshots: `?update=available|downloading|ready|waiting|applying|urgent|manual|error|updated` on the dashboard URL
(and `?job=running|done|failed` for the remote-job notice). The dashboard shows one notice at a time in the strip under
the header, the most important first (app/igneum-app/ui/app.js, Notices).
## Testing

View file

@ -9,7 +9,7 @@
#define ArtDir "..\..\brand\icons"
#endif
#ifndef AppVersion
#define AppVersion "0.3.5"
#define AppVersion "0.3.7"
#endif
#define AppName "Igneum Miner"
#define Publisher "Igneum"

View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Refuses a Windows payload whose exes import a symbol the shipped runtime DLL does not export.
#
# packaging/windows/check-runtime-dlls.sh <folder with igneumd.exe, igneum-miner.exe and lib*.dll>
#
# 5 October 2026, Igneum Miner 0.3.6: the PC-built igneumd.exe (GCC 13's mingw) was shipped with libstdc++-6.dll from
# the Mac's toolchain (GCC 16), which no longer exports five std::codecvt symbols the exe imports
# (_ZNKSt25__codecvt_utf8_utf16_baseIwE10do_unshiftERiPcS2_RS2_ and four siblings); Windows refused to start the node
# ("Entry Point Not Found") on both PCs. `-C link-arg=-static` had not removed the import. Rule from here: the DLLs
# in a payload come from the toolchain that linked the exes, and this check runs before anything is signed
# (push-inputs.sh) and before the installer is packed (make-payload.sh). Needs x86_64-w64-mingw32-objdump (Homebrew
# mingw-w64 on the Mac); prints a note and exits 0 where it is missing (the runner), exits 1 on a missing symbol.
set -euo pipefail
DIR="${1:?folder with the exes and the DLLs}"
OBJDUMP="${OBJDUMP:-x86_64-w64-mingw32-objdump}"
if ! command -v "$OBJDUMP" >/dev/null 2>&1; then echo "runtime DLL check: no $OBJDUMP here, skipped (the Mac's push-inputs.sh ran it before signing)"; exit 0; fi
# objdump -p prints, under "DLL Name: x.dll", one import per line: "<vma> <ordinal or <none>> <hint> <name>"; and under
# "[Ordinal/Name Pointer] Table" one export per line: "[ n] +base[ m] <hint> <name>"
imports_from() { "$OBJDUMP" -p "$1" | awk -v dll="$2" 'index(tolower($0), "dll name: " tolower(dll)) > 0 {f=1; next} /DLL Name:/ {f=0} f && /^\t[0-9a-f]+[ \t]+[^ \t]+[ \t]+[0-9a-f]+[ \t]+[^ \t]+[ \t]*$/ {print $NF}' | sort -u; }
exports_of() { "$OBJDUMP" -p "$1" | awk '/^\t\[ *[0-9]+\] \+base\[ *[0-9]+\][ \t]+[0-9a-f]+[ \t]+[^ \t]+[ \t]*$/ {print $NF}' | sort -u; }
bad=0; checked=0
for exe in "$DIR"/igneumd.exe "$DIR"/igneum-miner.exe; do
[ -f "$exe" ] || continue
for dll in $("$OBJDUMP" -p "$exe" | awk '/DLL Name:/ {print $3}' | sort -u); do
case "$dll" in lib*.dll) ;; *) continue ;; esac
if [ ! -f "$DIR/$dll" ]; then echo "runtime DLL check: $(basename "$exe") imports $dll and the payload has no $dll"; bad=1; continue; fi
missing="$(comm -23 <(imports_from "$exe" "$dll") <(exports_of "$DIR/$dll") || true)"
n_imp="$(imports_from "$exe" "$dll" | wc -l | tr -d ' ')"; n_exp="$(exports_of "$DIR/$dll" | wc -l | tr -d ' ')"
checked=$((checked+1))
if [ -n "$missing" ]; then
echo "runtime DLL check: $(basename "$exe") imports $(echo "$missing" | wc -l | tr -d ' ') symbol(s) from $dll that this $dll does not export ($n_imp imported, $n_exp exported):"
echo "$missing" | head -5 | sed 's/^/ /'; bad=1
else
echo "runtime DLL check: $(basename "$exe") <- $dll: all $n_imp imported symbols exported ($n_exp exports)"
fi
done
done
[ "$checked" -gt 0 ] || echo "runtime DLL check: no lib*.dll import in the exes (statically linked)"
[ "$bad" = 0 ] || { echo "runtime DLL check FAILED: ship the DLLs of the toolchain that linked the exes" >&2; exit 1; }

View file

@ -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/<token>/), 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/<token>/), where the other packages live. Run on the Mac:
#
# packaging/windows/fetch-ci-artifacts.sh [--deploy] [run-id]
# packaging/windows/fetch-ci-artifacts.sh [--deploy] [--sign-manifest] [run-id]
#
# Without --deploy it prints the deploy command for the main session to run; with --deploy it deploys the folder with
# the Vercel CLI itself. A run id (gh run list) picks a specific run instead of the latest green one.
# The installer also goes into the over-the-air update manifest (packaging/ota/publish-manifest.sh, Windows entry;
# OTA_NOTES= for the changelog line, OTA_SKIP=1 to leave the manifest alone), so the deploy ships both.
#
# The over-the-air update manifest (packaging/ota/publish-manifest.sh, Windows entry) is NOT touched unless
# --sign-manifest is given (review round 4, R4.5.2, ledger G13: signing used to be automatic from "the latest green
# run"). --sign-manifest needs an explicit run id, and before it signs anything it downloads that run's
# igneum-windows-inputs artifact (the payload-inputs.json the runner verified, its signature and the runner's record)
# and re-verifies on this Mac: the Ed25519 signature against ~/.config/igneum/ota-signing-key.pub, the pinned node
# commit against packaging/windows/node-source.pin AT THE RUN'S COMMIT, the run's branch (master) and event (push or
# workflow_dispatch), and that the runner's record names this run, this commit and this key. Any failure stops
# before the signature. OTA_NOTES= sets the changelog line (default: the version and the run id).
# Reads ~/.config/igneum/dl-token, ~/.config/igneum/dlsite-dir (IGNEUM_DLSITE overrides) and the gh login, which must
# be igneum-josh (gh auth switch --user igneum-josh).
set -euo pipefail
REPO="igneum-network/igneum"
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
DEPLOY=0
SIGN=0
RUN_ID=""
for a in "$@"; do
case "$a" in --deploy) DEPLOY=1 ;; *) RUN_ID="$a" ;; esac
case "$a" in --deploy) DEPLOY=1 ;; --sign-manifest) SIGN=1 ;; --*) echo "unknown flag $a" >&2; exit 2 ;; *) RUN_ID="$a" ;; esac
done
if [ "${OTA_SKIP:-}" = 0 ]; then SIGN=1; fi # the old spelling; OTA_SKIP=1 (the default now) leaves the manifest alone
if [ "$SIGN" = 1 ] && [ -z "$RUN_ID" ]; then
echo "--sign-manifest needs the run id it signs (gh run list --repo $REPO --workflow windows.yml); the latest green run is never signed by default" >&2
exit 2
fi
TOKEN_FILE="$HOME/.config/igneum/dl-token"
DLSITE="${IGNEUM_DLSITE:-}"
[ -n "$DLSITE" ] || { [ -f "$HOME/.config/igneum/dlsite-dir" ] && DLSITE="$(tr -d '[:space:]' < "$HOME/.config/igneum/dlsite-dir")"; } || true
@ -32,6 +47,9 @@ if [ -z "$RUN_ID" ]; then
[ -n "$RUN_ID" ] && [ "$RUN_ID" != "null" ] || { echo "no green windows.yml run on master yet" >&2; exit 1; }
fi
gh run view "$RUN_ID" --repo "$REPO" --json headSha,displayTitle,updatedAt,url,conclusion --jq '"run \(.url)\n\(.displayTitle)\n\(.headSha[0:12]) \(.updatedAt) \(.conclusion)"'
RUN_JSON="$(gh run view "$RUN_ID" --repo "$REPO" --json headSha,headBranch,event,conclusion,status)"
read -r HEAD_SHA HEAD_BRANCH RUN_EVENT RUN_CONCLUSION < <(printf '%s' "$RUN_JSON" | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["headSha"], r["headBranch"], r["event"], r["conclusion"])')
[ "$RUN_CONCLUSION" = success ] || { echo "run $RUN_ID concluded '$RUN_CONCLUSION', not success" >&2; exit 1; }
TMP="$(mktemp -d)"
gh run download "$RUN_ID" --repo "$REPO" --name igneum-windows-installer --name igneum-windows-payload --dir "$TMP"
@ -49,11 +67,41 @@ ls -la "$DEST/$(basename "$SETUP")" "$DEST/igneum-windows-app.zip"
# the console's Builds tab (relay/): one build event per fetched CI run; never fatal. CONSOLE_SKIP=1 leaves it to the
# caller (tools/ship-app.mjs posts one item for the whole cut).
[ "${CONSOLE_SKIP:-0}" = 1 ] || node "$(dirname "$0")/../../tools/console.mjs" post --kind build --title "Windows CI $(basename "$SETUP") fetched (run $RUN_ID)" --body "https://github.com/$REPO/actions/runs/$RUN_ID" >/dev/null 2>&1 || true
# the over-the-air manifest (packaging/ota): the Windows entry for this installer; the Mac entry of the same version is
# carried over. OTA_NOTES= sets the changelog line; OTA_SKIP=1 leaves the manifest alone.
if [ "${OTA_SKIP:-0}" != 1 ]; then
# the over-the-air manifest (packaging/ota): only with --sign-manifest, only for the named run, and only after the
# run's verified inputs manifest re-verifies here (G13). The Mac entry of the same version is carried over.
if [ "$SIGN" = 1 ]; then
PUB="$HOME/.config/igneum/ota-signing-key.pub"
SIGNER="$ROOT/app/igneum-app/target/release/igneum-ota-sign"
[ -f "$PUB" ] || { echo "no $PUB" >&2; exit 1; }
[ -x "$SIGNER" ] || (cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet)
[ "$HEAD_BRANCH" = master ] || { echo "refusing to sign: run $RUN_ID is on branch '$HEAD_BRANCH', not master" >&2; exit 1; }
case "$RUN_EVENT" in push|workflow_dispatch) ;; *) echo "refusing to sign: run $RUN_ID was triggered by '$RUN_EVENT'" >&2; exit 1 ;; esac
INP="$(mktemp -d)"
gh run download "$RUN_ID" --repo "$REPO" --name igneum-windows-inputs --dir "$INP" || { echo "refusing to sign: run $RUN_ID has no igneum-windows-inputs artifact (the run verified no inputs manifest)" >&2; exit 1; }
IJSON="$(find "$INP" -name payload-inputs.json | head -1)"; ISIG="$(find "$INP" -name payload-inputs.json.sig | head -1)"; IREC="$(find "$INP" -name inputs-verified.json | head -1)"
[ -n "$IJSON" ] && [ -n "$ISIG" ] && [ -n "$IREC" ] || { echo "refusing to sign: the inputs artifact is incomplete" >&2; ls -R "$INP" >&2; exit 1; }
# the pin as it stood in the commit the runner built, from this clone (fetched if the commit is not here yet)
git -C "$ROOT" cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null || git -C "$ROOT" fetch --quiet origin "$HEAD_SHA" || true
PIN="$(git -C "$ROOT" show "$HEAD_SHA:packaging/windows/node-source.pin" 2>/dev/null | tr -d '[:space:]')"
[ ${#PIN} = 40 ] || { echo "refusing to sign: commit ${HEAD_SHA:0:12} carries no packaging/windows/node-source.pin" >&2; exit 1; }
"$SIGNER" verify-inputs "$PUB" "$IJSON" "$ISIG" --node-commit "$PIN" || { echo "refusing to sign: the run's inputs manifest does not verify against $PUB and the pin at ${HEAD_SHA:0:12}" >&2; exit 1; }
FP="$("$SIGNER" fingerprint "$PUB" | sed -n 2p)"
python3 - "$IREC" "$RUN_ID" "$HEAD_SHA" "$FP" <<'PYREC'
import json, sys
rec = json.load(open(sys.argv[1]))
want = {"run_id": sys.argv[2], "head_sha": sys.argv[3], "key_fingerprint": sys.argv[4]}
bad = [k for k, v in want.items() if str(rec.get(k, "")) != v]
if bad:
print("refusing to sign: the runner's record disagrees on " + ", ".join(f"{k} (record {rec.get(k)!r}, expected {want[k]!r})" for k in bad), file=sys.stderr)
sys.exit(1)
print(f"inputs verified by the runner and again here: node commit {rec.get('node_commit')}, zip {rec.get('zip_sha256')}, key {rec.get('key_fingerprint')}")
PYREC
rm -rf "$INP"
SETUP_VERSION="$(basename "$SETUP" | sed -n 's/^Igneum-Miner-Setup-\(.*\)\.exe$/\1/p')"
"$(dirname "$0")/../ota/publish-manifest.sh" --version "$SETUP_VERSION" --win "$DEST/$(basename "$SETUP")" --notes "${OTA_NOTES:-Windows build $SETUP_VERSION from CI run $RUN_ID}" --no-deploy
echo "signing the update manifest for Windows $SETUP_VERSION over run $RUN_ID (${HEAD_SHA:0:12})"
"$HERE/../ota/publish-manifest.sh" --version "$SETUP_VERSION" --win "$DEST/$(basename "$SETUP")" --notes "${OTA_NOTES:-Windows build $SETUP_VERSION from CI run $RUN_ID}" --no-deploy
else
echo "update manifest untouched (pass --sign-manifest <run-id> 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)

View file

@ -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 <stage-dir> <zip> <node-commit-40hex> <node-branch> <repo-commit-40hex> <out.json>
#
# 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"
}

View file

@ -6,13 +6,16 @@
#
# What goes in:
# igneum-app.exe the engine, cross-compiled here (app/igneum-app, x86_64-pc-windows-gnu)
# igneum-prove-verify.exe the node's proof verifier wrapper (runs igneum-prove-host inside WSL2), same build
# igneumd.exe, igneum-miner.exe the devnet-v4 cross-build (vendor/igneum-node/target-integration/x86_64-pc-windows-gnu/release)
# lib*.dll the three mingw runtime DLLs, as igneum-windows-v4.zip ships them
# igneum-worker-cuda.exe, igneum-worker-opencl.exe, nvrtc*.dll the prebuilt GPU workers from the proto-cuda/proto-opencl
# agent when they exist (searched in a few places; IGNEUM_WORKERS_DIR overrides); without them
# the engine builds the CUDA worker from proto-cuda\ on the PC (needs the CUDA Toolkit and MSVC)
# proto-cuda\, proto-opencl\ the worker sources and build.bat for that fallback
# igneum-app.json the update manifest URL (token from ~/.config/igneum/dl-token, never in the repo), the log intake
# igneum-app.json the update manifest URL and the log intake key, from the files named by IGNEUM_DL_TOKEN_FILE and
# IGNEUM_INTAKE_KEY_FILE (defaults: the .next files under ~/.config/igneum when staged, else the
# plain ones; packaging/mac/packaged-config.sh, sourced here; never in the repo)
# stop-igneum.ps1 what the installer runs before an upgrade and on uninstall
# app\windows\ host.cpp, host.rc, version.h, BUILD-APP.bat (the window host is built on the PC or by CI)
# Igneum Miner.exe the window host, when app/windows/dist/ holds one (BUILD-APP.bat ran before this script)
@ -30,7 +33,8 @@ REL="${IGNEUM_WIN_RELEASE:-$ROOT/vendor/igneum-node/target-integration/x86_64-pc
ENGINE="${IGNEUM_APP_EXE:-$ROOT/app/igneum-app/target/x86_64-pc-windows-gnu/release/igneum-app.exe}"
MINGW=/opt/homebrew/opt/mingw-w64/toolchain-x86_64/x86_64-w64-mingw32
STAGE="$HERE/igneum-windows-app"
TOKEN_FILE="$HOME/.config/igneum/dl-token"
# the packaged configuration (the manifest URL from the token file, the intake key from the key file, NODE_OVERRIDE_PARAMS)
. "$ROOT/packaging/mac/packaged-config.sh"
[ -f "$ENGINE" ] || { echo "no $ENGINE: build it first (cd app/igneum-app && cargo build --release --target x86_64-pc-windows-gnu, see proto-cuda/windows-node/cross-build.sh for the environment)" >&2; exit 1; }
[ -f "$REL/igneumd.exe" ] || { echo "no $REL/igneumd.exe; cross-compile the node first" >&2; exit 1; }
@ -39,6 +43,10 @@ TOKEN_FILE="$HOME/.config/igneum/dl-token"
rm -rf "$STAGE"
mkdir -p "$STAGE/proto-cuda/packs" "$STAGE/proto-opencl" "$STAGE/app/windows"
cp "$ENGINE" "$STAGE/igneum-app.exe"
# the node's proof verifier on a PC (release 0.3.6, app/igneum-app/src/bin/prove-verify.rs): the engine sets
# IGNEUM_PROOF_VERIFIER to this wrapper, which runs the WSL2 host; built beside the engine by the same cargo build
if [ -f "$(dirname "$ENGINE")/igneum-prove-verify.exe" ]; then cp "$(dirname "$ENGINE")/igneum-prove-verify.exe" "$STAGE/"; echo "verifier wrapper: igneum-prove-verify.exe"
else echo "warning: no igneum-prove-verify.exe next to $ENGINE; the node on this build relays proof records and never includes them"; fi
cp "$REL/igneumd.exe" "$STAGE/igneumd.exe"
cp "$REL/igneum-miner.exe" "$STAGE/igneum-miner.exe"
for dll in lib/libstdc++-6.dll lib/libgcc_s_seh-1.dll bin/libwinpthread-1.dll; do
@ -47,6 +55,7 @@ for dll in lib/libstdc++-6.dll lib/libgcc_s_seh-1.dll bin/libwinpthread-1.dll; d
elif [ -f "$MINGW/$dll" ]; then cp "$MINGW/$dll" "$STAGE/"; x86_64-w64-mingw32-strip "$STAGE/$name" 2>/dev/null || true
else echo "note: $name not found in the mingw toolchain (the node exe is linked -static, so it may not need it)"; fi
done
"$(dirname "$0")/check-runtime-dlls.sh" "$STAGE" # every imported libstdc++ symbol exported by the shipped DLL (5 October 2026)
# the prebuilt one-click workers, from where proto-cuda/windows-app/make-package.sh takes them: the NVRTC CUDA worker
# with NVIDIA's redistributable DLLs and licence texts, and the OpenCL worker (IGNEUM_WORKERS_DIR overrides with a flat folder)
@ -89,27 +98,9 @@ else echo "warning: no Linux igneum-prove-host/igneum-prove-export in $PROVE_LIN
cp "$ROOT"/proving/windows-wsl2/*.sh "$ROOT"/proving/windows-wsl2/*.ps1 "$ROOT"/proving/windows-wsl2/*.bat "$ROOT/proving/windows-wsl2/README.txt" "$STAGE/wsl2/"
cp "$ROOT"/proving/fixtures/*.json "$STAGE/wsl2/fixtures/"
# the packaged configuration: the download token stays out of the repo
TOKEN=""
[ -f "$TOKEN_FILE" ] && TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
MANIFEST=""
[ -n "$TOKEN" ] && MANIFEST="https://dl.igneum.network/dl/$TOKEN/igneum-app-latest.json"
[ -n "$MANIFEST" ] || echo "note: no $TOKEN_FILE; the update check is disabled in this build"
LOG_KEY="$(sed -n 's/^LOG_KEY="\(.*\)"/\1/p' "$ROOT/packaging/mac/packaged-config.sh")"
# the pinned consensus parameters (packaged-config.sh NODE_OVERRIDE_PARAMS, e.g. the difficulty v2 activation height)
NODE_OVERRIDE_PARAMS="$(sed -n "s/^NODE_OVERRIDE_PARAMS='\(.*\)'/\1/p" "$ROOT/packaging/mac/packaged-config.sh")"
OVERRIDE_LINE=""
[ -n "$NODE_OVERRIDE_PARAMS" ] && OVERRIDE_LINE=" \"node_override_params\": $NODE_OVERRIDE_PARAMS,"
cat > "$STAGE/igneum-app.json" <<JSON
{
$OVERRIDE_LINE
"update_manifest": "$MANIFEST",
"log_intake_url": "https://igneum-six.vercel.app/api/log",
"log_intake_key": "$LOG_KEY",
"live_page": "https://igneum.network/live",
"download_page": "https://igneum.network/#mine"
}
JSON
# the packaged configuration: the token and the key stay out of the repo (packaged-config.sh prints the file names and
# the fingerprints, never the values)
write_packaged_config "$STAGE/igneum-app.json"
cat > "$STAGE/README.txt" <<TXT
Igneum Miner $VERSION for Windows (payload). Devnet v4. Test network; nothing is bought or sold.
Nobody from Igneum will ever ask for your seed.

View file

@ -0,0 +1 @@
2b6d23ef377f4df30e3a63d17d488795f0881f36

View file

@ -130,10 +130,17 @@ cat "${OUT%.zip}.json"
if [ "$DEPLOY" = 1 ]; then
echo "deploying $DLSITE"
(cd "$DLSITE" && npx --yes vercel@latest --global-config "$HOME/.config/igneum/vercel" deploy --prod --yes 2>&1 | grep -v "$TOKEN" || true)
# the edge serves the previous file for a few seconds after "Aliased" (5 October 2026: the 0.3.6 job failed here
# on a sha256 that matched a moment later), so the check retries, as publish-jobs.sh does
LIVE="$(mktemp)"
code="$(curl -s -o "$LIVE" -w '%{http_code}' "https://dl.igneum.network/dl/$TOKEN/build-inputs.sha256")"
echo "https://dl.igneum.network/dl/<token>/build-inputs.sha256 -> HTTP $code"
[ "$code" = 200 ] && [ "$(tr -d '[:space:]' < "$LIVE")" = "$SUM" ] || { echo "the live sha256 is not reachable or is not this zip's; check the deploy output" >&2; rm -f "$LIVE"; exit 1; }
ok=0
for try in 1 2 3 4 5 6; do
code="$(curl -s -o "$LIVE" -w '%{http_code}' "https://dl.igneum.network/dl/$TOKEN/build-inputs.sha256")"
echo "https://dl.igneum.network/dl/<token>/build-inputs.sha256 -> HTTP $code (try $try)"
if [ "$code" = 200 ] && [ "$(tr -d '[:space:]' < "$LIVE")" = "$SUM" ]; then ok=1; break; fi
sleep 10
done
[ "$ok" = 1 ] || { echo "the live sha256 is not reachable or is not this zip's after 6 tries; check the deploy output" >&2; rm -f "$LIVE"; exit 1; }
rm -f "$LIVE"
echo "live: build-inputs.zip verified by sha256"
elif [ -n "$TOKEN" ]; then

View file

@ -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/<token>/) 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/<token>/) 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)"
@ -38,11 +43,16 @@ TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
STAGE="$(mktemp -d)/payload-inputs"
mkdir -p "$STAGE"
cp "$REL/igneumd.exe" "$REL/igneum-miner.exe" "$STAGE/"
# the runtime DLLs: the ones next to the exes first (a PC build job ships its own toolchain's, 0.3.7), else this Mac's
# toolchain (which linked a Mac cross-build); then the gate: every symbol the exes import must be exported by the DLL
# (5 October 2026, 0.3.6: GCC 13 exes with the GCC 16 DLL, "Entry Point Not Found" on both PCs)
for dll in lib/libstdc++-6.dll lib/libgcc_s_seh-1.dll bin/libwinpthread-1.dll; do
name="$(basename "$dll")"
if [ -f "$MINGW/$dll" ]; then cp "$MINGW/$dll" "$STAGE/"; x86_64-w64-mingw32-strip "$STAGE/$name" 2>/dev/null || true
if [ -f "$REL/$name" ]; then cp "$REL/$name" "$STAGE/"; echo "$name: from next to the exes ($REL)"
elif [ -f "$MINGW/$dll" ]; then cp "$MINGW/$dll" "$STAGE/"; x86_64-w64-mingw32-strip "$STAGE/$name" 2>/dev/null || true; echo "$name: from the Mac toolchain ($MINGW)"
else echo "note: $name not in the mingw toolchain (the node exe is linked -static, so it may not need it)"; fi
done
"$(dirname "$0")/check-runtime-dlls.sh" "$STAGE"
if [ -f "$NVRTC_DIR/igneum-worker-cuda.exe" ]; then
cp "$NVRTC_DIR/igneum-worker-cuda.exe" "$STAGE/"
for f in "$NVRTC_DIR"/redist/bin/nvrtc*.dll "$NVRTC_DIR"/redist/LICENSE-*.txt "$NVRTC_DIR/THIRD-PARTY.md"; do [ -f "$f" ] && cp "$f" "$STAGE/"; done
@ -50,38 +60,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

View file

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

View file

@ -3,10 +3,16 @@ rem Igneum miner log uploader. Sends the last 256 KB of a log file to the Igneum
rem so Claude on the Mac can read it (node tools/logs.mjs). Needs Windows 10 or 11 (curl.exe, PowerShell).
rem Usage: upload-log.bat <logfile> <label> [run_id]
rem run_id falls back to the IGNEUM_RUN_ID environment variable, then to <label>-<date>-<time>.
rem The key below only authorises log uploads. It is meant to ship inside this package.
rem The key only authorises log uploads. It is not in the repository (rotation phase 2, 5 October 2026): it comes from
rem the IGNEUM_LOG_KEY environment variable, else from igneum-log-key.txt next to this script (one line; the packager
rem writes it from the file IGNEUM_INTAKE_KEY_FILE names), else the upload is refused with exit 3.
setlocal
set "IGNEUM_LOG_URL=https://igneum-six.vercel.app/api/log"
set "IGNEUM_LOG_KEY=okLO0nuvYzKkgNCWL5WNW37e"
if "%IGNEUM_LOG_KEY%"=="" if exist "%~dp0igneum-log-key.txt" set /p IGNEUM_LOG_KEY=<"%~dp0igneum-log-key.txt"
if "%IGNEUM_LOG_KEY%"=="" (
echo upload-log: no intake key: set IGNEUM_LOG_KEY or put igneum-log-key.txt next to this script
exit /b 3
)
if "%~1"=="" goto usage
if "%~2"=="" goto usage

View file

@ -1,41 +1,47 @@
@echo off
rem Igneum miner log uploader. Sends the last 256 KB of a log file to the Igneum log intake
rem so Claude on the Mac can read it (node tools/logs.mjs). Needs Windows 10 or 11 (curl.exe, PowerShell).
rem Usage: upload-log.bat <logfile> <label> [run_id]
rem run_id falls back to the IGNEUM_RUN_ID environment variable, then to <label>-<date>-<time>.
rem The key below only authorises log uploads. It is meant to ship inside this package.
setlocal
set "IGNEUM_LOG_URL=https://igneum-six.vercel.app/api/log"
set "IGNEUM_LOG_KEY=okLO0nuvYzKkgNCWL5WNW37e"
if "%~1"=="" goto usage
if "%~2"=="" goto usage
if not exist "%~1" (
echo upload-log: file not found: %~1
exit /b 2
)
set "LOGFILE=%~f1"
set "LABEL=%~2"
set "RUNID=%~3"
if "%RUNID%"=="" set "RUNID=%IGNEUM_RUN_ID%"
set "OUT=%TEMP%\igneum-upload-%RANDOM%.json"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $fs=New-Object IO.FileStream($env:LOGFILE,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); $b=New-Object byte[] $fs.Length; [void]$fs.Read($b,0,$b.Length); $fs.Close(); $n=[Math]::Min($b.Length,262144); $t=[Text.Encoding]::UTF8.GetString($b,$b.Length-$n,$n); $r=$env:RUNID; if (-not $r) { $r=$env:LABEL + '-' + (Get-Date -Format 'yyyyMMdd-HHmm') }; $o=@{label=$env:LABEL;machine=$env:COMPUTERNAME;run_id=$r;lines=$t}; [IO.File]::WriteAllText($env:OUT,(ConvertTo-Json $o -Compress),(New-Object Text.UTF8Encoding $false)); Write-Host ('upload-log: run_id ' + $r + ', ' + $n + ' bytes')"
if errorlevel 1 (
echo upload-log: could not read or encode %LOGFILE%
exit /b 3
)
curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%"
set "RC=%ERRORLEVEL%"
echo.
del "%OUT%" >nul 2>&1
if not "%RC%"=="0" (
echo upload-log: curl failed with code %RC%
exit /b %RC%
)
exit /b 0
:usage
echo Usage: upload-log.bat ^<logfile^> ^<label^> [run_id]
exit /b 1
@echo off
rem Igneum miner log uploader. Sends the last 256 KB of a log file to the Igneum log intake
rem so Claude on the Mac can read it (node tools/logs.mjs). Needs Windows 10 or 11 (curl.exe, PowerShell).
rem Usage: upload-log.bat <logfile> <label> [run_id]
rem run_id falls back to the IGNEUM_RUN_ID environment variable, then to <label>-<date>-<time>.
rem The key only authorises log uploads. It is not in the repository (rotation phase 2, 5 October 2026): it comes from
rem the IGNEUM_LOG_KEY environment variable, else from igneum-log-key.txt next to this script (one line; the packager
rem writes it from the file IGNEUM_INTAKE_KEY_FILE names), else the upload is refused with exit 3.
setlocal
set "IGNEUM_LOG_URL=https://igneum-six.vercel.app/api/log"
if "%IGNEUM_LOG_KEY%"=="" if exist "%~dp0igneum-log-key.txt" set /p IGNEUM_LOG_KEY=<"%~dp0igneum-log-key.txt"
if "%IGNEUM_LOG_KEY%"=="" (
echo upload-log: no intake key: set IGNEUM_LOG_KEY or put igneum-log-key.txt next to this script
exit /b 3
)
if "%~1"=="" goto usage
if "%~2"=="" goto usage
if not exist "%~1" (
echo upload-log: file not found: %~1
exit /b 2
)
set "LOGFILE=%~f1"
set "LABEL=%~2"
set "RUNID=%~3"
if "%RUNID%"=="" set "RUNID=%IGNEUM_RUN_ID%"
set "OUT=%TEMP%\igneum-upload-%RANDOM%.json"
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $fs=New-Object IO.FileStream($env:LOGFILE,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); $b=New-Object byte[] $fs.Length; [void]$fs.Read($b,0,$b.Length); $fs.Close(); $n=[Math]::Min($b.Length,262144); $t=[Text.Encoding]::UTF8.GetString($b,$b.Length-$n,$n); $r=$env:RUNID; if (-not $r) { $r=$env:LABEL + '-' + (Get-Date -Format 'yyyyMMdd-HHmm') }; $o=@{label=$env:LABEL;machine=$env:COMPUTERNAME;run_id=$r;lines=$t}; [IO.File]::WriteAllText($env:OUT,(ConvertTo-Json $o -Compress),(New-Object Text.UTF8Encoding $false)); Write-Host ('upload-log: run_id ' + $r + ', ' + $n + ' bytes')"
if errorlevel 1 (
echo upload-log: could not read or encode %LOGFILE%
exit /b 3
)
curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%"
set "RC=%ERRORLEVEL%"
echo.
del "%OUT%" >nul 2>&1
if not "%RC%"=="0" (
echo upload-log: curl failed with code %RC%
exit /b %RC%
)
exit /b 0
:usage
echo Usage: upload-log.bat ^<logfile^> ^<label^> [run_id]
exit /b 1

View file

@ -27,7 +27,11 @@ export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
export LIBCLANG_PATH=/opt/homebrew/opt/llvm/lib
export BINDGEN_EXTRA_CLANG_ARGS_x86_64_pc_windows_gnu="--target=x86_64-w64-mingw32 --sysroot=$MINGW -I$MINGW/include"
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-arg=-static -C link-arg=-static-libgcc"
# 5 October 2026 (0.3.7): -static-libstdc++ added as a try; -static alone left libstdc++-6.dll in the import table of
# every exe so far (0.3.5's and the PC's), which is why the payload must ship the DLLs of the toolchain that linked
# the exes (push-inputs.sh takes them from next to the exes first, then from this toolchain) and why push-inputs.sh
# refuses an exe that imports a symbol the shipped DLL does not export.
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS="-C link-arg=-static -C link-arg=-static-libgcc -C link-arg=-static-libstdc++"
cd "$NODE"
nice -n 19 cargo build --release -j "$JOBS" -p kaspad -p igneum-miner --features igneum-pow --target x86_64-pc-windows-gnu
for exe in igneumd igneum-miner; do

View file

@ -20,13 +20,22 @@ nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader 2>
echo "cpu: $(nproc) cores, ram: $(free -g | awk '/Mem:/ {print $2}') GB visible to WSL"
upload() {
# Last 256 KB of the log to the intake, same URL and key as upload-log.bat (log uploads only).
# Last 256 KB of the log to the intake (log uploads only); key and URL from the environment, see below.
python3 - "$LOG" "$RUN_ID" <<'PY'
import json, socket, sys, urllib.request
import json, os, socket, sys, urllib.request
path, run_id = sys.argv[1], sys.argv[2]
# the key and the intake come from the environment (the app's job runner sets IGNEUM_INTAKE_KEY and IGNEUM_INTAKE_URL;
# by hand: export them, or IGNEUM_INTAKE_KEY_FILE naming a file); no key literal lives in the repository
key = os.environ.get("IGNEUM_INTAKE_KEY", "").strip()
if not key and os.environ.get("IGNEUM_INTAKE_KEY_FILE"):
try: key = open(os.environ["IGNEUM_INTAKE_KEY_FILE"]).read().strip()
except OSError: key = ""
if not key:
print("upload skipped: no IGNEUM_INTAKE_KEY in the environment"); sys.exit(0)
url = os.environ.get("IGNEUM_INTAKE_URL", "").strip() or "https://igneum-six.vercel.app/api/log"
data = open(path, 'rb').read()[-262144:].decode('utf-8', 'replace')
body = json.dumps({"label": "prove-" + socket.gethostname(), "machine": socket.gethostname(), "run_id": run_id, "lines": data}).encode()
req = urllib.request.Request("https://igneum-six.vercel.app/api/log", data=body, headers={"Content-Type": "application/json", "x-igneum-key": "okLO0nuvYzKkgNCWL5WNW37e"})
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json", "x-igneum-key": key})
try:
with urllib.request.urlopen(req, timeout=60) as r:
print("upload:", r.status, r.read()[:200].decode('utf-8', 'replace'))

View file

@ -21,13 +21,22 @@ nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader 2>
echo "cpu: $(nproc) cores, ram: $(free -g | awk '/Mem:/ {print $2}') GB visible to WSL"
upload() {
# Last 256 KB of the log to the intake, same URL and key as upload-log.bat (log uploads only).
# Last 256 KB of the log to the intake (log uploads only); key and URL from the environment, see below.
python3 - "$LOG" "$RUN_ID" <<'PY'
import json, socket, sys, urllib.request
import json, os, socket, sys, urllib.request
path, run_id = sys.argv[1], sys.argv[2]
# the key and the intake come from the environment (the app's job runner sets IGNEUM_INTAKE_KEY and IGNEUM_INTAKE_URL;
# by hand: export them, or IGNEUM_INTAKE_KEY_FILE naming a file); no key literal lives in the repository
key = os.environ.get("IGNEUM_INTAKE_KEY", "").strip()
if not key and os.environ.get("IGNEUM_INTAKE_KEY_FILE"):
try: key = open(os.environ["IGNEUM_INTAKE_KEY_FILE"]).read().strip()
except OSError: key = ""
if not key:
print("upload skipped: no IGNEUM_INTAKE_KEY in the environment"); sys.exit(0)
url = os.environ.get("IGNEUM_INTAKE_URL", "").strip() or "https://igneum-six.vercel.app/api/log"
data = open(path, 'rb').read()[-262144:].decode('utf-8', 'replace')
body = json.dumps({"label": "shards-" + socket.gethostname(), "machine": socket.gethostname(), "run_id": run_id, "lines": data}).encode()
req = urllib.request.Request("https://igneum-six.vercel.app/api/log", data=body, headers={"Content-Type": "application/json", "x-igneum-key": "okLO0nuvYzKkgNCWL5WNW37e"})
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json", "x-igneum-key": key})
try:
with urllib.request.urlopen(req, timeout=60) as r:
print("upload:", r.status, r.read()[:200].decode('utf-8', 'replace'))

View file

@ -171,6 +171,7 @@ p{margin:0;color:var(--ink-2);max-width:52ch}
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

View file

@ -515,6 +515,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

View file

@ -263,7 +263,7 @@ function stampProduct(html, file) {
return `<span data-product="${k}">${esc(PRODUCT[k])}</span>`;
});
}
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['404.html', '']];
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['404.html', '']];
for (const [file, active] of PAGES) {
const p = join(here, file);
if (!existsSync(p)) throw new Error(`missing page ${file}`);

View file

@ -260,6 +260,7 @@ code{font-family:var(--f-mono);font-size:.92em;background:var(--obsidian);paddin
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

File diff suppressed because one or more lines are too long

View file

@ -210,45 +210,45 @@
"text": "Devnet-v4 integration: nine branches merged, 3-node test network on the merged node, Windows cross-build",
"short": "Devnet v4: nine branches merged into one node"
},
{
"date": "2026-10-03",
"text": "Proto-opencl: OpenCL path built and proven without AMD silicon",
"short": "OpenCL worker built and proven without AMD silicon"
},
{
"date": "2026-10-03",
"text": "AMD gfx1036 , AMD OpenCL 2.1 driver 3652.0",
"short": "AMD integrated GPU runs the hash through OpenCL"
},
{
"date": "2026-10-03",
"text": "First devnet blocks on the real lottery hash: CPU, then Metal GPU, three worker implementations",
"short": "First devnet blocks on the real lottery hash"
},
{
"date": "2026-10-03",
"text": "R3.26 / M15: PoW checked after the cheap checks, cache-build cap, attack before and after",
"short": "Cache-build attack closed: 10.6 s of rebuilds to 14 ms"
},
{
"date": "2026-10-03",
"text": "Difficulty controller: devnet record, simulator, Igneum dual-lane rule, 3-node CPU test network",
"short": "Igneum dual-lane difficulty rule built and simulated"
},
{
"date": "2026-10-03",
"text": "Weak-program census: 400,000 program runs through the CPU reference, the redundant-load finding, and the rules for M5 and M6",
"short": "Census of 400,000 programs: redundant loads found"
},
{
"date": "2026-10-03",
"text": "Proving v0: first SP1 proof of an Igneum block, Apple M5 Max CPU, loaded machine",
"short": "First SP1 proof of an Igneum block, on a laptop CPU"
},
{
"date": "2026-10-03",
"text": "RTX 5090 first run",
"short": "RTX 5090 first run"
},
{
"date": "2026-10-03",
"text": "RTX 5090 through NVIDIA OpenCL",
"short": "RTX 5090 through NVIDIA OpenCL"
},
{
"date": "2026-10-03",
"text": "Igneum-node devnet v0: 3-node igneum-devnet at 1 BPS with the 80/20 coinbase and vote_key_hash",
"short": "Devnet v0: three nodes at one block per second"
},
{
"date": "2026-10-03",
"text": "Windows node package: igneumd cross-compiled for x86_64-pc-windows-gnu, two-peer sync test",
"short": "Windows node package: cross-compiled, two-peer sync"
},
{
"date": "2026-10-03",
"text": "Igneum-node devnet v2: sustained-mining finality rule v2 on a four-miner test network, and as a follower of the live devnet",
"short": "Finality rule v2 live on a four-miner test network"
},
{
"date": "2026-10-03",
"text": "Execution layer devnet v3: revm over the selected chain, 3-node simnet, viem smoke test",
"short": "EVM execution layer: identical state on three nodes"
},
{
"date": "2026-10-03",
"text": "Per-identity hash rate \"decay\" on the RTX 5090: diagnosis and Metal reproduction",
"short": "RTX 5090 hash-rate decay diagnosed: a miner bug, fixed"
},
{
"date": "2026-10-03",
"text": "RTX 5090, memory-hard dataset",
"short": "RTX 5090 on the memory-hard dataset"
}
]
}

View file

@ -763,6 +763,7 @@ body.all .pager{display:none}
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

View file

@ -296,6 +296,7 @@ main{padding-bottom:var(--sec)}
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

295
site/metamask.html Normal file
View file

@ -0,0 +1,295 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Add Igneum to MetaMask</title>
<meta name="description" content="Add the Igneum network to MetaMask or any Ethereum wallet in one click: chain id, RPC URL, the IGN symbol and 18 decimals. Devnet today, public testnet next.">
<link rel="canonical" href="https://igneum.network/wallet">
<meta name="theme-color" content="#0C0C0E">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Igneum">
<meta property="og:title" content="Add Igneum to MetaMask">
<meta property="og:description" content="Add the Igneum network to MetaMask or any Ethereum wallet in one click: chain id, RPC URL, the IGN symbol and 18 decimals. Devnet today, public testnet next.">
<meta property="og:url" content="https://igneum.network/wallet">
<meta property="og:image" content="https://igneum.network/og.png?v=3">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Add Igneum to MetaMask">
<meta name="twitter:description" content="Add the Igneum network to MetaMask or any Ethereum wallet in one click: chain id, RPC URL, the IGN symbol and 18 decimals. Devnet today, public testnet next.">
<meta name="twitter:image" content="https://igneum.network/og.png?v=3">
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192">
<link rel="icon" href="/icon-512.png" type="image/png" sizes="512x512">
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="manifest" href="/site.webmanifest">
<!-- head:start -->
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
<style>
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
html{-webkit-text-size-adjust:100%}
h1,h2,h3{text-wrap:balance}
p,li,dd,figcaption{text-wrap:pretty}
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
.skip:focus{top:12px}
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
.nav a{color:inherit;text-decoration:none}
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
.brand .mark{display:block;flex:0 0 auto}
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
.nav .brand:hover{color:var(--ui-ink)}
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
@media (max-width:940px){
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
.nav .links a.cta{margin-top:8px;min-height:48px}
.nav .links.open{display:flex}
.burger{display:inline-flex}
}
/* footer, the same on every page */
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
.foot .wrap{padding-block:48px 32px}
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
</style>
<!-- head:end -->
<style>
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
--fs-h1:clamp(32px,5.5vw,56px);--fs-h2:clamp(28px,4.2vw,44px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
*{box-sizing:border-box}body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:16px;line-height:1.6;padding:0;-webkit-font-smoothing:antialiased}
a{color:var(--ember);text-decoration:none}a:hover{text-decoration:underline}
[id]{scroll-margin-top:calc(var(--nav-h) + 16px)}
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
.head{padding-top:var(--head)}
h1{font-family:var(--f-display);font-weight:900;font-size:var(--fs-h1);line-height:1.05;margin:12px 0 12px}
h2{font-family:var(--f-display);font-weight:700;font-size:var(--fs-h3);line-height:1.3;margin:44px 0 12px;padding-top:24px;border-top:1px solid var(--line)}
.note{color:var(--ash);font-size:15px;max-width:72ch;margin:0 0 24px}
.note b{color:var(--bone);font-weight:500}
.grid{display:grid;gap:var(--gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));margin:8px 0 8px}
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0}
.card h3{font-family:var(--f-display);font-weight:700;font-size:var(--fs-h3);margin:0 0 6px}
.card .tag{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--molten);border:1px solid var(--line);border-radius:999px;padding:3px 9px;margin-bottom:12px}
.card p{margin:0 0 12px;color:var(--ink-2);font-size:15px}
dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:0 0 16px;font-size:14px}
dt{font-family:var(--f-mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--ash);padding-top:3px}
dd{margin:0;font-family:var(--f-mono);font-size:14px;color:var(--bone);overflow-wrap:anywhere}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--ember);color:var(--obsidian);background:var(--ember);cursor:pointer;font-family:inherit}
.btn:hover{background:var(--molten);border-color:var(--molten);text-decoration:none}
.btn[disabled]{opacity:.5;cursor:not-allowed}
.btn.ghost{background:transparent;color:var(--bone);border-color:var(--line)}
.status{font-family:var(--f-mono);font-size:13px;color:var(--ash);margin:10px 0 0;min-height:1.4em}
ol{margin:0 0 14px;padding-left:22px}li{margin-bottom:6px}
code{font-family:var(--f-mono);font-size:.92em;background:var(--graphite);padding:1px 5px;border-radius:4px;overflow-wrap:anywhere}
pre{margin:0 0 16px;padding:16px 18px;background:var(--graphite);border-radius:var(--card-r);font-family:var(--f-mono);font-size:13px;line-height:1.55;overflow-x:auto}
.asof{color:var(--ash);font-size:13px;margin:40px 0 0;max-width:72ch}
</style>
</head>
<body>
<!-- nav:start -->
<a class="skip" href="#main">Skip to content</a>
<nav class="nav" aria-label="Main">
<div class="wrap">
<a href="/" class="brand" aria-label="Igneum home">
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
<span class="word">IGNEUM</span>
</a>
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
<div class="links" id="nav-links">
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
<a href="/live" data-nav="live">Live devnet</a>
<a href="/bench" data-nav="bench">Engineering log</a>
<a href="/miner" data-nav="miner">Miner</a>
<a href="/wallet" data-nav="wallet">Wallet</a>
<a href="/evidence" data-nav="evidence">Evidence</a>
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
<a href="/miner#get" class="cta">Get the miner</a>
</div>
</div>
</nav>
<script>
(function(){
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
})();
</script>
<!-- nav:end -->
<main id="main" class="wrap">
<div class="head">
<div class="eyebrow">Wallets · chain id · RPC</div>
<h1>Add Igneum to MetaMask</h1>
</div>
<p class="note">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 <b>IGN</b> and <b>18</b> decimals. The button below sends them to the wallet with the standard <code>wallet_addEthereumChain</code> 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.</p>
<div class="grid">
<div class="card" id="testnet">
<span class="tag">Public testnet · coming</span>
<h3>Igneum testnet</h3>
<p>The network the one-click miner app joins at public testnet. Coins on it have no value and the chain resets with notice (<a href="/#testnet-terms">testnet terms</a>).</p>
<dl>
<dt>Network name</dt><dd>Igneum Testnet</dd>
<dt>Chain id</dt><dd>4462 (0x116e)</dd>
<dt>RPC URL</dt><dd id="testnet-rpc">https://rpc.testnet.igneum.network <span style="color:var(--ash)">(published with the testnet)</span></dd>
<dt>Symbol</dt><dd>IGN</dd>
<dt>Decimals</dt><dd>18</dd>
<dt>Explorer</dt><dd>published with the testnet</dd>
</dl>
<button class="btn" type="button" data-add="testnet" disabled>Add Igneum Testnet to MetaMask</button>
<p class="status" data-status="testnet">The button switches on when the public RPC is live.</p>
</div>
<div class="card" id="devnet">
<span class="tag">Devnet · live now</span>
<h3>Igneum devnet, through your own node</h3>
<p>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.</p>
<dl>
<dt>Network name</dt><dd>Igneum Devnet (local node)</dd>
<dt>Chain id</dt><dd>4463 (0x116f)</dd>
<dt>RPC URL</dt><dd>http://127.0.0.1:26790</dd>
<dt>Symbol</dt><dd>IGN</dd>
<dt>Decimals</dt><dd>18</dd>
<dt>Explorer</dt><dd>none yet</dd>
</dl>
<button class="btn" type="button" data-add="devnet">Add Igneum Devnet to MetaMask</button>
<p class="status" data-status="devnet"></p>
</div>
</div>
<h2 id="manual">Add it by hand</h2>
<p class="note">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.</p>
<ol>
<li>Network name: Igneum Testnet (or Igneum Devnet for your own node).</li>
<li>RPC URL: the one on the card.</li>
<li>Chain id: 4462 for the testnet, 4463 for the devnet.</li>
<li>Currency symbol: IGN. Decimals: 18.</li>
</ol>
<h2 id="builders">For builders</h2>
<p class="note">The same request from your own page or app, so your users land on the right chain:</p>
<pre>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: []
}]
});</pre>
<p class="note">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 <code>eth_gasPrice</code> and <code>eth_estimateGas</code> work as they do on Ethereum. The <a href="/litepaper#builders-ask">litepaper</a> has the differences.</p>
<h2 id="app-wallet">The app and the Igneum Wallet</h2>
<p class="note">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.</p>
<p class="asof">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.</p>
</main>
<!-- footer:start -->
<footer class="foot">
<div class="wrap">
<div class="foot-grid">
<div class="foot-brand">
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
<p>Mined by GPUs. Proven by fire.</p>
</div>
<nav class="foot-col" aria-label="Read">
<div class="eyebrow">Read</div>
<a href="/litepaper">Litepaper</a>
<a href="/litepaper#limits">What Igneum does not claim</a>
<a href="/litepaper#randomx">Igneum vs RandomX</a>
<a href="/litepaper#shoulders">Built on the shoulders</a>
<a href="/bench">Engineering log</a>
<a href="/evidence">Evidence</a>
</nav>
<nav class="foot-col" aria-label="Run">
<div class="eyebrow">Run</div>
<a href="/miner">The miner</a>
<a href="/wallet">The wallet</a>
<a href="/miners">GPU bench table</a>
<a href="/miner#fee">The dev fee</a>
</nav>
<nav class="foot-col" aria-label="Follow">
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>
</div>
<div class="foot-base">
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
<span class="mono">igneum.network</span>
</div>
</div>
</footer>
<!-- footer:end -->
<script>
(function(){
// one request per button; the wallet shows the chain to the user and decides. No key, no seed, no signature here.
var CHAINS = {
testnet: { chainId: '0x116e', chainName: 'Igneum Testnet', nativeCurrency: { name: 'Igneum', symbol: 'IGN', decimals: 18 }, rpcUrls: ['https://rpc.testnet.igneum.network'], blockExplorerUrls: [] },
devnet: { chainId: '0x116f', chainName: 'Igneum Devnet (local node)', nativeCurrency: { name: 'Igneum', symbol: 'IGN', decimals: 18 }, rpcUrls: ['http://127.0.0.1:26790'], blockExplorerUrls: [] }
};
function status(name, text) { var el = document.querySelector('[data-status="' + name + '"]'); if (el) el.textContent = text; }
Array.prototype.forEach.call(document.querySelectorAll('[data-add]'), function (btn) {
btn.addEventListener('click', function () {
var name = btn.getAttribute('data-add'); var chain = CHAINS[name];
if (!window.ethereum || !window.ethereum.request) { status(name, 'No Ethereum wallet found in this browser. Install MetaMask, or add the network by hand (below).'); return; }
status(name, 'Waiting for the wallet...');
window.ethereum.request({ method: 'wallet_addEthereumChain', params: [chain] })
.then(function () { status(name, 'Added. The wallet is on ' + chain.chainName + ' (chain id ' + parseInt(chain.chainId, 16) + ').'); })
.catch(function (e) { status(name, e && e.code === 4001 ? 'Cancelled in the wallet.' : 'The wallet refused: ' + ((e && e.message) || 'unknown error') + '. Add it by hand (below).'); });
});
});
})();
</script>
</body>
</html>

View file

@ -505,6 +505,7 @@ pre b{color:var(--molten);font-weight:500}
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

View file

@ -216,6 +216,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

View file

@ -25,6 +25,7 @@
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

View file

@ -6,4 +6,6 @@
<url><loc>https://igneum.network/bench</loc><lastmod>2026-10-04</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
<url><loc>https://igneum.network/evidence</loc><lastmod>2026-10-04</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
<url><loc>https://igneum.network/miners</loc><lastmod>2026-10-04</lastmod><changefreq>weekly</changefreq><priority>0.6</priority></url>
<url><loc>https://igneum.network/wallet</loc><lastmod>2026-10-05</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>
<url><loc>https://igneum.network/metamask</loc><lastmod>2026-10-05</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
</urlset>

View file

@ -413,6 +413,7 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
<div class="eyebrow">Follow</div>
<a href="/live">Live devnet</a>
<a href="/#journey">Journey</a>
<a href="/wallet">Add Igneum to MetaMask</a>
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
<a href="/miner#get">Get the miner</a>
</nav>

View file

@ -157,7 +157,7 @@ async function fetchOutputs(id, finals) {
if (o.target === 'linux') { try { chmodSync(plain, 0o755); } catch {} }
let note = '';
if (o.target === 'windows') {
const pe = peCheck(plain);
const pe = peCheck(plain, o.name.endsWith('.dll') ? 16 * 1024 : undefined); // the runtime DLLs the PC ships next to the exes are small
if (!pe.ok) { console.log(`${o.name}: PE check FAILED: ${pe.problems.join('; ')}`); bad++; continue; }
note = ` PE ok (${pe.sections.join(' ')}, subsystem ${pe.subsystem})`;
if (o.name === 'igneum-app.exe') {

View file

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

View file

@ -3,9 +3,58 @@
// node tools/logs.mjs list runs: label, machine, run_id, last received, total bytes
// node tools/logs.mjs <run_id> print the latest upload for that run
// node tools/logs.mjs <run_id> --all print every upload for that run, oldest first
// node tools/logs.mjs --rotation rotation phase 2 (docs/plans/rotation-phase-2.md): per app machine (labels mac-*,
// win-*), the version and the "config:" header line of its latest upload (the
// intake key's fingerprint and the downloads folder's fingerprint), against the
// fingerprints of ~/.config/igneum/log-intake-key.next and dl-token.next; exit 1
// while any machine still reports with the old values
// node tools/logs.mjs --self-test the header parser on sample lines
// Reads DATABASE_URL from ~/.config/igneum/env. No dependencies: Neon HTTP SQL over fetch.
import { readFileSync } from 'node:fs';
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
// the two header lines the engine logs at every start (app/igneum-app/src/engine.rs run(), config.rs describe()):
// IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=...
// config: intake https://.../api/log key 477bb0ef (packaged); manifest https://.../dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)
// The LAST occurrence wins (the restart after an OTA apply logs them again). Fields missing from the upload read ''.
export function parseRotation(lines) {
const out = { version: '', keyFp: '', keySource: '', folderFp: '', manifestSource: '' };
for (const line of String(lines).split('\n')) {
let m = /IGNEUM-APP version=(\S+)/.exec(line);
if (m) out.version = m[1];
m = /config: intake \S+ (?:key ([0-9a-f]{8})|no key) \((packaged|file [^)]+|none)\); manifest \S+ (?:folder ([0-9a-f]{8})|no folder|custom) \((packaged|file [^)]+|none)\)/.exec(line);
if (m) { out.keyFp = m[1] || ''; out.keySource = m[2]; out.folderFp = m[3] || ''; out.manifestSource = m[4]; }
}
return out;
}
// the first 8 hex of sha256 over the trimmed file content; '' when the file is missing (the app's config::fingerprint8)
export function fingerprintFile(path) {
if (!existsSync(path)) return '';
const v = readFileSync(path, 'utf8').trim();
return v ? createHash('sha256').update(v).digest('hex').slice(0, 8) : '';
}
if (process.argv[2] === '--self-test') {
let fails = 0;
const check = (name, ok, detail = '') => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ': ' + detail : ''}`); if (!ok) fails++; };
const sample = ['1 Igneum Miner 0.3.6 on pc (machine id 1ccfe586abcdef01), devnet (run win-1ccfe586-x)',
'1 IGNEUM-APP version=0.3.5 machine=1ccfe586 platform=windows node=igneumd_0.3.5',
'1 config: intake https://igneum-six.vercel.app/api/log key e2005de8 (packaged); manifest https://dl.igneum.network/dl/<token>/igneum-app-latest.json folder df66a82c (packaged)',
'2 update: applied', '2 IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=igneumd_0.3.6',
'2 config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (file log-intake-key.next); manifest https://dl.igneum.network/dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)'].join('\n');
const r = parseRotation(sample);
check('the last header wins', r.version === '0.3.6' && r.keyFp === '477bb0ef' && r.folderFp === 'ed9c4d2e', JSON.stringify(r));
check('sources are read', r.keySource === 'file log-intake-key.next' && r.manifestSource === 'packaged', JSON.stringify(r));
const none = parseRotation('1 config: intake none no key (none); manifest none no folder (none)\n');
check('a build without key or folder reads empty fingerprints', none.keyFp === '' && none.folderFp === '' && none.keySource === 'none', JSON.stringify(none));
const custom = parseRotation('1 config: intake https://x/api/log key 01234567 (packaged); manifest http://127.0.0.1:9/dl/t/igneum-app-latest.json custom (packaged)\n');
check('a custom manifest URL reads no folder', custom.keyFp === '01234567' && custom.folderFp === '', JSON.stringify(custom));
check('an old upload without the config line reads version only', (() => { const o = parseRotation('1 IGNEUM-APP version=0.3.4 machine=a platform=mac node=x\n'); return o.version === '0.3.4' && o.keyFp === '' && o.keySource === ''; })());
check('fingerprintFile of a missing file is empty', fingerprintFile('/nonexistent/igneum/key') === '');
console.log(fails ? `${fails} check(s) failed` : 'all checks passed');
process.exit(fails ? 1 : 0);
}
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
@ -28,6 +77,30 @@ async function sql(query, params = []) {
const [runId, flag] = process.argv.slice(2);
if (runId === '--rotation') {
const cfg = `${homedir()}/.config/igneum`;
const want = { key: fingerprintFile(`${cfg}/log-intake-key.next`) || fingerprintFile(`${cfg}/log-intake-key`), folder: fingerprintFile(`${cfg}/dl-token.next`) || fingerprintFile(`${cfg}/dl-token`) };
const old = { key: fingerprintFile(`${cfg}/log-intake-key`), folder: fingerprintFile(`${cfg}/dl-token`) };
// the latest upload per app label (mac-<id8>, win-<id8>); the header lines sit in the first bytes, the config line
// may repeat after an OTA restart, so the whole upload is parsed
const rows = await sql(`
SELECT DISTINCT ON (label) label, machine, run_id, received_at, lines
FROM miner_logs WHERE label LIKE 'mac-%' OR label LIKE 'win-%'
ORDER BY label, received_at DESC`);
if (!rows.length) { console.log('No app uploads yet.'); process.exit(1); }
let moved = 0, stale = 0;
const table = rows.map(r => {
const p = parseRotation(r.lines);
const ok = p.keyFp === want.key && p.folderFp === want.folder;
if (ok) moved++; else stale++;
return { label: r.label, machine: r.machine, version: p.version || '?', key: p.keyFp || '-', folder: p.folderFp || '-', sources: [p.keySource, p.manifestSource].filter(Boolean).join(' / ') || '-', last_received: new Date(r.received_at).toISOString().replace('T', ' ').slice(0, 19) + ' UTC', state: ok ? 'moved' : p.keyFp === old.key || p.folderFp === old.folder ? 'OLD' : 'unknown' };
}).sort((a, b) => a.state.localeCompare(b.state) || a.label.localeCompare(b.label));
console.table(table);
console.log(`expected: key ${want.key || '?'} folder ${want.folder || '?'} (from the .next files when they exist); old: key ${old.key || '?'} folder ${old.folder || '?'}`);
console.log(`${moved} machine(s) on the new key and folder, ${stale} not yet; a machine silent for over a day is listed by its last upload`);
process.exit(stale ? 1 : 0);
}
if (!runId) {
const rows = await sql(`
SELECT run_id, max(label) AS label, max(machine) AS machine, count(*)::int AS uploads,

253
tools/repo/fresh-repo.sh Executable file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env bash
# The history rewrite of docs/plans/history-rewrite.md, section 2, as one script: a fresh mirror clone, one
# git-filter-repo pass with the plan's rules, the greps that must read zero, and the commands (printed, never run)
# that create the fresh repository under the organisation and push the rewritten refs there (the owner's decision of
# 5 October 2026: option B, a fresh repository, never a force-push over the old one).
#
# tools/repo/fresh-repo.sh [--source <url|path>] [--work <dir>] [--new-repo <org/name>] [--new-login <login>]
# [--public-claude-md <file>] [--clean]
#
# --source what to clone (default: this checkout's origin URL; a local path makes a throwaway dry run)
# --work where the clone and the report go (default: a fresh directory under $TMPDIR); never inside a checkout
# --new-repo the repository the printed commands create (default: igneum-network/igneum-core)
# --new-login the renamed GitHub login (the owner renames it first; the numeric noreply id stays): every author
# line and every file mention of the standing login is rewritten to it, and the identity grep then
# demands zero hits for the old login too. Without it the standing login stays and is reported as such
# --public-claude-md a scrubbed CLAUDE.md that replaces the file in EVERY commit (docs/fud-fixes.md section 5 step 2)
# --clean remove the work directory at the end (the default keeps it: the push runs from that clone)
#
# Reads (never prints): ~/.config/igneum/log-intake-key, log-intake-key.next, dl-token, dl-token.next (those that
# exist) for the secret rules and the secret grep; the personal identities and the second owner login are read from
# the history itself (every author or committer that is not the standing login). The rule files are written 0600
# in a 0700 directory and removed (rm -P) as soon as the pass has run. Nothing is pushed; nothing in --source changes.
#
# Needs git-filter-repo 2.38 or later: `git filter-repo` on PATH, or IGNEUM_FILTER_REPO=<path to git_filter_repo.py>
# (pip: python3 -m pip install --target <dir> git-filter-repo). TZ is forced to UTC for everything this script runs.
set -euo pipefail
export TZ=UTC
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
STANDING_LOGIN="${IGNEUM_STANDING_LOGIN:-igneum-josh}"
ORG="igneum-network"
SOURCE="" WORK="" NEW_REPO="$ORG/igneum-core" NEW_LOGIN="" PUBLIC_CLAUDE="" CLEAN=0
while [ $# -gt 0 ]; do
case "$1" in
--source) SOURCE="$2"; shift 2 ;;
--work) WORK="$2"; shift 2 ;;
--new-repo) NEW_REPO="$2"; shift 2 ;;
--new-login) NEW_LOGIN="$2"; shift 2 ;;
--public-claude-md) PUBLIC_CLAUDE="$2"; shift 2 ;;
--clean) CLEAN=1; shift ;;
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -n "$SOURCE" ] || SOURCE="$(git -C "$ROOT" remote get-url origin)"
[ -n "$WORK" ] || WORK="$(mktemp -d "${TMPDIR:-/tmp}/igneum-fresh-repo.XXXXXX")"
case "$WORK" in /*) ;; *) WORK="$PWD/$WORK" ;; esac
mkdir -p "$WORK"
CLONE="$WORK/clone"
[ ! -e "$CLONE" ] || { echo "$CLONE exists; the pass runs on a fresh clone only (remove it or give another --work)" >&2; exit 1; }
if [ -n "$PUBLIC_CLAUDE" ]; then [ -f "$PUBLIC_CLAUDE" ] || { echo "no $PUBLIC_CLAUDE" >&2; exit 1; }; PUBLIC_CLAUDE="$(cd "$(dirname "$PUBLIC_CLAUDE")" && pwd)/$(basename "$PUBLIC_CLAUDE")"; fi
case "$NEW_LOGIN" in *[!A-Za-z0-9-]*) echo "--new-login must be a GitHub login (letters, digits, hyphens)" >&2; exit 2 ;; esac
[ "$NEW_LOGIN" != "$STANDING_LOGIN" ] || NEW_LOGIN=""
# the filter
if [ -n "${IGNEUM_FILTER_REPO:-}" ]; then FILTER=(python3 "$IGNEUM_FILTER_REPO")
elif git filter-repo --version >/dev/null 2>&1; then FILTER=(git filter-repo)
elif python3 -c 'import git_filter_repo' 2>/dev/null; then FILTER=(python3 -m git_filter_repo)
else echo "git-filter-repo is not installed: python3 -m pip install --target <dir> git-filter-repo, then IGNEUM_FILTER_REPO=<dir>/git_filter_repo.py" >&2; exit 1; fi
command -v perl >/dev/null || { echo "perl is needed for the greps" >&2; exit 1; }
say() { printf '%s\n' "$*" | tee -a "$WORK/report.txt"; }
: > "$WORK/report.txt"
say "fresh-repo: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
say "source: $SOURCE"
say "work: $WORK"
say "filter: ${FILTER[*]} ($("${FILTER[@]}" --version 2>/dev/null | head -1 || echo '?'))"
# ---- 1. the fresh mirror clone ------------------------------------------------------------------------------------
git clone --quiet --mirror --no-hardlinks "$SOURCE" "$CLONE"
cd "$CLONE"
# ---- 2. the values, read at run time, never printed ------------------------------------------------------------------
umask 077
RULES="$WORK/rules"; mkdir -p "$RULES"; chmod 700 "$RULES"
cleanup_rules() { if [ -d "$RULES" ]; then for f in "$RULES"/*; do [ -f "$f" ] && { rm -P "$f" 2>/dev/null || rm -f "$f"; }; done; rmdir "$RULES" 2>/dev/null || true; fi; }
trap cleanup_rules EXIT
# the standing login's noreply address, from the history
STANDING_EMAIL="$(git log --all --format='%ae%n%ce' | grep -E "^[0-9]+\+$STANDING_LOGIN@users\.noreply\.github\.com$" | sort -u | head -1 || true)"
[ -n "$STANDING_EMAIL" ] || { echo "the history carries no commit by $STANDING_LOGIN (set IGNEUM_STANDING_LOGIN)" >&2; exit 1; }
STANDING_ID="${STANDING_EMAIL%%+*}"
if [ -n "$NEW_LOGIN" ]; then TARGET_LOGIN="$NEW_LOGIN"; else TARGET_LOGIN="$STANDING_LOGIN"; fi
TARGET_EMAIL="$STANDING_ID+$TARGET_LOGIN@users.noreply.github.com"
TARGET_IDENT="$TARGET_LOGIN <$TARGET_EMAIL>"
# every other identity: "name|email" pairs (author and committer)
PERSONAL_PAIRS="$(git log --all --format='%an|%ae%n%cn|%ce' | grep -v "|$STANDING_EMAIL$" | sort -u || true)"
PERSONAL_EMAILS="$(printf '%s\n' "$PERSONAL_PAIRS" | awk -F'|' 'NF==2{print $2}' | sort -u)"
PERSONAL_NAMES="$(printf '%s\n' "$PERSONAL_PAIRS" | awk -F'|' 'NF==2{print $1}' | grep -v "^$STANDING_LOGIN$" | sort -u || true)"
FIRST_NAMES="$(printf '%s\n' "$PERSONAL_NAMES" | awk 'NF>=1{print $1}' | sort -u)"
LAST_NAMES="$(printf '%s\n' "$PERSONAL_NAMES" | awk 'NF>=2{print $NF}' | sort -u)"
SECOND_LOGINS="$(printf '%s\n' "$PERSONAL_EMAILS" | sed -nE 's/^[0-9]+\+([A-Za-z0-9-]+)@users\.noreply\.github\.com$/\1/p' | grep -v "^$STANDING_LOGIN$" | sort -u || true)"
# the other businesses named in the plan (brand names, not people)
OTHER_BUSINESSES='vivanmn|peasehill|thrsty|gemven|jbm exec'
# the secrets: whichever of the four files exist
SECRET_FILES=(); for n in log-intake-key log-intake-key.next dl-token dl-token.next; do [ -f "$HOME/.config/igneum/$n" ] && SECRET_FILES+=("$HOME/.config/igneum/$n"); done
say "standing login: $STANDING_LOGIN (noreply id $STANDING_ID)${NEW_LOGIN:+ -> $NEW_LOGIN}"
say "personal identities in the history: $(printf '%s\n' "$PERSONAL_PAIRS" | grep -c . || true) (names $(printf '%s\n' "$PERSONAL_NAMES" | grep -c . || true), addresses $(printf '%s\n' "$PERSONAL_EMAILS" | grep -c . || true), second owner logins $(printf '%s\n' "$SECOND_LOGINS" | grep -c . || true))"
say "secret files for the rules: ${#SECRET_FILES[@]} of 4"
# the rule files
REPLACE="$RULES/replace.txt"; MAILMAP="$RULES/mailmap"; IDENT="$RULES/identity.pl"; SECRETS="$RULES/secrets.pl"
: > "$REPLACE"; : > "$MAILMAP"; : > "$IDENT"; : > "$SECRETS"
for f in ${SECRET_FILES[@]+"${SECRET_FILES[@]}"}; do
v="$(tr -d '[:space:]' < "$f")"; [ ${#v} -ge 8 ] || continue
case "$(basename "$f")" in log-intake-key*) tag='***INTAKE-KEY-REMOVED***' ;; *) tag='***DL-TOKEN-REMOVED***' ;; esac
printf 'literal:%s==>%s\n' "$v" "$tag" >> "$REPLACE"
printf '%s\n' "$v" | sed 's/[][\\.*^$?+(){}|/]/\\&/g' >> "$SECRETS" # a regex for the scanner: metacharacters escaped (never \Q, which qr// does not expand from a variable)
done
while IFS='|' read -r name email; do
[ -n "$email" ] || continue
printf 'literal:%s <%s>==>%s\n' "$name" "$email" "$TARGET_IDENT" >> "$REPLACE"
printf '%s <%s> <%s>\n' "$TARGET_LOGIN" "$TARGET_EMAIL" "$email" >> "$MAILMAP"
done <<< "$PERSONAL_PAIRS"
while IFS= read -r email; do
[ -n "$email" ] || continue
printf 'literal:%s==>[removed]\n' "$email" >> "$REPLACE"
printf '%s\n' "$email" | sed 's/[][\\.*^$?+(){}|/]/\\&/g' >> "$IDENT"
done <<< "$PERSONAL_EMAILS"
if [ -n "$NEW_LOGIN" ]; then
printf 'literal:%s==>%s\n' "$STANDING_EMAIL" "$TARGET_EMAIL" >> "$REPLACE"
printf '%s <%s> <%s>\n' "$TARGET_LOGIN" "$TARGET_EMAIL" "$STANDING_EMAIL" >> "$MAILMAP"
printf 'regex:\\b%s\\b==>%s\n' "$STANDING_LOGIN" "$NEW_LOGIN" >> "$REPLACE"
printf '\\b%s\\b\n' "$STANDING_LOGIN" >> "$IDENT"
fi
while IFS= read -r first; do
[ -n "$first" ] || continue
printf 'regex:\\b%s%ss\\b==>the project lead%ss\n' "$first" "'" "'" >> "$REPLACE"
while IFS= read -r last; do [ -n "$last" ] && printf 'regex:\\b%s\\s+%s\\b==>the project lead\n' "$first" "$last" >> "$REPLACE"; done <<< "$LAST_NAMES"
printf 'regex:\\b%s\\b==>the project lead\n' "$first" >> "$REPLACE"
done <<< "$FIRST_NAMES"
while IFS= read -r last; do
[ -n "$last" ] || continue
printf 'regex:\\b%s\\b==>[removed]\n' "$last" >> "$REPLACE"
printf '\\b%s\\b\n' "$last" >> "$IDENT"
done <<< "$LAST_NAMES"
while IFS= read -r first; do
[ -n "$first" ] || continue
# the lower-case user-name form (Windows and WSL paths, the browser profile), never the standing login's suffix
printf 'regex:(?i)(?<!%s-)\\b%s\\b==>[user]\n' "${STANDING_LOGIN%%-*}" "$first" >> "$REPLACE"
printf '(?<!%s-)\\b%s\\b\n' "${STANDING_LOGIN%%-*}" "$first" >> "$IDENT"
done <<< "$FIRST_NAMES"
while IFS= read -r login; do
[ -n "$login" ] || continue
printf 'regex:(?i)\\b%s\\b==>[second-owner-login]\n' "$login" >> "$REPLACE"
printf '\\b%s\\b\n' "$login" >> "$IDENT"
done <<< "$SECOND_LOGINS"
printf 'regex:(?i)\\b(%s)\\b==>[other-business]\n' "$OTHER_BUSINESSES" >> "$REPLACE"
printf '\\b(%s)\\b\n' "$OTHER_BUSINESSES" >> "$IDENT"
say "rules: $(grep -c . "$REPLACE") replace lines, $(grep -c . "$MAILMAP") mailmap lines, $(grep -c . "$IDENT") identity patterns, $(grep -c . "$SECRETS") secret patterns (files under $RULES, removed at exit)"
# ---- 3. the counts, before and after ---------------------------------------------------------------------------------
# every blob in the object store (reachable or not: before the pass the mirror holds everything, after it filter-repo's gc
# has pruned), one count of matching lines over a pattern file (perl regexes, case-insensitive)
scan_blobs() {
git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype)' --unordered 2>/dev/null | awk '$2 == "blob" { print $1 }' \
| git cat-file --batch 2>/dev/null \
| perl -ne 'BEGIN { open(P, "<", shift) or die; @p = map { chomp; qr/$_/i } grep { /\S/ } <P>; $n = 0 } for my $p (@p) { if ($_ =~ $p) { $n++; last } } END { print "$n\n" }' "$1"
}
scan_meta() { git log --all --format='%an%n%ae%n%cn%n%ce%n%s%n%b' | perl -ne 'BEGIN { open(P, "<", shift) or die; @p = map { chomp; qr/$_/i } grep { /\S/ } <P>; $n = 0 } for my $p (@p) { if ($_ =~ $p) { $n++; last } } END { print "$n\n" }' "$1"; }
DROPPED=(docs/fud-ledger.md docs/fud-fixes.md docs/review site/ledger.html)
counts() { # <label>
local label="$1"
say ""
say "[$label]"
say " commits (all refs): $(git rev-list --all --count)"
say " refs: $(git for-each-ref | wc -l | tr -d ' ')"
say " author+committer identities: $(git log --all --format='%an <%ae>%n%cn <%ce>' | sort -u | wc -l | tr -d ' ')"
say " stamps not +0000 (of $(git log --all --format='%ad%n%cd' --date=raw | wc -l | tr -d ' ')): $(git log --all --format='%ad%n%cd' --date=raw | grep -vc ' +0000$' || true)"
say " commits touching the dropped files: $(git log --all --format=%H -- "${DROPPED[@]}" | sort -u | wc -l | tr -d ' ')"
say " secret lines in any blob: $(scan_blobs "$SECRETS")"
say " identity lines in any blob: $(scan_blobs "$IDENT")"
say " identity lines in commit metadata: $(scan_meta "$IDENT")"
say " standing login lines in any blob: $(printf '\\b%s\\b\n' "$STANDING_LOGIN" > "$RULES/login.pl"; scan_blobs "$RULES/login.pl")${NEW_LOGIN:+ (must be 0 with --new-login)}"
}
counts "before"
# ---- 4. the pass --------------------------------------------------------------------------------------------------
say ""
say "running: ${FILTER[*]} --force --invert-paths ${DROPPED[*]/#/--path } --replace-text <rules> --replace-message <rules> --mailmap <rules> --commit-callback <offsets to +0000>${PUBLIC_CLAUDE:+ --file-info-callback <CLAUDE.md from $PUBLIC_CLAUDE>}"
PATH_ARGS=(); for p in "${DROPPED[@]}"; do PATH_ARGS+=(--path "$p"); done
CALLBACK_ARGS=()
if [ -n "$PUBLIC_CLAUDE" ]; then
cat > "$RULES/file-info.py" <<PY
if filename == b'CLAUDE.md':
if 'claude' not in value.data:
value.data['claude'] = value.insert_file_with_contents(open('$PUBLIC_CLAUDE', 'rb').read())
return (filename, mode, value.data['claude'])
return (filename, mode, blob_id)
PY
CALLBACK_ARGS+=(--file-info-callback "$RULES/file-info.py")
fi
T0=$(date +%s)
"${FILTER[@]}" --force --quiet \
--invert-paths "${PATH_ARGS[@]}" \
--replace-text "$REPLACE" \
--replace-message "$REPLACE" \
--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")
' ${CALLBACK_ARGS[@]+"${CALLBACK_ARGS[@]}"}
say "pass done in $(( $(date +%s) - T0 )) s"
[ -f .git/filter-repo/commit-map ] && cp .git/filter-repo/commit-map "$WORK/commit-map" && say "commit-map: $WORK/commit-map ($(grep -c . "$WORK/commit-map") lines; keep it with the private notes)"
[ -f filter-repo/commit-map ] && cp filter-repo/commit-map "$WORK/commit-map" && say "commit-map: $WORK/commit-map ($(grep -c . "$WORK/commit-map") lines; keep it with the private notes)"
# ---- 5. the verification: every count that must read zero ------------------------------------------------------------
counts "after"
FAIL=0
must_zero() { local what="$1" n="$2"; if [ "$n" != "0" ]; then say " FAIL $what: $n (must be 0)"; FAIL=1; else say " ok $what: 0"; fi; }
say ""
say "[verdict]"
must_zero "secret lines in any blob" "$(scan_blobs "$SECRETS")"
must_zero "identity lines in any blob" "$(scan_blobs "$IDENT")"
must_zero "identity lines in commit metadata" "$(scan_meta "$IDENT")"
must_zero "stamps not +0000" "$(git log --all --format='%ad%n%cd' --date=raw | grep -vc ' +0000$' || true)"
must_zero "commits touching the dropped files" "$(git log --all --format=%H -- "${DROPPED[@]}" | sort -u | wc -l | tr -d ' ')"
must_zero "identities other than $TARGET_IDENT" "$(git log --all --format='%an <%ae>%n%cn <%ce>' | sort -u | grep -vcF "$TARGET_IDENT" || true)"
[ -n "$NEW_LOGIN" ] && must_zero "old login $STANDING_LOGIN in any blob" "$(scan_blobs "$RULES/login.pl")"
if [ -n "$PUBLIC_CLAUDE" ]; then
for ref in $(git for-each-ref --format='%(refname)' refs/heads | head -3); do
if git cat-file -p "$ref:CLAUDE.md" 2>/dev/null | cmp -s - "$PUBLIC_CLAUDE"; then say " ok CLAUDE.md on $ref is the public text"; else say " FAIL CLAUDE.md on $ref is not the public text"; FAIL=1; fi
done
fi
cleanup_rules; trap - EXIT
if [ "$FAIL" = 1 ]; then say ""; say "NOT CLEAN: fix the rules and run again on a fresh clone (nothing was pushed)"; [ "$CLEAN" = 1 ] && rm -rf "$WORK"; exit 1; fi
# ---- 6. the commands that create the fresh repository and push (printed, never run) ---------------------------------
say ""
say "clean. The push, when the owner says so (option B of docs/plans/history-rewrite.md section 5; every line by hand):"
say ""
say " # 1. freeze: every agent has committed and pushed; gh pr list --repo $ORG/igneum is empty; git worktree list recorded"
say " gh auth switch --user $TARGET_LOGIN && gh auth status"
say " # 2. the fresh repository (private; the name is the owner's; igneum-core is the suggestion)"
say " gh repo create $NEW_REPO --private --description 'Igneum: the GPU-mined zkEVM L1' --disable-wiki"
say " # 3. push every rewritten ref from the clone (the pass removed its origin remote on purpose)"
say " cd $CLONE"
say " git remote add origin https://github.com/$NEW_REPO.git"
say " git push --mirror origin"
say " # 4. after the push, on GitHub: default branch master; Settings > Secrets: DL_TOKEN, DL_TOKEN_NEXT, LOG_INTAKE_KEY,"
say " # LOG_INTAKE_KEY_NEXT (tr -d '[:space:]' < ~/.config/igneum/<file> | gh secret set <NAME> --repo $NEW_REPO);"
say " # Vercel project igneum (team igneum): Git > disconnect $ORG/igneum, connect $NEW_REPO, production branch master;"
say " # archive $ORG/igneum (Settings > Archive), keep it private; never delete it the same day"
say " # 5. re-clone the main checkout from the new history and re-create every worktree from its rewritten branch:"
say " cd ~/Projects && mv igneum igneum-old-history && git clone https://github.com/$NEW_REPO.git igneum"
say " # for each worktree: git -C ~/Projects/igneum worktree add ../igneum-wt-<name> <branch>; vendor/ is copied back by hand (gitignored)"
say " # 6. TZ=UTC in every shell that commits; tools/ci/identity-check.sh and the +0100 count stay the daily check"
[ "$CLEAN" = 1 ] && { cd /; rm -rf "$WORK"; say "work directory removed (--clean)"; } || say "report: $WORK/report.txt; clone kept at $CLONE"
exit 0

View file

@ -4,7 +4,7 @@
//
// node tools/ship-app.mjs 0.3.4 --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>]
// [--skip-windows | --skip-mac] [--node-commit <sha>] [--win-release <dir>] [--mac-release <dir>]
// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."]
// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."] [--dl-both]
// node tools/ship-app.mjs --check the six version files agree (exit 1 when they do not)
// node tools/ship-app.mjs --self-test the bump, on a scratch copy of the version files
//
@ -18,11 +18,21 @@
// fetch packaging/windows/fetch-ci-artifacts.sh <run>: the installer and the payload zip into the downloads folder
// dmg packaging/mac/build-dmg.sh under tools/lock/with-lock.sh build (nice 19, 4 cargo jobs)
// copy the DMG into the downloads folder
// manifest packaging/ota/publish-manifest.sh --no-deploy: canonical JSON, signed, signature verified locally
// mirror --dl-both only: the version's files and the folder-level files (jobs, payload inputs, CI record) into the
// NEXT token folder, dl/<dl-token.next>/, so both folders carry the same bytes
// manifest packaging/ota/publish-manifest.sh --no-deploy: canonical JSON, signed, signature verified locally; with
// --dl-both a second manifest in the NEXT folder (--dest, --base-url) carrying the same override, tuning and
// min_supported, checked field by field against the first
// deploy the downloads folder with the Vercel CLI (one deploy carries the files and the manifest together)
// verify HEAD and GET of the three files (size and sha256 against the local copies), the live manifest and its signature
// verify HEAD and GET of the three files (size and sha256 against the local copies), the live manifest and its signature;
// with --dl-both the same for the NEXT folder
// console one console item (tools/console.mjs post --kind build) with version, sizes and hashes, then sync-dl
//
// --dl-both (rotation phase 2, 5 October 2026, docs/plans/rotation-phase-2.md): the downloads token is being rotated.
// Installed apps check the OLD folder (dl-token); the new build checks the NEW one (dl-token.next, what
// packaging/mac/packaged-config.sh packages by default while that file exists). The version is published in BOTH
// folders so the old apps find the update and the new ones find their folder; one deploy, both verified.
//
// Secrets: ~/.config/igneum/dl-token, dlsite-dir, relay-token, relay-key, ota-signing-key, vercel/ are read by this
// tool or by the scripts it calls and never printed; every output line is scrubbed of the tokens. State that is not a
// secret (commit, run id, bump time) lives in ~/.cache/igneum/ship/<version>.json. gh auth switch --user igneum-josh runs
@ -107,7 +117,7 @@ function checkVersionFiles(root) {
}
// ---- output: every line scrubbed of the tokens -------------------------------------------------------------------
const SECRETS = ['dl-token', 'relay-token', 'relay-key', 'log-intake-key'].map(cfg).filter(s => s.length >= 8);
const SECRETS = ['dl-token', 'dl-token.next', 'relay-token', 'relay-key', 'log-intake-key', 'log-intake-key.next'].map(cfg).filter(s => s.length >= 8);
const scrub = s => SECRETS.reduce((t, k) => t.split(k).join('<token>'), String(s));
const say = (...a) => console.log(scrub(a.join(' ')));
const fmtSize = n => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
@ -154,7 +164,7 @@ for (let i = 0; i < argv.length; i++) {
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
else pos.push(a);
}
const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', 'manifest', 'deploy', 'verify', 'console'];
const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', 'mirror', 'manifest', 'deploy', 'verify', 'console'];
if (flags['self-test']) { process.exit(selfTest()); }
if (flags.check) {
@ -166,7 +176,7 @@ if (flags.check) {
}
const VERSION = pos[0];
if (!VERSION || !isVersion(VERSION)) { console.error('usage: node tools/ship-app.mjs <major.minor.patch> --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>] [--skip-windows|--skip-mac]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); }
if (!VERSION || !isVersion(VERSION)) { console.error('usage: node tools/ship-app.mjs <major.minor.patch> --node <fork worktree> [--notes "..."] [--dry-run] [--from <step>] [--skip-windows|--skip-mac] [--dl-both]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); }
if (!flags.node) { console.error('--node <fork worktree> is required (the igneum-node worktree the node and miner were built from)'); process.exit(2); }
if (flags.from && !STEPS.includes(flags.from)) { console.error(`--from must be one of: ${STEPS.join(', ')}`); process.exit(2); }
if (flags['skip-windows'] && flags['skip-mac']) { console.error('--skip-windows and --skip-mac together leave nothing to ship'); process.exit(2); }
@ -179,6 +189,14 @@ const TOKEN = cfg('dl-token');
const DLSITE = process.env.IGNEUM_DLSITE || cfg('dlsite-dir');
const DEST = DLSITE && TOKEN ? join(DLSITE, 'dl', TOKEN) : '';
const BASE = `https://dl.igneum.network/dl/${TOKEN}`;
// --dl-both: the NEXT folder, from ~/.config/igneum/dl-token.next
const BOTH = !!flags['dl-both'];
const TOKEN_NEXT = BOTH ? cfg('dl-token.next') : '';
const DEST_NEXT = BOTH && DLSITE && TOKEN_NEXT ? join(DLSITE, 'dl', TOKEN_NEXT) : '';
const BASE_NEXT = `https://dl.igneum.network/dl/${TOKEN_NEXT}`;
// the folder-level files the apps and the CI read next to the manifest (jobs, the CI's inputs, the CI record, the
// WSL2 prover zip): mirrored into the NEXT folder when present in the current one
const FOLDER_FILES = ['igneum-jobs.json', 'igneum-jobs.json.sig', 'payload-inputs.zip', 'payload-inputs.json', 'payload-inputs.sha256', 'igneum-windows-ci.json', 'igneum-prove-wsl2.zip'];
const DMG_NAME = `Igneum-Miner-${VERSION}.dmg`;
const SETUP_NAME = `Igneum-Miner-Setup-${VERSION}.exe`;
const ZIP_NAME = 'igneum-windows-app.zip';
@ -193,7 +211,42 @@ const WIN_RELEASE = flags['win-release'] ? resolve(flags['win-release']) : first
const MAC_RELEASE = flags['mac-release'] ? resolve(flags['mac-release']) : firstDir([join(NODE_DIR, 'target-integration', 'release'), join(NODE_DIR, 'target', 'release')], 'igneumd');
const WIN_INPUTS = ['igneumd.exe', 'igneum-miner.exe'].map(n => join(WIN_RELEASE, n));
const WORKERS = [join(ROOT, 'proto-cuda', 'nvrtc', 'igneum-worker-cuda.exe'), join(ROOT, 'proto-opencl', 'igneum-worker-opencl.exe')];
const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''} --from ${step}`;
const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''}${BOTH ? ' --dl-both' : ''} --from ${step}`;
// ---- --dl-both helpers (pure; the self-test runs them on scratch folders) -------------------------------------------
// which of `names` must be copied from src to dst: 'copy' (missing or different bytes), 'same', or 'absent' (not in src)
function mirrorPlan(src, dst, names) {
return names.map(name => {
const a = join(src, name), b = join(dst, name);
if (!existsSync(a)) return { name, action: 'absent' };
if (existsSync(b) && sha256(a) === sha256(b)) return { name, action: 'same' };
return { name, action: 'copy' };
});
}
// the arguments the second publish-manifest.sh call takes so the NEXT folder's manifest carries what the first one
// carries (override, tuning, min_supported are otherwise carried over from the manifest already in THAT folder, which
// is older or missing): [args, tuningFile|null]
function secondManifestArgs(first, dest, base, tmpDir) {
const args = ['--dest', dest, '--base-url', base];
const o = first.consensus && first.consensus.override;
if (o && typeof o === 'object' && Object.keys(o).length) args.push('--override', JSON.stringify(o));
if (first.min_supported_version) args.push('--min-supported', String(first.min_supported_version));
let tuningFile = null;
if (first.tuning && typeof first.tuning === 'object' && first.tuning.cards) { tuningFile = join(tmpDir, 'tuning.json'); writeFileSync(tuningFile, JSON.stringify(first.tuning)); args.push('--tuning', tuningFile); }
else args.push('--no-tuning');
return [args, tuningFile];
}
// the two manifests must agree on everything except published_at and the folder in the URLs: the differences, [] when none
function manifestDifferences(a, b, baseA, baseB) {
const diffs = [];
const norm = (m, base) => { const c = JSON.parse(JSON.stringify(m)); delete c.published_at; for (const e of Object.values(c.platforms || {})) if (typeof e.url === 'string') e.url = e.url.replace(base, '<base>'); return c; };
const x = norm(a, baseA), y = norm(b, baseB);
for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) {
const sx = JSON.stringify(x[k] === undefined ? null : x[k]), sy = JSON.stringify(y[k] === undefined ? null : y[k]);
if (sx !== sy) diffs.push(`${k}: ${sx.slice(0, 80)} vs ${sy.slice(0, 80)}`);
}
return diffs;
}
const results = []; // the final table
let dryProblems = 0; // a dry run lists preflight problems and goes on with the plan; its exit code says so
const done = (step, result, detail = '') => { results.push([step, result, detail]); say(`[${step}] ${result}${detail ? ': ' + detail : ''}`); };
@ -244,6 +297,12 @@ async function preflight() {
for (const n of ['dl-token', 'dlsite-dir', 'ota-signing-key', 'ota-signing-key.pub', 'relay-token', 'relay-key']) if (!existsSync(join(CFG, n))) problems.push(`no ~/.config/igneum/${n}`);
if (!existsSync(join(CFG, 'vercel'))) problems.push('no ~/.config/igneum/vercel (the Vercel login for the downloads host)');
if (!DEST || !existsSync(DEST)) problems.push(`no downloads folder at <dlsite>/dl/<token> (~/.config/igneum/dlsite-dir says ${DLSITE || 'nothing'})`);
if (BOTH) {
if (!TOKEN_NEXT) problems.push('--dl-both needs ~/.config/igneum/dl-token.next (the next downloads token)');
else if (TOKEN_NEXT === TOKEN) problems.push('--dl-both: dl-token.next equals dl-token; nothing to rotate');
else if (!DEST_NEXT || !existsSync(DEST_NEXT)) problems.push('--dl-both: no folder at <dlsite>/dl/<dl-token.next>; mkdir it first (an empty folder is fine)');
if (!existsSync(join(CFG, 'log-intake-key.next'))) notes.push('no ~/.config/igneum/log-intake-key.next: the packagers ship the current intake key (packaged-config.sh)');
}
// gh account (a read; the real steps switch before every call)
const st = runSync('gh', ['auth', 'status']).out;
const active = /Logged in to github\.com account (\S+) \(keyring\)\n\s+- Active account: true/.exec(st);
@ -262,6 +321,7 @@ async function preflight() {
['mac binaries', MAC ? ['igneumd', 'igneum-miner'].map(n => existsSync(join(MAC_RELEASE, n)) ? `${n} ${fmtSize(sizeOf(join(MAC_RELEASE, n)))}` : `${n} MISSING`).join(', ') : 'skipped'],
['workers', WORKERS.map(w => existsSync(w) ? basename(w) : `${basename(w)} missing`).join(', ')],
['downloads folder', DEST ? DEST.replace(TOKEN, '<token>') : 'none'],
['next folder', BOTH ? (DEST_NEXT ? DEST_NEXT.replace(TOKEN_NEXT, '<token.next>') : 'MISSING') : 'not used (no --dl-both)'],
['gh active', `${ghActive}${ghActive === GH_USER ? '' : ` (switched to ${GH_USER} before every call)`}`],
['live inputs', live.inputs ? `node ${live.inputs.node_source_commit} built ${live.inputs.built_at}` : 'none'],
['live ci', live.ci ? `${live.ci.installer} (${live.ci.run.split('/').pop()})` : 'none'],
@ -402,19 +462,50 @@ async function copy() {
done('copy', 'ok', `${DMG_NAME} ${fmtSize(sizeOf(dst))} copied`);
}
async function mirror() {
if (!BOTH) return done('mirror', 'skipped', 'no --dl-both');
const names = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME, ...FOLDER_FILES].filter(Boolean);
const plan = mirrorPlan(DEST, DEST_NEXT, names);
const copies = plan.filter(p => p.action === 'copy'), same = plan.filter(p => p.action === 'same'), absent = plan.filter(p => p.action === 'absent');
const summary = `${copies.length} to copy (${copies.map(p => p.name).join(', ') || 'none'}), ${same.length} same, ${absent.length} absent in the current folder${absent.length ? ` (${absent.map(p => p.name).join(', ')})` : ''}`;
if (DRY) return done('mirror', 'would', `copy into dl/<token.next>/: ${summary}`);
for (const name of [MAC && DMG_NAME, WIN && SETUP_NAME].filter(Boolean)) if (!existsSync(join(DEST, name))) throw new Error(`missing ${name} in the current folder; ${retryCmd(name.endsWith('.dmg') ? 'copy' : 'fetch')}`);
for (const p of copies) copyFileSync(join(DEST, p.name), join(DEST_NEXT, p.name));
const after = mirrorPlan(DEST, DEST_NEXT, names).filter(p => p.action === 'copy');
if (after.length) throw new Error(`still differ after the copy: ${after.map(p => p.name).join(', ')}`);
done('mirror', copies.length ? 'ok' : 'already', summary);
}
async function manifest() {
const args = ['--version', VERSION, '--notes', NOTES, '--no-deploy'];
if (MAC) args.push('--mac', join(DEST, DMG_NAME));
if (WIN) args.push('--win', join(DEST, SETUP_NAME));
for (const k of ['min-supported', 'activation-height', 'deadline-note', 'channel']) if (flags[k]) args.push(`--${k}`, String(flags[k]));
const cmd = `packaging/ota/publish-manifest.sh ${args.map(a => a.includes(' ') ? JSON.stringify(a) : a).join(' ')}`;
if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '<token>')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})`);
if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '<token>')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})${BOTH ? '; then the same for dl/<token.next>/ with --dest and --base-url, carrying this manifest\'s override, tuning and min_supported; the two compared field by field' : ''}`);
for (const p of [MAC && join(DEST, DMG_NAME), WIN && join(DEST, SETUP_NAME)].filter(Boolean)) if (!existsSync(p)) throw new Error(`missing ${p.replace(TOKEN, '<token>')}; ${retryCmd(p.endsWith('.dmg') ? 'copy' : 'fetch')}`);
const r = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args]);
if (r.code !== 0) throw new Error(`publish-manifest.sh exited ${r.code}; retry: ${cmd.replace(TOKEN, '<token>')}`);
const m = JSON.parse(readFileSync(join(DEST, 'igneum-app-latest.json'), 'utf8'));
if (m.version !== VERSION) throw new Error(`the written manifest says ${m.version}`);
done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`);
if (!BOTH) return done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`);
// the NEXT folder: the same entries from its own copies, the same override, tuning and min_supported
const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-m-'));
try {
const [extra, tuningFile] = secondManifestArgs(m, DEST_NEXT, BASE_NEXT, tmp);
const args2 = ['--version', VERSION, '--notes', NOTES, '--no-deploy', ...extra];
if (MAC) args2.push('--mac', join(DEST_NEXT, DMG_NAME));
if (WIN) args2.push('--win', join(DEST_NEXT, SETUP_NAME));
for (const k of ['activation-height', 'deadline-note', 'channel']) if (flags[k]) args2.push(`--${k}`, String(flags[k]));
const cmd2 = `packaging/ota/publish-manifest.sh ${args2.map(a => a.includes(' ') || a.startsWith('{') ? JSON.stringify(a) : a).join(' ')}`;
const r2 = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args2]);
if (r2.code !== 0) throw new Error(`publish-manifest.sh (next folder) exited ${r2.code}; retry: ${cmd2.replace(TOKEN_NEXT, '<token.next>')}`);
const m2 = JSON.parse(readFileSync(join(DEST_NEXT, 'igneum-app-latest.json'), 'utf8'));
const diffs = manifestDifferences(m, m2, BASE, BASE_NEXT);
if (diffs.length) throw new Error(`the two manifests differ beyond the folder and the publish time:\n ${diffs.join('\n ')}`);
if (tuningFile) say(` tuning carried into the next folder (${Object.keys(m.tuning.cards).length} card model(s))`);
done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')} in both folders, signed, verified locally, same fields`);
} finally { rmSync(tmp, { recursive: true, force: true }); }
}
async function deploy() {
@ -426,53 +517,62 @@ async function deploy() {
done('deploy', 'ok', 'downloads folder deployed');
}
async function verify() {
const files = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME].filter(Boolean);
if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify`);
// one folder: HEAD and GET of the files against the local copies, the manifest's bytes and signature; the failures
async function verifyFolder(dest, base, files, label) {
const rows = [['file', 'local', 'HEAD', 'sha256']];
const failures = [];
const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-'));
try {
for (const name of files) {
const local = join(DEST, name); const want = sha256(local); const size = sizeOf(local);
const local = join(dest, name); const want = sha256(local); const size = sizeOf(local);
let h, got = '';
for (let attempt = 1; attempt <= 3; attempt++) {
h = await head(`${BASE}/${name}`);
h = await head(`${base}/${name}`);
if (h.status === 200 && (h.length === null || h.length === size)) {
const r = await run('curl', ['-fsSL', '--retry', '3', '-o', join(tmp, name), `${BASE}/${name}`], { quiet: true });
const r = await run('curl', ['-fsSL', '--retry', '3', '-o', join(tmp, name), `${base}/${name}`], { quiet: true });
if (r.code === 0) { got = sha256(join(tmp, name)); if (got === want) break; }
}
if (attempt < 3) { say(` ${name}: not matching yet (HTTP ${h.status}, length ${h.length}, sha ${got ? got.slice(0, 12) : '-'}); again in 15 s`); await sleep(15000); }
}
const ok = h.status === 200 && (h.length === null || h.length === size) && got === want;
rows.push([name, `${size} B ${want.slice(0, 12)}`, `${h.status} ${h.length === null ? '(no length)' : h.length + ' B'}`, got === want ? `ok ${want.slice(0, 12)}` : `MISMATCH ${got.slice(0, 12) || 'no body'}`]);
if (!ok) failures.push(name);
if (!ok) failures.push(`${label}:${name}`);
state.files = state.files || {}; state.files[name] = { size, sha256: want, served: ok };
}
// the manifest: bytes and signature, through the same verifier the apps use
const mr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.json'), `${BASE}/igneum-app-latest.json`], { quiet: true });
const sr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.sig'), `${BASE}/igneum-app-latest.json.sig`], { quiet: true });
const mr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.json'), `${base}/igneum-app-latest.json`], { quiet: true });
const sr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.sig'), `${base}/igneum-app-latest.json.sig`], { quiet: true });
let mline = 'not reachable';
if (mr.code === 0 && sr.code === 0) {
const v = await run(SIGNER, ['verify', join(CFG, 'ota-signing-key.pub'), join(tmp, 'm.json'), join(tmp, 'm.sig')], { quiet: true });
const m = JSON.parse(readFileSync(join(tmp, 'm.json'), 'utf8'));
const same = readFileSync(join(tmp, 'm.json')).equals(readFileSync(join(DEST, 'igneum-app-latest.json')));
const same = readFileSync(join(tmp, 'm.json')).equals(readFileSync(join(dest, 'igneum-app-latest.json')));
const plat = Object.entries(m.platforms || {}).map(([k, e]) => `${k} ${e.sha256.slice(0, 12)}`).join(', ');
mline = `${m.version} ${v.code === 0 ? 'signature ok' : 'SIGNATURE FAILS'}${same ? '' : ' (DIFFERS from the local manifest)'} ${plat}`;
if (v.code !== 0 || m.version !== VERSION || !same) failures.push('manifest');
state.manifest = { version: m.version, published_at: m.published_at, platforms: m.platforms };
} else failures.push('manifest');
const inFolder = Object.values(m.platforms || {}).every(e => typeof e.url === 'string' && e.url.startsWith(base + '/'));
mline = `${m.version} ${v.code === 0 ? 'signature ok' : 'SIGNATURE FAILS'}${same ? '' : ' (DIFFERS from the local manifest)'}${inFolder ? '' : ' (URLS POINT OUTSIDE THIS FOLDER)'} ${plat}`;
if (v.code !== 0 || m.version !== VERSION || !same || !inFolder) failures.push(`${label}:manifest`);
if (!state.manifest || label === 'current') state.manifest = { version: m.version, published_at: m.published_at, platforms: m.platforms };
} else failures.push(`${label}:manifest`);
rows.push(['igneum-app-latest.json', '', '', mline]);
} finally { rmSync(tmp, { recursive: true, force: true }); }
say(` ${label} folder: ${label === 'next' ? 'dl/<token.next>/' : 'dl/<token>/'}`);
table(rows);
return failures;
}
async function verify() {
const files = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME].filter(Boolean);
if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify${BOTH ? '; the same for dl/<token.next>/' : ''}`);
const failures = await verifyFolder(DEST, BASE, files, 'current');
if (BOTH) failures.push(...await verifyFolder(DEST_NEXT, BASE_NEXT, files, 'next'));
saveState();
if (failures.length) throw new Error(`not served as expected: ${failures.join(', ')}; the deploy may still be propagating. Retry: ${retryCmd('deploy')}`);
done('verify', 'ok', `${files.length} files and the manifest match the local copies`);
done('verify', 'ok', `${files.length} files and the manifest match the local copies${BOTH ? ' in both folders' : ''}`);
}
async function consoleStep() {
const lines = Object.entries(state.files || {}).map(([n, f]) => `${n} ${f.size} B sha256 ${f.sha256}`);
const body = [NOTES, ...lines, state.runId ? `Windows CI https://github.com/${REPO}/actions/runs/${state.runId}` : '', state.sha ? `commit ${state.sha.slice(0, 12)}, node fork ${state.forkCommit || '?'}` : ''].filter(Boolean).join('\n');
const body = [NOTES, ...lines, state.runId ? `Windows CI https://github.com/${REPO}/actions/runs/${state.runId}` : '', state.sha ? `commit ${state.sha.slice(0, 12)}, node fork ${state.forkCommit || '?'}` : '', BOTH ? 'published in both downloads folders (token rotation)' : ''].filter(Boolean).join('\n');
const meta = { version: VERSION, files: state.files || {}, run: state.runId || null, commit: state.sha || null, fork: state.forkCommit || null, manifest_published_at: state.manifest ? state.manifest.published_at : null };
if (DRY) return done('console', 'would', `tools/console.mjs post --kind build --key ship:${VERSION} --title "Igneum Miner ${VERSION} shipped" (sizes, hashes, run, commit), then sync-dl`);
const r = await run('node', [join(ROOT, 'tools', 'console.mjs'), 'post', '--kind', 'build', '--key', `ship:${VERSION}`, '--title', `Igneum Miner ${VERSION} shipped (${[MAC && 'mac', WIN && 'windows'].filter(Boolean).join('+')})`, '--body', body, '--meta', JSON.stringify(meta)], { quiet: true });
@ -482,7 +582,7 @@ async function consoleStep() {
}
// ---- the runner ----------------------------------------------------------------------------------------------------
const IMPL = { preflight, bump, inputs, commit, ci, fetch: fetchStep, dmg, copy, manifest, deploy, verify, console: consoleStep };
const IMPL = { preflight, bump, inputs, commit, ci, fetch: fetchStep, dmg, copy, mirror, manifest, deploy, verify, console: consoleStep };
async function main() {
const start = flags.from ? STEPS.indexOf(flags.from) : 0;
// preflight always runs: it is reads only and the later steps need its facts (fork commit, state)
@ -555,6 +655,23 @@ function selfTest() {
check('1.2 is refused', refused);
check('comma helpers round-trip', toComma('0.3.4') === '0,3,4,0' && fromComma('0,3,4,0') === '0.3.4' && fromComma('0,3,4,1') === '0,3,4,1');
check('version compare', cmpVersion('0.3.4', '0.3.3') > 0 && cmpVersion('0.10.0', '0.9.9') > 0 && cmpVersion('1.0.0', '1.0.0') === 0);
// 5. --dl-both helpers on two scratch folders
const a = join(dir, 'dl', 'old'), b = join(dir, 'dl', 'new');
mkdirSync(a, { recursive: true }); mkdirSync(b, { recursive: true });
writeFileSync(join(a, 'x.dmg'), 'dmg bytes'); writeFileSync(join(a, 'same.json'), 'same'); writeFileSync(join(b, 'same.json'), 'same');
writeFileSync(join(a, 'differs.zip'), 'v2'); writeFileSync(join(b, 'differs.zip'), 'v1');
const plan = Object.fromEntries(mirrorPlan(a, b, ['x.dmg', 'same.json', 'differs.zip', 'absent.sig']).map(p => [p.name, p.action]));
check('mirror plan: missing -> copy, same -> same, different -> copy, absent -> absent', plan['x.dmg'] === 'copy' && plan['same.json'] === 'same' && plan['differs.zip'] === 'copy' && plan['absent.sig'] === 'absent', JSON.stringify(plan));
const first = { version: '0.3.6', published_at: '2026-10-05T12:00:00Z', channel: 'devnet', notes: 'n', min_supported_version: '0.3.0', platforms: { mac: { url: 'https://dl.igneum.network/dl/OLD/Igneum-Miner-0.3.6.dmg', sha256: 'aa', size: 1, kind: 'dmg' } }, consensus: { activation_height: null, deadline_note: '', override: { difficulty_v2_activation_daa: 33000 } }, tuning: { cards: { 'RTX 5090': { v: 1 } } } };
const [args, tuningFile] = secondManifestArgs(first, '/dest', 'https://dl.igneum.network/dl/NEW', dir);
check('second manifest args carry override, min_supported and tuning', args.includes('--override') && args[args.indexOf('--override') + 1] === '{"difficulty_v2_activation_daa":33000}' && args.includes('--min-supported') && args[args.indexOf('--min-supported') + 1] === '0.3.0' && args.includes('--tuning') && tuningFile && JSON.parse(readFileSync(tuningFile, 'utf8')).cards['RTX 5090'].v === 1, args.join(' '));
const [args0] = secondManifestArgs({ version: '0.3.6', platforms: {} }, '/dest', 'https://x', dir);
check('second manifest args without override or tuning say --no-tuning and no --override', args0.includes('--no-tuning') && !args0.includes('--override') && !args0.includes('--min-supported'), args0.join(' '));
const second = JSON.parse(JSON.stringify(first)); second.published_at = '2026-10-05T12:01:00Z'; second.platforms.mac.url = 'https://dl.igneum.network/dl/NEW/Igneum-Miner-0.3.6.dmg';
check('two manifests that differ only by folder and time agree', manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW').length === 0);
second.consensus.override.difficulty_v2_activation_daa = 1; delete second.tuning;
const d = manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW');
check('a changed override and a dropped tuning are reported', d.length === 2 && d.some(x => x.startsWith('consensus')) && d.some(x => x.startsWith('tuning')), d.join(' | '));
} finally { rmSync(dir, { recursive: true, force: true }); }
say(fails ? `${fails} check(s) failed` : 'all checks passed');
return fails ? 1 : 0;