wallet 0.1.5: merge release-0.3.13 (the 0.3.13 tree under the wallet app: igneum-common, packaged-config, the ten-to-thirteen-field objects)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> # Conflicts: # .github/workflows/ci.yml # .gitignore # docs/bench-log.md # packaging/README-ship.md # packaging/mac/packaged-config.sh # packaging/ota/publish-manifest.sh
This commit is contained in:
commit
c36705f846
993 changed files with 150510 additions and 2614 deletions
47
.github/workflows/ci.yml
vendored
47
.github/workflows/ci.yml
vendored
|
|
@ -1,8 +1,10 @@
|
|||
# CI on every push and pull request (private repository, free runner minutes).
|
||||
#
|
||||
# What runs: the lottery-hash crate's tests (igneum-pow, release profile), the census tool's build, the two Python
|
||||
# simulators' --quick modes (each under two minutes), the site build with an internal link check, and the gh-free
|
||||
# identity grep of the public export list (tools/ci/forbidden-strings.txt).
|
||||
# simulators' --quick modes (each under two minutes), the site build with an internal link check, the gh-free
|
||||
# identity grep of the public export list (tools/ci/forbidden-strings.txt), and the no-secrets check of the tree
|
||||
# (tools/ci/no-secrets-check.sh: no file named like a key of ~/.config/igneum, no 64-hex value assigned to a
|
||||
# token/key/secret name outside tests and the allowlist; docs/security/keys.md).
|
||||
#
|
||||
# What does not run, on purpose: the node fork (vendor/igneum-node*, a rusty-kaspa fork of about 500 crates with
|
||||
# rocksdb, blst and the execution layer) is gitignored here and too big for the free runners today (a cold build is
|
||||
|
|
@ -23,6 +25,8 @@ jobs:
|
|||
- name: igneum-pow tests (release)
|
||||
working-directory: igneum-pow
|
||||
run: cargo test --release
|
||||
- name: pack loader seed rule (packfile.h on a known-good and a known-mismatched pack)
|
||||
run: bash proto-cuda/nvrtc/emu/packfile-test.sh
|
||||
- name: igneum-census build (release)
|
||||
working-directory: igneum-census
|
||||
run: cargo build --release
|
||||
|
|
@ -61,7 +65,38 @@ jobs:
|
|||
run: node tools/ci/link-check.mjs
|
||||
- name: identity grep of the public export list
|
||||
run: bash tools/ci/identity-check.sh
|
||||
- name: relay unit tests (parsers, secret compare)
|
||||
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs
|
||||
- name: wallet update card (wording, note lines, when the card shows)
|
||||
run: node --test app/igneum-wallet/ui/update-card.test.mjs
|
||||
- name: no conflict markers in tracked files
|
||||
run: bash tools/ci/no-conflict-markers.sh
|
||||
- name: copied sources are re-stamped before a build
|
||||
run: bash tools/ci/copied-sources-check.sh
|
||||
- name: second-engine playbooks log to a file and end their tree (C35)
|
||||
run: bash tools/ci/second-engine-check.sh
|
||||
- name: no playbook quits, pauses or resumes the installed app (self-test first, then the tree)
|
||||
run: bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh
|
||||
- name: the signer is never piped into head
|
||||
run: bash tools/ci/signer-pipe-check.sh
|
||||
- name: bash bodies in PowerShell job scripts pass bash -n, the lost-quote class (self-test first, then the tree)
|
||||
run: bash tools/ci/bash-body-check.sh --self-test && bash tools/ci/bash-body-check.sh
|
||||
- name: run jobs test their fetched kit before use, the wiped-jobs-folder class (self-test first, then the tree)
|
||||
run: bash tools/ci/kit-path-check.sh --self-test && bash tools/ci/kit-path-check.sh
|
||||
- name: every Windows spawn of the app runs with a hidden console (self-test first, then the tree)
|
||||
run: node tools/ci/windows-spawn-check.mjs --self-test && node tools/ci/windows-spawn-check.mjs
|
||||
- name: pinned guest programs match their manifest and are built only by pin-guests.sh
|
||||
run: bash tools/ci/pinned-guests-check.sh
|
||||
- name: root prover playbooks kill the GPU server and unlink its socket (the root-socket class, 5 October 2026)
|
||||
run: bash tools/ci/prover-socket-check.sh
|
||||
- name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree)
|
||||
run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh
|
||||
- name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors)
|
||||
run: node --test site/api/faucet.test.mjs
|
||||
- name: explorer and public stats unit tests (search router, formatters, emission rule against the node's own test values, the documented API fields from a fixture)
|
||||
run: node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
|
||||
- name: public stats API answers with the documented fields (the live site; master only, the endpoints exist there after the merge)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: node tools/ci/public-api-check.mjs https://igneum.network
|
||||
- name: ship tool self-test (version bump, the dl-both and public manifest helpers)
|
||||
run: node tools/ship-app.mjs --self-test
|
||||
- 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 relay/test/ember.test.mjs
|
||||
- name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows)
|
||||
run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
|
|
|
|||
83
.github/workflows/windows.yml
vendored
83
.github/workflows/windows.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -27,7 +27,7 @@ proto-opencl/igneum-bench-cl-generic-test*
|
|||
proto-opencl/soak-*.log
|
||||
proto-opencl/hardened-check*.log
|
||||
vendor/igneum-node-ship/
|
||||
# the wallet and the common crate (4 October 2026)
|
||||
app/igneum-wallet/target/
|
||||
app/igneum-common/target/
|
||||
packaging/mac/build-wallet/
|
||||
|
||||
# Trademark instruction packs name the director and the applicant company; never in the repository
|
||||
brand/trademark/pbip-pack/
|
||||
brand/trademark/*.zip
|
||||
|
|
|
|||
2
app/igneum-app/Cargo.lock
generated
2
app/igneum-app/Cargo.lock
generated
|
|
@ -219,7 +219,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "igneum-app"
|
||||
version = "0.3.3"
|
||||
version = "0.3.13"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "igneum-app"
|
||||
version = "0.3.3"
|
||||
version = "0.3.13"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
1 ICON "igneum.ico"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,3,3,0
|
||||
PRODUCTVERSION 0,3,3,0
|
||||
FILEVERSION 0,3,13,0
|
||||
PRODUCTVERSION 0,3,13,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.3"
|
||||
VALUE "FileVersion", "0.3.13"
|
||||
VALUE "InternalName", "igneum-app"
|
||||
VALUE "LegalCopyright", "Igneum contributors"
|
||||
VALUE "OriginalFilename", "igneum-app.exe"
|
||||
VALUE "ProductName", "Igneum Miner"
|
||||
VALUE "ProductVersion", "0.3.3"
|
||||
VALUE "ProductVersion", "0.3.13"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
|
|
|||
|
|
@ -9,11 +9,21 @@
|
|||
//! 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 envelope-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file> prints igneum-jobs.signed.json:
|
||||
//! the file and its signature in ONE object (src/jobs.rs), refused when the pair does not verify
|
||||
//! igneum-ota-sign verify-signed-jobs <public-key-file|hex> <igneum-jobs.signed.json>
|
||||
//! 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 +125,71 @@ fn main() {
|
|||
Err(e) => die(&e),
|
||||
}
|
||||
}
|
||||
Some("envelope-jobs") if args.len() == 4 => {
|
||||
let pk = read_key_arg(&args[1]);
|
||||
let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2])));
|
||||
let sig = std::fs::read_to_string(&args[3]).unwrap_or_else(|e| die(&format!("{}: {e}", args[3])));
|
||||
jobs::verify_and_parse(&bytes, sig.trim(), &pk).unwrap_or_else(|e| die(&format!("refusing to wrap: {e}")));
|
||||
let env = jobs::signed_envelope(&bytes, sig.trim(), &pk).unwrap_or_else(|e| die(&e));
|
||||
jobs::verify_and_parse_signed(env.as_bytes(), &pk).unwrap_or_else(|e| die(&format!("the envelope does not read back: {e}")));
|
||||
println!("{env}");
|
||||
}
|
||||
Some("verify-signed-jobs") if args.len() == 3 => {
|
||||
let pk = read_key_arg(&args[1]);
|
||||
let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2])));
|
||||
match jobs::verify_and_parse_signed(&bytes, &pk) {
|
||||
Ok(f) => println!("ok: {} job(s), published {}, file and signature in one object", f.jobs.len(), f.published_at),
|
||||
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> | envelope-jobs <pub> <jobs.json> <sig> | verify-signed-jobs <pub> <jobs.signed.json> | 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
157
app/igneum-app/src/bin/prove-verify.rs
Normal file
157
app/igneum-app/src/bin/prove-verify.rs
Normal 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/"));
|
||||
}
|
||||
}
|
||||
|
|
@ -12,9 +12,33 @@ use std::path::{Path, PathBuf};
|
|||
pub struct CardPref {
|
||||
pub enabled: bool,
|
||||
pub identities: u32,
|
||||
/// NVIDIA power cap, percent of the card's default limit (60 to 100); 0 = the default 80
|
||||
/// NVIDIA power cap, percent of the card's default limit (50 to 100); 0 = the default 80
|
||||
#[serde(default)]
|
||||
pub power_pct: u32,
|
||||
/// the user moved the slider: this cap stays; the efficiency sweep (src/sweep.rs) records but does not change it
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
/// the last efficiency sweep: when (unix s), the cap it chose, and that step's numbers
|
||||
#[serde(default)]
|
||||
pub sweep_at: u64,
|
||||
#[serde(default)]
|
||||
pub sweep_pct: u32,
|
||||
#[serde(default)]
|
||||
pub sweep_eff: f64,
|
||||
#[serde(default)]
|
||||
pub sweep_watts: f64,
|
||||
#[serde(default)]
|
||||
pub sweep_mhs: f64,
|
||||
/// Ember Tune (src/ember.rs): the clock cap the last tune chose (0 = unlocked), the driver and program class it
|
||||
/// ran under (a change makes the card due again), and the plan that produced it (full | confirm | baseline)
|
||||
#[serde(default)]
|
||||
pub sweep_clock_mhz: u32,
|
||||
#[serde(default)]
|
||||
pub sweep_driver: String,
|
||||
#[serde(default)]
|
||||
pub sweep_class: String,
|
||||
#[serde(default)]
|
||||
pub sweep_source: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
|
|
@ -55,6 +79,35 @@ pub struct Settings {
|
|||
/// Prove the shards assigned to this machine's keys (src/prover.rs). Default off until the GPU numbers exist.
|
||||
#[serde(default)]
|
||||
pub prove: bool,
|
||||
/// The efficiency sweep (src/sweep.rs): once after install, then weekly, each NVIDIA card's cap is stepped from
|
||||
/// 100% to 50% on the live program and held at the best MH per watt. Default off; implied by `power_control`
|
||||
/// (on when that is switched on, never effective while it is off). A pinned card is skipped.
|
||||
#[serde(default)]
|
||||
pub sweep: bool,
|
||||
/// Power control (the project lead, 5 October 2026: "if we don't have to ask then don't ask"): the NVIDIA power cap and the
|
||||
/// efficiency sweep need administrator rights (one UAC prompt on Windows). Default OFF on every machine; the app
|
||||
/// never raises the prompt on its own. Switching it on asks once, at that moment; a refused, cancelled or
|
||||
/// unanswered prompt switches it back off with a notice, no retries.
|
||||
#[serde(default)]
|
||||
pub power_control: bool,
|
||||
/// When this install first ran (unix s), for the "first hour after install" sweep.
|
||||
#[serde(default)]
|
||||
pub installed_at: u64,
|
||||
/// The miner software's dev fee (1 block template in 100 to the dev address; decision of 4 October 2026). Default
|
||||
/// on; off passes `--dev-fee 0` to igneum-miner. The protocol itself carries no fee.
|
||||
#[serde(default = "yes")]
|
||||
pub dev_fee: bool,
|
||||
/// 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,
|
||||
/// Proving v1 step 1 (5 October 2026): the install-time default for `prove` has been applied once (src/provedefault.rs:
|
||||
/// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start.
|
||||
#[serde(default)]
|
||||
pub prove_default_applied: bool,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
|
|
@ -66,13 +119,51 @@ 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 }
|
||||
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, power_control: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// What a measurement engine (`--sweep`, started by a job beside the installed app) runs with, whatever the copied
|
||||
/// file says: no remote jobs (run 4, 6 October 2026: the second engine fetched the jobs file and ran 96 old jobs
|
||||
/// inside its scratch root), no updates, no proving, not paused, the tune on, Power control off (only an engine
|
||||
/// that is itself elevated controls NVIDIA, through the probe's `direct`), every card due and unpinned. The file
|
||||
/// on disk is never changed: the playbook copies the installed app's settings verbatim (a PowerShell JSON round
|
||||
/// trip rewrote big integers as doubles and the engine read the whole file as defaults: no payout address, every
|
||||
/// card off).
|
||||
pub fn for_measurement(mut self) -> Settings {
|
||||
self.remote_jobs = false;
|
||||
self.auto_update = false;
|
||||
self.prove = false;
|
||||
self.paused = false;
|
||||
self.sweep = true;
|
||||
self.power_control = false;
|
||||
self.setup_done = true;
|
||||
for p in self.cards.values_mut() {
|
||||
p.sweep_at = 0;
|
||||
p.pinned = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Settings {
|
||||
std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
|
||||
let mut s: Settings = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
|
||||
let mut dirty = false;
|
||||
if s.installed_at == 0 {
|
||||
// an install from before the sweep existed counts as installed now: it gets its first-hour sweep
|
||||
s.installed_at = crate::platform::unix_now();
|
||||
dirty = true;
|
||||
}
|
||||
if s.sweep && !s.power_control {
|
||||
// the sweep is implied by power control (5 October 2026): an install from before that setting carried
|
||||
// sweep = true by default; it no longer prompts on its own
|
||||
s.sweep = false;
|
||||
dirty = true;
|
||||
}
|
||||
if dirty {
|
||||
s.save(path);
|
||||
}
|
||||
s
|
||||
}
|
||||
pub fn save(&self, path: &Path) {
|
||||
if let Some(d) = path.parent() {
|
||||
|
|
@ -120,16 +211,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +323,9 @@ pub struct Runtime {
|
|||
pub app_dir: PathBuf,
|
||||
pub log_dir: PathBuf,
|
||||
pub status_secs: u32,
|
||||
/// `--sweep`: run the efficiency sweep on every supported card as soon as it mines, print the table on stdout,
|
||||
/// leave the chosen caps in force and quit (the PC measurement job; docs/plans/miner-eff.md).
|
||||
pub sweep_only: bool,
|
||||
/// The machine's hostname: a friendly label only, never part of a key or a payout address.
|
||||
pub host: String,
|
||||
/// Per-install random id (16 hex, app data dir/machine-id, locked to the user). Identity labels, vote keys and
|
||||
|
|
@ -181,7 +359,8 @@ impl Runtime {
|
|||
let log_dir = crate::platform::log_root();
|
||||
let status_secs = env("IGNEUM_APP_STATUS_SECS").and_then(|v| v.parse().ok()).unwrap_or(30);
|
||||
let machine_id = machine_id(&app_dir);
|
||||
Runtime { network, rpc_port, p2p_port, peers, unsynced_mining, devnet_suffix, node_dir, app_dir, log_dir, status_secs, host: crate::platform::host_label(), machine_id }
|
||||
let sweep_only = env("IGNEUM_APP_SWEEP").map(|v| v == "1").unwrap_or(false);
|
||||
Runtime { network, rpc_port, p2p_port, peers, unsynced_mining, devnet_suffix, node_dir, app_dir, log_dir, status_secs, sweep_only, host: crate::platform::host_label(), machine_id }
|
||||
}
|
||||
/// The first 8 hex of the machine id: what goes into labels.
|
||||
pub fn id8(&self) -> String {
|
||||
|
|
@ -200,6 +379,119 @@ 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 a_measurement_engine_overrides_the_copied_settings_in_memory() {
|
||||
let mut s = Settings { remote_jobs: true, auto_update: true, prove: true, paused: true, sweep: false, power_control: true, address: "0xabc".into(), ..Default::default() };
|
||||
s.cards.insert("nvidia:0:x".into(), CardPref { enabled: true, identities: 8, sweep_at: 1_791_000_000, pinned: true, power_pct: 70, ..Default::default() });
|
||||
let m = s.for_measurement();
|
||||
assert!(!m.remote_jobs && !m.auto_update && !m.prove && !m.paused && m.sweep && !m.power_control && m.setup_done);
|
||||
assert_eq!(m.address, "0xabc", "the payout address is the installed app's");
|
||||
let c = &m.cards["nvidia:0:x"];
|
||||
assert!(c.enabled && c.identities == 8 && c.power_pct == 70, "the card's choices stay");
|
||||
assert!(c.sweep_at == 0 && !c.pinned, "every card is due and unpinned");
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
@ -208,3 +500,18 @@ mod tests {
|
|||
assert!(p.node_override_params.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixture_tests {
|
||||
/// `IGNEUM_TEST_SETTINGS=<path> cargo test settings_fixture`: parses a real settings.json with this crate's
|
||||
/// struct and prints what it read (6 October 2026: PC 1's copied file read as defaults; this names the field).
|
||||
#[test]
|
||||
fn settings_fixture_parses_when_given() {
|
||||
let Ok(p) = std::env::var("IGNEUM_TEST_SETTINGS") else { return };
|
||||
let t = std::fs::read_to_string(&p).unwrap();
|
||||
match serde_json::from_str::<super::Settings>(&t) {
|
||||
Ok(s) => println!("parsed: address {} cards {} remote_jobs {} setup_done {}", s.address, s.cards.len(), s.remote_jobs, s.setup_done),
|
||||
Err(e) => panic!("the crate refuses the file: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
//! GPU detection with the real names. macOS: the Metal worker's ready line (the device Metal reports) plus the core
|
||||
//! count from system_profiler. Windows: nvidia-smi for NVIDIA cards, the OpenCL worker's --list for the rest
|
||||
//! (AMD, Intel), and the WMI name list as a last resort when neither tool runs.
|
||||
//! (AMD, Intel), the Windows adapter list (Win32_VideoController: status, problem code, memory) for the cards no
|
||||
//! worker can drive and for the integrated-or-discrete call, and that list's names as a last resort when neither
|
||||
//! tool runs. The engine runs this at start and again every minute (src/hotplug.rs compares the two lists).
|
||||
|
||||
use crate::state::CardState;
|
||||
use std::io::Write;
|
||||
|
|
@ -15,9 +17,43 @@ pub struct Bins {
|
|||
pub metal: Option<std::path::PathBuf>,
|
||||
pub cuda: Option<std::path::PathBuf>,
|
||||
pub opencl: Option<std::path::PathBuf>,
|
||||
/// igneum-gpu-telemetry: AMD power, heat, fans and clocks (proto-opencl/gpu-telemetry.c), 5 October 2026
|
||||
pub telemetry: Option<std::path::PathBuf>,
|
||||
pub dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// One enumeration: the cards, the notes for the setup screen, and which tools answered. A tool that did not answer
|
||||
/// (nvidia-smi timed out, the OpenCL worker crashed) says nothing about its cards: the engine keeps them rather
|
||||
/// than calling them removed (src/hotplug.rs).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Detection {
|
||||
pub cards: Vec<CardState>,
|
||||
pub notes: Vec<String>,
|
||||
/// duplicate OpenCL platform entries left out (one line each, for the log)
|
||||
pub dropped: Vec<String>,
|
||||
pub nvidia_listed: bool,
|
||||
pub opencl_listed: bool,
|
||||
pub adapters_listed: bool,
|
||||
pub metal_listed: bool,
|
||||
}
|
||||
|
||||
impl Detection {
|
||||
/// Whether this enumeration can say that `c` is gone: the tool that lists its vendor answered.
|
||||
pub fn listed(&self, c: &CardState) -> bool {
|
||||
if !c.problem.is_empty() {
|
||||
return self.adapters_listed;
|
||||
}
|
||||
match c.vendor.as_str() {
|
||||
"apple" => self.metal_listed,
|
||||
"nvidia" => self.nvidia_listed,
|
||||
_ => self.opencl_listed || (c.device.is_empty() && self.adapters_listed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hint on a card the OS reports as faulty (Windows Code 43, 12, 31 and friends).
|
||||
pub const PROBLEM_HINT: &str = "reboot with the card attached; if it persists, reinstall the driver with the card attached";
|
||||
|
||||
/// Runs a command with a time limit; returns stdout (and stderr appended) or None.
|
||||
pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option<String> {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
|
@ -56,7 +92,8 @@ pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration)
|
|||
fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState {
|
||||
CardState {
|
||||
index,
|
||||
key: format!("{vendor}:{device}:{name}"),
|
||||
key: format!("{vendor}:{name}"),
|
||||
code: name.to_string(),
|
||||
name: name.to_string(),
|
||||
vendor: vendor.into(),
|
||||
worker: worker.into(),
|
||||
|
|
@ -64,18 +101,146 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi
|
|||
device: device.into(),
|
||||
enabled: true,
|
||||
state: "off".into(),
|
||||
amd_ordinal: -1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete).
|
||||
#[allow(dead_code)]
|
||||
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is
|
||||
/// discrete), and the gfx codes AMD's OpenCL runtime reports instead of a marketing name (the worker's --list prints
|
||||
/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026).
|
||||
pub fn looks_integrated(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"];
|
||||
integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ")
|
||||
if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") {
|
||||
return true;
|
||||
}
|
||||
// AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and
|
||||
// gfx909, Renoir/Cezanne/Lucienne gfx90c, Van Gogh gfx1033, Rembrandt gfx1035, Raphael/Granite Ridge gfx1036,
|
||||
// Mendocino gfx1037, Phoenix gfx1103, Strix gfx1150 to gfx1152. Discrete codes (gfx1030 and so on) are not here.
|
||||
let apu = ["gfx902", "gfx909", "gfx90c", "gfx1033", "gfx1035", "gfx1036", "gfx1037", "gfx1103", "gfx1150", "gfx1151", "gfx1152"];
|
||||
let code = n.trim();
|
||||
apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} ")))
|
||||
}
|
||||
|
||||
/// One row of Windows' adapter list (Win32_VideoController), the part this app reads.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Adapter {
|
||||
pub name: String,
|
||||
/// "OK", "Error", "Degraded", ... (the Status property)
|
||||
pub status: String,
|
||||
/// the PnP problem code (ConfigManagerErrorCode): 0 = fine, 43 = the driver stopped it, 12 = no resources, 31 = not loaded
|
||||
pub code: u32,
|
||||
/// AdapterRAM in MB; 0 = unknown (a faulty card reports 0, and the property caps at 4 GB on 32-bit values)
|
||||
pub ram_mb: u64,
|
||||
pub processor: String,
|
||||
pub pnp_id: String,
|
||||
/// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them
|
||||
pub bus: String,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
/// The PCI device id from the PnP id ("PCI\\VEN_1002&DEV_7550&..." gives 0x7550); 0 when there is none.
|
||||
pub fn device_id(&self) -> u16 {
|
||||
let up = self.pnp_id.to_ascii_uppercase();
|
||||
up.find("DEV_").and_then(|i| u16::from_str_radix(up.get(i + 4..i + 8)?, 16).ok()).unwrap_or(0)
|
||||
}
|
||||
/// "Code 43" for a problem code, "status Error" for a bad status without one, None when the device is fine.
|
||||
pub fn problem(&self) -> Option<String> {
|
||||
if self.code != 0 {
|
||||
return Some(format!("Code {}", self.code));
|
||||
}
|
||||
let st = self.status.trim();
|
||||
if !st.is_empty() && !st.eq_ignore_ascii_case("ok") {
|
||||
return Some(format!("status {st}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrated or discrete, from the name (APU and iGPU names, AMD gfx codes) and, when Windows' adapter row is
|
||||
/// known, its processor string or a dedicated memory under 1 GB (a shared-memory iGPU; 0 = unknown, says nothing).
|
||||
pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str {
|
||||
if looks_integrated(name) {
|
||||
return "integrated";
|
||||
}
|
||||
if let Some(a) = adapter {
|
||||
// the processor string names Intel iGPUs ("Intel(R) Iris(R) Xe Graphics Family"); AMD's reads "AMD Radeon
|
||||
// Graphics Processor (0x7550)" for discrete cards too, so only the Intel markers count here
|
||||
let proc_ = a.processor.to_ascii_lowercase();
|
||||
if looks_integrated(&a.name) || (["iris", "uhd graphics", "hd graphics"].iter().any(|k| proc_.contains(k)) && !proc_.contains("arc")) {
|
||||
return "integrated";
|
||||
}
|
||||
if a.ram_mb > 0 && a.ram_mb < 1024 {
|
||||
return "integrated";
|
||||
}
|
||||
}
|
||||
"discrete"
|
||||
}
|
||||
|
||||
pub fn vendor_of(name: &str) -> &'static str {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains("nvidia") || n.contains("geforce") {
|
||||
"nvidia"
|
||||
} else if n.contains("amd") || n.contains("radeon") || n.starts_with("gfx") {
|
||||
"amd"
|
||||
} else if n.contains("apple") {
|
||||
"apple"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `Get-CimInstance Win32_VideoController | Select-Object ... | ConvertTo-Json` (one object or an array).
|
||||
pub fn parse_adapters(json: &str) -> Vec<Adapter> {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(json.trim()) else { return Vec::new() };
|
||||
let rows: Vec<serde_json::Value> = match v {
|
||||
serde_json::Value::Array(a) => a,
|
||||
o @ serde_json::Value::Object(_) => vec![o],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let s = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
let n = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse::<u64>().ok()))).unwrap_or(0);
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
// DEVPKEY_Device_Address on PCI is (device << 16) | function
|
||||
let bus = match (r.get("BusNumber").and_then(|x| x.as_u64()), r.get("Address").and_then(|x| x.as_u64())) {
|
||||
(Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff),
|
||||
_ => String::new(),
|
||||
};
|
||||
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus }
|
||||
})
|
||||
.filter(|a| !a.name.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
|
||||
#[cfg(windows)]
|
||||
pub fn adapters() -> Option<Vec<Adapter>> {
|
||||
// one object per adapter, with the PCI bus number and address from the PnP properties (they name the card
|
||||
// the OpenCL worker's "pci" field names); @() keeps a single adapter an array
|
||||
let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress";
|
||||
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
|
||||
let start = out.find(|c| c == '[' || c == '{')?;
|
||||
Some(parse_adapters(&out[start..]))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub fn adapters() -> Option<Vec<Adapter>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Windows' row for a detected card, by name (nvidia-smi and Windows agree on NVIDIA names; AMD's OpenCL runtime
|
||||
/// reports gfx codes, which match nothing here and fall back to the name rules).
|
||||
pub fn adapter_for<'a>(name: &str, adapters: &'a [Adapter]) -> Option<&'a Adapter> {
|
||||
let n = name.trim().to_ascii_lowercase();
|
||||
adapters.iter().find(|a| a.name.trim().to_ascii_lowercase() == n)
|
||||
}
|
||||
|
||||
/// The row's words for an integrated GPU that is off by default (the switch turns it on; the choice is kept).
|
||||
pub const INTEGRATED_REASON: &str = "integrated GPU, off by default (2 to 3 MH/s for 30 W)";
|
||||
|
||||
/// The defaults the launchers use: discrete cards on (8 identities on a big card, 2 on a small one), integrated off
|
||||
/// (1 identity), Apple silicon on with 1.
|
||||
pub fn apply_defaults(c: &mut CardState) {
|
||||
|
|
@ -87,7 +252,7 @@ pub fn apply_defaults(c: &mut CardState) {
|
|||
"integrated" => {
|
||||
c.enabled = false;
|
||||
c.identities = 1;
|
||||
c.reason = "integrated: about 3 MH/s and it shares your system memory. Switch it on if you want it.".into();
|
||||
c.reason = INTEGRATED_REASON.into();
|
||||
}
|
||||
_ => {
|
||||
c.enabled = true;
|
||||
|
|
@ -99,6 +264,34 @@ pub fn apply_defaults(c: &mut CardState) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Marks whether the efficiency sweep (src/sweep.rs) can run on a card, with the reason when it cannot. Called
|
||||
/// once the power limits are known.
|
||||
pub fn mark_sweep_support(c: &mut CardState) {
|
||||
match crate::sweep::unsupported_reason(&c.vendor, c.power_default_w, &c.device) {
|
||||
None => {
|
||||
c.sweep_supported = true;
|
||||
if c.sweep_state.is_empty() || c.sweep_state == "unsupported" {
|
||||
c.sweep_state = "idle".into();
|
||||
c.sweep_note = String::new();
|
||||
}
|
||||
}
|
||||
Some(why) => {
|
||||
c.sweep_supported = false;
|
||||
c.sweep_state = "unsupported".into();
|
||||
c.sweep_note = why.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `nvidia-smi --query-gpu=index,power.draw`: index -> watts now (the one-shot form; the telemetry child streams it).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[allow(dead_code)]
|
||||
pub fn nvidia_power_draw() -> std::collections::HashMap<String, f64> {
|
||||
run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,power.draw", "--format=csv,noheader,nounits"]), None, Duration::from_secs(10))
|
||||
.map(|t| crate::sweep::parse_power_draw(&t))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// NVIDIA power limits per card index: (default, current, min, max) in watts.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64, f64)> {
|
||||
|
|
@ -120,19 +313,20 @@ pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64
|
|||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
||||
let mut cards = Vec::new();
|
||||
pub fn detect(bins: &Bins) -> Detection {
|
||||
let mut d = Detection::default();
|
||||
let Some(metal) = bins.metal.as_ref() else {
|
||||
notes.push("the Metal worker (igneum-bench) is missing from the app".into());
|
||||
return cards;
|
||||
d.notes.push("the Metal worker (igneum-bench) is missing from the app".into());
|
||||
return d;
|
||||
};
|
||||
// the worker's own ready line: "ready metal Apple_M5_Max dataset-log2 28 batch 4194304 prepare 1"
|
||||
let out = run_timeout(Command::new(metal).arg("--serve"), Some("quit\n"), Duration::from_secs(20)).unwrap_or_default();
|
||||
let ready = out.lines().find(|l| l.starts_with("ready "));
|
||||
let Some(ready) = ready else {
|
||||
notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
|
||||
return cards;
|
||||
d.notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
|
||||
return d;
|
||||
};
|
||||
d.metal_listed = true;
|
||||
let fields: Vec<&str> = ready.split_whitespace().collect();
|
||||
let name = fields.get(2).map(|s| s.replace('_', " ")).unwrap_or_else(|| "Apple GPU".into());
|
||||
let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1");
|
||||
|
|
@ -147,112 +341,366 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
|
||||
if let Ok(b) = mem.trim().parse::<u64>() {
|
||||
let gb = b / (1024 * 1024 * 1024);
|
||||
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
|
||||
}
|
||||
let mem = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)).and_then(|m| m.trim().parse::<u64>().ok());
|
||||
if let Some(b) = mem {
|
||||
let gb = b / (1024 * 1024 * 1024);
|
||||
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
|
||||
}
|
||||
if !prepare {
|
||||
notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
|
||||
d.notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
|
||||
}
|
||||
let mut c = card(0, &name, "apple", "Metal", &detail, "");
|
||||
c.kind = "apple".into();
|
||||
c.path = "prebuilt".into();
|
||||
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
|
||||
c.vram_mb = mem.trim().parse::<u64>().map(|b| b / (1024 * 1024)).unwrap_or(0);
|
||||
}
|
||||
c.vram_mb = mem.map(|b| b / (1024 * 1024)).unwrap_or(0);
|
||||
apply_defaults(&mut c);
|
||||
cards.push(c);
|
||||
cards
|
||||
mark_sweep_support(&mut c);
|
||||
d.cards.push(c);
|
||||
assign_keys(&mut d.cards);
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
||||
let mut cards: Vec<CardState> = Vec::new();
|
||||
// NVIDIA: nvidia-smi ships with the driver
|
||||
let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
match smi {
|
||||
/// AMD gfx codes the OpenCL runtime reports as the device name, with the card names Windows uses, the words for
|
||||
/// the row when no adapter matches, and the PCI device ids (approximate, from AMD's public ROCm and Linux driver
|
||||
/// tables; add a line when a card is seen). gfx1036 is the Ryzen desktop iGPU (PC 1: DEV_13C0, 5 October 2026).
|
||||
const GFX: &[(&str, &str, &[&str], &[u16])] = &[
|
||||
("gfx1201", "Radeon RX 9070 XT / 9070", &["Radeon RX 9070 XT", "Radeon RX 9070"], &[0x7550]),
|
||||
("gfx1200", "Radeon RX 9060 XT", &["Radeon RX 9060 XT", "Radeon RX 9060"], &[0x7590]),
|
||||
("gfx1100", "Radeon RX 7900 XTX / XT", &["Radeon RX 7900 XTX", "Radeon RX 7900 XT", "Radeon RX 7900 GRE"], &[0x744C]),
|
||||
("gfx1101", "Radeon RX 7800 XT / 7700 XT", &["Radeon RX 7800 XT", "Radeon RX 7700 XT"], &[0x747E]),
|
||||
("gfx1102", "Radeon RX 7600", &["Radeon RX 7600 XT", "Radeon RX 7600"], &[0x7480]),
|
||||
("gfx1030", "Radeon RX 6800 / 6900", &["Radeon RX 6900 XT", "Radeon RX 6950 XT", "Radeon RX 6800 XT", "Radeon RX 6800"], &[0x73BF]),
|
||||
("gfx1031", "Radeon RX 6700 XT", &["Radeon RX 6750 XT", "Radeon RX 6700 XT", "Radeon RX 6700"], &[0x73DF]),
|
||||
("gfx1032", "Radeon RX 6600", &["Radeon RX 6650 XT", "Radeon RX 6600 XT", "Radeon RX 6600"], &[0x73FF]),
|
||||
("gfx1036", "Ryzen integrated Radeon Graphics", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x164E, 0x13C0]),
|
||||
("gfx1035", "Radeon 680M (integrated)", &["Radeon 680M", "Radeon 660M"], &[0x1681]),
|
||||
("gfx1103", "Radeon 780M (integrated)", &["Radeon 780M", "Radeon 760M"], &[0x15BF, 0x15C8]),
|
||||
("gfx1150", "Radeon 890M (integrated)", &["Radeon 890M", "Radeon 880M"], &[0x150E]),
|
||||
("gfx90c", "Radeon Graphics (Renoir / Cezanne, integrated)", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x1636, 0x1638]),
|
||||
];
|
||||
|
||||
fn gfx_entry(code: &str) -> Option<&'static (&'static str, &'static str, &'static [&'static str], &'static [u16])> {
|
||||
let c = code.trim().to_ascii_lowercase();
|
||||
let c = c.split(|ch: char| ch == ':' || ch == ' ').next().unwrap_or("");
|
||||
GFX.iter().find(|e| e.0 == c)
|
||||
}
|
||||
|
||||
/// The name on the row for a device the tool knows by `code`: Windows' adapter name when one matches (by PCI bus,
|
||||
/// else by the gfx code's device ids, else by the card names in the table), else the table's words, else the code.
|
||||
/// `used` holds the adapters already given to another card, so two gfx1036 entries never share one.
|
||||
pub fn resolve_name(code: &str, vendor: &str, bus: &str, adapters: &[Adapter], used: &mut Vec<usize>) -> (String, Option<usize>) {
|
||||
let free = |i: &usize| !used.contains(i);
|
||||
let fine = |a: &Adapter| a.problem().is_none();
|
||||
let vendor_ok = |a: &Adapter| vendor == "other" || vendor_of(&a.name) == vendor;
|
||||
if !bus.is_empty() {
|
||||
if let Some(i) = (0..adapters.len()).filter(free).find(|&i| adapters[i].bus == bus && vendor_ok(&adapters[i])) {
|
||||
used.push(i);
|
||||
return (adapters[i].name.clone(), Some(i));
|
||||
}
|
||||
}
|
||||
// the name is already a marketing name (nvidia-smi, Windows): the adapter with the same name
|
||||
let same: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| adapters[i].name.trim().eq_ignore_ascii_case(code.trim())).collect();
|
||||
if same.len() == 1 {
|
||||
used.push(same[0]);
|
||||
return (adapters[same[0]].name.clone(), Some(same[0]));
|
||||
}
|
||||
let Some(entry) = gfx_entry(code) else { return (code.to_string(), None) };
|
||||
let by_id: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && entry.3.contains(&adapters[i].device_id())).collect();
|
||||
if by_id.len() == 1 {
|
||||
used.push(by_id[0]);
|
||||
return (adapters[by_id[0]].name.clone(), Some(by_id[0]));
|
||||
}
|
||||
let by_name: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && vendor_ok(&adapters[i]) && { let n = adapters[i].name.to_ascii_lowercase(); entry.2.iter().any(|m| n.contains(&m.to_ascii_lowercase())) }).collect();
|
||||
if by_name.len() == 1 {
|
||||
used.push(by_name[0]);
|
||||
return (adapters[by_name[0]].name.clone(), Some(by_name[0]));
|
||||
}
|
||||
(entry.1.to_string(), None)
|
||||
}
|
||||
|
||||
/// One device line pair of the OpenCL worker's --list.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ClDevice {
|
||||
pub index: String,
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub platform_version: String,
|
||||
pub is_gpu: bool,
|
||||
pub vendor: String,
|
||||
pub driver: String,
|
||||
pub units: String,
|
||||
/// "01:00.0" when the worker printed `pci` (workers from 5 October 2026 on), else empty
|
||||
pub bus: String,
|
||||
pub mem_mb: u64,
|
||||
}
|
||||
|
||||
impl ClDevice {
|
||||
pub fn vendor_word(&self) -> &'static str {
|
||||
if self.vendor.contains("NVIDIA") || self.name.contains("NVIDIA") {
|
||||
"nvidia"
|
||||
} else if self.vendor.contains("Advanced Micro") || self.vendor.contains("AMD") || self.name.contains("Radeon") || self.name.contains("AMD") || self.name.to_ascii_lowercase().starts_with("gfx") {
|
||||
"amd"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
fn platform_key(&self) -> String {
|
||||
format!("{} ({}) driver {}", self.platform, self.platform_version, self.driver)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `igneum-worker-opencl --list`: `[idx] name | platform (version)` then `GPU, vendor V, driver D, OpenCL C
|
||||
/// x.y, N compute units, M MHz[, pci bb:dd.f]` then `global N MiB, ...`. The bool says the worker printed its header.
|
||||
pub fn parse_opencl_list(text: &str) -> (Vec<ClDevice>, bool) {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let listed = lines.iter().any(|l| l.starts_with("OpenCL devices"));
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
|
||||
if !t.starts_with('[') {
|
||||
continue;
|
||||
}
|
||||
let Some(close) = t.find(']') else { continue };
|
||||
let rest = &t[close + 1..];
|
||||
let mut halves = rest.splitn(2, " |");
|
||||
let name = halves.next().unwrap_or("").trim().to_string();
|
||||
let plat = halves.next().unwrap_or("").trim();
|
||||
// the first " (" opens the version: AMD's version string carries brackets of its own, "OpenCL 2.1 AMD-APP (3617.0)"
|
||||
let (platform, platform_version) = match plat.find(" (") {
|
||||
Some(p) if plat.ends_with(')') => (plat[..p].to_string(), plat[p + 2..plat.len() - 1].to_string()),
|
||||
_ => (plat.to_string(), String::new()),
|
||||
};
|
||||
let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or("");
|
||||
let parts: Vec<&str> = info.split(", ").collect();
|
||||
let mem_mb = lines.get(i + 2).map(|l| l.trim()).and_then(|l| l.strip_prefix("global ")).and_then(|l| l.split_whitespace().next()).and_then(|n| n.parse::<u64>().ok()).unwrap_or(0);
|
||||
out.push(ClDevice {
|
||||
index: t[1..close].to_string(),
|
||||
name,
|
||||
platform,
|
||||
platform_version,
|
||||
is_gpu: info.starts_with("GPU"),
|
||||
vendor: parts.iter().find_map(|p| p.strip_prefix("vendor ")).unwrap_or("").trim().to_string(),
|
||||
driver: parts.iter().find_map(|p| p.strip_prefix("driver ")).unwrap_or("").trim().to_string(),
|
||||
units: parts.iter().find(|p| p.contains("compute units")).unwrap_or(&"").to_string(),
|
||||
bus: parts.iter().find_map(|p| p.strip_prefix("pci ")).unwrap_or("").trim().to_string(),
|
||||
mem_mb,
|
||||
});
|
||||
}
|
||||
(out, listed)
|
||||
}
|
||||
|
||||
fn version_tuple(s: &str) -> Vec<u64> {
|
||||
s.split(|c: char| !c.is_ascii_digit()).filter(|p| !p.is_empty()).map(|p| p.parse::<u64>().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
/// One entry per physical card across OpenCL platforms. Two AMD ICDs after a driver upgrade each list every AMD
|
||||
/// card (PC 1, 5 October 2026: gfx1036 and gfx1201 twice, two workers on one 9070 XT). Per vendor, the fuller
|
||||
/// platform wins (most GPUs, then the newest driver, then the first listed); a device on another platform is kept
|
||||
/// only when the winner has no device at the same PCI address (or, without addresses, the same code and ordinal).
|
||||
/// Returns the kept devices and one note per dropped duplicate.
|
||||
pub fn dedupe_platforms(devs: Vec<ClDevice>) -> (Vec<ClDevice>, Vec<String>) {
|
||||
let gpus: Vec<ClDevice> = devs.into_iter().filter(|d| d.is_gpu).collect();
|
||||
let mut kept: Vec<ClDevice> = Vec::new();
|
||||
let mut dropped = Vec::new();
|
||||
let mut vendors: Vec<&'static str> = Vec::new();
|
||||
for d in &gpus {
|
||||
let v = d.vendor_word();
|
||||
if !vendors.contains(&v) {
|
||||
vendors.push(v);
|
||||
}
|
||||
}
|
||||
for v in vendors {
|
||||
let mine: Vec<&ClDevice> = gpus.iter().filter(|d| d.vendor_word() == v).collect();
|
||||
let mut plats: Vec<String> = Vec::new();
|
||||
for d in &mine {
|
||||
let k = d.platform_key();
|
||||
if !plats.contains(&k) {
|
||||
plats.push(k);
|
||||
}
|
||||
}
|
||||
let score = |k: &String| {
|
||||
let count = mine.iter().filter(|d| &d.platform_key() == k).count();
|
||||
let driver = mine.iter().find(|d| &d.platform_key() == k).map(|d| version_tuple(&d.driver)).unwrap_or_default();
|
||||
(count, driver)
|
||||
};
|
||||
let winner = plats.iter().max_by(|a, b| score(a).cmp(&score(b))).cloned().unwrap_or_default();
|
||||
let identity = |d: &ClDevice, ordinal: usize| if d.bus.is_empty() { format!("{}#{ordinal}", d.name.to_ascii_lowercase()) } else { d.bus.clone() };
|
||||
let mut have: Vec<String> = Vec::new();
|
||||
let mut seen_codes: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut ordinal = |d: &ClDevice| {
|
||||
let n = seen_codes.entry(format!("{}|{}", d.platform_key(), d.name.to_ascii_lowercase())).or_insert(0);
|
||||
*n += 1;
|
||||
*n
|
||||
};
|
||||
for d in mine.iter().filter(|d| d.platform_key() == winner) {
|
||||
let o = ordinal(d);
|
||||
have.push(identity(d, o));
|
||||
kept.push((*d).clone());
|
||||
}
|
||||
for d in mine.iter().filter(|d| d.platform_key() != winner) {
|
||||
let o = ordinal(d);
|
||||
let id = identity(d, o);
|
||||
if have.contains(&id) {
|
||||
dropped.push(format!("[{}] {} on {} ({}) is the same card as the one on {}: no worker", d.index, d.name, d.platform, d.platform_version, winner));
|
||||
} else {
|
||||
have.push(id);
|
||||
kept.push((*d).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
kept.sort_by_key(|d| d.index.parse::<u64>().unwrap_or(u64::MAX));
|
||||
(kept, dropped)
|
||||
}
|
||||
|
||||
/// Keys without an index (it moves when a card arrives): vendor:code, "#2" and up for identical cards in list order.
|
||||
pub fn assign_keys(cards: &mut [CardState]) {
|
||||
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for c in cards.iter_mut() {
|
||||
if c.code.is_empty() {
|
||||
c.code = c.name.clone();
|
||||
}
|
||||
let base = format!("{}:{}", c.vendor, c.code);
|
||||
let n = seen.entry(base.clone()).or_insert(0);
|
||||
*n += 1;
|
||||
c.key = if *n == 1 { base } else { format!("{base}#{n}") };
|
||||
}
|
||||
}
|
||||
|
||||
/// What one Windows enumeration gathered; `assemble` turns it into the list (pure, so the PC 1 cases are tests).
|
||||
#[derive(Default)]
|
||||
pub struct Inputs {
|
||||
/// `nvidia-smi --query-gpu=index,name,memory.total,pci.bus_id --format=csv,noheader`; None = nvidia-smi did not run
|
||||
pub nvidia: Option<String>,
|
||||
pub nvidia_limits: std::collections::HashMap<String, (f64, f64, f64, f64)>,
|
||||
pub cuda_worker: bool,
|
||||
/// the OpenCL worker's --list; None = no worker installed or it did not answer
|
||||
pub opencl: Option<String>,
|
||||
pub opencl_installed: bool,
|
||||
/// Windows' adapter list; None = PowerShell did not answer
|
||||
pub adapters: Option<Vec<Adapter>>,
|
||||
}
|
||||
|
||||
pub fn assemble(inp: Inputs) -> Detection {
|
||||
let mut d = Detection::default();
|
||||
d.adapters_listed = inp.adapters.is_some();
|
||||
let adapters = inp.adapters.unwrap_or_default();
|
||||
let mut used: Vec<usize> = Vec::new();
|
||||
match inp.nvidia {
|
||||
Some(out) => {
|
||||
d.nvidia_listed = true;
|
||||
for line in out.lines() {
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
|
||||
let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::<f64>().ok()).map(|v| v as u64).unwrap_or(0);
|
||||
let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() };
|
||||
let worker_ok = bins.cuda.is_some();
|
||||
let mut c = card(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
|
||||
c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() };
|
||||
// nvidia-smi prints 00000000:01:00.0; the worker and Windows say 01:00.0
|
||||
let bus = parts.get(3).map(|b| b.trim().to_ascii_lowercase()).map(|b| b.rsplit_once(':').map(|(d, r)| format!("{}:{r}", d.rsplit(':').next().unwrap_or(d))).unwrap_or(b)).unwrap_or_default();
|
||||
let (name, adapter) = resolve_name(parts[1], "nvidia", &bus, &adapters, &mut used);
|
||||
let mut c = card(d.cards.len(), &name, "nvidia", "CUDA", &detail, parts[0]);
|
||||
c.code = parts[1].to_string();
|
||||
c.bus = bus;
|
||||
c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into();
|
||||
c.vram_mb = mem_mb;
|
||||
c.path = if worker_ok { "prebuilt".into() } else { "build".into() };
|
||||
if !worker_ok {
|
||||
c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() };
|
||||
if !inp.cuda_worker {
|
||||
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
|
||||
}
|
||||
apply_defaults(&mut c);
|
||||
cards.push(c);
|
||||
d.cards.push(c);
|
||||
}
|
||||
}
|
||||
if cards.is_empty() {
|
||||
notes.push("nvidia-smi ran but listed no card".into());
|
||||
if d.cards.is_empty() {
|
||||
d.notes.push("nvidia-smi ran but listed no card".into());
|
||||
}
|
||||
let limits = nvidia_power_limits();
|
||||
for c in cards.iter_mut() {
|
||||
if let Some((d, cur, lo, hi)) = limits.get(&c.device) {
|
||||
c.power_default_w = *d;
|
||||
for c in d.cards.iter_mut() {
|
||||
if let Some((dflt, cur, lo, hi)) = inp.nvidia_limits.get(&c.device) {
|
||||
c.power_default_w = *dflt;
|
||||
c.power_limit_w = *cur;
|
||||
c.power_before_w = *cur;
|
||||
c.power_min_w = *lo;
|
||||
c.power_max_w = *hi;
|
||||
}
|
||||
mark_sweep_support(c);
|
||||
}
|
||||
}
|
||||
None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
|
||||
None => d.notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
|
||||
}
|
||||
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
|
||||
if let Some(cl) = bins.opencl.as_ref() {
|
||||
if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) {
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
|
||||
if !t.starts_with('[') {
|
||||
continue;
|
||||
}
|
||||
let Some(close) = t.find(']') else { continue };
|
||||
let idx = &t[1..close];
|
||||
let rest = &t[close + 1..];
|
||||
let name = rest.split(" |").next().unwrap_or("").trim();
|
||||
let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or("");
|
||||
let is_gpu = info.starts_with("GPU");
|
||||
let vendor_s = info.split("vendor ").nth(1).unwrap_or("").split(", driver").next().unwrap_or("").trim();
|
||||
if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") {
|
||||
continue;
|
||||
}
|
||||
let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") { "amd" } else { "other" };
|
||||
let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string();
|
||||
let mut c = card(cards.len(), name, vendor, "OpenCL", &units, idx);
|
||||
c.device = idx.to_string();
|
||||
c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() };
|
||||
c.path = "prebuilt".into();
|
||||
apply_defaults(&mut c);
|
||||
cards.push(c);
|
||||
}
|
||||
if let Some(out) = inp.opencl {
|
||||
let (devs, listed) = parse_opencl_list(&out);
|
||||
d.opencl_listed = listed;
|
||||
let (kept, dropped) = dedupe_platforms(devs.into_iter().filter(|dv| dv.vendor_word() != "nvidia").collect());
|
||||
d.dropped = dropped;
|
||||
for dv in kept {
|
||||
let vendor = dv.vendor_word();
|
||||
let (name, adapter) = resolve_name(&dv.name, vendor, &dv.bus, &adapters, &mut used);
|
||||
let mut c = card(d.cards.len(), &name, vendor, "OpenCL", &dv.units, &dv.index);
|
||||
c.code = dv.name.clone();
|
||||
c.bus = dv.bus.clone();
|
||||
c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver);
|
||||
c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into();
|
||||
c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb };
|
||||
c.path = "prebuilt".into();
|
||||
apply_defaults(&mut c);
|
||||
mark_sweep_support(&mut c);
|
||||
d.cards.push(c);
|
||||
}
|
||||
} else if cards.is_empty() {
|
||||
notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
|
||||
} else if !inp.opencl_installed && d.cards.is_empty() {
|
||||
d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
|
||||
}
|
||||
if cards.is_empty() {
|
||||
if d.cards.is_empty() {
|
||||
// last resort: the names Windows knows, so the screen can at least say what is in the PC
|
||||
if let Some(out) = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) {
|
||||
for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
|
||||
let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" };
|
||||
let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0");
|
||||
c.kind = if looks_integrated(n) { "integrated".into() } else { "unknown".into() };
|
||||
c.enabled = false;
|
||||
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
|
||||
cards.push(c);
|
||||
}
|
||||
for (i, a) in adapters.iter().enumerate().filter(|(_, a)| a.problem().is_none()) {
|
||||
let vendor = vendor_of(&a.name);
|
||||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.bus.clone();
|
||||
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
|
||||
c.enabled = false;
|
||||
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
|
||||
used.push(i);
|
||||
d.cards.push(c);
|
||||
}
|
||||
}
|
||||
cards
|
||||
// the cards Windows lists with a problem (Code 43 after an eGPU hot-plug on PC 1, 5 October 2026): shown, never driven
|
||||
for (i, a) in adapters.iter().enumerate() {
|
||||
let Some(problem) = a.problem() else { continue };
|
||||
if used.contains(&i) || d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) {
|
||||
continue;
|
||||
}
|
||||
let vendor = vendor_of(&a.name);
|
||||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.bus.clone();
|
||||
c.kind = classify_kind(&a.name, Some(a)).into();
|
||||
mark_unusable(&mut c, &problem);
|
||||
d.cards.push(c);
|
||||
}
|
||||
assign_keys(&mut d.cards);
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn detect(bins: &Bins) -> Detection {
|
||||
// Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and
|
||||
// processor for the integrated call, the PCI address and the names for the rows
|
||||
let adapters = adapters();
|
||||
// NVIDIA: nvidia-smi ships with the driver
|
||||
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() };
|
||||
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
|
||||
let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)));
|
||||
assemble(Inputs { nvidia, nvidia_limits, cuda_worker: bins.cuda.is_some(), opencl, opencl_installed: bins.opencl.is_some(), adapters })
|
||||
}
|
||||
|
||||
/// A listed card no worker can drive: off, no switch, the problem on the row and the hint under it.
|
||||
pub fn mark_unusable(c: &mut CardState, problem: &str) {
|
||||
c.problem = problem.to_string();
|
||||
c.enabled = false;
|
||||
c.identities = 1;
|
||||
c.state = "unusable".into();
|
||||
c.message = format!("not usable ({problem})");
|
||||
c.reason = PROBLEM_HINT.into();
|
||||
c.sweep_supported = false;
|
||||
c.sweep_state = "unsupported".into();
|
||||
c.sweep_note = c.message.clone();
|
||||
}
|
||||
|
||||
/// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle).
|
||||
|
|
@ -280,7 +728,7 @@ pub fn find_bins() -> Result<Bins, String> {
|
|||
// the prebuilt CUDA worker needs NVIDIA's nvrtc64_*_0.dll next to it (as igneum-common.ps1 checks)
|
||||
let nvrtc = std::fs::read_dir(dir).ok().map(|rd| rd.flatten().any(|e| { let n = e.file_name().to_string_lossy().to_ascii_lowercase(); n.starts_with("nvrtc64_") && n.ends_with("_0.dll") })).unwrap_or(false);
|
||||
let cuda = opt("igneum-worker-cuda").filter(|_| nvrtc || cfg!(not(windows)));
|
||||
return Ok(Bins { node, miner, metal: opt("igneum-bench"), cuda, opencl: opt("igneum-worker-opencl"), dir: dir.clone() });
|
||||
return Ok(Bins { node, miner, metal: opt("igneum-bench"), cuda, opencl: opt("igneum-worker-opencl"), telemetry: opt("igneum-gpu-telemetry"), dir: dir.clone() });
|
||||
}
|
||||
}
|
||||
Err(format!("igneumd and igneum-miner were not found next to the app (looked in {})", candidates.iter().map(|c| c.display().to_string()).collect::<Vec<_>>().join(", ")))
|
||||
|
|
@ -291,3 +739,266 @@ pub fn node_version(node: &Path) -> String {
|
|||
.and_then(|o| o.lines().next().map(|l| l.trim().to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// PC 1's adapter list on the evening of 5 October 2026, after the RX 9070 XT went in through the Sonnet eGPU box
|
||||
// while the app ran: "AMD Radeon RX 9070 XT | status Error | ram 0 GB", "AMD Radeon(TM) Graphics | status OK |
|
||||
// ram 2 GB" (the Ryzen iGPU, gfx1036 to OpenCL), "NVIDIA GeForce RTX 5090 | status OK".
|
||||
fn pc1() -> Vec<Adapter> {
|
||||
parse_adapters(r#"[{"Name":"AMD Radeon RX 9070 XT","Status":"Error","ConfigManagerErrorCode":43,"AdapterRAM":0,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008"},
|
||||
{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x164E)","PNPDeviceID":"PCI\\VEN_1002&DEV_164E&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041"},
|
||||
{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019"}]"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_parse_with_status_code_and_memory() {
|
||||
let a = pc1();
|
||||
assert_eq!(a.len(), 3);
|
||||
assert_eq!(a[0].name, "AMD Radeon RX 9070 XT");
|
||||
assert_eq!(a[0].status, "Error");
|
||||
assert_eq!(a[0].code, 43);
|
||||
assert_eq!(a[0].ram_mb, 0);
|
||||
assert_eq!(a[0].problem().as_deref(), Some("Code 43"));
|
||||
assert_eq!(a[1].ram_mb, 2048);
|
||||
assert_eq!(a[1].problem(), None);
|
||||
assert_eq!(a[2].problem(), None);
|
||||
// a single adapter: ConvertTo-Json gives one object, not an array
|
||||
let one = parse_adapters(r#"{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE"}"#);
|
||||
assert_eq!(one.len(), 1);
|
||||
assert!(parse_adapters("not json").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn problem_without_a_code_is_the_status_word() {
|
||||
let a = Adapter { name: "x".into(), status: "Degraded".into(), ..Default::default() };
|
||||
assert_eq!(a.problem().as_deref(), Some("status Degraded"));
|
||||
let fine = Adapter { name: "x".into(), status: "OK".into(), ..Default::default() };
|
||||
assert_eq!(fine.problem(), None);
|
||||
let code12 = Adapter { name: "x".into(), status: "Error".into(), code: 12, ..Default::default() };
|
||||
assert_eq!(code12.problem().as_deref(), Some("Code 12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_from_pc1_lines_and_the_mac() {
|
||||
let a = pc1();
|
||||
// the discrete cards, with and without their adapter row
|
||||
assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete");
|
||||
// the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code)
|
||||
assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036", None), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036:xnack-", None), "integrated");
|
||||
// a discrete gfx code stays discrete; an Arc card is discrete despite "Intel"
|
||||
assert_eq!(classify_kind("gfx1201", None), "discrete");
|
||||
assert_eq!(classify_kind("gfx1030", None), "discrete");
|
||||
assert_eq!(classify_kind("Intel(R) Arc(TM) A770 Graphics", None), "discrete");
|
||||
// an Intel iGPU by its processor string; an AMD discrete card's processor string ("AMD Radeon Graphics Processor") does not count
|
||||
let intel = Adapter { name: "Intel(R) Iris(R) Xe Graphics".into(), processor: "Intel(R) Iris(R) Xe Graphics Family".into(), ram_mb: 1024, ..Default::default() };
|
||||
assert_eq!(classify_kind("Intel(R) Iris(R) Xe Graphics", Some(&intel)), "integrated");
|
||||
assert_eq!(a[0].processor, "AMD Radeon Graphics Processor (0x7550)");
|
||||
// shared memory under 1 GB on the adapter row makes an unknown name integrated; 0 says nothing
|
||||
let small = Adapter { name: "Some iGPU".into(), ram_mb: 512, ..Default::default() };
|
||||
assert_eq!(classify_kind("Some iGPU", Some(&small)), "integrated");
|
||||
let unknown = Adapter { name: "Some card".into(), ram_mb: 0, ..Default::default() };
|
||||
assert_eq!(classify_kind("Some card", Some(&unknown)), "discrete");
|
||||
// the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated
|
||||
assert!(!looks_integrated("Apple M5 Max"));
|
||||
assert_eq!(vendor_of("Apple M5 Max"), "apple");
|
||||
assert_eq!(vendor_of("gfx1036"), "amd");
|
||||
assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd");
|
||||
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
|
||||
}
|
||||
|
||||
// PC 1 after Adrenalin 26.9.2 and a reboot (5 October 2026, evening): all three adapters OK, the Windows names,
|
||||
// DEV ids and PCI addresses as the coordinator read them (the 5090's bus 01:00.0; the two AMD cards' addresses are
|
||||
// not in that reading, so this fixture leaves them empty, as an old worker's --list would)
|
||||
fn pc1_rebooted() -> Vec<Adapter> {
|
||||
parse_adapters(r#"[{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x13C0)","PNPDeviceID":"PCI\\VEN_1002&DEV_13C0&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041","BusNumber":null,"Address":null},
|
||||
{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019","BusNumber":1,"Address":0},
|
||||
{"Name":"AMD Radeon RX 9070 XT","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008","BusNumber":null,"Address":null}]"#)
|
||||
}
|
||||
// the 0.3.9 app's five rows came from this shape of --list: two AMD platforms, each listing both AMD GPUs (the old
|
||||
// 32.0.21042 ICD and the new 32.0.32015 one); the driver strings are the shape AMD's runtime prints, the numbers
|
||||
// are the Windows driver builds (approximate: the OpenCL CL_DRIVER_VERSION was not captured)
|
||||
const PC1_LIST: &str = "OpenCL devices (4):\n\
|
||||
[0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\
|
||||
GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\
|
||||
global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
|
||||
[1] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\
|
||||
GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\
|
||||
global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
|
||||
[2] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\
|
||||
GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\
|
||||
global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
|
||||
[3] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\
|
||||
GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\
|
||||
global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n";
|
||||
const PC1_SMI: &str = "0, NVIDIA GeForce RTX 5090, 32607 MiB, 00000000:01:00.0\n";
|
||||
|
||||
fn pc1_inputs(list: &str) -> Inputs {
|
||||
Inputs { nvidia: Some(PC1_SMI.into()), nvidia_limits: Default::default(), cuda_worker: true, opencl: Some(list.into()), opencl_installed: true, adapters: Some(pc1_rebooted()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencl_list_parses_both_platforms_and_the_pci_field() {
|
||||
let (devs, listed) = parse_opencl_list(PC1_LIST);
|
||||
assert!(listed);
|
||||
assert_eq!(devs.len(), 4);
|
||||
assert_eq!(devs[1].index, "1");
|
||||
assert_eq!(devs[1].name, "gfx1201");
|
||||
assert_eq!(devs[1].platform, "AMD Accelerated Parallel Processing");
|
||||
assert_eq!(devs[1].platform_version, "OpenCL 2.1 AMD-APP (3617.0)");
|
||||
assert_eq!(devs[1].driver, "3617.0 (PAL,HSAIL)");
|
||||
assert_eq!(devs[1].units, "32 compute units");
|
||||
assert_eq!(devs[1].mem_mb, 16368);
|
||||
assert_eq!(devs[1].bus, "");
|
||||
assert!(devs[1].is_gpu);
|
||||
assert_eq!(devs[1].vendor_word(), "amd");
|
||||
let with_pci = PC1_LIST.replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n");
|
||||
let (devs, _) = parse_opencl_list(&with_pci);
|
||||
assert_eq!(devs[1].bus, "05:00.0");
|
||||
assert_eq!(devs[3].bus, "05:00.0");
|
||||
assert!(!parse_opencl_list("").1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_amd_platforms_give_one_entry_per_card() {
|
||||
// without PCI addresses: by code and ordinal, the newer driver wins
|
||||
let (devs, _) = parse_opencl_list(PC1_LIST);
|
||||
let (kept, dropped) = dedupe_platforms(devs);
|
||||
assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::<Vec<_>>(), vec!["2", "3"]);
|
||||
assert_eq!(dropped.len(), 2);
|
||||
assert!(dropped[0].starts_with("[0] gfx1036 on AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0)) is the same card as"), "{}", dropped[0]);
|
||||
// with PCI addresses: by address; a card the winner does not list (the old ICD still serving a third card) is kept
|
||||
let text = PC1_LIST
|
||||
.replace("2 compute units, 2200 MHz\n", "2 compute units, 2200 MHz, pci 0c:00.0\n")
|
||||
.replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n")
|
||||
+ " [4] gfx1100 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 96 compute units, 2500 MHz, pci 09:00.0\n global 24560 MiB\n";
|
||||
let (devs, _) = parse_opencl_list(&text);
|
||||
let (kept, dropped) = dedupe_platforms(devs);
|
||||
// the old platform now lists three and wins on count: its three stay, the new platform's two are duplicates
|
||||
assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::<Vec<_>>(), vec!["0", "1", "4"]);
|
||||
assert_eq!(dropped.len(), 2);
|
||||
// two real twins on one platform keep both entries (same code, different ordinal or address)
|
||||
let twins = "OpenCL devices (2):\n [0] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n [1] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n";
|
||||
let (kept, dropped) = dedupe_platforms(parse_opencl_list(twins).0);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert!(dropped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_come_from_windows_by_bus_then_device_id_then_the_table() {
|
||||
let a = pc1_rebooted();
|
||||
let mut used = Vec::new();
|
||||
assert_eq!(resolve_name("NVIDIA GeForce RTX 5090", "nvidia", "01:00.0", &a, &mut used).0, "NVIDIA GeForce RTX 5090");
|
||||
assert_eq!(resolve_name("gfx1201", "amd", "", &a, &mut used).0, "AMD Radeon RX 9070 XT");
|
||||
assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "AMD Radeon(TM) Graphics");
|
||||
assert_eq!(used.len(), 3);
|
||||
// every adapter is taken: a second gfx1036 gets the table's words, a code the table lacks stays a code
|
||||
assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "Ryzen integrated Radeon Graphics");
|
||||
assert_eq!(resolve_name("gfx9999", "amd", "", &a, &mut used).0, "gfx9999");
|
||||
assert_eq!(resolve_name("gfx1100", "amd", "", &[], &mut Vec::new()).0, "Radeon RX 7900 XTX / XT");
|
||||
// by PCI address when both sides have one, before any table
|
||||
let mut b = pc1_rebooted();
|
||||
b[2].bus = "05:00.0".into();
|
||||
let mut used = Vec::new();
|
||||
assert_eq!(resolve_name("gfx1201", "amd", "05:00.0", &b, &mut used), ("AMD Radeon RX 9070 XT".to_string(), Some(2)));
|
||||
// the device id alone names a card whose adapter name the table does not know
|
||||
let mut c = pc1_rebooted();
|
||||
c[2].name = "AMD Radeon RX 9070 XT OC Edition".into();
|
||||
assert_eq!(resolve_name("gfx1201", "amd", "", &c, &mut Vec::new()).0, "AMD Radeon RX 9070 XT OC Edition");
|
||||
assert_eq!(a[2].device_id(), 0x7550);
|
||||
assert_eq!(a[0].device_id(), 0x13C0);
|
||||
assert_eq!(a[1].bus, "01:00.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pc1_five_rows_become_three_cards_named_properly() {
|
||||
let d = assemble(pc1_inputs(PC1_LIST));
|
||||
assert!(d.nvidia_listed && d.opencl_listed && d.adapters_listed);
|
||||
let rows: Vec<(String, String, String, String, bool, String)> = d.cards.iter().map(|c| (c.name.clone(), c.key.clone(), c.kind.clone(), c.device.clone(), c.enabled, c.code.clone())).collect();
|
||||
assert_eq!(rows, vec![
|
||||
("NVIDIA GeForce RTX 5090".into(), "nvidia:NVIDIA GeForce RTX 5090".into(), "discrete".into(), "0".into(), true, "NVIDIA GeForce RTX 5090".into()),
|
||||
("AMD Radeon(TM) Graphics".into(), "amd:gfx1036".into(), "integrated".into(), "2".into(), false, "gfx1036".into()),
|
||||
("AMD Radeon RX 9070 XT".into(), "amd:gfx1201".into(), "discrete".into(), "3".into(), true, "gfx1201".into()),
|
||||
]);
|
||||
assert_eq!(d.cards[0].bus, "01:00.0");
|
||||
assert_eq!(d.cards[1].reason, INTEGRATED_REASON);
|
||||
assert_eq!(d.cards[1].identities, 1);
|
||||
assert_eq!(d.cards[2].identities, 8, "16 GB: 8 identities");
|
||||
assert_eq!(d.cards[2].vram_mb, 16368);
|
||||
assert!(d.cards[2].platform.contains("3649.0"));
|
||||
assert_eq!(d.dropped.len(), 2);
|
||||
assert!(d.notes.is_empty(), "{:?}", d.notes);
|
||||
assert_eq!(crate::hotplug::cards_line(&d.cards), "cards: NVIDIA GeForce RTX 5090 [discrete, off] | AMD Radeon(TM) Graphics [integrated, off] | AMD Radeon RX 9070 XT [discrete, off]");
|
||||
// the same machine before the eGPU: one platform, the iGPU alone; the keys do not depend on the index
|
||||
let before = "OpenCL devices (1):\n [0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n global 16384 MiB\n";
|
||||
let d0 = assemble(pc1_inputs(before));
|
||||
assert_eq!(d0.cards[1].key, "amd:gfx1036");
|
||||
assert_eq!(d0.cards[1].device, "0");
|
||||
// and the diff between the two lists: the iGPU moved (device 0 to 2), the 9070 XT is new, nothing is removed
|
||||
let diff = crate::hotplug::diff(&d0.cards, &d.cards, &|c| d.listed(c));
|
||||
assert_eq!(diff.unchanged, vec![0]);
|
||||
assert_eq!(diff.moved.len(), 1);
|
||||
assert_eq!(diff.moved[0].0, 1);
|
||||
assert_eq!(diff.added.len(), 1);
|
||||
assert_eq!(diff.added[0].name, "AMD Radeon RX 9070 XT");
|
||||
assert!(diff.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_number_identical_cards() {
|
||||
let mut cards = vec![card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "0"), card(1, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "1"), card(2, "gfx1201", "amd", "OpenCL", "", "2")];
|
||||
cards[2].name = "AMD Radeon RX 9070 XT".into();
|
||||
assign_keys(&mut cards);
|
||||
assert_eq!(cards.iter().map(|c| c.key.as_str()).collect::<Vec<_>>(), vec!["nvidia:NVIDIA GeForce RTX 5090", "nvidia:NVIDIA GeForce RTX 5090#2", "amd:gfx1201"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_card_row() {
|
||||
let mut c = card(2, "AMD Radeon RX 9070 XT", "amd", "OpenCL", "", "");
|
||||
mark_unusable(&mut c, "Code 43");
|
||||
assert!(!c.enabled);
|
||||
assert_eq!(c.state, "unusable");
|
||||
assert_eq!(c.message, "not usable (Code 43)");
|
||||
assert_eq!(c.reason, PROBLEM_HINT);
|
||||
assert!(!c.present());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_default_is_off_with_the_row_words() {
|
||||
let mut c = card(1, "gfx1036", "amd", "OpenCL", "2 compute units", "1");
|
||||
c.kind = classify_kind("gfx1036", None).into();
|
||||
apply_defaults(&mut c);
|
||||
assert!(!c.enabled);
|
||||
assert_eq!(c.identities, 1);
|
||||
assert_eq!(c.reason, INTEGRATED_REASON);
|
||||
let mut big = card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "32 GB", "0");
|
||||
big.kind = "discrete".into();
|
||||
big.vram_mb = 32768;
|
||||
apply_defaults(&mut big);
|
||||
assert!(big.enabled);
|
||||
assert_eq!(big.identities, 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine's RAM in MB (the prover default's RAM gate, src/provedefault.rs): Windows through
|
||||
/// `Win32_OperatingSystem.TotalVisibleMemorySize` (KB), Linux through `/proc/meminfo`, macOS through `sysctl hw.memsize`;
|
||||
/// None when unreadable (no gate).
|
||||
pub fn total_ram_mb() -> Option<u64> {
|
||||
if cfg!(windows) {
|
||||
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "(Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize"]), None, Duration::from_secs(20))?;
|
||||
return out.replace('\0', "").trim().parse::<u64>().ok().map(|kb| kb / 1024);
|
||||
}
|
||||
if cfg!(target_os = "linux") {
|
||||
let text = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
return text.lines().find(|l| l.starts_with("MemTotal:")).and_then(|l| l.split_whitespace().nth(1)).and_then(|kb| kb.parse::<u64>().ok()).map(|kb| kb / 1024);
|
||||
}
|
||||
let out = run_timeout(Command::new("sysctl").args(["-n", "hw.memsize"]), None, Duration::from_secs(5))?;
|
||||
out.trim().parse::<u64>().ok().map(|b| b / (1024 * 1024))
|
||||
}
|
||||
|
|
|
|||
1033
app/igneum-app/src/ember.rs
Normal file
1033
app/igneum-app/src/ember.rs
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
501
app/igneum-app/src/hotplug.rs
Normal file
501
app/igneum-app/src/hotplug.rs
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
//! Hot-plug: what changed between two enumerations of the cards (src/detect.rs runs one at start and one every
|
||||
//! minute; the Windows host also asks for one on WM_DEVICECHANGE). Pure, so the rules are unit-tested here; the
|
||||
//! engine applies the result (start a worker, stop one, mark a row). Born 5 October 2026, when an RX 9070 XT went
|
||||
//! into PC 1 through an eGPU box while the app ran and nothing noticed.
|
||||
//!
|
||||
//! Rules: a card is the same card when its key (vendor:code, "#2" for a twin) matches and the PCI addresses do not
|
||||
//! disagree, or, failing that, when vendor and name match and that pair is unique on both sides (a twin whose
|
||||
//! ordinal moved because the first one left). The device index is never part of the identity: it moves. A
|
||||
//! card missing from a list is removed only when the tool that lists its vendor answered. Removed and faulty cards
|
||||
//! stay in the engine's list (the other cards' indices are the miner slots), marked, and the dashboard hides a
|
||||
//! removed row after five minutes.
|
||||
|
||||
use crate::config::CardPref;
|
||||
use crate::state::CardState;
|
||||
|
||||
/// How long a removed card's row says "removed" before it hides.
|
||||
pub const REMOVED_SHOWN_S: f64 = 300.0;
|
||||
/// How often the engine enumerates again, seconds (macOS has no GPU hot-plug on Apple silicon: slower there).
|
||||
pub const POLL_S: u64 = if cfg!(target_os = "macos") { 300 } else { 60 };
|
||||
/// How often the app log carries the full card list, seconds (the console reads it from the log tail).
|
||||
pub const CARDS_LINE_S: u64 = 600;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Diff {
|
||||
/// new cards (usable or with a problem), to be appended
|
||||
pub added: Vec<CardState>,
|
||||
/// cards that were marked removed earlier and are listed again: (slot, the fresh entry)
|
||||
pub revived: Vec<(usize, CardState)>,
|
||||
/// slots whose card is gone
|
||||
pub removed: Vec<usize>,
|
||||
/// slots whose card now reports a problem: (slot, "Code 43")
|
||||
pub errored: Vec<(usize, String)>,
|
||||
/// slots whose card had a problem and is now driven by a tool again: (slot, the fresh entry)
|
||||
pub recovered: Vec<(usize, CardState)>,
|
||||
/// slots whose card is the same but its device index (or memory, bus) changed: (slot, the fresh entry)
|
||||
pub moved: Vec<(usize, CardState)>,
|
||||
pub unchanged: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Diff {
|
||||
/// Nothing to do: every present card is where it was.
|
||||
pub fn is_quiet(&self) -> bool {
|
||||
self.added.is_empty() && self.revived.is_empty() && self.removed.is_empty() && self.errored.is_empty() && self.recovered.is_empty() && self.moved.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn bus_compat(a: &CardState, b: &CardState) -> bool {
|
||||
a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus
|
||||
}
|
||||
|
||||
fn same_identity(a: &CardState, b: &CardState) -> bool {
|
||||
a.vendor == b.vendor && (a.name.trim().eq_ignore_ascii_case(b.name.trim()) || (!a.code.is_empty() && a.code.eq_ignore_ascii_case(&b.code))) && bus_compat(a, b)
|
||||
}
|
||||
|
||||
/// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could
|
||||
/// have seen that card (Detection::listed); a card its tool did not answer for is kept, not removed.
|
||||
pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) -> bool) -> Diff {
|
||||
let mut out = Diff::default();
|
||||
let mut used = vec![false; fresh.len()];
|
||||
let mut pair: Vec<Option<usize>> = vec![None; old.len()];
|
||||
// exact keys first
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key && bus_compat(o, f)) {
|
||||
used[j] = true;
|
||||
pair[i] = Some(j);
|
||||
}
|
||||
}
|
||||
// then vendor + name, when that pair is unique among what is still unmatched on both sides
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
if pair[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let cands: Vec<usize> = fresh.iter().enumerate().filter(|(j, f)| !used[*j] && same_identity(o, f)).map(|(j, _)| j).collect();
|
||||
let twins = old.iter().enumerate().filter(|(k, x)| pair[*k].is_none() && *k != i && same_identity(o, x)).count();
|
||||
if cands.len() == 1 && twins == 0 {
|
||||
used[cands[0]] = true;
|
||||
pair[i] = Some(cands[0]);
|
||||
}
|
||||
}
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
match pair[i] {
|
||||
Some(j) => {
|
||||
let f = &fresh[j];
|
||||
if o.removed_at > 0.0 {
|
||||
out.revived.push((i, f.clone()));
|
||||
} else if o.problem.is_empty() && !f.problem.is_empty() {
|
||||
out.errored.push((i, f.problem.clone()));
|
||||
} else if !o.problem.is_empty() && f.problem.is_empty() {
|
||||
out.recovered.push((i, f.clone()));
|
||||
} else if !o.problem.is_empty() {
|
||||
// still faulty: the same problem or a new code, nothing to start or stop
|
||||
if o.problem != f.problem {
|
||||
out.errored.push((i, f.problem.clone()));
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
} else if o.device != f.device || o.key != f.key {
|
||||
out.moved.push((i, f.clone()));
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if o.removed_at > 0.0 {
|
||||
// already removed: stays hidden or shown as removed
|
||||
out.unchanged.push(i);
|
||||
} else if listed(o) {
|
||||
out.removed.push(i);
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (j, f) in fresh.iter().enumerate() {
|
||||
if !used[j] {
|
||||
out.added.push(f.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The saved choice for a card: by its key (vendor:code), else a key saved by an app before 0.3.11 (vendor:index:code,
|
||||
/// vendor:index:name) when exactly one matches; an index that moved never changes the answer.
|
||||
pub fn pref_for<'a>(prefs: &'a std::collections::HashMap<String, CardPref>, c: &CardState) -> Option<&'a CardPref> {
|
||||
if let Some(p) = prefs.get(&c.key) {
|
||||
return Some(p);
|
||||
}
|
||||
if c.key.contains('#') {
|
||||
return None; // a twin's choice is its own
|
||||
}
|
||||
let head = format!("{}:", c.vendor);
|
||||
for tail in [format!(":{}", c.code), format!(":{}", c.name)] {
|
||||
if tail.len() <= 1 {
|
||||
continue;
|
||||
}
|
||||
let found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail) && !k.contains('#')).map(|(_, p)| p).collect();
|
||||
if found.len() == 1 {
|
||||
return Some(found[0]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Applies a saved choice to a freshly detected card (the first detection and every later one use this).
|
||||
pub fn apply_pref(c: &mut CardState, p: &CardPref) {
|
||||
if !c.problem.is_empty() {
|
||||
return;
|
||||
}
|
||||
c.enabled = p.enabled && c.kind != "unknown";
|
||||
c.identities = p.identities.clamp(1, 64);
|
||||
if c.enabled || c.kind != "integrated" {
|
||||
c.reason = String::new();
|
||||
}
|
||||
if c.vendor == "nvidia" && p.power_pct > 0 {
|
||||
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
|
||||
}
|
||||
c.pinned = p.pinned;
|
||||
c.sweep_pct = p.sweep_pct;
|
||||
c.sweep_eff = p.sweep_eff;
|
||||
c.sweep_watts = p.sweep_watts;
|
||||
c.sweep_mhs = p.sweep_mhs;
|
||||
c.sweep_at = p.sweep_at as f64;
|
||||
// Ember Tune (src/ember.rs): the clock cap the last tune chose, its plan, and the row's Tuned line
|
||||
c.tune_clock_mhz = p.sweep_clock_mhz;
|
||||
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
|
||||
c.tune_source = p.sweep_source.clone();
|
||||
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
|
||||
c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff);
|
||||
}
|
||||
}
|
||||
|
||||
/// A card a re-detection added (or brought back): the saved choice if there is one, else the detect defaults it
|
||||
/// came with; its slot and the time it appeared.
|
||||
pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now: f64) {
|
||||
c.index = index;
|
||||
c.added_at = now;
|
||||
c.removed_at = 0.0;
|
||||
c.gone = false;
|
||||
if let Some(p) = pref {
|
||||
apply_pref(c, p);
|
||||
}
|
||||
if c.problem.is_empty() {
|
||||
c.state = if c.enabled { "waiting".into() } else { "off".into() };
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a card unplugged: no worker, the row says removed, the saved choice is untouched.
|
||||
pub fn mark_removed(c: &mut CardState, now: f64) {
|
||||
c.removed_at = now;
|
||||
c.gone = false;
|
||||
c.state = "removed".into();
|
||||
c.message = "removed".into();
|
||||
c.hash_now = 0.0;
|
||||
c.pid = 0;
|
||||
c.restart_in_s = 0;
|
||||
c.ready = false;
|
||||
c.prepared = false;
|
||||
}
|
||||
|
||||
/// A removed row hides after REMOVED_SHOWN_S; returns true when something changed.
|
||||
pub fn age(cards: &mut [CardState], now: f64) -> bool {
|
||||
let mut changed = false;
|
||||
for c in cards.iter_mut() {
|
||||
let gone = c.removed_at > 0.0 && now - c.removed_at >= REMOVED_SHOWN_S;
|
||||
if gone != c.gone {
|
||||
c.gone = gone;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The event line for a card that appeared: "New card: <name>, mining" and its kind (ok | info | warn).
|
||||
pub fn added_words(c: &CardState) -> (&'static str, String) {
|
||||
if !c.problem.is_empty() {
|
||||
("warn", format!("New card: {}, not usable ({}); {}", c.name, c.problem, crate::detect::PROBLEM_HINT))
|
||||
} else if c.enabled {
|
||||
("ok", format!("New card: {}, mining", c.name))
|
||||
} else if c.kind == "integrated" {
|
||||
("info", format!("New card: {}, off ({})", c.name, crate::detect::INTEGRATED_REASON))
|
||||
} else {
|
||||
("info", format!("New card: {}, off (switched off in settings)", c.name))
|
||||
}
|
||||
}
|
||||
|
||||
/// One word for a card's state on the log line and the console: mining, waiting, off, removed, not usable (Code 43).
|
||||
pub fn state_word(c: &CardState) -> String {
|
||||
if c.removed_at > 0.0 {
|
||||
"removed".into()
|
||||
} else if !c.problem.is_empty() {
|
||||
format!("not usable ({})", c.problem)
|
||||
} else if !c.enabled {
|
||||
"off".into()
|
||||
} else {
|
||||
c.state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-log line the console reads (relay/lib/parse.mjs): `cards: <name> [<kind>, <state>] | ...`, hidden rows
|
||||
/// left out, `cards: none` when nothing is listed.
|
||||
pub fn cards_line(cards: &[CardState]) -> String {
|
||||
let parts: Vec<String> = cards.iter().filter(|c| !c.gone).map(|c| format!("{} [{}, {}]", c.name.replace('|', "/").replace('[', "(").replace(']', ")"), c.kind, state_word(c))).collect();
|
||||
if parts.is_empty() { "cards: none".into() } else { format!("cards: {}", parts.join(" | ")) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::detect::{classify_kind, mark_unusable, INTEGRATED_REASON};
|
||||
|
||||
fn card(name: &str, vendor: &str, device: &str) -> CardState {
|
||||
let mut c = CardState { index: 0, key: format!("{vendor}:{name}"), code: name.into(), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() };
|
||||
c.kind = classify_kind(name, None).into();
|
||||
crate::detect::apply_defaults(&mut c);
|
||||
c
|
||||
}
|
||||
// PC 1 at start on 5 October 2026: the 5090 on nvidia-smi index 0, the Ryzen iGPU as OpenCL device 0 (gfx1036)
|
||||
fn pc1_start() -> Vec<CardState> {
|
||||
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
|
||||
a.bus = "00000000:01:00.0".into();
|
||||
a.state = "mining".into();
|
||||
let mut b = card("gfx1036", "amd", "0");
|
||||
b.index = 1;
|
||||
vec![a, b]
|
||||
}
|
||||
fn all_listed(_: &CardState) -> bool {
|
||||
true
|
||||
}
|
||||
fn fresh_pc1_with_egpu() -> Vec<CardState> {
|
||||
let mut v = pc1_start();
|
||||
v[0].state = "off".into();
|
||||
let mut e = card("gfx1201", "amd", "1");
|
||||
e.index = 2;
|
||||
v.push(e);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_list_is_quiet() {
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
fresh[0].state = "off".into(); // runtime state in the fresh list means nothing
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert!(d.is_quiet(), "{d:?}");
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_usable_card_is_added_and_nothing_else_moves() {
|
||||
let old = pc1_start();
|
||||
let d = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert_eq!(d.added[0].name, "gfx1201");
|
||||
assert_eq!(d.added[0].kind, "discrete");
|
||||
assert!(d.added[0].enabled);
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
assert!(d.removed.is_empty() && d.moved.is_empty() && d.errored.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_card_with_a_problem_is_added_as_unusable() {
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
fresh.push(bad);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert_eq!(d.added[0].problem, "Code 43");
|
||||
assert!(!d.added[0].enabled);
|
||||
let (kind, text) = added_words(&d.added[0]);
|
||||
assert_eq!(kind, "warn");
|
||||
assert!(text.starts_with("New card: AMD Radeon RX 9070 XT, not usable (Code 43); reboot with the card attached"), "{text}");
|
||||
assert_eq!(state_word(&d.added[0]), "not usable (Code 43)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unplugged_card_is_removed_only_when_its_tool_answered() {
|
||||
let old = fresh_pc1_with_egpu();
|
||||
let fresh = pc1_start();
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.removed, vec![2]);
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
// the OpenCL worker did not answer this round: nothing is called removed
|
||||
let opencl_dead = |c: &CardState| c.vendor == "nvidia";
|
||||
let d2 = diff(&old, &pc1_start().into_iter().filter(|c| c.vendor == "nvidia").collect::<Vec<_>>(), &opencl_dead);
|
||||
assert!(d2.removed.is_empty(), "{d2:?}");
|
||||
assert!(d2.is_quiet());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_whose_index_moved_keeps_its_slot() {
|
||||
// the eGPU landed before the iGPU in the OpenCL list: the iGPU is device 1 now, the eGPU device 0
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
fresh[1].device = "1".into();
|
||||
let mut e = card("gfx1201", "amd", "0");
|
||||
e.index = 2;
|
||||
fresh.push(e);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.moved.len(), 1);
|
||||
assert_eq!(d.moved[0].0, 1);
|
||||
assert_eq!(d.moved[0].1.device, "1");
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert!(d.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_identical_cards_are_told_apart_by_key_and_never_swapped() {
|
||||
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
|
||||
let mut b = card("NVIDIA GeForce RTX 5090", "nvidia", "1");
|
||||
b.index = 1;
|
||||
b.key = "nvidia:NVIDIA GeForce RTX 5090#2".into();
|
||||
a.bus = "01:00.0".into();
|
||||
b.bus = "02:00.0".into();
|
||||
let old = vec![a.clone(), b.clone()];
|
||||
// the second twin leaves: the first keeps its slot by key; the missing one is removed, not "moved"
|
||||
let d = diff(&old, &[a.clone()], &all_listed);
|
||||
assert_eq!(d.unchanged, vec![0]);
|
||||
assert_eq!(d.removed, vec![1]);
|
||||
// the FIRST twin leaves: the survivor is now index 0 with the unsuffixed key, but its bus says which card it
|
||||
// is, so slot 1 is "moved" (new key and device) and slot 0 is removed; no worker is swapped between cards
|
||||
let mut survivor = b.clone();
|
||||
survivor.device = "0".into();
|
||||
survivor.key = "nvidia:NVIDIA GeForce RTX 5090".into();
|
||||
let d2 = diff(&old, &[survivor], &all_listed);
|
||||
assert_eq!(d2.removed, vec![0]);
|
||||
assert_eq!(d2.moved.len(), 1);
|
||||
assert_eq!(d2.moved[0].0, 1);
|
||||
assert_eq!(d2.moved[0].1.key, "nvidia:NVIDIA GeForce RTX 5090");
|
||||
// the same two cards again, nothing changed: quiet
|
||||
let d3 = diff(&old, &old, &all_listed);
|
||||
assert!(d3.is_quiet(), "{d3:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_driven_card_that_turns_faulty_is_errored_and_recovers_later() {
|
||||
let old = fresh_pc1_with_egpu();
|
||||
// the eGPU is still listed by Windows, now with Code 43, and no longer by OpenCL
|
||||
let mut fresh = pc1_start();
|
||||
let mut bad = card("gfx1201", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
fresh.push(bad);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.errored, vec![(2, "Code 43".to_string())]);
|
||||
assert!(d.added.is_empty() && d.removed.is_empty());
|
||||
// after a reboot with the card attached it is driven again: recovered, same slot
|
||||
let mut faulty = old.clone();
|
||||
mark_unusable(&mut faulty[2], "Code 43");
|
||||
faulty[2].device = String::new();
|
||||
let d2 = diff(&faulty, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d2.recovered.len(), 1);
|
||||
assert_eq!(d2.recovered[0].0, 2);
|
||||
assert!(d2.recovered[0].1.problem.is_empty());
|
||||
// the same problem again next minute: quiet
|
||||
let d3 = diff(&faulty, &fresh, &all_listed);
|
||||
assert!(d3.is_quiet(), "{d3:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_card_that_comes_back_is_revived_in_its_slot() {
|
||||
let mut old = fresh_pc1_with_egpu();
|
||||
mark_removed(&mut old[2], 1000.0);
|
||||
assert_eq!(state_word(&old[2]), "removed");
|
||||
// still absent: quiet (and hidden after five minutes)
|
||||
let d = diff(&old, &pc1_start(), &all_listed);
|
||||
assert!(d.is_quiet(), "{d:?}");
|
||||
assert!(age(&mut old, 1000.0 + REMOVED_SHOWN_S));
|
||||
assert!(old[2].gone);
|
||||
assert!(!age(&mut old, 1000.0 + REMOVED_SHOWN_S + 1.0));
|
||||
// back: revived in slot 2
|
||||
let d2 = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d2.revived.len(), 1);
|
||||
assert_eq!(d2.revived[0].0, 2);
|
||||
assert!(d2.added.is_empty());
|
||||
let mut back = d2.revived[0].1.clone();
|
||||
settle_new(&mut back, 2, None, 2000.0);
|
||||
assert_eq!(back.index, 2);
|
||||
assert_eq!(back.removed_at, 0.0);
|
||||
assert!(!back.gone);
|
||||
assert_eq!(back.added_at, 2000.0);
|
||||
assert_eq!(back.state, "waiting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_users_choice_is_kept_on_a_new_card_and_an_integrated_one_is_off_by_default() {
|
||||
let mut prefs = std::collections::HashMap::new();
|
||||
// the user switched the iGPU on earlier and set 2 identities; the setting was saved under OpenCL index 0
|
||||
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 2, ..Default::default() });
|
||||
// the iGPU comes back as device 1 (the eGPU took index 0): the 0.3.9 key still answers, by vendor and code
|
||||
let mut igpu = card("gfx1036", "amd", "1");
|
||||
assert_eq!(igpu.kind, "integrated");
|
||||
assert!(!igpu.enabled);
|
||||
assert_eq!(igpu.reason, INTEGRATED_REASON);
|
||||
let p = pref_for(&prefs, &igpu).cloned();
|
||||
assert!(p.is_some());
|
||||
settle_new(&mut igpu, 1, p.as_ref(), 5.0);
|
||||
assert!(igpu.enabled);
|
||||
assert_eq!(igpu.identities, 2);
|
||||
assert_eq!(igpu.reason, "");
|
||||
assert_eq!(igpu.state, "waiting");
|
||||
// the user switched it off (saved under the index-free key): the row keeps the integrated words
|
||||
prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
|
||||
let mut igpu2 = card("gfx1036", "amd", "1");
|
||||
let p2 = pref_for(&prefs, &igpu2).cloned();
|
||||
settle_new(&mut igpu2, 1, p2.as_ref(), 6.0);
|
||||
assert!(!igpu2.enabled);
|
||||
assert_eq!(igpu2.reason, INTEGRATED_REASON);
|
||||
assert_eq!(igpu2.state, "off");
|
||||
let (kind, text) = added_words(&igpu2);
|
||||
assert_eq!(kind, "info");
|
||||
assert_eq!(text, format!("New card: gfx1036, off ({INTEGRATED_REASON})"));
|
||||
// no saved choice: the detect default (integrated off, discrete on)
|
||||
let mut egpu = card("gfx1201", "amd", "0");
|
||||
settle_new(&mut egpu, 2, None, 7.0);
|
||||
assert!(egpu.enabled);
|
||||
assert_eq!(added_words(&egpu), ("ok", "New card: gfx1201, mining".to_string()));
|
||||
// a pref never switches on a card with a problem
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
settle_new(&mut bad, 3, Some(&CardPref { enabled: true, identities: 8, ..Default::default() }), 8.0);
|
||||
assert!(!bad.enabled);
|
||||
assert_eq!(bad.state, "unusable");
|
||||
// old keys only, two of them for the same code (the five-row PC 1 list had amd:0:gfx1036 and amd:2:gfx1036):
|
||||
// ambiguous, so the default applies; the index-free key, once saved, always wins
|
||||
prefs.remove("amd:gfx1036");
|
||||
prefs.insert("amd:2:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() });
|
||||
let other = card("gfx1036", "amd", "7");
|
||||
assert!(pref_for(&prefs, &other).is_none());
|
||||
prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: true, identities: 4, ..Default::default() });
|
||||
assert_eq!(pref_for(&prefs, &other).map(|p| p.identities), Some(4));
|
||||
// a twin never borrows the first card's choice
|
||||
let mut twin = card("gfx1201", "amd", "3");
|
||||
twin.key = "amd:gfx1201#2".into();
|
||||
prefs.insert("amd:gfx1201".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
|
||||
assert!(pref_for(&prefs, &twin).is_none());
|
||||
// the name on the row is the Windows name while the key keeps the code: the 0.3.9 key by code still answers
|
||||
let mut named = card("gfx1201", "amd", "1");
|
||||
named.name = "AMD Radeon RX 9070 XT".into();
|
||||
prefs.clear();
|
||||
prefs.insert("amd:1:gfx1201".to_string(), CardPref { enabled: false, identities: 2, ..Default::default() });
|
||||
assert_eq!(pref_for(&prefs, &named).map(|p| p.identities), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_console_line_lists_every_shown_card_with_kind_and_state() {
|
||||
let mut cards = fresh_pc1_with_egpu();
|
||||
cards[0].state = "mining".into();
|
||||
cards[2].state = "starting".into();
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
cards.push(bad);
|
||||
assert_eq!(cards_line(&cards), "cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, starting] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]");
|
||||
mark_removed(&mut cards[2], 10.0);
|
||||
assert!(cards_line(&cards).contains("gfx1201 [discrete, removed]"));
|
||||
age(&mut cards, 10.0 + REMOVED_SHOWN_S);
|
||||
assert!(!cards_line(&cards).contains("gfx1201"));
|
||||
assert_eq!(cards_line(&[]), "cards: none");
|
||||
}
|
||||
}
|
||||
281
app/igneum-app/src/inputs.rs
Normal file
281
app/igneum-app/src/inputs.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
606
app/igneum-app/src/jobbuild.rs
Normal file
606
app/igneum-app/src/jobbuild.rs
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
//! The `build` job (the model is src/jobs.rs, the runner src/jobrun.rs): a Windows PC builds the node and the app
|
||||
//! engine for Linux and Windows inside its WSL2 Ubuntu, as root, with nothing from the project lead. the project lead's ask, 4 October 2026
|
||||
//! evening ("efficiency"): every Windows build went through a GitHub runner at 15 to 25 minutes a round and every
|
||||
//! Linux binary was cross-compiled on the Mac under the build lock; the two RTX 5090 PCs sit idle on the CPU side.
|
||||
//!
|
||||
//! What this module holds is the pure part, so it is unit-tested on the Mac: the job's parameters, the plan read
|
||||
//! from the inputs manifest (what to build, what to test), the bash stage scripts that run inside the distro, the
|
||||
//! per-stage time caps, and the parsers for what comes back (free space, the pack stage's outputs file, the relay's
|
||||
//! JSON replies). The runner (jobrun.rs, `run_build`) does the process work: fetch, wsl.exe per stage, upload.
|
||||
//!
|
||||
//! Stages, in order (each a RESULT or STAGE line with a UTC time in the report, each under its own cap):
|
||||
//! fetch the build-inputs zip by https, sha256 checked (src/jobrun.rs fetch_file), the manifest read out of it
|
||||
//! setup apt packages (mingw, clang for bindgen, protoc, zstd), rustup target x86_64-pc-windows-gnu; idempotent
|
||||
//! extract /root/igneum-build/src replaced by the zip's sources (the target dir /root/igneum-build/target persists)
|
||||
//! linux cargo build --release per unit, native
|
||||
//! windows cargo build --release --target x86_64-pc-windows-gnu per unit, mingw-w64 (posix threads), static libgcc
|
||||
//! test cargo test --release for the packages the manifest names (Linux, native)
|
||||
//! pack zstd per binary, sha256 of both forms, build-outputs.json, copied to the job folder on the Windows side
|
||||
//! upload every .zst and the outputs file to the relay (fn=upload client token, PUT to Blob, fn=drop); 50 MB each
|
||||
//! Mining is never stopped: the build is CPU work under `nice`; the app's job runner is serial, so a build never
|
||||
//! overlaps a shard benchmark (src/jobrun.rs: one `Active` at a time, queued jobs wait).
|
||||
//!
|
||||
//! The zip's layout (packaging/windows/push-build-inputs.sh): igneum-build-inputs/{manifest.json, node/ (the fork
|
||||
//! worktree without target dirs), app/igneum-app/, brand/icons/, proto-cuda/ (without nvrtc/redist)}. The manifest:
|
||||
//! { "created_at", "node": {"branch","commit","dirty","source"}, "repo": {...}, "app_version",
|
||||
//! "builds": [{"dir":"node","packages":["kaspad","igneum-miner"],"features":["kaspad/igneum-pow"],"bins":["igneumd","igneum-miner"],"targets":["linux","windows"]},
|
||||
//! {"dir":"app/igneum-app","packages":["igneum-app"],"bins":["igneum-app"],"targets":["linux","windows"],"optional_on":["linux"]}],
|
||||
//! "tests": [{"dir":"app/igneum-app","packages":["igneum-app"]}, {"dir":"node","packages":["igneum-miner"]}] }
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::jobs::{self, Job};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const RELAY_URL_DEFAULT: &str = "https://relay.igneum.network";
|
||||
/// Everything of the build lives here inside the distro: target/ (persists), src/ (per job), out/<id>/ (outputs).
|
||||
pub const WSL_BASE: &str = "/root/igneum-build";
|
||||
pub const ZIP_ROOT: &str = "igneum-build-inputs";
|
||||
pub const OUTPUTS_FILE: &str = "build-outputs.json";
|
||||
pub const DEFAULT_DISTRO: &str = "Ubuntu-24.04";
|
||||
pub const DEFAULT_WSL_USER: &str = "root";
|
||||
pub const DEFAULT_NICE: u64 = 19;
|
||||
/// The relay's cap for one Blob upload (relay/lib/relay.mjs MAX_BLOB).
|
||||
pub const MAX_UPLOAD_BYTES: u64 = 50 * 1024 * 1024;
|
||||
/// Debian packages the build needs; installed only when `dpkg -s` says they are missing.
|
||||
pub const APT_COMMON: &[&str] = &["build-essential", "pkg-config", "libssl-dev", "clang", "libclang-dev", "cmake", "unzip", "zstd", "protobuf-compiler", "ca-certificates", "curl", "git"];
|
||||
pub const APT_WINDOWS: &[&str] = &["gcc-mingw-w64-x86-64", "g++-mingw-w64-x86-64", "binutils-mingw-w64-x86-64", "mingw-w64-x86-64-dev"];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BuildParams {
|
||||
pub targets: Vec<String>,
|
||||
pub budget_min: u64,
|
||||
pub stage_min: BTreeMap<String, u64>,
|
||||
pub min_free_gb: u64,
|
||||
pub tests: bool,
|
||||
pub relay_url: String,
|
||||
pub distro: String,
|
||||
pub wsl_user: String,
|
||||
pub nice: u64,
|
||||
/// cargo -j; 0 = cargo's default (every core)
|
||||
pub cargo_jobs: u64,
|
||||
}
|
||||
|
||||
impl BuildParams {
|
||||
pub fn from_job(job: &Job) -> BuildParams {
|
||||
let mut targets = job.list_param("targets");
|
||||
if targets.is_empty() {
|
||||
targets = jobs::BUILD_TARGETS.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
targets.dedup();
|
||||
let mut stage_min = BTreeMap::new();
|
||||
if let Some(o) = job.params.get("stage_minutes").and_then(|v| v.as_object()) {
|
||||
for (k, v) in o {
|
||||
if let Some(n) = v.as_u64() {
|
||||
stage_min.insert(k.clone(), n.max(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
let relay = job.str_param("relay_url");
|
||||
let distro = job.str_param("distro");
|
||||
let user = job.str_param("wsl_user");
|
||||
BuildParams {
|
||||
targets,
|
||||
budget_min: job.timeout_minutes(),
|
||||
stage_min,
|
||||
min_free_gb: job.u64_param("min_free_gb").unwrap_or(jobs::DEFAULT_BUILD_MIN_FREE_GB),
|
||||
tests: job.params.get("tests").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
relay_url: if relay.is_empty() { RELAY_URL_DEFAULT.to_string() } else { relay.trim_end_matches('/').to_string() },
|
||||
distro: if distro.is_empty() { DEFAULT_DISTRO.to_string() } else { distro },
|
||||
wsl_user: if user.is_empty() { DEFAULT_WSL_USER.to_string() } else { user },
|
||||
nice: job.u64_param("nice").unwrap_or(DEFAULT_NICE).min(19),
|
||||
cargo_jobs: job.u64_param("cargo_jobs").unwrap_or(0),
|
||||
}
|
||||
}
|
||||
pub fn wants(&self, target: &str) -> bool {
|
||||
self.targets.iter().any(|t| t == target)
|
||||
}
|
||||
}
|
||||
|
||||
/// A stage's cap: its own minutes when the job names them, else what is left of the total; never over what is left.
|
||||
pub fn stage_cap(p: &BuildParams, stage: &str, remaining: Duration) -> Duration {
|
||||
match p.stage_min.get(stage) {
|
||||
Some(m) => Duration::from_secs(m * 60).min(remaining),
|
||||
None => remaining,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the plan from the inputs manifest ------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Unit {
|
||||
/// path inside the zip root ("node", "app/igneum-app")
|
||||
pub dir: String,
|
||||
pub packages: Vec<String>,
|
||||
pub features: Vec<String>,
|
||||
/// the binaries cargo leaves in <target dir>/release (and <target dir>/x86_64-pc-windows-gnu/release with .exe)
|
||||
pub bins: Vec<String>,
|
||||
pub targets: Vec<String>,
|
||||
/// targets where a failure is reported but does not fail the job (the engine on Linux)
|
||||
pub optional_on: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct TestUnit {
|
||||
pub dir: String,
|
||||
pub packages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Manifest {
|
||||
pub created_at: String,
|
||||
pub node_branch: String,
|
||||
pub node_commit: String,
|
||||
pub node_dirty: bool,
|
||||
pub app_version: String,
|
||||
pub builds: Vec<Unit>,
|
||||
pub tests: Vec<TestUnit>,
|
||||
}
|
||||
|
||||
fn strings(v: Option<&Value>) -> Vec<String> {
|
||||
v.and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A plain relative directory inside the zip root: no "..", no leading slash, no drive, one or more components.
|
||||
fn plain_dir(s: &str) -> bool {
|
||||
jobs::safe_rel_path(s).is_some()
|
||||
}
|
||||
|
||||
fn plain_name(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= 80 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/') && !s.starts_with('.') && !s.contains("..")
|
||||
}
|
||||
|
||||
/// Reads manifest.json out of the zip. Refuses anything that would not be a plain cargo invocation inside the
|
||||
/// extracted tree (a dir with "..", a package name with spaces), because these strings go into a shell script.
|
||||
pub fn parse_manifest(text: &str) -> Result<Manifest, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("manifest.json is not JSON: {e}"))?;
|
||||
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
let node = v.get("node").cloned().unwrap_or(Value::Null);
|
||||
let ns = |k: &str| node.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
let mut m = Manifest { created_at: s("created_at"), node_branch: ns("branch"), node_commit: ns("commit"), node_dirty: node.get("dirty").and_then(|x| x.as_bool()).unwrap_or(false), app_version: s("app_version"), ..Default::default() };
|
||||
let builds = v.get("builds").and_then(|b| b.as_array()).ok_or("manifest.json has no \"builds\" list")?;
|
||||
for (i, b) in builds.iter().enumerate() {
|
||||
let dir = b.get("dir").and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
if !plain_dir(&dir) {
|
||||
return Err(format!("builds[{i}].dir '{dir}' is not a plain relative path"));
|
||||
}
|
||||
let u = Unit { dir, packages: strings(b.get("packages")), features: strings(b.get("features")), bins: strings(b.get("bins")), targets: { let t = strings(b.get("targets")); if t.is_empty() { jobs::BUILD_TARGETS.iter().map(|s| s.to_string()).collect() } else { t } }, optional_on: strings(b.get("optional_on")) };
|
||||
if u.packages.is_empty() {
|
||||
return Err(format!("builds[{i}] ({}) names no packages", u.dir));
|
||||
}
|
||||
if u.bins.is_empty() {
|
||||
return Err(format!("builds[{i}] ({}) names no bins", u.dir));
|
||||
}
|
||||
for x in u.packages.iter().chain(u.features.iter()).chain(u.bins.iter()) {
|
||||
if !plain_name(x) {
|
||||
return Err(format!("builds[{i}] ({}): '{x}' is not a plain name", u.dir));
|
||||
}
|
||||
}
|
||||
for t in &u.targets {
|
||||
if !jobs::BUILD_TARGETS.contains(&t.as_str()) {
|
||||
return Err(format!("builds[{i}] ({}): target '{t}' is unknown", u.dir));
|
||||
}
|
||||
}
|
||||
m.builds.push(u);
|
||||
}
|
||||
if m.builds.is_empty() {
|
||||
return Err("manifest.json names nothing to build".into());
|
||||
}
|
||||
for (i, t) in v.get("tests").and_then(|b| b.as_array()).map(|a| a.to_vec()).unwrap_or_default().iter().enumerate() {
|
||||
let dir = t.get("dir").and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
if !plain_dir(&dir) {
|
||||
return Err(format!("tests[{i}].dir '{dir}' is not a plain relative path"));
|
||||
}
|
||||
let packages = strings(t.get("packages"));
|
||||
if packages.iter().any(|p| !plain_name(p)) {
|
||||
return Err(format!("tests[{i}] ({dir}): a package name is not plain"));
|
||||
}
|
||||
if !packages.is_empty() {
|
||||
m.tests.push(TestUnit { dir, packages });
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
// ---- the stage scripts (bash, run as `wsl -d <distro> -u root -- bash <script on /mnt/c>`) ---------------------------
|
||||
|
||||
/// What every stage script starts with: the same PATH the working shard job used (run-20261004-173115: cargo and
|
||||
/// the toolchain live under /root; a login shell from a process without a console need not source ~/.cargo/env),
|
||||
/// the persistent target dir, no interactive apt.
|
||||
pub fn prelude(p: &BuildParams, job_id: &str) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("#!/usr/bin/env bash\n");
|
||||
s.push_str("set -uo pipefail\n");
|
||||
s.push_str("export HOME=/root\n");
|
||||
s.push_str("export CARGO_HOME=/root/.cargo RUSTUP_HOME=/root/.rustup\n");
|
||||
s.push_str("CUDA_DIR=$(ls -d /usr/local/cuda-12.* 2>/dev/null | sort -V | tail -1)\n");
|
||||
s.push_str("export PATH=\"/root/.cargo/bin:${CUDA_DIR:+$CUDA_DIR/bin:}/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"\n");
|
||||
s.push_str("export DEBIAN_FRONTEND=noninteractive\n");
|
||||
s.push_str(&format!("B={WSL_BASE}\n"));
|
||||
s.push_str(&format!("SRC=$B/src/{ZIP_ROOT}\n"));
|
||||
s.push_str(&format!("OUT=$B/out/{job_id}\n"));
|
||||
s.push_str("export CARGO_TARGET_DIR=$B/target\n");
|
||||
s.push_str("export CARGO_NET_RETRY=5 CARGO_TERM_COLOR=never CARGO_INCREMENTAL=0\n");
|
||||
s.push_str(&format!("NICE=\"nice -n {}\"\n", p.nice));
|
||||
s.push_str(&format!("JOBS=\"{}\"\n", if p.cargo_jobs > 0 { format!("-j {}", p.cargo_jobs) } else { String::new() }));
|
||||
s.push_str("now() { date -u +%Y-%m-%dT%H:%M:%SZ; }\n");
|
||||
s.push_str("mkdir -p \"$B\" \"$OUT\"\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The environment of the Windows target build: Ubuntu's mingw-w64 (posix threads, so libstdc++ has std::thread
|
||||
/// for rocksdb), bindgen's clang pointed at the mingw headers (librocksdb-sys), static libgcc and winpthread
|
||||
/// (`-static`; proto-cuda/windows-node/cross-build.sh does the same with Homebrew's toolchain). libstdc++ itself
|
||||
/// stayed dynamic whatever these flags said (the gcc driver ignores `-static-libstdc++`), and the GCC 13 exe
|
||||
/// calling into libstdc++-6.dll died at its first rocksdb call (5 October 2026, release-0.3.6 plan section 10);
|
||||
/// since the fork's database/build.rs (housekeeping) the C++ runtime is static and the exes import no mingw DLL.
|
||||
/// The DLL copy below stays for a fork without that build script.
|
||||
pub fn windows_env() -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc-posix\n");
|
||||
s.push_str("export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++-posix\n");
|
||||
s.push_str("export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar\n");
|
||||
s.push_str("export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc-posix\n");
|
||||
s.push_str("export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS=\"-C link-arg=-static -C link-arg=-static-libgcc\"\n");
|
||||
s.push_str("export IGNEUM_WINDRES=x86_64-w64-mingw32-windres\n");
|
||||
s.push_str("LLVM_LIB=$(ls -d /usr/lib/llvm-*/lib 2>/dev/null | sort -V | tail -1)\n");
|
||||
s.push_str("export LIBCLANG_PATH=\"${LLVM_LIB:-/usr/lib/llvm-18/lib}\"\n");
|
||||
s.push_str("export BINDGEN_EXTRA_CLANG_ARGS_x86_64_pc_windows_gnu=\"--target=x86_64-w64-mingw32 --sysroot=/usr/x86_64-w64-mingw32 -I/usr/x86_64-w64-mingw32/include\"\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The stage script for `setup`: packages the build needs when dpkg says they are missing, rustup when cargo is
|
||||
/// missing, the Windows target when wanted. Every step idempotent; every outcome a RESULT line.
|
||||
pub fn setup_script(p: &BuildParams, job_id: &str) -> String {
|
||||
let mut s = prelude(p, job_id);
|
||||
s.push_str("echo \"STAGE setup start $(now)\"\n");
|
||||
s.push_str("need=\"\"\n");
|
||||
let mut pkgs: Vec<&str> = APT_COMMON.to_vec();
|
||||
if p.wants("windows") {
|
||||
pkgs.extend_from_slice(APT_WINDOWS);
|
||||
}
|
||||
s.push_str(&format!("for pkg in {}; do dpkg -s \"$pkg\" >/dev/null 2>&1 || need=\"$need $pkg\"; done\n", pkgs.join(" ")));
|
||||
s.push_str("if [ -n \"$need\" ]; then echo \"installing:$need\"; apt-get update -qq && apt-get install -y -qq --no-install-recommends $need || { echo \"RESULT setup apt failed for:$need\"; exit 2; }; else echo \"apt packages present\"; fi\n");
|
||||
s.push_str("if ! command -v cargo >/dev/null 2>&1; then echo \"installing rustup (minimal)\"; curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path || { echo \"RESULT setup rustup failed\"; exit 2; }; fi\n");
|
||||
if p.wants("windows") {
|
||||
s.push_str("rustup target list --installed 2>/dev/null | grep -qx x86_64-pc-windows-gnu || rustup target add x86_64-pc-windows-gnu || { echo \"RESULT setup rustup target x86_64-pc-windows-gnu failed\"; exit 2; }\n");
|
||||
s.push_str("command -v x86_64-w64-mingw32-gcc-posix >/dev/null 2>&1 || { echo \"RESULT setup mingw gcc (posix) missing after install\"; exit 2; }\n");
|
||||
s.push_str("command -v x86_64-w64-mingw32-windres >/dev/null 2>&1 || { echo \"RESULT setup mingw windres missing after install\"; exit 2; }\n");
|
||||
}
|
||||
s.push_str("command -v protoc >/dev/null 2>&1 || { echo \"RESULT setup protoc missing after install\"; exit 2; }\n");
|
||||
s.push_str("command -v zstd >/dev/null 2>&1 || { echo \"RESULT setup zstd missing after install\"; exit 2; }\n");
|
||||
if p.wants("windows") {
|
||||
s.push_str("echo \"RESULT setup ok $(cargo --version) | $(rustc --version) | mingw $(x86_64-w64-mingw32-gcc-posix --version 2>/dev/null | head -1 || echo none) | $(now)\"\n");
|
||||
} else {
|
||||
s.push_str("echo \"RESULT setup ok $(cargo --version) | $(rustc --version) | $(now)\"\n");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `extract`: the zip (copied from the job folder on /mnt/c) replaces $B/src; the target dir stays.
|
||||
pub fn extract_script(p: &BuildParams, job_id: &str, zip_wsl: &str) -> String {
|
||||
let mut s = prelude(p, job_id);
|
||||
s.push_str("echo \"STAGE extract start $(now)\"\n");
|
||||
s.push_str(&format!("ZIP='{}'\n", zip_wsl.replace('\'', "'\\''")));
|
||||
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
|
||||
s.push_str("find \"$B/src\" -type f -exec touch {} + 2>/dev/null || true\n");
|
||||
s.push_str("echo \"RESULT extract ok $(find \"$B/src\" -type f | wc -l) files, $(du -sh \"$B/src\" | cut -f1) at $(now)\"\n");
|
||||
s
|
||||
}
|
||||
|
||||
fn cargo_unit_args(u: &Unit) -> String {
|
||||
let mut a = String::new();
|
||||
for p in &u.packages {
|
||||
a.push_str(&format!(" -p {p}"));
|
||||
}
|
||||
if !u.features.is_empty() {
|
||||
a.push_str(&format!(" --features {}", u.features.join(",")));
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// `linux` or `windows`: every unit of the manifest that wants the target, one cargo build each, the binaries
|
||||
/// copied to $OUT/<target>/. A unit that is optional on this target reports and goes on; any other failure ends
|
||||
/// the stage with its exit code.
|
||||
pub fn build_script(p: &BuildParams, job_id: &str, m: &Manifest, target: &str) -> String {
|
||||
let mut s = prelude(p, job_id);
|
||||
let windows = target == "windows";
|
||||
if windows {
|
||||
s.push_str(&windows_env());
|
||||
}
|
||||
s.push_str(&format!("echo \"STAGE {target} start $(now)\"\n"));
|
||||
s.push_str(&format!("mkdir -p \"$OUT/{target}\"\n"));
|
||||
s.push_str("rc_all=0\n");
|
||||
for u in m.builds.iter().filter(|u| u.targets.iter().any(|t| t == target)) {
|
||||
let optional = u.optional_on.iter().any(|t| t == target);
|
||||
let rel = if windows { "x86_64-pc-windows-gnu/release" } else { "release" };
|
||||
let tflag = if windows { " --target x86_64-pc-windows-gnu" } else { "" };
|
||||
s.push_str(&format!("echo \"STAGE {target} {dir} start $(now)\"\n", dir = u.dir));
|
||||
s.push_str(&format!("t0=$(date +%s); rc=1\n"));
|
||||
s.push_str(&format!("if cd \"$SRC/{dir}\"; then $NICE cargo build --release $JOBS{args}{tflag} 2>&1; rc=$?; else echo \"no $SRC/{dir}\"; rc=2; fi\n", dir = u.dir, args = cargo_unit_args(u)));
|
||||
s.push_str(&format!("echo \"RESULT {target} {dir} build exit $rc $(( $(date +%s) - t0 )) s at $(now)\"\n", dir = u.dir));
|
||||
s.push_str("if [ \"$rc\" = 0 ]; then\n");
|
||||
for b in &u.bins {
|
||||
let name = if windows { format!("{b}.exe") } else { b.clone() };
|
||||
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 {
|
||||
s.push_str("[ \"$rc\" = 0 ] || rc_all=$rc\n");
|
||||
}
|
||||
}
|
||||
s.push_str(&format!("echo \"STAGE {target} done $(now) exit $rc_all\"\n"));
|
||||
s.push_str("exit $rc_all\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// `test`: cargo test --release for the manifest's test units (native). Failures are reported per unit and the
|
||||
/// stage's exit is the first failure; the runner packs and uploads the binaries regardless and marks the job failed.
|
||||
pub fn test_script(p: &BuildParams, job_id: &str, m: &Manifest) -> String {
|
||||
let mut s = prelude(p, job_id);
|
||||
s.push_str("echo \"STAGE test start $(now)\"\n");
|
||||
s.push_str("rc_all=0\n");
|
||||
for t in &m.tests {
|
||||
let pk: String = t.packages.iter().map(|p| format!(" -p {p}")).collect();
|
||||
s.push_str(&format!("t0=$(date +%s); rc=1\n"));
|
||||
s.push_str(&format!("if cd \"$SRC/{dir}\"; then $NICE cargo test --release $JOBS{pk} 2>&1; rc=$?; else echo \"no $SRC/{dir}\"; rc=2; fi\n", dir = t.dir));
|
||||
s.push_str(&format!("echo \"RESULT test {dir} [{names}] exit $rc $(( $(date +%s) - t0 )) s at $(now)\"\n", dir = t.dir, names = t.packages.join(" ")));
|
||||
s.push_str("[ \"$rc\" = 0 ] || [ \"$rc_all\" != 0 ] || rc_all=$rc\n");
|
||||
}
|
||||
if m.tests.is_empty() {
|
||||
s.push_str("echo \"RESULT test nothing named in the manifest\"\n");
|
||||
}
|
||||
s.push_str("echo \"STAGE test done $(now) exit $rc_all\"\n");
|
||||
s.push_str("exit $rc_all\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// `pack`: zstd per binary (linux: <name>.linux.zst, windows: <name>.exe.zst), sha256 of both forms,
|
||||
/// build-outputs.json, the lot copied to the job folder on the Windows side for the upload.
|
||||
pub fn pack_script(p: &BuildParams, job_id: &str, m: &Manifest, out_wsl: &str) -> String {
|
||||
let mut s = prelude(p, job_id);
|
||||
s.push_str("echo \"STAGE pack start $(now)\"\n");
|
||||
s.push_str(&format!("DEST='{}'\n", out_wsl.replace('\'', "'\\''")));
|
||||
s.push_str("PACK=$OUT/pack; rm -rf \"$PACK\"; mkdir -p \"$PACK\" \"$DEST\" || { echo \"RESULT pack cannot make $DEST\"; exit 2; }\n");
|
||||
s.push_str("n=0; first=1\n");
|
||||
s.push_str(&format!("printf '{{\"job\":\"%s\",\"packed_at\":\"%s\",\"node_branch\":\"%s\",\"node_commit\":\"%s\",\"app_version\":\"%s\",\"files\":[' \"{job_id}\" \"$(now)\" '{nb}' '{nc}' '{av}' > \"$PACK/{OUTPUTS_FILE}\"\n", nb = m.node_branch.replace('\'', ""), nc = m.node_commit.replace('\'', ""), av = m.app_version.replace('\'', "")));
|
||||
s.push_str("for target in linux windows; do\n");
|
||||
s.push_str(" [ -d \"$OUT/$target\" ] || continue\n");
|
||||
s.push_str(" for f in \"$OUT/$target\"/*; do\n");
|
||||
s.push_str(" [ -f \"$f\" ] || continue\n");
|
||||
s.push_str(" name=$(basename \"$f\"); case \"$target\" in linux) z=\"$name.linux.zst\";; *) z=\"$name.zst\";; esac\n");
|
||||
s.push_str(" zstd -q -f -T0 -12 \"$f\" -o \"$PACK/$z\" || { echo \"RESULT pack zstd failed on $name\"; exit 2; }\n");
|
||||
s.push_str(" sum=$(sha256sum \"$f\" | cut -c1-64); zsum=$(sha256sum \"$PACK/$z\" | cut -c1-64); bytes=$(stat -c %s \"$f\"); zbytes=$(stat -c %s \"$PACK/$z\")\n");
|
||||
s.push_str(" [ $first = 1 ] || printf ',' >> \"$PACK/build-outputs.json\"; first=0\n");
|
||||
s.push_str(" printf '{\"name\":\"%s\",\"target\":\"%s\",\"bytes\":%s,\"sha256\":\"%s\",\"zst\":\"%s\",\"zst_bytes\":%s,\"zst_sha256\":\"%s\"}' \"$name\" \"$target\" \"$bytes\" \"$sum\" \"$z\" \"$zbytes\" \"$zsum\" >> \"$PACK/build-outputs.json\"\n");
|
||||
s.push_str(" echo \"RESULT output $target $name $bytes bytes sha256 $sum zst $z $zbytes bytes sha256 $zsum\"\n");
|
||||
s.push_str(" n=$((n+1))\n");
|
||||
s.push_str(" done\n");
|
||||
s.push_str("done\n");
|
||||
s.push_str("printf ']}\\n' >> \"$PACK/build-outputs.json\"\n");
|
||||
s.push_str("[ $n -gt 0 ] || { echo \"RESULT pack nothing to pack\"; exit 3; }\n");
|
||||
s.push_str("rm -f \"$DEST\"/*.zst \"$DEST\"/build-outputs.json; cp -f \"$PACK\"/* \"$DEST\"/ || { echo \"RESULT pack cannot copy to $DEST\"; exit 2; }\n");
|
||||
s.push_str("rm -rf \"$OUT/linux\" \"$OUT/windows\" \"$B/inputs.zip\"\n");
|
||||
s.push_str("echo \"RESULT pack ok $n files, $(du -sh \"$PACK\" | cut -f1), target dir $(du -sh \"$CARGO_TARGET_DIR\" 2>/dev/null | cut -f1) at $(now)\"\n");
|
||||
s.push_str("echo \"STAGE pack done $(now) exit 0\"\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The free-space probe inside the distro: GB available where the build lives (the ext4 vhdx; df reports its
|
||||
/// virtual size, so the Windows drive is checked too by the runner).
|
||||
pub fn free_gb_script() -> &'static str {
|
||||
"mkdir -p /root/igneum-build; df -BG --output=avail /root/igneum-build | tail -1 | tr -dc '0-9'"
|
||||
}
|
||||
|
||||
/// What to run inside the distro after a cap ends wsl.exe: the Linux side outlives it.
|
||||
pub fn kill_script() -> &'static str {
|
||||
"pkill -f 'cargo build' ; pkill -f 'cargo test' ; pkill -x rustc ; pkill -x cc1plus ; pkill -x zstd ; true"
|
||||
}
|
||||
|
||||
// ---- parsers for what comes back ------------------------------------------------------------------------------------
|
||||
|
||||
/// The first whole number in a probe's output ("123" from "123\n", or from " 123G").
|
||||
pub fn parse_free_gb(s: &str) -> Option<u64> {
|
||||
let digits: String = s.chars().skip_while(|c| !c.is_ascii_digit()).take_while(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Output {
|
||||
pub name: String,
|
||||
pub target: String,
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
pub zst: String,
|
||||
pub zst_bytes: u64,
|
||||
pub zst_sha256: String,
|
||||
}
|
||||
|
||||
/// build-outputs.json from the pack stage.
|
||||
pub fn parse_outputs(text: &str) -> Result<Vec<Output>, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("{OUTPUTS_FILE} is not JSON: {e}"))?;
|
||||
let files = v.get("files").and_then(|f| f.as_array()).ok_or(format!("{OUTPUTS_FILE} has no files list"))?;
|
||||
let mut out = Vec::new();
|
||||
for f in files {
|
||||
let s = |k: &str| f.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let n = |k: &str| f.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
let o = Output { name: s("name"), target: s("target"), bytes: n("bytes"), sha256: s("sha256"), zst: s("zst"), zst_bytes: n("zst_bytes"), zst_sha256: s("zst_sha256") };
|
||||
if o.name.is_empty() || o.zst.is_empty() || o.sha256.len() != 64 || o.zst_sha256.len() != 64 {
|
||||
return Err(format!("{OUTPUTS_FILE}: entry {:?} is incomplete", o.name));
|
||||
}
|
||||
out.push(o);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A string field of a JSON reply (the relay's `token`, `put_url`, `api_version`, the Blob PUT's `url`).
|
||||
pub fn json_str(text: &str, key: &str) -> Option<String> {
|
||||
let v: Value = serde_json::from_str(text.trim()).ok()?;
|
||||
v.get(key).and_then(|x| match x { Value::String(s) => Some(s.clone()), Value::Number(n) => Some(n.to_string()), _ => None })
|
||||
}
|
||||
|
||||
/// The relay's item id from a `drop` reply.
|
||||
pub fn json_u64(text: &str, key: &str) -> Option<u64> {
|
||||
let v: Value = serde_json::from_str(text.trim()).ok()?;
|
||||
v.get(key).and_then(|x| x.as_u64())
|
||||
}
|
||||
|
||||
/// The title of a relay item that carries one output, which tools/build-job.mjs searches for.
|
||||
pub fn upload_title(job_id: &str, file: &str) -> String {
|
||||
format!("build-job {job_id} {file}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
const MANIFEST: &str = r#"{"created_at":"2026-10-04T20:00:00Z","node":{"branch":"devnet-v4","commit":"3bfe346f","dirty":false,"source":"vendor/igneum-node-v4"},"repo":{"commit":"0f44edd","branch":"build-job","dirty":true},"app_version":"0.3.4",
|
||||
"builds":[{"dir":"node","packages":["kaspad","igneum-miner"],"features":["kaspad/igneum-pow"],"bins":["igneumd","igneum-miner"],"targets":["linux","windows"]},
|
||||
{"dir":"app/igneum-app","packages":["igneum-app"],"bins":["igneum-app"],"optional_on":["linux"]}],
|
||||
"tests":[{"dir":"app/igneum-app","packages":["igneum-app"]},{"dir":"node","packages":["igneum-miner"]},{"dir":"node","packages":[]}]}"#;
|
||||
|
||||
fn job(params: Value) -> Job {
|
||||
let text = json!({"jobs":[{"id":"build-20261004-200000","kind":"build","expires_at":"2026-10-06T15:00:00Z","target":{"machine_ids":["ae432dc7"],"platform":"windows","requires":["wsl"]},"params":params}]}).to_string();
|
||||
jobs::parse(&text).unwrap().jobs.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_defaults_and_overrides() {
|
||||
let base = json!({"zip_url":"https://dl.igneum.network/dl/t/build-inputs.zip","sha256":"a".repeat(64),"size":1});
|
||||
let p = BuildParams::from_job(&job(base.clone()));
|
||||
assert_eq!(p.targets, vec!["linux", "windows"]);
|
||||
assert_eq!(p.budget_min, 40);
|
||||
assert_eq!(p.min_free_gb, 20);
|
||||
assert!(p.tests);
|
||||
assert_eq!(p.relay_url, RELAY_URL_DEFAULT);
|
||||
assert_eq!((p.distro.as_str(), p.wsl_user.as_str(), p.nice, p.cargo_jobs), ("Ubuntu-24.04", "root", 19, 0));
|
||||
assert!(p.stage_min.is_empty());
|
||||
let mut o = base.as_object().unwrap().clone();
|
||||
o.insert("targets".into(), json!(["windows", "windows"]));
|
||||
o.insert("budget_minutes".into(), json!(120));
|
||||
o.insert("stage_minutes".into(), json!({"linux": 30, "upload": 5}));
|
||||
o.insert("min_free_gb".into(), json!(30));
|
||||
o.insert("tests".into(), json!(false));
|
||||
o.insert("relay_url".into(), json!("https://relay.example/"));
|
||||
o.insert("nice".into(), json!(5));
|
||||
o.insert("cargo_jobs".into(), json!(8));
|
||||
let p = BuildParams::from_job(&job(Value::Object(o)));
|
||||
assert_eq!(p.targets, vec!["windows"]);
|
||||
assert!(p.wants("windows") && !p.wants("linux"));
|
||||
assert_eq!(p.budget_min, 120);
|
||||
assert_eq!(p.stage_min.get("linux"), Some(&30));
|
||||
assert_eq!(p.min_free_gb, 30);
|
||||
assert!(!p.tests);
|
||||
assert_eq!(p.relay_url, "https://relay.example");
|
||||
assert_eq!((p.nice, p.cargo_jobs), (5, 8));
|
||||
// the cap: a stage's own minutes, never over what is left of the budget
|
||||
assert_eq!(stage_cap(&p, "linux", Duration::from_secs(3600)), Duration::from_secs(1800));
|
||||
assert_eq!(stage_cap(&p, "linux", Duration::from_secs(600)), Duration::from_secs(600));
|
||||
assert_eq!(stage_cap(&p, "windows", Duration::from_secs(600)), Duration::from_secs(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_parses_and_refuses_shell_unsafe_names() {
|
||||
let m = parse_manifest(MANIFEST).unwrap();
|
||||
assert_eq!((m.node_branch.as_str(), m.node_commit.as_str(), m.app_version.as_str(), m.node_dirty), ("devnet-v4", "3bfe346f", "0.3.4", false));
|
||||
assert_eq!(m.builds.len(), 2);
|
||||
assert_eq!(m.builds[0].features, vec!["kaspad/igneum-pow"]);
|
||||
assert_eq!(m.builds[1].targets, vec!["linux", "windows"]); // default when absent
|
||||
assert_eq!(m.builds[1].optional_on, vec!["linux"]);
|
||||
assert_eq!(m.tests.len(), 2); // the empty one is dropped
|
||||
assert!(parse_manifest("nope").unwrap_err().contains("JSON"));
|
||||
assert!(parse_manifest(r#"{"builds":[]}"#).unwrap_err().contains("nothing"));
|
||||
assert!(parse_manifest(&MANIFEST.replace("\"dir\":\"node\",\"packages\":[\"kaspad\"", "\"dir\":\"../node\",\"packages\":[\"kaspad\"")).unwrap_err().contains("plain"));
|
||||
assert!(parse_manifest(&MANIFEST.replace("\"kaspad\"", "\"kaspad; rm -rf /\"")).unwrap_err().contains("plain name"));
|
||||
assert!(parse_manifest(&MANIFEST.replace("\"bins\":[\"igneum-app\"]", "\"bins\":[]")).unwrap_err().contains("bins"));
|
||||
assert!(parse_manifest(&MANIFEST.replace("\"linux\",\"windows\"", "\"linux\",\"amiga\"")).unwrap_err().contains("amiga"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_scripts_carry_the_plan() {
|
||||
let p = BuildParams::from_job(&job(json!({"zip_url":"https://x/a.zip","sha256":"b".repeat(64),"cargo_jobs":6,"nice":10})));
|
||||
let m = parse_manifest(MANIFEST).unwrap();
|
||||
let id = "build-20261004-200000";
|
||||
let pre = prelude(&p, id);
|
||||
assert!(pre.contains("B=/root/igneum-build\n") && pre.contains("export CARGO_TARGET_DIR=$B/target"));
|
||||
assert!(pre.contains("OUT=$B/out/build-20261004-200000"));
|
||||
assert!(pre.contains("NICE=\"nice -n 10\"") && pre.contains("JOBS=\"-j 6\""));
|
||||
assert!(!pre.contains('\r'));
|
||||
let setup = setup_script(&p, id);
|
||||
assert!(setup.contains("gcc-mingw-w64-x86-64") && setup.contains("protobuf-compiler") && setup.contains("rustup target add x86_64-pc-windows-gnu"));
|
||||
let only_linux = BuildParams { targets: vec!["linux".into()], ..p.clone() };
|
||||
assert!(!setup_script(&only_linux, id).contains("mingw"));
|
||||
let lin = build_script(&p, id, &m, "linux");
|
||||
assert!(lin.contains("cargo build --release $JOBS -p kaspad -p igneum-miner --features kaspad/igneum-pow 2>&1"));
|
||||
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"));
|
||||
assert!(win.contains("x86_64-pc-windows-gnu/release/igneumd.exe") && win.contains("igneum-app.exe"));
|
||||
assert!(!win.contains("optional on windows"));
|
||||
let t = test_script(&p, id, &m);
|
||||
assert!(t.contains("cargo test --release $JOBS -p igneum-app 2>&1") && t.contains("-p igneum-miner"));
|
||||
let pk = pack_script(&p, id, &m, "/mnt/c/Users/Admin/AppData/Local/igneum/app/jobs/build-20261004-200000/out");
|
||||
assert!(pk.contains("zstd -q -f -T0 -12") && pk.contains("DEST='/mnt/c/Users/Admin/AppData/Local/igneum/app/jobs/build-20261004-200000/out'"));
|
||||
assert!(pk.contains("\"node_commit\":\"%s\"") && pk.contains("'3bfe346f'"));
|
||||
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]
|
||||
fn parsers() {
|
||||
assert_eq!(parse_free_gb("123\n"), Some(123));
|
||||
assert_eq!(parse_free_gb(" 57G\r\n"), Some(57));
|
||||
assert_eq!(parse_free_gb("Avail\n0\n"), Some(0));
|
||||
assert_eq!(parse_free_gb("no disk"), None);
|
||||
let text = r#"{"job":"b","packed_at":"2026-10-04T20:30:00Z","files":[{"name":"igneumd","target":"linux","bytes":46883304,"sha256":"a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4","zst":"igneumd.linux.zst","zst_bytes":15000000,"zst_sha256":"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}]}"#;
|
||||
let o = parse_outputs(text).unwrap();
|
||||
assert_eq!(o.len(), 1);
|
||||
assert_eq!((o[0].name.as_str(), o[0].target.as_str(), o[0].bytes, o[0].zst.as_str(), o[0].zst_bytes), ("igneumd", "linux", 46883304, "igneumd.linux.zst", 15000000));
|
||||
assert!(parse_outputs(&text.replace("igneumd.linux.zst", "")).unwrap_err().contains("incomplete"));
|
||||
assert!(parse_outputs("{}").unwrap_err().contains("files"));
|
||||
assert_eq!(json_str(r#"{"ok":true,"token":"t.x","put_url":"https://vercel.com/api/blob/?pathname=a","api_version":"11"}"#, "put_url"), Some("https://vercel.com/api/blob/?pathname=a".into()));
|
||||
assert_eq!(json_str(r#"{"api_version":11}"#, "api_version"), Some("11".into()));
|
||||
assert_eq!(json_str("garbage", "url"), None);
|
||||
assert_eq!(json_u64(r#"{"ok":true,"id":4711,"kind":"file"}"#, "id"), Some(4711));
|
||||
assert_eq!(upload_title("build-1", "igneumd.exe.zst"), "build-job build-1 igneumd.exe.zst");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,27 @@
|
|||
//! The remote-job runner (the model is src/jobs.rs). Driven from the engine's tick like the updater:
|
||||
//! poll (40 s after start, then every 10 minutes): fetch igneum-jobs.json and its .sig from the folder of the
|
||||
//! update manifest, verify with the OTA public key, parse, keep the jobs this machine has not run that target it
|
||||
//! poll (40 s after start, then every 2 minutes) and, since 0.3.6, a wake: one thread long-polls the relay's public
|
||||
//! /wake and the engine fetches the moment publish-jobs.sh records a new jobs-file stamp (seconds, not minutes;
|
||||
//! backoff 5, 15, 60 s while the relay is unreachable, the 2-minute poll carries on). The fetch: the signed
|
||||
//! envelope igneum-jobs.signed.json (0.3.9; the pair igneum-jobs.json + .sig when no envelope is published) from
|
||||
//! the folder of the update manifest, verify with the OTA public key, parse, keep the jobs this machine has not run that target it
|
||||
//! (machine id, platform, requirements probed here: wsl, wsl-prover, nvidia)
|
||||
//! -> run them one at a time, in file order; each id at most once (jobs-state.json, written before the run)
|
||||
//! -> kinds: run (a script, optionally elevated, optionally with the miners stopped first), fetch (a file by
|
||||
//! https and sha256 into the app data dir), collect (files by glob or a command's output to the log intake),
|
||||
//! restart (miners, node or the app), update-now (the updater checks and installs at once), shard-benchmark
|
||||
//! (miners stopped, GPU idle, the prove package fetched, prove-shard.sh inside WSL under a time cap, every
|
||||
//! RESULT and STAGE line and the results/*.json to the intake, miners back)
|
||||
//! RESULT and STAGE line and the results/*.json to the intake, miners back), build (the node and the app
|
||||
//! engine for Linux and Windows inside WSL2 as root, mining untouched, outputs zstd-compressed to the relay;
|
||||
//! the plan and the stage scripts are src/jobbuild.rs)
|
||||
//! -> every job reports to the log intake as run_id job-<id>-<machine id8>: the first line is a JSON summary
|
||||
//! (SUMMARY {...}), then the captured output; long jobs report every 5 minutes while they run
|
||||
//! -> the dashboard shows the running job (strip) and the history (Settings), and the switch "Allow remote jobs
|
||||
//! from Igneum (signed)" with the key fingerprint; off aborts the running job and stops polling
|
||||
//! Environment (tests): IGNEUM_APP_JOBS_URL overrides the jobs file URL, IGNEUM_APP_JOBS_CHECK_SECS the interval,
|
||||
//! IGNEUM_APP_JOBS_FIRST_SECS the first delay.
|
||||
//! IGNEUM_APP_JOBS_FIRST_SECS the first delay, IGNEUM_APP_JOBS_WAKE_URL the wake endpoint (set and empty: no waker).
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use crate::jobbuild as jb;
|
||||
use crate::jobs::{self, Eligibility, Job, Ledger};
|
||||
use crate::manifest;
|
||||
use serde_json::{json, Value};
|
||||
|
|
@ -26,8 +32,19 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const CHECK_EVERY_S: u64 = 600;
|
||||
/// The safety-net poll. Before 0.3.6 this was 600 s and a published job waited up to 10 minutes on every PC (the project lead,
|
||||
/// 5 October 2026: "why is it taking so long for pc2 and pc1s tasks to spin up?"); the wake below makes it seconds.
|
||||
const CHECK_EVERY_S: u64 = 120;
|
||||
const RETRY_AFTER_ERROR_S: u64 = 300;
|
||||
/// The wake endpoint (relay/api/wake.mjs): a public, rate-limited long-poll that answers the moment publish-jobs.sh
|
||||
/// records a new jobs-file stamp. No token: the apps hold none. IGNEUM_APP_JOBS_WAKE_URL overrides it.
|
||||
const WAKE_URL: &str = "https://relay.igneum.network/wake";
|
||||
/// The relay holds a request this long (its function is capped at 60 s); curl's max-time sits 13 s above it.
|
||||
const WAKE_HOLD_S: u64 = 45;
|
||||
/// Two wake requests are never closer than this (the relay allows 30 a minute per address, and both PCs share one).
|
||||
const WAKE_FLOOR_S: u64 = 10;
|
||||
/// Waits after the 1st, 2nd and every later failed wake request.
|
||||
const WAKE_BACKOFF_S: [u64; 3] = [5, 15, 60];
|
||||
const PROGRESS_REPORT_EVERY_S: u64 = 300;
|
||||
const GPU_IDLE_PCT: f64 = 5.0;
|
||||
const GPU_IDLE_WAIT_S: u64 = 180;
|
||||
|
|
@ -40,6 +57,8 @@ const HISTORY_SHOWN: usize = 20;
|
|||
|
||||
pub enum Event {
|
||||
Fetched(Result<Fetched, String>),
|
||||
/// the relay's stamp moved (the waker thread): fetch the jobs file now
|
||||
Wake(String),
|
||||
Progress { id: String, stage: String },
|
||||
Finished { id: String, outcome: Outcome },
|
||||
}
|
||||
|
|
@ -109,6 +128,11 @@ pub struct Jobs {
|
|||
active: Option<Active>,
|
||||
needs_logged: std::collections::HashSet<String>,
|
||||
fingerprint: String,
|
||||
wake: Arc<WakeCtl>,
|
||||
/// a wake arrived while a fetch was in flight: fetch again as soon as it ends
|
||||
wake_pending: bool,
|
||||
/// the published_at of the last fetched file: an unchanged file is not logged every 2 minutes
|
||||
last_published: String,
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
|
|
@ -142,8 +166,18 @@ impl Jobs {
|
|||
active: None,
|
||||
needs_logged: std::collections::HashSet::new(),
|
||||
fingerprint: manifest::fingerprint(manifest::OTA_PUBLIC_KEY_HEX),
|
||||
wake: Arc::new(WakeCtl { on: AtomicBool::new(allowed) }),
|
||||
wake_pending: false,
|
||||
last_published: String::new(),
|
||||
};
|
||||
j.publish(shared);
|
||||
if !j.url.is_empty() {
|
||||
let wake_url = wake_url_of(std::env::var("IGNEUM_APP_JOBS_WAKE_URL").ok());
|
||||
if !wake_url.is_empty() {
|
||||
let (sh, ctl) = (shared.clone(), j.wake.clone());
|
||||
std::thread::spawn(move || wake_loop(sh, wake_url, ctl));
|
||||
}
|
||||
}
|
||||
let sh = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let ctx = account_context();
|
||||
|
|
@ -205,6 +239,7 @@ impl Jobs {
|
|||
|
||||
pub fn set_allowed(&mut self, shared: &Arc<Shared>, on: bool) {
|
||||
self.allowed = on;
|
||||
self.wake.on.store(on, Ordering::Relaxed);
|
||||
{
|
||||
let mut s = shared.settings.lock().unwrap();
|
||||
s.remote_jobs = on;
|
||||
|
|
@ -266,6 +301,7 @@ impl Jobs {
|
|||
fn start_fetch(&mut self, shared: &Arc<Shared>) {
|
||||
let every = std::env::var("IGNEUM_APP_JOBS_CHECK_SECS").ok().and_then(|v| v.parse().ok()).unwrap_or(CHECK_EVERY_S);
|
||||
self.next_check = Instant::now() + Duration::from_secs(every);
|
||||
self.wake_pending = false;
|
||||
if self.url.is_empty() {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
st.jobs.error = "no jobs URL in this build (no update manifest configured)".into();
|
||||
|
|
@ -278,7 +314,10 @@ impl Jobs {
|
|||
let machine_id = shared.runtime.machine_id.clone();
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = fetch_jobs(&url, &dir).map(|f| {
|
||||
let r = fetch_jobs(&url, &dir).map(|(f, skipped)| {
|
||||
for id in &skipped {
|
||||
shared2.log(&format!("job {id}: its kind is unknown to this version ({}); skipped, it waits for an app update", crate::engine::VERSION));
|
||||
}
|
||||
let now = crate::platform::unix_now();
|
||||
let platform = manifest::platform_name();
|
||||
let probes: Mutex<std::collections::HashMap<String, bool>> = Mutex::new(std::collections::HashMap::new());
|
||||
|
|
@ -350,15 +389,32 @@ impl Jobs {
|
|||
added += 1;
|
||||
}
|
||||
}
|
||||
shared.log(&format!("jobs: file of {} (published {}): {} new for this machine, {} queued, {} waiting on requirements", f.total, f.published_at, added, self.queue.len(), f.pending.len()));
|
||||
// polled every 2 minutes since 0.3.6: the line is for a changed file or new work, not every poll
|
||||
if added > 0 || f.published_at != self.last_published {
|
||||
shared.log(&format!("jobs: file of {} (published {}): {} new for this machine, {} queued, {} waiting on requirements", f.total, f.published_at, added, self.queue.len(), f.pending.len()));
|
||||
}
|
||||
self.last_published = f.published_at.clone();
|
||||
if added > 0 {
|
||||
shared.event("info", &format!("{added} remote job{} received from Igneum", if added == 1 { "" } else { "s" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.wake_pending {
|
||||
// the file moved while this fetch ran: the next tick fetches again, whatever the retry delay
|
||||
self.wake_pending = false;
|
||||
self.next_check = Instant::now();
|
||||
}
|
||||
self.publish(shared);
|
||||
None
|
||||
}
|
||||
Event::Wake(_stamp) => {
|
||||
if self.busy {
|
||||
self.wake_pending = true;
|
||||
} else {
|
||||
self.next_check = Instant::now();
|
||||
}
|
||||
None
|
||||
}
|
||||
Event::Progress { id, stage } => {
|
||||
if self.active.as_ref().map(|a| a.job.id == id).unwrap_or(false) {
|
||||
shared.state.lock().unwrap().jobs.stage = stage;
|
||||
|
|
@ -469,6 +525,7 @@ impl Jobs {
|
|||
"fetch" => run_fetch(&job, &sink, &dir, &data_root, &shared2.runtime.app_dir, &ctl),
|
||||
"collect" => run_collect(&shared2, &job, &sink, &data_root, &ctl),
|
||||
"shard-benchmark" => run_shard_benchmark(&shared2, &job, &sink, &data_root, &jobs_url, &ctl, started),
|
||||
"build" => run_build(&shared2, &job, &sink, &dir, &ctl, started),
|
||||
k => Err(format!("kind {k} is not run on this side")),
|
||||
};
|
||||
let finished = crate::platform::unix_now();
|
||||
|
|
@ -581,6 +638,137 @@ fn upload_file(shared: &Arc<Shared>, job: &Job, path: &Path, label_prefix: &str)
|
|||
crate::update::upload_log(&p.log_intake_url, &p.log_intake_key, &label, &machine, &job.run_id(&shared.runtime.machine_id), path, &shared.upload_header())
|
||||
}
|
||||
|
||||
// ---- the wake signal (0.3.6) ----------------------------------------------------------------------------------------
|
||||
// The PCs have no inbound ports and may sit off the LAN (one miner is in the US), so a new jobs file is announced over
|
||||
// an outbound long-poll: GET <WAKE_URL>?since=<stamp> is held up to 45 s by the relay and answered the moment the
|
||||
// stamp recorded by publish-jobs.sh changes. One thread per app; the engine fetches on Event::Wake. The thread never
|
||||
// busy-loops: a reply that came back early is followed by the rest of a 10 s floor, a failing endpoint backs off
|
||||
// 5, 15, then 60 s, and an empty stamp (nothing published yet) waits a full hold.
|
||||
|
||||
/// Shared with the waker thread: it polls only while remote jobs are allowed.
|
||||
pub struct WakeCtl {
|
||||
on: AtomicBool,
|
||||
}
|
||||
|
||||
/// The stamp logic, free of I/O for the tests.
|
||||
#[derive(Default, Debug)]
|
||||
struct WakeState {
|
||||
stamp: String,
|
||||
seeded: bool,
|
||||
failures: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum WakeReply {
|
||||
/// the first stamp seen: remembered, nothing to fetch (the first poll covers the start)
|
||||
Seeded,
|
||||
Same,
|
||||
/// the stamp moved: fetch now
|
||||
Changed { from: String },
|
||||
/// the relay holds no stamp yet
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl WakeState {
|
||||
/// A successful reply. The bool says the endpoint had been failing (one recovery line in the log).
|
||||
fn reply(&mut self, stamp: &str) -> (WakeReply, bool) {
|
||||
let recovered = self.failures > 0;
|
||||
self.failures = 0;
|
||||
if stamp.is_empty() {
|
||||
return (WakeReply::Empty, recovered);
|
||||
}
|
||||
if !self.seeded {
|
||||
self.seeded = true;
|
||||
self.stamp = stamp.to_string();
|
||||
return (WakeReply::Seeded, recovered);
|
||||
}
|
||||
if stamp == self.stamp {
|
||||
return (WakeReply::Same, recovered);
|
||||
}
|
||||
let from = std::mem::replace(&mut self.stamp, stamp.to_string());
|
||||
(WakeReply::Changed { from }, recovered)
|
||||
}
|
||||
|
||||
/// A failed request: how long to wait (5, 15, 60, 60, ... s) and whether this is the first failure of a run.
|
||||
fn failed(&mut self) -> (Duration, bool) {
|
||||
self.failures += 1;
|
||||
let i = (self.failures as usize - 1).min(WAKE_BACKOFF_S.len() - 1);
|
||||
(Duration::from_secs(WAKE_BACKOFF_S[i]), self.failures == 1)
|
||||
}
|
||||
|
||||
/// What to wait after a reply so two requests are never closer than the floor.
|
||||
fn pause_after(elapsed: Duration) -> Duration {
|
||||
Duration::from_secs(WAKE_FLOOR_S).saturating_sub(elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The wake URL: the environment overrides the built-in one; set and empty switches the waker off.
|
||||
fn wake_url_of(env: Option<String>) -> String {
|
||||
match env {
|
||||
None => WAKE_URL.to_string(),
|
||||
Some(u) => u.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wake_query(url: &str, since: &str) -> String {
|
||||
if since.is_empty() {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{url}{}since={since}", if url.contains('?') { '&' } else { '?' })
|
||||
}
|
||||
}
|
||||
|
||||
/// One long-poll. Ok carries the relay's stamp (empty when it holds none).
|
||||
fn wake_request(url: &str, since: &str) -> Result<String, String> {
|
||||
let full = wake_query(url, since);
|
||||
let max_time = (WAKE_HOLD_S + 13).to_string();
|
||||
let (code, out) = run_capture(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", &max_time, &full]), Duration::from_secs(WAKE_HOLD_S + 20));
|
||||
if code != Some(0) {
|
||||
return Err(format!("curl exit {code:?}: {}", short_out(&out)));
|
||||
}
|
||||
let v: Value = serde_json::from_str(out.trim()).map_err(|e| format!("bad reply ({e}): {}", short_out(&out)))?;
|
||||
if v.get("ok").and_then(|b| b.as_bool()) != Some(true) {
|
||||
return Err(format!("relay: {}", short_out(&out)));
|
||||
}
|
||||
Ok(v.get("stamp").and_then(|s| s.as_str()).unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
fn wake_loop(shared: Arc<Shared>, url: String, ctl: Arc<WakeCtl>) {
|
||||
let mut st = WakeState::default();
|
||||
loop {
|
||||
if !ctl.on.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
continue;
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
match wake_request(&url, &st.stamp) {
|
||||
Ok(stamp) => {
|
||||
let (r, recovered) = st.reply(&stamp);
|
||||
if recovered {
|
||||
shared.log("wake: the relay answers again; a new jobs file is fetched within seconds from here");
|
||||
}
|
||||
match r {
|
||||
WakeReply::Seeded => shared.log(&format!("wake: listening at {url} (jobs stamp {stamp}); a new jobs file is fetched within seconds")),
|
||||
WakeReply::Changed { from } => {
|
||||
shared.log(&format!("wake: jobs stamp {from} -> {stamp}; fetching the jobs file now"));
|
||||
shared.send(Cmd::Job(Event::Wake(stamp)));
|
||||
}
|
||||
WakeReply::Same => {}
|
||||
WakeReply::Empty => std::thread::sleep(Duration::from_secs(WAKE_HOLD_S)),
|
||||
}
|
||||
std::thread::sleep(WakeState::pause_after(t0.elapsed()));
|
||||
}
|
||||
Err(e) => {
|
||||
let (wait, first) = st.failed();
|
||||
if first {
|
||||
shared.log(&format!("wake: {url} unreachable ({e}); the {}-minute poll carries on; retrying in {} s, then {} s, then every {} s", CHECK_EVERY_S / 60, WAKE_BACKOFF_S[0], WAKE_BACKOFF_S[1], WAKE_BACKOFF_S[2]));
|
||||
}
|
||||
std::thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- fetching the jobs file and probing requirements -----------------------------------------------------------------
|
||||
|
||||
fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
|
||||
|
|
@ -591,19 +779,43 @@ fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
|
|||
if code == Some(0) { Ok(()) } else { Err(if t.is_empty() { format!("curl exit {code:?}") } else { t.lines().last().unwrap_or("curl failed").to_string() }) }
|
||||
}
|
||||
|
||||
fn fetch_jobs(url: &str, dir: &Path) -> Result<jobs::JobsFile, String> {
|
||||
/// The jobs file, verified; jobs of a kind this version does not know come back as skipped ids.
|
||||
///
|
||||
/// One request: the signed envelope `igneum-jobs.signed.json` (jobs.rs, 0.3.9) carries the file and its
|
||||
/// signature together, so the two can never come from two deployments (5 October 2026, 13:19:41Z: this function
|
||||
/// fetched `igneum-jobs.json` and then `.sig` while a deploy was landing on the edge and refused the pair). When
|
||||
/// the folder has no envelope (a publisher before 0.3.9), the pair is fetched as before. `Cache-Control: no-cache`
|
||||
/// asks the edge for the current object, as the Mac's own verify does.
|
||||
fn fetch_jobs(url: &str, dir: &Path) -> Result<(jobs::JobsFile, Vec<String>), String> {
|
||||
let jf = dir.join("jobs.json.new");
|
||||
let sf = dir.join("jobs.json.sig.new");
|
||||
let _ = std::fs::remove_file(&jf);
|
||||
let _ = std::fs::remove_file(&sf);
|
||||
curl(&["-fsSL", "--max-time", "20", "-o", &jf.display().to_string(), url], Duration::from_secs(25)).map_err(|e| if e.contains("404") { "no jobs file published".to_string() } else { format!("jobs file: {e}") })?;
|
||||
curl(&["-fsSL", "--max-time", "20", "-o", &sf.display().to_string(), &format!("{url}.sig")], Duration::from_secs(25)).map_err(|e| format!("jobs signature: {e}"))?;
|
||||
let bytes = std::fs::read(&jf).map_err(|e| e.to_string())?;
|
||||
let sig = std::fs::read_to_string(&sf).map_err(|e| e.to_string())?;
|
||||
let f = jobs::verify_and_parse(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?;
|
||||
let ef = dir.join("jobs.signed.json.new");
|
||||
for f in [&jf, &sf, &ef] {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
let signed_url = jobs::signed_jobs_url(url);
|
||||
let (f, skipped) = match curl(&["-fsSL", "--max-time", "20", "-H", "Cache-Control: no-cache", "-o", &ef.display().to_string(), &signed_url], Duration::from_secs(25)) {
|
||||
Ok(()) => {
|
||||
let bytes = std::fs::read(&ef).map_err(|e| e.to_string())?;
|
||||
let (f, skipped, inner) = jobs::verify_and_parse_signed_lenient(&bytes, manifest::OTA_PUBLIC_KEY_HEX)?;
|
||||
let _ = std::fs::write(&jf, &inner);
|
||||
let _ = std::fs::rename(&ef, dir.join("jobs.signed.json"));
|
||||
(f, skipped)
|
||||
}
|
||||
Err(e) if e.contains("404") => {
|
||||
// no envelope published: the pair, two requests (a publisher before 0.3.9)
|
||||
curl(&["-fsSL", "--max-time", "20", "-H", "Cache-Control: no-cache", "-o", &jf.display().to_string(), url], Duration::from_secs(25)).map_err(|e| if e.contains("404") { "no jobs file published".to_string() } else { format!("jobs file: {e}") })?;
|
||||
curl(&["-fsSL", "--max-time", "20", "-H", "Cache-Control: no-cache", "-o", &sf.display().to_string(), &format!("{url}.sig")], Duration::from_secs(25)).map_err(|e| format!("jobs signature: {e}"))?;
|
||||
let bytes = std::fs::read(&jf).map_err(|e| e.to_string())?;
|
||||
let sig = std::fs::read_to_string(&sf).map_err(|e| e.to_string())?;
|
||||
let r = jobs::verify_and_parse_lenient(&bytes, sig.trim(), manifest::OTA_PUBLIC_KEY_HEX)?;
|
||||
let _ = std::fs::rename(&sf, dir.join("jobs.json.sig"));
|
||||
r
|
||||
}
|
||||
Err(e) => return Err(format!("signed jobs file: {e}")),
|
||||
};
|
||||
let _ = std::fs::rename(&jf, dir.join("jobs.json"));
|
||||
let _ = std::fs::rename(&sf, dir.join("jobs.json.sig"));
|
||||
Ok(f)
|
||||
Ok((f, skipped))
|
||||
}
|
||||
|
||||
/// What the toolchain probe runs inside the distro: the cargo and sp1 paths set by hand (a login shell from a
|
||||
|
|
@ -644,12 +856,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)));
|
||||
|
|
@ -907,13 +1115,11 @@ fn run_script(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, dat
|
|||
.map(|(k, v)| format!("$env:{k} = '{}'\r\n", v.replace('\'', "''")))
|
||||
.collect();
|
||||
let wrapper = dir.join("elevated.ps1");
|
||||
let w = format!("{env_lines}& '{}' *>&1 | Out-File -FilePath '{}' -Encoding utf8\r\nexit $LASTEXITCODE\r\n", script.display().to_string().replace('\'', "''"), out_file.display().to_string().replace('\'', "''"));
|
||||
let w = elevated_wrapper(&env_lines, &script.display().to_string(), &out_file.display().to_string());
|
||||
std::fs::write(&wrapper, [b"\xEF\xBB\xBF".as_slice(), w.as_bytes()].concat()).map_err(|e| e.to_string())?;
|
||||
let _ = std::fs::remove_file(&out_file);
|
||||
let inner = format!("-NoProfile -ExecutionPolicy Bypass -File \"{}\"", wrapper.display());
|
||||
let ps = format!("$p = Start-Process -FilePath powershell.exe -ArgumentList '{}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode", inner.replace('\'', "''"));
|
||||
cmd = Command::new(crate::platform::tool("powershell"));
|
||||
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]);
|
||||
cmd = crate::platform::elevated_command("powershell.exe", &inner);
|
||||
} else if shell == "powershell" {
|
||||
cmd = Command::new(crate::platform::tool("powershell"));
|
||||
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]);
|
||||
|
|
@ -923,10 +1129,17 @@ fn run_script(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, dat
|
|||
}
|
||||
cmd.current_dir(&dir);
|
||||
job_env(&mut cmd, shared, job, &dir, data_root);
|
||||
// the elevated script's output reaches this side through a file: follow it while the script runs, so the
|
||||
// 5-minute progress reports carry its lines (6 October 2026: a 35-minute run that never mined showed only
|
||||
// "script running" until it ended; the lines that said why were in the file the whole time)
|
||||
let follow = if elevated { Some(follow_file(sink, out_file.clone())) } else { None };
|
||||
let ran = run_streamed(&mut cmd, sink, ctl, limit, shared, job, started, "script running")?;
|
||||
if elevated {
|
||||
if let Some(f) = follow {
|
||||
f.stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let seen = f.handle.join().unwrap_or(0);
|
||||
// the tail the follower had not read when the script ended
|
||||
if let Ok(t) = std::fs::read_to_string(&out_file) {
|
||||
for l in t.lines() {
|
||||
for l in t.lines().skip(seen) {
|
||||
sink.line(l);
|
||||
}
|
||||
}
|
||||
|
|
@ -934,9 +1147,60 @@ fn run_script(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, dat
|
|||
finish_ran(ran, "script")
|
||||
}
|
||||
|
||||
/// Follows a file another process writes (the elevated script's output), feeding each new complete line to the
|
||||
/// sink every 2 s until stopped; returns how many lines it delivered, so the caller can hand over the remainder.
|
||||
struct Follow {
|
||||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||||
handle: std::thread::JoinHandle<usize>,
|
||||
}
|
||||
|
||||
fn follow_file(sink: &Sink, path: PathBuf) -> Follow {
|
||||
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let stop2 = stop.clone();
|
||||
let s = Sink { shared: sink.shared.clone(), id: sink.id.clone(), dir: sink.dir.clone(), log_path: sink.log_path.clone(), file: Mutex::new(std::fs::OpenOptions::new().append(true).open(&sink.log_path).ok()), results: Mutex::new(Vec::new()) };
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut seen = 0usize;
|
||||
loop {
|
||||
if let Ok(t) = std::fs::read_to_string(&path) {
|
||||
let lines: Vec<&str> = t.lines().collect();
|
||||
// only complete lines (the writer may be mid-line): keep the last one for the next pass unless the
|
||||
// text ends with a newline
|
||||
let complete = if t.ends_with('\n') { lines.len() } else { lines.len().saturating_sub(1) };
|
||||
for l in lines.iter().take(complete).skip(seen) {
|
||||
s.line(l);
|
||||
}
|
||||
seen = seen.max(complete);
|
||||
}
|
||||
if stop2.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
break seen;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
});
|
||||
Follow { stop, handle }
|
||||
}
|
||||
|
||||
/// The PowerShell wrapper an elevated job runs (its own process, its own environment): the IGNEUM_* values, then one
|
||||
/// line about its console (the elevated process cannot inherit the engine's headless console and gets one of its own;
|
||||
/// `-WindowStyle Hidden` on the launch keeps it hidden, and this line is the running measurement of that on every
|
||||
/// elevated job: "elevated console: hwnd N visible False"), then the script, everything into `out_file` for the engine
|
||||
/// to read back. The console-window class, PC 1, 5 October 2026 (tools/windows/console-watch-elevated.ps1).
|
||||
fn elevated_wrapper(env_lines: &str, script: &str, out_file: &str) -> String {
|
||||
let (script, out) = (crate::platform::ps_quote(script), crate::platform::ps_quote(out_file));
|
||||
format!(
|
||||
"{env_lines}$ErrorActionPreference = 'Continue'\r\n\
|
||||
$igc = ''\r\n\
|
||||
try {{ Add-Type -Name IgCon -Namespace Igneum -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern System.IntPtr GetConsoleWindow(); [DllImport(\"user32.dll\")] public static extern bool IsWindowVisible(System.IntPtr h);'; $h = [Igneum.IgCon]::GetConsoleWindow(); $igc = \"elevated console: hwnd $h visible $([Igneum.IgCon]::IsWindowVisible($h))\" }} catch {{ $igc = \"elevated console: unknown ($_)\" }}\r\n\
|
||||
$igc | Out-File -FilePath '{out}' -Encoding utf8\r\n\
|
||||
& '{script}' *>&1 | Out-File -FilePath '{out}' -Encoding utf8 -Append\r\n\
|
||||
exit $LASTEXITCODE\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn finish_ran(ran: Ran, what: &str) -> Result<Done, String> {
|
||||
match ran.code {
|
||||
Some(0) => Ok(Done { status: "done".into(), exit: 0, summary: format!("{what} finished, exit 0"), extra: json!({}) }),
|
||||
Some(251) => Ok(Done { status: "failed".into(), exit: 251, summary: format!("{what} did not start: the administrator prompt was refused, cancelled or timed out (click Yes within 2 minutes)"), extra: json!({}) }),
|
||||
Some(c) => Ok(Done { status: "failed".into(), exit: c as i64, summary: format!("{what} exited with code {c}"), extra: json!({}) }),
|
||||
None if ran.timed_out => Ok(Done { status: "timeout".into(), exit: -1, summary: format!("{what} hit the time cap and was ended"), extra: json!({}) }),
|
||||
None => Err(format!("{what} was ended")),
|
||||
|
|
@ -1009,6 +1273,7 @@ fn run_collect(shared: &Arc<Shared>, job: &Job, sink: &Sink, data_root: &Path, c
|
|||
}
|
||||
}
|
||||
let command = job.str_param("command");
|
||||
let mut ran: Option<Ran> = None;
|
||||
if !command.trim().is_empty() {
|
||||
sink.stage(&format!("running: {}", short(&command, 120)));
|
||||
let mut cmd = if cfg!(windows) {
|
||||
|
|
@ -1022,11 +1287,130 @@ fn run_collect(shared: &Arc<Shared>, job: &Job, sink: &Sink, data_root: &Path, c
|
|||
};
|
||||
cmd.current_dir(data_root);
|
||||
job_env(&mut cmd, shared, job, &sink.dir, data_root);
|
||||
let ran = run_streamed(&mut cmd, sink, ctl, Duration::from_secs(600), shared, job, crate::platform::unix_now(), "collect command running")?;
|
||||
sink.line(&format!("command exit {:?}", ran.code));
|
||||
let r = run_streamed(&mut cmd, sink, ctl, Duration::from_secs(600), shared, job, crate::platform::unix_now(), "collect command running")?;
|
||||
sink.line(&format!("command exit {:?}", r.code));
|
||||
ran = Some(r);
|
||||
}
|
||||
Ok(collect_done(uploaded, failed, names, ran))
|
||||
}
|
||||
|
||||
/// The outcome of a collect job from its parts: the upload counts and, when a command ran, how it ended. A command
|
||||
/// that exits non-zero fails the job and its code is the job's exit; one ended by the cap is a timeout; one ended
|
||||
/// otherwise is failed (4 October 2026, collect-pc1-board3: PowerShell could not parse the command, "command exit
|
||||
/// Some(1)" was in the report and the job still said "done (exit 0)" because only the upload count was judged).
|
||||
fn collect_done(uploaded: u32, failed: u32, names: Vec<String>, ran: Option<Ran>) -> Done {
|
||||
let files = format!("{uploaded} file{} uploaded{}", if uploaded == 1 { "" } else { "s" }, if failed > 0 { format!(", {failed} failed") } else { String::new() });
|
||||
let extra = json!({ "uploaded_files": names });
|
||||
let by_files = || (if failed > 0 { "failed" } else { "done" }.to_string(), failed as i64);
|
||||
match ran {
|
||||
None => { let (status, exit) = by_files(); Done { status, exit, summary: files, extra } }
|
||||
Some(Ran { code: Some(0), .. }) => { let (status, exit) = by_files(); Done { status, exit, summary: format!("{files}, command exit 0, output in the report"), extra } }
|
||||
Some(Ran { code: Some(c), .. }) => Done { status: "failed".into(), exit: c as i64, summary: format!("{files}, command exited with code {c}, output in the report"), extra },
|
||||
Some(Ran { code: None, timed_out: true }) => Done { status: "timeout".into(), exit: -1, summary: format!("{files}, command hit the time cap and was ended"), extra },
|
||||
Some(Ran { code: None, timed_out: false }) => Done { status: "failed".into(), exit: -1, summary: format!("{files}, command was ended"), extra },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_refused_administrator_prompt_is_a_failure_not_done() {
|
||||
// 5 October 2026: the elevated launcher exited 0 after Windows cancelled an unanswered UAC prompt
|
||||
let d = finish_ran(Ran { code: Some(251), timed_out: false }, "script").unwrap();
|
||||
assert_eq!((d.status.as_str(), d.exit), ("failed", 251));
|
||||
assert!(d.summary.contains("administrator prompt"), "{}", d.summary);
|
||||
let d = finish_ran(Ran { code: Some(0), timed_out: false }, "script").unwrap();
|
||||
assert_eq!(d.status, "done");
|
||||
// the launcher string itself (platform::elevated_ps_line since 13755b9): a thrown Start-Process must not fall
|
||||
// through to `exit $p.ExitCode`
|
||||
let l = crate::platform::elevated_ps_line("powershell.exe", "-NoProfile -File x.ps1");
|
||||
assert!(l.contains("-Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop } catch {"), "{l}");
|
||||
assert!(l.contains("if ($null -eq $p) { Write-Error 'elevated launch failed: no process'; exit 251 }"), "{l}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevated_wrapper_reports_its_console_then_runs_the_script() {
|
||||
let w = elevated_wrapper("$env:IGNEUM_JOB_ID = 'j1'\r\n", r"C:\jobs\j1\script.ps1", r"C:\jobs\it's\elevated-output.log");
|
||||
assert!(w.starts_with("$env:IGNEUM_JOB_ID = 'j1'\r\n$ErrorActionPreference = 'Continue'\r\n"), "{w}");
|
||||
assert!(w.contains("GetConsoleWindow()") && w.contains("IsWindowVisible("), "{w}");
|
||||
assert!(w.contains("$igc | Out-File -FilePath 'C:\\jobs\\it''s\\elevated-output.log' -Encoding utf8\r\n"), "{w}");
|
||||
assert!(w.contains("& 'C:\\jobs\\j1\\script.ps1' *>&1 | Out-File -FilePath 'C:\\jobs\\it''s\\elevated-output.log' -Encoding utf8 -Append\r\n"), "{w}");
|
||||
assert!(w.ends_with("exit $LASTEXITCODE\r\n"), "{w}");
|
||||
// every line ends in CRLF (the file is written for Windows PowerShell): the env line and six of its own
|
||||
assert_eq!(w.matches("\r\n").count(), 7, "{w:?}");
|
||||
assert_eq!(w.matches('\n').count(), 7, "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_outcome_follows_the_command_exit() {
|
||||
let d = collect_done(0, 0, vec![], None);
|
||||
assert_eq!((d.status.as_str(), d.exit, d.summary.as_str()), ("done", 0, "0 files uploaded"));
|
||||
let d = collect_done(2, 0, vec!["a".into(), "b".into()], Some(Ran { code: Some(0), timed_out: false }));
|
||||
assert_eq!((d.status.as_str(), d.exit), ("done", 0));
|
||||
assert_eq!(d.summary, "2 files uploaded, command exit 0, output in the report");
|
||||
assert_eq!(d.extra["uploaded_files"].as_array().map(|a| a.len()), Some(2));
|
||||
// the board3 case: the command failed, the job must not say done
|
||||
let d = collect_done(0, 0, vec![], Some(Ran { code: Some(1), timed_out: false }));
|
||||
assert_eq!((d.status.as_str(), d.exit), ("failed", 1));
|
||||
assert_eq!(d.summary, "0 files uploaded, command exited with code 1, output in the report");
|
||||
// a failed upload fails the job even when the command was fine
|
||||
let d = collect_done(1, 2, vec!["a".into()], Some(Ran { code: Some(0), timed_out: false }));
|
||||
assert_eq!((d.status.as_str(), d.exit), ("failed", 2));
|
||||
assert!(d.summary.starts_with("1 file uploaded, 2 failed, command exit 0"));
|
||||
let d = collect_done(0, 0, vec![], Some(Ran { code: None, timed_out: true }));
|
||||
assert_eq!((d.status.as_str(), d.exit), ("timeout", -1));
|
||||
let d = collect_done(0, 0, vec![], Some(Ran { code: None, timed_out: false }));
|
||||
assert_eq!((d.status.as_str(), d.exit), ("failed", -1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_state_seeds_then_reports_each_change_once() {
|
||||
let mut w = WakeState::default();
|
||||
assert_eq!(w.reply(""), (WakeReply::Empty, false));
|
||||
assert_eq!(w.reply("S1"), (WakeReply::Seeded, false));
|
||||
assert_eq!(w.reply("S1"), (WakeReply::Same, false));
|
||||
assert_eq!(w.reply("S2"), (WakeReply::Changed { from: "S1".into() }, false));
|
||||
assert_eq!(w.reply("S2"), (WakeReply::Same, false));
|
||||
assert_eq!(w.stamp, "S2");
|
||||
// an empty reply after seeding keeps the last stamp, so the next request still carries it
|
||||
assert_eq!(w.reply(""), (WakeReply::Empty, false));
|
||||
assert_eq!(w.stamp, "S2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_backoff_is_5_15_60_and_stays_there_until_a_reply() {
|
||||
let mut w = WakeState::default();
|
||||
w.reply("S1");
|
||||
assert_eq!(w.failed(), (Duration::from_secs(5), true));
|
||||
assert_eq!(w.failed(), (Duration::from_secs(15), false));
|
||||
assert_eq!(w.failed(), (Duration::from_secs(60), false));
|
||||
assert_eq!(w.failed(), (Duration::from_secs(60), false));
|
||||
// the reply after an outage reports the recovery once and is still compared with the stamp from before it
|
||||
assert_eq!(w.reply("S2"), (WakeReply::Changed { from: "S1".into() }, true));
|
||||
assert_eq!(w.reply("S2"), (WakeReply::Same, false));
|
||||
assert_eq!(w.failed(), (Duration::from_secs(5), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_never_busy_loops() {
|
||||
assert_eq!(WakeState::pause_after(Duration::from_millis(300)), Duration::from_millis(9_700));
|
||||
assert_eq!(WakeState::pause_after(Duration::from_secs(45)), Duration::ZERO);
|
||||
assert!(WAKE_BACKOFF_S.iter().all(|s| *s >= 5));
|
||||
assert!(WAKE_HOLD_S + 13 < 60, "curl's max-time must stay under the relay function's 60 s cap");
|
||||
assert!(CHECK_EVERY_S <= 120, "the safety-net poll is the fallback when the relay is down");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_url_and_query() {
|
||||
assert_eq!(wake_url_of(None), WAKE_URL);
|
||||
assert_eq!(wake_url_of(Some(String::new())), "");
|
||||
assert_eq!(wake_url_of(Some(" http://127.0.0.1:4180/wake ".into())), "http://127.0.0.1:4180/wake");
|
||||
assert_eq!(wake_query("https://r/wake", ""), "https://r/wake");
|
||||
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5"), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
|
||||
assert_eq!(wake_query("https://r/api/wake?x=1", "S"), "https://r/api/wake?x=1&since=S");
|
||||
}
|
||||
let summary = format!("{uploaded} file{} uploaded{}{}", if uploaded == 1 { "" } else { "s" }, if failed > 0 { format!(", {failed} failed") } else { String::new() }, if command.trim().is_empty() { String::new() } else { ", command output in the report".into() });
|
||||
Ok(Done { status: if failed > 0 { "failed".into() } else { "done".into() }, exit: failed as i64, summary, extra: json!({ "uploaded_files": names }) })
|
||||
}
|
||||
|
||||
// ---- kind: shard-benchmark ----------------------------------------------------------------------------------------------
|
||||
|
|
@ -1106,7 +1490,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
|
||||
|
|
@ -1130,8 +1515,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) {
|
||||
|
|
@ -1153,7 +1538,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();
|
||||
|
|
@ -1181,6 +1568,250 @@ fn run_shard_benchmark(shared: &Arc<Shared>, job: &Job, sink: &Sink, data_root:
|
|||
Ok(done)
|
||||
}
|
||||
|
||||
// ---- kind: build ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// One stage inside the distro: the script written to the job folder (LF only), run as
|
||||
/// `wsl -d <distro> -u <user> -- bash /mnt/c/.../stage-<name>.sh` under the stage's cap and the abort flag, every
|
||||
/// line into the sink, STAGE lines with UTC times around it. A cap ends wsl.exe and then the Linux side.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_stage(shared: &Arc<Shared>, job: &Job, sink: &Sink, ctl: &Ctl, p: &jb::BuildParams, dir: &Path, stage: &str, script: &str, started: u64, overall: Instant) -> Result<Ran, String> {
|
||||
let path = dir.join(format!("stage-{stage}.sh"));
|
||||
std::fs::write(&path, script.replace("\r\n", "\n")).map_err(|e| format!("cannot write the {stage} script: {e}"))?;
|
||||
let wsl_path = jobs::to_wsl_path(&path.display().to_string()).ok_or("the job folder has no drive letter; WSL cannot see it")?;
|
||||
let left = overall.saturating_duration_since(Instant::now());
|
||||
if left < Duration::from_secs(30) {
|
||||
sink.line(&format!("STAGE {stage} skipped {}: the budget is used up", jobs::format_time(crate::platform::unix_now())));
|
||||
return Ok(Ran { code: None, timed_out: true });
|
||||
}
|
||||
let cap = jb::stage_cap(p, stage, left);
|
||||
sink.line(&format!("STAGE {stage} start {} cap {} min", jobs::format_time(crate::platform::unix_now()), cap.as_secs() / 60));
|
||||
let t0 = Instant::now();
|
||||
let mut cmd = Command::new(crate::platform::tool("wsl"));
|
||||
cmd.args(["-d", &p.distro, "-u", &p.wsl_user, "--", "bash", &wsl_path]);
|
||||
cmd.current_dir(dir);
|
||||
let ran = run_streamed(&mut cmd, sink, ctl, cap, shared, job, started, &format!("build: {stage}"))?;
|
||||
if ran.code.is_none() {
|
||||
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)
|
||||
}
|
||||
|
||||
/// One output to the relay: fn=upload for a client token (the intake key is allowed for upload and drop; round 4,
|
||||
/// X23 keeps it away from run and task posts), the bytes PUT straight to Vercel Blob, then fn=drop with the Blob
|
||||
/// URL so the file is an item the Mac finds by its title (jobbuild::upload_title). Returns (item id, blob url).
|
||||
fn relay_upload(shared: &Arc<Shared>, p: &jb::BuildParams, path: &Path, title: &str, body: &str) -> Result<(u64, String), String> {
|
||||
let key = shared.packaged.log_intake_key.clone();
|
||||
if key.is_empty() {
|
||||
return Err("this build carries no intake key; nothing can be uploaded".into());
|
||||
}
|
||||
let size = std::fs::metadata(path).map(|m| m.len()).map_err(|e| e.to_string())?;
|
||||
if size > jb::MAX_UPLOAD_BYTES {
|
||||
return Err(format!("{size} bytes is over the relay's {} MB cap", jb::MAX_UPLOAD_BYTES / 1024 / 1024));
|
||||
}
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let api = format!("{}/api/relay?fn=", p.relay_url);
|
||||
let key_header = format!("x-igneum-key: {key}");
|
||||
let post = |fn_name: &str, body: &str, limit: u64| -> Result<String, String> {
|
||||
let (code, out) = run_capture(Command::new(crate::platform::tool("curl")).args(["-sS", "--max-time", &limit.to_string(), "-X", "POST", &format!("{api}{fn_name}"), "-H", "Content-Type: application/json", "-H", &key_header, "--data-binary", body]), Duration::from_secs(limit + 10));
|
||||
if code != Some(0) {
|
||||
return Err(format!("relay {fn_name}: curl exit {code:?}: {}", short_out(&out)));
|
||||
}
|
||||
if out.contains("\"ok\":false") {
|
||||
return Err(format!("relay {fn_name}: {}", short_out(&out)));
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
let t = post("upload", &json!({ "name": name, "size": size }).to_string(), 60)?;
|
||||
let token = jb::json_str(&t, "token").ok_or("relay upload: no token in the reply")?;
|
||||
let put_url = jb::json_str(&t, "put_url").ok_or("relay upload: no put_url in the reply")?;
|
||||
let api_version = jb::json_str(&t, "api_version").unwrap_or_else(|| "11".into());
|
||||
let (code, out) = run_capture(
|
||||
Command::new(crate::platform::tool("curl")).args(["-sS", "--max-time", "900", "-X", "PUT", &put_url, "-H", &format!("authorization: Bearer {token}"), "-H", &format!("x-api-version: {api_version}"), "-H", "x-add-random-suffix: 1", "-H", "x-content-type: application/octet-stream", "--data-binary", &format!("@{}", path.display())]),
|
||||
Duration::from_secs(910),
|
||||
);
|
||||
if code != Some(0) {
|
||||
return Err(format!("blob PUT: curl exit {code:?}: {}", short_out(&out)));
|
||||
}
|
||||
let url = jb::json_str(&out, "url").ok_or_else(|| format!("blob PUT: no url in the reply: {}", short_out(&out)))?;
|
||||
if !url.contains(".public.blob.vercel-storage.com/") {
|
||||
return Err(format!("blob PUT: unexpected url {}", short_out(&url)));
|
||||
}
|
||||
let from = format!("{}-{}", shared.runtime.host, shared.runtime.id8());
|
||||
let d = post("drop", &json!({ "from": from, "to": "mac", "kind": "file", "title": title, "body": body, "file_name": name, "file_url": url, "size": size }).to_string(), 60)?;
|
||||
let id = jb::json_u64(&d, "id").ok_or("relay drop: no id in the reply")?;
|
||||
Ok((id, url))
|
||||
}
|
||||
|
||||
/// `build`: the plan is in src/jobbuild.rs. Here: the free-space check (the Windows drive and the distro), the
|
||||
/// inputs zip (sha256 checked) and its manifest, then setup, extract, the targets, the tests, pack and upload,
|
||||
/// each as a stage under its cap. The miners are never touched. Required failures mark the job failed after every
|
||||
/// stage that can still run has run, so the binaries of a good target reach the Mac even when another failed.
|
||||
fn run_build(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, ctl: &Ctl, started: u64) -> Result<Done, String> {
|
||||
if !cfg!(windows) {
|
||||
return Err("build runs on a Windows PC with WSL2 (the Linux build happens inside the distro)".into());
|
||||
}
|
||||
let p = jb::BuildParams::from_job(job);
|
||||
let dir = jobs_dir.join(&job.id);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let overall = Instant::now() + Duration::from_secs(p.budget_min * 60);
|
||||
let now = || jobs::format_time(crate::platform::unix_now());
|
||||
let wsl = crate::platform::tool("wsl");
|
||||
sink.line(&format!("STAGE plan {} targets {} budget {} min, stage caps {:?}, free floor {} GB, tests {}, nice {}, distro {} as {}, relay {}", now(), p.targets.join("+"), p.budget_min, p.stage_min, p.min_free_gb, p.tests, p.nice, p.distro, p.wsl_user, p.relay_url));
|
||||
sink.line("the miners keep mining: this job stops nothing");
|
||||
let mut stages: serde_json::Map<String, Value> = serde_json::Map::new();
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
// ---- check: free space on the Windows drive (the vhdx grows into it) and inside the distro
|
||||
sink.stage("checking free space");
|
||||
let drive = dir.display().to_string().trim_start_matches("\\\\?\\").chars().next().filter(|c| c.is_ascii_alphabetic()).unwrap_or('C');
|
||||
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 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()) };
|
||||
let Some(lf) = wsl_free else { return Err(format!("{} does not answer the free-space probe; is WSL installed and the distro present?", p.distro)) };
|
||||
if wf < p.min_free_gb || lf < p.min_free_gb {
|
||||
return Err(format!("not enough free space: drive {drive} {wf} GB, distro {lf} GB, floor {} GB", p.min_free_gb));
|
||||
}
|
||||
if ctl.aborted() {
|
||||
return Err("aborted".into());
|
||||
}
|
||||
|
||||
// ---- fetch: the inputs zip and its manifest
|
||||
sink.stage("fetching the build inputs");
|
||||
let t0 = Instant::now();
|
||||
let zip = dir.join("build-inputs.zip");
|
||||
let size = fetch_file(&job.str_param("zip_url"), &zip, &job.str_param("sha256"), job.u64_param("size"), sink)?;
|
||||
let member = format!("{}/manifest.json", jb::ZIP_ROOT);
|
||||
let _ = std::fs::remove_dir_all(dir.join(jb::ZIP_ROOT));
|
||||
let (tc, to) = run_capture(Command::new(crate::platform::tool("tar")).args(["-xf", &zip.display().to_string(), "-C", &dir.display().to_string(), &member]), Duration::from_secs(120));
|
||||
if tc != Some(0) {
|
||||
return Err(format!("cannot read {member} out of the zip: tar exit {tc:?}: {}", short_out(&to)));
|
||||
}
|
||||
let manifest_text = std::fs::read_to_string(dir.join(jb::ZIP_ROOT).join("manifest.json")).map_err(|e| format!("manifest.json: {e}"))?;
|
||||
let m = jb::parse_manifest(&manifest_text)?;
|
||||
sink.line(&format!("RESULT fetch {} {size} bytes sha256 ok, node {} {}{}, app {}, {} build unit{}, {} test unit{}, {} s", now(), m.node_branch, m.node_commit, if m.node_dirty { " (dirty worktree)" } else { "" }, m.app_version, m.builds.len(), if m.builds.len() == 1 { "" } else { "s" }, m.tests.len(), if m.tests.len() == 1 { "" } else { "s" }, t0.elapsed().as_secs()));
|
||||
stages.insert("fetch".into(), json!({ "exit": 0, "secs": t0.elapsed().as_secs() }));
|
||||
|
||||
// ---- the stages inside the distro
|
||||
let record = |name: &str, ran: &Ran, required: bool, stages: &mut serde_json::Map<String, Value>, failures: &mut Vec<String>| {
|
||||
stages.insert(name.into(), json!({ "exit": ran.code, "timeout": ran.timed_out }));
|
||||
let ok = ran.code == Some(0);
|
||||
if !ok && required {
|
||||
failures.push(format!("{name} {}", if ran.timed_out { "hit its cap".to_string() } else { format!("exit {}", ran.code.map(|c| c.to_string()).unwrap_or_else(|| "none".into())) }));
|
||||
}
|
||||
ok
|
||||
};
|
||||
sink.stage("setup inside the distro");
|
||||
let ran = build_stage(shared, job, sink, ctl, &p, &dir, "setup", &jb::setup_script(&p, &job.id), started, overall)?;
|
||||
if !record("setup", &ran, true, &mut stages, &mut failures) {
|
||||
return Err(format!("setup failed: {}", failures.join("; ")));
|
||||
}
|
||||
sink.stage("extracting the sources");
|
||||
let zip_wsl = jobs::to_wsl_path(&zip.display().to_string()).ok_or("the zip path has no drive letter")?;
|
||||
let ran = build_stage(shared, job, sink, ctl, &p, &dir, "extract", &jb::extract_script(&p, &job.id, &zip_wsl), started, overall)?;
|
||||
if !record("extract", &ran, true, &mut stages, &mut failures) {
|
||||
return Err(format!("extract failed: {}", failures.join("; ")));
|
||||
}
|
||||
let mut built_any = false;
|
||||
for target in ["linux", "windows"] {
|
||||
if !p.wants(target) || ctl.aborted() {
|
||||
continue;
|
||||
}
|
||||
sink.stage(&format!("building for {target}"));
|
||||
let ran = build_stage(shared, job, sink, ctl, &p, &dir, target, &jb::build_script(&p, &job.id, &m, target), started, overall)?;
|
||||
if record(target, &ran, true, &mut stages, &mut failures) {
|
||||
built_any = true;
|
||||
}
|
||||
}
|
||||
if p.tests && !m.tests.is_empty() && !ctl.aborted() {
|
||||
sink.stage("running the tests");
|
||||
let ran = build_stage(shared, job, sink, ctl, &p, &dir, "test", &jb::test_script(&p, &job.id, &m), started, overall)?;
|
||||
record("test", &ran, true, &mut stages, &mut failures);
|
||||
}
|
||||
if ctl.aborted() {
|
||||
return Err("aborted".into());
|
||||
}
|
||||
|
||||
// ---- pack and upload what was built
|
||||
let out_dir = dir.join("out");
|
||||
let _ = std::fs::remove_dir_all(&out_dir);
|
||||
let _ = std::fs::create_dir_all(&out_dir);
|
||||
let mut outputs: Vec<Value> = Vec::new();
|
||||
let mut uploaded = 0usize;
|
||||
let mut uploaded_bytes = 0u64;
|
||||
if built_any {
|
||||
sink.stage("packing the binaries");
|
||||
let out_wsl = jobs::to_wsl_path(&out_dir.display().to_string()).ok_or("the out folder has no drive letter")?;
|
||||
let ran = build_stage(shared, job, sink, ctl, &p, &dir, "pack", &jb::pack_script(&p, &job.id, &m, &out_wsl), started, overall)?;
|
||||
if record("pack", &ran, true, &mut stages, &mut failures) {
|
||||
sink.stage("uploading to the relay");
|
||||
let t0 = Instant::now();
|
||||
let listed = std::fs::read_to_string(out_dir.join(jb::OUTPUTS_FILE)).map_err(|e| format!("{}: {e}", jb::OUTPUTS_FILE))?;
|
||||
let files = jb::parse_outputs(&listed)?;
|
||||
let cap = jb::stage_cap(&p, "upload", overall.saturating_duration_since(Instant::now()).max(Duration::from_secs(120)));
|
||||
let deadline = Instant::now() + cap;
|
||||
let mut all_ok = true;
|
||||
for o in &files {
|
||||
if ctl.aborted() {
|
||||
return Err("aborted".into());
|
||||
}
|
||||
let path = out_dir.join(&o.zst);
|
||||
let mut entry = json!({ "name": o.name, "target": o.target, "bytes": o.bytes, "sha256": o.sha256, "zst": o.zst, "zst_bytes": o.zst_bytes, "zst_sha256": o.zst_sha256 });
|
||||
if Instant::now() >= deadline {
|
||||
sink.line(&format!("RESULT upload {} {} skipped: the upload cap is used up", now(), o.zst));
|
||||
all_ok = false;
|
||||
} else {
|
||||
let body = format!("job {}\nnode {} {}\napp {}\n{} {} {} bytes sha256 {}\n{} {} bytes sha256 {}\n", job.id, m.node_branch, m.node_commit, m.app_version, o.target, o.name, o.bytes, o.sha256, o.zst, o.zst_bytes, o.zst_sha256);
|
||||
match relay_upload(shared, &p, &path, &jb::upload_title(&job.id, &o.zst), &body) {
|
||||
Ok((id, url)) => {
|
||||
uploaded += 1;
|
||||
uploaded_bytes += o.zst_bytes;
|
||||
sink.line(&format!("RESULT upload {} {} relay item {id} {} bytes sha256 {} (unpacked {} {} bytes sha256 {})", now(), o.zst, o.zst_bytes, o.zst_sha256, o.name, o.bytes, o.sha256));
|
||||
entry["relay_id"] = json!(id);
|
||||
entry["url"] = json!(url);
|
||||
}
|
||||
Err(e) => {
|
||||
sink.line(&format!("RESULT upload {} {} FAILED: {e}", now(), o.zst));
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
outputs.push(entry);
|
||||
}
|
||||
let list_path = out_dir.join(jb::OUTPUTS_FILE);
|
||||
match relay_upload(shared, &p, &list_path, &jb::upload_title(&job.id, jb::OUTPUTS_FILE), &format!("job {}\nnode {} {}\napp {}\n{} files\n", job.id, m.node_branch, m.node_commit, m.app_version, files.len())) {
|
||||
Ok((id, _)) => sink.line(&format!("RESULT upload {} {} relay item {id}", now(), jb::OUTPUTS_FILE)),
|
||||
Err(e) => {
|
||||
sink.line(&format!("RESULT upload {} {} FAILED: {e}", now(), jb::OUTPUTS_FILE));
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
stages.insert("upload".into(), json!({ "exit": if all_ok { 0 } else { 1 }, "secs": t0.elapsed().as_secs(), "files": uploaded }));
|
||||
if !all_ok {
|
||||
failures.push("upload incomplete".into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sink.line(&format!("RESULT pack {} skipped: no target built", now()));
|
||||
}
|
||||
let _ = std::fs::remove_file(&zip);
|
||||
|
||||
let mins = (crate::platform::unix_now().saturating_sub(started)) / 60;
|
||||
let summary = format!("node {} {} app {}: {}{}; {uploaded} file{} uploaded ({} MB); {mins} min of {}", m.node_branch, m.node_commit, m.app_version, if failures.is_empty() { "every stage ok".to_string() } else { format!("FAILED: {}", failures.join(", ")) }, if built_any { "" } else { "; nothing built" }, if uploaded == 1 { "" } else { "s" }, uploaded_bytes / 1024 / 1024, p.budget_min);
|
||||
sink.line(&format!("RESULT build {} {} {summary}", now(), if failures.is_empty() { "done" } else { "failed" }));
|
||||
sink.line("the miners were never stopped by this job");
|
||||
let extra = json!({ "node_branch": m.node_branch, "node_commit": m.node_commit, "node_dirty": m.node_dirty, "app_version": m.app_version, "targets": p.targets, "stages": stages, "outputs": outputs, "uploaded_files": uploaded, "relay_url": p.relay_url, "failures": failures });
|
||||
let status = if failures.is_empty() { "done" } else if failures.iter().any(|f| f.contains("hit its cap")) { "timeout" } else { "failed" };
|
||||
Ok(Done { status: status.into(), exit: if failures.is_empty() { 0 } else { 1 }, summary, extra })
|
||||
}
|
||||
|
||||
// ---- kind: restart app -----------------------------------------------------------------------------------------------
|
||||
|
||||
/// A detached helper that starts the app again a few seconds after this engine has gone.
|
||||
|
|
@ -1196,6 +1827,7 @@ fn spawn_relaunch_helper(shared: &Arc<Shared>) -> Result<(), String> {
|
|||
{
|
||||
let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?;
|
||||
let exe = dir.join("igneum-app.exe");
|
||||
// console: igneum-app.exe is a windows-subsystem program in release builds (main.rs), it never gets a console; SW_HIDE would hide the window host it opens
|
||||
let ps = format!("Start-Sleep 8; Start-Process -FilePath '{}' -ArgumentList '--launch' -WorkingDirectory '{}'", exe.display().to_string().replace('\'', "''"), dir.display().to_string().replace('\'', "''"));
|
||||
c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", &ps]);
|
||||
|
|
@ -1215,7 +1847,7 @@ fn spawn_relaunch_helper(shared: &Arc<Shared>) -> Result<(), String> {
|
|||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.creation_flags(0x0800_0000 | 0x0000_0008);
|
||||
c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW only, never DETACHED_PROCESS (powershell exits without a console)
|
||||
}
|
||||
shared.log("job: relaunch helper started; the app quits and opens again in about 10 s");
|
||||
c.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
|
|
|
|||
|
|
@ -31,8 +31,16 @@
|
|||
//! update-now no params: the over-the-air check runs and a newer version installs at once
|
||||
//! shard-benchmark zip_url, sha256, size, fixtures [shard fixture, block fixtures...], cap_minutes (90), distro
|
||||
//! (Ubuntu-24.04), wsl_user
|
||||
//! build zip_url, sha256, size (the build-inputs zip from packaging/windows/push-build-inputs.sh), targets
|
||||
//! ["linux", "windows"], budget_minutes (40 in total), stage_minutes {setup, fetch, linux, windows,
|
||||
//! test, pack, upload}, min_free_gb (20), tests (true), relay_url (https, where the outputs go),
|
||||
//! distro, wsl_user, nice (19). Mining is never stopped; the build is CPU work inside WSL (src/jobbuild.rs).
|
||||
//! A job never writes outside the app data directory except through an explicit `run` script, which is the
|
||||
//! operator's responsibility.
|
||||
//!
|
||||
//! Unknown kinds: the signer refuses them (`parse`), so a typo never ships; the app skips them (`parse_lenient`) so
|
||||
//! a jobs file that carries a kind this version does not know still runs the kinds it does (0.3.3 and earlier reject
|
||||
//! the whole file, which is why a new kind goes to the PCs in an app update before its first job is published).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
|
|
@ -42,7 +50,16 @@ use std::collections::BTreeMap;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const JOBS_FILE: &str = "igneum-jobs.json";
|
||||
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark"];
|
||||
/// The signed envelope (0.3.9): ONE file that carries the jobs file and its signature together, so an app never
|
||||
/// pairs a file with a signature from another deployment. 5 October 2026, 13:19:41Z: PC 2's 0.3.7 app fetched
|
||||
/// `igneum-jobs.json` and then `igneum-jobs.json.sig` in two requests while a deploy was landing on the edge, got
|
||||
/// a pair that did not belong together and logged "jobs file signature does not verify"; the identical bytes
|
||||
/// re-signed verified four minutes later. The pair stays published for apps before 0.3.9.
|
||||
/// Shape: `{"file":"<the exact canonical igneum-jobs.json text>","format":"igneum-jobs-signed-1","sig":"<hex>"}`;
|
||||
/// the signature is over the bytes of `file`, so the same key and the same signer sign both forms.
|
||||
pub const JOBS_SIGNED_FILE: &str = "igneum-jobs.signed.json";
|
||||
pub const JOBS_SIGNED_FORMAT: &str = "igneum-jobs-signed-1";
|
||||
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build"];
|
||||
/// Requirements the engine knows how to probe (src/jobrun.rs). An unknown requirement is never satisfied.
|
||||
pub const KNOWN_REQUIRES: &[&str] = &["wsl", "wsl-prover", "nvidia"];
|
||||
/// Named folders a `fetch` may write into, all under the app data root.
|
||||
|
|
@ -50,6 +67,11 @@ pub const FETCH_DIRS: &[&str] = &["jobs", "prove", "packs", "updates"];
|
|||
pub const DEFAULT_RUN_TIMEOUT_MIN: u64 = 60;
|
||||
pub const MAX_RUN_TIMEOUT_MIN: u64 = 600;
|
||||
pub const DEFAULT_SHARD_CAP_MIN: u64 = 90;
|
||||
/// `build`: the whole job (fetch, setup, both targets, tests, pack, upload) must fit this unless budget_minutes says more.
|
||||
pub const DEFAULT_BUILD_BUDGET_MIN: u64 = 40;
|
||||
pub const DEFAULT_BUILD_MIN_FREE_GB: u64 = 20;
|
||||
pub const BUILD_TARGETS: &[&str] = &["linux", "windows"];
|
||||
pub const BUILD_STAGES: &[&str] = &["fetch", "setup", "extract", "linux", "windows", "test", "pack", "upload"];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Target {
|
||||
|
|
@ -101,10 +123,14 @@ impl Job {
|
|||
_ => vec![],
|
||||
}
|
||||
}
|
||||
/// `run`: the script's timeout; `shard-benchmark`: the cap. Clamped to MAX_RUN_TIMEOUT_MIN.
|
||||
/// `run`: the script's timeout; `shard-benchmark`: the cap; `build`: the total budget. Clamped to MAX_RUN_TIMEOUT_MIN.
|
||||
pub fn timeout_minutes(&self) -> u64 {
|
||||
let d = if self.kind == "shard-benchmark" { DEFAULT_SHARD_CAP_MIN } else { DEFAULT_RUN_TIMEOUT_MIN };
|
||||
let v = self.u64_param("timeout_minutes").or_else(|| self.u64_param("cap_minutes")).unwrap_or(d);
|
||||
let d = match self.kind.as_str() {
|
||||
"shard-benchmark" => DEFAULT_SHARD_CAP_MIN,
|
||||
"build" => DEFAULT_BUILD_BUDGET_MIN,
|
||||
_ => DEFAULT_RUN_TIMEOUT_MIN,
|
||||
};
|
||||
let v = self.u64_param("timeout_minutes").or_else(|| self.u64_param("cap_minutes")).or_else(|| self.u64_param("budget_minutes")).unwrap_or(d);
|
||||
v.clamp(1, MAX_RUN_TIMEOUT_MIN)
|
||||
}
|
||||
/// The run id under which the machine reports this job to the log intake.
|
||||
|
|
@ -113,6 +139,58 @@ impl Job {
|
|||
}
|
||||
}
|
||||
|
||||
/// The signed envelope sits next to the jobs file: same folder, fixed name (`<folder>/igneum-jobs.signed.json`).
|
||||
pub fn signed_jobs_url(jobs_url: &str) -> String {
|
||||
let u = jobs_url.trim();
|
||||
if u.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
match u.rfind('/') {
|
||||
Some(i) => format!("{}/{}", &u[..i], JOBS_SIGNED_FILE),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The envelope text for a jobs file and its detached signature: what publish-jobs.sh writes next to the pair
|
||||
/// (through `igneum-ota-sign envelope-jobs`). The signature is checked here, so a pair that does not belong
|
||||
/// together is never wrapped. Keys in sorted order, no whitespace, as the jobs file itself.
|
||||
pub fn signed_envelope(file: &[u8], sig_hex: &str, pub_hex: &str) -> Result<String, String> {
|
||||
manifest::verify_signature(file, sig_hex.trim(), pub_hex).map_err(|_| "jobs file signature does not verify; not wrapping it".to_string())?;
|
||||
let text = std::str::from_utf8(file).map_err(|_| "jobs file is not UTF-8")?;
|
||||
Ok(format!("{{\"file\":{},\"format\":\"{}\",\"sig\":\"{}\"}}", Value::String(text.to_string()), JOBS_SIGNED_FORMAT, sig_hex.trim()))
|
||||
}
|
||||
|
||||
/// Opens the envelope: the jobs file bytes and the signature hex, both as strings in one JSON object. Nothing is
|
||||
/// verified here; `verify_and_parse_signed*` do that over the exact inner bytes.
|
||||
pub fn open_envelope(bytes: &[u8]) -> Result<(Vec<u8>, String), String> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "signed jobs file is not UTF-8")?;
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("signed jobs file is not JSON: {e}"))?;
|
||||
let format = v.get("format").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if format != JOBS_SIGNED_FORMAT {
|
||||
return Err(format!("signed jobs file: format '{format}' is not {JOBS_SIGNED_FORMAT}"));
|
||||
}
|
||||
let file = v.get("file").and_then(|x| x.as_str()).ok_or("signed jobs file has no \"file\" string")?;
|
||||
let sig = v.get("sig").and_then(|x| x.as_str()).ok_or("signed jobs file has no \"sig\" string")?.trim();
|
||||
if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("signed jobs file: sig is not 128 hex characters".into());
|
||||
}
|
||||
Ok((file.as_bytes().to_vec(), sig.to_string()))
|
||||
}
|
||||
|
||||
/// The signer's check of an envelope: the inner file and signature verify and parse (strict).
|
||||
pub fn verify_and_parse_signed(bytes: &[u8], pub_hex: &str) -> Result<JobsFile, String> {
|
||||
let (file, sig) = open_envelope(bytes)?;
|
||||
verify_and_parse(&file, &sig, pub_hex)
|
||||
}
|
||||
|
||||
/// The runner's check of an envelope: as `verify_and_parse_lenient` over the inner pair. Also returns the inner
|
||||
/// file bytes, which the runner keeps on disk as jobs.json for the dashboard.
|
||||
pub fn verify_and_parse_signed_lenient(bytes: &[u8], pub_hex: &str) -> Result<(JobsFile, Vec<String>, Vec<u8>), String> {
|
||||
let (file, sig) = open_envelope(bytes)?;
|
||||
let (f, skipped) = verify_and_parse_lenient(&file, &sig, pub_hex)?;
|
||||
Ok((f, skipped, file))
|
||||
}
|
||||
|
||||
/// The jobs file sits next to the update manifest: same folder, fixed name.
|
||||
pub fn jobs_url_from_manifest(manifest_url: &str) -> String {
|
||||
let u = manifest_url.trim();
|
||||
|
|
@ -233,10 +311,21 @@ fn parse_target(v: Option<&Value>) -> Result<Target, String> {
|
|||
/// Parses the jobs file (after the signature was checked). Every job is validated; one bad job rejects the file,
|
||||
/// so a typo on the Mac is caught by the signer before anything is published.
|
||||
pub fn parse(text: &str) -> Result<JobsFile, String> {
|
||||
parse_inner(text, false).map(|(f, _)| f)
|
||||
}
|
||||
|
||||
/// The runner's parse: a job whose kind this version does not know is skipped (its id is returned) instead of
|
||||
/// rejecting the file. Everything else is as strict as `parse`.
|
||||
pub fn parse_lenient(text: &str) -> Result<(JobsFile, Vec<String>), String> {
|
||||
parse_inner(text, true)
|
||||
}
|
||||
|
||||
fn parse_inner(text: &str, skip_unknown_kinds: bool) -> Result<(JobsFile, Vec<String>), String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("jobs file is not JSON: {e}"))?;
|
||||
let s = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
let list = v.get("jobs").and_then(|j| j.as_array()).ok_or("jobs file has no \"jobs\" list")?;
|
||||
let mut out = JobsFile { published_at: s(&v, "published_at"), jobs: Vec::new() };
|
||||
let mut skipped = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (i, j) in list.iter().enumerate() {
|
||||
let id = s(j, "id");
|
||||
|
|
@ -248,6 +337,10 @@ pub fn parse(text: &str) -> Result<JobsFile, String> {
|
|||
}
|
||||
let kind = s(j, "kind");
|
||||
if !KINDS.contains(&kind.as_str()) {
|
||||
if skip_unknown_kinds {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
return Err(format!("job {id}: kind '{kind}' is unknown (known: {})", KINDS.join(", ")));
|
||||
}
|
||||
let expires_at = s(j, "expires_at");
|
||||
|
|
@ -266,7 +359,7 @@ pub fn parse(text: &str) -> Result<JobsFile, String> {
|
|||
validate_params(&job).map_err(|e| format!("job {id}: {e}"))?;
|
||||
out.jobs.push(job);
|
||||
}
|
||||
Ok(out)
|
||||
Ok((out, skipped))
|
||||
}
|
||||
|
||||
fn https_ok(url: &str) -> bool {
|
||||
|
|
@ -339,18 +432,69 @@ pub fn validate_params(job: &Job) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
}
|
||||
"build" => {
|
||||
if !https_ok(&job.str_param("zip_url")) {
|
||||
return Err("build: params.zip_url is not https (the build-inputs zip)".into());
|
||||
}
|
||||
if !sha_ok(&job.str_param("sha256")) {
|
||||
return Err("build: params.sha256 of the build-inputs zip is not 64 hex characters".into());
|
||||
}
|
||||
for t in job.list_param("targets") {
|
||||
if !BUILD_TARGETS.contains(&t.as_str()) {
|
||||
return Err(format!("build: target '{t}' is not one of {}", BUILD_TARGETS.join(", ")));
|
||||
}
|
||||
}
|
||||
if let Some(b) = job.params.get("budget_minutes") {
|
||||
match b.as_u64() {
|
||||
Some(n) if (1..=MAX_RUN_TIMEOUT_MIN).contains(&n) => {}
|
||||
_ => return Err(format!("build: budget_minutes must be 1 to {MAX_RUN_TIMEOUT_MIN}")),
|
||||
}
|
||||
}
|
||||
if let Some(m) = job.params.get("stage_minutes") {
|
||||
let o = m.as_object().ok_or("build: stage_minutes is not an object of stage: minutes")?;
|
||||
for (k, v) in o {
|
||||
if !BUILD_STAGES.contains(&k.as_str()) {
|
||||
return Err(format!("build: stage_minutes has unknown stage '{k}' (stages: {})", BUILD_STAGES.join(", ")));
|
||||
}
|
||||
if !matches!(v.as_u64(), Some(n) if n >= 1) {
|
||||
return Err(format!("build: stage_minutes.{k} must be a whole number of minutes, at least 1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(g) = job.params.get("min_free_gb") {
|
||||
if g.as_u64().is_none() {
|
||||
return Err("build: min_free_gb must be a whole number of GB".into());
|
||||
}
|
||||
}
|
||||
let relay = job.str_param("relay_url");
|
||||
if !relay.is_empty() && !https_ok(&relay) {
|
||||
return Err("build: relay_url is not https".into());
|
||||
}
|
||||
if let Some(n) = job.params.get("nice") {
|
||||
if !matches!(n.as_u64(), Some(v) if v <= 19) {
|
||||
return Err("build: nice must be 0 to 19".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("kind '{}' is unknown", job.kind)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies the detached signature over the exact bytes, then parses.
|
||||
/// Verifies the detached signature over the exact bytes, then parses (strict: the signer's check).
|
||||
pub fn verify_and_parse(bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<JobsFile, String> {
|
||||
manifest::verify_signature(bytes, sig_hex, pub_hex).map_err(|_| "jobs file signature does not verify".to_string())?;
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "jobs file is not UTF-8")?;
|
||||
parse(text)
|
||||
}
|
||||
|
||||
/// The runner's variant: the same signature check, unknown kinds skipped (their ids come back).
|
||||
pub fn verify_and_parse_lenient(bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<(JobsFile, Vec<String>), String> {
|
||||
manifest::verify_signature(bytes, sig_hex, pub_hex).map_err(|_| "jobs file signature does not verify".to_string())?;
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "jobs file is not UTF-8")?;
|
||||
parse_lenient(text)
|
||||
}
|
||||
|
||||
// ---- targeting ----------------------------------------------------------------------------------------------------
|
||||
|
||||
pub fn targets_machine(t: &Target, machine_id: &str) -> bool {
|
||||
|
|
@ -685,6 +829,100 @@ mod tests {
|
|||
assert!(j("run", r#"{"script":"ls","shell":"zsh"}"#).unwrap_err().contains("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_params_are_checked() {
|
||||
let j = |params: &str| parse(&format!(r#"{{"jobs":[{{"id":"b","kind":"build","expires_at":"2026-10-06T15:00:00Z","target":{{"machine_ids":["ae432dc7"],"platform":"windows","requires":["wsl"]}},"params":{params}}}]}}"#));
|
||||
let sha = "c".repeat(64);
|
||||
let ok = format!(r#"{{"zip_url":"https://dl.igneum.network/dl/t/build-inputs.zip","sha256":"{sha}","size":12345}}"#);
|
||||
let f = j(&ok).unwrap();
|
||||
let b = &f.jobs[0];
|
||||
assert_eq!(b.timeout_minutes(), DEFAULT_BUILD_BUDGET_MIN);
|
||||
assert!(b.list_param("targets").is_empty());
|
||||
assert!(j(r#"{}"#).unwrap_err().contains("zip_url"));
|
||||
assert!(j(r#"{"zip_url":"http://x/a.zip","sha256":"aa"}"#).unwrap_err().contains("https"));
|
||||
assert!(j(r#"{"zip_url":"https://x/a.zip","sha256":"aa"}"#).unwrap_err().contains("sha256"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"targets":["linux","amiga"]"#)).unwrap_err().contains("amiga"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"budget_minutes":0"#)).unwrap_err().contains("budget_minutes"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"budget_minutes":601"#)).unwrap_err().contains("budget_minutes"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"stage_minutes":{"lunch":5}"#)).unwrap_err().contains("lunch"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"stage_minutes":{"linux":"ten"}"#)).unwrap_err().contains("stage_minutes.linux"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"stage_minutes":[5]"#)).unwrap_err().contains("object"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"min_free_gb":"lots""#)).unwrap_err().contains("min_free_gb"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"relay_url":"ftp://relay""#)).unwrap_err().contains("relay_url"));
|
||||
assert!(j(&ok.replace("12345", r#"1,"nice":25"#)).unwrap_err().contains("nice"));
|
||||
let full = j(&ok.replace("12345", r#"1,"targets":["windows"],"budget_minutes":120,"stage_minutes":{"linux":30,"windows":40},"min_free_gb":25,"tests":false,"relay_url":"https://relay.igneum.network","nice":10"#)).unwrap();
|
||||
let b = &full.jobs[0];
|
||||
assert_eq!(b.timeout_minutes(), 120);
|
||||
assert_eq!(b.list_param("targets"), vec!["windows"]);
|
||||
assert_eq!(b.u64_param("min_free_gb"), Some(25));
|
||||
assert!(!b.params.get("tests").and_then(|v| v.as_bool()).unwrap_or(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kinds_are_refused_by_the_signer_and_skipped_by_the_runner() {
|
||||
let (sk, pk) = key();
|
||||
let text = SAMPLE.replace("],\"published_at\"", r#",{"id":"future-1","kind":"teleport","expires_at":"2026-10-06T15:00:00Z","target":{"machine_ids":"all"}}],"published_at""#);
|
||||
assert!(text.contains("teleport"), "the sample must carry the unknown kind");
|
||||
assert!(parse(&text).unwrap_err().contains("teleport"));
|
||||
let (f, skipped) = parse_lenient(&text).unwrap();
|
||||
assert_eq!(f.jobs.len(), 2);
|
||||
assert_eq!(skipped, vec!["future-1".to_string()]);
|
||||
// the signature still has to verify, and a bad job of a known kind still rejects the file
|
||||
let sig = manifest::hex_encode(&sk.sign(text.as_bytes()).to_bytes());
|
||||
let (f2, s2) = verify_and_parse_lenient(text.as_bytes(), &sig, &pk).unwrap();
|
||||
assert_eq!((f2.jobs.len(), s2.len()), (2, 1));
|
||||
assert!(verify_and_parse_lenient(text.replace("future-1", "future-2").as_bytes(), &sig, &pk).is_err());
|
||||
let bad = text.replace("\"kind\":\"collect\"", "\"kind\":\"restart\"");
|
||||
assert!(parse_lenient(&bad).unwrap_err().contains("restart"));
|
||||
// a duplicate id is a duplicate even when one of them is unknown
|
||||
let dup = text.replace("future-1", "collect-1");
|
||||
assert!(parse_lenient(&dup).unwrap_err().contains("twice"));
|
||||
// the strict parse is unchanged for a clean file
|
||||
assert_eq!(parse_lenient(SAMPLE).unwrap().1.len(), 0);
|
||||
}
|
||||
|
||||
/// The envelope: one object, the file text and its signature together. A file with the signature of another
|
||||
/// file (the 13:19:41Z pair) is refused at wrapping time and at reading time; a tampered inner text is refused;
|
||||
/// a missing field, another format and a short sig are named.
|
||||
#[test]
|
||||
fn signed_envelope_binds_file_and_signature() {
|
||||
let (sk, pk) = key();
|
||||
let sig = manifest::hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes());
|
||||
let env = signed_envelope(SAMPLE.as_bytes(), &sig, &pk).unwrap();
|
||||
assert!(env.starts_with("{\"file\":\"{") && env.ends_with(&format!("\",\"format\":\"{JOBS_SIGNED_FORMAT}\",\"sig\":\"{sig}\"}}")), "{env}");
|
||||
assert!(!env.contains('\n'), "one line, like the jobs file");
|
||||
// reading it back gives the exact inner bytes and the same parse as the pair
|
||||
let (file, s2) = open_envelope(env.as_bytes()).unwrap();
|
||||
assert_eq!((file.as_slice(), s2.as_str()), (SAMPLE.as_bytes(), sig.as_str()));
|
||||
let f = verify_and_parse_signed(env.as_bytes(), &pk).unwrap();
|
||||
assert_eq!(f.jobs.len(), 2);
|
||||
let (f2, skipped, inner) = verify_and_parse_signed_lenient(env.as_bytes(), &pk).unwrap();
|
||||
assert_eq!((f2.jobs.len(), skipped.len(), inner.as_slice()), (2, 0, SAMPLE.as_bytes()));
|
||||
// a stale pair cannot be wrapped: the signature of another publish over this file
|
||||
let other_file = SAMPLE.replace("collect-1", "collect-2");
|
||||
let other_sig = manifest::hex_encode(&sk.sign(other_file.as_bytes()).to_bytes());
|
||||
assert_eq!(signed_envelope(SAMPLE.as_bytes(), &other_sig, &pk).unwrap_err(), "jobs file signature does not verify; not wrapping it");
|
||||
// and a mixed envelope made by hand is refused on reading with the same words the app logs
|
||||
let mixed = format!("{{\"file\":{},\"format\":\"{JOBS_SIGNED_FORMAT}\",\"sig\":\"{other_sig}\"}}", Value::String(SAMPLE.to_string()));
|
||||
assert_eq!(verify_and_parse_signed(mixed.as_bytes(), &pk).unwrap_err(), "jobs file signature does not verify");
|
||||
// a byte changed inside the inner text after wrapping
|
||||
let tampered = env.replace("shard-20261004-150000", "shard-20261004-150001");
|
||||
assert_eq!(verify_and_parse_signed(tampered.as_bytes(), &pk).unwrap_err(), "jobs file signature does not verify");
|
||||
// another key
|
||||
let other_key = manifest::hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes());
|
||||
assert!(verify_and_parse_signed(env.as_bytes(), &other_key).is_err());
|
||||
// shape errors are named
|
||||
assert!(open_envelope(b"nope").unwrap_err().contains("not JSON"));
|
||||
assert!(open_envelope(env.replace(JOBS_SIGNED_FORMAT, "igneum-jobs-signed-9").as_bytes()).unwrap_err().contains("format"));
|
||||
assert!(open_envelope(env.replace("\"file\":", "\"body\":").as_bytes()).unwrap_err().contains("\"file\""));
|
||||
assert!(open_envelope(env.replace(&sig, "abcd").as_bytes()).unwrap_err().contains("128 hex"));
|
||||
// the plain pair still works for apps before 0.3.9: the same signature verifies the inner file on its own
|
||||
assert!(verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).is_ok());
|
||||
// the URL next to the jobs file
|
||||
assert_eq!(signed_jobs_url("https://dl.igneum.network/dl/tok/igneum-jobs.json"), "https://dl.igneum.network/dl/tok/igneum-jobs.signed.json");
|
||||
assert_eq!(signed_jobs_url(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_verifies_and_tampering_fails() {
|
||||
let (sk, pk) = key();
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
//! --wrapper, reads `URL ...` and `STATE {...}` lines from its stdout and writes `quit` on its stdin. Without a host
|
||||
//! (--open) the dashboard opens in the default browser.
|
||||
//!
|
||||
//! igneum-app [--wrapper | --open | --no-open | --launch] [--print-url]
|
||||
//! igneum-app [--wrapper | --open | --no-open | --launch | --sweep] [--print-url]
|
||||
//! --launch is what the Start Menu entry runs on Windows: it hands over to "Igneum Miner.exe" (the window host) when
|
||||
//! that is installed next to the engine, else runs the engine with the dashboard in the default browser.
|
||||
//! --sweep runs the efficiency sweep (src/sweep.rs) on every supported card as soon as it mines, prints the SWEEP
|
||||
//! table on stdout, leaves the chosen caps in force and quits; no browser opens (the PC measurement job).
|
||||
//!
|
||||
//! Environment (tests): IGNEUM_APP_NETWORK devnet|simnet, IGNEUM_APP_RPC_PORT, IGNEUM_APP_P2P_PORT, IGNEUM_APP_PEERS
|
||||
//! (comma list, empty for none), IGNEUM_APP_UNSYNCED=1, IGNEUM_APP_DATA, IGNEUM_APP_LOGS, IGNEUM_APP_BIN, IGNEUM_APP_NODE_DIR.
|
||||
|
|
@ -14,6 +16,7 @@
|
|||
mod config;
|
||||
mod detect;
|
||||
mod engine;
|
||||
mod hotplug;
|
||||
mod keys;
|
||||
mod platform;
|
||||
mod procs;
|
||||
|
|
@ -24,7 +27,16 @@ mod ota;
|
|||
mod update;
|
||||
mod jobs;
|
||||
mod jobrun;
|
||||
mod jobbuild;
|
||||
mod prover;
|
||||
mod provedefault;
|
||||
mod segments;
|
||||
mod verifier;
|
||||
mod wslhost;
|
||||
mod sweep;
|
||||
mod ember;
|
||||
mod powertask;
|
||||
mod watchdog;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::mpsc::channel;
|
||||
|
|
@ -33,17 +45,29 @@ use std::sync::Arc;
|
|||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let wrapper = args.iter().any(|a| a == "--wrapper");
|
||||
let no_open = wrapper || args.iter().any(|a| a == "--no-open");
|
||||
let sweep = args.iter().any(|a| a == "--sweep");
|
||||
if sweep {
|
||||
// the runtime reads it (config::Runtime::from_env); the engine starts at once and quits after the sweep
|
||||
std::env::set_var("IGNEUM_APP_SWEEP", "1");
|
||||
}
|
||||
let no_open = wrapper || sweep || args.iter().any(|a| a == "--no-open");
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("igneum-app {}", engine::VERSION);
|
||||
return;
|
||||
}
|
||||
if args.iter().any(|a| a == "--power-helper") {
|
||||
// the scheduled task's action (src/powertask.rs): elevated, runs only digit-argument nvidia-smi commands
|
||||
// from <app data>/app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes
|
||||
let dir = powertask::sweep_dir(&platform::data_root().join("app"));
|
||||
std::process::exit(powertask::run_helper(&dir));
|
||||
}
|
||||
if args.iter().any(|a| a == "--launch") {
|
||||
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
let host = dir.join("Igneum Miner.exe");
|
||||
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;
|
||||
}
|
||||
|
|
@ -76,8 +100,10 @@ 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"));
|
||||
// a measurement engine runs with the installed app's choices and its own switches (config.rs for_measurement)
|
||||
let settings = if sweep { settings.for_measurement() } else { settings };
|
||||
|
||||
// the per-launch token: 32 hex characters from the OS
|
||||
let mut raw = [0u8; 16];
|
||||
|
|
@ -119,16 +145,18 @@ fn main() {
|
|||
let Ok(l) = line else { break };
|
||||
let t = l.trim();
|
||||
match t {
|
||||
"quit" => shared.send(engine::Cmd::Quit),
|
||||
"quit" => shared.send(engine::Cmd::Quit("the window host (quit on stdin: the tray menu or the installer)")),
|
||||
"pause" => shared.send(engine::Cmd::Pause),
|
||||
"resume" => shared.send(engine::Cmd::Resume),
|
||||
// the window host saw WM_DEVICECHANGE (a card plugged in or out): enumerate now, not at the next minute
|
||||
"detect" => shared.send(engine::Cmd::Detect),
|
||||
"elevated ok" => shared.send(engine::Cmd::ElevatedDone(Ok(()))),
|
||||
_ if t.starts_with("elevated fail") => shared.send(engine::Cmd::ElevatedDone(Err(t.trim_start_matches("elevated fail").trim_start_matches(':').trim().to_string()))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if wrapper {
|
||||
shared.send(engine::Cmd::Quit);
|
||||
shared.send(engine::Cmd::Quit("the window host went away (stdin closed)"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@
|
|||
//! "version": "0.3.1", "published_at": "2026-10-04T13:00:00Z", "channel": "devnet",
|
||||
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
|
||||
//! "min_supported_version": "0.3.0", "notes": "one line",
|
||||
//! "consensus": { "activation_height": null|number, "deadline_note": "" }
|
||||
//! "consensus": { "activation_height": null|number, "deadline_note": "", "override": {...} },
|
||||
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } }
|
||||
//! }
|
||||
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
|
||||
//! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it
|
||||
//! to <app data>/tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
|
|
@ -53,6 +56,9 @@ pub struct Manifest {
|
|||
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
|
||||
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest.
|
||||
pub override_params: Option<serde_json::Value>,
|
||||
/// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries
|
||||
/// it), written as is to <app data>/tuning.json for the GPU workers; signed with the rest of the manifest.
|
||||
pub tuning: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
|
|
@ -157,6 +163,11 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
|
|||
Some(o) if !o.is_null() => return Err("consensus.override must be an object".into()),
|
||||
_ => None,
|
||||
},
|
||||
tuning: match v.get("tuning") {
|
||||
Some(t) if t.is_object() && t.get("cards").map(|c| c.is_object()).unwrap_or(false) => Some(t.clone()),
|
||||
Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -371,6 +382,16 @@ mod tests {
|
|||
assert_eq!(ours, theirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuning_parses() {
|
||||
let m = parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"updated":"2026-10-04T20:00:00Z","cards":{"NVIDIA_GeForce_RTX_5090":{"variant":"u2-ldg","race":true,"candidates":["u2-ldg","ldg","base"]}}}}"#).unwrap();
|
||||
assert_eq!(m.tuning.as_ref().unwrap()["cards"]["NVIDIA_GeForce_RTX_5090"]["variant"], "u2-ldg");
|
||||
assert!(parse(r#"{"version":"0.3.4","platforms":{},"tuning":null}"#).unwrap().tuning.is_none());
|
||||
assert!(parse(r#"{"version":"0.3.4","platforms":{}}"#).unwrap().tuning.is_none());
|
||||
assert!(parse(r#"{"version":"0.3.4","platforms":{},"tuning":"fast"}"#).is_err());
|
||||
assert!(parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"cards":[]}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consensus_override_parses() {
|
||||
let m = parse(r#"{"version":"0.3.2","platforms":{},"consensus":{"activation_height":5000,"override":{"difficulty_v2_activation_daa":5000}}}"#).unwrap();
|
||||
|
|
@ -525,3 +546,42 @@ mod tests {
|
|||
assert_eq!(fingerprint("zz"), "");
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix seconds of a manifest `published_at` ("2026-10-04T13:00:00Z", whole seconds, UTC); `None` for any other shape.
|
||||
pub fn unix_from_rfc3339(t: &str) -> Option<u64> {
|
||||
let t = t.trim();
|
||||
let b = t.as_bytes();
|
||||
if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' || !t.ends_with('Z') {
|
||||
return None;
|
||||
}
|
||||
let n = |a: usize, z: usize| t[a..z].parse::<i64>().ok();
|
||||
let (y, m, d, hh, mm, ss) = (n(0, 4)?, n(5, 7)?, n(8, 10)?, n(11, 13)?, n(14, 16)?, n(17, 19)?);
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
|
||||
return None;
|
||||
}
|
||||
// days from civil (Howard Hinnant), valid for every date after 1970
|
||||
let (y2, m2) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
|
||||
let era = y2.div_euclid(400);
|
||||
let yoe = y2 - era * 400;
|
||||
let doy = (153 * m2 + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146097 + doe - 719468;
|
||||
if days < 0 {
|
||||
return None;
|
||||
}
|
||||
Some((days as u64) * 86400 + (hh as u64) * 3600 + (mm as u64) * 60 + ss as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rfc3339_tests {
|
||||
use super::unix_from_rfc3339;
|
||||
#[test]
|
||||
fn a_manifest_publish_time_parses_to_unix_seconds() {
|
||||
assert_eq!(unix_from_rfc3339("1970-01-01T00:00:00Z"), Some(0));
|
||||
assert_eq!(unix_from_rfc3339("2026-10-05T23:57:49Z"), Some(1791244669));
|
||||
assert_eq!(unix_from_rfc3339("2026-10-04T13:00:00Z"), Some(1791118800));
|
||||
assert_eq!(unix_from_rfc3339(""), None);
|
||||
assert_eq!(unix_from_rfc3339("2026-10-05 23:57:49"), None);
|
||||
assert_eq!(unix_from_rfc3339("2026-13-05T23:57:49Z"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ use std::process::Command;
|
|||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A manifest published this long before the engine started is a catch-up: the hourly rollout slot does not apply.
|
||||
const CATCH_UP_AFTER_S: u64 = 3600;
|
||||
const HEALTHY_AFTER_S: u64 = 90;
|
||||
const CHECK_EVERY_S: u64 = 3600;
|
||||
const RETRY_AFTER_ERROR_S: u64 = 600;
|
||||
|
|
@ -109,12 +111,19 @@ pub struct Updater {
|
|||
/// the consensus override file written from the manifest, when it changed since the last take
|
||||
override_changed: Option<PathBuf>,
|
||||
override_daa: u64,
|
||||
/// the per-card tuning file written from the manifest, when it changed since the last take
|
||||
tuning_changed: Option<PathBuf>,
|
||||
/// Windows: the installer was started and the engine is still up (it stops us when it may run)
|
||||
apply_launched: Option<Instant>,
|
||||
/// the administrator prompt was not answered: no automatic retry before this (Install now still works)
|
||||
deferred_until: Option<Instant>,
|
||||
/// this machine's minute of the hour for applying (manifest::slot_minute of the machine id)
|
||||
slot: u64,
|
||||
/// When this engine started (unix seconds): an update published more than an hour before it is a catch-up, not a
|
||||
/// rollout, and skips the hourly slot (the project lead's morning of 6 October 2026: PC 1 came up after the 0.3.11 publish and
|
||||
/// sat on "installs at the next safe moment" until he pressed Install now).
|
||||
started_unix: u64,
|
||||
catch_up_logged: bool,
|
||||
/// identity counts from /api/live over the last 10 minutes, sampled while an update is ready
|
||||
live_samples: Vec<(Instant, u64)>,
|
||||
live_next: Instant,
|
||||
|
|
@ -157,9 +166,12 @@ impl Updater {
|
|||
staged_digest: String::new(),
|
||||
override_changed: None,
|
||||
override_daa: 0,
|
||||
tuning_changed: None,
|
||||
apply_launched: None,
|
||||
deferred_until: None,
|
||||
slot: manifest::slot_minute(&shared.runtime.id8()),
|
||||
started_unix: crate::platform::unix_now(),
|
||||
catch_up_logged: false,
|
||||
live_samples: Vec::new(),
|
||||
live_next: now,
|
||||
live_busy: false,
|
||||
|
|
@ -172,7 +184,11 @@ impl Updater {
|
|||
if crate::platform::start_at_login_is_on() {
|
||||
let _ = crate::platform::set_start_at_login(true);
|
||||
}
|
||||
firewall_first_run(shared);
|
||||
// a measurement engine (--sweep) uses the installed app's node and asks for nothing: the rule is the
|
||||
// installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here)
|
||||
if !shared.runtime.sweep_only {
|
||||
firewall_first_run(shared);
|
||||
}
|
||||
}
|
||||
u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::<Vec<String>>(&t).ok()).unwrap_or_default();
|
||||
// the cached manifest: the rollback floor and the consensus override are known before the first check
|
||||
|
|
@ -180,6 +196,7 @@ impl Updater {
|
|||
if let Ok(m) = manifest::parse(&text) {
|
||||
u.min_supported = m.min_supported_version.clone();
|
||||
u.write_override(shared, &m);
|
||||
u.write_tuning(shared, &m);
|
||||
}
|
||||
}
|
||||
u.settle_previous(shared);
|
||||
|
|
@ -224,6 +241,45 @@ impl Updater {
|
|||
shared.state.lock().unwrap().node.consensus_switch_daa = self.override_daa;
|
||||
}
|
||||
|
||||
/// The per-card tuning from the manifest (docs/design/miner-tuning.md): `tuning` written as is to
|
||||
/// <app data>/tuning.json; the GPU workers read it at their next prepare through IGNEUM_TUNING_FILE (the engine
|
||||
/// passes the path to every miner it starts). A manifest without tuning removes the file: the workers race
|
||||
/// every variant again.
|
||||
fn write_tuning(&mut self, shared: &Arc<Shared>, m: &Manifest) {
|
||||
let path = self.app_dir.join("tuning.json");
|
||||
match &m.tuning {
|
||||
Some(t) => {
|
||||
let text = t.to_string();
|
||||
if std::fs::read_to_string(&path).ok().as_deref() == Some(text.as_str()) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, &text) {
|
||||
shared.log(&format!("could not write {}: {e}", path.display()));
|
||||
return;
|
||||
}
|
||||
let cards = t.get("cards").and_then(|c| c.as_object()).map(|c| c.len()).unwrap_or(0);
|
||||
shared.event("info", &format!("kernel tuning from the signed manifest: {cards} card model(s), updated {}", t.get("updated").and_then(|u| u.as_str()).unwrap_or("?")));
|
||||
self.tuning_changed = Some(path);
|
||||
}
|
||||
None => {
|
||||
if path.is_file() && std::fs::remove_file(&path).is_ok() {
|
||||
shared.log("the manifest carries no kernel tuning any more; tuning.json removed (the workers race every variant again)");
|
||||
self.tuning_changed = Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tuning file, once per change (the engine only logs it: the workers read the file at their next prepare).
|
||||
pub fn take_tuning_change(&mut self) -> Option<PathBuf> {
|
||||
self.tuning_changed.take()
|
||||
}
|
||||
|
||||
pub fn tuning_path(&self) -> Option<PathBuf> {
|
||||
let p = self.app_dir.join("tuning.json");
|
||||
if p.is_file() { Some(p) } else { None }
|
||||
}
|
||||
|
||||
/// The override file to start the node with, once per change.
|
||||
pub fn take_override_change(&mut self) -> Option<PathBuf> {
|
||||
self.override_changed.take()
|
||||
|
|
@ -476,7 +532,12 @@ impl Updater {
|
|||
}
|
||||
};
|
||||
let minute = (crate::platform::unix_now() / 60) % 60;
|
||||
let slot_ok = minute == self.slot || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
|
||||
let catch_up = self.manifest.as_ref().and_then(|m| manifest::unix_from_rfc3339(&m.published_at)).map(|p| p + CATCH_UP_AFTER_S <= self.started_unix).unwrap_or(false);
|
||||
if catch_up && !self.catch_up_logged {
|
||||
self.catch_up_logged = true;
|
||||
shared.log(&format!("update: {} was published over an hour before this start, so it installs at the first safe moment (no hourly slot)", self.version()));
|
||||
}
|
||||
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
|
||||
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
|
||||
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
|
||||
if !self.auto && !urgent && !self.install_asked {
|
||||
|
|
@ -616,6 +677,7 @@ impl Updater {
|
|||
self.manifest = Some(m.clone());
|
||||
self.min_supported = m.min_supported_version.clone();
|
||||
self.write_override(shared, &m);
|
||||
self.write_tuning(shared, &m);
|
||||
if let Some(e) = entry {
|
||||
if changed {
|
||||
self.file = None;
|
||||
|
|
@ -1063,7 +1125,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 {
|
||||
|
|
@ -1072,7 +1134,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 {
|
||||
|
|
@ -1123,7 +1185,12 @@ fn spawn_detached(c: &mut Command) -> Result<(), String> {
|
|||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
c.creation_flags(0x0800_0000 | 0x0000_0008); // CREATE_NO_WINDOW | DETACHED_PROCESS
|
||||
// CREATE_NO_WINDOW only. With DETACHED_PROCESS as well (0.3.0 to 0.3.4) powershell.exe has no console to
|
||||
// hide and exits during start-up before the first line of ota-apply.ps1 runs: the first Windows update over
|
||||
// the air (0.3.3 to 0.3.4, 4 October 2026) sat on "the installer is starting" with nothing logged, while the
|
||||
// same helper launched by a remote job ran at once (docs/bugs.md). CREATE_NO_WINDOW gives it a hidden
|
||||
// console, and the child is not tied to this process's lifetime, so it still outlives the engine.
|
||||
c.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
c.spawn().map(|_| ()).map_err(|e| format!("cannot start the helper: {e}"))
|
||||
}
|
||||
|
|
@ -1215,6 +1282,7 @@ function EngineAlive() { return [bool](Get-Process -Id $EnginePid -ErrorAction S
|
|||
function Relaunch() {
|
||||
if (EngineAlive) { return }
|
||||
$exe = Join-Path $InstallDir 'igneum-app.exe'
|
||||
# console: igneum-app.exe is a windows-subsystem program (no console); -WindowStyle Hidden would hide the window host it opens
|
||||
if (Test-Path $exe) { Log 'engine gone and nothing installed: starting the old app again'; Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null }
|
||||
}
|
||||
Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps mining until the installer runs)"
|
||||
|
|
@ -1229,6 +1297,7 @@ $setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICAT
|
|||
try {
|
||||
# no -Verb RunAs: a per-user installer just runs; an administrator installer makes Windows ask, and a declined or
|
||||
# timed-out prompt comes back here as an exception with the engine still mining
|
||||
# console: the Inno Setup installer is a GUI program (no console), /VERYSILENT shows nothing
|
||||
$p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru
|
||||
if ($p.ExitCode -eq 0) {
|
||||
if ($Mode -eq 'rollback') { Done $false "Igneum Miner $Version did not stay up twice; the previous version was reinstalled" $true $false }
|
||||
|
|
|
|||
|
|
@ -166,17 +166,28 @@ pub fn lock_permissions(path: &Path, dir: bool) {
|
|||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = dir;
|
||||
let user = std::env::var("USERNAME").unwrap_or_default();
|
||||
if !user.is_empty() {
|
||||
let _ = Command::new(tool("icacls"))
|
||||
.arg(path)
|
||||
.args(["/inheritance:r", "/grant:r", &format!("{user}:F")])
|
||||
.output();
|
||||
let _ = quiet(&mut Command::new(tool("icacls"))).arg(path).args(icacls_lock_args(dir, &user)).output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The icacls arguments that lock a path to the user. A folder gets an INHERITABLE grant (`user:(OI)(CI)F`) and
|
||||
/// NO `/T`: Windows propagates the inheritable entry to every child, existing or future, as `(I)(F)`. Measured on
|
||||
/// PC 1, 6 October 2026 (collect ember-acl-2): the old non-inheritable `user:F` cut the folder's inheritance and
|
||||
/// left a file COPIED in before the engine started with no entry at all (the measurement engine's settings.json,
|
||||
/// machine-id and wallet.json read as nothing, so it ran on defaults with no payout address; its own files, written
|
||||
/// after the lock, inherited fine and hid it); the same grant WITH `/T` also left the file empty, because `/T`
|
||||
/// re-applies `/inheritance:r` to the file after the propagation and an `(OI)(CI)` entry on a file is inherit-only.
|
||||
pub fn icacls_lock_args(dir: bool, user: &str) -> Vec<String> {
|
||||
if dir {
|
||||
vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:(OI)(CI)F")]
|
||||
} else {
|
||||
vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:F")]
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a URL in the default browser (the fallback when no window host runs).
|
||||
pub fn open_url(url: &str) {
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
@ -314,9 +325,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 +349,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())
|
||||
|
|
@ -396,10 +407,7 @@ pub fn sync_clock() -> Result<String, String> {
|
|||
#[cfg(windows)]
|
||||
{
|
||||
let cmd = tool("cmd").display().to_string();
|
||||
let script = format!("Start-Process -FilePath '{cmd}' -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden");
|
||||
let mut c = Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
|
||||
quiet(&mut c);
|
||||
let mut c = elevated_command(&cmd, "/c net start w32time & w32tm /resync /force");
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok("asked Windows Time to resync (w32tm /resync)".into())
|
||||
|
|
@ -425,18 +433,14 @@ pub fn sync_clock() -> Result<String, String> {
|
|||
pub fn run_elevated(cmdline: &str) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let escaped = cmdline.replace('\'', "''");
|
||||
let cmd = tool("cmd").display().to_string();
|
||||
let script = format!("$p = Start-Process -FilePath '{cmd}' -ArgumentList '/c {escaped}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode");
|
||||
let mut c = Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
|
||||
quiet(&mut c);
|
||||
let mut c = elevated_command(&cmd, &format!("/c {cmdline}"));
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.contains("canceled") || err.contains("cancelled") || err.is_empty() { "the administrator prompt was cancelled".into() } else { err })
|
||||
Err(elevated_failure(out.status.code(), &err))
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
|
|
@ -451,6 +455,52 @@ pub fn run_elevated(cmdline: &str) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The reason an elevated step failed, from the launcher's exit code and stderr: exit 251 (the prompt refused,
|
||||
/// cancelled or timed out, `elevated_ps_line`) and the "canceled" wording name the prompt; any other code is the
|
||||
/// step's own exit (the engine then keeps Power control on: rights were given).
|
||||
pub fn elevated_failure(code: Option<i32>, stderr: &str) -> String {
|
||||
if code == Some(ELEVATED_LAUNCH_FAILED) || stderr.contains("canceled") || stderr.contains("cancelled") {
|
||||
"the administrator prompt was refused, cancelled or timed out".into()
|
||||
} else if stderr.is_empty() {
|
||||
format!("the elevated step exited with code {}", code.map(|c| c.to_string()).unwrap_or_else(|| "?".into()))
|
||||
} else {
|
||||
stderr.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Doubles the single quotes of `s` for a single-quoted PowerShell literal.
|
||||
pub fn ps_quote(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// The PowerShell line that starts `file args` as administrator (one UAC prompt), waits, and exits with the child's
|
||||
/// code. Every elevated launch of the app goes through here (the NVIDIA power cap, the sweep helper, the clock sync,
|
||||
/// an elevated remote job) so the console flags live in one place: `-WindowStyle Hidden` is SW_HIDE on the new
|
||||
/// process the AppInfo service creates; the elevated child cannot inherit this process's headless console, so without
|
||||
/// it the child gets a console of its own (5 October 2026, PC 1 watcher, tools/windows/console-watch*.ps1).
|
||||
/// A refused, cancelled or unanswered prompt makes Start-Process throw and `$p` stay null: that is exit 251 with the
|
||||
/// reason on stderr, never `exit $p.ExitCode` = 0 (the 5 October 2026 driver job on PC 1 was reported done after
|
||||
/// Windows cancelled its prompt at 122 s).
|
||||
pub fn elevated_ps_line(file: &str, args: &str) -> String {
|
||||
format!(
|
||||
"try {{ $p = Start-Process -FilePath '{}' -ArgumentList '{}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop }} catch {{ Write-Error ('elevated launch failed (UAC refused, cancelled or timed out): ' + $_.Exception.Message); exit 251 }}; if ($null -eq $p) {{ Write-Error 'elevated launch failed: no process'; exit 251 }}; exit $p.ExitCode",
|
||||
ps_quote(file),
|
||||
ps_quote(args)
|
||||
)
|
||||
}
|
||||
|
||||
/// The exit code `elevated_ps_line` uses when the elevated process never started (the prompt refused, cancelled or
|
||||
/// timed out).
|
||||
pub const ELEVATED_LAUNCH_FAILED: i32 = 251;
|
||||
|
||||
/// The hidden PowerShell that runs `elevated_ps_line(file, args)`: blocking when run, one UAC prompt on the PC.
|
||||
pub fn elevated_command(file: &str, args: &str) -> Command {
|
||||
let mut c = Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &elevated_ps_line(file, args)]);
|
||||
quiet(&mut c);
|
||||
c
|
||||
}
|
||||
|
||||
/// Builds a command that runs without a console window on Windows.
|
||||
pub fn quiet(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(windows)]
|
||||
|
|
@ -461,8 +511,47 @@ pub fn quiet(cmd: &mut Command) -> &mut Command {
|
|||
cmd
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lock_tests {
|
||||
#[test]
|
||||
fn a_locked_folder_grants_the_user_inheritably_and_covers_what_is_inside() {
|
||||
let d = super::icacls_lock_args(true, "Admin");
|
||||
assert_eq!(d, vec!["/inheritance:r", "/grant:r", "Admin:(OI)(CI)F"], "inheritable, and never /T (it empties the children)");
|
||||
let f = super::icacls_lock_args(false, "Admin");
|
||||
assert_eq!(f, vec!["/inheritance:r", "/grant:r", "Admin:F"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn elevated_line_is_hidden_and_quoted() {
|
||||
let l = super::elevated_ps_line(r"C:\WINDOWS\system32\cmd.exe", "/c echo it's & exit 3");
|
||||
assert!(l.starts_with("try { $p = "), "{l}");
|
||||
assert!(l.contains("-FilePath 'C:\\WINDOWS\\system32\\cmd.exe' -ArgumentList '/c echo it''s & exit 3' -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop } catch {"), "{l}");
|
||||
assert!(l.contains("-Verb RunAs"), "{l}");
|
||||
assert!(l.contains("-WindowStyle Hidden"), "{l}");
|
||||
// a thrown Start-Process (the prompt refused) never falls through to `exit $p.ExitCode`
|
||||
assert!(l.contains("exit 251 }; if ($null -eq $p) { Write-Error 'elevated launch failed: no process'; exit 251 }; exit $p.ExitCode"), "{l}");
|
||||
assert!(l.ends_with("exit $p.ExitCode"), "{l}");
|
||||
assert_eq!(super::ELEVATED_LAUNCH_FAILED, 251);
|
||||
assert_eq!(super::elevated_failure(Some(251), "elevated launch failed (UAC refused, cancelled or timed out): ..."), "the administrator prompt was refused, cancelled or timed out");
|
||||
assert_eq!(super::elevated_failure(Some(1), "The operation was canceled by the user."), "the administrator prompt was refused, cancelled or timed out");
|
||||
assert_eq!(super::elevated_failure(Some(2), ""), "the elevated step exited with code 2");
|
||||
assert_eq!(super::elevated_failure(Some(3), "nvidia-smi: bad"), "nvidia-smi: bad");
|
||||
assert_eq!(super::ps_quote("a'b''c"), "a''b''''c");
|
||||
assert_eq!(super::ps_quote("plain"), "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elevated_command_is_a_hidden_powershell() {
|
||||
let c = super::elevated_command("powershell.exe", "-NoProfile -File \"C:\\x y\\elevated.ps1\"");
|
||||
let args: Vec<String> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(&args[..4], ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"]);
|
||||
assert!(args[4].contains("-ArgumentList '-NoProfile -File \"C:\\x y\\elevated.ps1\"' -Verb RunAs -Wait -WindowStyle Hidden"), "{}", args[4]);
|
||||
assert!(c.get_program().to_string_lossy().contains("powershell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_redaction() {
|
||||
let l = "dashboard at http://127.0.0.1:58776/t/a3a01c537130bceeaa1f6118ba48d63e/ (log x)";
|
||||
|
|
|
|||
265
app/igneum-app/src/powertask.rs
Normal file
265
app/igneum-app/src/powertask.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
//! One administrator approval, ever (the project lead, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning:
|
||||
//! "can we make sure all these popups are not needed in future?").
|
||||
//!
|
||||
//! What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; but every later cap (an app
|
||||
//! start, a reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. This module
|
||||
//! makes the first approval the last: the one elevated step also registers a per-user Windows scheduled task,
|
||||
//! `Igneum Power Helper`, principal = the signed-in user, RunLevel Highest, no trigger, whose action is this very
|
||||
//! executable with `--power-helper`. A task the user owns can be STARTED by the user's unelevated processes without a
|
||||
//! prompt (`Start-ScheduledTask`), and it runs elevated; so every later cap and tune starts the task and talks to it
|
||||
//! through the command file `<app data>/app/sweep/cmd.txt` (the protocol the 0.3.9 helper scripts spoke: `<seq> pl
|
||||
//! <watts>`, `<seq> lgc <MHz>`, `<seq> rgc`, `quit`; plus `remove`, the kill switch). The task survives app restarts,
|
||||
//! updates (the per-user installer replaces the exe in place; the task's action path is the install folder) and
|
||||
//! reboots (a task, not a process). Power control off starts the task once and sends `remove`: the helper unregisters
|
||||
//! the task (elevated) and exits; nothing is left behind.
|
||||
//!
|
||||
//! Threat note (what the helper will and will not run):
|
||||
//! - The action is fixed at registration: the app's own exe in the install folder with `--power-helper`. The task
|
||||
//! has no trigger and no arguments from outside; only `Start-ScheduledTask` by the owning user starts it.
|
||||
//! - The helper reads ONE file, `<app data>/app/sweep/cmd.txt`, in the user's own profile. Every command it accepts
|
||||
//! is a fixed verb with digit-only arguments: `pl <watts>` runs `nvidia-smi -i <dev> -pl <watts>`, `lgc <MHz>` runs
|
||||
//! `nvidia-smi -i <dev> -lgc 0,<MHz>`, `rgc` runs `nvidia-smi -i <dev> -rgc`, `quit` ends it, `remove` unregisters
|
||||
//! the task and ends it. The device index is digits only too (`dev <n>` sets it). No shell, no path, no string from
|
||||
//! the file reaches a process: `Command::new(nvidia-smi).args([...])`, never `cmd /c`.
|
||||
//! - nvidia-smi is resolved to the driver's install path (platform::tool), never from PATH.
|
||||
//! - What an attacker running as the user gains: the power limit and the clock cap of the user's own NVIDIA cards,
|
||||
//! within the ranges the driver allows, which the same user could set with one approved prompt anyway. Nothing
|
||||
//! else: no file, no process, no registry, no other binary.
|
||||
//! - The helper exits after 20 idle minutes; a stale command file is cleared at start (sequence numbers must rise).
|
||||
//! - Linux keeps pkexec per step (no scheduled task); macOS has no cap to set.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The task name in the Windows Task Scheduler (per user).
|
||||
pub const TASK_NAME: &str = "Igneum Power Helper";
|
||||
/// The helper ends after this long without a new command.
|
||||
pub const IDLE_S: u64 = 20 * 60;
|
||||
|
||||
/// One parsed command from cmd.txt.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HelperCmd {
|
||||
Dev(String),
|
||||
PowerLimit(u64),
|
||||
ClockCap(u64),
|
||||
ClockReset,
|
||||
Quit,
|
||||
Remove,
|
||||
}
|
||||
|
||||
/// Parses one line: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` reads as a power limit; `quit` and
|
||||
/// `remove` need no sequence). Anything that is not a fixed verb with digit-only arguments is None.
|
||||
pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
|
||||
let t = line.trim();
|
||||
if t == "quit" {
|
||||
return Some((0, HelperCmd::Quit));
|
||||
}
|
||||
if t == "remove" {
|
||||
return Some((0, HelperCmd::Remove));
|
||||
}
|
||||
let p: Vec<&str> = t.split_whitespace().collect();
|
||||
let digits = |s: &str| !s.is_empty() && s.len() <= 6 && s.chars().all(|c| c.is_ascii_digit());
|
||||
let seq: u64 = p.first().filter(|s| digits(s)).and_then(|s| s.parse().ok())?;
|
||||
match p.as_slice() {
|
||||
[_, w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))),
|
||||
[_, "pl", w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))),
|
||||
[_, "lgc", m] if digits(m) => Some((seq, HelperCmd::ClockCap(m.parse().ok()?))),
|
||||
[_, "rgc"] => Some((seq, HelperCmd::ClockReset)),
|
||||
[_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing).
|
||||
pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
|
||||
match c {
|
||||
HelperCmd::PowerLimit(w) => Some(vec!["-i".into(), dev.into(), "-pl".into(), w.to_string()]),
|
||||
HelperCmd::ClockCap(m) => Some(vec!["-i".into(), dev.into(), "-lgc".into(), format!("0,{m}")]),
|
||||
HelperCmd::ClockReset => Some(vec!["-i".into(), dev.into(), "-rgc".into()]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is this
|
||||
/// executable's path in the install folder. Principal: the signed-in user, interactive logon, highest run level; no
|
||||
/// trigger; may start on battery; one hour limit per run; multiple starts are ignored while one runs.
|
||||
pub fn register_script(exe: &Path) -> String {
|
||||
let exe = exe.display().to_string().replace('\'', "''");
|
||||
format!(
|
||||
"$a = New-ScheduledTaskAction -Execute '{exe}' -Argument '--power-helper' -WorkingDirectory '{dir}'\r\n\
|
||||
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest\r\n\
|
||||
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew -Hidden\r\n\
|
||||
Register-ScheduledTask -TaskName '{name}' -Action $a -Principal $p -Settings $s -Force | Out-Null\r\n\
|
||||
exit 0\r\n",
|
||||
dir = exe.rfind(['\\', '/']).map(|i| exe[..i].to_string()).unwrap_or_default(),
|
||||
name = TASK_NAME
|
||||
)
|
||||
}
|
||||
|
||||
/// The PowerShell that starts the task from an unelevated process (no prompt: the user owns the task).
|
||||
pub fn start_command() -> String {
|
||||
format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0")
|
||||
}
|
||||
|
||||
/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1).
|
||||
pub fn query_command() -> String {
|
||||
format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}")
|
||||
}
|
||||
|
||||
/// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left.
|
||||
pub fn remove_command() -> String {
|
||||
format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false; exit 0")
|
||||
}
|
||||
|
||||
/// Is the task registered? Windows only; false elsewhere.
|
||||
pub fn registered() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
let mut c = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]);
|
||||
crate::platform::quiet(&mut c);
|
||||
c.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Starts the task (no prompt). Ok when Start-ScheduledTask returned 0.
|
||||
pub fn start() -> Result<(), String> {
|
||||
let mut c = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &start_command()]);
|
||||
crate::platform::quiet(&mut c);
|
||||
let out = c.output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Start-ScheduledTask failed: {}", String::from_utf8_lossy(&out.stderr).trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The helper process (`igneum-app --power-helper`): polls `<dir>/cmd.txt` twice a second, runs the parsed commands
|
||||
/// through nvidia-smi, logs what it ran to `<dir>/helper.log`, ends on `quit`, on `remove` (after unregistering the
|
||||
/// task) or after 20 idle minutes. `dir` is `<app data>/app/sweep`.
|
||||
pub fn run_helper(dir: &Path) -> i32 {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let cmd_file = dir.join("cmd.txt");
|
||||
let log_file = dir.join("helper.log");
|
||||
let log = |line: &str| {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).create(true).open(&log_file) {
|
||||
let _ = writeln!(f, "{} {line}", crate::platform::unix_now());
|
||||
}
|
||||
};
|
||||
log("helper started (scheduled task, elevated)");
|
||||
// a stale file from an earlier run is not a command: only lines after the start count
|
||||
let mut last_seq: u64 = std::fs::read_to_string(&cmd_file).ok().and_then(|t| t.lines().filter_map(parse_line).map(|(s, _)| s).max()).unwrap_or(0);
|
||||
let mut last_text = String::new();
|
||||
let mut dev = "0".to_string();
|
||||
let mut idle = Instant::now();
|
||||
let smi = crate::platform::tool("nvidia-smi");
|
||||
loop {
|
||||
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
||||
if text != last_text {
|
||||
last_text = text.clone();
|
||||
for (seq, c) in text.lines().filter_map(parse_line) {
|
||||
match c {
|
||||
HelperCmd::Quit => {
|
||||
log("quit");
|
||||
return 0;
|
||||
}
|
||||
HelperCmd::Remove => {
|
||||
let mut p = std::process::Command::new(crate::platform::tool("powershell"));
|
||||
p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &remove_command()]);
|
||||
crate::platform::quiet(&mut p);
|
||||
let ok = p.status().map(|s| s.success()).unwrap_or(false);
|
||||
log(&format!("remove: the task is {}", if ok { "unregistered" } else { "still registered (Unregister-ScheduledTask failed)" }));
|
||||
return if ok { 0 } else { 1 };
|
||||
}
|
||||
_ if seq <= last_seq => continue,
|
||||
HelperCmd::Dev(d) => {
|
||||
last_seq = seq;
|
||||
idle = Instant::now();
|
||||
dev = d;
|
||||
log(&format!("{seq} dev {dev}"));
|
||||
}
|
||||
other => {
|
||||
last_seq = seq;
|
||||
idle = Instant::now();
|
||||
let args = smi_args(&dev, &other).unwrap_or_default();
|
||||
let mut p = std::process::Command::new(&smi);
|
||||
p.args(&args);
|
||||
crate::platform::quiet(&mut p);
|
||||
let out = p.output().map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_else(|e| e.to_string());
|
||||
log(&format!("{seq} nvidia-smi {} : {}", args.join(" "), out.replace('\n', " ").trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
|
||||
log("idle 20 min: exit (the engine starts the task again when it needs it)");
|
||||
return 0;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the command file lives for a data root.
|
||||
pub fn sweep_dir(app_dir: &Path) -> PathBuf {
|
||||
app_dir.join("sweep")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_fixed_verbs_with_digit_arguments_parse() {
|
||||
assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460))));
|
||||
assert_eq!(parse_line("8 lgc 2472"), Some((8, HelperCmd::ClockCap(2472))));
|
||||
assert_eq!(parse_line("9 rgc"), Some((9, HelperCmd::ClockReset)));
|
||||
assert_eq!(parse_line("3 dev 1"), Some((3, HelperCmd::Dev("1".into()))));
|
||||
assert_eq!(parse_line("5 403"), Some((5, HelperCmd::PowerLimit(403))), "the 0.3.9 form");
|
||||
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
|
||||
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
|
||||
// nothing else: no shell, no path, no string argument, no oversized number
|
||||
for bad in ["7 pl 460; calc", "7 pl -460", "7 pl 4.60", "7 lgc 0,2472", "7 rm C:\\x", "x pl 460", "7 pl", "7 lgc 12345678", "7 dev ../1", "", "7 pl 460 extra"] {
|
||||
assert_eq!(parse_line(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arguments_reach_nvidia_smi_as_a_list_never_a_shell() {
|
||||
assert_eq!(smi_args("0", &HelperCmd::PowerLimit(460)).unwrap(), vec!["-i", "0", "-pl", "460"]);
|
||||
assert_eq!(smi_args("1", &HelperCmd::ClockCap(2472)).unwrap(), vec!["-i", "1", "-lgc", "0,2472"]);
|
||||
assert_eq!(smi_args("1", &HelperCmd::ClockReset).unwrap(), vec!["-i", "1", "-rgc"]);
|
||||
assert_eq!(smi_args("0", &HelperCmd::Quit), None);
|
||||
assert_eq!(smi_args("0", &HelperCmd::Remove), None);
|
||||
assert_eq!(smi_args("0", &HelperCmd::Dev("1".into())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registration_is_per_user_highest_no_trigger_fixed_action() {
|
||||
let s = register_script(Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe"));
|
||||
assert!(s.contains("-Execute 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner\\igneum-app.exe' -Argument '--power-helper'"), "{s}");
|
||||
assert!(s.contains("-WorkingDirectory 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}");
|
||||
assert!(s.contains("-RunLevel Highest") && s.contains("-LogonType Interactive"), "{s}");
|
||||
assert!(s.contains("[System.Security.Principal.WindowsIdentity]::GetCurrent().Name"), "the signed-in user, never a literal");
|
||||
assert!(!s.contains("-Trigger"), "no trigger: only the app starts it");
|
||||
assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}");
|
||||
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
|
||||
// a quote in the path cannot break out of the literal
|
||||
let q = register_script(Path::new(r"C:\it's\igneum-app.exe"));
|
||||
assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}");
|
||||
assert!(start_command().starts_with("Start-ScheduledTask -TaskName 'Igneum Power Helper'"));
|
||||
assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Power Helper' -Confirm:$false"));
|
||||
assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_command_file_does_not_run_at_start() {
|
||||
// the helper's start reads the highest sequence already in the file and runs nothing below or at it
|
||||
let text = "3 pl 460\n4 lgc 2472\n";
|
||||
let last = text.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0);
|
||||
assert_eq!(last, 4);
|
||||
let newer: Vec<_> = "3 pl 460\n4 lgc 2472\n5 rgc\n".lines().filter_map(parse_line).filter(|(s, _)| *s > last).collect();
|
||||
assert_eq!(newer, vec![(5, HelperCmd::ClockReset)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ pub enum Source {
|
|||
Watch,
|
||||
Miner(usize), // card index
|
||||
Telemetry, // nvidia-smi -l 5
|
||||
AmdTelemetry, // igneum-gpu-telemetry -l 5 (ADLX or sysfs), 5 October 2026
|
||||
}
|
||||
|
||||
impl Source {
|
||||
|
|
@ -22,6 +23,7 @@ impl Source {
|
|||
Source::Watch => "watch".into(),
|
||||
Source::Miner(i) => format!("miner{}", i + 1),
|
||||
Source::Telemetry => "gpu".into(),
|
||||
Source::AmdTelemetry => "gpu-amd".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,10 +42,14 @@ pub struct Proc {
|
|||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// Starts a program with stdout and stderr piped; every line goes to `tx` and to the log file.
|
||||
pub fn spawn(src: Source, exe: &Path, args: &[String], cwd: Option<&Path>, log_path: &Path, tx: &Sender<Line>) -> std::io::Result<Proc> {
|
||||
/// Starts a program with stdout and stderr piped; every line goes to `tx` and to the log file. `envs` are added to
|
||||
/// the child's environment (a miner passes IGNEUM_TUNING_FILE on to its GPU worker).
|
||||
pub fn spawn(src: Source, exe: &Path, args: &[String], cwd: Option<&Path>, log_path: &Path, tx: &Sender<Line>, envs: &[(String, String)]) -> std::io::Result<Proc> {
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
for (k, v) in envs {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if let Some(d) = cwd {
|
||||
cmd.current_dir(d);
|
||||
}
|
||||
|
|
|
|||
169
app/igneum-app/src/provedefault.rs
Normal file
169
app/igneum-app/src/provedefault.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! Proving v1 step 1 (5 October 2026, the project lead: "open the proving round asap"): the prover is on by default on every
|
||||
//! mining machine that can prove, decided once per install after the cards are detected (src/engine.rs
|
||||
//! `apply_prove_default`). The rule, one line each:
|
||||
//!
|
||||
//! | Machine | Default | Why (bench-log 5 October 2026, "proving v1", the S_p curve on the RTX 5090, SP1 6.8.1's GPU prover) |
|
||||
//! |---|---|---|
|
||||
//! | NVIDIA card with 24 GB or more, mining or not, Windows with WSL2 (Ubuntu-24.04) answering or Linux | on | a full shard at the adopted v1 budget (30,000 pgas, 4.7 M cycles) peaks at 20,434 MiB alone and 22,210 beside the miner (measured on the 5090; approximate for a 24 GB card's own allocation); the prototype shard the devnet proves until its fee switch (6.75 M pgas) peaks at 28,307 MiB alone and 30,039 beside the miner, so until the switch only a 32 GB card proves it and a 24 GB card's prover waits for shards it can hold (the host refuses nothing; a proof that runs out of memory fails and the shard is left) |
|
||||
//! | NVIDIA card of 16 to 24 GB | off, with the line saying why | the GPU prover's floor is 13,874 MiB for an EMPTY shard, 15,670 beside the miner; a 16 GB card holds no full shard |
|
||||
//! | NVIDIA card under 16 GB | off | 13,874 MiB does not fit; the project lead's 12 GB requirement is open until a prover build with a smaller floor is measured |
|
||||
//! | Windows under 32 GB of RAM | off, with the line saying why | the WSL2 prover held 7.9 GB on a 63 GB PC; a 16 GB PC would swap |
|
||||
//! | Windows with a qualifying card but WSL2 silent | off, with the Set up hint | nothing can prove until the distribution exists |
|
||||
//! | Apple silicon | off | the M5 Max CPU took 41 to 55 s for an EMPTY shard's compressed proof under load and 272 s for a 200-pgas shard; a full shard was never under 60 s (bench-log 4 and 5 October 2026) |
|
||||
//! | AMD-only (no NVIDIA card) | off, "mines and does not prove" | no zkVM proves on an AMD GPU today (docs/analysis/amd-proving.md); the SP1 CPU prover on PC 1 cost 82 to 87 s core plus 199 to 202 s compressed a shard at a 30 GB RSS whatever the shard size (bench-log, "the SP1 CPU prover on PC 1") |
|
||||
//!
|
||||
//! Decided 5 October 2026 (delegated by the project lead: "deploy what is absolute best"), docs/plans/proving-v1.md. The default
|
||||
//! never switches an explicit on back off, and Settings always wins afterwards.
|
||||
|
||||
use crate::state::CardState;
|
||||
|
||||
/// A card that proves, mining or not, needs this much: the adopted v1 shard peaks at 20,434 MiB alone (the S_p curve,
|
||||
/// 5 October 2026) and 22,210 beside the miner; `nvidia-smi` reports MiB and a 24 GB card reports 24,564, so the
|
||||
/// test is at 23 GB. (The same value for a mining and an idle card: the floor is the GPU server's, not the miner's.)
|
||||
pub const MIN_VRAM_MB_MINING: u64 = 23_552;
|
||||
pub const MIN_VRAM_MB_PROVE_ONLY: u64 = 23_552;
|
||||
/// What a 32 GB card alone can do that a 24 GB one cannot: the prototype shard (28,307 MiB alone, 30,039 beside the
|
||||
/// miner), the devnet's shard until its fee switch at DAA 210,000; `nvidia-smi` reports 32,607 for the RTX 5090.
|
||||
pub const VRAM_MB_PROTOTYPE_SHARD: u64 = 31_000;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Decision {
|
||||
pub on: bool,
|
||||
/// One plain sentence for the log and the Proving tile.
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
fn gb(mb: u64) -> u64 {
|
||||
(mb + 512) / 1024
|
||||
}
|
||||
|
||||
/// Windows machines under this much RAM stay off until measured (consequences review C4, 5 October 2026): PC 2 at
|
||||
/// 63 GB had 25.6 GB in use with the WSL2 VM's working set at 7.9 GB while proving; a 16 GB PC would swap.
|
||||
pub const MIN_RAM_MB_WINDOWS: u64 = 31_000;
|
||||
|
||||
/// The card an aggregation (the chained SP1 recursion, spec 7.8) may run on: 16,751 MiB measured with the miner
|
||||
/// resident (13.4 GB alone, approximate), so a 24 GB card mining or not; the same gate as the shard prover.
|
||||
pub fn aggregation_card(cards: &[CardState]) -> Option<&CardState> {
|
||||
cards.iter().filter(|c| c.vendor == "nvidia" && c.vram_mb >= if c.enabled { MIN_VRAM_MB_MINING } else { MIN_VRAM_MB_PROVE_ONLY }).max_by_key(|c| c.vram_mb)
|
||||
}
|
||||
|
||||
/// `os` is `std::env::consts::OS` ("windows", "linux", "macos"); `wsl_answers` is read on Windows only; `ram_mb` is the
|
||||
/// machine's RAM when the platform reports it (None = unknown, no gate).
|
||||
pub fn decide(cards: &[CardState], os: &str, wsl_answers: Option<bool>, ram_mb: Option<u64>) -> Decision {
|
||||
let nvidia: Vec<&CardState> = cards.iter().filter(|c| c.vendor == "nvidia").collect();
|
||||
// a mining card needs 20 GB (the measured mine-and-prove peak of 16.8 GB), a card that only proves 16 GB
|
||||
let able: Vec<&CardState> = nvidia.iter().copied().filter(|c| c.vram_mb >= if c.enabled { MIN_VRAM_MB_MINING } else { MIN_VRAM_MB_PROVE_ONLY }).collect();
|
||||
let off = |line: String| Decision { on: false, line };
|
||||
if os == "macos" {
|
||||
return off("proving stays off on Apple silicon: the M5 Max CPU took 41 to 55 s for an empty shard and minutes for a full one; Settings switches it on (CPU, slow)".into());
|
||||
}
|
||||
let Some(best) = able.iter().max_by_key(|c| c.vram_mb) else {
|
||||
let seen = if nvidia.is_empty() {
|
||||
"no NVIDIA card".to_string()
|
||||
} else {
|
||||
nvidia.iter().map(|c| format!("{} {} GB{}", c.name, gb(c.vram_mb), if c.enabled { ", mining" } else { "" })).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
let why = if nvidia.iter().any(|c| c.vram_mb >= 15_872) {
|
||||
"a full shard needs a 24 GB card (measured 20.4 GB on the adopted shard size, 13.9 GB for an empty one); this card is under that, so Settings would switch proving on at your own risk"
|
||||
} else if nvidia.is_empty() {
|
||||
"this machine mines and does not prove: no zkVM proves on an AMD GPU today, and the CPU prover costs about 5 minutes a shard at a 30 GB RSS (bench-log, the SP1 CPU prover on PC 1); proving needs an NVIDIA card with 24 GB or more"
|
||||
} else {
|
||||
"no NVIDIA card with 24 GB or more (the GPU prover's floor is 13.9 GB for an empty shard and 20.4 GB for a full one)"
|
||||
};
|
||||
return off(format!("proving off by default: {why} ({seen})"));
|
||||
};
|
||||
let card = format!("{} ({} GB{})", best.name, gb(best.vram_mb), if best.enabled { ", mining too" } else { ", proving only" });
|
||||
if os == "windows" {
|
||||
if let Some(ram) = ram_mb {
|
||||
if ram < MIN_RAM_MB_WINDOWS {
|
||||
return off(format!("proving off by default: {card} qualifies but this PC has {} GB of RAM; proving needs 32 GB on Windows until a smaller PC is measured (the WSL2 prover held 7.9 GB on a 63 GB PC); Settings switches it on", gb(ram)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let size_note = if best.vram_mb >= VRAM_MB_PROTOTYPE_SHARD { "" } else { "; until the devnet's fee switch its shards are the prototype size, which needs 32 GB, so this card proves from the switch on" };
|
||||
match os {
|
||||
"windows" => match wsl_answers {
|
||||
Some(true) => Decision { on: true, line: format!("proving on by default: {card} with WSL2 (Ubuntu-24.04 answers){size_note}; Settings switches it off") },
|
||||
_ => off(format!("proving off: {card} qualifies but WSL2 (Ubuntu-24.04) did not answer; Set up installs it, then Settings switches proving on")),
|
||||
},
|
||||
"linux" => Decision { on: true, line: format!("proving on by default: {card} on Linux (the host runs next to the engine){size_note}; Settings switches it off") },
|
||||
other => off(format!("proving off: {card} on {other}, no prover path there; Settings switches it on")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn card(vendor: &str, name: &str, vram_mb: u64) -> CardState {
|
||||
CardState { vendor: vendor.into(), name: name.into(), vram_mb, enabled: true, ..Default::default() }
|
||||
}
|
||||
fn idle(vendor: &str, name: &str, vram_mb: u64) -> CardState {
|
||||
CardState { enabled: false, ..card(vendor, name, vram_mb) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_5090_with_wsl2_on_windows_is_on() {
|
||||
let d = decide(&[card("nvidia", "NVIDIA GeForce RTX 5090", 32_607), card("amd", "AMD Radeon(TM) Graphics", 512)], "windows", Some(true), Some(63_132));
|
||||
assert!(d.on);
|
||||
assert!(d.line.starts_with("proving on by default: NVIDIA GeForce RTX 5090 (32 GB, mining too) with WSL2"), "{}", d.line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_without_wsl2_is_off_with_the_setup_hint() {
|
||||
let d = decide(&[card("nvidia", "NVIDIA GeForce RTX 4090", 24_564)], "windows", Some(false), Some(65_000));
|
||||
assert!(!d.on);
|
||||
assert!(d.line.contains("did not answer") && d.line.contains("Set up"), "{}", d.line);
|
||||
assert!(!decide(&[card("nvidia", "RTX 4090", 24_564)], "windows", None, Some(65_000)).on, "an unread probe is not an answer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_needs_no_wsl2_and_the_memory_gates_hold() {
|
||||
// the tiers of the S_p curve: 32 GB on with no note; 24 GB on with the prototype-size note; 16 GB and 12 GB off
|
||||
let d = decide(&[card("nvidia", "NVIDIA GeForce RTX 5090", 32_607)], "linux", None, None);
|
||||
assert!(d.on && !d.line.contains("fee switch"), "{}", d.line);
|
||||
let d = decide(&[card("nvidia", "NVIDIA GeForce RTX 4090", 24_564)], "linux", None, None);
|
||||
assert!(d.on && d.line.contains("needs 32 GB, so this card proves from the switch on"), "{}", d.line);
|
||||
assert!(decide(&[idle("nvidia", "NVIDIA GeForce RTX 4090", 24_564)], "linux", None, None).on, "mining or not, 24 GB proves");
|
||||
let d = decide(&[card("nvidia", "NVIDIA GeForce RTX 5080", 16_303)], "linux", None, None);
|
||||
assert!(!d.on);
|
||||
assert!(d.line.contains("a full shard needs a 24 GB card") && d.line.contains("RTX 5080 16 GB, mining"), "{}", d.line);
|
||||
assert!(!decide(&[idle("nvidia", "NVIDIA GeForce RTX 5080", 16_303)], "linux", None, None).on, "16 GB holds no full shard even alone");
|
||||
let d = decide(&[idle("nvidia", "NVIDIA GeForce RTX 3060", 12_288)], "linux", None, None);
|
||||
assert!(!d.on);
|
||||
assert!(d.line.contains("no NVIDIA card with 24 GB or more") && d.line.contains("RTX 3060 12 GB"), "{}", d.line);
|
||||
assert!(!decide(&[card("nvidia", "NVIDIA GeForce RTX 3080", 10_240)], "linux", None, None).on);
|
||||
let d = decide(&[card("amd", "Radeon RX 9070 XT", 16_384)], "linux", None, None);
|
||||
assert!(!d.on && d.line.contains("mines and does not prove"), "{}", d.line);
|
||||
assert!(decide(&[], "linux", None, None).line.contains("mines and does not prove"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_silicon_stays_off() {
|
||||
let d = decide(&[card("apple", "Apple M5 Max", 65_536)], "macos", None, Some(65_536));
|
||||
assert!(!d.on);
|
||||
assert!(d.line.contains("Apple silicon"));
|
||||
assert!(!decide(&[card("nvidia", "RTX 5090", 32_607)], "macos", Some(true), None).on, "the OS rule comes first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_windows_pc_under_32_gb_stays_off_and_the_aggregation_card_follows_the_same_gate() {
|
||||
let d = decide(&[card("nvidia", "NVIDIA GeForce RTX 4090", 24_564)], "windows", Some(true), Some(16_300));
|
||||
assert!(!d.on);
|
||||
assert!(d.line.contains("16 GB of RAM") && d.line.contains("needs 32 GB on Windows"), "{}", d.line);
|
||||
assert!(decide(&[card("nvidia", "NVIDIA GeForce RTX 4090", 24_564)], "windows", Some(true), None).on, "unknown RAM is not a gate");
|
||||
assert!(decide(&[card("nvidia", "NVIDIA GeForce RTX 4090", 24_564)], "linux", None, Some(16_300)).on, "the RAM gate is Windows only (the WSL2 VM)");
|
||||
let cards = [card("nvidia", "RTX 5080", 16_303), idle("nvidia", "RTX 4070 Ti", 12_282)];
|
||||
assert!(aggregation_card(&cards).is_none(), "a mining 16 GB card and an idle 12 GB card cannot aggregate");
|
||||
let cards = [card("nvidia", "RTX 5080", 16_303), idle("nvidia", "RTX 4090", 24_564)];
|
||||
assert_eq!(aggregation_card(&cards).map(|c| c.name.as_str()), Some("RTX 4090"));
|
||||
let cards = [card("nvidia", "RTX 5090", 32_607)];
|
||||
assert_eq!(aggregation_card(&cards).map(|c| c.vram_mb), Some(32_607));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_biggest_qualifying_card_is_named() {
|
||||
let d = decide(&[idle("nvidia", "RTX 4090", 24_564), card("nvidia", "RTX 5090", 32_607)], "linux", None, None);
|
||||
assert!(d.line.contains("RTX 5090 (32 GB, mining too)"), "{}", d.line);
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -41,6 +47,8 @@ pub struct Work {
|
|||
pub shard_wei: u128,
|
||||
/// The first of this machine's keys that is assigned (the label that signs).
|
||||
pub key_hash: String,
|
||||
/// The chain block's DAA score (the deadline clock of spec 7.8).
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
/// Parses the node's work list. Newest first, as the node returns it.
|
||||
|
|
@ -61,6 +69,7 @@ pub fn parse_work(v: &Value) -> Vec<Work> {
|
|||
in_pool: w["pool"].as_array().map(|p| !p.is_empty()).unwrap_or(false),
|
||||
shard_wei: hexu(&w["shardWei"]),
|
||||
key_hash: w["assignedKeys"].as_array().and_then(|k| k.first()).and_then(|k| k.as_str()).unwrap_or("").to_string(),
|
||||
daa: hexu(&w["daaScore"]) as u64,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
|
|
@ -80,15 +89,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,19 +155,25 @@ 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 probe = Command::new(crate::platform::tool("wsl")).args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", &script]).output();
|
||||
// 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");
|
||||
match host {
|
||||
Some(host) => {
|
||||
let export = host.parent().map(|d| d.join("igneum-prove-export")).unwrap_or_default();
|
||||
// a Linux path: never PathBuf::join here, which writes a backslash on Windows ("/opt/igneum\\igneum-prove-export"
|
||||
// broke every export on PC 2 under 0.3.7, 5 October 2026)
|
||||
let export = PathBuf::from(format!("{}/igneum-prove-export", host.to_string_lossy().rsplit_once('/').map(|(d, _)| d).unwrap_or("")));
|
||||
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");
|
||||
|
|
@ -178,23 +185,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())) };
|
||||
|
|
@ -241,6 +291,44 @@ fn set<F: FnOnce(&mut crate::state::ProvingState)>(shared: &Shared, f: F) {
|
|||
f(&mut st.proving);
|
||||
}
|
||||
|
||||
/// The pinned ids out of `igneum-prove-host --mode id` ("RESULT id: pinned guests: shard program id 0x... (...)
|
||||
/// aggregator id 0x... (...)"): (shard program id, aggregator id). None when the line is not there.
|
||||
pub fn ids_from_describe(text: &str) -> Option<(String, String)> {
|
||||
let line = text.lines().find(|l| l.contains("shard program id "))?;
|
||||
let after = |key: &str| -> Option<String> {
|
||||
let rest = line.split(key).nth(1)?.trim_start();
|
||||
let id: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric()).collect();
|
||||
(id.starts_with("0x") && id.len() == 66).then_some(id)
|
||||
};
|
||||
Some((after("shard program id ")?, after("aggregator id ")?))
|
||||
}
|
||||
|
||||
/// Reads the pinned ids once (`--mode id` does no key setup, under a second) and puts them on the state. The Prove
|
||||
/// page shows them with the verifier state, proving on or off.
|
||||
fn read_ids(shared: &Shared, t: &Tools) {
|
||||
if !shared.state.lock().unwrap().proving.program_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
// not run_tool: that stops the child while proving is off, and the ids are wanted proving on or off
|
||||
let out = if t.wsl {
|
||||
let body = format!("export RUST_LOG=off\nexec {} --mode id", crate::wslhost::sq(&t.host.display().to_string()));
|
||||
let Ok(file) = crate::wslhost::write_script("prove-ids", &body) else { return };
|
||||
crate::detect::run_timeout(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[]), None, Duration::from_secs(60))
|
||||
} else {
|
||||
crate::detect::run_timeout(Command::new(&t.host).args(["--mode", "id"]).env("RUST_LOG", "off"), None, Duration::from_secs(60))
|
||||
};
|
||||
match ids_from_describe(&out.unwrap_or_default()) {
|
||||
Some((shard, agg)) => {
|
||||
shared.log(&format!("prover: pinned shard program id {shard}, aggregator id {agg}"));
|
||||
set(shared, |p| {
|
||||
p.program_id = shard;
|
||||
p.aggregator_id = agg;
|
||||
});
|
||||
}
|
||||
None => shared.log("prover: --mode id printed no pinned ids (an older host)"),
|
||||
}
|
||||
}
|
||||
|
||||
static BIN_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Starts the prover thread. It idles while the setting is off or the node is not synced.
|
||||
|
|
@ -254,19 +342,42 @@ pub fn start(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
|
||||
fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
||||
let mut attempted: HashSet<(String, u32)> = HashSet::new();
|
||||
let mut attempted_segments: HashSet<u64> = HashSet::new();
|
||||
let mut tools: Option<Tools> = None;
|
||||
let ram_mb = crate::detect::total_ram_mb();
|
||||
let mut last_probe = Instant::now() - Duration::from_secs(600);
|
||||
let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new();
|
||||
// proving v1 segment path: (first, last, aggregator wei) of the segment records this machine submitted
|
||||
let mut submitted_segments: Vec<(u64, u64, u128)> = Vec::new();
|
||||
let mut last_segment_secs: Option<f64> = None;
|
||||
// segment records the node refused by the chain rule ("does not chain to ... pending"): held and offered again
|
||||
// every pass until the segment's deadline (the fresh-record window of spec 7.8 is the segment length in DAA on
|
||||
// the rule as shipped, 6 October 2026; from the fresh-rule switch the first retry lands)
|
||||
let mut held_segments: Vec<HeldSegment> = Vec::new();
|
||||
let mut last_verifier_read = Instant::now() - Duration::from_secs(600);
|
||||
let mut asked_restart = false;
|
||||
// macOS and Linux: the host sits next to the engine, so its pinned ids are read at once, proving on or off
|
||||
// (Windows runs the host inside WSL2, which is probed only once proving is on)
|
||||
if !cfg!(windows) {
|
||||
if let Ok(t) = find_tools(&bin_dir) {
|
||||
read_ids(&shared, &t);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
|
@ -284,6 +395,14 @@ 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()));
|
||||
}
|
||||
read_ids(&shared, &t);
|
||||
tools = Some(t);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -299,6 +418,20 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
}
|
||||
}
|
||||
let Some(t) = tools.as_ref() else { continue };
|
||||
// consequences review C22 (5 October 2026): the SP1 CPU prover takes 29.5 to 30.5 GB of RSS and about five
|
||||
// minutes a shard whatever the shard size (PC 1, bench-log "the SP1 CPU prover on PC 1"); on a machine under
|
||||
// 32 GB it would swap the node out, so the CPU path is refused here, Settings or not, with the reason
|
||||
if !t.cuda {
|
||||
if let Some(ram) = ram_mb {
|
||||
if ram < crate::provedefault::MIN_RAM_MB_WINDOWS {
|
||||
set(&shared, |p| {
|
||||
p.status = "off".into();
|
||||
p.message = format!("the CPU prover needs 32 GB of RAM (30 GB measured on PC 1); this machine has {} GB, so proving stays off here", (ram + 512) / 1024);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = true;
|
||||
|
|
@ -327,7 +460,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
});
|
||||
continue;
|
||||
}
|
||||
let work = match evm_rpc(&shared, "igneum_getAssignedShards", json!([keys.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(), 60]), Duration::from_secs(10)) {
|
||||
let work = match evm_rpc(&shared, "igneum_getAssignedShards", json!([keys.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(), crate::segments::WORK_LOOKBACK]), Duration::from_secs(10)) {
|
||||
Ok(v) => parse_work(&v),
|
||||
Err(e) => {
|
||||
set(&shared, |p| {
|
||||
|
|
@ -354,15 +487,125 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
});
|
||||
}
|
||||
}
|
||||
// held segment records: offered again, dropped past the deadline
|
||||
if !held_segments.is_empty() {
|
||||
let tip_daa = evm_rpc(&shared, "igneum_getProvingStatus", json!([]), Duration::from_secs(10)).ok().and_then(|st| st["tipDaa"].as_str().and_then(|x| u64::from_str_radix(x.trim_start_matches("0x"), 16).ok())).unwrap_or(0);
|
||||
let mut keep = Vec::new();
|
||||
for h in held_segments.drain(..) {
|
||||
match retry_held(&shared, &h, tip_daa) {
|
||||
Retry::Accepted => {
|
||||
shared.event("proving", &format!("segment {}..{} record accepted on retry {} (held {} s)", h.first, h.last, h.tries + 1, h.since.elapsed().as_secs()));
|
||||
submitted_segments.push((h.first, h.last, h.agg_wei));
|
||||
set(&shared, |p| {
|
||||
p.segments_submitted += 1;
|
||||
p.aggregated += 1;
|
||||
p.segment_note = format!("segment {}..{} accepted on retry", h.first, h.last);
|
||||
});
|
||||
}
|
||||
Retry::Expired(why) => {
|
||||
shared.log(&format!("prover: segment {}..{} record dropped after {} tries: {why}", h.first, h.last, h.tries));
|
||||
}
|
||||
Retry::Again(why) => {
|
||||
let mut h = h;
|
||||
h.tries += 1;
|
||||
if h.tries % 30 == 1 {
|
||||
shared.log(&format!("prover: segment {}..{} record held (try {}): {why}", h.first, h.last, h.tries));
|
||||
}
|
||||
keep.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
held_segments = keep;
|
||||
set(&shared, |p| p.segments_held = held_segments.len() as u32);
|
||||
}
|
||||
// paid segments among what we submitted
|
||||
for (first, last, wei) in submitted_segments.clone() {
|
||||
let paid = evm_rpc(&shared, "igneum_getSegmentRecords", json!([format!("{first:#x}")]), Duration::from_secs(10))
|
||||
.ok()
|
||||
.map(|r| !r["paid"].is_null() && r["paid"]["payout"].as_str().map(|a| a.eq_ignore_ascii_case(&payout_address(&shared))).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if paid {
|
||||
submitted_segments.retain(|x| x.0 != first);
|
||||
shared.event("proving", &format!("segment {first}..{last} paid {} IGN to the aggregator", wei as f64 / 1e18));
|
||||
set(&shared, |p| {
|
||||
p.segments_paid += 1;
|
||||
p.segment_paid_wei += wei;
|
||||
});
|
||||
}
|
||||
}
|
||||
let assigned = work.iter().filter(|w| w.assigned).count() as u32;
|
||||
set(&shared, |p| {
|
||||
p.assigned = assigned;
|
||||
p.keys = keys.len() as u32;
|
||||
});
|
||||
// proving v1 (spec 7.8): the aggregator step, when the node says v1 is active; one attempt a pass
|
||||
if let Some((label0, _)) = keys.first() {
|
||||
match aggregate_once(&shared, t, label0, &payout_address(&shared), &mut attempted_segments) {
|
||||
Ok(Some(msg)) => {
|
||||
shared.log(&format!("aggregator: {msg}"));
|
||||
set(&shared, |p| p.segment_note = msg);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
shared.log(&format!("aggregator: {e}"));
|
||||
set(&shared, |p| p.segment_note = e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// proving v1 segment path (src/segments.rs, 6 October 2026): a whole segment first, the newest shard only
|
||||
// when no whole segment qualifies
|
||||
let payout = shared.settings.lock().unwrap().address.clone();
|
||||
if payout.len() == 42 {
|
||||
if let Some((seg, prev_file, expected_pv)) = pick_segment(&shared, &work, &keys[0].1, &mut attempted_segments, last_segment_secs) {
|
||||
attempted_segments.insert(seg.first);
|
||||
let started = Instant::now();
|
||||
match prove_segment(&shared, t, &seg, &keys[0].0, &payout, prev_file.as_deref(), &expected_pv, &mut submitted) {
|
||||
Ok(SegmentOutcome::Held(h)) => {
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
last_segment_secs = Some(secs);
|
||||
shared.event("proving", &format!("segment {}..{}: {} shards proven and submitted in {secs:.0} s; the segment record is held ({})", seg.first, seg.last, seg.shards.len(), h.why));
|
||||
set(&shared, |p| {
|
||||
p.segment_last_s = secs;
|
||||
p.status = "submitted".into();
|
||||
p.message = format!("segment {}..{}: shards submitted, the segment record waits for the chain rule", seg.first, seg.last);
|
||||
p.segment_note = format!("segment {}..{} proven whole in {secs:.0} s; its record is held: {}", seg.first, seg.last, h.why);
|
||||
p.current = String::new();
|
||||
});
|
||||
held_segments.push(h);
|
||||
set(&shared, |p| p.segments_held = held_segments.len() as u32);
|
||||
}
|
||||
Ok(SegmentOutcome::Submitted(agg_wei)) => {
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
last_segment_secs = Some(secs);
|
||||
submitted_segments.push((seg.first, seg.last, agg_wei));
|
||||
shared.event("proving", &format!("segment {}..{}: {} shards proven, aggregated and submitted in {secs:.0} s", seg.first, seg.last, seg.shards.len()));
|
||||
set(&shared, |p| {
|
||||
p.segments_submitted += 1;
|
||||
p.segment_last_s = secs;
|
||||
p.status = "submitted".into();
|
||||
p.message = format!("segment {}..{} submitted; paid when a block carries it", seg.first, seg.last);
|
||||
p.segment_note = format!("segment {}..{} proven whole in {secs:.0} s", seg.first, seg.last);
|
||||
p.current = String::new();
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
shared.log(&format!("prover: segment {}..{}: {e}", seg.first, seg.last));
|
||||
set(&shared, |p| {
|
||||
p.failed += 1;
|
||||
p.status = "idle".into();
|
||||
p.message = e.clone();
|
||||
p.segment_note = e;
|
||||
p.current = String::new();
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(w) = choose(&work, &attempted) else {
|
||||
set(&shared, |p| {
|
||||
p.status = if submitted.is_empty() { "idle".into() } else { "submitted".into() };
|
||||
p.message = if assigned == 0 { "no shard assigned to this machine and none open in the last 60 blocks".into() } else { "every assigned and open shard is proven or paid".into() };
|
||||
p.message = if assigned == 0 { format!("no shard assigned to this machine and none open in the last {} blocks", crate::segments::WORK_LOOKBACK) } else { "every assigned and open shard is proven or paid".into() };
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
|
@ -398,11 +641,15 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
if !ok || !fixture.exists() {
|
||||
return Err(format!("exporter: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
}
|
||||
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "proving on the CPU (slow)".into() });
|
||||
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "CPU prover: about five minutes a shard, 30 GB of RAM, paid only when no card proves first".into() });
|
||||
let prover_env = if t.cuda { "cuda" } else { "cpu" };
|
||||
let (ok, out) = run_tool(&shared, t, &t.host, &[fix_p, "--mode".into(), "compressed".into(), "--shard".into(), w.shard.to_string(), "--prover".into(), payout.clone(), "--out".into(), res_p], &[("SP1_PROVER", prover_env), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join(format!("prove-{}-{}.log", w.number, w.shard)));
|
||||
if !ok || !results.exists() {
|
||||
return Err(format!("prover: {}", out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed")));
|
||||
let last = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
|
||||
// the root-socket class (5 October 2026, PC 2 at 20:00Z and 21:25Z): a job that ran the host as root
|
||||
// inside WSL2 left /tmp/sp1-cuda-0.sock owned by root, and this user's client cannot open it
|
||||
let hint = if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
|
||||
return Err(format!("prover: {last}{hint}"));
|
||||
}
|
||||
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let statement = res["statement"].as_str().ok_or("no statement in the results")?.to_string();
|
||||
|
|
@ -451,6 +698,336 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A segment record the node refused by the chain rule, kept with its proof for another offer.
|
||||
pub struct HeldSegment {
|
||||
pub first: u64,
|
||||
pub last: u64,
|
||||
pub deadline_daa: u64,
|
||||
pub record: String,
|
||||
pub proof_path: PathBuf,
|
||||
pub agg_wei: u128,
|
||||
pub why: String,
|
||||
pub tries: u32,
|
||||
pub since: Instant,
|
||||
}
|
||||
|
||||
pub enum SegmentOutcome {
|
||||
Submitted(u128),
|
||||
Held(HeldSegment),
|
||||
}
|
||||
|
||||
pub enum Retry {
|
||||
Accepted,
|
||||
Again(String),
|
||||
Expired(String),
|
||||
}
|
||||
|
||||
/// Offers a held segment record again: accepted, held for another pass, or dropped past the segment's deadline.
|
||||
fn retry_held(shared: &Shared, h: &HeldSegment, tip_daa: u64) -> Retry {
|
||||
if tip_daa > 0 && tip_daa + 1 > h.deadline_daa {
|
||||
return Retry::Expired(format!("past the deadline DAA {} at tip DAA {tip_daa}", h.deadline_daa));
|
||||
}
|
||||
let Ok(proof) = std::fs::read(&h.proof_path) else { return Retry::Expired(format!("proof file {} gone", h.proof_path.display())) };
|
||||
let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
match evm_rpc(shared, "igneum_submitSegmentRecord", json!([{ "record": h.record, "proof": proof_hex }]), Duration::from_secs(60)) {
|
||||
Ok(r) if r["accepted"].as_bool().unwrap_or(false) => Retry::Accepted,
|
||||
Ok(r) => Retry::Again(r["reason"].as_str().unwrap_or("?").to_string()),
|
||||
Err(e) => Retry::Again(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Proving v1 segment path, the choice: the node's v1 status (active, the grid start, the segment length, the
|
||||
/// deadline clock), the work list grouped into whole untouched segments (`segments::whole_segments`), the
|
||||
/// candidates inside the deadline ranked for this key, then for the best three the node's segment statement:
|
||||
/// executed and pending; the previous segment either paid with its proof in this node's pool (the chain continues,
|
||||
/// `--prev`) or not paid and with no verified record of it waiting in the pool (fresh). Returns the segment, the
|
||||
/// previous proof's host path when the chain continues, and the public values the node expects.
|
||||
fn pick_segment(shared: &Shared, work: &[Work], key_hash: &str, attempted: &mut HashSet<u64>, last_secs: Option<f64>) -> Option<(crate::segments::SegmentWork, Option<String>, String)> {
|
||||
let hexu = |x: &Value| x.as_str().and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0);
|
||||
let st = evm_rpc(shared, "igneum_getProvingStatus", json!([]), Duration::from_secs(10)).ok()?;
|
||||
let v1 = &st["v1"];
|
||||
if !v1["active"].as_bool().unwrap_or(false) || v1["start"].is_null() {
|
||||
return None;
|
||||
}
|
||||
let (start, n, unproven, tip_daa) = (hexu(&v1["start"]), hexu(&v1["segmentBlocks"]).max(1), hexu(&v1["unprovenDaa"]), hexu(&st["tipDaa"]));
|
||||
let segs = crate::segments::whole_segments(start, n, unproven, work);
|
||||
let need = crate::segments::need_daa(last_secs);
|
||||
let cands = crate::segments::candidates(&segs, tip_daa, need, key_hash, attempted);
|
||||
if cands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let dir = shared.runtime.app_dir.join("proving");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let wsl = cfg!(windows);
|
||||
let as_host_path = |p: &Path| if wsl { wsl_path(p) } else { p.display().to_string() };
|
||||
for seg in cands.into_iter().take(3) {
|
||||
let Ok(stmt) = evm_rpc(shared, "igneum_getSegmentStatement", json!([format!("{:#x}", seg.first)]), Duration::from_secs(10)) else { continue };
|
||||
if !stmt["executed"].as_bool().unwrap_or(false) || stmt["status"]["status"].as_str() != Some("pending") {
|
||||
attempted.insert(seg.first);
|
||||
continue;
|
||||
}
|
||||
let prev = &stmt["previous"];
|
||||
if prev.is_null() {
|
||||
// fresh only when no record of the previous segment is waiting to be carried (the chain rule would
|
||||
// refuse a fresh record once that one pays)
|
||||
if seg.first >= start + n {
|
||||
let p = evm_rpc(shared, "igneum_getSegmentRecords", json!([format!("{:#x}", seg.first - n)]), Duration::from_secs(10)).unwrap_or(Value::Null);
|
||||
let waiting = p["pool"].as_array().map(|a| a.iter().any(|e| e["verified"] == json!(true) && e["includedIn"].is_null())).unwrap_or(false);
|
||||
if waiting || !p["paid"].is_null() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Some((seg, None, stmt["publicValuesFresh"].as_str().unwrap_or("").to_string()));
|
||||
}
|
||||
if prev["proofInPool"] != json!(true) {
|
||||
continue;
|
||||
}
|
||||
let Ok(got) = evm_rpc(shared, "igneum_getSegmentProofBytes", json!([prev["first"], prev["keyHash"]]), Duration::from_secs(60)) else { continue };
|
||||
let hex = got["proof"].as_str().unwrap_or("").trim_start_matches("0x").to_string();
|
||||
if hex.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bytes: Vec<u8> = (0..hex.len() / 2).map(|k| u8::from_str_radix(&hex[2 * k..2 * k + 2], 16).unwrap_or(0)).collect();
|
||||
let f = dir.join(format!("prev-{}.bin", seg.first));
|
||||
if std::fs::write(&f, bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
return Some((seg, Some(as_host_path(&f)), stmt["publicValuesContinuing"].as_str().unwrap_or("").to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Proving v1 segment path, the work: one export of the chain to the segment's last block, one fixture per block,
|
||||
/// one host run (`--mode chain --save-shards`, `--prev` when the chain continues) that proves every shard and
|
||||
/// aggregates the segment, then every shard record signed and submitted (the shard payouts) and the segment
|
||||
/// record signed and submitted (the aggregator share). Returns the segment's aggregator wei.
|
||||
fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork, label: &str, payout: &str, prev_file: Option<&str>, expected_pv: &str, submitted: &mut Vec<(u64, String, u32, u128)>) -> Result<SegmentOutcome, String> {
|
||||
let (first, last) = (seg.first, seg.last);
|
||||
let dir = shared.runtime.app_dir.join("proving").join(format!("seg-{first}"));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let as_host_path = |p: &Path| if t.wsl { wsl_path(p) } else { p.display().to_string() };
|
||||
set(shared, |p| {
|
||||
p.status = "proving".into();
|
||||
p.current = format!("segment {first}..{last} ({} shards)", seg.shards.len());
|
||||
p.started_at = crate::platform::unix_now_f();
|
||||
p.message = "exporting the chain and cutting the segment's blocks".into();
|
||||
});
|
||||
shared.log(&format!("prover: segment {first}..{last} claimed ({} shards{}): export, cut, chain ({}), sign, submit", seg.shards.len(), if prev_file.is_some() { ", continuing the previous segment's proof" } else { ", fresh" }, if t.cuda { "CUDA" } else { "CPU" }));
|
||||
// 1. export once, cut every block
|
||||
let seq = dir.join("seq.json");
|
||||
let export = evm_rpc(shared, "igneum_exportSegments", json!(["0x0", format!("{last:#x}")]), Duration::from_secs(300))?;
|
||||
std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?;
|
||||
let mut fixtures: Vec<String> = Vec::new();
|
||||
for b in first..=last {
|
||||
let fixture = dir.join(format!("block-{b}.json"));
|
||||
let (ok, out) = run_tool(shared, t, &t.export, &[as_host_path(&seq), b.to_string(), as_host_path(&fixture)], &[], Duration::from_secs(600), &dir.join(format!("export-{b}.log")));
|
||||
if !ok || !fixture.exists() {
|
||||
return Err(format!("exporter, block {b}: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
}
|
||||
fixtures.push(as_host_path(&fixture));
|
||||
}
|
||||
let _ = std::fs::remove_file(&seq);
|
||||
// 2. the chain: every shard proven, every block aggregated with the previous, in one process
|
||||
set(shared, |p| p.message = format!("proving {} shards and aggregating segment {first}..{last} ({})", seg.shards.len(), if t.cuda { "GPU" } else { "CPU, slow" }));
|
||||
let results = dir.join("chain-results.json");
|
||||
let mut args: Vec<String> = vec!["--mode".into(), "chain".into(), "--chain".into(), fixtures.join(","), "--prover".into(), payout.to_string(), "--save-shards".into(), "--out".into(), as_host_path(&results)];
|
||||
if let Some(pf) = prev_file {
|
||||
args.push("--prev".into());
|
||||
args.push(pf.to_string());
|
||||
}
|
||||
let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join("chain.log"));
|
||||
if !ok || !results.exists() {
|
||||
let last_line = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
|
||||
let hint = if last_line.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user: the root-socket class)" } else { "" };
|
||||
return Err(format!("chain: {last_line}{hint}"));
|
||||
}
|
||||
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let to_win = |f: &str| if t.wsl { PathBuf::from(f.replace("/mnt/c/", "C:/")) } else { PathBuf::from(f) };
|
||||
// 3. the shard records
|
||||
let chain = chain_name(shared);
|
||||
let mut shard_ok = 0usize;
|
||||
for b in res["blocks"].as_array().cloned().unwrap_or_default() {
|
||||
for r in b["shard_records"].as_array().cloned().unwrap_or_default() {
|
||||
let (number, hash, shard) = (r["number"].as_u64().unwrap_or(0), r["block_hash"].as_str().unwrap_or("").to_string(), r["shard"].as_u64().unwrap_or(0) as u32);
|
||||
let statement = r["statement"].as_str().unwrap_or("").to_string();
|
||||
let proof_sha = r["proof_sha256"].as_str().unwrap_or("").to_string();
|
||||
let proof_file = r["proof_file"].as_str().unwrap_or("").to_string();
|
||||
let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-record", label, &chain, &hash, &number.to_string(), &shard.to_string(), payout, &statement, &proof_sha]), None, Duration::from_secs(20)).ok_or("sign-record did not run")?;
|
||||
let signed: Value = serde_json::from_str(sg.lines().last().unwrap_or("")).map_err(|_| format!("sign-record: {}", sg.trim()))?;
|
||||
let record = signed["record"].as_str().ok_or("sign-record gave no record")?.to_string();
|
||||
let proof = std::fs::read(to_win(&proof_file)).map_err(|e| format!("proof file {proof_file}: {e}"))?;
|
||||
let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
let out = evm_rpc(shared, "igneum_submitProofRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?;
|
||||
if out["accepted"].as_bool().unwrap_or(false) {
|
||||
shard_ok += 1;
|
||||
let wei = seg.shards.iter().position(|(n, _, s)| *n == number && *s == shard).map(|_| seg.shard_wei / seg.shards.len().max(1) as u128).unwrap_or(0);
|
||||
submitted.push((number, hash.clone(), shard, wei));
|
||||
} else {
|
||||
shared.log(&format!("prover: segment {first}..{last}: block {number} shard {shard} record refused: {}", out["reason"].as_str().unwrap_or("?")));
|
||||
}
|
||||
}
|
||||
}
|
||||
if shard_ok != seg.shards.len() {
|
||||
return Err(format!("{shard_ok} of {} shard records accepted; the segment record is not submitted", seg.shards.len()));
|
||||
}
|
||||
set(shared, |p| {
|
||||
p.proved += shard_ok as u32;
|
||||
p.submitted += shard_ok as u32;
|
||||
});
|
||||
// 4. the segment record: the aggregated statement against the node's native one (every field but provers)
|
||||
let pv = res["segment_public_values"].as_str().ok_or("no public values in the chain results")?.to_string();
|
||||
let proof_sha = res["segment_proof_sha256"].as_str().ok_or("no segment proof hash in the chain results")?.to_string();
|
||||
let proof_file = res["segment_proof_file"].as_str().ok_or("no segment proof file in the chain results")?.to_string();
|
||||
let strip = |h: &str| { let h = h.trim_start_matches("0x"); if h.len() == 680 { format!("{}{}", &h[..472], &h[536..]) } else { h.to_string() } };
|
||||
if strip(&pv) != strip(expected_pv) {
|
||||
return Err(format!("the aggregated statement differs from the node's native statement (it would be vetoed); ours {} node {}", &pv[..66.min(pv.len())], &expected_pv[..66.min(expected_pv.len())]));
|
||||
}
|
||||
let last_hash = seg.shards.iter().rev().find(|(n, _, _)| *n == last).map(|(_, h, _)| h.clone()).ok_or("no last block hash")?;
|
||||
let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-segment-record", label, &chain, &first.to_string(), &last.to_string(), &last_hash, payout, &pv, &proof_sha]), None, Duration::from_secs(20)).ok_or("sign-segment-record did not run")?;
|
||||
let signed: Value = serde_json::from_str(sg.lines().last().unwrap_or("")).map_err(|_| format!("sign-segment-record: {}", sg.trim()))?;
|
||||
let record = signed["record"].as_str().ok_or("sign-segment-record gave no record")?.to_string();
|
||||
let proof = std::fs::read(to_win(&proof_file)).map_err(|e| format!("segment proof file {proof_file}: {e}"))?;
|
||||
let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
let r = evm_rpc(shared, "igneum_submitSegmentRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?;
|
||||
let hexu = |x: &Value| x.as_str().and_then(|s| u128::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0);
|
||||
let stmt = evm_rpc(shared, "igneum_getSegmentStatement", json!([format!("{first:#x}")]), Duration::from_secs(10)).unwrap_or(Value::Null);
|
||||
let agg_wei = hexu(&stmt["aggregatorWei"]);
|
||||
// the fixtures and the export go; the proofs stay (the next segment's chain link, and a held record's offer)
|
||||
for b in first..=last {
|
||||
let _ = std::fs::remove_file(dir.join(format!("block-{b}.json")));
|
||||
}
|
||||
if !r["accepted"].as_bool().unwrap_or(false) {
|
||||
let why = r["reason"].as_str().unwrap_or("?").to_string();
|
||||
// the chain rule's refusal ("does not chain to ... pending until DAA ..."): held, not failed; anything else
|
||||
// (a bad statement, a late carrier) is an error
|
||||
if why.contains("does not chain") {
|
||||
let deadline = hexu(&stmt["status"]["deadline_daa"]) as u64;
|
||||
return Ok(SegmentOutcome::Held(HeldSegment { first, last, deadline_daa: if deadline > 0 { deadline } else { u64::MAX }, record, proof_path: to_win(&proof_file), agg_wei, why, tries: 0, since: Instant::now() }));
|
||||
}
|
||||
return Err(format!("segment record refused: {why}"));
|
||||
}
|
||||
set(shared, |p| p.aggregated += 1);
|
||||
Ok(SegmentOutcome::Submitted(agg_wei))
|
||||
}
|
||||
|
||||
/// Proving v1 (spec 7.8): one aggregation attempt. When the node reports v1 active, takes the newest executed
|
||||
/// segment that is still pending and not yet attempted here, needs one shard proof per shard of every block in
|
||||
/// this node's pool (`igneum_getProofBytes`, a verified one when there is one) and, when the previous segment is
|
||||
/// proven, its aggregated proof (`igneum_getSegmentProofBytes`); runs `igneum-prove-host --mode aggregate` over the
|
||||
/// run of blocks (one process, one key setup), checks the public values against the node's native statement
|
||||
/// (every field but `provers`), signs the record with the first key's label and submits it. Returns a line for
|
||||
/// the log and the tile, or None when there is nothing to do.
|
||||
fn aggregate_once(shared: &Shared, t: &Tools, label: &str, payout: &str, attempted: &mut HashSet<u64>) -> Result<Option<String>, String> {
|
||||
let hexu = |x: &Value| x.as_str().and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0);
|
||||
let st = evm_rpc(shared, "igneum_getProvingStatus", json!([]), Duration::from_secs(10))?;
|
||||
let v1 = &st["v1"];
|
||||
if !v1["active"].as_bool().unwrap_or(false) || v1["start"].is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
// the card gate (consequences review C2): a chained aggregation peaked at 16,751 MiB with the miner resident; the
|
||||
// prover's own 24 GB gate applies; without such a card this machine proves shards and never aggregates
|
||||
{
|
||||
let cards = shared.state.lock().unwrap().mining.cards.clone();
|
||||
if crate::provedefault::aggregation_card(&cards).is_none() {
|
||||
return Ok(Some("no aggregation on this machine: it needs a 24 GB card (16.8 GB measured with the miner resident); shards still prove".into()));
|
||||
}
|
||||
}
|
||||
let tip = hexu(&evm_rpc(shared, "eth_blockNumber", json!([]), Duration::from_secs(10))?);
|
||||
let mut seg = evm_rpc(shared, "igneum_getSegmentStatement", json!([format!("{tip:#x}")]), Duration::from_secs(10))?;
|
||||
if !seg["executed"].as_bool().unwrap_or(false) {
|
||||
let first = hexu(&seg["first"]);
|
||||
if first == 0 || first - 1 < hexu(&v1["start"]) {
|
||||
return Ok(None);
|
||||
}
|
||||
seg = evm_rpc(shared, "igneum_getSegmentStatement", json!([format!("{:#x}", first - 1)]), Duration::from_secs(10))?;
|
||||
}
|
||||
let (first, last) = (hexu(&seg["first"]), hexu(&seg["last"]));
|
||||
if seg["status"]["status"].as_str() != Some("pending") || attempted.contains(&first) || payout.len() != 42 {
|
||||
return Ok(None);
|
||||
}
|
||||
let dir = shared.runtime.app_dir.join("proving").join(format!("seg-{first}"));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let as_host_path = |p: &Path| if t.wsl { wsl_path(p) } else { p.display().to_string() };
|
||||
// the shard proofs, one per shard of every block, from this node's pool
|
||||
let mut groups: Vec<String> = Vec::new();
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
for b in seg["blocks"].as_array().cloned().unwrap_or_default() {
|
||||
let n = hexu(&b["number"]);
|
||||
let shards = b["shards"].as_u64().unwrap_or(0) as u32;
|
||||
let have = b["shardProofs"].as_array().cloned().unwrap_or_default();
|
||||
let mut files = Vec::new();
|
||||
for i in 0..shards {
|
||||
let pick = have.iter().find(|e| e["shard"].as_u64() == Some(i as u64) && e["verified"] == json!(true)).or_else(|| have.iter().find(|e| e["shard"].as_u64() == Some(i as u64)));
|
||||
let Some(e) = pick else {
|
||||
missing.push(format!("{n}/{i}"));
|
||||
continue;
|
||||
};
|
||||
let got = evm_rpc(shared, "igneum_getProofBytes", json!([format!("{n:#x}"), i, e["keyHash"]]), Duration::from_secs(60))?;
|
||||
let hex = got["proof"].as_str().ok_or("no proof bytes")?.trim_start_matches("0x").to_string();
|
||||
let bytes: Vec<u8> = (0..hex.len() / 2).map(|k| u8::from_str_radix(&hex[2 * k..2 * k + 2], 16).unwrap_or(0)).collect();
|
||||
let f = dir.join(format!("b{n}-s{i}.bin"));
|
||||
std::fs::write(&f, bytes).map_err(|e| e.to_string())?;
|
||||
files.push(as_host_path(&f));
|
||||
}
|
||||
groups.push(files.join(","));
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Ok(Some(format!("segment {first}..{last}: waiting for shard proofs {} in this node's pool", missing.join(" "))));
|
||||
}
|
||||
// the previous segment's aggregated proof, when the chain continues
|
||||
let prev = &seg["previous"];
|
||||
let (prev_file, expected_pv) = if prev.is_null() {
|
||||
(None, seg["publicValuesFresh"].as_str().unwrap_or("").to_string())
|
||||
} else if prev["proofInPool"] == json!(true) {
|
||||
let got = evm_rpc(shared, "igneum_getSegmentProofBytes", json!([prev["first"], prev["keyHash"]]), Duration::from_secs(60))?;
|
||||
let hex = got["proof"].as_str().ok_or("no segment proof bytes")?.trim_start_matches("0x").to_string();
|
||||
let bytes: Vec<u8> = (0..hex.len() / 2).map(|k| u8::from_str_radix(&hex[2 * k..2 * k + 2], 16).unwrap_or(0)).collect();
|
||||
let f = dir.join("prev-aggregated.bin");
|
||||
std::fs::write(&f, bytes).map_err(|e| e.to_string())?;
|
||||
(Some(as_host_path(&f)), seg["publicValuesContinuing"].as_str().unwrap_or("").to_string())
|
||||
} else {
|
||||
return Ok(Some(format!("segment {first}..{last}: the previous segment's proof is not in this node's pool; waiting")));
|
||||
};
|
||||
attempted.insert(first);
|
||||
let parent = seg["blocks"][0]["parentHash"].as_str().ok_or("no parent hash")?.to_string();
|
||||
let last_hash = seg["blocks"].as_array().and_then(|a| a.last()).and_then(|b| b["hash"].as_str()).ok_or("no last hash")?.to_string();
|
||||
let results = dir.join("results.json");
|
||||
let started = Instant::now();
|
||||
set(shared, |p| p.message = format!("aggregating segment {first}..{last} ({})", if t.cuda { "GPU" } else { "CPU, slow" }));
|
||||
let mut args: Vec<String> = vec!["--mode".into(), "aggregate".into(), "--proofs".into(), groups.join(";"), "--parent".into(), parent, "--out".into(), as_host_path(&results)];
|
||||
if let Some(pf) = prev_file {
|
||||
args.push("--prev".into());
|
||||
args.push(pf);
|
||||
}
|
||||
let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(2 * 3600), &dir.join("aggregate.log"));
|
||||
if !ok || !results.exists() {
|
||||
return Err(format!("segment {first}..{last}: aggregator: {}", out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed")));
|
||||
}
|
||||
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let pv = res["segment_public_values"].as_str().ok_or("no public values in the results")?.to_string();
|
||||
let proof_sha = res["segment_proof_sha256"].as_str().ok_or("no proof hash in the results")?.to_string();
|
||||
let proof_file = res["segment_proof_file"].as_str().ok_or("no proof file in the results")?.to_string();
|
||||
// the node's native statement, every field but provers (bytes 236..268 of the 340)
|
||||
let strip = |h: &str| { let h = h.trim_start_matches("0x"); if h.len() == 680 { format!("{}{}", &h[..472], &h[536..]) } else { h.to_string() } };
|
||||
if strip(&pv) != strip(&expected_pv) {
|
||||
return Err(format!("segment {first}..{last}: the aggregated statement differs from the node's native statement (it would be vetoed); ours {} node {}", &pv[..66.min(pv.len())], &expected_pv[..66.min(expected_pv.len())]));
|
||||
}
|
||||
let proof_path = if t.wsl { PathBuf::from(proof_file.replace("/mnt/c/", "C:/")) } else { PathBuf::from(proof_file) };
|
||||
let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-segment-record", label, &chain_name(shared), &first.to_string(), &last.to_string(), &last_hash, payout, &pv, &proof_sha]), None, Duration::from_secs(20)).ok_or("sign-segment-record did not run")?;
|
||||
let signed: Value = serde_json::from_str(sg.lines().last().unwrap_or("")).map_err(|_| format!("sign-segment-record: {}", sg.trim()))?;
|
||||
let record = signed["record"].as_str().ok_or("sign-segment-record gave no record")?.to_string();
|
||||
let proof = std::fs::read(&proof_path).map_err(|e| format!("proof file {}: {e}", proof_path.display()))?;
|
||||
let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
let r = evm_rpc(shared, "igneum_submitSegmentRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?;
|
||||
if !r["accepted"].as_bool().unwrap_or(false) {
|
||||
return Err(format!("segment {first}..{last}: record refused: {}", r["reason"].as_str().unwrap_or("?")));
|
||||
}
|
||||
let secs = started.elapsed().as_secs_f64();
|
||||
shared.event("proving", &format!("segment {first}..{last} aggregated and submitted in {secs:.0} s (chain_len {})", res["segment_chain_len"]));
|
||||
set(shared, |p| p.aggregated += 1);
|
||||
Ok(Some(format!("segment {first}..{last} aggregated in {secs:.0} s and submitted; paid when a block carries it")))
|
||||
}
|
||||
|
||||
/// Windows: runs the WSL2 setup from the payload (`wsl2/setup-wsl.sh` next to the engine) in a window of its own;
|
||||
/// the user watches it and reboots when it asks. Elsewhere there is nothing to set up.
|
||||
pub fn setup(shared: &Shared) -> Result<Value, String> {
|
||||
|
|
@ -462,9 +1039,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 }))
|
||||
|
|
@ -474,6 +1060,16 @@ pub fn setup(shared: &Shared) -> Result<Value, String> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pinned_ids_come_out_of_the_hosts_id_line() {
|
||||
let out = "RESULT id: pinned guests: shard program id 0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a (2832504 bytes, sha256 0x150f4c05a2951fc5) aggregator id 0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896 (319744 bytes), pinned 2026-10-05T16:20:38Z on Darwin, SP1 5.0 circuit v5\n";
|
||||
let (shard, agg) = ids_from_describe(out).unwrap();
|
||||
assert_eq!(shard, "0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a");
|
||||
assert_eq!(agg, "0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896");
|
||||
assert_eq!(ids_from_describe("RESULT setup: 1.2 s"), None);
|
||||
assert_eq!(ids_from_describe("shard program id 0xabc aggregator id 0xdef"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_the_newest_unpaid_assigned_shard_not_yet_attempted() {
|
||||
let v: Value = serde_json::from_str(r#"[
|
||||
|
|
@ -497,8 +1093,10 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_paths() {
|
||||
assert_eq!(wsl_path(Path::new("C:\\Users\\[user]\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/[user]/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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
214
app/igneum-app/src/segments.rs
Normal file
214
app/igneum-app/src/segments.rs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//! Proving v1 (spec 7.8): segment-aligned work for the prover loop (6 October 2026).
|
||||
//!
|
||||
//! The shipped loop took the newest open shard each pass, so one prover scattered one block in about 45 across
|
||||
//! the segment grid and no segment ever had all its blocks proven (node 1, 04:16Z: pending 55, proven 0). Here a
|
||||
//! free prover claims a whole segment (`proving_v1_segment_blocks` consecutive chain blocks), proves every shard
|
||||
//! of it in order from one export in one host run (`--mode chain --save-shards`), submits the shard records and the
|
||||
//! aggregated segment record, then takes the next. One card completes whole segments at its own rate instead of
|
||||
//! completing none.
|
||||
//!
|
||||
//! The choice is deterministic per prover: among the untouched whole segments still inside their deadline by a
|
||||
//! margin, the lowest FNV-1a of (first block, this prover's key hash) wins, so several provers spread over the
|
||||
//! candidates without a coordinator; the per-block fallback (`prover::choose`) stays for the passes where no whole
|
||||
//! segment qualifies.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use crate::prover::Work;
|
||||
|
||||
/// The least time a claimed segment is given before its deadline (DAA units, about one a second on devnet): the
|
||||
/// chain of 8 empty blocks took 135.6 s cold beside the miner (bench-log, 5 October 2026), so 240 leaves the
|
||||
/// submission and the carrying block inside the window.
|
||||
pub const SEGMENT_MARGIN_MIN_DAA: u64 = 240;
|
||||
/// The margin grows with what the last segment actually took, times this.
|
||||
pub const SEGMENT_MARGIN_FACTOR: f64 = 1.5;
|
||||
/// How far back the work list reaches (chain blocks): the record window, so every open segment inside the
|
||||
/// deadline is visible.
|
||||
pub const WORK_LOOKBACK: u64 = 600;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SegmentWork {
|
||||
pub first: u64,
|
||||
pub last: u64,
|
||||
/// the last block's DAA score; the deadline is it plus `proving_v1_unproven_daa`
|
||||
pub last_daa: u64,
|
||||
pub deadline_daa: u64,
|
||||
/// (chain block number, block hash, shard index) in chain order
|
||||
pub shards: Vec<(u64, String, u32)>,
|
||||
/// the shard payouts summed (what the shards earn when carried)
|
||||
pub shard_wei: u128,
|
||||
}
|
||||
|
||||
/// The segment holding chain block `number` on the grid that starts at `start`.
|
||||
pub fn segment_of(start: u64, n: u64, number: u64) -> (u64, u64) {
|
||||
let n = n.max(1);
|
||||
let k = number.saturating_sub(start) / n;
|
||||
(start + k * n, start + k * n + n - 1)
|
||||
}
|
||||
|
||||
/// The DAA margin a segment must have before its deadline: the floor, or 1.5 times the last segment's wall time.
|
||||
pub fn need_daa(last_segment_secs: Option<f64>) -> u64 {
|
||||
let from_last = last_segment_secs.map(|s| (s * SEGMENT_MARGIN_FACTOR).ceil() as u64).unwrap_or(0);
|
||||
from_last.max(SEGMENT_MARGIN_MIN_DAA)
|
||||
}
|
||||
|
||||
/// Groups the node's work list into whole, untouched segments: every block of the segment is in the list, every
|
||||
/// listed shard is open (past its exclusive window), unpaid and not in this node's pool from us. A segment with a
|
||||
/// block missing (inside the exclusive window, or outside the lookback) or a shard already paid is not a candidate.
|
||||
pub fn whole_segments(start: u64, n: u64, unproven_daa: u64, work: &[Work]) -> Vec<SegmentWork> {
|
||||
let n = n.max(1);
|
||||
let mut by_block: BTreeMap<u64, Vec<&Work>> = BTreeMap::new();
|
||||
for w in work.iter().filter(|w| w.number >= start) {
|
||||
by_block.entry(w.number).or_default().push(w);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for number in by_block.keys() {
|
||||
let (first, last) = segment_of(start, n, *number);
|
||||
if !seen.insert(first) {
|
||||
continue;
|
||||
}
|
||||
let mut shards = Vec::new();
|
||||
let mut wei: u128 = 0;
|
||||
let mut last_daa = 0;
|
||||
let mut whole = true;
|
||||
for b in first..=last {
|
||||
let Some(entries) = by_block.get(&b) else {
|
||||
whole = false;
|
||||
break;
|
||||
};
|
||||
let mut e: Vec<&&Work> = entries.iter().collect();
|
||||
e.sort_by_key(|w| w.shard);
|
||||
e.dedup_by_key(|w| w.shard);
|
||||
if e.iter().any(|w| !w.open || w.paid || w.in_pool) {
|
||||
whole = false;
|
||||
break;
|
||||
}
|
||||
for w in e {
|
||||
shards.push((w.number, w.hash.clone(), w.shard));
|
||||
wei = wei.saturating_add(w.shard_wei);
|
||||
if b == last {
|
||||
last_daa = w.daa;
|
||||
}
|
||||
}
|
||||
}
|
||||
if whole && !shards.is_empty() {
|
||||
out.push(SegmentWork { first, last, last_daa, deadline_daa: last_daa.saturating_add(unproven_daa), shards, shard_wei: wei });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// FNV-1a 64 of the segment's first block and this prover's key hash: the per-prover rank.
|
||||
pub fn rank(first: u64, key_hash: &str) -> u64 {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in first.to_be_bytes().iter().chain(key_hash.as_bytes()) {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The segments to try, best first: inside the deadline by `need` DAA at `tip_daa`, not attempted, ranked by
|
||||
/// `rank(first, key)` (ties by the older first block).
|
||||
pub fn candidates(segs: &[SegmentWork], tip_daa: u64, need: u64, key_hash: &str, attempted: &HashSet<u64>) -> Vec<SegmentWork> {
|
||||
let mut c: Vec<SegmentWork> = segs.iter().filter(|s| !attempted.contains(&s.first) && s.deadline_daa >= tip_daa.saturating_add(1).saturating_add(need)).cloned().collect();
|
||||
c.sort_by(|a, b| rank(a.first, key_hash).cmp(&rank(b.first, key_hash)).then(a.first.cmp(&b.first)));
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn w(number: u64, shard: u32, daa: u64, open: bool, paid: bool, in_pool: bool) -> Work {
|
||||
Work { number, hash: format!("0x{number:064x}"), shard, pgas: 0, tx_count: 0, assigned: false, open, paid, in_pool, shard_wei: 10, key_hash: String::new(), daa }
|
||||
}
|
||||
|
||||
fn grid(start: u64, n: u64, segments: u64, daa0: u64) -> Vec<Work> {
|
||||
(0..segments * n).map(|i| w(start + i, 0, daa0 + i, true, false, false)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_is_counted_from_the_first_v1_block() {
|
||||
assert_eq!(segment_of(100, 8, 100), (100, 107));
|
||||
assert_eq!(segment_of(100, 8, 107), (100, 107));
|
||||
assert_eq!(segment_of(100, 8, 108), (108, 115));
|
||||
assert_eq!(segment_of(100, 8, 123), (116, 123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_whole_open_unpaid_untouched_segments_qualify() {
|
||||
let mut work = grid(100, 4, 3, 1000); // 100..111, three segments
|
||||
work.retain(|x| x.number != 105); // 104..107 has a block missing (inside its exclusive window, say)
|
||||
work.iter_mut().find(|x| x.number == 110).unwrap().paid = true; // 108..111 has a paid shard
|
||||
let segs = whole_segments(100, 4, 600, &work);
|
||||
assert_eq!(segs.len(), 1);
|
||||
assert_eq!((segs[0].first, segs[0].last), (100, 103));
|
||||
assert_eq!(segs[0].shards.len(), 4);
|
||||
assert_eq!(segs[0].last_daa, 1003);
|
||||
assert_eq!(segs[0].deadline_daa, 1603);
|
||||
assert_eq!(segs[0].shard_wei, 40);
|
||||
// a shard of ours already in the pool, or one still exclusive, also disqualifies
|
||||
let mut work = grid(100, 4, 1, 1000);
|
||||
work[1].in_pool = true;
|
||||
assert!(whole_segments(100, 4, 600, &work).is_empty());
|
||||
let mut work = grid(100, 4, 1, 1000);
|
||||
work[3].open = false;
|
||||
assert!(whole_segments(100, 4, 600, &work).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_with_several_shards_lists_them_in_order() {
|
||||
let mut work = grid(100, 2, 1, 1000);
|
||||
work.push(w(101, 1, 1001, true, false, false));
|
||||
work.push(w(100, 1, 1000, true, false, false));
|
||||
let segs = whole_segments(100, 2, 600, &work);
|
||||
assert_eq!(segs[0].shards.iter().map(|(n, _, s)| (*n, *s)).collect::<Vec<_>>(), vec![(100, 0), (100, 1), (101, 0), (101, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_deadline_margin_and_the_attempted_set_filter_the_candidates() {
|
||||
let work = grid(100, 8, 4, 1000); // 100..131, deadlines 1607, 1615, 1623, 1631
|
||||
let segs = whole_segments(100, 8, 600, &work);
|
||||
assert_eq!(segs.len(), 4);
|
||||
// at tip DAA 1380 with a 240 margin only the segments with a deadline at or past 1621 remain
|
||||
let c = candidates(&segs, 1380, 240, "0xkey", &HashSet::new());
|
||||
let firsts: Vec<u64> = c.iter().map(|s| s.first).collect();
|
||||
assert_eq!(firsts.len(), 2);
|
||||
assert!(firsts.contains(&116) && firsts.contains(&124));
|
||||
let mut attempted = HashSet::new();
|
||||
attempted.insert(firsts[0]);
|
||||
let c2 = candidates(&segs, 1380, 240, "0xkey", &attempted);
|
||||
assert_eq!(c2.len(), 1);
|
||||
assert_eq!(c2[0].first, firsts[1]);
|
||||
// past every deadline: nothing
|
||||
assert!(candidates(&segs, 1700, 240, "0xkey", &HashSet::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_order_is_deterministic_per_key_and_differs_between_keys() {
|
||||
let work = grid(100, 8, 6, 1000);
|
||||
let segs = whole_segments(100, 8, 600, &work);
|
||||
let a = candidates(&segs, 1000, 240, "0xaaaa", &HashSet::new());
|
||||
let a2 = candidates(&segs, 1000, 240, "0xaaaa", &HashSet::new());
|
||||
assert_eq!(a, a2);
|
||||
assert_eq!(a.len(), 6);
|
||||
// two provers rank the six candidates differently (the spread); the sets are the same
|
||||
let b = candidates(&segs, 1000, 240, "0xbbbb", &HashSet::new());
|
||||
let (fa, fb): (Vec<u64>, Vec<u64>) = (a.iter().map(|s| s.first).collect(), b.iter().map(|s| s.first).collect());
|
||||
let mut sa = fa.clone();
|
||||
let mut sb = fb.clone();
|
||||
sa.sort();
|
||||
sb.sort();
|
||||
assert_eq!(sa, sb);
|
||||
assert_ne!(fa, fb, "two keys should not rank six segments identically");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_margin_follows_the_last_segment_time() {
|
||||
assert_eq!(need_daa(None), 240);
|
||||
assert_eq!(need_daa(Some(100.0)), 240);
|
||||
assert_eq!(need_daa(Some(190.0)), 285);
|
||||
}
|
||||
}
|
||||
|
|
@ -244,7 +244,7 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
key: c.get("key")?.as_str()?.to_string(),
|
||||
enabled: c.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
identities: c.get("identities").and_then(|v| v.as_u64()).unwrap_or(1).clamp(1, 64) as u32,
|
||||
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|v| v.clamp(60, 100) as u32),
|
||||
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|v| v.clamp(crate::sweep::MIN_PCT as u64, 100) as u32),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -257,7 +257,9 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
let login = body.get("start_at_login").and_then(|v| v.as_bool());
|
||||
let address = s("address");
|
||||
let display_name = s("display_name");
|
||||
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref())
|
||||
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
|
||||
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),
|
||||
|
|
@ -300,6 +302,34 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::ApplyPower);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
// the efficiency sweep (src/sweep.rs): start one card now, stop the running one, pin or unpin a card's cap,
|
||||
// the weekly toggle
|
||||
"/api/sweep/start" => {
|
||||
let key = s("key").ok_or("key missing")?;
|
||||
shared.send(Cmd::SweepStart(key));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/sweep/stop" => {
|
||||
shared.send(Cmd::SweepStop);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/sweep/pin" => {
|
||||
let key = s("key").ok_or("key missing")?;
|
||||
let pinned = body.get("pinned").and_then(|v| v.as_bool()).ok_or("pinned missing")?;
|
||||
shared.send(Cmd::SweepPin(key, pinned));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
// Power control (config.rs power_control): on = one administrator prompt now for the cap, off = nothing asks
|
||||
"/api/power/control" => {
|
||||
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
|
||||
shared.send(Cmd::PowerControl(on));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/sweep/enable" => {
|
||||
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
|
||||
shared.send(Cmd::SweepEnable(on));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/clock/sync" => {
|
||||
shared.send(Cmd::ClockSync);
|
||||
Ok(json!({ "ok": true }))
|
||||
|
|
@ -309,7 +339,8 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/quit" => {
|
||||
shared.send(Cmd::Quit);
|
||||
// the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url
|
||||
shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
_ => Err("unknown api".into()),
|
||||
|
|
|
|||
|
|
@ -24,12 +24,26 @@ pub struct NodeState {
|
|||
/// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none
|
||||
pub consensus_switch_daa: u64,
|
||||
pub override_restart_wait: String,
|
||||
/// the node's "Consensus params digest: <64 hex>" line (exchanged in the p2p handshake); empty until the node prints it
|
||||
pub consensus_digest: String,
|
||||
/// every activation height in the override file the node was started with (miner-ui-2): the Node page names the next one
|
||||
pub consensus_switches: Vec<ConsensusSwitch>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
/// One planned rule change the node applies by itself at a DAA score (from the override file's `*_activation_daa` keys).
|
||||
#[derive(Clone, Serialize, Default, PartialEq, Debug)]
|
||||
pub struct ConsensusSwitch {
|
||||
pub key: String, // the override key, for example fees_v1_activation_daa
|
||||
pub name: String, // plain words: Fees v1
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default, Debug)]
|
||||
pub struct CardState {
|
||||
pub index: usize,
|
||||
pub key: String, // stable id for the saved preference: vendor:device:name
|
||||
pub key: String, // stable id for the saved preference: vendor:code, "#2" and up for a second identical card (no index: it moves)
|
||||
pub code: String, // the tool's own device name (AMD's OpenCL runtime says "gfx1201"); the key and the tooltip carry it
|
||||
pub platform: String, // the OpenCL platform and driver the worker opens it through (the tooltip)
|
||||
pub kind: String, // apple | discrete | integrated | external | unknown
|
||||
pub vram_mb: u64, // 0 when unknown
|
||||
pub reason: String, // why it is off by default, if it is
|
||||
|
|
@ -40,7 +54,7 @@ pub struct CardState {
|
|||
pub detail: String, // memory, cores
|
||||
pub device: String, // the worker's --device value (Windows)
|
||||
pub enabled: bool,
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
|
||||
pub hash_now: f64, // MH/s, the last interval
|
||||
pub hash_avg: f64, // MH/s since the start
|
||||
pub accepted: u64,
|
||||
|
|
@ -57,6 +71,19 @@ pub struct CardState {
|
|||
pub pid: u32,
|
||||
pub last_status_age_s: f64,
|
||||
pub message: String,
|
||||
/// the device's own problem as the OS reports it ("Code 43" on Windows); set = listed but no worker can drive it
|
||||
pub problem: String,
|
||||
/// PCI bus id where the tool gives one (nvidia-smi pci.bus_id); a second way to recognise a card whose index moved
|
||||
pub bus: String,
|
||||
/// hot-plug (src/hotplug.rs): unix s when a re-detection added this card (0 = found at start), when it lost it
|
||||
/// (0 = present), and `gone` once a removed card has been shown as removed for five minutes (the row hides)
|
||||
pub added_at: f64,
|
||||
pub removed_at: f64,
|
||||
pub gone: bool,
|
||||
/// `WORKER FAULT` lines seen (the miner killed and restarted its worker) and the miner's own `faults=` count
|
||||
pub faults: u64,
|
||||
/// shares that failed the miner's CPU re-check this run (`mismatched=` on the STATUS line)
|
||||
pub mismatched: u64,
|
||||
// power and heat (NVIDIA through nvidia-smi; 0 = unknown)
|
||||
pub power_w: f64, // draw now
|
||||
pub power_limit_w: f64, // the limit in force
|
||||
|
|
@ -64,12 +91,53 @@ pub struct CardState {
|
|||
pub power_min_w: f64,
|
||||
pub power_max_w: f64,
|
||||
pub power_before_w: f64, // the limit before the app touched it (restored on quit)
|
||||
pub power_pct: u32, // the chosen cap, percent of the default (60 to 100)
|
||||
pub power_pct: u32, // the chosen cap, percent of the default (50 to 100)
|
||||
pub power_applied: bool,
|
||||
pub power_note: String,
|
||||
pub temp_gpu: f64,
|
||||
pub temp_mem: f64,
|
||||
pub telemetry_at: f64,
|
||||
// AMD through igneum-gpu-telemetry (ADLX on Windows, amdgpu sysfs on Linux), 5 October 2026; 0 = unknown
|
||||
pub fan_pct: f64,
|
||||
pub fan_rpm: f64,
|
||||
pub mclk_mhz: f64,
|
||||
pub util_pct: f64,
|
||||
// hash per watt (src/sweep.rs)
|
||||
pub eff_mhw: f64, // live: hash_now over power_w, MH per watt; 0 = unknown
|
||||
pub sweep_supported: bool, // NVIDIA with readable limits; the note says why not otherwise
|
||||
pub sweep_state: String, // idle | running | unsupported
|
||||
pub sweep_note: String, // the phase in words while running, else why unsupported or the last outcome
|
||||
pub sweep_pct: u32, // the cap the last sweep chose (0 = never swept)
|
||||
pub sweep_eff: f64, // MH per watt at that cap
|
||||
pub sweep_watts: f64,
|
||||
pub sweep_mhs: f64,
|
||||
pub sweep_at: f64, // unix s of the last sweep
|
||||
pub pinned: bool, // the user set the cap by hand; the sweep records but does not change it
|
||||
// Ember Tune (src/ember.rs): the two-knob tune, 5 October 2026
|
||||
pub clock_max_mhz: u32, // the vendor's maximum core clock (0 = unknown)
|
||||
pub clock_min_mhz: u32, // the vendor's floor for a cap (0 = 60% of the maximum)
|
||||
pub gclk_mhz: f64, // core clock now
|
||||
pub clock_cap_mhz: u32, // the cap in force (0 = unlocked)
|
||||
pub amd_ordinal: i64, // the `amd N` ordinal of igneum-gpu-telemetry (-1 = unknown)
|
||||
pub driver: String, // the driver version (nvidia-smi, or the worker's race line)
|
||||
pub program_class: String, // the program class of the race line (loads and wide loads per hash); "" = unknown
|
||||
pub tune_control: bool, // both knobs reach the card (else measure only; sweep_note says why)
|
||||
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
|
||||
pub tune_source: String, // full | confirm | baseline
|
||||
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
|
||||
// the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose
|
||||
pub variant: String,
|
||||
pub race_mhs: f64,
|
||||
pub race_gain_pct: f64,
|
||||
pub race_variants: u32,
|
||||
}
|
||||
|
||||
impl CardState {
|
||||
/// Listed, driver fine, not unplugged: a worker can run on it (hot-plug keeps removed and faulty cards in the
|
||||
/// list so the other cards' indices stay put).
|
||||
pub fn present(&self) -> bool {
|
||||
self.removed_at == 0.0 && self.problem.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
|
|
@ -82,6 +150,18 @@ pub struct MiningState {
|
|||
pub accepted_session: u64,
|
||||
pub rejected_session: u64,
|
||||
pub found: Vec<f64>, // unix times of the accepted blocks, last hour
|
||||
/// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime
|
||||
pub fee_session: u64,
|
||||
pub fee_total: u64,
|
||||
}
|
||||
|
||||
/// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`).
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct DevFeeState {
|
||||
pub on: bool,
|
||||
pub percent: u32,
|
||||
pub address: String, // 0x + 40 hex once a miner has printed it
|
||||
pub line: String, // the miner's own words
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
|
|
@ -109,12 +189,47 @@ pub struct ProvingState {
|
|||
pub submitted: u32,
|
||||
pub paid: u32,
|
||||
pub failed: u32,
|
||||
/// wei, serialised as a decimal string: serde_json's `to_value` refuses a u128 over u64::MAX (about 18.45 IGN,
|
||||
/// 15 paid shards at 1.23 IGN), and that refusal emptied the whole `/api/state` reply to "{}" (6 October 2026)
|
||||
#[serde(serialize_with = "u128_string")]
|
||||
pub paid_wei: u128,
|
||||
pub current: String,
|
||||
pub started_at: f64,
|
||||
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,
|
||||
/// the pinned guests' ids (`igneum-prove-host --mode id`): the shard program and the aggregator; empty until read
|
||||
pub program_id: String,
|
||||
pub aggregator_id: String,
|
||||
/// proving v1 step 1: the install-time default's one plain line (why proving is on or off on this machine)
|
||||
pub default_note: String,
|
||||
/// proving v1: segment records this machine aggregated and submitted, and the aggregator's last line
|
||||
pub aggregated: u32,
|
||||
pub segment_note: String,
|
||||
/// proving v1 segment path (6 October 2026): whole segments this machine proved and submitted, paid, and what
|
||||
/// they paid (wei as a decimal string, see `paid_wei`); the last segment's wall time
|
||||
pub segments_submitted: u32,
|
||||
pub segments_paid: u32,
|
||||
#[serde(serialize_with = "u128_string")]
|
||||
pub segment_paid_wei: u128,
|
||||
pub segment_last_s: f64,
|
||||
/// segment records the node refused by the chain rule and this machine offers again each pass
|
||||
pub segments_held: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
|
|
@ -158,6 +273,20 @@ pub struct SettingsState {
|
|||
pub remote_jobs: bool,
|
||||
/// the prover service (src/prover.rs)
|
||||
pub prove: bool,
|
||||
/// the efficiency sweep (src/sweep.rs): once after install, then weekly; effective only with `power_control`
|
||||
pub sweep: bool,
|
||||
/// the NVIDIA power cap and the sweep may ask for administrator rights (config.rs: default off, one prompt when
|
||||
/// switched on)
|
||||
pub power_control: bool,
|
||||
/// the line beside the Power control switch: why it is off, or that the rights were given
|
||||
pub power_note: String,
|
||||
/// Ember Tune is paused fleet-wide by the signed manifest's kill switch (tuning.ember.enabled = false)
|
||||
pub tuning_off: bool,
|
||||
pub tuning_note: String,
|
||||
/// 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.
|
||||
|
|
@ -267,6 +396,7 @@ pub struct State {
|
|||
pub clock: ClockState,
|
||||
pub address: AddressState,
|
||||
pub settings: SettingsState,
|
||||
pub dev_fee: DevFeeState,
|
||||
pub update: UpdateState,
|
||||
pub jobs: JobsState,
|
||||
pub events: Vec<Event>,
|
||||
|
|
@ -308,3 +438,22 @@ impl Rings {
|
|||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A u128 as a decimal JSON string (the dashboard reads it with `Number()`).
|
||||
pub fn u128_string<S: serde::Serializer>(v: &u128, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&v.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod paid_wei_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_paid_total_over_u64_max_still_serialises_the_whole_state() {
|
||||
let mut st = State::default();
|
||||
st.proving.paid_wei = u64::MAX as u128 + 1;
|
||||
let v = serde_json::to_value(&st).expect("the state serialises");
|
||||
assert_eq!(v["proving"]["paid_wei"], serde_json::Value::String("18446744073709551616".into()));
|
||||
assert!(v["mining"].is_object());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
619
app/igneum-app/src/sweep.rs
Normal file
619
app/igneum-app/src/sweep.rs
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
//! The efficiency sweep: hash per watt, the number miners compare. For one NVIDIA card at a time the engine steps
|
||||
//! the power cap from 100% of the card's default limit down to 50% in 10% steps, holds each step for 60 s after a
|
||||
//! 15 s settle, averages the card's reported draw (nvidia-smi power.draw, the telemetry child) and the worker's
|
||||
//! interval rate (the STATUS lines), computes MH per watt per step, picks the best step and leaves the cap there.
|
||||
//! It runs on the live kernel: the worker is never restarted and the hour's program is never lost. It never starts
|
||||
//! while a remote job holds the GPU, and it aborts the moment the card leaves "mining" (a fault, an hour boundary,
|
||||
//! a pause, a job).
|
||||
//!
|
||||
//! This file is the logic: the step plan, the per-step rows, the choice, the parser for nvidia-smi's power query and
|
||||
//! the state machine, all driven by an explicit clock so the tests run without a card. The engine (src/engine.rs,
|
||||
//! `tick_sweep` and the `Cmd::Sweep*` commands) owns the processes: it sets the cap (directly when the engine runs
|
||||
//! elevated, else through one elevated helper that polls a command file, so a sweep costs one administrator prompt
|
||||
//! instead of six), reads the limit back through the telemetry child, feeds the samples in and writes the table to
|
||||
//! the app log:
|
||||
//! SWEEP start card=<label> name=<name> steps=100,90,80,70,60,50
|
||||
//! SWEEP card=<label> cap=<pct> limit=<set W> watts=<mean draw W> mhs=<MH/s> eff=<MH per W>
|
||||
//! SWEEP chosen card=<label> cap=<pct> limit=<W> watts=<W> mhs=<x> eff=<MH per W>
|
||||
//! SWEEP aborted card=<label> reason=<text>
|
||||
//! Apple silicon and AMD are reported unsupported with the reason (no cap to set; powermetrics needs root; no AMD
|
||||
//! power reading in this app).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The cap steps, percent of the card's default limit, highest first.
|
||||
pub const STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50];
|
||||
/// The lowest cap the slider and the sweep may set (the former floor was 60; the sweep needs 50).
|
||||
pub const MIN_PCT: u32 = 50;
|
||||
/// A sweep repeats this often when the toggle is on.
|
||||
pub const PERIOD_S: u64 = 7 * 86_400;
|
||||
/// The sweep needs at least this long before the hour boundary (6 steps of 75 s plus the cap latencies).
|
||||
pub const NEEDS_S: i64 = 600;
|
||||
/// The worker must have been mining this long before a sweep starts (the first status lines are warm-up).
|
||||
pub const STABLE_S: u64 = 120;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Timing {
|
||||
pub settle: Duration,
|
||||
pub hold: Duration,
|
||||
/// how long a cap may take to show in the readback before the sweep gives up
|
||||
pub apply: Duration,
|
||||
}
|
||||
|
||||
impl Timing {
|
||||
pub fn standard() -> Timing {
|
||||
Timing { settle: Duration::from_secs(15), hold: Duration::from_secs(60), apply: Duration::from_secs(30) }
|
||||
}
|
||||
/// IGNEUM_APP_SWEEP_FAST=1: a dry run in seconds (settle 2 s, hold 6 s), for a local check of the plumbing.
|
||||
pub fn from_env() -> Timing {
|
||||
if std::env::var("IGNEUM_APP_SWEEP_FAST").map(|v| v == "1").unwrap_or(false) {
|
||||
Timing { settle: Duration::from_secs(2), hold: Duration::from_secs(6), apply: Duration::from_secs(30) }
|
||||
} else {
|
||||
Timing::standard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Step {
|
||||
pub pct: u32,
|
||||
/// the limit to set, watts, inside the card's min and max
|
||||
pub watts: f64,
|
||||
}
|
||||
|
||||
/// The steps for a card: each percent of the default limit, clamped to the card's min and max, rounded to a watt;
|
||||
/// a step whose watts equal the previous step's (the clamp bit) is dropped, so a card whose floor is 70% of its
|
||||
/// default sweeps 100, 90, 80, 70 and once more at the floor.
|
||||
pub fn plan_steps(default_w: f64, min_w: f64, max_w: f64) -> Vec<Step> {
|
||||
let mut out: Vec<Step> = Vec::new();
|
||||
if default_w <= 0.0 {
|
||||
return out;
|
||||
}
|
||||
for pct in STEPS_PCT {
|
||||
let mut w = default_w * pct as f64 / 100.0;
|
||||
if min_w > 0.0 {
|
||||
w = w.max(min_w);
|
||||
}
|
||||
if max_w > 0.0 {
|
||||
w = w.min(max_w);
|
||||
}
|
||||
let w = w.round();
|
||||
if out.last().map(|s| (s.watts - w).abs() < 0.5).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
out.push(Step { pct, watts: w });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One step's result. `watts` is the mean draw the card reported during the hold (what the miner pays for),
|
||||
/// `limit` the cap that was set. `eff` is MH per watt of draw; 0 when the step had no readings.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Row {
|
||||
pub pct: u32,
|
||||
pub limit: f64,
|
||||
pub watts: f64,
|
||||
pub mhs: f64,
|
||||
pub eff: f64,
|
||||
pub draws: usize,
|
||||
pub rates: usize,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn from_samples(step: &Step, draws: &[f64], rates: &[f64]) -> Row {
|
||||
let mean = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
|
||||
let watts = mean(draws);
|
||||
let mhs = mean(rates);
|
||||
let usable = draws.len() >= 3 && !rates.is_empty() && watts > 1.0;
|
||||
Row { pct: step.pct, limit: step.watts, watts, mhs, eff: if usable { mhs / watts } else { 0.0 }, draws: draws.len(), rates: rates.len() }
|
||||
}
|
||||
pub fn usable(&self) -> bool {
|
||||
self.eff > 0.0
|
||||
}
|
||||
/// `SWEEP card=<label> cap=<pct> limit=<W> watts=<W> mhs=<x> eff=<MH/W>`; a step without readings says so.
|
||||
pub fn line(&self, card: &str) -> String {
|
||||
if self.usable() {
|
||||
format!("SWEEP card={card} cap={} limit={:.0} watts={:.1} mhs={:.2} eff={:.4}", self.pct, self.limit, self.watts, self.mhs, self.eff)
|
||||
} else {
|
||||
format!("SWEEP card={card} cap={} limit={:.0} watts={:.1} mhs={:.2} eff=0 reason=no_readings draws={} rates={}", self.pct, self.limit, self.watts, self.mhs, self.draws, self.rates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The best step: the highest MH per watt. Within 1% of the best, the higher hash rate wins (more blocks for the
|
||||
/// same efficiency); within 1% on both, the lower cap wins (the same output with more headroom from the limit).
|
||||
pub fn choose(rows: &[Row]) -> Option<Row> {
|
||||
let mut best: Option<&Row> = None;
|
||||
for r in rows.iter().filter(|r| r.usable()) {
|
||||
best = Some(match best {
|
||||
None => r,
|
||||
Some(b) => {
|
||||
let eff_tie = (r.eff - b.eff).abs() <= 0.01 * b.eff.max(r.eff);
|
||||
if !eff_tie {
|
||||
if r.eff > b.eff { r } else { b }
|
||||
} else {
|
||||
let mhs_tie = (r.mhs - b.mhs).abs() <= 0.01 * b.mhs.max(r.mhs);
|
||||
if !mhs_tie {
|
||||
if r.mhs > b.mhs { r } else { b }
|
||||
} else if r.pct < b.pct {
|
||||
r
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
best.cloned()
|
||||
}
|
||||
|
||||
/// Parses `nvidia-smi --query-gpu=index,power.draw --format=csv,noheader,nounits` (and the same query with units,
|
||||
/// or with a header) into index -> watts. A card that reports `[N/A]` or `[Not Supported]` is left out.
|
||||
pub fn parse_power_draw(text: &str) -> HashMap<String, f64> {
|
||||
let mut out = HashMap::new();
|
||||
for line in text.lines() {
|
||||
let p: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if p.len() < 2 || p[0].is_empty() || !p[0].chars().all(|c| c.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let w = p[1].trim_end_matches('W').trim();
|
||||
if let Ok(v) = w.parse::<f64>() {
|
||||
if v > 0.0 {
|
||||
out.insert(p[0].to_string(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where the sweep stands, for the tile ("holding 70% · 41 s").
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Phase {
|
||||
/// the cap for step `i` was (or is about to be) requested; waiting for the readback to match
|
||||
Applying { since: Instant, sent: bool },
|
||||
Settling { since: Instant },
|
||||
Holding { since: Instant },
|
||||
/// the chosen cap was requested; waiting for the readback
|
||||
Finishing { since: Instant, sent: bool },
|
||||
Done,
|
||||
}
|
||||
|
||||
/// What the engine must do after a tick.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Out {
|
||||
/// set this cap (watts) on the card
|
||||
Apply(f64),
|
||||
/// a step finished: log its line
|
||||
Row(Row),
|
||||
/// the sweep finished and the chosen cap is in force
|
||||
Finished(Row),
|
||||
/// the sweep failed; the engine restores the cap from before
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub struct Run {
|
||||
pub card: usize,
|
||||
#[allow(dead_code)]
|
||||
pub key: String,
|
||||
pub device: String,
|
||||
pub label: String,
|
||||
pub steps: Vec<Step>,
|
||||
pub i: usize,
|
||||
pub phase: Phase,
|
||||
pub rows: Vec<Row>,
|
||||
pub chosen: Option<Row>,
|
||||
/// the cap percent and the limit in force before the sweep (restored on abort)
|
||||
pub before_pct: u32,
|
||||
pub before_w: f64,
|
||||
pub started: Instant,
|
||||
pub forced: bool,
|
||||
pub timing: Timing,
|
||||
draws: Vec<f64>,
|
||||
rates: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
pub fn new(card: usize, key: &str, device: &str, label: &str, steps: Vec<Step>, before_pct: u32, before_w: f64, forced: bool, timing: Timing, now: Instant) -> Run {
|
||||
Run {
|
||||
card,
|
||||
key: key.into(),
|
||||
device: device.into(),
|
||||
label: label.into(),
|
||||
steps,
|
||||
i: 0,
|
||||
phase: Phase::Applying { since: now, sent: false },
|
||||
rows: Vec::new(),
|
||||
chosen: None,
|
||||
before_pct,
|
||||
before_w,
|
||||
started: now,
|
||||
forced,
|
||||
timing,
|
||||
draws: Vec::new(),
|
||||
rates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> Option<&Step> {
|
||||
self.steps.get(self.i)
|
||||
}
|
||||
|
||||
/// A worker STATUS interval rate (MH/s); counted while holding.
|
||||
pub fn sample_rate(&mut self, mhs: f64) {
|
||||
if matches!(self.phase, Phase::Holding { .. }) && mhs > 0.0 {
|
||||
self.rates.push(mhs);
|
||||
}
|
||||
}
|
||||
|
||||
/// A telemetry draw reading (watts); counted while holding.
|
||||
pub fn sample_draw(&mut self, w: f64) {
|
||||
if matches!(self.phase, Phase::Holding { .. }) && w > 0.0 {
|
||||
self.draws.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// The phase in words for the tile.
|
||||
pub fn words(&self, now: Instant) -> String {
|
||||
let pct = self.current().map(|s| s.pct).unwrap_or(0);
|
||||
match &self.phase {
|
||||
Phase::Applying { .. } => format!("sweep: setting {pct}%"),
|
||||
Phase::Settling { since } => format!("sweep: {pct}% settling · {} s", self.timing.settle.as_secs().saturating_sub(now.duration_since(*since).as_secs())),
|
||||
Phase::Holding { since } => format!("sweep: holding {pct}% · {} s", self.timing.hold.as_secs().saturating_sub(now.duration_since(*since).as_secs())),
|
||||
Phase::Finishing { .. } => format!("sweep: setting the best cap ({}%)", self.chosen.as_ref().map(|r| r.pct).unwrap_or(0)),
|
||||
Phase::Done => "sweep: done".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the state machine. `limit_w` is the limit the card reports now (the telemetry readback; 0 = unknown).
|
||||
pub fn tick(&mut self, now: Instant, limit_w: f64) -> Vec<Out> {
|
||||
let mut out = Vec::new();
|
||||
let settle = self.timing.settle;
|
||||
let hold = self.timing.hold;
|
||||
let apply = self.timing.apply;
|
||||
match self.phase.clone() {
|
||||
Phase::Applying { since, sent } => {
|
||||
let Some(step) = self.current().cloned() else {
|
||||
self.phase = Phase::Done;
|
||||
out.push(Out::Failed("no steps".into()));
|
||||
return out;
|
||||
};
|
||||
if !sent {
|
||||
self.phase = Phase::Applying { since: now, sent: true };
|
||||
out.push(Out::Apply(step.watts));
|
||||
} else if limit_w > 0.0 && (limit_w - step.watts).abs() < 1.5 {
|
||||
self.phase = Phase::Settling { since: now };
|
||||
} else if now.duration_since(since) > apply {
|
||||
self.phase = Phase::Done;
|
||||
out.push(Out::Failed(format!("the {}% cap ({:.0} W) did not take within {} s (card reports {:.0} W)", step.pct, step.watts, apply.as_secs(), limit_w)));
|
||||
}
|
||||
}
|
||||
Phase::Settling { since } => {
|
||||
if now.duration_since(since) >= settle {
|
||||
self.draws.clear();
|
||||
self.rates.clear();
|
||||
self.phase = Phase::Holding { since: now };
|
||||
}
|
||||
}
|
||||
Phase::Holding { since } => {
|
||||
if now.duration_since(since) >= hold {
|
||||
let step = self.current().cloned().expect("a step while holding");
|
||||
let row = Row::from_samples(&step, &self.draws, &self.rates);
|
||||
self.rows.push(row.clone());
|
||||
out.push(Out::Row(row));
|
||||
self.i += 1;
|
||||
if self.i < self.steps.len() {
|
||||
let next = self.steps[self.i].watts;
|
||||
self.phase = Phase::Applying { since: now, sent: true };
|
||||
out.push(Out::Apply(next));
|
||||
} else {
|
||||
match choose(&self.rows) {
|
||||
Some(best) => {
|
||||
self.chosen = Some(best.clone());
|
||||
self.phase = Phase::Finishing { since: now, sent: true };
|
||||
out.push(Out::Apply(best.limit));
|
||||
}
|
||||
None => {
|
||||
self.phase = Phase::Done;
|
||||
out.push(Out::Failed("no step had readings (no draw from nvidia-smi or no STATUS line from the worker)".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Phase::Finishing { since, sent } => {
|
||||
let best = self.chosen.clone().expect("a chosen row while finishing");
|
||||
if !sent {
|
||||
self.phase = Phase::Finishing { since: now, sent: true };
|
||||
out.push(Out::Apply(best.limit));
|
||||
} else if limit_w > 0.0 && (limit_w - best.limit).abs() < 1.5 {
|
||||
self.phase = Phase::Done;
|
||||
out.push(Out::Finished(best));
|
||||
} else if now.duration_since(since) > apply {
|
||||
self.phase = Phase::Done;
|
||||
out.push(Out::Failed(format!("the chosen cap ({:.0} W) did not take within {} s", best.limit, apply.as_secs())));
|
||||
}
|
||||
}
|
||||
Phase::Done => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The elevated helper that sets limits for a tune (one administrator prompt per tune, not one per step). It polls
|
||||
/// `<dir>/cmd.txt` twice a second; each line is `<seq> pl <watts>` (`nvidia-smi -i <device> -pl <watts>`; the
|
||||
/// 0.3.9 form `<seq> <watts>` still works), `<seq> lgc <mhz>` (`-lgc 0,<mhz>`, the core clock cap; the memory clock
|
||||
/// is never touched) or `<seq> rgc` (`-rgc`, unlocked); `quit` ends it. After 20 minutes without a new command it
|
||||
/// restores `<restore watts>`, resets the clocks and exits by itself, so an engine that died mid-tune leaves the
|
||||
/// card on its old limits. It writes what it ran to `<dir>/helper.log`.
|
||||
pub fn helper_script_windows() -> &'static str {
|
||||
r#"param([string]$Dir, [string]$Smi, [string]$Device, [string]$Restore)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$cmd = Join-Path $Dir 'cmd.txt'
|
||||
$log = Join-Path $Dir 'helper.log'
|
||||
$last = ''
|
||||
$idle = Get-Date
|
||||
"$(Get-Date -Format o) helper started: device $Device, restore $Restore W" | Out-File -FilePath $log -Append -Encoding utf8
|
||||
while ($true) {
|
||||
$c = ''
|
||||
if (Test-Path -LiteralPath $cmd) { try { $c = (Get-Content -LiteralPath $cmd -Raw -ErrorAction Stop).Trim() } catch { $c = '' } }
|
||||
if ($c -and $c -ne $last) {
|
||||
$last = $c
|
||||
$idle = Get-Date
|
||||
if ($c -eq 'quit') { "$(Get-Date -Format o) quit" | Out-File -FilePath $log -Append -Encoding utf8; break }
|
||||
foreach ($line in ($c -split "`n")) {
|
||||
$p = ($line.Trim() -split ' ')
|
||||
if ($p.Count -lt 2) { continue }
|
||||
$op = $p[1]; $v = $p[-1]
|
||||
if ($p.Count -eq 2 -and $v -match '^\d+$') { $op = 'pl' }
|
||||
if ($op -eq 'pl' -and $v -match '^\d+$') {
|
||||
$out = (& $Smi -i $Device -pl $v 2>&1 | Out-String).Trim()
|
||||
"$(Get-Date -Format o) $($p[0]) -pl $v : $out" | Out-File -FilePath $log -Append -Encoding utf8
|
||||
} elseif ($op -eq 'lgc' -and $v -match '^\d+$') {
|
||||
$out = (& $Smi -i $Device -lgc "0,$v" 2>&1 | Out-String).Trim()
|
||||
"$(Get-Date -Format o) $($p[0]) -lgc 0,$v : $out" | Out-File -FilePath $log -Append -Encoding utf8
|
||||
} elseif ($op -eq 'rgc') {
|
||||
$out = (& $Smi -i $Device -rgc 2>&1 | Out-String).Trim()
|
||||
"$(Get-Date -Format o) $($p[0]) -rgc : $out" | Out-File -FilePath $log -Append -Encoding utf8
|
||||
}
|
||||
}
|
||||
}
|
||||
if (((Get-Date) - $idle).TotalMinutes -gt 20) {
|
||||
$out = (& $Smi -i $Device -pl $Restore 2>&1 | Out-String).Trim()
|
||||
$out2 = (& $Smi -i $Device -rgc 2>&1 | Out-String).Trim()
|
||||
"$(Get-Date -Format o) idle 20 min: restored $Restore W, clocks reset, and quit: $out / $out2" | Out-File -FilePath $log -Append -Encoding utf8
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
exit 0
|
||||
"#
|
||||
}
|
||||
|
||||
/// The same helper for Linux (run through pkexec sh).
|
||||
pub fn helper_script_unix() -> &'static str {
|
||||
r#"#!/bin/sh
|
||||
# igneum sweep helper: $1 dir, $2 nvidia-smi, $3 device, $4 restore watts
|
||||
dir="$1"; smi="$2"; dev="$3"; restore="$4"
|
||||
last=""; idle=$(date +%s)
|
||||
echo "$(date -u +%FT%TZ) helper started: device $dev, restore $restore W" >> "$dir/helper.log"
|
||||
while true; do
|
||||
c=""; [ -f "$dir/cmd.txt" ] && c=$(cat "$dir/cmd.txt" 2>/dev/null | tr -d '\r\n')
|
||||
if [ -n "$c" ] && [ "$c" != "$last" ]; then
|
||||
last="$c"; idle=$(date +%s)
|
||||
if [ "$c" = "quit" ]; then echo "$(date -u +%FT%TZ) quit" >> "$dir/helper.log"; break; fi
|
||||
printf '%s\n' "$c" | while IFS= read -r line; do
|
||||
set -- $line
|
||||
[ $# -ge 2 ] || continue
|
||||
op="$2"; v="${line##* }"
|
||||
[ $# -eq 2 ] && op=pl
|
||||
case "$op" in
|
||||
pl) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -pl $v : $("$smi" -i "$dev" -pl "$v" 2>&1)" >> "$dir/helper.log";; esac ;;
|
||||
lgc) case "$v" in ''|*[!0-9]*) ;; *) echo "$(date -u +%FT%TZ) $1 -lgc 0,$v : $("$smi" -i "$dev" -lgc "0,$v" 2>&1)" >> "$dir/helper.log";; esac ;;
|
||||
rgc) echo "$(date -u +%FT%TZ) $1 -rgc : $("$smi" -i "$dev" -rgc 2>&1)" >> "$dir/helper.log" ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
if [ $(( $(date +%s) - idle )) -gt 1200 ]; then
|
||||
echo "$(date -u +%FT%TZ) idle 20 min: restored $restore W, clocks reset: $("$smi" -i "$dev" -pl "$restore" 2>&1) / $("$smi" -i "$dev" -rgc 2>&1)" >> "$dir/helper.log"; break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
exit 0
|
||||
"#
|
||||
}
|
||||
|
||||
/// Why a card cannot be swept, or None when it can (NVIDIA with a readable default limit and a device index).
|
||||
pub fn unsupported_reason(vendor: &str, power_default_w: f64, device: &str) -> Option<&'static str> {
|
||||
match vendor {
|
||||
"nvidia" if power_default_w > 0.0 && !device.is_empty() => None,
|
||||
"nvidia" => Some("not available: nvidia-smi did not report this card's power limits"),
|
||||
// Ember Tune (src/ember.rs, 5 October 2026): AMD is tuned through igneum-gpu-telemetry, Apple measures only;
|
||||
// the tune itself says which at its start (the card row's note)
|
||||
"apple" | "amd" => None,
|
||||
_ => Some("not available: no power reading or cap for this card"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// PC 1's RTX 5090 (4 October 2026 app log): default 575 W, the 80% cap 460 W, min 400 W, max 600 W. The
|
||||
/// 60% and 50% steps clamp to the 400 W floor, so the plan ends at 70% plus one step at the floor.
|
||||
#[test]
|
||||
fn plan_clamps_to_the_card_floor_and_drops_duplicates() {
|
||||
let s = plan_steps(575.0, 400.0, 600.0);
|
||||
assert_eq!(s.iter().map(|s| s.pct).collect::<Vec<_>>(), vec![100, 90, 80, 70, 60]);
|
||||
assert_eq!(s.iter().map(|s| s.watts).collect::<Vec<_>>(), vec![575.0, 518.0, 460.0, 403.0, 400.0]);
|
||||
// a card with no floor reported: six steps
|
||||
assert_eq!(plan_steps(320.0, 0.0, 0.0).len(), 6);
|
||||
assert_eq!(plan_steps(320.0, 0.0, 0.0)[5], Step { pct: 50, watts: 160.0 });
|
||||
// the max caps the 100% step: a card whose max is under its default (rare, but nvidia-smi allows it)
|
||||
assert_eq!(plan_steps(300.0, 100.0, 250.0)[0], Step { pct: 100, watts: 250.0 });
|
||||
assert!(plan_steps(0.0, 0.0, 0.0).is_empty());
|
||||
}
|
||||
|
||||
/// Recorded on PC 1 (RTX 5090, 4 October 2026): the telemetry query answered "0, 290.12, 65, [N/A], 460.00"
|
||||
/// (draw, GPU temperature, memory temperature not supported, limit). The draw query is the same shape.
|
||||
#[test]
|
||||
fn parses_nvidia_smi_power_draw() {
|
||||
let nounits = "0, 290.12\r\n";
|
||||
assert_eq!(parse_power_draw(nounits).get("0"), Some(&290.12));
|
||||
// two cards, one without a reading
|
||||
let two = "0, 290.12\n1, [N/A]\n";
|
||||
let m = parse_power_draw(two);
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m.get("0"), Some(&290.12));
|
||||
// with units and a header (the same query without noheader,nounits)
|
||||
let units = "index, power.draw [W]\n0, 290.12 W\n1, 45.50 W\n";
|
||||
let m = parse_power_draw(units);
|
||||
assert_eq!(m.get("0"), Some(&290.12));
|
||||
assert_eq!(m.get("1"), Some(&45.5));
|
||||
// garbage and a warning line
|
||||
assert!(parse_power_draw("NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver.\n").is_empty());
|
||||
assert!(parse_power_draw("0, [Not Supported]\n").is_empty());
|
||||
assert!(parse_power_draw("").is_empty());
|
||||
}
|
||||
|
||||
fn row(pct: u32, watts: f64, mhs: f64) -> Row {
|
||||
Row { pct, limit: watts, watts, mhs, eff: if watts > 0.0 { mhs / watts } else { 0.0 }, draws: 12, rates: 2 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_the_best_mh_per_watt_then_rate_then_the_lower_cap() {
|
||||
// a card that gets more efficient as the cap drops, until it collapses
|
||||
let rows = vec![row(100, 560.0, 124.0), row(90, 510.0, 123.0), row(80, 455.0, 121.0), row(70, 400.0, 112.0), row(60, 345.0, 80.0)];
|
||||
assert_eq!(choose(&rows).unwrap().pct, 70); // 0.280 beats 0.266, 0.241, 0.221, 0.232
|
||||
// PC 1's case: the draw never reaches the cap (290 W under every limit), so every step reads the same;
|
||||
// ties on efficiency and rate go to the lowest cap
|
||||
let flat = vec![row(100, 290.0, 124.0), row(90, 290.5, 123.5), row(80, 289.8, 124.2), row(70, 290.2, 123.9)];
|
||||
assert_eq!(choose(&flat).unwrap().pct, 70);
|
||||
// within 1% on efficiency, the higher rate wins
|
||||
let close = vec![row(100, 500.0, 125.0), row(80, 400.5, 100.3)];
|
||||
assert_eq!(choose(&close).unwrap().pct, 100);
|
||||
// a step without readings never wins; none usable = no choice
|
||||
let mut r = row(60, 300.0, 90.0);
|
||||
r.eff = 0.0;
|
||||
assert_eq!(choose(&[r.clone(), row(100, 500.0, 120.0)]).unwrap().pct, 100);
|
||||
assert!(choose(&[r]).is_none());
|
||||
assert!(choose(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_lines_carry_the_table_fields() {
|
||||
let s = Step { pct: 80, watts: 460.0 };
|
||||
let r = Row::from_samples(&s, &[290.0, 291.0, 289.0, 290.0], &[124.0, 123.0]);
|
||||
assert!((r.eff - 123.5 / 290.0).abs() < 1e-9);
|
||||
assert_eq!(r.line("nvidia-ae432dc7-1"), "SWEEP card=nvidia-ae432dc7-1 cap=80 limit=460 watts=290.0 mhs=123.50 eff=0.4259");
|
||||
// under 3 draws or no rate: no efficiency, and the line says why
|
||||
let r = Row::from_samples(&s, &[290.0, 291.0], &[124.0]);
|
||||
assert!(!r.usable());
|
||||
assert!(r.line("c").contains("eff=0 reason=no_readings draws=2 rates=1"));
|
||||
}
|
||||
|
||||
/// A full sweep with a fake clock: three steps, the readback follows each cap after 4 s, two draws and one rate
|
||||
/// per hold. The engine's view is the sequence of Out values.
|
||||
#[test]
|
||||
fn state_machine_runs_a_sweep() {
|
||||
let t0 = Instant::now();
|
||||
let timing = Timing { settle: Duration::from_secs(15), hold: Duration::from_secs(60), apply: Duration::from_secs(30) };
|
||||
let steps = plan_steps(300.0, 200.0, 300.0); // 300, 270, 240, 210, 200 (60% and 50% clamp to 200)
|
||||
assert_eq!(steps.len(), 5);
|
||||
let mut run = Run::new(0, "nvidia:0:x", "0", "nvidia-test-1", steps.clone(), 80, 240.0, false, timing, t0);
|
||||
let mut limit = 240.0;
|
||||
let mut t = t0;
|
||||
let mut applied: Vec<f64> = Vec::new();
|
||||
let mut pending: Option<(f64, Instant)> = None;
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
let mut finished: Option<Row> = None;
|
||||
// the draw at each cap: the card draws min(cap, 260); once the cap bites, the rate falls with the square of
|
||||
// the cap (faster than the watts), so efficiency drops below the caps that do not bite
|
||||
let draw_at = |cap: f64| cap.min(260.0);
|
||||
let rate_at = |cap: f64| if cap >= 240.0 { 100.0 } else { 100.0 * (cap / 240.0) * (cap / 240.0) };
|
||||
for _ in 0..2000 {
|
||||
// the readback: the telemetry shows the new limit 4 s after the request
|
||||
if let Some((w, at)) = pending {
|
||||
if t.duration_since(at) >= Duration::from_secs(4) {
|
||||
limit = w;
|
||||
pending = None;
|
||||
}
|
||||
}
|
||||
for o in run.tick(t, limit) {
|
||||
match o {
|
||||
Out::Apply(w) => {
|
||||
applied.push(w);
|
||||
pending = Some((w, t));
|
||||
}
|
||||
Out::Row(r) => rows.push(r),
|
||||
Out::Finished(r) => finished = Some(r),
|
||||
Out::Failed(e) => panic!("failed: {e}"),
|
||||
}
|
||||
}
|
||||
if matches!(run.phase, Phase::Holding { .. }) {
|
||||
run.sample_draw(draw_at(limit));
|
||||
run.sample_draw(draw_at(limit) + 1.0);
|
||||
run.sample_draw(draw_at(limit) - 1.0);
|
||||
run.sample_rate(rate_at(limit));
|
||||
}
|
||||
if run.phase == Phase::Done {
|
||||
break;
|
||||
}
|
||||
t += Duration::from_secs(1);
|
||||
}
|
||||
assert_eq!(rows.len(), 5, "one row per step");
|
||||
assert_eq!(rows.iter().map(|r| r.pct).collect::<Vec<_>>(), vec![100, 90, 80, 70, 60]);
|
||||
// the first three caps sit above the 260 W draw: the same draw, the same rate (0.385 MH/W), so the lowest
|
||||
// of them (80%) wins the tie; 70% and 60% bite and lose rate faster than watts (0.337 and 0.347 MH/W)
|
||||
let f = finished.expect("finished");
|
||||
assert_eq!(f.pct, 80);
|
||||
assert_eq!(applied, vec![300.0, 270.0, 240.0, 210.0, 200.0, 240.0], "every step's cap, then the chosen one");
|
||||
assert!(run.rows.iter().all(|r| r.draws >= 3 && r.rates >= 1));
|
||||
// timing: five steps of apply (4 s) + settle 15 + hold 60 = 79 s each, plus the final apply
|
||||
let elapsed = t.duration_since(t0).as_secs();
|
||||
assert!((395..=420).contains(&elapsed), "elapsed {elapsed} s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_fails_when_a_cap_never_takes() {
|
||||
let t0 = Instant::now();
|
||||
let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(1), apply: Duration::from_secs(30) };
|
||||
let mut run = Run::new(0, "k", "0", "c", plan_steps(300.0, 0.0, 0.0), 80, 240.0, false, timing, t0);
|
||||
assert_eq!(run.tick(t0, 240.0), vec![Out::Apply(300.0)]);
|
||||
assert!(run.tick(t0 + Duration::from_secs(29), 240.0).is_empty());
|
||||
match run.tick(t0 + Duration::from_secs(31), 240.0).as_slice() {
|
||||
[Out::Failed(e)] => assert!(e.contains("did not take"), "{e}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(run.phase, Phase::Done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_fails_without_readings() {
|
||||
let t0 = Instant::now();
|
||||
let timing = Timing { settle: Duration::from_secs(1), hold: Duration::from_secs(1), apply: Duration::from_secs(30) };
|
||||
let mut run = Run::new(0, "k", "0", "c", vec![Step { pct: 100, watts: 300.0 }], 80, 240.0, false, timing, t0);
|
||||
run.tick(t0, 0.0);
|
||||
run.tick(t0 + Duration::from_secs(1), 300.0); // settling
|
||||
run.tick(t0 + Duration::from_secs(3), 300.0); // holding
|
||||
let out = run.tick(t0 + Duration::from_secs(5), 300.0);
|
||||
assert!(matches!(out.as_slice(), [Out::Row(_), Out::Failed(_)]), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_reasons() {
|
||||
assert!(unsupported_reason("nvidia", 575.0, "0").is_none());
|
||||
assert!(unsupported_reason("nvidia", 0.0, "0").unwrap().contains("power limits"));
|
||||
assert!(unsupported_reason("apple", 0.0, "").is_none(), "measure only, said by the tune");
|
||||
assert!(unsupported_reason("amd", 0.0, "1").is_none(), "tuned through igneum-gpu-telemetry");
|
||||
assert!(unsupported_reason("other", 0.0, "1").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_scripts_carry_the_protocol() {
|
||||
for s in [helper_script_windows(), helper_script_unix()] {
|
||||
assert!(s.contains("cmd.txt") && s.contains("quit") && s.contains("-pl") && s.contains("20 min"));
|
||||
assert!(s.contains("-lgc") && s.contains("-rgc"), "the clock cap and its reset");
|
||||
}
|
||||
}
|
||||
}
|
||||
174
app/igneum-app/src/verifier.rs
Normal file
174
app/igneum-app/src/verifier.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
592
app/igneum-app/src/watchdog.rs
Normal file
592
app/igneum-app/src/watchdog.rs
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
//! The engine's watchdog, as state machines with no clock and no process (seconds come from the caller), so the
|
||||
//! rules can be unit-tested with recorded miner lines.
|
||||
//!
|
||||
//! Per card (`CardWatch`): a miner that prints no status line for 90 s, or reports a hash rate of 0 for 60 s while
|
||||
//! the node is synced, is restarted once; when that recurs before five minutes of healthy status, the card is marked
|
||||
//! faulted with the reason, its miner is not restarted again, and the other cards keep mining. The miner's own
|
||||
//! worker restarts (`WORKER FAULT`, `worker exited`) suspend both rules until the worker is ready again, so the app
|
||||
//! never restarts a miner that is already restarting its worker (no double restarts); if the worker is not back
|
||||
//! within 180 s the app steps in. Exit code 43 (the miner gave up on its worker after three guard trips) counts
|
||||
//! like a watchdog restart: once, then faulted.
|
||||
//!
|
||||
//! Per node (`NodeWatch`): a node of ours that answers no `watch` reading for 120 s is restarted by the app, with a
|
||||
//! growing delay when it repeats inside ten minutes.
|
||||
//!
|
||||
//! Review round 4, X21 (4 October 2026): the app now reads `mismatched=` and `faults=` from STATUS lines and the
|
||||
//! `WORKER FAULT` lines, and shows them on the card.
|
||||
|
||||
/// No status line from a running miner for this long: restart it.
|
||||
pub const NO_STATUS_S: f64 = 90.0;
|
||||
/// Hash rate 0 while the node is synced and the worker is ready for this long: restart the miner.
|
||||
pub const ZERO_RATE_S: f64 = 60.0;
|
||||
/// The miner is restarting its own worker: the app waits this long for `ready` before it steps in.
|
||||
pub const WORKER_RESTART_GRACE_S: f64 = 180.0;
|
||||
/// Continuous healthy status (rate above 0) that renews the one-restart budget.
|
||||
pub const HEALTHY_RESET_S: f64 = 300.0;
|
||||
/// `igneum-miner` exit code when its guards gave up on the worker (three trips in ten minutes).
|
||||
pub const MINER_GAVE_UP_CODE: i32 = 43;
|
||||
/// No `watch` reading from our node for this long: restart it.
|
||||
pub const NODE_SILENT_S: f64 = 120.0;
|
||||
/// `igneum-miner` exit code when its worker refused the program pack it was started with and the miner could not
|
||||
/// rebuild it (or rebuilt it `PACK_REBUILD_CAP` times this epoch): the app exports the pack from the node again
|
||||
/// before the next start, at most `PACK_REBUILD_CAP` times per epoch, then shows the card.
|
||||
pub const PACK_OUT_OF_DATE_CODE: i32 = 44;
|
||||
/// Pack rebuilds per epoch seed before the app stops restarting the miner on it.
|
||||
pub const PACK_REBUILD_CAP: u32 = 3;
|
||||
/// Node restarts inside this window grow the delay before the next one.
|
||||
pub const NODE_WINDOW_S: f64 = 600.0;
|
||||
|
||||
/// What the watchdog reads from one `STATUS` line of `igneum-miner`.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Status {
|
||||
/// MH/s of the last interval (`now=`; older miners: the average `hash=`)
|
||||
pub hash_now: f64,
|
||||
pub mismatched: u64,
|
||||
pub faults: u64,
|
||||
pub restarts: u64,
|
||||
pub synced: bool,
|
||||
}
|
||||
|
||||
/// Parses a miner STATUS line; None for any other line.
|
||||
pub fn parse_status(text: &str) -> Option<Status> {
|
||||
if !text.contains(" STATUS '") {
|
||||
return None;
|
||||
}
|
||||
let avg = kv_f64(text, "hash").unwrap_or(0.0);
|
||||
Some(Status {
|
||||
hash_now: kv_f64(text, "now").unwrap_or(avg),
|
||||
mismatched: kv_u64(text, "mismatched").unwrap_or(0),
|
||||
faults: kv_u64(text, "faults").unwrap_or(0),
|
||||
restarts: kv_u64(text, "restarts").unwrap_or(0),
|
||||
synced: kv(text, "synced") == Some("true"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The reason text of a `WORKER FAULT` line, without the timestamp and the trailing restart note.
|
||||
pub fn fault_reason(text: &str) -> Option<String> {
|
||||
let i = text.find("WORKER FAULT ")?;
|
||||
let rest = &text["WORKER FAULT ".len() + i..];
|
||||
let end = rest.find("; restarting the worker").or_else(|| rest.find("; killing the worker")).unwrap_or(rest.len());
|
||||
Some(rest[..end].trim().to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Event<'a> {
|
||||
/// The app started the miner process.
|
||||
Started,
|
||||
/// The worker said ready.
|
||||
Ready,
|
||||
/// A STATUS line.
|
||||
Status(&'a Status),
|
||||
/// The miner killed its worker (a guard) or saw it exit; it restarts the worker itself.
|
||||
WorkerRestart(&'a str),
|
||||
/// The miner process ended with this code.
|
||||
Exited(i32),
|
||||
/// The app stopped the miner on purpose (pause, settings, node restart).
|
||||
Stopped,
|
||||
/// The user changed the card's settings: a faulted card may try again.
|
||||
Reset,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
None,
|
||||
/// Stop the miner and start it again now, for this reason.
|
||||
Restart(String),
|
||||
/// Mark the card faulted with this reason; do not restart its miner.
|
||||
Fault(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CardWatch {
|
||||
started_s: Option<f64>,
|
||||
last_status_s: Option<f64>,
|
||||
ready: bool,
|
||||
zero_since: Option<f64>,
|
||||
healthy_since: Option<f64>,
|
||||
worker_restart_since: Option<f64>,
|
||||
/// app-level restarts without five healthy minutes since
|
||||
restarts: u32,
|
||||
faulted: Option<String>,
|
||||
last_fault: String,
|
||||
}
|
||||
|
||||
impl CardWatch {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn faulted(&self) -> Option<&str> {
|
||||
self.faulted.as_deref()
|
||||
}
|
||||
|
||||
pub fn watchdog_restarts(&self) -> u32 {
|
||||
self.restarts
|
||||
}
|
||||
|
||||
fn arm(&mut self, now_s: f64) {
|
||||
self.started_s = Some(now_s);
|
||||
self.last_status_s = None;
|
||||
self.ready = false;
|
||||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
self.worker_restart_since = None;
|
||||
}
|
||||
|
||||
fn escalate(&mut self, reason: String) -> Action {
|
||||
self.started_s = None;
|
||||
self.last_status_s = None;
|
||||
self.ready = false;
|
||||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
self.worker_restart_since = None;
|
||||
if self.restarts >= 1 {
|
||||
let r = format!("{reason} (restarted once already)");
|
||||
self.faulted = Some(r.clone());
|
||||
Action::Fault(r)
|
||||
} else {
|
||||
self.restarts += 1;
|
||||
Action::Restart(reason)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event(&mut self, now_s: f64, ev: Event<'_>) -> Action {
|
||||
match ev {
|
||||
Event::Started => {
|
||||
self.arm(now_s);
|
||||
Action::None
|
||||
}
|
||||
Event::Ready => {
|
||||
self.ready = true;
|
||||
self.worker_restart_since = None;
|
||||
Action::None
|
||||
}
|
||||
Event::Status(s) => {
|
||||
self.last_status_s = Some(now_s);
|
||||
if s.hash_now > 0.0 {
|
||||
self.zero_since = None;
|
||||
let since = *self.healthy_since.get_or_insert(now_s);
|
||||
if now_s - since >= HEALTHY_RESET_S {
|
||||
self.restarts = 0;
|
||||
}
|
||||
} else {
|
||||
self.healthy_since = None;
|
||||
if self.ready && self.worker_restart_since.is_none() {
|
||||
self.zero_since.get_or_insert(now_s);
|
||||
}
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
Event::WorkerRestart(reason) => {
|
||||
self.last_fault = reason.to_string();
|
||||
self.worker_restart_since = Some(now_s);
|
||||
self.ready = false;
|
||||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
Action::None
|
||||
}
|
||||
Event::Exited(code) => {
|
||||
let running = self.started_s.is_some();
|
||||
self.started_s = None;
|
||||
if code == MINER_GAVE_UP_CODE && running {
|
||||
let why = if self.last_fault.is_empty() { "its guards tripped three times in ten minutes".to_string() } else { self.last_fault.clone() };
|
||||
self.escalate(format!("the miner gave up on its worker: {why}"))
|
||||
} else {
|
||||
Action::None
|
||||
}
|
||||
}
|
||||
Event::Stopped => {
|
||||
self.started_s = None;
|
||||
self.last_status_s = None;
|
||||
self.ready = false;
|
||||
self.zero_since = None;
|
||||
self.worker_restart_since = None;
|
||||
Action::None
|
||||
}
|
||||
Event::Reset => {
|
||||
*self = Self::default();
|
||||
Action::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called every engine tick while the miner process is alive.
|
||||
pub fn tick(&mut self, now_s: f64, node_synced: bool) -> Action {
|
||||
if self.faulted.is_some() {
|
||||
return Action::None;
|
||||
}
|
||||
let Some(started) = self.started_s else { return Action::None };
|
||||
if let Some(t) = self.worker_restart_since {
|
||||
// the miner is restarting its worker: its own guards own the card until the worker is ready
|
||||
if now_s - t > WORKER_RESTART_GRACE_S {
|
||||
return self.escalate(format!("the worker did not come back within {} s of the miner restarting it ({})", WORKER_RESTART_GRACE_S as u64, self.last_fault));
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
let last = self.last_status_s.unwrap_or(started);
|
||||
if now_s - last > NO_STATUS_S {
|
||||
return self.escalate(format!("no status line from the miner for {} s", NO_STATUS_S as u64));
|
||||
}
|
||||
if !node_synced {
|
||||
// a zero rate while the node syncs is expected; the timer starts again once it is synced
|
||||
self.zero_since = None;
|
||||
}
|
||||
if node_synced && self.ready {
|
||||
if let Some(z) = self.zero_since {
|
||||
if now_s - z >= ZERO_RATE_S {
|
||||
return self.escalate(format!("hash rate 0 for {} s while the node is synced", ZERO_RATE_S as u64));
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
}
|
||||
|
||||
/// The node watchdog: no reading for `NODE_SILENT_S` restarts our node, with a growing delay when it repeats.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NodeWatch {
|
||||
restarts_s: Vec<f64>,
|
||||
}
|
||||
|
||||
impl NodeWatch {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `silent_s`: seconds since the last reading (or since the node started, when it never answered). Returns the
|
||||
/// delay in seconds before the restart when one is due.
|
||||
pub fn tick(&mut self, now_s: f64, ours: bool, silent_s: f64, accepted_recent: bool) -> Option<u64> {
|
||||
if !ours || accepted_recent || silent_s < NODE_SILENT_S {
|
||||
return None;
|
||||
}
|
||||
self.restarts_s.retain(|t| now_s - *t <= NODE_WINDOW_S);
|
||||
self.restarts_s.push(now_s);
|
||||
let n = self.restarts_s.len();
|
||||
Some((3u64.saturating_mul(1u64 << (n.saturating_sub(1) * 2).min(8))).min(300))
|
||||
}
|
||||
|
||||
pub fn restarts_in_window(&self) -> usize {
|
||||
self.restarts_s.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn kv<'a>(line: &'a str, key: &str) -> Option<&'a str> {
|
||||
let pat = format!(" {key}=");
|
||||
let i = line.find(&pat)? + pat.len();
|
||||
let rest = &line[i..];
|
||||
let end = rest.find(|c: char| c == ' ' || c == ',' || c == ')' || c == ';').unwrap_or(rest.len());
|
||||
Some(&rest[..end])
|
||||
}
|
||||
|
||||
fn kv_u64(line: &str, key: &str) -> Option<u64> {
|
||||
kv(line, key)?.parse().ok()
|
||||
}
|
||||
|
||||
fn kv_f64(line: &str, key: &str) -> Option<f64> {
|
||||
kv(line, key)?.trim_end_matches('s').parse().ok()
|
||||
}
|
||||
|
||||
/// The decision after a pack refusal (exit `PACK_OUT_OF_DATE_CODE`, or a `PACK OUT OF DATE` / `error 0 pack` line
|
||||
/// from the miner): rebuild the pack before the restart, or stop for this epoch. 5 October 2026: the app restarted
|
||||
/// two workers every 20 to 40 s for an hour on a pack neither it nor the miner had re-exported in between.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PackAction {
|
||||
/// Export the pack from the node again (the n-th time this epoch) and start the miner on it now.
|
||||
Rebuild { n: u32, cap: u32 },
|
||||
/// The cap for this epoch is reached: show the card with this reason and wait for the next epoch (or a reset).
|
||||
GiveUp { n: u32, reason: String },
|
||||
}
|
||||
|
||||
/// Pack rebuilds per epoch seed (the epoch the exported pack names in its seeds.txt).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PackRebuilds {
|
||||
epoch: Option<String>,
|
||||
n: u32,
|
||||
}
|
||||
|
||||
impl PackRebuilds {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The miner exited over its pack while the exported pack is for `epoch`; `why` is the miner's reason.
|
||||
pub fn decide(&mut self, epoch: &str, why: &str) -> PackAction {
|
||||
if self.epoch.as_deref() != Some(epoch) {
|
||||
self.epoch = Some(epoch.to_string());
|
||||
self.n = 0;
|
||||
}
|
||||
if self.n >= PACK_REBUILD_CAP {
|
||||
return PackAction::GiveUp { n: self.n, reason: format!("the program pack still fails after {} rebuilds this epoch ({why}); the worker and the pack disagree", self.n) };
|
||||
}
|
||||
self.n += 1;
|
||||
PackAction::Rebuild { n: self.n, cap: PACK_REBUILD_CAP }
|
||||
}
|
||||
|
||||
pub fn count(&self) -> u32 {
|
||||
self.n
|
||||
}
|
||||
}
|
||||
|
||||
/// A miner line that names a pack refusal: the worker's own `error 0 pack <dir>: <why>` (relayed as
|
||||
/// `worker error: ...`) or the miner's `PACK OUT OF DATE <dir>: <why>`. Returns the reason, in plain words.
|
||||
pub fn pack_refusal(text: &str) -> Option<String> {
|
||||
if let Some(i) = text.find("PACK OUT OF DATE") {
|
||||
let rest = text[i + "PACK OUT OF DATE".len()..].trim_start_matches(':').trim();
|
||||
return Some(rest.split_once(": ").map(|(_, why)| why).unwrap_or(rest).to_string());
|
||||
}
|
||||
if let Some(i) = text.find("error 0 pack ") {
|
||||
let rest = &text[i + "error 0 pack ".len()..];
|
||||
let (_, why) = rest.split_once(": ")?;
|
||||
return Some(why.trim().to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The epoch seed hex an exported pack names (`epoch_seed_hex <hex>` in its seeds.txt), 16 chars.
|
||||
pub fn pack_epoch_of(seeds_txt: &str) -> Option<String> {
|
||||
seeds_txt.lines().find_map(|l| l.strip_prefix("epoch_seed_hex ")).map(|h| h.trim().chars().take(16).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// The refusal as PC 1 and PC 2 logged it on 5 October 2026 (epoch 34), the miner's own line, and non-refusals.
|
||||
const REFUSAL: &str = "! 1791224840.037 worker error: error 0 pack packs\\devnet: the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT (wrong seeds.txt for this pack?)";
|
||||
const MINER_LINE: &str = "1791224840.100 PACK OUT OF DATE packs\\devnet: the worker refused its program pack (the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT); rebuilding the program pack before the restart";
|
||||
|
||||
#[test]
|
||||
fn pack_refusal_reads_both_lines_and_nothing_else() {
|
||||
assert_eq!(pack_refusal(REFUSAL).unwrap(), "the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT (wrong seeds.txt for this pack?)");
|
||||
assert!(pack_refusal(MINER_LINE).unwrap().starts_with("the worker refused its program pack"));
|
||||
assert_eq!(pack_refusal("! 1791224651.247 worker error: error 5838 epoch seed mismatch: this worker holds epoch bed7ab62cbece66c"), None);
|
||||
assert_eq!(pack_refusal(STATUS_OK), None);
|
||||
assert_eq!(pack_epoch_of("epoch_seed_hex 009858237e118f69abc8d096e9b1af21c24539eaecdfd1b896588825660a69ec\nday_seed_hex 69676e65\n").as_deref(), Some("009858237e118f69"));
|
||||
assert_eq!(pack_epoch_of("day_seed_hex 69676e65\n"), None);
|
||||
}
|
||||
|
||||
/// Known-good: a refusal rebuilds the pack, once per refusal, and a new epoch starts the count again.
|
||||
#[test]
|
||||
fn pack_rebuilds_known_good() {
|
||||
let mut p = PackRebuilds::new();
|
||||
assert_eq!(p.decide("009858237e118f69", "x"), PackAction::Rebuild { n: 1, cap: PACK_REBUILD_CAP });
|
||||
assert_eq!(p.decide("009858237e118f69", "x"), PackAction::Rebuild { n: 2, cap: PACK_REBUILD_CAP });
|
||||
assert_eq!(p.decide("5e5d0c3b2a19f8e7", "x"), PackAction::Rebuild { n: 1, cap: PACK_REBUILD_CAP });
|
||||
assert_eq!(p.count(), 1);
|
||||
}
|
||||
|
||||
/// Known-mismatched: a pack the worker refuses after every rebuild stops at the cap with the reason in plain
|
||||
/// words, and stays stopped for that epoch.
|
||||
#[test]
|
||||
fn pack_rebuilds_known_mismatched_gives_up_at_the_cap() {
|
||||
let mut p = PackRebuilds::new();
|
||||
for n in 1..=PACK_REBUILD_CAP {
|
||||
assert_eq!(p.decide("009858237e118f69", "the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT"), PackAction::Rebuild { n, cap: PACK_REBUILD_CAP });
|
||||
}
|
||||
match p.decide("009858237e118f69", "the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT") {
|
||||
PackAction::GiveUp { n, reason } => {
|
||||
assert_eq!(n, PACK_REBUILD_CAP);
|
||||
assert_eq!(reason, "the program pack still fails after 3 rebuilds this epoch (the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT); the worker and the pack disagree");
|
||||
}
|
||||
a => panic!("{a:?}"),
|
||||
}
|
||||
assert!(matches!(p.decide("009858237e118f69", "x"), PackAction::GiveUp { .. }));
|
||||
assert_eq!(p.decide("5e5d0c3b2a19f8e7", "x"), PackAction::Rebuild { n: 1, cap: PACK_REBUILD_CAP });
|
||||
}
|
||||
|
||||
// Lines as igneum-miner 0.3.x prints them (devnet v4, 4 October 2026; identities and labels shortened).
|
||||
const STATUS_OK: &str = "1791138616.597 STATUS 'win-1' [worker]: 70s jobs=486 accepted=3 rejected=0 mismatched=0 extra=0 rate=0.04 blocks/s hash=123.90 MH/s wall (124.20 MH/s inside jobs) now=124.10 MH/s wall (124.30 MH/s inside jobs, 70 jobs, seed walk 0 calls) template_age=0.31s synced=true idle=0.2% (last 10s: 0.1%) queued=2 restarts=0 faults=0 identities=8 accepted_by_identity=1/0/1/0/0/1/0/0";
|
||||
const STATUS_ZERO: &str = "1791138626.597 STATUS 'win-1' [worker]: 80s jobs=486 accepted=3 rejected=0 mismatched=0 extra=0 rate=0.04 blocks/s hash=108.41 MH/s wall (124.20 MH/s inside jobs) now=0.00 MH/s wall (0.00 MH/s inside jobs, 0 jobs, seed walk 0 calls) template_age=0.31s synced=true idle=12.5% (last 10s: 100.0%) queued=2 restarts=0 faults=0 identities=8 accepted_by_identity=1/0/1/0/0/1/0/0";
|
||||
const STATUS_MISMATCH: &str = "1791138636.597 STATUS 'win-1' [worker]: 90s jobs=500 accepted=3 rejected=0 mismatched=3 extra=0 rate=0.03 blocks/s hash=110.00 MH/s wall (124.20 MH/s inside jobs) now=124.00 MH/s wall (124.30 MH/s inside jobs, 70 jobs, seed walk 0 calls) template_age=0.31s synced=true idle=0.2% (last 10s: 0.1%) queued=2 restarts=1 faults=1 identities=8 accepted_by_identity=1/0/1/0/0/1/0/0";
|
||||
const FAULT_LINE: &str = "1791138640.100 WORKER FAULT the last 10 s ran at 4300000.00 MH/s inside jobs against 3.30 MH/s over this worker's healthy intervals, over 10x: the worker is not running its kernel; restarting the worker (fault 1)";
|
||||
|
||||
#[test]
|
||||
fn parses_status_and_fault_lines() {
|
||||
let s = parse_status(STATUS_OK).unwrap();
|
||||
assert_eq!(s, Status { hash_now: 124.10, mismatched: 0, faults: 0, restarts: 0, synced: true });
|
||||
let m = parse_status(STATUS_MISMATCH).unwrap();
|
||||
assert_eq!((m.mismatched, m.faults, m.restarts), (3, 1, 1));
|
||||
assert_eq!(parse_status(STATUS_ZERO).unwrap().hash_now, 0.0);
|
||||
assert_eq!(parse_status("1791138640.100 worker: ready metal Apple_M5_Max prepare 1"), None);
|
||||
assert_eq!(fault_reason(FAULT_LINE).unwrap(), "the last 10 s ran at 4300000.00 MH/s inside jobs against 3.30 MH/s over this worker's healthy intervals, over 10x: the worker is not running its kernel");
|
||||
assert_eq!(fault_reason(STATUS_OK), None);
|
||||
}
|
||||
|
||||
fn healthy(w: &mut CardWatch, from: f64, to: f64) {
|
||||
let s = parse_status(STATUS_OK).unwrap();
|
||||
let mut t = from;
|
||||
while t <= to {
|
||||
assert_eq!(w.event(t, Event::Status(&s)), Action::None);
|
||||
assert_eq!(w.tick(t, true), Action::None);
|
||||
t += 10.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_miner_is_left_alone() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 3600.0);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_rate_restarts_once_then_faults() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 100.0);
|
||||
let z = parse_status(STATUS_ZERO).unwrap();
|
||||
for t in [110.0, 120.0, 130.0, 140.0, 150.0, 160.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None, "under 60 s at {t}");
|
||||
}
|
||||
w.event(170.0, Event::Status(&z));
|
||||
let a = w.tick(170.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("hash rate 0 for 60 s")), "{a:?}");
|
||||
// the app restarted it; still zero: faulted, not restarted again
|
||||
w.event(172.0, Event::Started);
|
||||
w.event(174.0, Event::Ready);
|
||||
for t in [180.0, 190.0, 200.0, 210.0, 220.0, 230.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None);
|
||||
}
|
||||
w.event(240.0, Event::Status(&z));
|
||||
let a = w.tick(240.0, true);
|
||||
assert!(matches!(a, Action::Fault(ref r) if r.contains("restarted once already")), "{a:?}");
|
||||
assert!(w.faulted().is_some());
|
||||
// faulted stays: no more actions
|
||||
w.event(250.0, Event::Status(&z));
|
||||
assert_eq!(w.tick(260.0, true), Action::None);
|
||||
// the user changed the card's settings: a fresh start
|
||||
w.event(300.0, Event::Reset);
|
||||
assert!(w.faulted().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_rate_only_counts_with_a_synced_node_and_a_ready_worker() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
let z = parse_status(STATUS_ZERO).unwrap();
|
||||
// not ready yet (program loading): no zero timer
|
||||
for t in [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None);
|
||||
}
|
||||
w.event(82.0, Event::Ready);
|
||||
// node not synced: no zero timer either
|
||||
for t in [90.0, 100.0, 110.0, 120.0, 130.0, 140.0, 150.0, 160.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, false), Action::None);
|
||||
}
|
||||
assert_eq!(w.tick(170.0, true), Action::None, "the timer starts at the first zero status after ready");
|
||||
for t in [180.0, 190.0, 200.0, 210.0, 220.0, 230.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
w.tick(t, true);
|
||||
}
|
||||
assert!(matches!(w.tick(240.0, true), Action::Restart(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_status_for_90s_restarts() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
// the miner goes silent (a stopped process, a hung RPC)
|
||||
assert_eq!(w.tick(149.0, true), Action::None);
|
||||
let a = w.tick(151.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("no status line")), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_status_from_the_start() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
assert_eq!(w.tick(89.0, true), Action::None);
|
||||
assert!(matches!(w.tick(91.0, true), Action::Restart(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_miners_own_worker_restart_is_not_doubled() {
|
||||
// The gfx1036 fault: the miner prints WORKER FAULT, kills the worker, restarts it 2 s later; STATUS lines in
|
||||
// between say now=0. The app must not restart the miner on top of that.
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 570.0);
|
||||
w.event(580.0, Event::WorkerRestart(&fault_reason(FAULT_LINE).unwrap()));
|
||||
let z = parse_status(STATUS_ZERO).unwrap();
|
||||
for t in [580.0, 590.0, 600.0, 610.0, 620.0, 630.0, 640.0, 650.0, 660.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None, "the miner owns the restart at {t}");
|
||||
}
|
||||
w.event(665.0, Event::Ready);
|
||||
healthy(&mut w, 670.0, 900.0);
|
||||
assert_eq!(w.watchdog_restarts(), 0, "no app restart happened");
|
||||
// a worker restart that never comes back: the app steps in after the grace
|
||||
w.event(910.0, Event::WorkerRestart("no job completed for 60 s with 2 queued"));
|
||||
for t in (920..=1080).step_by(10) {
|
||||
w.event(t as f64, Event::Status(&z));
|
||||
assert_eq!(w.tick(t as f64, true), Action::None);
|
||||
}
|
||||
let a = w.tick(1091.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("did not come back within 180 s")), "{a:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_43_once_then_faulted() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
w.event(70.0, Event::WorkerRestart("cpu re-check: 3 consecutive mismatches (mismatched=3 in this run): the worker computes a wrong program"));
|
||||
let a = w.event(75.0, Event::Exited(43));
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("gave up") && r.contains("wrong program")), "{a:?}");
|
||||
w.event(80.0, Event::Started);
|
||||
let a = w.event(300.0, Event::Exited(43));
|
||||
assert!(matches!(a, Action::Fault(_)), "{a:?}");
|
||||
// an ordinary crash is the engine's own jittered restart, not the watchdog's
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
assert_eq!(w.event(5.0, Event::Exited(1)), Action::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_healthy_minutes_renew_the_budget() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
assert!(matches!(w.tick(100.0, true), Action::Restart(_)));
|
||||
w.event(101.0, Event::Started);
|
||||
w.event(103.0, Event::Ready);
|
||||
healthy(&mut w, 110.0, 420.0);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
// a second incident later is again a restart, not a fault
|
||||
assert!(matches!(w.tick(520.0, true), Action::Restart(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deliberate_stop_is_not_a_fault() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
w.event(30.0, Event::Stopped);
|
||||
assert_eq!(w.tick(500.0, true), Action::None);
|
||||
assert_eq!(w.event(500.0, Event::Exited(0)), Action::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_watch_restarts_a_silent_node_with_growing_delay() {
|
||||
let mut n = NodeWatch::new();
|
||||
assert_eq!(n.tick(100.0, true, 119.0, false), None);
|
||||
assert_eq!(n.tick(100.0, false, 500.0, false), None, "an external node is never restarted");
|
||||
assert_eq!(n.tick(100.0, true, 500.0, true), None, "our block was accepted in the last minute: the node is alive");
|
||||
assert_eq!(n.tick(100.0, true, 120.0, false), Some(3));
|
||||
assert_eq!(n.tick(300.0, true, 120.0, false), Some(12));
|
||||
assert_eq!(n.tick(500.0, true, 120.0, false), Some(48));
|
||||
assert_eq!(n.restarts_in_window(), 3);
|
||||
assert_eq!(n.tick(2000.0, true, 120.0, false), Some(3), "the window passed");
|
||||
}
|
||||
}
|
||||
289
app/igneum-app/src/wslhost.rs
Normal file
289
app/igneum-app/src/wslhost.rs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
//! 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.
|
||||
/// Whether the distribution answers at all (`wsl.exe -d Ubuntu-24.04 -- echo <marker>` within 30 s): the install-time
|
||||
/// prover default (src/provedefault.rs) needs WSL2 on Windows before it switches proving on. Elsewhere: false.
|
||||
#[allow(dead_code)] // also compiled into src/bin/prove-verify.rs, which does not call it
|
||||
pub fn distro_answers() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
// self-contained (this file is also compiled into src/bin/prove-verify.rs, which has no detect or platform module)
|
||||
let mut cmd = std::process::Command::new("wsl");
|
||||
cmd.args(["-d", DISTRO, "--", "echo", "igneum-wsl-answers"]).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
let Ok(mut child) = cmd.spawn() else { return false };
|
||||
let Some(out) = child.stdout.take() else { return false };
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut s = String::new();
|
||||
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(out), &mut s);
|
||||
s
|
||||
});
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(100)),
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.join().map(|o| o.replace('\0', "").contains("igneum-wsl-answers")).unwrap_or(false)
|
||||
}
|
||||
|
||||
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 {
|
||||
// console: a builder; every caller runs it through run_capture, run_streamed or platform::quiet (tools/ci/windows-spawn-check.mjs)
|
||||
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\\the project lead\\AppData\\Local\\igneum\\wsl\\probe-12-3.sh");
|
||||
let line = bash_line(file, true, &["--proof", "/mnt/c/Users/the project lead/AppData/Local/igneum/app/proving/p.bin", "--statement", "0xab", "it's", "two\nlines"]);
|
||||
assert_eq!(
|
||||
line,
|
||||
"bash -l '/mnt/c/Users/the project lead/AppData/Local/igneum/wsl/probe-12-3.sh' '--proof' '/mnt/c/Users/the project lead/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\\[user]\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/[user]/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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
/* Igneum Miner dashboard. The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, ash;
|
||||
Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary. */
|
||||
/* Igneum Miner dashboard (miner-ui-2). The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone,
|
||||
ash; Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary.
|
||||
One type scale (--t-*), one spacing scale (--s-*), one colour set (:root), one accent (ember), used by every page.
|
||||
Layout: a rail on the left (six sections), a thin top bar, the status strip under it, the page in the middle, the
|
||||
log drawer at the bottom. The window lays out from 900 x 600 up (the hosts say the same minimum).
|
||||
Nothing here is newer than 2022 CSS (the WebView2 host). */
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexSans-400.woff2) format('woff2')}
|
||||
|
|
@ -9,53 +13,93 @@
|
|||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')}
|
||||
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
|
||||
--gutter:28px;--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:20px;--gap-tile:12px;
|
||||
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
|
||||
--top:60px;--bottom:52px}
|
||||
:root{
|
||||
/* colour */
|
||||
--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
|
||||
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--node-blue:#7FA7C9;--nvidia:#8BE37A;--rail-bg:#111114;
|
||||
/* type scale */
|
||||
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:16px;--t-2xl:18px;--t-num:26px;--t-h3:16px;--t-h2:32px;--t-h1:52px;
|
||||
/* spacing scale */
|
||||
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:20px;--s-6:28px;--s-7:40px;
|
||||
--gutter:var(--s-6);--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:var(--s-5);--gap-tile:var(--s-3);
|
||||
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
|
||||
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none}
|
||||
.mono,code,pre{font-family:var(--mono)}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-lg);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
|
||||
.mono,code,pre{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
||||
.dim{color:var(--ash)}
|
||||
h1,h2,h3{font-family:var(--head);margin:0;line-height:1.1;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:52px;letter-spacing:-.01em}
|
||||
h2{font-weight:700;font-size:32px}
|
||||
h3{font-weight:700;font-size:16px}
|
||||
h1{font-weight:900;font-size:var(--t-h1);letter-spacing:-.01em}
|
||||
h2{font-weight:700;font-size:var(--t-h2)}
|
||||
h3{font-weight:700;font-size:var(--t-h3)}
|
||||
p{margin:0}
|
||||
a{color:inherit}
|
||||
button{font:inherit;color:inherit}
|
||||
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:8px}
|
||||
input,textarea{font-variant-numeric:tabular-nums}
|
||||
.eyebrow{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.18em;text-transform:uppercase;color:var(--ash);display:inline-flex;align-items:center;gap:var(--s-2);white-space:nowrap}
|
||||
.eyebrow.ember{color:var(--ember)}
|
||||
[hidden]{display:none !important}
|
||||
::selection{background:rgba(242,84,27,.45)}
|
||||
|
||||
/* buttons */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:15px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease;white-space:nowrap}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:var(--s-2);min-height:44px;padding:10px 20px;border-radius:10px;font-weight:600;font-size:var(--t-lg);border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;transition:transform .15s ease,background .15s ease,border-color .15s ease,opacity .15s ease,color .15s ease;white-space:nowrap}
|
||||
.btn:hover{transform:translateY(-1px);border-color:var(--ash)}
|
||||
.btn:active{transform:none}
|
||||
.btn:disabled{opacity:.4;cursor:default;transform:none}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B}
|
||||
.btn.big{min-height:52px;padding:12px 28px;font-size:16px}
|
||||
.btn.small{min-height:34px;padding:6px 14px;font-size:13px;border-radius:8px}
|
||||
.btn.tiny{min-height:26px;padding:2px 10px;font-size:12px;border-radius:7px;font-family:var(--mono);font-weight:500}
|
||||
.btn.primary:hover{background:var(--ember-hi);border-color:var(--ember-hi)}
|
||||
.btn.big{min-height:52px;padding:12px 28px;font-size:var(--t-xl)}
|
||||
.btn.small{min-height:34px;padding:6px 14px;font-size:var(--t-base);border-radius:8px}
|
||||
.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500}
|
||||
.btn.ghost{border-color:transparent;color:var(--ink-2)}
|
||||
.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)}
|
||||
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)}
|
||||
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
|
||||
.icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line-2);background:transparent;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink-2);font-size:20px;line-height:1}
|
||||
.icon-btn:hover{color:var(--bone);border-color:var(--ash)}
|
||||
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
|
||||
|
||||
/* the rail */
|
||||
.rail{position:fixed;top:0;left:0;bottom:0;width:var(--rail);background:var(--rail-bg);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:22;padding:14px 12px 14px;-webkit-app-region:drag}
|
||||
.rail button{-webkit-app-region:no-drag}
|
||||
.rail-brand{display:flex;align-items:center;gap:10px;padding:6px 10px 18px;min-width:0}
|
||||
body.mac .rail-brand{padding-top:30px}
|
||||
.rail-brand .word{font-family:var(--head);font-weight:900;font-size:17px;letter-spacing:.06em}
|
||||
.rail-nav{display:flex;flex-direction:column;gap:3px}
|
||||
.nav{position:relative;display:flex;align-items:center;gap:12px;width:100%;min-height:42px;padding:8px 12px;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--ink-2);font-weight:500;font-size:var(--t-md);text-align:left;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease}
|
||||
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
|
||||
.nav:hover{background:rgba(255,255,255,.04);color:var(--bone)}
|
||||
.nav:hover svg{color:var(--ink-2)}
|
||||
.nav.on{background:var(--ember-12);border-color:rgba(242,84,27,.28);color:var(--bone);font-weight:600}
|
||||
.nav.on svg{color:var(--ember)}
|
||||
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
|
||||
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
|
||||
.nav.small svg{width:16px;height:16px;flex-basis:16px}
|
||||
.nav.danger:hover{color:var(--ember)}
|
||||
.nav.danger:hover svg{color:var(--ember)}
|
||||
.nav.on.logs{color:var(--molten)}
|
||||
.nav-dot{position:absolute;right:12px;top:50%;width:7px;height:7px;margin-top:-3px;border-radius:50%;background:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
|
||||
.rail-foot{margin-top:auto;display:flex;flex-direction:column;gap:2px;padding-top:12px;border-top:1px solid var(--line)}
|
||||
.rail-status{font-size:var(--t-xs);color:var(--ash);padding:0 12px 10px;line-height:1.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rail-status b{color:var(--ink-2);font-weight:500}
|
||||
.rail-version{font-size:var(--t-xs);color:var(--ash);padding:8px 12px 0;letter-spacing:.06em}
|
||||
|
||||
/* top bar */
|
||||
.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag}
|
||||
.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag}
|
||||
.top button,.top .pill{-webkit-app-region:no-drag}
|
||||
body.mac .top{padding-left:92px}
|
||||
.brand{display:flex;align-items:center;gap:10px}
|
||||
body.mac:not(.has-rail) .top{padding-left:92px}
|
||||
body.has-rail .top{left:var(--rail)}
|
||||
.brand{display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
|
||||
.brand .miner{font-family:var(--mono);font-size:11px;letter-spacing:.22em;color:var(--ash);margin-left:4px;padding-top:3px}
|
||||
.top-right{display:flex;align-items:center;gap:12px}
|
||||
.pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite)}
|
||||
.pill.on{color:var(--molten);border-color:rgba(255,179,92,.35)}
|
||||
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
|
||||
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
|
||||
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
|
||||
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
|
||||
.top-status{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:380px}
|
||||
.top-status .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)}
|
||||
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
|
||||
.pill.on{color:var(--molten);border-color:var(--molten-40)}
|
||||
.pill.warn{color:var(--ember)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
|
||||
.dot.small{width:7px;height:7px;flex-basis:7px}
|
||||
|
|
@ -64,144 +108,229 @@ 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}}
|
||||
|
||||
/* update banner */
|
||||
.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:rgba(242,84,27,.12);border-bottom:1px solid rgba(242,84,27,.4);font-size:14px}
|
||||
/* the status 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). */
|
||||
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
|
||||
body.has-rail .notices{left:var(--rail)}
|
||||
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);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}
|
||||
|
||||
.banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap}
|
||||
.banner.clock.warn{background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)}
|
||||
.banner .hint{font-size:11px;color:var(--ash);flex-basis:100%;text-align:center}
|
||||
.banner.update{flex-wrap:wrap;row-gap:8px}
|
||||
.banner.update.urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
|
||||
.banner .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-2px}
|
||||
.banner .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
|
||||
/* remote job strip (src/jobrun.rs): running, then the outcome */
|
||||
.banner.job{flex-wrap:wrap;row-gap:8px;background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)}
|
||||
.banner.job.failed{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5)}
|
||||
.banner.job .job-results{flex-basis:100%;margin:0;font-size:11px;line-height:1.5;color:var(--ink-2);white-space:pre-wrap;word-break:break-word;max-height:120px;overflow:auto}
|
||||
.job-history table{margin-top:8px}
|
||||
.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:11px;padding:4px 6px 4px 0}
|
||||
.job-history td{padding:5px 6px 5px 0;border-top:1px solid var(--line);vertical-align:top}
|
||||
.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)}
|
||||
.job-history tr.running td{color:var(--molten)}
|
||||
.clock-card{margin-top:12px;border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px}
|
||||
.clock-card.warn{border-color:rgba(255,179,92,.4);background:rgba(255,179,92,.06)}
|
||||
.clock-msg{font-size:14px;color:var(--bone);line-height:1.45}
|
||||
.clock-card .note{margin-top:0}
|
||||
|
||||
/* screens */
|
||||
main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)}
|
||||
body.has-bottom main{bottom:var(--bottom)}
|
||||
body.drawer-open main{bottom:calc(var(--bottom) + 260px)}
|
||||
/* screens and pages */
|
||||
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-rail main{left:var(--rail)}
|
||||
body.drawer-open main{bottom:var(--drawer-h)}
|
||||
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
|
||||
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){.screen{animation:none}}
|
||||
@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}}
|
||||
#screen-dashboard{padding:22px 0 32px}
|
||||
.page{display:flex;flex-direction:column;gap:var(--gap);animation:rise .3s ease}
|
||||
|
||||
/* welcome */
|
||||
.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:18px;padding:40px 0 48px}
|
||||
.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-7) 0 48px}
|
||||
.coin-wrap{position:relative;width:148px;height:148px;margin-bottom:6px}
|
||||
.coin-wrap::before{content:"";position:absolute;inset:-40px;border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.28) 0,rgba(242,84,27,0) 65%);animation:breathe 4s ease-in-out infinite}
|
||||
.coin{position:relative;display:block;border-radius:50%;filter:drop-shadow(0 10px 30px rgba(242,84,27,.35))}
|
||||
@keyframes breathe{0%,100%{opacity:.7;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
|
||||
.lead{font-size:18px;color:var(--ink-2);max-width:54ch}
|
||||
@media (prefers-reduced-motion:reduce){.coin-wrap::before{animation:none}}
|
||||
.lead{font-size:var(--t-2xl);color:var(--ink-2);max-width:54ch}
|
||||
.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap-tile);width:100%;max-width:860px;margin-top:10px;text-align:left}
|
||||
.tile{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.tile .k{font-family:var(--mono);font-size:11px;letter-spacing:.14em;color:var(--ember)}
|
||||
.tile .t{font-family:var(--head);font-weight:700;font-size:15px;line-height:1.25}
|
||||
.tile .s{font-size:13px;color:var(--ash);line-height:1.45}
|
||||
.cta{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;margin-top:10px}
|
||||
.seedline{font-size:12px;color:var(--ash);letter-spacing:.04em}
|
||||
.tile .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;color:var(--ember)}
|
||||
.tile .t{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.25}
|
||||
.tile .s{font-size:var(--t-base);color:var(--ash);line-height:1.45}
|
||||
.cta{display:flex;flex-wrap:wrap;gap:var(--s-3);align-items:center;justify-content:center;margin-top:10px}
|
||||
.seedline{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em}
|
||||
|
||||
/* steps */
|
||||
.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:16px}
|
||||
.step .sub{font-size:16px;color:var(--ink-2);max-width:60ch}
|
||||
.step .cta{justify-content:flex-start;margin-top:8px}
|
||||
.note{font-size:13px;color:var(--ash);line-height:1.5}
|
||||
.note.small{font-size:12px;word-break:break-all}
|
||||
.cards{display:flex;flex-direction:column;gap:12px;margin-top:8px}
|
||||
.step{max-width:720px;margin:0 auto;padding:56px 0 48px;display:flex;flex-direction:column;gap:var(--s-4)}
|
||||
.step .sub{font-size:var(--t-xl);color:var(--ink-2);max-width:60ch}
|
||||
.step .cta{justify-content:flex-start;margin-top:var(--s-2)}
|
||||
.note{font-size:var(--t-base);color:var(--ash);line-height:1.5}
|
||||
.note.small{font-size:var(--t-sm);word-break:break-all}
|
||||
.help{font-size:var(--t-base);color:var(--ash);line-height:1.5;max-width:64ch}
|
||||
.cards{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-2)}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0}
|
||||
.card.detecting{display:flex;align-items:center;gap:18px}
|
||||
.card.detecting .t{font-family:var(--head);font-weight:700;font-size:16px}
|
||||
.card.detecting .s{font-size:12px;color:var(--ash);margin-top:4px}
|
||||
.card.detecting .t{font-family:var(--head);font-weight:700;font-size:var(--t-xl)}
|
||||
.card.detecting .s{font-size:var(--t-sm);color:var(--ash);margin-top:var(--s-1)}
|
||||
.spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.gpu{display:flex;align-items:center;gap:18px}
|
||||
.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
|
||||
.gpu .badge.apple{color:var(--bone)}
|
||||
.gpu .badge.nvidia{color:#8BE37A}
|
||||
.gpu .badge.amd{color:var(--ember)}
|
||||
.gpu .name{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.2}
|
||||
.gpu .meta{font-family:var(--mono);font-size:12px;color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px}
|
||||
.gpu .meta b{color:var(--ink-2);font-weight:500}
|
||||
.gpu .tick{margin-left:auto;width:36px;height:36px;border-radius:50%;background:var(--ember);display:flex;align-items:center;justify-content:center;flex:0 0 36px}
|
||||
.gpu.off .tick{background:var(--line-2)}
|
||||
.gpu .tick svg path{stroke-dasharray:30;stroke-dashoffset:30;animation:draw .8s .2s ease forwards}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
.gpu .msg{font-size:12px;color:var(--ember);margin-top:4px}
|
||||
|
||||
/* GPU rows (first run and settings) */
|
||||
.gpu-row{display:flex;align-items:center;gap:16px;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0}
|
||||
/* GPU rows (first run) */
|
||||
.gpu-row{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap}
|
||||
.gpu-row.off{opacity:.72}
|
||||
.gpu-row .badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
|
||||
.gpu-row .badge.apple{color:var(--bone)}
|
||||
.gpu-row .badge.nvidia{color:#8BE37A}
|
||||
.gpu-row .badge.amd{color:var(--ember)}
|
||||
.gpu-row .info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||
.gpu-row .name{font-family:var(--head);font-weight:700;font-size:16px;line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500}
|
||||
.kind.discrete,.kind.apple{color:var(--molten);border-color:rgba(255,179,92,.4)}
|
||||
.badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
|
||||
.badge.apple{color:var(--bone)}
|
||||
.badge.nvidia{color:var(--nvidia)}
|
||||
.badge.amd{color:var(--ember)}
|
||||
.gpu-row .info{flex:1;min-width:180px;display:flex;flex-direction:column;gap:var(--s-1)}
|
||||
.gpu-row .name{font-family:var(--head);font-weight:700;font-size:var(--t-xl);line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500;white-space:nowrap}
|
||||
.kind.discrete,.kind.apple{color:var(--molten);border-color:var(--molten-40)}
|
||||
.kind.external{color:var(--bone)}
|
||||
.gpu-row .meta{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px}
|
||||
.gpu-row .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 14px}
|
||||
.gpu-row .meta b{color:var(--ink-2);font-weight:500}
|
||||
.gpu-row .reason{font-size:12px;color:var(--ash)}
|
||||
.gpu-row .msg{font-size:12px;color:var(--ember)}
|
||||
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto}
|
||||
.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:4px}
|
||||
.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)}
|
||||
.gpu-row .msg{font-size:var(--t-sm);color:var(--ember)}
|
||||
.gpu-row .switch{padding:0;flex:0 0 auto;margin-left:auto}
|
||||
/* hot-plug (src/hotplug.rs): a removed card dims, a faulty one is named in ember, neither has live controls */
|
||||
.gpu-row.removed,.gpu-line.removed,.set-card.removed{opacity:.5}
|
||||
.gpu-row.unusable .name,.gpu-line.unusable .name,.set-card.unusable .name{color:var(--ember)}
|
||||
|
||||
/* the Mine page: the big switch and the three numbers */
|
||||
.hero-row{display:grid;grid-template-columns:1.3fr 1fr 1fr 1fr;gap:var(--gap-tile)}
|
||||
.toggle-big{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:4px;min-height:112px;padding:18px 20px;border-radius:var(--tile-r);border:1px solid var(--ember);background:var(--ember);color:var(--ember-ink);cursor:pointer;text-align:left;transition:background .15s ease,border-color .15s ease,transform .15s ease,color .15s ease;min-width:0}
|
||||
.toggle-big:hover{background:var(--ember-hi);border-color:var(--ember-hi);transform:translateY(-1px)}
|
||||
.toggle-big:disabled{opacity:.45;cursor:default;transform:none}
|
||||
.toggle-big .ring{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:rgba(12,12,14,.18);margin-bottom:6px}
|
||||
.toggle-big .ring svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round}
|
||||
.toggle-big .tl{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap}
|
||||
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||||
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
|
||||
.toggle-big.stop:hover{border-color:var(--ash);background:#1b1b20}
|
||||
.toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)}
|
||||
.toggle-big.stop .ts{color:var(--molten);opacity:1}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
|
||||
.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em}
|
||||
.cell.ember .v{color:var(--ember)}
|
||||
.cell .v .unit{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);font-weight:500;letter-spacing:.08em}
|
||||
.cell .v.state{text-transform:capitalize;font-size:22px}
|
||||
.cell .v.small{font-size:18px}
|
||||
.cell .v.dim{color:var(--ash)}
|
||||
.cell .s{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell.ok .v.state{color:var(--molten)}
|
||||
.cell.bad .v.state{color:var(--ember)}
|
||||
.cell.bad .s{color:var(--ember)}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap);align-items:start}
|
||||
.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap}
|
||||
.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);color:var(--ash)}
|
||||
.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
|
||||
#dag{display:block;width:100%;height:170px;border-radius:12px;background:var(--obsidian)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;gap:var(--s-2)}
|
||||
.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)}
|
||||
.sw.ember{background:var(--ember);border-color:var(--ember)}
|
||||
.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
|
||||
.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0}
|
||||
.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0}
|
||||
.empty.err{color:var(--ember)}
|
||||
.kv{display:flex;flex-direction:column}
|
||||
.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
|
||||
.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}
|
||||
.card .help+.help{margin-top:6px}
|
||||
.big-word{font-family:var(--head);font-weight:700;font-size:20px;line-height:1.2;margin:2px 0 8px;word-break:break-word}
|
||||
.big-word.ok{color:var(--molten)}
|
||||
.big-word.warn{color:var(--ember)}
|
||||
.big-word.dim{color:var(--ash)}
|
||||
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:260px;overflow:auto}
|
||||
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline}
|
||||
.feed>div:last-child{border-bottom:0}
|
||||
.feed>div>span:first-child{min-width:0}
|
||||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
|
||||
.feed .k{color:var(--molten);margin-right:6px}
|
||||
.feed .k.error{color:var(--ember)}
|
||||
.feed .k.warn{color:var(--ember)}
|
||||
.feed .k.block{color:var(--ember)}
|
||||
.feed .k.build{color:var(--ink-2)}
|
||||
|
||||
/* the GPU rows on the Mine page: badge, name, numbers, the switch */
|
||||
.gpu-list{display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.gpu-line{display:grid;grid-template-columns:44px minmax(160px,1.4fr) repeat(3,minmax(84px,.7fr)) 48px;align-items:center;gap:var(--s-4);padding:12px 14px;border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);min-width:0}
|
||||
.gpu-line.off{opacity:.62}
|
||||
.gpu-line .badge{width:44px;height:44px;flex-basis:44px;border-radius:12px}
|
||||
.gpu-line .who{min-width:0;display:flex;flex-direction:column;gap:3px}
|
||||
.gpu-line .name{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.gpu-line .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.gpu-line .st.on{color:var(--molten)}
|
||||
.gpu-line .st.bad{color:var(--ember)}
|
||||
.gpu-line .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block;flex:0 0 7px}
|
||||
.gpu-line .msg{font-size:var(--t-sm);color:var(--ember);line-height:1.4}
|
||||
.gpu-line .msg.dim{color:var(--ash)}
|
||||
.gpu-line .num{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.gpu-line .num .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
|
||||
.gpu-line .num .v{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:5px}
|
||||
.gpu-line .num .v .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em}
|
||||
.gpu-line .num .v.hot{color:var(--ember)}
|
||||
.gpu-line .num .v.warm{color:var(--molten)}
|
||||
.gpu-line .num .v.na{color:var(--ash);font-weight:500;font-size:var(--t-md)}
|
||||
.gpu-line.on .num.hash .v{color:var(--ember)}
|
||||
.gpu-line .switch{padding:0;justify-self:end}
|
||||
|
||||
/* the Settings page: per-card controls */
|
||||
.set-cards{display:flex;flex-direction:column;gap:var(--s-3);margin-bottom:var(--s-3)}
|
||||
.set-card{border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);padding:14px 16px;display:flex;flex-direction:column;gap:10px;min-width:0}
|
||||
.set-card .head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.set-card .head .name{font-family:var(--head);font-weight:700;font-size:var(--t-md)}
|
||||
.set-card .head .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
|
||||
.set-card .ctl{display:grid;grid-template-columns:150px 1fr auto;align-items:center;gap:var(--s-3)}
|
||||
.set-card .ctl .k{font-size:var(--t-md);color:var(--bone);font-weight:500}
|
||||
.set-card .ctl .pv{font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);min-width:110px;text-align:right;white-space:nowrap}
|
||||
.set-card input[type=range]{width:100%;accent-color:var(--ember);margin:0}
|
||||
.set-card .ctl .help{grid-column:1 / -1;margin-top:-4px}
|
||||
.set-card .line{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center}
|
||||
.set-card .line b{color:var(--ink-2);font-weight:500}
|
||||
.set-card .line.ok{color:var(--molten)}
|
||||
.set-card .line.hot{color:var(--ember)}
|
||||
.set-card .line.on{color:var(--molten)}
|
||||
.set-card .line .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px}
|
||||
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto;justify-self:end}
|
||||
.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1}
|
||||
.ids button:hover{border-color:var(--ash)}
|
||||
.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:13px;user-select:text;-webkit-user-select:text}
|
||||
.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:var(--t-base);user-select:text;-webkit-user-select:text}
|
||||
.ids input:focus{outline:none;border-color:var(--ember)}
|
||||
.ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
|
||||
.gpu-row.off .ids{opacity:.4;pointer-events:none}
|
||||
.gpu-row .switch{padding:0;flex:0 0 auto}
|
||||
.cards.compact .gpu-row{padding:12px 14px;gap:12px;border-radius:14px}
|
||||
.cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px}
|
||||
.cards.compact .gpu-row .name{font-size:14px}
|
||||
.cards.compact .ids .k{display:none}
|
||||
|
||||
.power{display:flex;align-items:center;gap:8px;flex:0 0 auto}
|
||||
.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.power input[type=range]{width:110px;accent-color:var(--ember)}
|
||||
.power .pv{font-size:12px;color:var(--ink-2);min-width:84px}
|
||||
.cards.compact .power .k{display:none}
|
||||
.cards.compact .power input[type=range]{width:80px}
|
||||
.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)}
|
||||
.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)}
|
||||
.gpu-tile .msg.ok,.gpu-row .msg.ok{color:var(--molten)}
|
||||
.gpu-row .msg.hot,.gpu-tile .msg.hot{color:var(--ember)}
|
||||
.gpu-tile .msg .btn.tiny,.gpu-row .msg .btn.tiny{margin-left:6px;vertical-align:middle}
|
||||
.gpu-tile .msg.warm{color:var(--molten)}
|
||||
.gpu-tile .msg.hot{color:var(--ember)}
|
||||
|
||||
/* per-card tiles on the dashboard */
|
||||
.gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)}
|
||||
.gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:14px;line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums}
|
||||
.gpu-tile .h .unit{font-family:var(--mono);font-size:11px;color:var(--ash);font-weight:500;letter-spacing:.08em}
|
||||
.gpu-tile.idle .h{color:var(--ash)}
|
||||
.gpu-tile .m{font-family:var(--mono);font-size:12px;color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px}
|
||||
.gpu-tile .m b{color:var(--ink-2);font-weight:500}
|
||||
.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)}
|
||||
.gpu-tile .st.mining{color:var(--molten)}
|
||||
.gpu-tile .st.bad{color:var(--ember)}
|
||||
.gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block}
|
||||
.gpu-tile .msg{font-size:12px;color:var(--ash)}
|
||||
.gpu-off{margin-top:12px;font-size:12px;color:var(--ash)}
|
||||
.lead-card .lead-row,.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)}
|
||||
.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px}
|
||||
.lead-text h3{font-size:var(--t-2xl)}
|
||||
.switch-list{display:flex;flex-direction:column;gap:3px;font-size:var(--t-sm);color:var(--ash);margin-top:8px}
|
||||
.switch-list span{display:flex;justify-content:space-between;gap:12px}
|
||||
.switch-list span.past{opacity:.55}
|
||||
.switch-list span.next{color:var(--molten)}
|
||||
.addr-big{display:flex;align-items:center;gap:12px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:12px;padding:14px 16px;font-size:var(--t-lg);word-break:break-all;user-select:text;-webkit-user-select:text;margin-bottom:10px}
|
||||
.addr-big span{flex:1;min-width:0;color:var(--molten)}
|
||||
.card.adv{padding:0}
|
||||
.card.adv summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:var(--card-pad)}
|
||||
.card.adv summary::-webkit-details-marker{display:none}
|
||||
.card.adv summary::after{content:"+";font-family:var(--mono);color:var(--ash);font-size:18px;margin-left:auto}
|
||||
.card.adv[open] summary::after{content:"\2212"}
|
||||
.card.adv summary .eyebrow{margin-left:0}
|
||||
.card.adv>:not(summary){margin-left:var(--card-pad);margin-right:var(--card-pad)}
|
||||
.card.adv>:last-child{margin-bottom:var(--card-pad)}
|
||||
.job-history table{margin-top:var(--s-2)}
|
||||
table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
||||
.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase}
|
||||
.job-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
|
||||
.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)}
|
||||
.job-history tr.running td{color:var(--molten)}
|
||||
.clock-card{border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.clock-card.warn{border-color:var(--molten-40);background:rgba(255,179,92,.06)}
|
||||
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
|
||||
.clock-card .note{margin-top:0}
|
||||
|
||||
/* address options */
|
||||
.options{display:flex;flex-direction:column;gap:12px;margin-top:6px}
|
||||
.option{display:flex;gap:16px;align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease}
|
||||
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
|
||||
.option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease}
|
||||
.option:hover{border-color:var(--line-2)}
|
||||
.option.on{border-color:var(--ember);background:#1A1614}
|
||||
.option input[type=radio]{position:absolute;opacity:0;width:0;height:0}
|
||||
|
|
@ -209,123 +338,188 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.option.on .radio{border-color:var(--ember)}
|
||||
.option.on .radio::after{content:"";position:absolute;inset:4px;border-radius:50%;background:var(--ember)}
|
||||
.option .body{display:flex;flex-direction:column;gap:6px;min-width:0;flex:1}
|
||||
.option .t{font-family:var(--head);font-weight:700;font-size:16px;display:flex;align-items:center;gap:10px}
|
||||
.option .s{font-size:14px;color:var(--ash);line-height:1.5}
|
||||
.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid rgba(255,179,92,.4);border-radius:999px;padding:2px 8px}
|
||||
.addr-input{width:100%;margin-top:8px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:14px;letter-spacing:.02em;user-select:text;-webkit-user-select:text}
|
||||
.option .t{font-family:var(--head);font-weight:700;font-size:var(--t-xl);display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.option .s{font-size:var(--t-md);color:var(--ash);line-height:1.5}
|
||||
.tag{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--molten);border:1px solid var(--molten-40);border-radius:999px;padding:2px 8px}
|
||||
.addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text}
|
||||
.addr-input:focus{outline:none;border-color:var(--ember)}
|
||||
.option:not(.on) .addr-input{display:none}
|
||||
.err{font-size:13px;color:var(--ember)}
|
||||
.err{font-size:var(--t-base);color:var(--ember)}
|
||||
|
||||
/* dashboard */
|
||||
#screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)}
|
||||
body[data-phase="dashboard"] #screen-dashboard{display:flex}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:var(--head);font-weight:700;font-size:26px;line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:8px}
|
||||
.cell.ember .v{color:var(--ember)}
|
||||
.cell .v .unit{font-family:var(--mono);font-size:12px;color:var(--ash);font-weight:500;letter-spacing:.08em}
|
||||
.cell .v.state{text-transform:capitalize;font-size:22px}
|
||||
.cell .s{font-family:var(--mono);font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell.ok .v.state{color:var(--molten)}
|
||||
.cell.bad .v.state{color:var(--ember)}
|
||||
.grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start}
|
||||
.col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0}
|
||||
.card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:12px;color:var(--ash)}
|
||||
.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
|
||||
#dag{display:block;width:100%;height:190px;border-radius:12px;background:var(--obsidian)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:12px;font-size:12px;color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;gap:8px}
|
||||
.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)}
|
||||
.sw.ember{background:var(--ember);border-color:var(--ember)}
|
||||
.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
|
||||
.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0}
|
||||
.tbl{overflow-x:auto}
|
||||
table{border-collapse:collapse;width:100%;font-size:13px}
|
||||
th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||
th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px;color:var(--ash)}
|
||||
td .st.mining{color:var(--molten)}
|
||||
td .st.bad{color:var(--ember)}
|
||||
td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block}
|
||||
td .sub{display:block;font-family:var(--mono);font-size:11px;color:var(--ash);white-space:normal;max-width:280px}
|
||||
.empty{color:var(--ash);font-size:13px;padding:10px 0}
|
||||
.kv{display:flex;flex-direction:column}
|
||||
.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:7px 0;border-bottom:1px solid var(--line)}
|
||||
.kv>div:last-child{border-bottom:0}
|
||||
.kv .k{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ash)}
|
||||
.kv .v{font-size:14px;font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card .note{margin-top:10px}
|
||||
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:12px;color:var(--ink-2);max-height:300px;overflow:auto}
|
||||
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline}
|
||||
.feed>div:last-child{border-bottom:0}
|
||||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:11px}
|
||||
.feed .k{color:var(--molten);margin-right:6px}
|
||||
.feed .k.error{color:var(--ember)}
|
||||
.feed .k.block{color:var(--ember)}
|
||||
.feed .k.build{color:var(--ink-2)}
|
||||
/* the update card (app.js, UpdateCard; the wallet carries the same block): the mark with its ring, the name, one
|
||||
line, up to three note lines, the size, Install now and Later. Over the key sheet and the pages. */
|
||||
.upd-wrap{position:fixed;inset:0;z-index:35;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease}
|
||||
@keyframes fade{from{opacity:0}to{opacity:1}}
|
||||
.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none}
|
||||
.upd-card::before{content:"";position:absolute;left:50%;top:-80px;width:360px;height:260px;transform:translateX(-50%);border-radius:50%;background:radial-gradient(circle,rgba(242,84,27,.22) 0,rgba(242,84,27,0) 62%);pointer-events:none}
|
||||
.upd-mark{position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;margin-bottom:var(--s-3)}
|
||||
.upd-mark img{position:relative;display:block;filter:drop-shadow(0 6px 18px rgba(242,84,27,.35))}
|
||||
.upd-ring{position:absolute;inset:0;transform:rotate(-90deg)}
|
||||
.upd-ring .track{fill:none;stroke:var(--line-2);stroke-width:3}
|
||||
.upd-ring .arc{fill:none;stroke:var(--ember);stroke-width:3;stroke-linecap:round;stroke-dasharray:276.5;stroke-dashoffset:276.5;transition:stroke-dashoffset .4s linear,stroke .3s ease}
|
||||
.upd-mark.none .track{stroke:var(--line)}
|
||||
.upd-mark.full .arc{stroke:var(--molten);stroke-dashoffset:0}
|
||||
.upd-mark.busy .arc{stroke-dasharray:69 207.5;stroke-dashoffset:0;animation:spin 1.1s linear infinite;transform-origin:50% 50%}
|
||||
.upd-mark.failed .arc{stroke:var(--ember);stroke-dashoffset:0;opacity:.55}
|
||||
.upd-pct{position:absolute;left:50%;bottom:-11px;transform:translateX(-50%);font-size:var(--t-xs);font-weight:500;letter-spacing:.06em;color:var(--molten);background:var(--obsidian);border:1px solid var(--line-2);border-radius:999px;padding:2px 8px;font-variant-numeric:tabular-nums}
|
||||
.upd-name{font-size:24px;font-weight:700;letter-spacing:-.01em;position:relative}
|
||||
.upd-line{font-size:var(--t-xl);color:var(--ink-2);line-height:1.4;position:relative}
|
||||
.upd-cause{font-size:var(--t-sm);color:var(--ember);line-height:1.5;max-width:40ch;word-break:break-word}
|
||||
.upd-notes{list-style:none;margin:var(--s-2) auto 0;padding:0;width:max-content;max-width:100%;display:flex;flex-direction:column;gap:6px;align-items:flex-start;font-size:var(--t-md);color:var(--bone);line-height:1.4}
|
||||
.upd-notes:empty{display:none}
|
||||
.upd-notes li{display:flex;align-items:baseline;gap:10px;text-align:left}
|
||||
.upd-notes li::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ember);flex:0 0 6px;position:relative;top:-2px}
|
||||
.upd-more{font:inherit;font-size:var(--t-sm);font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);background:transparent;border:0;padding:4px 8px;cursor:pointer;border-radius:6px;margin-top:2px}
|
||||
.upd-more:hover{color:var(--bone)}
|
||||
.upd-all{list-style:none;margin:0;padding:10px 14px;width:100%;max-height:150px;overflow:auto;text-align:left;font-size:var(--t-base);color:var(--ink-2);line-height:1.5;background:var(--obsidian);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;gap:4px;user-select:text;-webkit-user-select:text}
|
||||
.upd-meta{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em;margin-top:var(--s-2);min-height:18px;line-height:1.5;max-width:44ch}
|
||||
.upd-meta:empty{display:none}
|
||||
.upd-actions{display:flex;gap:var(--s-3);align-items:center;justify-content:center;flex-wrap:wrap;margin-top:var(--s-4);position:relative}
|
||||
.upd-actions:empty{display:none}
|
||||
.upd-actions .btn.primary{min-width:160px}
|
||||
@media (prefers-reduced-motion:reduce){.upd-wrap,.upd-card{animation:none}.upd-ring .arc{transition:none}.upd-mark.busy .arc{animation:none;stroke-dasharray:207.5 69}}
|
||||
@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}}
|
||||
|
||||
/* sheet (the key) */
|
||||
.sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease}
|
||||
.sheet .sub{font-size:15px;color:var(--ink-2)}
|
||||
.field{display:flex;flex-direction:column;gap:8px;margin-top:6px}
|
||||
.field .k{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:13px;word-break:break-all;user-select:text;-webkit-user-select:text}
|
||||
.sheet-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease}
|
||||
.sheet .sub{font-size:var(--t-lg);color:var(--ink-2)}
|
||||
.field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px}
|
||||
.field .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.box{display:flex;align-items:center;gap:10px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:10px 12px;font-size:var(--t-base);word-break:break-all;user-select:text;-webkit-user-select:text}
|
||||
.box span{flex:1;min-width:0}
|
||||
.box.key{color:var(--molten)}
|
||||
.check{display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;margin-top:4px}
|
||||
.check.small{font-size:12px;color:var(--ash)}
|
||||
.check input{width:18px;height:18px;accent-color:var(--ember)}
|
||||
.check{display:flex;align-items:center;gap:10px;font-size:var(--t-md);cursor:pointer;margin-top:var(--s-1)}
|
||||
.check.small{font-size:var(--t-sm);color:var(--ash);margin-top:0}
|
||||
.check input{width:18px;height:18px;accent-color:var(--ember);margin:0}
|
||||
.check.small input{width:15px;height:15px}
|
||||
.sheet .cta{justify-content:flex-start}
|
||||
@media (prefers-reduced-motion:reduce){.sheet{animation:none}}
|
||||
|
||||
/* settings panel */
|
||||
.panel-wrap{justify-content:flex-end;padding:0}
|
||||
.panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease}
|
||||
@keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}}
|
||||
.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)}
|
||||
.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px}
|
||||
.row{display:flex;gap:10px;align-items:center}
|
||||
/* rows, switches */
|
||||
.row{display:flex;gap:10px;align-items:center;min-width:0}
|
||||
.row.between{justify-content:space-between}
|
||||
.row .addr-input{margin-top:0}
|
||||
.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:14px;user-select:text;-webkit-user-select:text}
|
||||
.num:focus{outline:none;border-color:var(--ember)}
|
||||
.switch{display:flex;align-items:center;gap:12px;font-size:14px;cursor:pointer;padding:6px 0}
|
||||
.row.wrap{flex-wrap:wrap}
|
||||
.row .addr-input{margin-top:0;min-width:0}
|
||||
.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:8px 0 2px;line-height:1.35}
|
||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||||
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease}
|
||||
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease}
|
||||
.switch input:checked+.track{background:var(--ember)}
|
||||
.switch input:checked+.track::after{transform:translateX(18px)}
|
||||
|
||||
/* bottom bar */
|
||||
.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21}
|
||||
.bottom .left,.bottom .right{display:flex;align-items:center;gap:8px}
|
||||
.bottom .mid{font-size:12px;color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center;flex:1}
|
||||
.bottom .right .mono{font-size:12px}
|
||||
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
|
||||
.switch input:disabled+.track{opacity:.4}
|
||||
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
|
||||
.switch.lg .track::after{width:24px;height:24px}
|
||||
.switch.lg input:checked+.track::after{transform:translateX(22px)}
|
||||
.switch+.help{margin-bottom:6px}
|
||||
|
||||
/* log drawer */
|
||||
.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column}
|
||||
body.drawer-open .drawer{height:260px}
|
||||
.drawer-head{display:flex;justify-content:space-between;align-items:center;padding:8px var(--gutter);border-bottom:1px solid var(--line);flex:0 0 auto}
|
||||
.chips{display:flex;gap:6px}
|
||||
.chip{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer}
|
||||
.chip.on{color:var(--molten);border-color:rgba(255,179,92,.4)}
|
||||
.log{margin:0;flex:1;overflow:auto;padding:10px var(--gutter);font-size:12px;line-height:1.55;color:var(--ink-2);white-space:pre-wrap;word-break:break-all;user-select:text;-webkit-user-select:text}
|
||||
.drawer{position:fixed;left:0;right:0;bottom:0;height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:21;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px rgba(0,0,0,.35)}
|
||||
body.has-rail .drawer{left:var(--rail)}
|
||||
body.drawer-open .drawer{height:var(--drawer-h)}
|
||||
body.drawer-drag .drawer{transition:none}
|
||||
body.drawer-drag{cursor:row-resize}
|
||||
body.drawer-drag main{pointer-events:none}
|
||||
@media (prefers-reduced-motion:reduce){.drawer{transition:none}}
|
||||
.drawer-grip{height:10px;flex:0 0 10px;cursor:row-resize;display:flex;align-items:center;justify-content:center;touch-action:none}
|
||||
.drawer-grip i{width:44px;height:3px;border-radius:2px;background:var(--line-2);transition:background .15s ease}
|
||||
.drawer-grip:hover i,body.drawer-drag .drawer-grip i{background:var(--ash)}
|
||||
.drawer-head{display:flex;align-items:center;gap:var(--s-3);padding:2px var(--gutter) 8px;border-bottom:1px solid var(--line);flex:0 0 auto;flex-wrap:wrap;row-gap:6px}
|
||||
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.chip{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:4px 10px;background:transparent;color:var(--ash);cursor:pointer;white-space:nowrap;line-height:1.3;transition:color .15s ease,border-color .15s ease}
|
||||
.chip:hover{color:var(--ink-2);border-color:var(--line-2)}
|
||||
.chip.on{color:var(--molten);border-color:var(--molten-40)}
|
||||
.chip:disabled{opacity:.4;cursor:default}
|
||||
.log-search{display:flex;align-items:center;gap:6px;background:var(--graphite);border:1px solid var(--line);border-radius:999px;padding:3px 10px 3px 10px;color:var(--ash);flex:1 1 160px;min-width:140px;max-width:300px}
|
||||
.log-search:focus-within{border-color:var(--molten-40);color:var(--ink-2)}
|
||||
.log-search input{flex:1;min-width:0;background:transparent;border:0;outline:0;color:var(--bone);font-family:var(--mono);font-size:var(--t-sm);padding:2px 0;user-select:text;-webkit-user-select:text}
|
||||
.log-search input::-webkit-search-cancel-button{-webkit-appearance:none}
|
||||
.log-search input::placeholder{color:var(--ash)}
|
||||
.log-search .count{font-size:var(--t-xs);color:var(--ash);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.log-nav{display:flex;align-items:center;gap:6px}
|
||||
.log-nav input{width:84px;background:var(--graphite);border:1px solid var(--line);border-radius:999px;padding:4px 10px;color:var(--bone);font-size:var(--t-xs);letter-spacing:.06em;outline:0;user-select:text;-webkit-user-select:text;text-align:center}
|
||||
.log-nav input:focus{border-color:var(--molten-40)}
|
||||
.log-nav input::placeholder{color:var(--ash);letter-spacing:.04em}
|
||||
.log-tools{display:flex;align-items:center;gap:var(--s-2);margin-left:auto}
|
||||
.log-tools .at{font-size:var(--t-xs);min-width:64px;text-align:right}
|
||||
.log-ruler{position:relative;height:16px;flex:0 0 16px;background:var(--graphite);border-bottom:1px solid var(--line);cursor:pointer;overflow:hidden}
|
||||
.log-ruler.empty{cursor:default}
|
||||
.log-ruler.empty>*{display:none}
|
||||
.log-ruler .t0,.log-ruler .t1{position:absolute;top:0;z-index:2;font-size:9px;line-height:16px;color:var(--ash);padding:0 6px;margin:0 var(--gutter);pointer-events:none;letter-spacing:.04em;background:var(--graphite);border-radius:0 0 6px 0}
|
||||
.log-ruler .t1{right:0;border-radius:0 0 0 6px}
|
||||
.log-ruler .mk{position:absolute;top:4px;bottom:4px;width:2px;margin-left:-1px;background:var(--ember);opacity:.9;pointer-events:none}
|
||||
.log-ruler .mk.swap{background:var(--molten)}
|
||||
.log-ruler .win{position:absolute;top:0;bottom:0;background:rgba(244,241,236,.14);border-left:1px solid rgba(244,241,236,.35);border-right:1px solid rgba(244,241,236,.35);min-width:3px;pointer-events:none}
|
||||
.log-ruler:hover .win{background:rgba(244,241,236,.2)}
|
||||
.log-view{position:relative;flex:1;overflow:auto;overscroll-behavior:contain;user-select:text;-webkit-user-select:text;contain:strict}
|
||||
.log-pad{position:relative;min-height:100%}
|
||||
.log{margin:0;position:absolute;top:0;left:0;right:0;padding:6px var(--gutter);font-size:var(--t-sm);line-height:19px;color:var(--ink-2);white-space:pre;will-change:transform}
|
||||
.log .ln{height:19px;overflow:hidden;text-overflow:ellipsis;white-space:pre}
|
||||
.log-view.wrap .log{position:relative;padding-bottom:12px}
|
||||
.log-view.wrap .log .ln{height:auto;white-space:pre-wrap;word-break:break-all;overflow:visible}
|
||||
.log .src{color:var(--ash)}
|
||||
.log .src.node{color:#7FA7C9}
|
||||
.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4{color:var(--molten)}
|
||||
.log .src.node{color:var(--node-blue)}
|
||||
.log .src.miner1,.log .src.miner2,.log .src.miner3,.log .src.miner4,.log .src.miner5,.log .src.miner6,.log .src.miner7,.log .src.miner8{color:var(--molten)}
|
||||
.log .src.app{color:var(--ember)}
|
||||
.log .e{color:var(--ember)}
|
||||
.log .src.watch{color:var(--ash)}
|
||||
.log .e,.log .e .src{color:var(--ember)}
|
||||
.log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
|
||||
.log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px}
|
||||
.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center}
|
||||
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:2;gap:6px;padding:6px 12px}
|
||||
.log-jump:hover{background:#1c1a18;border-color:var(--molten)}
|
||||
|
||||
.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:13px;z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5)}
|
||||
.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center}
|
||||
body.has-rail .toast{left:calc(50% + var(--rail) / 2)}
|
||||
body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
||||
|
||||
@media (max-width:860px){
|
||||
/* narrow windows (the 900 px floor): the rail folds to icons with small labels, the hero row and the strips fold to
|
||||
two columns, the two-column grid to one, the GPU rows drop a number */
|
||||
@media (max-width:1180px){
|
||||
.hero-row{grid-template-columns:1fr 1fr}
|
||||
.toggle-big{grid-row:span 1}
|
||||
.strip{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.grid{grid-template-columns:1fr}
|
||||
.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.grid2{grid-template-columns:1fr}
|
||||
.top-status{max-width:220px}
|
||||
}
|
||||
@media (max-width:1000px){
|
||||
:root{--rail:76px;--gutter:var(--s-5)}
|
||||
.rail{padding:12px 8px}
|
||||
.rail-brand{justify-content:center;padding:6px 0 14px}
|
||||
.rail-brand .word{display:none}
|
||||
.nav{flex-direction:column;gap:4px;padding:8px 4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;font-family:var(--mono);font-weight:500;text-align:center;min-height:52px;justify-content:center}
|
||||
.nav.on{font-weight:500}
|
||||
.nav.on::before{left:-9px}
|
||||
.nav.small{min-height:44px}
|
||||
.nav-dot{right:8px;top:8px;margin:0}
|
||||
.rail-status{display:none}
|
||||
.rail-version{text-align:center;padding:8px 0 0;font-size:10px;white-space:nowrap}
|
||||
.rail-version .chain{display:none}
|
||||
.gpu-line{grid-template-columns:44px minmax(140px,1.4fr) repeat(2,minmax(80px,.7fr)) 48px}
|
||||
.gpu-line .num.power{display:none}
|
||||
.page-sub{display:none}
|
||||
.top-status{display:none}
|
||||
.strip.three{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.set-card .ctl{grid-template-columns:120px 1fr auto}
|
||||
}
|
||||
@media (max-width:860px){
|
||||
.three{grid-template-columns:1fr}
|
||||
h1{font-size:40px}
|
||||
}
|
||||
/* short windows (the 600 px floor): tighter paddings, a lower hero, a smaller canvas, so the drawer always has room */
|
||||
@media (max-height:700px){
|
||||
:root{--card-pad:18px;--tile-pad:14px 16px;--gap:var(--s-4)}
|
||||
#screen-dashboard{padding:16px 0 20px}
|
||||
#dag{height:140px}
|
||||
.hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)}
|
||||
.coin-wrap{width:104px;height:104px}
|
||||
.coin{width:104px;height:104px}
|
||||
h1{font-size:40px}
|
||||
.lead{font-size:var(--t-xl)}
|
||||
.step{padding:32px 0 32px}
|
||||
.feed{max-height:200px}
|
||||
.drawer-head{padding-bottom:6px}
|
||||
.toggle-big{min-height:96px}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,37 +8,55 @@
|
|||
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body class="phase-welcome" data-phase="welcome">
|
||||
<body class="phase-welcome" data-phase="welcome" data-page="mine">
|
||||
|
||||
<!-- the rail: one button per section (miner-ui-2). Shown on the dashboard; the setup screens have no rail. -->
|
||||
<aside class="rail" id="rail" hidden>
|
||||
<div class="rail-brand">
|
||||
<img src="mark.svg" width="28" height="28" alt="">
|
||||
<span class="word">IGNEUM</span>
|
||||
</div>
|
||||
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
|
||||
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
|
||||
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
|
||||
<button class="nav" data-page="rewards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Rewards</span></button>
|
||||
<button class="nav" data-page="node"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/></svg><span>Node</span></button>
|
||||
<button class="nav" data-page="updates"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5"/><path d="M4 19h16"/></svg><span>Updates</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
|
||||
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span></button>
|
||||
</nav>
|
||||
<div class="rail-foot">
|
||||
<div class="rail-status mono" id="rail-status"></div>
|
||||
<button class="nav small" id="btn-logs" aria-expanded="false" aria-controls="drawer"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span id="btn-logs-text">Logs</span></button>
|
||||
<button class="nav small danger" id="btn-quit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg><span>Quit</span></button>
|
||||
<div class="rail-version mono" id="foot-version"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<div class="brand" id="top-brand">
|
||||
<img src="mark.svg" width="30" height="30" alt="">
|
||||
<span class="word">IGNEUM</span><span class="miner">MINER</span>
|
||||
</div>
|
||||
<div class="page-title" id="page-title" hidden>
|
||||
<h1 id="page-title-text">Mine</h1>
|
||||
<span class="page-sub" id="page-sub"></span>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<span class="top-status mono" id="top-status"></span>
|
||||
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
|
||||
<button class="icon-btn" id="btn-settings" title="Settings" aria-label="Settings" hidden>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="banner 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 status strip: one notice at a time (updates, remote jobs, the clock), the most important first; app.js fills
|
||||
it from the state (Notices) and the content below moves once when it appears or goes. -->
|
||||
<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">×</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">
|
||||
|
|
@ -90,8 +108,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="note" id="cards-note" hidden></p>
|
||||
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable (an RTX 5090 at full power hard-crashed in the field). This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. The slider sets the cap per card.</p>
|
||||
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. Identities take turns on the card and each one votes and pays separately; 8 suits a big card, 2 a small one, 1 an integrated GPU.</p>
|
||||
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable. This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. Settings has a slider per card.</p>
|
||||
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
|
||||
<div class="cta">
|
||||
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
|
||||
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
|
||||
|
|
@ -104,7 +122,7 @@
|
|||
<div class="step">
|
||||
<div class="eyebrow">step 2 of 2</div>
|
||||
<h2>Where should rewards go?</h2>
|
||||
<p class="sub">Every block this machine finds pays one EVM address. Pick one way.</p>
|
||||
<p class="sub">Every block this machine finds pays one EVM address. Pick one way. One block in 100 pays the miner software's dev fee; Settings turns it off.</p>
|
||||
<div class="options">
|
||||
<label class="option on" id="opt-generate">
|
||||
<input type="radio" name="mode" value="generate" checked>
|
||||
|
|
@ -133,64 +151,169 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. dashboard -->
|
||||
<!-- 4. the dashboard: six pages behind the rail -->
|
||||
<section class="screen" id="screen-dashboard">
|
||||
<div class="strip">
|
||||
<div class="cell big ember">
|
||||
<div class="k">hash rate</div>
|
||||
<div class="v"><span id="d-hash">0.0</span><span class="unit">MH/s</span></div>
|
||||
<div class="s" id="d-hash-sub">waiting for the worker</div>
|
||||
</div>
|
||||
<div class="cell big">
|
||||
<div class="k">blocks found</div>
|
||||
<div class="v" id="d-blocks">0</div>
|
||||
<div class="s" id="d-blocks-sub">accepted by the node</div>
|
||||
</div>
|
||||
<div class="cell big" id="d-node-cell">
|
||||
<div class="k">node</div>
|
||||
<div class="v state" id="d-node">starting</div>
|
||||
<div class="s" id="d-node-sub">opening the database</div>
|
||||
</div>
|
||||
<div class="cell big">
|
||||
<div class="k">next program</div>
|
||||
<div class="v" id="d-eta">--:--</div>
|
||||
<div class="s" id="d-eta-sub">waiting for the node</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-main">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="eyebrow"><span class="dot small"></span>your blocks</div>
|
||||
<div class="stats mono"><span>last 10 min <b id="d-found-10">0</b></span><span>last hour <b id="d-found-60">0</b></span><span>chain <b id="d-chain-blocks">0</b></span></div>
|
||||
</div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="legend"><span><i class="sw ember"></i>block this machine found</span><span><i class="sw glow"></i>just accepted</span><span><i class="sw line"></i>one minute</span></div>
|
||||
<!-- Mine -->
|
||||
<section class="page" id="page-mine" data-page="mine">
|
||||
<div class="hero-row">
|
||||
<button class="toggle-big" id="btn-toggle" disabled>
|
||||
<span class="ring"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg></span>
|
||||
<span class="tl" id="btn-toggle-text">Start mining</span>
|
||||
<span class="ts" id="btn-toggle-sub">waiting for the engine</span>
|
||||
</button>
|
||||
<div class="cell big ember">
|
||||
<div class="k">hash rate</div>
|
||||
<div class="v"><span id="d-hash">0.0</span><span class="unit">MH/s</span></div>
|
||||
<div class="s" id="d-hash-sub">waiting for the worker</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Cards</h3><div class="eyebrow" id="d-cards-eyebrow">1 worker</div></div>
|
||||
<div class="gpu-tiles" id="d-cards"><div class="empty">No card yet.</div></div>
|
||||
<div class="gpu-off mono" id="d-cards-off" hidden></div>
|
||||
<div class="cell big">
|
||||
<div class="k">blocks found</div>
|
||||
<div class="v" id="d-blocks">0</div>
|
||||
<div class="s" id="d-blocks-sub">accepted by the node</div>
|
||||
</div>
|
||||
<div class="cell big">
|
||||
<div class="k">next program</div>
|
||||
<div class="v" id="d-eta">--:--</div>
|
||||
<div class="s" id="d-eta-sub">waiting for the node</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-side">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your GPUs</h3><div class="eyebrow" id="d-cards-eyebrow">detecting</div></div>
|
||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||
<p class="note" id="d-cards-note" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h3>Your blocks</h3>
|
||||
<div class="stats mono"><span>last 10 min <b id="d-found-10">0</b></span><span>last hour <b id="d-found-60">0</b></span><span>dev fee <b id="d-fee">0</b></span><span>chain <b id="d-chain-blocks">0</b></span></div>
|
||||
</div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="legend"><span><i class="sw ember"></i>block this machine found</span><span><i class="sw glow"></i>just accepted</span><span><i class="sw line"></i>one minute</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Activity</h3><div class="eyebrow">newest first</div></div>
|
||||
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Prove -->
|
||||
<section class="page" id="page-prove" data-page="prove" hidden>
|
||||
<div class="card lead-card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3>Prove shards on this machine</h3>
|
||||
<p class="help">Every block on Igneum is turned into a short mathematical proof, in pieces called shards. The chain assigns shards to your keys; this machine proves them and is paid for each one. On by default on an NVIDIA card with 24 GB or more (a full shard needs 20.4 GB of GPU memory, measured); off on a Mac, whose CPU prover is slow.</p>
|
||||
</div>
|
||||
<label class="switch lg" title="Prove assigned shards"><input type="checkbox" id="s-prove" aria-label="Prove shards on this machine"><span class="track"></span></label>
|
||||
</div>
|
||||
<p class="note" id="pv-note">Off. Switch it on and this machine proves the shards the chain assigns to its keys.</p>
|
||||
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">About 20 minutes, once.</span></div>
|
||||
</div>
|
||||
<div class="strip four">
|
||||
<div class="cell"><div class="k">state</div><div class="v state" id="pv-state">off</div><div class="s" id="pv-state-sub">not proving</div></div>
|
||||
<div class="cell"><div class="k">assigned</div><div class="v" id="pv-assigned">0</div><div class="s">shards given to your keys</div></div>
|
||||
<div class="cell"><div class="k">proven</div><div class="v" id="pv-submitted">0</div><div class="s">proofs sent to the node</div></div>
|
||||
<div class="cell"><div class="k">paid</div><div class="v" id="pv-paid">0</div><div class="s" id="pv-paid-sub">shards paid out</div></div>
|
||||
</div>
|
||||
<p class="note" id="pv-seg-note" hidden></p>
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Node</h3><div class="eyebrow" id="d-node-net">devnet v4</div></div>
|
||||
<div class="card-head"><h3>Verifier</h3><div class="eyebrow">the node's check</div></div>
|
||||
<div class="big-word" id="pv-verifier">not read yet</div>
|
||||
<p class="help" id="pv-verifier-help">Before a proof counts, the node checks it. A node without a verifier passes proofs along and never includes them.</p>
|
||||
<p class="note" id="pv-verifier-note" hidden></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Program</h3><div class="eyebrow">pinned guest</div></div>
|
||||
<div class="field">
|
||||
<div class="k">shard program id</div>
|
||||
<div class="box mono"><span id="pv-program">not read yet</span><button class="btn tiny" data-copy="pv-program">Copy</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">aggregator id</div>
|
||||
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
|
||||
</div>
|
||||
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rewards -->
|
||||
<section class="page" id="page-rewards" data-page="rewards" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Rewards address</h3><div class="eyebrow" id="r-source"></div></div>
|
||||
<div class="addr-big mono"><span id="r-address">not set</span><button class="btn small" data-copy="r-address">Copy</button></div>
|
||||
<p class="help" id="r-address-help">Every block this machine finds pays this address.</p>
|
||||
</div>
|
||||
<div class="strip three">
|
||||
<div class="cell"><div class="k">blocks found</div><div class="v" id="r-blocks">0</div><div class="s">lifetime, accepted by the node</div></div>
|
||||
<div class="cell"><div class="k">this run</div><div class="v" id="r-session">0</div><div class="s" id="r-session-sub">since the app started</div></div>
|
||||
<div class="cell"><div class="k">balance</div><div class="v dim" id="r-balance">--</div><div class="s">shown in the wallet, not here yet</div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="card" id="r-key-card">
|
||||
<div class="card-head"><h3>Save your key</h3><div class="eyebrow ember">once</div></div>
|
||||
<p class="help" id="r-key-help">The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe: anyone with the key can spend what the address holds.</p>
|
||||
<div class="row" id="r-key-row"><button class="btn small" id="s-reveal">Show my key</button><button class="btn small ghost" id="s-hide" hidden>Hide</button></div>
|
||||
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button></div>
|
||||
<p class="note mono small" id="r-key-file"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Use another address</h3></div>
|
||||
<p class="help">Paste an EVM address you control. The miner restarts and pays the new address from the next block.</p>
|
||||
<div class="row">
|
||||
<input type="text" class="addr-input mono" id="s-address-input" placeholder="0x" spellcheck="false" autocomplete="off" aria-label="New rewards address">
|
||||
<button class="btn small" id="s-address-save">Change</button>
|
||||
</div>
|
||||
<div class="err" id="s-address-err" hidden></div>
|
||||
<p class="note" id="r-devfee-line"></p>
|
||||
<div class="row"><button class="btn small ghost" id="r-wallet">Open the wallet page</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Node -->
|
||||
<section class="page" id="page-node" data-page="node" hidden>
|
||||
<div class="strip four">
|
||||
<div class="cell" id="n-state-cell"><div class="k">node</div><div class="v state" id="d-node">starting</div><div class="s" id="d-node-sub">opening the database</div></div>
|
||||
<div class="cell"><div class="k">height</div><div class="v" id="n-blocks">0</div><div class="s" id="n-blocks-sub">blocks this node holds</div></div>
|
||||
<div class="cell"><div class="k">peers</div><div class="v" id="n-peers">0</div><div class="s" id="n-peers-sub">other nodes it talks to</div></div>
|
||||
<div class="cell"><div class="k">version</div><div class="v small" id="n-version">--</div><div class="s" id="d-node-net">devnet v4</div></div>
|
||||
</div>
|
||||
<div class="clock-card" id="n-clock" hidden>
|
||||
<p class="clock-msg" id="n-clock-msg"></p>
|
||||
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
|
||||
<p class="note" id="n-clock-hint"></p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Chain</h3><div class="eyebrow" id="n-reading">reading</div></div>
|
||||
<div class="kv">
|
||||
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span></div>
|
||||
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span></div>
|
||||
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span></div>
|
||||
<div><span class="k">daa score</span><span class="v mono" id="n-daa">0</span></div>
|
||||
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span></div>
|
||||
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span></div>
|
||||
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span></div>
|
||||
</div>
|
||||
<div class="clock-card" id="n-clock" hidden>
|
||||
<p class="clock-msg" id="n-clock-msg"></p>
|
||||
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
|
||||
<p class="note" id="n-clock-hint"></p>
|
||||
<p class="help">Headers arrive before blocks. The DAA score counts blocks the whole network made; the difficulty is how hard the next one is to find.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Consensus</h3><div class="eyebrow">the rules</div></div>
|
||||
<div class="field">
|
||||
<div class="k">digest</div>
|
||||
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
|
||||
<p class="help">The fingerprint of the rules this node runs. Every node on the network shows the same one; a peer with another is refused.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">next switch</div>
|
||||
<div class="big-word" id="n-switch">none planned</div>
|
||||
<p class="help" id="n-switch-help">A switch is a planned rule change. The node applies it by itself when the chain reaches that height.</p>
|
||||
<div class="switch-list mono" id="n-switches"></div>
|
||||
</div>
|
||||
<p class="note" id="n-note"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Finality</h3><div class="eyebrow">miner-only</div></div>
|
||||
|
|
@ -199,28 +322,118 @@
|
|||
<div><span class="k">age</span><span class="v mono" id="f-age">n/a</span></div>
|
||||
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span></div>
|
||||
</div>
|
||||
<p class="note" id="f-note">Locks appear once the miner votes on checkpoints.</p>
|
||||
</div>
|
||||
<div class="tile" id="tile-proving">
|
||||
<div class="h">Proving</div>
|
||||
<div class="kv">
|
||||
<div><span class="k">state</span><span class="v mono" id="pv-state">off</span></div>
|
||||
<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>
|
||||
<p class="note" id="pv-note">Off. Settings switches it on: this machine proves the shards the chain assigns to its keys.</p>
|
||||
<div class="row" id="pv-setup-row" hidden><button class="btn small" id="pv-setup">Set up</button></div>
|
||||
<p class="help" id="f-note">A lock is a point the miners have agreed can never be undone. This machine votes on one every 30 s.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Events</h3><div class="eyebrow">newest first</div></div>
|
||||
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
||||
<div class="card-head"><h3>Sync</h3><div class="eyebrow" id="n-sync-eyebrow"></div></div>
|
||||
<div class="big-word" id="n-sync-word">starting</div>
|
||||
<p class="help" id="n-note"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Updates -->
|
||||
<section class="page" id="page-updates" data-page="updates" hidden>
|
||||
<div class="card lead-card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3 id="s-version">Igneum Miner</h3>
|
||||
<p class="help" id="s-update-note">Not checked yet.</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn small primary" id="s-install" hidden>Install now</button>
|
||||
<button class="btn small" id="s-update">Check now</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span>Install updates by itself</span></label>
|
||||
<p class="help">Downloads in the background and installs at a quiet moment, never mid-program. Off: it downloads, then waits for Install now.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3>Remote jobs</h3>
|
||||
<p class="help">Igneum publishes signed jobs (a benchmark, a script, logs to collect) next to the update manifest. This machine runs each one once and reports back. Only jobs signed by Igneum's key run.</p>
|
||||
</div>
|
||||
<button class="btn small" id="s-jobs-check">Check now</button>
|
||||
</div>
|
||||
<p class="note" id="s-jobs-note"></p>
|
||||
<div class="job-history" id="s-jobs-history"></div>
|
||||
<p class="note mono small" id="s-jobs-key"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings -->
|
||||
<section class="page" id="page-settings" data-page="settings" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Graphics cards</h3><div class="eyebrow" id="s-cards-eyebrow"></div></div>
|
||||
<div class="set-cards" id="s-cards"><div class="empty">No card yet.</div></div>
|
||||
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span>Ember Tune: tune every card for hashes per watt</span></label>
|
||||
<p class="help">Once after install, then weekly and after a driver or program change: the power limit steps from 100% down to 50%, then the core clock from its maximum down to 60%, 75 s a step on the live program; the memory clock is never touched. The card keeps the point with the most hashes per watt within 1% of its top rate. A step with a rejected hash, a hot GPU or a dragged memory clock is reverted. A card whose model the fleet already knows starts at that point and confirms it in two steps. Every result goes back to the fleet without anything that identifies you. A cap you set by hand is left alone.</p>
|
||||
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span>Power control: let the app set NVIDIA limits</span></label>
|
||||
<p class="help">Windows asks for administrator rights once; the NVIDIA cap and the tune need them. Off, the app never asks and NVIDIA cards measure only. AMD cards need no rights. <span id="s-power-note"></span></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>This machine</h3></div>
|
||||
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
|
||||
<p class="help">The miner opens when you sign in and keeps mining in the background.</p>
|
||||
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span>Allow remote jobs from Igneum</span></label>
|
||||
<p class="help">Signed jobs from Igneum run on this machine and report back. Updates shows what ran.</p>
|
||||
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span>Vote on finality checkpoints</span></label>
|
||||
<p class="help">Your miner signs a checkpoint every 30 s. Votes are what lock the chain; leave it on.</p>
|
||||
<div class="field">
|
||||
<div class="k">name</div>
|
||||
<div class="row">
|
||||
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
|
||||
<button class="btn small" id="s-name-save">Rename</button>
|
||||
</div>
|
||||
<p class="help">A label for you only. Keys come from the machine id <span class="mono" id="s-mid"></span>, never from the name.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Dev fee</h3></div>
|
||||
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span id="s-devfee-text">Dev fee 1% (1 block in 100)</span></label>
|
||||
<p class="help" id="s-devfee-note">One block in 100 is mined for the miner software's author, the same way every GPU miner takes a fee. The protocol itself takes nothing. This switch turns it off.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Logs</h3></div>
|
||||
<div class="row wrap">
|
||||
<button class="btn small" id="s-log-copy">Copy the log</button>
|
||||
<button class="btn small ghost" id="s-log-open">Show the log</button>
|
||||
</div>
|
||||
<p class="help">Copies the last lines the node and the miner wrote, for a support message. Show the log opens the drawer with every line.</p>
|
||||
<p class="note mono small" id="s-log-dir"></p>
|
||||
<p class="note mono small" id="s-node-dir"></p>
|
||||
</div>
|
||||
<details class="card adv" id="s-advanced">
|
||||
<summary><h3>Advanced</h3><span class="eyebrow">devnet tools</span></summary>
|
||||
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span>Trust proof records without verifying them</span></label>
|
||||
<p class="help" id="s-trust-note">Devnet only. When no verifier is found next to the engine, the node includes proof records it never checked. A found verifier always wins. Changing this restarts the node.</p>
|
||||
<div class="row"><button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button></div>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- the update card (app.js, UpdateCard): one update, centred; Later, Escape or the backdrop leaves the strip above -->
|
||||
<div class="upd-wrap" id="upd" hidden>
|
||||
<div class="upd-card" id="upd-card" role="dialog" aria-modal="true" aria-labelledby="upd-name" aria-describedby="upd-line" tabindex="-1">
|
||||
<div class="upd-mark" id="upd-mark">
|
||||
<svg class="upd-ring" viewBox="0 0 96 96" aria-hidden="true"><circle class="track" cx="48" cy="48" r="44"></circle><circle class="arc" id="upd-arc" cx="48" cy="48" r="44"></circle></svg>
|
||||
<img src="mark.svg" width="40" height="40" alt="">
|
||||
<span class="upd-pct mono" id="upd-pct" hidden></span>
|
||||
</div>
|
||||
<div class="eyebrow ember">update</div>
|
||||
<h2 class="upd-name" id="upd-name"></h2>
|
||||
<p class="upd-line" id="upd-line"></p>
|
||||
<p class="upd-cause mono" id="upd-cause" hidden></p>
|
||||
<ul class="upd-notes" id="upd-notes"></ul>
|
||||
<button class="upd-more" id="upd-more" type="button" aria-expanded="false" hidden>What changed</button>
|
||||
<ul class="upd-all" id="upd-all" hidden></ul>
|
||||
<p class="upd-meta mono" id="upd-meta"></p>
|
||||
<div class="upd-actions" id="upd-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- the key sheet, shown once -->
|
||||
<div class="sheet-wrap" id="sheet-key" hidden>
|
||||
<div class="sheet">
|
||||
|
|
@ -235,7 +448,7 @@
|
|||
<div class="k">private key</div>
|
||||
<div class="box mono key"><span id="key-private"></span><button class="btn tiny" data-copy="key-private">Copy</button></div>
|
||||
</div>
|
||||
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Settings can show it again.</p>
|
||||
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Rewards can show it again.</p>
|
||||
<p class="note">On devnet the vote keys are test keys derived from the miner's label. Mainnet vote keys will be random and stored like this wallet.</p>
|
||||
<label class="check"><input type="checkbox" id="key-ack"><span>I have saved my key</span></label>
|
||||
<div class="cta">
|
||||
|
|
@ -244,68 +457,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- settings -->
|
||||
<div class="panel-wrap" id="settings" hidden>
|
||||
<aside class="panel">
|
||||
<div class="panel-head"><h3>Settings</h3><button class="icon-btn" id="btn-settings-close" aria-label="Close">×</button></div>
|
||||
<div class="panel-body">
|
||||
<div class="field">
|
||||
<div class="k">rewards address</div>
|
||||
<div class="box mono"><span id="s-address"></span><button class="btn tiny" data-copy="s-address">Copy</button></div>
|
||||
<div class="row">
|
||||
<input type="text" class="addr-input mono" id="s-address-input" placeholder="paste a new address" spellcheck="false" autocomplete="off">
|
||||
<button class="btn small" id="s-address-save">Change</button>
|
||||
</div>
|
||||
<div class="err" id="s-address-err" hidden></div>
|
||||
<button class="btn small ghost" id="s-reveal" hidden>Show my key</button>
|
||||
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">this machine</div>
|
||||
<div class="row">
|
||||
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false">
|
||||
<button class="btn small" id="s-name-save">Rename</button>
|
||||
</div>
|
||||
<p class="note">A label for you only. Keys and labels come from the machine id <span class="mono" id="s-mid"></span>, never from the computer name.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">cards</div>
|
||||
<div class="cards compact" id="s-cards"></div>
|
||||
<div class="row between"><p class="note">Switch a card on or off, set its identities. Only that card's worker restarts; the node keeps running.</p><button class="btn small" id="s-cards-save">Apply</button></div>
|
||||
</div>
|
||||
<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-login"><span class="track"></span><span>Start at login</span></label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">version</div>
|
||||
<div class="row between"><span class="mono" id="s-version"></span><span class="row"><button class="btn small primary" id="s-install" hidden>Install now</button><button class="btn small" id="s-update">Check now</button></span></div>
|
||||
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span>Install updates by itself at a safe moment</span></label>
|
||||
<p class="note" id="s-update-note"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">remote jobs</div>
|
||||
<div class="row between"><label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span>Allow remote jobs from Igneum (signed)</span></label><button class="btn small" id="s-jobs-check">Check now</button></div>
|
||||
<p class="note">Igneum publishes signed jobs (a benchmark, a script, a file to fetch, logs to collect, a restart) next to the update manifest. This machine runs each one once and reports to the Igneum log intake. Only jobs signed by the key below run; nothing else can send one.</p>
|
||||
<p class="note mono small" id="s-jobs-key"></p>
|
||||
<p class="note" id="s-jobs-note"></p>
|
||||
<div class="job-history" id="s-jobs-history"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">folders</div>
|
||||
<p class="note mono small" id="s-node-dir"></p>
|
||||
<p class="note mono small" id="s-log-dir"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- the log drawer -->
|
||||
<div class="drawer" id="drawer">
|
||||
<!-- the log drawer: chips filter by source, search filters by text, the ruler and the jump controls move in time.
|
||||
The list is virtualised (fixed row height, only the visible rows are in the DOM) so 20,000 lines scroll smoothly. -->
|
||||
<div class="drawer" id="drawer" aria-label="Logs">
|
||||
<div class="drawer-grip" id="drawer-grip" title="Drag to resize"><i></i></div>
|
||||
<div class="drawer-head">
|
||||
<div class="chips" id="log-chips">
|
||||
<button class="chip on" data-src="">all</button>
|
||||
|
|
@ -314,23 +469,32 @@
|
|||
<button class="chip" data-src="miner">miner</button>
|
||||
<button class="chip" data-src="watch">watch</button>
|
||||
</div>
|
||||
<label class="check small"><input type="checkbox" id="log-follow" checked><span>follow</span></label>
|
||||
<div class="log-search">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.5-3.5"></path></svg>
|
||||
<input type="search" id="log-search" placeholder="search" spellcheck="false" autocomplete="off" aria-label="Search the log">
|
||||
<span class="count mono" id="log-count"></span>
|
||||
</div>
|
||||
<div class="log-nav">
|
||||
<input type="text" class="mono" id="log-time" placeholder="HH:MM:SS" maxlength="8" spellcheck="false" autocomplete="off" aria-label="Jump to a time" title="Jump to a time (Enter)">
|
||||
<button class="chip" id="log-last-error" title="Jump to the last error">last error</button>
|
||||
<button class="chip" id="log-last-swap" title="Jump to the last program swap">last swap</button>
|
||||
</div>
|
||||
<div class="log-tools">
|
||||
<span class="mono dim at" id="log-at" title="Time of the top line in view"></span>
|
||||
<button class="chip" id="log-wrap" title="Wrap long lines (up to 5,000 lines)">wrap</button>
|
||||
<button class="chip" id="log-copy" title="Copy the lines in view">copy</button>
|
||||
<label class="check small"><input type="checkbox" id="log-follow" checked><span>follow</span></label>
|
||||
<button class="chip" id="log-close" title="Close the log">close</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="log" id="log"></pre>
|
||||
<div class="log-ruler" id="log-ruler" title="Click to jump"><span class="t0 mono" id="log-ruler-0"></span><span class="t1 mono" id="log-ruler-1"></span><i class="win" id="log-ruler-win"></i></div>
|
||||
<div class="log-view" id="log-view">
|
||||
<div class="log-pad" id="log-pad"><pre class="log" id="log"></pre></div>
|
||||
<div class="log-empty mono" id="log-empty" hidden>Nothing logged yet.</div>
|
||||
</div>
|
||||
<button class="btn small log-jump" id="log-jump" hidden><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7"></path></svg><span id="log-jump-text">Newest</span></button>
|
||||
</div>
|
||||
|
||||
<footer class="bottom" id="bottom" hidden>
|
||||
<div class="left">
|
||||
<button class="btn small" id="btn-pause">Pause</button>
|
||||
<button class="btn small ghost" id="btn-logs">Logs</button>
|
||||
</div>
|
||||
<div class="mid mono" id="foot-status"></div>
|
||||
<div class="right">
|
||||
<span class="mono dim" id="foot-version"></span>
|
||||
<button class="btn small ghost danger" id="btn-quit">Quit</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
174
app/igneum-app/ui/notices.test.mjs
Normal file
174
app/igneum-app/ui/notices.test.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// 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, cardNotices, 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 }), []);
|
||||
});
|
||||
|
||||
// hot-plug (src/hotplug.rs): the strip says what appeared or went, for 5 minutes, on every screen
|
||||
const cardOf = (over) => ({ index: 0, key: 'nvidia:0:NVIDIA GeForce RTX 5090', kind: 'discrete', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', enabled: true, state: 'mining', problem: '', reason: '', message: '', added_at: 0, removed_at: 0, gone: false, ...over });
|
||||
test('card notices: new card mining, new card not usable with the hint, removed card, nothing for the cards found at start', () => {
|
||||
const start = cardOf();
|
||||
assert.deepEqual(cardNotices([start], NOW), []);
|
||||
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30, state: 'starting' });
|
||||
const [a] = cardNotices([start, added], NOW);
|
||||
assert.equal(a.kind, 'card-added'); assert.equal(a.level, N.LEVEL['job-done']); assert.equal(a.text, 'New card: gfx1201, mining.'); assert.equal(a.key, 'card:added:amd:1:gfx1201:' + (NOW - 30));
|
||||
const bad = cardOf({ key: 'amd::AMD Radeon RX 9070 XT', name: 'AMD Radeon RX 9070 XT', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: NOW - 10 });
|
||||
const [b] = cardNotices([bad], NOW);
|
||||
assert.equal(b.text, 'AMD Radeon RX 9070 XT: not usable (Code 43). No worker runs on it.'); assert.equal(b.tone, 'warn'); assert.match(b.detail, /^reboot with the card attached/);
|
||||
const igpu = cardOf({ key: 'amd:0:gfx1036', name: 'gfx1036', vendor: 'amd', kind: 'integrated', enabled: false, state: 'off', added_at: NOW - 5 });
|
||||
assert.equal(cardNotices([igpu], NOW)[0].text, 'New card: gfx1036, off (integrated). Settings switches it on.');
|
||||
const removed = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: NOW - 60 });
|
||||
const [r] = cardNotices([removed], NOW);
|
||||
assert.equal(r.kind, 'card-removed'); assert.equal(r.text, 'Card removed: gfx1201. Its worker stopped.'); assert.equal(r.tone, 'warn');
|
||||
// the newest first; both go after CARD_S
|
||||
const two = cardNotices([added, removed], NOW);
|
||||
assert.equal(two[0].kind, 'card-added');
|
||||
assert.deepEqual(cardNotices([added, removed, bad], NOW + N.CARD_S + 1), []);
|
||||
});
|
||||
|
||||
test('card notices sit under a running job and show on the setup screens too', () => {
|
||||
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30 });
|
||||
const withJob = s({ jobs: run(), mining: { cards: [added] } });
|
||||
assert.equal(pick(gather(withJob, { dashboard: true }), {}).kind, 'job-running');
|
||||
const quiet = s({ mining: { cards: [added] } });
|
||||
assert.equal(pick(gather(quiet, { dashboard: true }), {}).kind, 'card-added');
|
||||
assert.equal(pick(gather(quiet, {}), {}).kind, 'card-added');
|
||||
// closed: stays closed for that key; a later event on the same card has a new key
|
||||
assert.equal(pick(gather(quiet, { dashboard: true }), { ['card:added:amd:1:gfx1201:' + (NOW - 30)]: true }), null);
|
||||
});
|
||||
44
app/igneum-app/ui/tune-line.test.mjs
Normal file
44
app/igneum-app/ui/tune-line.test.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// node --test app/igneum-app/ui/tune-line.test.mjs (no dependencies; CI runs it in the site job)
|
||||
// The card row's Ember Tune line (app.js TuneLine): what a user sees per state, from the card state fields.
|
||||
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 { model, point } = mod.exports.TuneLine;
|
||||
const NOW = 1_800_000_000;
|
||||
const card = over => ({ vendor: 'nvidia', sweep_state: 'idle', sweep_note: '', sweep_pct: 0, power_pct: 80, sweep_at: 0, tune_line: '', tune_source: '', tune_clock_mhz: 0, tune_control: true, pinned: false, ...over });
|
||||
|
||||
test('tuned: the line the brief asks for, with the point, the source and when', () => {
|
||||
const m = model(card({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 3600 }), NOW);
|
||||
assert.equal(m.kind, 'tuned');
|
||||
assert.equal(m.text, 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)');
|
||||
assert.equal(m.note, '2470 MHz at 100%, full tune, 1 h ago');
|
||||
const c = model(card({ tune_line: 'Tuned: 17.7 MH/s at 177 W (0.100 MH/W)', tune_source: 'confirm', sweep_pct: 90, sweep_at: NOW - 120, vendor: 'amd' }), NOW);
|
||||
assert.equal(c.note, '90%, clock unlocked, from the fleet prior, confirmed, 2 min ago');
|
||||
const p = model(card({ tune_line: 'Tuned: 1 MH/s at 1 W (1.000 MH/W)', tune_source: 'full', sweep_pct: 70, pinned: true }), NOW);
|
||||
assert.match(p.note, /your setting stays pinned$/);
|
||||
});
|
||||
|
||||
test('measure only: Apple and NVIDIA without Power control say so beside the measured line', () => {
|
||||
const a = model(card({ vendor: 'apple', tune_control: false, tune_line: 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)', tune_source: 'baseline', sweep_note: 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed', sweep_at: NOW - 60 }), NOW);
|
||||
assert.equal(a.kind, 'measured');
|
||||
assert.equal(a.text, 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W) (measured as it runs, 1 min ago)');
|
||||
assert.match(a.note, /^measure only on Apple silicon/);
|
||||
const n = model(card({ tune_control: false, tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'baseline', sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)' }), NOW);
|
||||
assert.equal(n.kind, 'measured');
|
||||
assert.match(n.note, /Power control/);
|
||||
});
|
||||
|
||||
test('running, stopped, idle and off', () => {
|
||||
assert.deepEqual(model(card({ sweep_state: 'running', sweep_note: 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)' }), NOW), { kind: 'running', text: 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)' });
|
||||
assert.equal(model(card({ sweep_note: 'tuning stopped: a remote job took the GPU' }), NOW).kind, 'stopped');
|
||||
assert.equal(model(card({}), NOW).text, 'tuning: not run yet (starts after 120 s of steady mining)');
|
||||
assert.equal(model(card({ sweep_note: 'tuning: waits for 120 s of steady mining' }), NOW).text, 'tuning: waits for 120 s of steady mining');
|
||||
assert.equal(model(card({ vendor: 'other' }), NOW).kind, 'off');
|
||||
assert.equal(point({ tune_clock_mhz: 0, sweep_pct: 0, power_pct: 0 }), '');
|
||||
});
|
||||
118
app/igneum-app/ui/update-card.test.mjs
Normal file
118
app/igneum-app/ui/update-card.test.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// node --test app/igneum-app/ui/update-card.test.mjs (no dependencies; CI runs it in the site job)
|
||||
// Loads the UpdateCard block at the top of app.js (plain browser JS, run with `module` defined and no `document`)
|
||||
// and checks what the card says for each update state, the release-note lines, and when it shows or waits.
|
||||
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 C = mod.exports.UpdateCard;
|
||||
const { model, decide, blocked, splitNotes, sentences, size } = C;
|
||||
|
||||
const NOTES = 'Difficulty rule v2 from DAA score 33,000 (about 18:35 UTC on 4 October); every node must carry it before then. The log drawer wraps long lines. Touch ID on the Mac. Windows Hello is written but untested.';
|
||||
const upd = (over) => ({ available: true, version: '0.3.7', notes: NOTES, checked_at: 0, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: '', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
|
||||
const quiet = { jobActive: false, uptime_s: 5000, quitting: false, sheetOpen: false };
|
||||
|
||||
test('release notes: sentences, three lines under 70 characters, the rest behind What changed', () => {
|
||||
assert.deepEqual(sentences('one. two! three? four; five'), ['One.', 'Two!', 'Three?', 'Four', 'Five']);
|
||||
assert.deepEqual(sentences('DAA score 33,000 is the switch. v2.1 ships.'), ['DAA score 33,000 is the switch.', 'V2.1 ships.']);
|
||||
assert.deepEqual(sentences('line one\nline two\n\n'), ['Line one', 'Line two']);
|
||||
assert.deepEqual(sentences(''), []);
|
||||
const n = splitNotes(NOTES);
|
||||
assert.equal(n.lines.length, 3);
|
||||
for (const l of n.lines) assert.ok(l.length < C.LINE_MAX, `${l.length}: ${l}`);
|
||||
assert.equal(n.lines[0], 'Difficulty rule v2 from DAA score 33,000 (about 18:35 UTC on 4…');
|
||||
assert.equal(n.lines[1], 'Every node must carry it before then');
|
||||
assert.equal(n.lines[2], 'The log drawer wraps long lines');
|
||||
assert.equal(n.more, true);
|
||||
assert.equal(n.all.length, 5);
|
||||
const short = splitNotes('Faster sync. Fewer restarts.');
|
||||
assert.deepEqual(short.lines, ['Faster sync', 'Fewer restarts']); assert.equal(short.more, false);
|
||||
assert.deepEqual(splitNotes('').lines, []); assert.equal(splitNotes('').more, false);
|
||||
assert.equal(size(20_588_331), '21 MB'); assert.equal(size(612_000), '0.6 MB'); assert.equal(size(0), '');
|
||||
});
|
||||
|
||||
test('what the card says: available, downloading with the ring, ready, installing, failed, manual, waiting', () => {
|
||||
const a = model(upd({ status: 'available', downloaded: false, ready: false, progress: 0 }), {});
|
||||
assert.equal(a.name, 'Igneum Ember 0.3.7'); assert.equal(a.line, 'is available.'); assert.equal(a.stage, 'available');
|
||||
assert.equal(a.key, 'update:0.3.7:pending'); assert.equal(a.ring, 'none'); assert.equal(a.size, '21 MB');
|
||||
assert.deepEqual(a.actions.map((x) => x.label), ['Install now', 'Later']); assert.equal(a.dismissable, true);
|
||||
const d = model(upd({ status: 'downloading', downloaded: false, ready: false, progress: 0.43 }), {});
|
||||
assert.equal(d.line, 'is downloading.'); assert.equal(d.pct, 43); assert.equal(d.ring, 'progress'); assert.equal(d.key, a.key);
|
||||
assert.equal(model(upd({ status: 'staging', ready: false }), {}).key, a.key);
|
||||
const r = model(upd(), {});
|
||||
assert.equal(r.line, 'is ready to install.'); assert.equal(r.note, 'It installs by itself at a quiet moment.'); assert.equal(r.key, 'update:0.3.7:ready'); assert.equal(r.ring, 'full');
|
||||
assert.equal(model(upd({ auto: false, wait: 'waiting for Install now (automatic updates are off)' }), {}).note, '');
|
||||
assert.equal(model(upd({ wait: 'this version failed to install before; it waits for Install now' }), {}).note, 'It failed to install before.');
|
||||
const i = model(upd({ status: 'applying', applying: true }), {});
|
||||
assert.equal(i.stage, 'installing'); assert.equal(i.line, 'Installing. The app restarts itself.'); assert.deepEqual(i.actions, []); assert.equal(i.dismissable, false); assert.equal(i.ring, 'busy');
|
||||
assert.equal(model(upd({ wait: 'installing now' }), {}).stage, 'installing');
|
||||
const f = model(upd({ status: 'error', error: 'sha256 mismatch: the file is not what the manifest signed\nsecond line', ready: false }), {});
|
||||
assert.equal(f.stage, 'failed'); assert.equal(f.line, 'did not install.'); assert.equal(f.cause, 'sha256 mismatch: the file is not what the manifest signed');
|
||||
assert.deepEqual(f.actions.map((x) => x.label), ['Try again', 'Later']); assert.equal(f.key, 'update:0.3.7:failed');
|
||||
assert.equal(model(upd({ status: 'error', error: 'did not stay up', rolled_back: '0.3.7: did not stay up' }), {}).line, 'did not stay up and was rolled back.');
|
||||
const m = model(upd({ status: 'manual', ready: false, wait: '/Applications is not writable' }), {});
|
||||
assert.equal(m.line, 'is downloaded.'); assert.equal(m.actions[0].label, 'Open the download');
|
||||
assert.equal(model(upd({ status: 'deferred' }), {}).line, 'is waiting for permission.');
|
||||
// urgent: no Later, the engine's words as the note
|
||||
const u = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'Consensus upgrade at height 120000. Installing now.' }), {});
|
||||
assert.equal(u.dismissable, false); assert.deepEqual(u.actions.map((x) => x.label), ['Install now']); assert.equal(u.note, 'Consensus upgrade at height 120000. Installing now.');
|
||||
// nothing to show
|
||||
assert.equal(model(upd({ status: 'current', available: false }), {}), null);
|
||||
assert.equal(model(upd({ status: 'checking' }), {}), null);
|
||||
assert.equal(model(upd({ status: 'error', error: 'no update manifest configured in this build' }), {}), null);
|
||||
assert.equal(model(upd({ status: 'error', version: '', error: 'manifest: connection refused' }), {}), null);
|
||||
assert.equal(model(null, {}), null);
|
||||
});
|
||||
|
||||
test('deferred: a running job, the first 60 s, the key sheet, quitting; installing is never hidden', () => {
|
||||
assert.equal(blocked(quiet), '');
|
||||
assert.equal(blocked({ ...quiet, jobActive: true }), 'a job is running');
|
||||
assert.equal(blocked({ ...quiet, uptime_s: 59 }), 'the engine started under a minute ago');
|
||||
assert.equal(blocked({ ...quiet, uptime_s: 60 }), '');
|
||||
assert.equal(blocked({ ...quiet, sheetOpen: true }), 'the key sheet is open');
|
||||
assert.equal(blocked({ ...quiet, quitting: true }), 'the app is quitting');
|
||||
const a = model(upd({ status: 'available' }), {});
|
||||
assert.deepEqual(decide(a, { ...quiet, jobActive: true }, {}), { show: false, why: 'deferred', reason: 'a job is running' });
|
||||
assert.deepEqual(decide(a, { ...quiet, uptime_s: 10 }, {}), { show: false, why: 'deferred', reason: 'the engine started under a minute ago' });
|
||||
assert.equal(decide(a, quiet, {}).show, true);
|
||||
// the block lifts: the card comes (it was queued, not dismissed)
|
||||
assert.equal(decide(a, { ...quiet, uptime_s: 61 }, { open: false, later: '', seen: '' }).show, true);
|
||||
const i = model(upd({ status: 'applying', applying: true }), {});
|
||||
assert.equal(decide(i, { ...quiet, jobActive: true }, { open: true }).show, true);
|
||||
});
|
||||
|
||||
test('Later: hides this version at this stage; back for a newer version or a ready download with auto off', () => {
|
||||
const pend = model(upd({ status: 'downloading', progress: 0.5, ready: false }), {});
|
||||
const ready = model(upd(), {});
|
||||
const readyOff = model(upd({ auto: false }), {});
|
||||
assert.deepEqual(decide(pend, quiet, {}), { show: true, why: 'new' });
|
||||
const later = { open: false, later: pend.key, seen: '0.3.7' };
|
||||
assert.deepEqual(decide(pend, quiet, later), { show: false, why: 'later' });
|
||||
// auto on: the ready download installs by itself, the strip says so, no second card
|
||||
assert.deepEqual(decide(ready, quiet, later), { show: false, why: 'auto' });
|
||||
// auto off: the ready download needs a click, so the card returns once
|
||||
assert.deepEqual(decide(readyOff, quiet, later), { show: true, why: 'ready' });
|
||||
assert.deepEqual(decide(readyOff, quiet, { open: false, later: readyOff.key, seen: '0.3.7' }), { show: false, why: 'later' });
|
||||
// a newer version is a new key
|
||||
const newer = model(upd({ status: 'available', version: '0.3.8' }), {});
|
||||
assert.deepEqual(decide(newer, quiet, later), { show: true, why: 'new' });
|
||||
// ready first seen with auto on (the download happened while the card could not open): shown once
|
||||
assert.deepEqual(decide(ready, quiet, { open: false, later: '', seen: '' }), { show: true, why: 'ready' });
|
||||
// an open card follows its update: downloading, ready, installing, failed
|
||||
const open = { open: true, later: '', seen: '0.3.7' };
|
||||
assert.equal(decide(pend, quiet, open).why, 'open'); assert.equal(decide(ready, quiet, open).why, 'open');
|
||||
assert.equal(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, open).show, true);
|
||||
assert.equal(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, open).show, true);
|
||||
// installing and failed without an open card: the strip has them
|
||||
assert.deepEqual(decide(model(upd({ status: 'applying', applying: true }), {}), quiet, {}), { show: false, why: 'strip' });
|
||||
assert.deepEqual(decide(model(upd({ status: 'error', error: 'x' }), {}), quiet, {}), { show: false, why: 'strip' });
|
||||
// urgent cannot be dismissed
|
||||
const urgent = model(upd({ status: 'downloading', progress: 0.2, urgent: true, urgent_text: 'Consensus upgrade.' }), {});
|
||||
assert.deepEqual(decide(urgent, quiet, { later: urgent.key }), { show: true, why: 'urgent' });
|
||||
assert.deepEqual(decide(null, quiet, {}), { show: false, why: 'none' });
|
||||
});
|
||||
200
app/igneum-app/ui/view.test.mjs
Normal file
200
app/igneum-app/ui/view.test.mjs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// node --test app/igneum-app/ui/view.test.mjs (no dependencies; CI runs it in the site job)
|
||||
// Loads the View block of app.js (plain browser JS: the file is run with `module` defined and no `document`, so only
|
||||
// the pure blocks execute) and checks the words each page shows for a given state (miner-ui-2).
|
||||
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 V = mod.exports.View;
|
||||
|
||||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||||
|
||||
test('the six sections and their order', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'prove', 'rewards', 'node', 'updates', 'settings']);
|
||||
assert.equal(V.page('node').title, 'Node');
|
||||
assert.equal(V.page('nonsense').id, 'mine');
|
||||
});
|
||||
|
||||
test('a GPU row: name, kind, the numbers where known, the switch', () => {
|
||||
const r = V.cardRow(card());
|
||||
assert.equal(r.name, 'NVIDIA GeForce RTX 5090');
|
||||
assert.equal(r.kindWord, 'Discrete');
|
||||
assert.equal(r.integrated, false);
|
||||
assert.equal(r.on, true);
|
||||
assert.equal(r.word, 'mining');
|
||||
assert.equal(r.tone, 'on');
|
||||
assert.equal(r.hash, '124');
|
||||
assert.equal(r.temp, '61 °C');
|
||||
assert.equal(r.tempTone, '');
|
||||
assert.equal(r.power, '410 W');
|
||||
assert.equal(r.cap, 460);
|
||||
assert.equal(r.meta, '32 GB · 3 blocks · next program ready');
|
||||
assert.equal(r.canToggle, true);
|
||||
});
|
||||
|
||||
test('a GPU row: temperatures turn amber then red; unknown numbers are empty', () => {
|
||||
assert.equal(V.cardRow(card({ temp_mem: 92 })).tempTone, 'warm');
|
||||
assert.equal(V.cardRow(card({ temp_mem: 96 })).tempTone, 'hot');
|
||||
assert.equal(V.cardRow(card({ temp_gpu: 93 })).tempTone, 'hot');
|
||||
const apple = V.cardRow(card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072, power_w: 0, power_default_w: 0, temp_gpu: 0, temp_mem: 0, telemetry_at: 0, hash_now: 7.4, accepted: 0, prepared: false }));
|
||||
assert.equal(apple.kindWord, 'Apple silicon');
|
||||
assert.equal(apple.hash, '7.4');
|
||||
assert.equal(apple.temp, '');
|
||||
assert.equal(apple.power, '');
|
||||
assert.equal(apple.cap, 0);
|
||||
assert.equal(apple.meta, '128 GB unified');
|
||||
});
|
||||
|
||||
test('an integrated GPU is shown as integrated and off, with its reason', () => {
|
||||
const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 }));
|
||||
assert.equal(r.integrated, true);
|
||||
assert.equal(r.kindWord, 'Integrated');
|
||||
assert.equal(r.on, false);
|
||||
assert.equal(r.word, 'off');
|
||||
assert.equal(r.tone, 'off');
|
||||
assert.equal(r.hash, '');
|
||||
assert.equal(r.sub, 'integrated GPU: slow and shares the machine memory');
|
||||
assert.equal(V.cardRow(card({ kind: 'unknown' })).canToggle, false);
|
||||
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 7 })).word, 'restart in 7 s');
|
||||
assert.equal(V.cardRow(card({ state: 'faulted' })).tone, 'bad');
|
||||
assert.equal(V.cardRow(card({ state: 'waiting' })).word, 'waiting for the node');
|
||||
});
|
||||
|
||||
test('the big button: start when paused, stop when mining, disabled with no card on', () => {
|
||||
const m = (over) => ({ state: 'mining', paused: false, cards: [card()], ...over });
|
||||
assert.deepEqual(V.toggle(m(), { synced: true }, {}), { label: 'Stop mining', sub: 'mining on 1 of 1 card', cls: 'stop', disabled: false, act: 'pause' });
|
||||
assert.deepEqual(V.toggle(m({ state: 'paused', paused: true }), { synced: true }, {}), { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' });
|
||||
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
|
||||
assert.equal(none.disabled, true);
|
||||
assert.equal(none.act, '');
|
||||
assert.equal(none.sub, 'switch a GPU on below first');
|
||||
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
|
||||
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
|
||||
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
|
||||
});
|
||||
|
||||
test('the node words: synced, syncing with a percentage, failed, a blocked clock', () => {
|
||||
const n = (over) => ({ state: 'synced', blocks: 135200, headers: 135200, peers: 3, daa: 140000, last_reading_age_s: 4, message: '', restart_in_s: 0, ...over });
|
||||
const ok = V.nodeWords(n(), { severity: 'none' }, '');
|
||||
assert.equal(ok.word, 'synced');
|
||||
assert.equal(ok.tone, 'ok');
|
||||
assert.match(ok.line, /every block/);
|
||||
const sy = V.nodeWords(n({ state: 'syncing', blocks: 50000, headers: 100000 }), { severity: 'none' }, 'about 3 min left at 300 blocks/s');
|
||||
assert.equal(sy.word, 'syncing');
|
||||
assert.equal(sy.line, 'about 3 min left at 300 blocks/s · 50,000 of 100,000 (50%)');
|
||||
assert.equal(V.nodeWords(n({ state: 'failed', message: 'igneumd could not start' }), { severity: 'none' }, '').tone, 'bad');
|
||||
const blocked = V.nodeWords(n(), { severity: 'block', skew_s: -75 }, '');
|
||||
assert.equal(blocked.tone, 'bad');
|
||||
assert.match(blocked.line, /clock is off by 75 s/);
|
||||
assert.equal(V.peersLine(n({ peers: 0 })), 'none yet: looking for the seed node');
|
||||
assert.equal(V.peersLine(n({ peers: 1 })), 'one other node this one talks to');
|
||||
assert.equal(V.heightLine(n({ state: 'syncing', blocks: 10, headers: 500 })), 'of 500 headers seen');
|
||||
assert.equal(V.heightLine(n()), 'blocks this node holds');
|
||||
});
|
||||
|
||||
test('the next consensus switch is the first height above the DAA score, in plain words', () => {
|
||||
const sw = [
|
||||
{ key: 'fees_v1_activation_daa', name: 'Fees v1', daa: 210000 },
|
||||
{ key: 'difficulty_v2_activation_daa', name: 'Difficulty v2', daa: 33000 },
|
||||
{ key: 'finality_v3_activation_daa', name: 'Finality v3', daa: 135200 }
|
||||
];
|
||||
const next = V.nextSwitch(sw, 140000);
|
||||
assert.equal(next.name, 'Fees v1');
|
||||
assert.equal(next.away, 70000);
|
||||
assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
||||
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
|
||||
assert.equal(V.nextSwitch(sw, 300000), null);
|
||||
assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/);
|
||||
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
|
||||
assert.equal(V.nextSwitch([], 5), null);
|
||||
});
|
||||
|
||||
test('the prove words follow the switch, the setup, the node and the status', () => {
|
||||
const pv = (over) => ({ enabled: true, available: true, setup_hint: '', backend: 'cuda', status: 'idle', message: '', current: '', verifier_mode: 'command', pool_entries: 4, pool_verified: 4, pool_failed: 0, ...over });
|
||||
assert.equal(V.proveWords(pv(), false, true).word, 'off');
|
||||
assert.equal(V.proveWords(pv({ status: 'setup', available: false, setup_hint: 'proving needs the WSL2 setup' }), true, true).word, 'needs setup');
|
||||
assert.equal(V.proveWords(pv(), true, false).word, 'waiting');
|
||||
assert.equal(V.proveWords(pv({ status: 'proving', current: 'block 59199 shard 0' }), true, true).sub, 'block 59199 shard 0');
|
||||
assert.equal(V.proveWords(pv({ status: 'submitted' }), true, true).tone, 'on');
|
||||
assert.equal(V.proveWords(pv(), true, true).word, 'idle');
|
||||
assert.equal(V.verifierWords(pv()).word, 'verifying · pool 4/4');
|
||||
assert.equal(V.verifierWords(pv()).tone, 'ok');
|
||||
assert.equal(V.verifierWords(pv({ verifier_mode: 'off', pool_entries: 0 })).tone, 'warn');
|
||||
assert.equal(V.verifierWords(pv({ verifier_mode: 'off', verifier_reason: 'the WSL2 prover is not installed' })).needsSetup, true);
|
||||
assert.equal(V.verifierWords({}).word, 'not read yet');
|
||||
});
|
||||
|
||||
test('the dev-fee lines name the share and where Settings turns it off', () => {
|
||||
const s = (on, fee) => ({ settings: { dev_fee: on }, mining: { fee_total: fee }, dev_fee: { on, percent: on ? 1 : 0, address: '0x1234567890abcdef1234567890abcdef12345678', line: '' } });
|
||||
assert.equal(V.devFeeLine(s(true, 12)), 'One block in 100 pays the miner software’s dev fee (12 so far). Settings turns it off.');
|
||||
assert.equal(V.devFeeLine(s(false, 0)), 'The dev fee is off. Every block pays this address.');
|
||||
assert.equal(V.devFeeText(s(true, 0)), 'Dev fee 1% (1 block in 100) to 0x123456…5678');
|
||||
assert.match(V.devFeeText(s(false, 0)), /^Dev fee off/);
|
||||
});
|
||||
|
||||
test('the remote-jobs line and the helpers', () => {
|
||||
const title = (j) => j.title || j.kind;
|
||||
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until Settings allows remote jobs.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
|
||||
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
|
||||
assert.equal(V.shortHex('0xabc'), '0xabc');
|
||||
assert.equal(V.withCommas(1234567), '1,234,567');
|
||||
assert.equal(V.compact(2500000), '2.50M');
|
||||
assert.equal(V.rel(90), '1 min ago');
|
||||
});
|
||||
|
||||
test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as such, with no switch, and leave the counts', () => {
|
||||
const gone = card({ key: 'amd:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: 1000, added_at: 0, gone: false, hash_now: 0 });
|
||||
const r = V.cardRow(gone);
|
||||
assert.equal(r.removed, true);
|
||||
assert.equal(r.on, false);
|
||||
assert.equal(r.canToggle, false);
|
||||
assert.equal(r.word, 'removed');
|
||||
assert.equal(r.tone, 'off');
|
||||
assert.equal(r.hash, '');
|
||||
assert.match(r.sub, /^unplugged; its worker stopped/);
|
||||
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
|
||||
const b = V.cardRow(bad);
|
||||
assert.equal(b.unusable, true);
|
||||
assert.equal(b.canToggle, false);
|
||||
assert.equal(b.word, 'not usable (Code 43)');
|
||||
assert.equal(b.tone, 'bad');
|
||||
assert.match(b.sub, /^reboot with the card attached/);
|
||||
assert.equal(b.title, 'gfx1201 · CUDA device 1 · AMD Accelerated Parallel Processing · bus 0000:03:00.0');
|
||||
assert.equal(V.cardRow(card()).title, 'CUDA device undefined'.replace(' device undefined', '') === '' ? '' : V.cardRow(card()).title);
|
||||
// a row that is gone (five minutes after removal) is not shown at all; the big button counts only present cards
|
||||
assert.deepEqual(V.shownCards([card(), card({ key: 'x', gone: true })]).map((c) => c.key), ['nvidia:0:RTX 5090']);
|
||||
assert.equal(V.present(card()), true);
|
||||
// performance order (6 October 2026): PC 1 detects 5090, integrated AMD, 9070 XT; the list shows the 5090, the 9070 XT,
|
||||
// then the integrated card, whatever the detection order and whatever the integrated card's rate
|
||||
const pc1 = [
|
||||
card({ key: 'nvidia:0:RTX 5090', hash_avg: 122 }),
|
||||
card({ key: 'amd:gfx1036', name: 'AMD Radeon(TM) Graphics', vendor: 'amd', kind: 'integrated', vram_mb: 512, hash_avg: 2.1, enabled: true }),
|
||||
card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', vendor: 'amd', kind: 'discrete', vram_mb: 16384, hash_avg: 18.2 }),
|
||||
];
|
||||
assert.deepEqual(V.shownCards(pc1).map((c) => c.key), ['nvidia:0:RTX 5090', 'amd:gfx1201', 'amd:gfx1036']);
|
||||
// before any rate (first start): discrete by memory, integrated last; a removed card last of all; ties keep detection order
|
||||
const fresh = [
|
||||
card({ key: 'amd:gfx1036', kind: 'integrated', vram_mb: 512, hash_avg: 0, hash_now: 0, state: 'off' }),
|
||||
card({ key: 'amd:gfx1201', kind: 'discrete', vram_mb: 16384, hash_avg: 0, hash_now: 0, state: 'waiting' }),
|
||||
card({ key: 'nvidia:0:RTX 5090', hash_avg: 0, hash_now: 0, state: 'waiting' }),
|
||||
card({ key: 'nvidia:1:RTX 5090', hash_avg: 0, hash_now: 0, state: 'waiting', removed_at: 5 }),
|
||||
];
|
||||
assert.deepEqual(V.shownCards(fresh).map((c) => c.key), ['nvidia:0:RTX 5090', 'amd:gfx1201', 'amd:gfx1036', 'nvidia:1:RTX 5090']);
|
||||
// a small rate change does not reorder (5 MH/s buckets): 122 and 124 sort as equal and keep detection order
|
||||
assert.deepEqual(V.shownCards([card({ key: 'a', hash_avg: 122 }), card({ key: 'b', hash_avg: 124 })]).map((c) => c.key), ['a', 'b']);
|
||||
assert.equal(V.present(gone), false);
|
||||
assert.equal(V.present(bad), false);
|
||||
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
|
||||
assert.equal(t.disabled, true);
|
||||
assert.equal(t.sub, 'no GPU this app can drive');
|
||||
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
|
||||
assert.equal(t2.sub, 'mining on 1 of 1 card');
|
||||
});
|
||||
|
|
@ -116,7 +116,7 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
window.titleVisibility = .hidden
|
||||
window.isMovableByWindowBackground = true
|
||||
window.backgroundColor = obsidian
|
||||
window.minSize = NSSize(width: 900, height: 620)
|
||||
window.minSize = NSSize(width: 900, height: 600)
|
||||
window.center()
|
||||
window.delegate = self
|
||||
window.isReleasedWhenClosed = false
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
#endif
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <dbt.h>
|
||||
#include <wrl.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
|
@ -284,6 +285,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
case WM_SIZE:
|
||||
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
|
||||
return 0;
|
||||
case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same
|
||||
((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600;
|
||||
return 0;
|
||||
case WM_ENGINE_LINE: {
|
||||
if (wp == 1) {
|
||||
g_exited = true;
|
||||
|
|
@ -324,6 +328,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DEVICECHANGE:
|
||||
// a device arrived or left (an eGPU through a USB4 box, a driver coming up or crashing): the engine enumerates
|
||||
// the cards now instead of at its next minute poll (src/hotplug.rs); DBT_DEVNODES_CHANGED needs no registration
|
||||
if (wp == DBT_DEVNODES_CHANGED || wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) sendEngine("detect");
|
||||
return TRUE;
|
||||
case WM_TRAY:
|
||||
if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); }
|
||||
else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu();
|
||||
|
|
|
|||
|
|
@ -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.3"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,3,3,0
|
||||
#define IGNEUM_HOST_VERSION_STR "0.3.13"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,3,13,0
|
||||
#endif
|
||||
|
|
|
|||
53
brand/trademark/FILING.md
Normal file
53
brand/trademark/FILING.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# IGNEUM trademark filing pack (4 October 2026)
|
||||
|
||||
Three marks, three offices, three classes. File the word mark first; it is the strongest and needs no image.
|
||||
|
||||
## The marks
|
||||
|
||||
| Mark | Type | File | Notes |
|
||||
|---|---|---|---|
|
||||
| IGNEUM | Word mark, standard characters | none, type the word | Covers the name in any font, colour or case. File this everywhere. |
|
||||
| The flame | Device (figurative) mark | `IGNEUM-device-mark-black.jpg` | Filed in black and white, which in UK and EU practice covers every colour. Do not file the orange version as the main mark; attach it only if the office asks for the colour used. |
|
||||
| Flame + IGNEUM | Combined mark | `IGNEUM-combined-mark.jpg` | Optional third filing. Adds little once the first two are registered; file it only if the budget allows. |
|
||||
|
||||
Mark description for the device, when a form asks: "A stylised flame formed of two angular polygons, the upper a pointed crystal shape with a diamond cut-out at its centre, the lower a wide V-shaped base."
|
||||
|
||||
## Applicant
|
||||
|
||||
Igneum Labs LTD, Licensee Address: Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial
|
||||
Centre (decided 5 October 2026; it replaces the ADGM DLT Foundation named on 4 October). NOT [other-business]: a [other-business]
|
||||
filing would tie Igneum to VIVA and to its owner through public registers, which the standing rule forbids. The
|
||||
registered address above is the applicant address, with a trademark attorney as the address for service, so no
|
||||
personal address appears anywhere. If the company's registration is not complete the week you want to file, the
|
||||
attorney can file in the attorney firm's name as nominee and assign on registration; ask for that rather than filing
|
||||
under any existing company.
|
||||
|
||||
## Classes and specifications (Nice classification, 12th edition wording)
|
||||
|
||||
**Class 9** (software): Downloadable computer software for mining, validating and securing a blockchain; downloadable software for cryptocurrency wallets; downloadable software for generating and verifying cryptographic proofs; downloadable software for operating nodes of a distributed ledger network; downloadable mobile applications for sending, receiving and storing digital tokens; computer programs for distributed computing.
|
||||
|
||||
**Class 36** (financial): Financial services, namely providing a digital currency and a digital token for use by members of an online community via a global computer network; cryptocurrency exchange services; cryptocurrency payment processing; electronic transfer of digital tokens; financial information relating to blockchain networks.
|
||||
|
||||
**Class 42** (technology services): Providing online non-downloadable software for operating a blockchain; software as a service featuring software for verifying cryptographic proofs; design and development of computer software for distributed ledger technology; providing a decentralised computing platform; technical consultancy relating to blockchain technology; hosting of a blockchain network; computer security services, namely cryptographic proof generation and verification.
|
||||
|
||||
## Where and what it costs (approximate, official fees only, October 2026)
|
||||
|
||||
| Office | Fee for three classes | Route | Examination time |
|
||||
|---|---|---|---|
|
||||
| UK IPO | £170 for the first class plus £50 each further class, about £270 per mark online (Right Start is £100 plus £25 per class up front, then the balance) | gov.uk "apply to register a trade mark" | 4 to 6 months, 2-month opposition window |
|
||||
| EUIPO | €850 for one class, €50 for the second, €150 for each further class, about €1,050 per mark | euipo.europa.eu e-filing | 4 to 6 months, 3-month opposition window |
|
||||
| USPTO | $350 per class electronic, about $1,050 per mark | uspto.gov TEAS; a US-licensed attorney is mandatory for a foreign applicant | 8 to 12 months; needs proof of use before registration or an intent-to-use basis |
|
||||
|
||||
Two marks (word and device) in three offices is about £5,000 in official fees plus the attorney. Word mark alone in all three is about £2,200.
|
||||
|
||||
## The obstacle, and the plan for it
|
||||
|
||||
UK registration UK00918212492 IGNIUM in classes 9, 36 and 42 (the memo of 3 October 2026). File IGNEUM anyway: the examiner notifies the IGNIUM owner but does not refuse on relative grounds in the UK; the EUIPO likewise leaves it to the owner to oppose. In parallel, check the IGNIUM registration's filing date; if it is more than five years old and the mark is not in genuine use for those goods, file a non-use revocation (UK IPO form TM26(N), £200), which clears the path. The attorney should run a fresh clearance search for IGNEUM in the three classes in each office the week of filing.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. Attorney engaged, with Igneum Labs LTD's details and the address for service.
|
||||
2. Word mark IGNEUM filed in the UK, EU and US the same week (US on an intent-to-use basis).
|
||||
3. Device mark filed in the UK and EU; US device filing after the word mark is accepted.
|
||||
4. Non-use revocation against IGNIUM if the dates allow.
|
||||
5. Use ™ on the site and the apps from now; ® only after registration in that country.
|
||||
BIN
brand/trademark/IGNEUM-combined-mark.jpg
Normal file
BIN
brand/trademark/IGNEUM-combined-mark.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
brand/trademark/IGNEUM-device-mark-black.jpg
Normal file
BIN
brand/trademark/IGNEUM-device-mark-black.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
brand/trademark/IGNEUM-device-mark-colour.jpg
Normal file
BIN
brand/trademark/IGNEUM-device-mark-colour.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
131
docs/analysis/amd-proving.md
Normal file
131
docs/analysis/amd-proving.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Proving on AMD and Apple cards: what exists, what the CPU can do, what to tell the public
|
||||
|
||||
5 October 2026, from the project lead's two questions that evening: "test proving on the amd card?" and "can we test proving on
|
||||
mac?". PC 1 holds an RTX 5090 and an RX 9070 XT (gfx1201, 16 GB) in an eGPU; this Mac is an M5 Max. The prover is
|
||||
SP1 (`proving/igneum-prove`, `docs/plans/proving-v0.md`, `proving-v1.md`), run on the GPU only through SP1's CUDA
|
||||
server. Every figure below is measured (with its bench-log entry or job id) or cited (with its file or page); the
|
||||
rest is labelled approximate. Status words follow `docs/spec/00-overview.md` 0.2.
|
||||
|
||||
**The answer in three lines.** No zkVM proves on an AMD GPU on 5 October 2026: not SP1, not RISC Zero, not Jolt, not
|
||||
OpenVM, and the ICICLE library underneath them has no AMD backend either. Apple silicon has a shipped Metal prover in
|
||||
RISC Zero and a Metal backend in ICICLE, but SP1, the prover Igneum runs, is CPU-only on a Mac. So an AMD-only or
|
||||
Apple-only machine mines and does not prove on its card; it can prove on its CPU, at the times measured in section 2.
|
||||
|
||||
## 1. The backends (read 5 October 2026, 20:30 to 20:50 UTC)
|
||||
|
||||
| Prover | Version read | CPU | NVIDIA (CUDA) | AMD (ROCm or HIP) | Apple (Metal) | Vulkan or WebGPU | Where it says so |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| SP1 (ours) | v6.8.1, 24 Sep 2026 (pinned); `dev` head 318dd530, 28 Sep 2026 | yes; AVX2 and AVX-512 on x86 through Plonky3 | yes: `sp1-gpu-server`, "Compute Capability 8.0 or higher", "24GB or more VRAM", "the CUDA 12 runtime and a compatible NVIDIA driver", Linux x86_64 | **no** | **no** | **no** | docs.succinct.xyz, SP1 docs "Hardware acceleration" page; `crates/sdk/src/lib.rs` (`pub mod cpu`, `mock`, `light`, `#[cfg(feature = "cuda")] pub mod cuda`, `#[cfg(feature = "network")] pub mod network`: no other backend module); `sp1-gpu/README.md` (`CUDA_ARCHS` 89, 90, 100, 120; NTT by NVIDIA cuPQC or sppark); release notes v6.2.3 to v6.8.1 (the only backend line: "add optional cuPQC NTT backend", v6.8.0); a code search of the repository on 5 October: "rocm" 0 files, "metal" 0, "vulkan" 0, "webgpu" 0; `cuobjdump` of sp1-gpu-server 6.8.1: sm_80, 86, 89, 90, 100, 120 and compute_120 PTX, nothing else (`docs/bench-log.md`, "proving v1", 5 October 2026) |
|
||||
| sppark (SP1's NTT fallback, vendored at `sp1-gpu/crates/sys/sppark`) | `main` README, read 5 October 2026 | | yes: "x86_64 with Nvidia's Volta+ GPU hardware platforms on Linux and Windows" | "A limited support for AMD's RDNA and CDNA GPUs is provided" (upstream README). SP1's tree carries no HIP build: the 0 "rocm" files above, and `sp1-gpu/crates/sys/sppark/util/gpu_t.cuh` is CUDA only | no | no | github.com/supranational/sppark README; the SP1 files named |
|
||||
| RISC Zero | latest release v3.0.6, 17 Jul 2026 (a v5.0.0-rc.1 of 15 Jan 2026 is also on the releases page) | yes, "nearly any modern CPU (x86 or ARM)" | yes, "RISC Zero targets NVIDIA GPUs using the CUDA framework" | **no** ("rocm", "vulkan": 0 files in the repository) | **yes**: `metal = ["prove"]` in `risc0/zkvm/Cargo.toml`; kernels in `risc0/sys/kernels/zkp/metal/*.metal` (zk, fri, mix, sha); docs: "RISC Zero will use the integrated Metal compute cores" on Apple silicon. The Groth16 wrapper "only works on x86 architecture, and so Apple Silicon is currently unsupported (even via Docker)" | no | dev.risczero.com "Local proving"; `risc0/zkvm/Cargo.toml` features `cuda = [... risc0-zkp/cuda ...]`, `metal = ["prove"]` |
|
||||
| Jolt (a16z) | v0.3.0-alpha, 1 Oct 2025; "Jolt is in alpha and is not suitable for production use" | yes, "state-of-the-art performance on CPU" | no | **no** | a **draft** PR #1733 (opened 3 Aug 2026, not merged): titled "feat: Metal GPU backend (Apple Silicon)" and marked experimental: 92 Metal kernels, "2.12x speedup at 2^20 scale" on an M4 mini and "3.20x vs same-binary CPU" on an M5 Max, "Apple Silicon + macOS only. No CI coverage" | no | github.com/a16z/jolt README and book (jolt.a16zcrypto.com); PR #1733 |
|
||||
| OpenVM | v2.0.2, 14 Aug 2026 | yes | yes: `cuda-backend` (v1.4.2 notes), "Improves the Halo2 GPU prover" (v2.0.2) | **no** | **no** | no | github.com/openvm-org/openvm releases |
|
||||
| ICICLE (Ingonyama; the GPU library behind several provers, not SP1) | v4.0.0, 11 Jul 2025 | yes (MIT) | yes, "CUDA (for NVIDIA GPUs)" | **no** backend listed | yes, "Metal (for Apple Silicon GPUs)"; both under a special licence with a free research licence | Vulkan in the build system (PR #735, merged Jan 2025) and a draft "Vulkan NTT" PR #1019 (Jul 2025, "still wip"); nothing installable | dev.ingonyama.com "Install GPU backend"; the releases page; the README ("backends ... are distributed under a special license") |
|
||||
|
||||
Said plainly: **on 5 October 2026 no zkVM proves on an AMD GPU.** The only AMD code in the whole chain is sppark's
|
||||
limited HIP path, which SP1 does not build. For Apple silicon the answer is split: RISC Zero ships a Metal prover
|
||||
and ICICLE a Metal backend; SP1, Jolt and OpenVM do not. Nothing read tonight names an AMD plan with a date.
|
||||
|
||||
## 2. The CPU fallback, measured
|
||||
|
||||
SP1's CPU prover is the path an AMD-only or Apple-only machine has today. Three machines, the same pinned guests
|
||||
(shard program id `0x2b1a81cb...`, aggregator `0x474678f3...`, pinned 2026-10-05T16:20:38Z), `SP1_PROVER=cpu`,
|
||||
`--mode shard --shard 0` (execute, core proof, compressed proof, each verified). The RTX 5090 rows are the reference.
|
||||
|
||||
| Fixture (SP1 cycles) | Stage | PC 1 CPU, miner running on both cards (job `cpu-prove-pc1-small2`) | Apple M5 Max CPU (4 October, loaded; bench-log) | RTX 5090 (bench-log) |
|
||||
|---|---|---|---|---|
|
||||
| block-56-transfers-3shards shard 0, 200 pgas (315 k) | core | 82.5 s, 7,310,257 B, verify 0.210 s | 83.1 s, 7,310,257 B | not run on the 5090; the nearest rows are block-78 below and an empty live shard: 7.0 to 7.7 s compressed with the miner on the card (5 Oct, `chain-pc2-pv1b`, `pv1c`) |
|
||||
| | compressed | 199.2 s, 1,272,897 B, verify 0.035 s; 312 s wall for setup 22.8 s, execute 0.14 s, core, compressed | 272.3 s, 1,272,897 B | |
|
||||
| | peak RSS, CPU | 29.5 GB peak RSS; 978% CPU (9.8 of 16 cores), user 2,516 s, system 537 s | not recorded | |
|
||||
| block-78-increment, 2 transactions (626 k) | core | 87.0 s, 7,317,857 B, verify 0.209 s | 22.0 s, 7.3 MB (3 October, v0 guest) | 1.4 s (4 October, mining paused) |
|
||||
| | compressed | 202.3 s, 1,272,897 B, verify 0.034 s; 322 s wall (setup 21.8 s) | 55.7 s, 1.27 MB | 2.7 s |
|
||||
| | peak RSS, CPU | 30.5 GB peak RSS; 979% CPU, user 2,616 s, system 541 s | not recorded | |
|
||||
| block-338-shard1, one shard at `S_p` (60.8 M) | core | **not run**, by the PC 1 scheduler's decision at 21:05Z (PC 1's time tonight belongs to the Counter ASIC 2.0 gates; the job `cpu-prove-pc1-sp`, script `tools/amd-prove/pc1-cpu-prove-sp.ps1`, is written and unpublished). Extrapolation, approximate: 60.8 M cycles is about 29 SP1 shards of 2^21 cycles where the small fixtures are one, so the core proof alone is about 29 x 80 s, 40 min, and the compressed recursion over 29 shard proofs adds hours; the floor from the 5090's own ratios (6x on core, 4x on compressed between block-78 and `S_p`) is 9 min core and 13 min compressed. Either way far outside every deadline | not run on the CPU (execute alone 6.9 s) | 8.3 s |
|
||||
| | compressed | not run (see the core cell) | not run | 10.9 s with the card to itself (4 Oct); 33.0 s with the miner running (5 Oct, `memminer-pc2-pv1`); 7.3 to 7.7 s per EMPTY shard with the miner running (`chain-pc2-pv1c`) |
|
||||
| | peak RSS, CPU | not run; at least the 30 GB of the small rows | | GPU peak 28,295 MiB alone, 30,039 MiB beside the miner |
|
||||
|
||||
PC 1: Windows 11, WSL2 Ubuntu 24.04 as root, 16 cores and 46,994 MB visible to the VM, the Igneum Miner app 0.3.9 mining on the RTX 5090 (89% mean utilisation through both runs, 59 to 70% minimum: the miner, untouched) and on the RX 9070 XT (not visible to nvidia-smi, mining through the app's OpenCL worker). Job `cpu-prove-pc1-small2`, 20:49:00Z to 20:59:49Z, 649 s wall including a 6-s warm build; the host built without the `cuda` feature from the hosted package `igneum-prove-wsl2-pv1b.zip`, `--mode id` the pinned pair. The first job, `cpu-prove-pc1-small` (20:44 to 20:46Z), built the host cold in 126 s and proved nothing: an apostrophe inside a single-quoted awk program ended the quote, bash refused the whole loop and the job reported exit 0. The class fix: `tools/amd-prove/check-job-bash.sh` runs `bash -n` on the bash body of a PowerShell job before it is published, and the job itself runs `bash -n` inside the distro before the run; both were shown to fire on the bad body and pass the fixed one. Host RAM in the VM: 968 MB used before, 2,351 MB after; the prover's own peak 29.5 to 30.5 GB.
|
||||
|
||||
Mac, fresh run tonight: not taken. The Mac measure lock was held from 20:31Z (a read-width `packbench` under `measure`, three build slots, then a 1,500-s proving-v1 network under `run`) and did not free inside the 10-minute window the coordinator set, so the Apple column is the 4 October rows (M5 Max, 18 cores, 64 GB, load 38 to 47, `nice -n 19`): the same host modes on the same fixture, under heavier load than PC 1 tonight. The Mac's RAM peak was not recorded on 4 October; PC 1's 30 GB says a Mac needs more than 32 GB for the CPU prover, which a 64 GB M5 Max has and a 16 or 24 GB Mac does not.
|
||||
|
||||
The deadlines a CPU proof has to fit (all in the spec and the v1 plan): the exclusive window of an assigned shard is
|
||||
10 s of DAA time (spec 7.2 item 3; after it anyone may prove and be paid first); the litepaper promises the block's
|
||||
proof "within about a minute"; the launch target is 20 to 60 s behind the tip; from proving v1 a segment nobody has
|
||||
proven in `T` = 600 DAA s (10 min) pays nothing (`docs/plans/proving-v1.md`, decisions). So a CPU shard proof is
|
||||
useful only if it lands inside 10 min and competitive only if it lands inside about a minute.
|
||||
|
||||
Reading. On PC 1 the CPU proof of the smallest shard (315 k cycles) and of the two-transaction block (631 k cycles) cost the same: 82.5 and 87.0 s core, 199.2 and 202.3 s compressed. Doubling the cycles added 4.5 s to the core proof and 3.1 s to the compressed one, so about 280 s of every CPU proof is fixed cost (the recursion that turns the core proof into the 1.27 MB compressed proof the chain carries), and no shard size removes it. Against the deadlines: 282 s a shard (core plus compressed, the client already set up, as the app's loop runs it) is 28x the 10-s assignment window, 4.7x the minute the litepaper promises, and inside the 600-s unproven deadline of v1 with 5 min to spare; but an NVIDIA card proves the same shard in 2.7 to 7.7 s, so a CPU prover only ever wins a shard that no card has taken in 10 minutes. The Mac's 83.1 and 272.3 s of 4 October have the same shape. The RAM peak of 29.5 to 30.5 GB is the second finding: the SP1 CPU prover does not fit a 16 GB machine at all, and WSL2 gives a Windows VM half the host's RAM by default, so the CPU path needs a 64 GB Windows PC or a 32 GB Linux or Mac machine. The `S_p` shard on the CPU can only be slower (the 5090 takes 6x longer at `S_p` than on block-78: 8.3 s against 1.4 s core); it was not run tonight (the scheduler kept PC 1 for the Counter ASIC 2.0 gates) and could not change the conclusion.
|
||||
|
||||
## 3. What this means for each tier (the every-number rule, CLAUDE.md 5 October 2026)
|
||||
|
||||
| Tier | Mines | Proves on the card | The 20% proving-pool share (spec 2.5) | What the software does today |
|
||||
|---|---|---|---|---|
|
||||
| AMD-only home miner, one card of 8, 12 or 16 GB (an RX 9070 XT is 16 GB), Windows or Linux | yes (OpenCL worker, `proto-opencl`; PC 1's 9070 XT mines on the devnet) | **no**: no prover exists for the card | **lost**, unless CPU proving at a small shard size becomes a tier (section 4a) | the rig installer: `prover_decision` in `packaging/linux/bin/igneum-rig-lib.sh` (branch `rig-install`) skips every non-NVIDIA card (`[[ "$vendor" == nvidia ]] \|\| continue`) and prints "proving off by default: no NVIDIA card (no CUDA prover for AMD or Intel yet)"; the app: `provedefault.rs` (branch `proving-v1`) considers NVIDIA cards only. Both already right; neither offers the CPU path |
|
||||
| Apple silicon (M-series, unified memory) | yes: the M5 Max at 26.7 MH/s (bench-log 4 October, "first hourly program swap", Metal `prepare 1` row) | **no** with SP1; RISC Zero and ICICLE have Metal, SP1 does not | **lost** today; a Metal prover behind the swappable interface would restore it (section 4b) | `provedefault.rs`: "proving stays off on Apple silicon: the M5 Max CPU took 41 to 55 s for an empty shard and minutes for a full one; Settings switches it on (CPU, slow)". Right |
|
||||
| Mixed rig (NVIDIA and AMD cards in one box) | every card | the NVIDIA cards prove for the box; the AMD cards mine | kept, earned by the NVIDIA cards | the rig installer picks the biggest NVIDIA card (`prover_decision`, `PROVER_CARD` overrides), pauses its miner under 20 GB, keeps it mining at 20 GB or more; the AMD cards get a miner unit each. **The prover unit must never select an AMD card**: it does not (the vendor filter above), and that filter is now a stated requirement, not an accident |
|
||||
| NVIDIA home miner, 8 or 12 GB | yes | no on this SP1 build (13.9 GB floor on an empty shard, `memsweep-pc2-pv1`) | lost unless the shard size moves | unchanged from `proving-v1.md` |
|
||||
| NVIDIA 16 GB | yes | prove-only, miner paused per shard | kept | unchanged |
|
||||
| NVIDIA 24 or 32 GB | yes | mines and proves (peak 16.8 GB on empty shards, 30.0 GB on a full prototype shard beside the miner) | kept | unchanged |
|
||||
| Pool user | through the pool | the pool's own NVIDIA cards prove the shards assigned to the pool's keys (approximate: the pool protocol, spec 09, does not yet say who proves) | by the pool's rules | open, spec 09 |
|
||||
|
||||
## 4. The options
|
||||
|
||||
### 4a. CPU proving at a small shard size, as a tier
|
||||
|
||||
What it is: an AMD-only or Apple machine proves shards cut at a smaller budget than `S_p` on its CPU, through the
|
||||
same host (`SP1_PROVER=cpu`; the host's `--budget` re-plan from branch `proving-v1`, commit c2544be, cuts a fixture at
|
||||
any budget). The miner keeps the card; the prover takes the CPU.
|
||||
|
||||
What the numbers say: the fixed cost kills it. 282 s a shard on a 16-core PC and 355 s on the loaded M5 Max, with 30 GB of RAM, at the smallest shard there is; the time sits in the compressed-proof recursion, not in the cycles, so cutting shards smaller does not help, and the launch deadline (20 to 60 s behind the tip) is missed by 5x. It fits only the v1 unproven deadline (600 s), which pays a CPU prover only when no card has proven the shard in 10 minutes: on a chain with one NVIDIA prover that never happens. Recommendation: **no CPU tier**. Settings may still switch the CPU prover on (it does on macOS today), and the Proving tile must then say the proof takes about five minutes and is paid only when no card proves first.
|
||||
|
||||
What it costs the chain: a block cut into more, smaller shards costs more aggregation work (the aggregator guest
|
||||
verifies one deferred proof per shard; 1.66 M cycles for four shards on the executor, bench-log 4 October; the
|
||||
chained aggregation is 9.6 to 9.7 s per block on a mining 5090, `chain-pc2-pv1c`) and more records; the assignment
|
||||
rule (8 assignees, 10 s window, spec 7.2) would need a CPU class with a longer window or the CPU provers only ever
|
||||
win the open phase. None of that is measured. Status: Designed, nothing implemented.
|
||||
|
||||
### 4b. A second prover backend behind the swappable interface
|
||||
|
||||
The seam exists: `proving/igneum-prove/host/src/proof_system.rs` (`ProofSystem` trait, `Sp1ProofSystem`,
|
||||
`StubProofSystem`), versioned per the design. The candidates:
|
||||
|
||||
| Target | Most likely backend | What exists | What adopting it costs |
|
||||
|---|---|---|---|
|
||||
| Apple silicon | RISC Zero's Metal prover (`metal` feature, shipped) | a shipped feature with kernels in the tree; ICICLE's Metal backend as the other library | a second guest program (the shard statement, `core/` is plain Rust and ports; the precompile patches for keccak and secp256k1 differ), a second pinned program id and verifying key in `elf/manifest.json`, the node's verifier for both proof formats (RISC Zero receipt and SP1 compressed proof) in `--mode verify` and the proof pool, and an aggregation problem: SP1's aggregator folds SP1 proofs by deferred verification; it cannot fold a RISC Zero receipt, so a block with shards from both families needs two aggregations or a wrapper. Approximate: weeks of a person's time, no measurement of a Metal shard time exists; RISC Zero's Groth16 wrapper for light clients does not run on Apple silicon at all |
|
||||
| AMD | nothing | sppark's limited HIP path (not in SP1's tree); ICICLE's and Jolt's Vulkan and Metal work are not AMD | no backend to adopt. The honest statement is that it lands when a zkVM ships one |
|
||||
|
||||
### 4c. The public line
|
||||
|
||||
The site says today (read 5 October 2026 from `site/litepaper.html`, `site/miner.html`, `site/index.html`): "The same
|
||||
card proves every block", "The card mines and proves", "Ember finds your GPU, makes a wallet for you and runs the
|
||||
node, the miner and the prover as one app", "Target: shard size will be set so a 12 GB card proves one shard in about
|
||||
20 seconds". Every one of those is true of an NVIDIA card with enough memory and false of an AMD or Apple card, and the
|
||||
litepaper's own rule is "If consumer GPUs cannot prove shards fast enough, Igneum says so and does not launch on promises".
|
||||
|
||||
The recommended line, for the litepaper's proving section, the miner page and the app's Proving tile (copy law):
|
||||
|
||||
> Proving needs an NVIDIA card with 16 GB or more today (20 GB to mine and prove on the same card). AMD and Apple
|
||||
> cards mine. A prover for them lands when a zkVM ships one. A CPU can prove a small shard in about five minutes with 32 GB of RAM free; the chain pays the first proof, which a card delivers in seconds, so CPU proving is for testing, not income.
|
||||
|
||||
Where the numbers come from: 16 GB and 20 GB are the measured gates of `proving-v1.md` (13.8 GB prover-alone peak,
|
||||
16.8 GB mine-and-prove peak); "when a zkVM ships one" is section 1. The line changes when the memory sweep moves the
|
||||
gates or a backend ships; it is reviewed with every prover release.
|
||||
|
||||
## 5. What this analysis does about it (the consequences, before anyone asks)
|
||||
|
||||
| Consequence | Action | Owner |
|
||||
|---|---|---|
|
||||
| An AMD-only miner loses the proving share | the CPU tier of 4a is measured here (section 2); whether it becomes a tier is a decision for the project lead on those numbers | this analysis; the project lead |
|
||||
| The rig's prover unit must select NVIDIA cards only | already true in `prover_decision`; told the rig-installer agent to keep it as a stated rule and to print the CPU-fallback line for AMD-only rigs | rig-installer agent |
|
||||
| The app's Proving tile on an AMD-only or Apple machine should say why it is off and name the CPU path | the `provedefault.rs` lines already say so for Apple; AMD-only Windows machines get "no NVIDIA card ..." | proving agent (told) |
|
||||
| The site and litepaper over-promise for AMD and Apple | the line of 4c, to land with the next site pass (copy law; `node site/build.mjs`; link-check) | site-pages owner; not changed here |
|
||||
| A Metal prover is the only non-NVIDIA path with a shipped backend | 4b names RISC Zero's Metal path and its cost; no work started | proving agent (told) |
|
||||
|
||||
## 6. Commands, jobs and sources
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| The PC 1 jobs (signed `run` jobs, PowerShell, not elevated, miners untouched, SP1_PROVER=cpu, host built without the `cuda` feature) | `tools/amd-prove/pc1-cpu-prove.ps1` (small fixtures), `pc1-cpu-prove-sp.ps1` (the `S_p` shard); published as `cpu-prove-pc1-small` (built, proved nothing: the quote bug) and `cpu-prove-pc1-small2` (the numbers) by `packaging/ota/publish-jobs.sh add --kind run --target ae432dc7 --shell powershell --timeout-minutes 60`; `cpu-prove-pc1-sp` written, not published; `check-job-bash.sh` gates the bash body of every job script here; the package `igneum-prove-wsl2-pv1b.zip` (sha256 df50dee5...), the URL and hash filled at publish time, never committed |
|
||||
| The Mac run | `tools/lock/with-lock.sh measure /usr/bin/time -l igneum-prove-host block-56-transfers-3shards.json --mode shard --shard 0` with the `proving-v1` worktree's host (pinned ids checked with `--mode id`) |
|
||||
| Results | `node tools/jobs.mjs cpu-prove-pc1-small2 --all`, `docs/bench-log.md` entry "5 October 2026, the CPU prover on PC 1 and the backend survey" |
|
||||
| Pages read | SP1: docs.succinct.xyz hardware-acceleration page, github.com/succinctlabs/sp1 (releases, `crates/sdk/src/lib.rs`, `sp1-gpu/README.md`, code search); RISC Zero: dev.risczero.com local-proving, `risc0/zkvm/Cargo.toml`; Jolt: README, book, PR #1733; OpenVM: releases; ICICLE: install_gpu_backend page, releases, README, PRs #735 and #1019; sppark README |
|
||||
418
docs/analysis/asic-resistance-history.md
Normal file
418
docs/analysis/asic-resistance-history.md
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
# ASIC resistance, 2011 to 2026: the history, the papers, the lessons, and the audit of Igneum against them
|
||||
|
||||
5 October 2026 (night), branch `asic-history`. Asked by the project lead at 20:05 UTC: "do a full on deep dive into the full history of 'asic resistance' and see if we can add or upgrade anything." Baseline for the audit: the Counter ASIC 2.0 final class decided tonight (`docs/plans/counter-asic-2-status.md` on `ca2-coord`, entries 20:16 to 22:25 UTC; `docs/analysis/chip-model-v3.md` on `ca2-mixer` 1ab8b21). Every figure about another chain cites a repo file, a paper or a dated article, or is labelled approximate. Hash-per-joule gains are computed from the cited hashrate and watt figures of the chip and of the best consumer GPU of the same year, and are approximate by construction (GPU figures vary by tuning). Research gathered by four sub-agents between 20:10 and 20:45 UTC; the fetch failures they reported are listed in section 6.
|
||||
|
||||
## 0. One page for the project lead
|
||||
|
||||
**What the history says Igneum is doing right.**
|
||||
|
||||
| # | What | The evidence |
|
||||
|---|---|---|
|
||||
| 1 | Binding the hash to random reads over a dataset larger than any on-chip cache, with the dataset growing on a schedule, and measuring the latency-bound share per card | Every compute-bound hash fell to a chip at 20x to 1,200x per joule within 16 to 37 months (rows Scrypt, X11, Blake, kHeavyHash, Blake3). The memory-bound hashes capped the chip at 1.1x to 4.8x (Ethash rows) or saw no chip at all (KawPow, Verthash, Autolykos, FishHash). RandomX's own design chose a 2 GiB dataset because a 2 GiB SRAM die was "questionable" at 7 nm in 2019 (RandomX `doc/design.md`) |
|
||||
| 2 | A random program per epoch from a VDF seed, a weak-program filter, era draws from chain state, instruction families unlocked by height, and no scheduled human fork | Monero forked four times in 20 months and lost 85% of its hashrate at each fork to chips that returned within months (CryptoNight rows). Vertcoin forked three times and was 51%-attacked after two of them. Ravencoin's X16Rv2 fork was followed by FPGA bitstreams within weeks. Grin's six-monthly tweaks worked only because the lane was scheduled to die. The only random-program hashes with no chip after five years are the ones that never needed a fork (KawPow, FiroPoW, ProgPowZ) |
|
||||
| 3 | Pricing the on-die-cache recompute chip and spending the mixer budget against it (x8: 0.92x with the 3x factor), with the model public | This is the "light-evaluation attack" Least Authority flagged on ProgPoW in 2019 and ProgPoW never fixed; Bob Rao's hardware audit put an on-die-DAG ProgPoW chip at "<< 0.1x" the energy per hash of a GPU. Kik's 2020 exploit was the same attack through a 64-bit seed. Igneum has a number against it; ProgPoW had a suggestion |
|
||||
|
||||
**What the history says Igneum is missing or under-weighting.**
|
||||
|
||||
| # | What | The evidence |
|
||||
|---|---|---|
|
||||
| 1 | The partial-store chip with a custom memory system (HBM or many narrow DRAM channels) is not in the chip model. The model prices only the f = 0 endpoint (all SRAM, recompute everything) | The only chip class that ever beat a memory-bound GPU hash did it this way: Ethash chips reached 2.1x (Linzhi, 2020), 2.9x (E9, 2022) and 4.8x (Jasminer X4, 2021) per joule through custom memory controllers and on-package memory, with no on-die dataset at all. The time-memory curve between f = 0 and f = 1 is open item O-1.6 and has never been drawn (`proto-metal/MEMHARD.md` section 3 item 2). Cuckoo Cycle's "tmto-hard" claim fell to a 50x memory cut for 2x time within two months of publication (Andersen, 2014) |
|
||||
| 2 | The item-derivation mixer has a fixed shape. That fixed shape is exactly what hands the recompute chip its 3x fixed-function factor (bare 0.31x becomes 0.92x) | RandomX made the item derivation itself a random program (SuperscalarHash: about 450 instructions generated per seed, scheduled for a superscalar core, 170-cycle latency to match DRAM), so a chip cannot hard-wire it. Igneum draws the mixer's constants per day and keeps the shape; a chip hard-wires the shape. CryptoNight-R's random math per block raised chip latency only 2.5x; the lever is in the memory path, which for the recompute chip is the mixer |
|
||||
| 3 | No clock and no detector. Chips have appeared at market caps from $18M (Radiant) and $23M (Grin) upward, and Vorick's 2018 rule was "any coin with over $20M of block reward in a year has a secret ASIC on it". Monero's secret chips held 85% of the hashrate before anyone saw them. Igneum's bounty is unfunded (D11) and the public benchmark is January 2027 | Rows Monero, Radiant, Grin, Handshake, Kadena; section 2.5 (the market-cap table); MoneroCrusher's nonce analysis (February 2019) found the chips by their share pattern, which is the detector Igneum can run from day one on the observer |
|
||||
|
||||
**The one change I would make first.** Add the partial-store HBM chip to the chip model and draw the time-memory curve before genesis (ranked addition 1, section 4.3). It is an analysis, it costs the GPU nothing, and it is the one chip class that history shows beating memory-bound GPU work. If that row comes out under 2x the x8 decision stands as measured; if it does not, the next lever is known before the vectors are frozen.
|
||||
|
||||
Everything else in this document is the evidence behind that page.
|
||||
|
||||
## 1. The history, one row per attempt
|
||||
|
||||
Columns: what the hash relied on; when it went live; the first chip that beat it (vendor, model, date, rated hashrate and watts); the gain in hash per joule against the best consumer GPU of the time (approximate, derived); how long it held (months from live to first chip); what the chain did. "None" in the chip column means no shipped chip was found in any source as of October 2026.
|
||||
|
||||
### 1.1 The rows
|
||||
|
||||
| # | Hash (chain) | Live | Relied on | First chip (vendor, model, date, rate, watts) | Gain per joule vs GPU (approximate) | Held (months) | Response | Sources |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | Scrypt (Tenebrix, Litecoin, Dogecoin) | Sep and Oct 2011 | 128 KB scratchpad, meant to fit a CPU cache and not a 2011 GPU; latency at SRAM scale | Gridseed GC3355, early 2014, 360 kH/s at 7 to 8 W; Innosilicon A2 Terminator, Apr 2014, 28 nm; KnC Titan, 2014, 300 MH/s at 850 W | Gridseed 19x, A2 68x, Titan 140x (vs Radeon 7970 at 700 kH/s, 285 W); Antminer L7 (2021) 1,100x | 27 | Embraced. Dogecoin merge-mined with Litecoin from Sep 2014 | [S1] [S2] [S3] [S4] |
|
||||
| 2 | X11 (Dash) and the chain family X13, X15, X17, Quark, Nist5, C11 | Jan 2014 | A chain of 11 SHA-3 candidates; compute only. Duffield said it was meant to replay Bitcoin's CPU to GPU to ASIC path, not to prevent it | iBeLink DM384M, Mar 2016, 384 MH/s at 715 W; Baikal Giant A900 (2016); Antminer D3, Sep 2017, 19.3 GH/s at 1,200 W | DM384M 33x, D3 1,000x (vs R9 280X at 4 MH/s, 250 W) | 26 | Embraced. Baikal's multi-algo units covered the whole family by 2017 | [S5] [S6] [S7] |
|
||||
| 3 | Ethash (Ethereum) | Jul 2015 | DAG of 1 GB growing per epoch, 128-byte random reads; memory bandwidth | Antminer E3, announced Apr 2018, 180 MH/s at 800 W, 4 GB DDR3; Innosilicon A10 Pro (2020) 500 MH/s at 950 W; Linzhi Phoenix (Dec 2020) 2,733 MH/s at about 3,000 W; Jasminer X4 (Oct 2021) 2.5 GH/s at 1,200 W; Antminer E9 (Jun 2022) 2.4 GH/s at 1,920 W | E3 1.1x to 1.6x (a tuned 1080 Ti beat it per watt); A10 Pro 1.2x vs RTX 3080; Phoenix 2.1x; X4 4.8x; E9 2.9x | 32 to the first chip; about 65 to a chip over 2x | ProgPoW (EIP-1057) debated 2018 to 2020 and shelved; PoS at the Merge, 15 Sep 2022. ASIC share of hashrate stayed small (one 2018 estimate: 3%) | [S8] [S9] [S10] [S11] [S12] [S13] |
|
||||
| 4 | Etchash (Ethereum Classic) | Nov 2020 (Thanos, ECIP-1099) | Ethash with the DAG cut to 2.5 GB to keep 3 to 4 GB cards mining; not an anti-chip change | Post-Merge Ethash chips moved over: Antminer E9 Pro (Feb 2023) 3.68 GH/s at 2,200 W; Jasminer X16-P (2023) 5.8 GH/s at 1,900 W | E9 Pro about 4x, X16-P about 7x vs RTX 3090 (approximate) | n/a | Embraced | [S14] [S15] |
|
||||
| 5 | Equihash 200,9 (Zcash, Horizen, Pirate) | Oct 2016 | Generalised birthday problem (Wagner); 144 MB in practice; memory size, with a claimed 1,000x compute penalty for halving memory | Antminer Z9 mini, announced 3 May 2018, 10 kSol/s at 300 W; Innosilicon A9 ZMaster (Jun 2018) 50 kSol/s at 620 W; Z11 (2019) 135 kSol/s at 1,418 W; Z15 (2020) 420 kSol/s at 1,510 W | Z9 mini 12x, A9 29x, Z11 34x, Z15 100x (vs GTX 1080 Ti at 700 Sol/s, 250 W) | 18 | Zcash: no fork (Zcon0 vote 45 to 19 against prioritising resistance, Jun 2018; ECC chose Sapling over resistance); PoS plan announced Nov 2021. Horizen: stayed after a 51% attack (Jun 2018). Pirate: stayed | [S16] [S17] [S18] [S19] [S20] |
|
||||
| 6 | Equihash parameter forks: Zhash 144,5 (Bitcoin Gold), ZelHash 125,4 (Flux), BeamHash I to III 150,5 (Beam), 210,9 (Aion), 192,7 (Zero) | Jul 2018 (BTG), Jun 2019 (Flux), Jan 2019 (Beam) | Larger memory per solver than 200,9 (Beam and Flux also changed the datapath against FPGA bitstreams) | None found for 144,5, 125,4 or 150,5. Vorick (May 2018) wrote that an Equihash chip able to follow any parameter fork had been designed | n/a | BTG 7 years, Flux 7 years, Beam 7 years, with small prizes (section 2.5) | BTG: forked after the May 2018 51% attack, attacked again Jan 2020. Beam: planned "one or two hard forks" then BeamHash III (Jun 2020) as the last. Flux: stayed on ZelHash | [S21] [S22] [S23] [S24] |
|
||||
| 7 | Scrypt-N, Lyra2RE, Lyra2REv2 (Vertcoin) | Jan 2014, Dec 2014, Aug 2015 | Memory-hard sponge (Lyra2) inside a hash chain; Scrypt-N grew N over time | Dayun Zig Z1, Sep 2018, 6.8 GH/s at 1,200 W (FPGA bitstreams of about 216 MH/s per board preceded it in 2018) | Z1 20x (vs GTX 1080 Ti at 59 MH/s, 207 W) | 37 (Lyra2REv2) | Lyra2REv3, Feb 2019, "to rid the network of the current generation of ASICs and FPGAs"; 51% attacks via rented hash in Oct to Dec 2018 (22 reorgs) and Dec 2019 | [S25] [S26] [S27] [S28] |
|
||||
| 8 | Verthash (Vertcoin) | Jan 2021 | A 1.2 GB file generated from the chain's own block headers; random reads; bandwidth, like Ethash | None found | n/a | 69 and counting, small prize | No fork since | [S29] [S30] |
|
||||
| 9 | X16R (Ravencoin) | Jan 2018 | 16 hashes in an order set by the previous block hash; compute, with order randomised | OW Miner OW1, Sep 2019, about 182 MH/s at 1,400 W; SKC Turing R1 claimed. Widely thought FPGA-based | OW1 1.3x (vs GTX 1080 Ti at 18 to 31 MH/s, 190 to 284 W) | 20 | X16Rv2, 1 Oct 2019 (hashrate fell 70%); FPGA bitstreams for X16Rv2 within weeks (BittWare CVP-13 at 240 MH/s) | [S31] [S32] [S33] |
|
||||
| 10 | KawPow (Ravencoin; Neoxa, Clore, Meowcoin, Neurai) | 6 May 2020 | ProgPoW 0.9.4 variant: random math per block, DAG, 16 KB cache reads; targets the GPU datapath | None found as of 2026 (no vendor lists one; one retailer listing naming an "Antminer X9" for KawPow is an error) | n/a | 77 and counting | Roadmap: "No additional future algorithm forks are envisaged" | [S34] [S35] [S36] |
|
||||
| 11 | MTP (Zcoin, now Firo) | Dec 2018 | Argon2d memory array with a Merkle tree (Biryukov and Khovratovich, "Egalitarian computing"); 4 GB; memory size | None | n/a | 34, then replaced | Dinur and Nadler broke the 2 GB instance to under 1 MB at a 170x compute penalty before launch (2017); MTP 1.2 patched it. FiroPoW (ProgPoW variant) Oct 2021 for block size and GPU fairness, not for a chip | [S37] [S38] [S39] [S40] |
|
||||
| 12 | FiroPoW (Firo) | 26 Oct 2021 | ProgPoW 0.9.4 with a per-block program | None found | n/a | 59 and counting | Nov 2025 fork cut the maximum DAG to 6.76 GB to keep 8 GB cards | [S40] [S41] |
|
||||
| 13 | Blake-256 14r (Decred) | Feb 2016 | Compute; chosen to be ASIC-friendly ("easy and fast implementation of hardware is the main design goal") | Innosilicon D9, about Apr 2018, 2.4 TH/s at 1,000 W; Obelisk DCR1 (Jun 2018); Antminer DR5 (Dec 2018) 35 TH/s at 1,610 W | D9 130x, DR5 1,200x (vs GTX 1080 Ti at 4.6 GH/s, 250 W) | 26 | Embraced by design | [S42] [S43] [S44] |
|
||||
| 14 | Blake2b (Sia) | Jun 2015 | Compute | Antminer A3, Jan 2018, 815 GH/s at 1,186 W; Obelisk SC1 (Jul 2018) 550 GH/s at 500 W; Innosilicon S11 (2018) 3.83 TH/s at 1,380 W | A3 58x, S11 230x (vs GTX 1080 Ti at 2.96 GH/s, 250 W) | 31 | Fork at block 179,000 (Oct 2018) to brick Bitmain and Innosilicon units and keep Obelisk's; Innosilicon then held about 37% of hashrate | [S45] [S46] [S47] |
|
||||
| 15 | Blake2s (Kadena) | Nov 2019 | Compute; chosen to be "GPU mineable and not immediately ASIC mineable (but for which an ASIC can be made)" | Goldshell KD2 and KD5, Mar 2021, 18 TH/s at 2,250 W; Antminer KA3 (Sep 2022) 166 TH/s at 3,154 W | KD5 195x, KA3 1,280x (vs RTX 3080 at 8.9 GH/s, 217 W) | 16 | Embraced by design | [S48] [S49] [S50] |
|
||||
| 16 | CryptoNight (Bytecoin, Monero) | Jul 2012, Apr 2014 | 2 MB scratchpad sized to a per-core L3, AES rounds, random reads; latency at SRAM scale | Secret chips from about late 2017 (85% of the hashrate vanished at the April 2018 fork); Antminer X3, announced Mar 2018, 220 kH/s at 550 W; Baikal Giant-N | X3 40x to 50x (vs Vega 64 at about 2 kH/s, 200 to 250 W, approximate) | 43 to the secret chips, 47 to the announced one | Forks: CryptoNight v7 (6 Apr 2018), v8 (18 Oct 2018), CryptoNight-R (9 Mar 2019, random math per block seeded by height, chip latency up 2.5x), RandomX (30 Nov 2019). MoneroCrusher's nonce analysis (Feb 2019) found chips at over 85% of the hashrate again, four months after v8 | [S51] [S52] [S53] [S54] [S55] |
|
||||
| 17 | RandomX (Monero; Wownero, ArQmA, Zephyr, Tari) | 30 Nov 2019 | A VM running 8 chained random programs per hash on a superscalar CPU with floating point; 2 GiB dataset derived from a 256 MiB cache by a random superscalar program (SuperscalarHash); 2 MiB scratchpad in L1, L2, L3 tiers | Antminer X5, Sep 2023, 212 kH/s at 1,350 W (RISC-V cores); Antminer X9, Jul 2026 delivery, 1 MH/s at 2,472 W; Pinecone INIBOX R1X, Mar 2026, 1.2 MH/s at 2,055 W | X5 at parity with a Ryzen 9 7950X (157 against about 200 H/J, approximate); X9 and R1X 2x to 3x over the best CPU (approximate). GPUs are 25x worse per joule than CPUs on it | 46 to parity hardware, about 75 to a 2x to 3x chip | No fork as of Oct 2026. Four audits in 2019 (Trail of Bits, X41, Kudelski, QuarksLab) found nothing critical | [S56] [S57] [S58] [S59] [S60] |
|
||||
| 18 | Cuckoo Cycle (Grin Cuckaroo lane, Aeternity, Cortex) | Jan 2019 (Grin) | Find a 42-cycle in a random graph; lean solver one bit per edge; memory latency, or bandwidth in the mean solver | None on the Cuckaroo lane (Cuckaroo29 tweaked every 6 months: Cuckarood Jul 2019, Cuckaroom Jan 2020, Cuckarooz Jul 2020) | n/a | 24, retired on schedule | The lane was built to die: 90% of reward at launch falling to 0% in Jan 2021 (HF4) | [S61] [S62] [S63] |
|
||||
| 19 | Cuckatoo31+ (Grin's chip lane) | Jan 2019 | Same, with plain bits in place of ternary counters to simplify chips; "Proof of SRAM" per Tromp | Obelisk GRN1 announced Jan 2019 and cancelled Jul 2019; Innosilicon G32 announced 2019 and never shipped; iPollo G1, Dec 2020, 36 GPS Cuckatoo32 at 2,800 W | G1 about 4x (vs RTX 3090 at about 1 GPS, 300 W, approximate) | 23, by design | Surrender by schedule. Tromp's $10,000 linear TMTO bounty was claimed in Apr 2025 (N/k bits at about k + 1,000 hashes per edge) | [S61] [S64] [S65] [S66] |
|
||||
| 20 | ProgPoW (Ethereum proposal; Bitcoin Interest, Sero, Zano as ProgPowZ, Quai) | EIP May 2018; Bitcoin Interest 2018; Quai Jan 2025 | Random math per period on a 32-register file, 16 KB cache reads, 256-byte DAG loads, keccak-f800; "saturate the GPU" | None | n/a | 8 years across its adopters | Ethereum: tentatively approved Jan 2019 and Feb 2020, petition 27 Feb 2020, left "approved" and unscheduled on 6 Mar 2020, dead. Audits: Least Authority (Sep 2019) and Bob Rao (Sep 2019). Kik's 64-bit-seed exploit (Mar 2020) patched in 0.9.4 | [S67] [S68] [S69] [S70] [S71] [S72] |
|
||||
| 21 | Autolykos v1 and v2 (Ergo) | Jul 2019; v2 Feb 2021 | v1: memory-hard with a per-miner secret key, so puzzles could not be outsourced to pools. v2: the secret removed (contract pools bypassed it); a 2 GB table that grows 5% per 51,200 blocks from block 614,400 | None | n/a | 87 and counting, small prize | v2 by EIP-0009 at block 417,792 | [S73] [S74] |
|
||||
| 22 | Octopus (Conflux) | Oct 2020 | Ethash-style DAG; the "dense matrix step" could not be verified in `conflux-rust` tonight (unverified) | None | n/a | 72 and counting | CIP-102 (Aug 2022) proposed switching to Ethash to attract post-Merge miners; dormant | [S75] [S76] |
|
||||
| 23 | kHeavyHash (Kaspa; Bugna kept it) | Nov 2021 | cSHAKE256, a 64x64 4-bit matrix multiply from the pre-PoW hash, cSHAKE256; compute, designed for optical and specialised hardware | IceRiver KS0, Jul 2023, 100 GH/s at 65 W; KS1, KS2 (Sep 2023); Antminer KS3, Aug 2023, 8.3 TH/s at 3,188 W; KS5 Pro (Mar 2024) 21 TH/s at 3,150 W | KS0 250x, KS5 Pro 1,100x (vs RTX 3090 at 910 MH/s, 150 W) | 17 to 20 | Embraced (Sompolinsky, May 2023: "an overall positive"). Hashrate went from under 100 PH/s to over 700 PH/s in months; the GPU share was negligible by late 2023 (approximate). Forks that left: Karlsen (FishHashPlus, Sep 2024), Pyrin (PyrinHash v2, Sep 2024), Spectre (CPU AstroBWTv3), Nexellia, Waglayla, Cryptix, Hoosat | [S77] [S78] [S79] [S80] [S81] |
|
||||
| 24 | NexaPow (Nexa) | 2023 | SHA-256 plus a secp256k1 Schnorr signature per attempt; framed as "useful ASICs" | DragonBall A21, Jan 2025, 3.4 GH/s at 1,800 W | 3x to 4x (vs RTX 3090 at 123 to 137 MH/s, 230 W, approximate) | 24 | None | [S82] [S83] |
|
||||
| 25 | Blake3 (Alephium; Iron Fish until 2024) | Nov 2021 | Double Blake3; compute; chosen as ASIC-friendly | Goldshell AL-BOX, 2023, 360 GH/s at 180 W; IceRiver AL0; Antminer AL1 (2024) 15.6 TH/s at 3,510 W; AL3 | AL-BOX 156x, AL1 350x (vs RTX 3090 at 2.3 GH/s, 180 W) | 22 to 24 | Alephium embraced. Iron Fish forked to FishHash (Apr 2024, FIP-3: Ethash-derived, fixed 4.6 GB dataset, 512 iterations, 128-byte mix); Karlsen adopted FishHashPlus (Sep 2024) | [S84] [S85] [S86] [S87] |
|
||||
| 26 | FishHash (Iron Fish, Karlsen) | Apr 2024 | Ethash-derived, 4.6 GB fixed dataset; bandwidth | None found | n/a | 30 and counting, small prize | None | [S87] |
|
||||
| 27 | Eaglesong (Nervos) | Nov 2019 | Compute (a new sponge) | Toddminer C1 (Feb 2020); Antminer K5, Mar 2020, 1.13 TH/s at 1,580 W; Goldshell CK5 (Mar 2021) 12 TH/s at 2,400 W | K5 70x, CK5 500x (vs RTX 3090 at about 2.1 GH/s, approximate) | 4 | Embraced | [S88] [S89] |
|
||||
| 28 | Blake2b + SHA3 (Handshake) | Feb 2020 | Compute | Goldshell HS1, Jun 2020; HS3 (Jul 2020) 2 TH/s at 2,000 W; HS5 | Over 100x (approximate) | 5 | Embraced | [S90] [S91] |
|
||||
| 29 | SHA512/256d (Radiant) | 2022 | Compute | DragonBall A11; IceRiver RX0, Sep 2024, 260 GH/s at 100 W | About 550x (vs RTX 3090 at 1.3 to 1.5 GH/s, approximate) | About 24 | None | [S92] [S93] |
|
||||
| 30 | ProgPowZ (Zano), DynexSolve (Dynex), Janushash (Warthog), XelisHash v1 and v2 (Xelis), VerusHash 2.2 (Verus) | 2019 to 2024 | ProgPoW variant; GPU "neuromorphic" useful work; a product of VerusHash and SHA256t to balance CPU and GPU; CPU and GPU balanced; AES-based CPU hash | None found for any of them | n/a | Small prizes throughout | Xelis forked to v2 (Jul 2024) for FPGA resistance | [S94] [S95] [S96] [S97] [S98] |
|
||||
| 31 | Ethash on EthereumPoW (ETHW) after the Merge | Sep 2022 | As Ethash | The Ethash chips above | About 4x (E9 Pro, X16-P vs RTX 3090, approximate) | n/a | Embraced | [S15] |
|
||||
|
||||
### 1.2 What the rows say when sorted
|
||||
|
||||
| Class of hash | Rows | Months to first chip | First-chip gain per joule | Best gain reached |
|
||||
|---|---|---|---|---|
|
||||
| Compute only (chains of hashes, Blake family, SHA-3 family, matrix multiply) | 2, 13, 14, 15, 23, 25, 27, 28, 29 | 4 to 31 (median about 24) | 33x to 250x | 500x to 1,280x |
|
||||
| Memory at SRAM scale (128 KB scrypt, 2 MB CryptoNight) | 1, 16 | 27, 43 | 19x, 40x to 50x | 1,100x (Scrypt, 2021) |
|
||||
| Memory size without a bandwidth bound (Equihash, Lyra2REv2) | 5, 7 | 18, 37 | 12x, 20x | 100x |
|
||||
| Memory bandwidth at DRAM scale (Ethash, Verthash, FishHash, Etchash) | 3, 4, 8, 26 | 32 (Ethash); none for the others | 1.1x to 1.6x | 2.9x to 4.8x |
|
||||
| Random program on a commodity datapath (RandomX, ProgPoW family, X16R's order randomisation) | 9, 10, 12, 17, 20, 30 | X16R 20 (FPGA-class, 1.3x); RandomX 46 to parity; none for ProgPoW's adopters in 8 years | 1.3x (X16R), 1x (RandomX 2023) | 2x to 3x (RandomX 2026, approximate) |
|
||||
| Graph search (Cuckoo) | 18, 19 | 23 on the chip lane; never on the tweaked lane | 4x | 4x |
|
||||
|
||||
Two caveats on the random-program rows. The prizes were small: Ravencoin, Firo and Zano never reached the market caps at which the 2018 chips appeared (section 2.5), so "no chip" is partly an economic fact. And RandomX's chips arrived once Monero's reward justified them: parity hardware at 46 months, a 2x to 3x chip at about 75 months (approximate), on a hash whose whole purpose was to make the CPU the chip.
|
||||
|
||||
## 2. The academic side
|
||||
|
||||
### 2.1 Memory-hard functions
|
||||
|
||||
| Paper | Result | What it means for Igneum |
|
||||
|---|---|---|
|
||||
| Abadi, Burrows, Manasse, Wobber, "Moderately hard, memory-bound functions", NDSS 2003 and ACM TOIT 2005 [P1]; Dwork, Goldberg, Naor, "On memory-bound functions for fighting spam", CRYPTO 2003 [P2] | The origin of the idea: CPU speed varies 100x across machines, memory latency does not, so a cost function bound by cache misses is fairer than one bound by cycles | Igneum's latency-bound rule is this argument from 2003 applied to GPUs and DRAM: the DRAM row cycle is the same physics for a chip and a card (section 2.6) |
|
||||
| Percival, "Stronger key derivation via sequential memory-hard functions", BSDCan 2009 [P3] | Defines sequential memory-hardness; ROMix is sequential memory-hard in the random-oracle model; cost measured in area-time (dollar-seconds) | The area-time measure is the one the chip model uses (equal silicon); scrypt's 2011 deployment at 128 KB ignored the paper's own scale |
|
||||
| Alwen and Serbinenko, "High parallel complexity graphs and memory-hard functions", STOC 2015 [P4] | Cumulative memory complexity (CMC) in the parallel random-oracle model; earlier sequential measures fail against parallel, amortising adversaries | A chip is a parallel, amortising adversary; any Igneum claim about the dataset must be made in a parallel model |
|
||||
| Alwen and Blocki, "Efficiently computing data-independent memory-hard functions", CRYPTO 2016 [P5]; "Towards practical attacks on Argon2i and Balloon hashing", EuroS&P 2017 [P6] | Any data-independent MHF can be computed in less than n^2 cumulative memory; Argon2i at O(n^1.75 log n), Catena and Balloon at O(n^1.67); the attacks are practical at real parameters | Igneum's addresses are data-dependent (register state), which is the right side of this result; the price is cache-timing leakage, which does not matter for a PoW |
|
||||
| Alwen, Chen, Pietrzak, Reyzin, Tessaro, "Scrypt is maximally memory-hard", EUROCRYPT 2017 [P7] | scrypt's CMC is Omega(n^2 w) in the parallel ROM, optimal, against parallel amortising adversaries | Data-dependent chains of reads are the construction with the proof; Igneum's item derivation (8 dependent cache reads) is a short chain of this kind, with no proof |
|
||||
| Biryukov, Dinu, Khovratovich, "Argon2", EuroS&P 2016 [P8]; Boneh, Corrigan-Gibbs, Schechter, "Balloon hashing", ASIACRYPT 2016 [P9] | Argon2d: a one-pass adversary can cut memory at most 3x at equal area-time; Argon2i needs over 10 passes to resist the Alwen-Blocki attack. Balloon: provable in the sequential model only; the paper says parallel ASIC attacks are outside its model | A "memory-hard" label without a stated adversary model has been wrong three times in this list (Argon2i, Balloon, Catena) |
|
||||
| Biryukov and Khovratovich, "Tradeoff cryptanalysis of memory-hard functions", ASIACRYPT 2015 [P10]; Forler, Lucks, Wenzel, "Catena", 2013 [P11]; Simplicio et al., "Lyra2", IEEE TC 2016 [P12] | The ranking trade-off attack on Lyra2, yescrypt and Argon2; Catena's proofs flawed, 25x area-time cut; designers changed their algorithms | Lyra2REv2 (row 7) carried this construction into a PoW and still fell to a chip at 20x; the cryptanalysis found the shortcut before the chip did |
|
||||
|
||||
### 2.2 Bandwidth-hard functions
|
||||
|
||||
| Paper | Result | What it means for Igneum |
|
||||
|---|---|---|
|
||||
| Ren and Devadas, "Bandwidth hard functions for ASIC resistance", TCC 2017 [P13] | Memory-hardness (CMC) bounds a chip's area advantage and says nothing about energy; energy spent on off-chip memory traffic is comparable for a chip and a CPU, so bandwidth-hardness is the lever; scrypt, Catena-BRG and Balloon are bandwidth-hard with suitable parameters; the stacked double butterfly is capacity-hard and not bandwidth-hard | The chip model's "equal silicon" row is an area argument. The energy argument is the one the Ethash chips answered: they moved the same bytes at lower energy per byte with custom memory controllers (rows 3 and 4). Igneum's hash moves 128 x 64 B = 8 KB of DRAM lines per hash on AMD and 128 x 32 B on NVIDIA; a chip with 4-byte access granularity moves 512 B for the same work. That is the bandwidth-per-watt gain the plan's last section warns about, stated in Ren-Devadas's units |
|
||||
| Blocki, Ren, Zhou, "Bandwidth-hard functions: reductions and lower bounds", CCS 2018 [P14] | Bandwidth cost in the parallel ROM equals the red-blue pebbling cost of the graph; high CMC implies high bandwidth cost; Argon2i and DRSample are maximally bandwidth-hard; a tight lower bound on scrypt's energy | The right formal target for a future proof about the item derivation, if one is ever attempted; none exists today |
|
||||
| Alwen, Blocki, Harsha, "Practical graphs for optimal side-channel resistant MHFs", CCS 2017 [P15] | DRSample: a practical graph with maximal depth-robustness | Not applicable: Igneum does not need side-channel resistance |
|
||||
|
||||
### 2.3 Asymmetric, egalitarian and graph proofs of work
|
||||
|
||||
| Paper | Result | What it means for Igneum |
|
||||
|---|---|---|
|
||||
| Biryukov and Khovratovich, "Equihash", NDSS 2016 [P16] | Wagner's generalised birthday with algorithm binding; claimed 1,000x compute for halving memory | The claim did not survive contact with a chip design: 144 MB in practice fitted the Z9's memory system (row 5) |
|
||||
| Biryukov and Khovratovich, "Egalitarian computing", USENIX Security 2016 [P17]; Dinur and Nadler, "Time-memory tradeoff attacks on the MTP proof-of-work scheme", CRYPTO 2017 [P18] | MTP: Argon2d plus a Merkle tree. Dinur-Nadler: malicious proofs with under 1 MB in place of 2 GB at a 170x compute penalty, by injecting blocks that steer Argon2d's data-dependent addressing | The attacker who controls the memory's contents controls the addresses. In Igneum the day key comes from a VDF of chain state and the cache fill is a chained block function, so no miner chooses the contents. The analogy still holds for the unreviewed mixer: a structural weakness in M_r is the shortcut this paper found in MTP |
|
||||
| Tromp, "Cuckoo Cycle", BITCOIN 2015 [P19]; Andersen, "A public review of Cuckoo Cycle", 31 Mar 2014, and "Exploiting time-memory tradeoffs in Cuckoo Cycle", 1 Aug 2014 [P20]; the linear TMTO bounty, claimed Apr 2025 [S66] | Edge trimming cut memory about 50x for about 2x time, two months after publication; Tromp adopted it. The 2025 bounty result: an N/k-bit chip must hash each edge about k + 1,000 times | A time-memory claim is a curve, and the curve was wrong by 50x until someone drew it. Igneum's curve between "store everything" and "recompute everything" has not been drawn (O-1.6) |
|
||||
| Georghiades, Flolid, Vishwanath, "HashCore", 2019 [P21] | "Inverted benchmarking": random widgets modelled on SPEC CPU workloads so the CPU is already the chip | The same idea as RandomX and ProgPoW stated generally: the hash is a benchmark of the target hardware |
|
||||
|
||||
### 2.4 Program-based proofs of work and their audits
|
||||
|
||||
| Document | What it says | What it means for Igneum |
|
||||
|---|---|---|
|
||||
| RandomX `doc/design.md` and `doc/specs.md` (tevador) [S56] [S57] | A VM so that the work is "data and code"; 8 chained programs per hash so a miner cannot filter (filtering 25% of programs at a 50% speedup yields 0.44x honest speed); SuperscalarHash of about 450 instructions with 155 multiplies, scheduled for a superscalar core at a 170-cycle latency to match DRAM, so a light-mode chip with the 256 MiB cache on die pays 760 cycles and 1,240 multiplies per item, "energy comparable to loading 64 bytes from DRAM"; a 2,080 MiB dataset because a 2 GiB SRAM die was "questionable" at 7 nm in 2019; cache-to-dataset ratio capped at 8 to keep the area-time product constant; double-precision floating point to force the whole CPU; "DRAM cannot do more than about 25 million random accesses per second per bank group" | Igneum rebuilt the idea for a GPU. The parts that carried over: the dataset above on-chip cache, the dependent item derivation, the cache-to-dataset ratio (4 at genesis, 8 at year 4 under option C). The parts that did not: per-hash programs (a GPU cannot JIT per hash and stay a GPU), floating point (vendor rounding), a random item-derivation program (Igneum's mixer has a fixed shape with drawn constants). Section 4.3 ranks the last of these |
|
||||
| Trail of Bits audit of RandomX, 2 Jul 2019 [S60]; Kudelski, X41, QuarksLab (2019) | Two low findings and 47 brittle parameters; the design affirmed | Four paid external reviews before launch, for a hash whose whole value was the resistance claim. Igneum has had none (ledger M7) |
|
||||
| EIP-1057 ProgPoW [S67]; Least Authority audit, 9 Sep 2019 [S69]; Bob Rao hardware audit, Sep 2019 [S70] | Claimed chip gain 1.1x to 1.2x. Least Authority: no issues, five suggestions, one of them the light-evaluation attack (on-the-fly DAG generation with the 16 MB cache in on-die SRAM) "may become possible within a few years" once about 100 MB of fast on-die SRAM is feasible. Rao: energy per hash is the only meaningful metric; shipping Ethash chips show about 1.6x hashrate per watt; conventional compute chips gain little on ProgPoW; integrating the DAG on die cuts data-movement energy by over 10x, so "ProgPOW ASICs with << 0.1X E/H over GPUs can be built"; an advanced-node chip is "$20M+" and "1+ year"; a 16-die split holding a 2.78 GB DAG was about $172 per board in 2019 against about $240 for a GPU board, and a monolithic die "viable around 2025" | The light-evaluation attack is Igneum's M16 recompute chip. ProgPoW left it as a suggestion; Igneum priced it and spent the mixer against it (x8). Rao's "$172 per board for a 16-die split" is the HBM-class partial-store chip in a different form, and it is the row the Igneum model lacks |
|
||||
| Kik, "ProgPoW exploit", 4 Mar 2020 [S71] | A 64-bit seed lets a chip skip memory access with a cooperating node; patched in 0.9.4 | Igneum's seed is 256 bits and the program is the epoch's; the nearest analogue is header grinding for cache locality, unmeasured (section 4.3, check 4) |
|
||||
|
||||
### 2.5 The economics of a chip
|
||||
|
||||
**What a chip costs to make** (design plus masks, by node; all figures from the cited articles, which disagree with each other by 2x and say so):
|
||||
|
||||
| Node | Mask set | Full design, IBS as quoted by Semiengineering (2018 and 2021) | Full design, other estimates | Sources |
|
||||
|---|---|---|---|---|
|
||||
| 65 nm MPW shuttle | n/a | n/a | Europractice 2025: about €51,000 minimum (9 mm^2 at €5,720 per mm^2) | [E1] |
|
||||
| 28 nm | "beyond $1M" (SemiAnalysis 2022); $1M to $3M (Silicon Analysts 2026) | $51.3M (2018); $40M (2021) | $5M to $30M total NRE for a small chip (Silicon Analysts) | [E2] [E3] [E4] |
|
||||
| 16/12 nm | n/a | $106M (2018 revision of a 2014 $310M figure) | Europractice MPW 16 nm: about €125,000 minimum | [E1] [E4] |
|
||||
| 7 nm | "beyond $10M" (SemiAnalysis); $5M to $10M (Silicon Analysts) | $297.8M (2018); $217M "mainstream" (2021); Semiengineering's own 2023 discount: about $160M | Startups shipped 7 nm chips for "$50M to $75M" all-in (SemiAnalysis); a 10 nm-class mining chip "$20M+" (Rao 2019) | [E2] [E3] [E4] [S70] |
|
||||
| 5 nm | $10M to $20M | $542.2M (2018); $416M (2021); about $280M discounted (2023) | Marvell 2023: $449M (secondary source, approximate) | [E2] [E3] [E5] |
|
||||
| 3 nm | "$40M range" | $500M to $1.5B (2018); $590M (2021) | Marvell 2023: $581M (secondary, approximate) | [E2] [E3] [E5] |
|
||||
|
||||
Miner-makers' own numbers: Bitmain's 2018 filing shows R&D of $73M in 2017 and $86M in the first half of 2018 and three failed chips at a reported combined cost of about $500M [E6]; Canaan's 2019 prospectus shows R&D of $26.5M in 2018 and "seven tape-outs" at a 100% success rate [E7]; Vorick wrote that Bitmain brought the Sia A3 to market for "less than $10 million" and took over $20M of orders within eight minutes [S47]; Obelisk's DCR1 was a 28 nm part [S43]; Taylor's 2013 survey gives $150,000 for a 130 nm and $500,000 for a 65 nm Bitcoin chip NRE in 2012 [E8].
|
||||
|
||||
**Where the 256 MiB cache lands a chip.** The Counter ASIC 2.0 analysis priced the 256 MiB SRAM mirror at 128 mm^2 and $46 per good die at N5 on the shipped-product density (`sram-mirror.md` revision 2, from AMD V-Cache 64 MB on 41 mm^2 at N7 [E9], TSMC N5 HD macro 31.8 Mib/mm^2 [E10]). The history adds the node question: a cheap chip is a 28 nm chip ($1M to $3M of masks, a $5M to $30M project), and a 28 nm bit cell is about 6x an N7 cell (approximate, from memory: TSMC 28 nm HD about 0.127 um^2 against N7's 0.027 [E10]), so 256 MiB at 28 nm is about 1,000 mm^2 of SRAM on the V-Cache density: more than a reticle. The cache forces the recompute chip onto a 7 nm or better node, which moves its project from the $5M class to the $50M class (SemiAnalysis's 7 nm startup figure). That is a stronger statement than the $46 per die, and it is the reason the cache size matters more than its per-die cost. Option C (the cache doubles with the dataset) keeps it true as nodes shrink: at the 6% per year density trend the status file cites, a 512 MiB mirror in year 4 costs more mm^2 than 256 MiB today.
|
||||
|
||||
**When chips appeared** (CoinMarketCap historical snapshots pulled by the research agent; daily issuance is arithmetic from each chain's schedule; all approximate):
|
||||
|
||||
| Chain | First public chip | Market cap then | Daily issuance then (USD) |
|
||||
|---|---|---|---|
|
||||
| Litecoin | Gridseed, Dec 2013 | $817M | $1.0M |
|
||||
| Dash | PinIdea DR-100, Aug 2017 (iBeLink 2016 widely cited, date unverified) | $2.2B | $0.6M |
|
||||
| Siacoin | Obelisk SC1 announced Jun 2017; Antminer A3 Jan 2018 | $430M; $1.5B | $0.43M; $1.1M |
|
||||
| Decred | Obelisk DCR1 Jun 2017; Innosilicon D9 Apr 2018 | $216M; $353M | $0.18M |
|
||||
| Monero | Antminer X3, Mar 2018 (secret chips from early 2017 per Vorick, unverified) | $3.3B | $0.75M |
|
||||
| Ethereum | Antminer E3, Apr 2018 | $37.4B | $7.6M |
|
||||
| Zcash | Z9 mini, May 2018 | $1.1B | $2.1M |
|
||||
| Bitcoin Gold | the same chips, May 2018 | $1.3B | $0.14M |
|
||||
| Grin | GRN1 announced Jan 2019 (cancelled); G32 Apr 2019 (never shipped); iPollo G1 Dec 2020 | $23M (Apr 2019); $23M (Dec 2020) | $0.24M; $33K |
|
||||
| Nervos | Toddminer C1, Feb 2020 | $75M | $65K to $85K |
|
||||
| Handshake | Goldshell HS1, Jun 2020 | $30M | $31K |
|
||||
| Kadena | Goldshell KD5, Mar 2021 | $42M | $21K |
|
||||
| Kaspa | IceRiver KS0, Jul 2023 | $480M | $0.42M |
|
||||
| Alephium | Goldshell AL-BOX, May 2024 | $179M | $0.1M |
|
||||
| Radiant | IceRiver RX0, Sep 2024 | $18M | $22K |
|
||||
|
||||
Sources: [E11] (the research agent's CoinMarketCap pulls, dates in the table) and the chip rows above. Reading: a compute-bound hash gets a chip at $20K to $30K of daily issuance (Radiant, Kadena, Handshake); the 2018 cluster sat at $0.15M to $2M a day. Vorick's rule from May 2018: any coin with over $20M of block reward in a year (about $55K a day) should assume a secret chip [S47]. For Igneum the clock is the day its issuance in dollars crosses about $50K; a memory-bound hash buys time against that clock (Ethash: 32 months at the largest prize in the table), and the random program buys more (section 1.2), but nothing in the table says it buys forever.
|
||||
|
||||
### 2.6 Latency as the resource
|
||||
|
||||
| Source | What it says | What it means for Igneum |
|
||||
|---|---|---|
|
||||
| Li, Reddy, Jacob, "A performance and power comparison of modern high-speed DRAM architectures", MEMSYS 2018 [L1] | Row timings from datasheets: DDR4 tRCD 14, tRAS 33, tRP 14 ns; GDDR5 tRCD 14, tRAS 28, tRP 12; HBM and HBM2 tRCD 14, tRAS 34, tRP 14. Row cycle tRC about 40 ns (GDDR5) to 48 ns (DDR4, HBM2). "The memory-latency problem does still remain" | The row cycle is the floor under every dependent random read whatever the controller; HBM does not shorten it. What HBM and a custom controller change is the number of rows that can be opened per second per watt (channels, banks, pseudo-channels), which is the throughput of random reads in flight, which is what the 9070 XT probe measured as the card's ceiling (2.4 G reads/s against the 5090's 17.5 G) |
|
||||
| Chang, CMU thesis, Dec 2017 [L2] | Over two decades DRAM capacity improved 128x, bandwidth 20x, latency 1.3x | The latency-bound rule has a long half-life; the bandwidth-per-watt lever (the Ethash chips) does not stand still |
|
||||
| NVIDIA profiling guide and the Ampere tuning deck [L3] | L1 and L2 lines are 128 bytes in four 32-byte sectors; DRAM-to-L2 transactions default to 64 bytes since Volta, configurable 32, 64 or 128 on A100 | The 5090's 32-byte sector per 4-byte read is where a custom controller gains bandwidth efficiency (8x fewer bytes), the Ren-Devadas energy lever; the 9070 XT's 64-byte line is 16x. Neither changes the row cycle |
|
||||
| RandomX `doc/design.md` [S56] | About 25 million random accesses per second per DRAM bank group; "all Dataset accesses read one CPU cache line (64 bytes) and are fully prefetched"; one program iteration tuned to "typical DRAM access latency (50-100 ns)" | The same arithmetic Igneum uses (128 dependent reads per hash against the card's random-read ceiling), from the design that held longest |
|
||||
| Condrey, "PoSME", arXiv Apr 2026 (single author, not peer reviewed) [L4] | Latency-bound pointer chasing with hash compute under 3.5% of the step cost; GPUs 14x to 19x slower than a consumer CPU | The only dedicated latency-bound PoW paper found; its GPU-vs-CPU gap is the cost RandomX pays, and the cost Igneum avoids by keeping thousands of loads in flight per card |
|
||||
|
||||
The paper that does not exist: nothing found treats cache timing as a feature; Catena and Argon2i treat it as a leak. No peer-reviewed survey of ASIC resistance as such surfaced; the nearest are Cho's 2018 multi-hash evaluation [P22] (X11-style resistance "is not strong enough"), Feng and Luo's 2020 three-processor study [P23] (GPUs dominate CryptoNight, Ethash and Cuckoo on CPU, GPU and Xeon Phi) and Yaish and Zohar's 2023 pricing of mining hardware as a bundle of options [P24].
|
||||
|
||||
## 3. The lessons
|
||||
|
||||
Each lesson is stated once, with the rows it comes from.
|
||||
|
||||
1. **Compute-bound work loses by 30x to 1,000x within two years, whatever its shape.** Chains of eleven hashes (row 2), sixteen hashes in a random order (row 9), a 64x64 matrix multiply (row 23), a new sponge (row 27), a signature per attempt (row 24): every one got a chip, the random-order chain at 1.3x by an FPGA within 20 months and the rest at 33x to 1,100x. Multiplying the number of fixed functions multiplies the chip's die, not its difficulty. For Igneum: nothing in the program's ALU work is a defence and the design already says so (ledger M1); the defence is the memory path.
|
||||
|
||||
2. **Memory at SRAM scale is compute-bound with extra steps.** Scrypt's 128 KB (row 1) and CryptoNight's 2 MB (row 16) were sized to a 2011 and a 2014 CPU cache; a chip put the same memory on die and won 19x and 40x. Igneum's answer is the 256 MiB cache growing with the dataset (option C) and the 1 GiB to 2 GiB dataset; section 2.5 shows the cache size also sets the chip's node and therefore its project cost. The hot table (layer 5) was a step back toward SRAM scale, and the measurement agreed (the honest card paid 7% to 16%, the chip paid $0.23 per MB).
|
||||
|
||||
3. **Bandwidth-bound work gets a memory chip at 2x to 5x.** Ethash held 32 months and then got chips whose whole design was the memory system: DDR3 (E3, no gain), GDDR6 (A10 Pro, 1.2x), custom controllers (Linzhi, 2.1x), on-package memory (Jasminer X4, 4.8x) (rows 3, 4). Rao's audit explains why in energy terms: the chip moves the same bytes at lower energy per byte, and a split-die design holding the DAG was already cheaper than a GPU board in 2019. Ren and Devadas give the bound: a chip's energy advantage on a bandwidth-hard function is the ratio of its memory energy per bit to the GPU's. Igneum's rule "avoid leaning on bandwidth" is right; its model has no row for this chip (section 4.3, addition 1).
|
||||
|
||||
4. **Latency-bound and random-program work held longest, and the prize was usually small.** CryptoNight's latency bound at SRAM scale held 43 months, then fell to secret chips (row 16). RandomX's at DRAM scale held 46 months to parity hardware and about 75 to a 2x to 3x chip (row 17, approximate), on the largest prize any resistant hash has carried. ProgPoW's adopters have had no chip in 8 years on small prizes (rows 10, 12, 20). Verthash, Autolykos, Octopus and FishHash have none on small prizes (rows 8, 21, 22, 26). The honest reading: the random program on a DRAM-latency bound is the strongest construction the history has, and nobody has tested it at Ethereum's prize.
|
||||
|
||||
5. **Periodic human forks fail as a defence.** Monero: four forks in 20 months; chips were back at 85% of the hashrate within four months of the v8 fork (row 16), and Vorick wrote that a chip able to survive forks at under a 5x hit had been designed. Vertcoin: three forks, two followed by rented-hash 51% attacks within weeks, because each fork reset the hashrate to a rentable size (row 7). Ravencoin: FPGA bitstreams for the new order within weeks (row 9). Sia: the fork bricked competitors' chips and left one vendor at 37% (row 14). Grin: the tweaks worked because the lane was scheduled to die (row 18). The chip's design cycle is 5 months for Bitmain (Vorick) and 13 for a startup; a fork every 6 months is a race the chip wins on the second lap, and each fork is a governance event. Igneum's draws are automatic and scheduled at genesis; that is the right side of this lesson, and section 4.3 asks whether the epoch can also be shorter than a bitstream (addition 5).
|
||||
|
||||
6. **RandomX got the target right and the derivation right, and it costs GPUs 25x.** Right: the work is "code and data" so a fixed circuit cannot serve it; chained programs defeat filtering (0.44x); the dataset is above any SRAM die; the item derivation is a random superscalar program tuned to DRAM latency so the light-mode chip pays as much energy per item as a DRAM read (section 2.4). The cost: a GPU runs the VM at 25x worse per joule than a CPU (row 17), which is the cost Igneum refuses, and the reason the program is per hour and compiled. What Igneum did not take: the random item derivation (addition 2) and four external audits before launch (addition 3).
|
||||
|
||||
7. **ProgPoW got the datapath right and lost on governance and one unpriced attack.** Right: target the commodity hardware's whole datapath (random math, register file, cache reads, DAG loads) so a chip has to be a GPU; the hardware audit agreed for compute-only chips (1.1x to 1.2x, Rao). Unpriced: the DAG on die (Least Authority suggestion 2, Rao's "<< 0.1x"), the same attack Igneum calls M16. Not adopted: two tentative approvals, a petition, bugs found late (Kik), authorship disputes and a PoS roadmap; the change needed a contentious fork on a live chain (row 20). Igneum's lesson is the one it already follows: every layer goes in before the public testnet as a genesis rule or a reserve, so no adoption vote is ever needed.
|
||||
|
||||
8. **What a "GPU-friendly" chain lost when its GPU miner fell behind: the miners, then the chain's shape.** Kaspa's hashrate rose 7x in months and its GPU share went to nothing; seven forks left to re-resist (row 23). Alephium, Nervos, Handshake, Kadena and Radiant went the same way without the forks (rows 25, 27, 28, 15, 29). Iron Fish forked away from its own Blake3 within a year of the first box (row 25). The chains kept their security budget and lost the fleet that had launched them; the fleet's hardware went to the next GPU chain. For Igneum the metric is the share of hashrate on consumer cards by model, which is what the January 2027 benchmark should report and what the observer can estimate earlier (addition 4).
|
||||
|
||||
9. **An unreviewed memory-hard construction has a shortcut until someone looks.** MTP fell from 2 GB to under 1 MB before launch (row 11); Catena's proofs were flawed; Argon2i's parameters were attackable at the IRTF's "paranoid" setting; Cuckoo's memory claim was off by 50x within two months (section 2.3). Igneum's M_r and chained cache have had no cryptanalysis (`MEMHARD.md` section 3, ledger M7); the acceptance rule is a statistical filter, not a proof. The x8 decision multiplies the mixer's weight in the chip model, which multiplies the cost of a structural weakness in it (addition 3).
|
||||
|
||||
10. **The secret chip is found by its share, and it is on the chain before the announcement.** Monero's chips held 85% before anyone saw them; a nonce-pattern analysis found them (row 16). Zcash's Z9 was "5x to 10x below" what Obelisk's own study said the hash allowed, which is Vorick's evidence that better secret chips existed (section 1.1 row 5). A detector costs an observer query; a bounty costs escrow (addition 4).
|
||||
|
||||
## 4. The audit of Igneum against the history
|
||||
|
||||
### 4.1 The first-generation layers (live in class v2 and carried into v3)
|
||||
|
||||
| Layer | Answers which failure | Does not answer | Evidence |
|
||||
|---|---|---|---|
|
||||
| Random program per epoch from a VDF seed, 12 integer families, nonce-dependent select | Fixed-function chips (lesson 1); program filtering and seed grinding (RandomX's 0.44x, spec 04's 130-to-1) | A "GPU without graphics": a programmable sequencer over 12 ops and 8 registers (ledger M1); an FPGA overlay or bitstream compiled within the hour (rows 7, 9: FPGAs were the first adversary of Lyra2REv2 and X16R); the 7.5x AMD gap is a one-vendor fleet | Rows 9, 10, 16, 17, 20 |
|
||||
| Weak-program acceptance, exact 16 loads, fresh-source rule | Per-program hash-rate spread (1.10x residual) that a chip could pick | Nothing it claims to; a chip's advantage cannot come from the program (status 20:16) | Census [I1] |
|
||||
| 1 GiB to 2 GiB dataset of 4-byte random reads, latency-bound, cache 256 MiB | SRAM-scale memory (lesson 2); the bandwidth lever at the honest card (lesson 3: 128 x 4 B keeps the 5090 at 9% of its stream bandwidth) | The partial-store chip with a custom memory system (lesson 3); the time-memory curve (O-1.6) | Rows 1, 3, 16; [P13] |
|
||||
| 8 dependent cache reads per item, fixed-shape mixer with drawn constants | The on-die recompute chip (Least Authority's light-evaluation attack), priced at 2.45x bare under v2 | The fixed shape gives the chip its 3x factor (lesson 6); no cryptanalysis (lesson 9) | [S69] [S70]; M16 |
|
||||
| Era draws from chain state (op weights, fold rotations), reserve families by height, dataset growth, no human release | Fork fatigue and fork-reset attacks (lesson 5); the chip that "survives forks at under 5x" (Vorick) is the chip that the draws are meant to outlast | The draws touch the program, not the item derivation, so they cost the recompute chip nothing (`chip-model-v3.md` section 2) | Rows 7, 16, 18 |
|
||||
| Warp-unit CPU verification without the dataset (2.1 ms per warp under x8) | Keeps the verifier light, the Equihash and Cuckoo goal | Caps every lever: the mixer budget stops at the 10 ms gate | [P16] [P19] |
|
||||
|
||||
### 4.2 The Counter ASIC 2.0 layers as decided tonight
|
||||
|
||||
| Layer | Decision (status file) | Answers | Does not answer | History's verdict |
|
||||
|---|---|---|---|---|
|
||||
| 1 Load width 4, 16, 64 B | Keep 4 B (w16 closes nothing) | Keeps the 5090 latency-bound (9% of stream) | The AMD 7.5x gap (2.4 G reads/s at every width) | Right by lesson 3; the vendor gap is a 3.0 question and a soft form of lesson 8 |
|
||||
| 2 Per-program width mix | Out (spread over 5% on every card) | n/a | n/a | Right: a per-program spread is what a chip picks (lesson 1's X16R: randomised order gave 1.3x, the shape still fixed) |
|
||||
| 3 Per-warp scratch with RMW | Out (does not move the recompute chip; 2.4x at every share; costs GPUs 12% to 48%) | n/a | n/a | Right: SRAM-tier work favours the chip (lesson 2; Rao: SRAM is the chip's weapon) |
|
||||
| 4 + 8 Era layout (stride, interleave) and per-site windows | In (era inside the class) | A hard-wired layout tuned to one era | A programmable address decoder (era-layout.md section 8 says so); costs the recompute chip nothing | Small by itself; its value is in lesson 5 (automatic change without a fork) |
|
||||
| 5 Hot table sized to GPU cache | Measured, not adopted (honest card pays g = 0.84 to 0.93; chip pays SRAM) | n/a | n/a | Right by lesson 2 |
|
||||
| 6 Cache growth | Option C: doubles with the dataset (256 MiB, 512 MiB year 4, 1 GiB year 12) | Keeps the mirror on a leading node (section 2.5) | n/a | Right; RandomX's cache-to-dataset ratio of 8 is reached at year 4 |
|
||||
| 7 INT8 matrix family | Reserve R1 = mm8, W_new 4, unlock era 4 or 90% signal | A family that a 12-op chip lacks | Matrix hardware is the most abundant custom silicon on earth; Least Authority's suggestion 5 was "watch ML hardware"; Apple's emulation costs 1.6x to 4.7x per op | Keep in reserve, order it last (addition 6) |
|
||||
| 9 Epoch length as an era parameter (10 min to 2 h) | Reserve only, design on `ca2-epoch` | The bitstream-per-epoch FPGA (rows 7, 9) | The FPGA overlay (a soft GPU) and the HBM FPGA | Rank it up (addition 5) |
|
||||
| Mixer x8 (M16's lever) | In: 0.31x bare, 0.92x with the 3x factor, verifier 2.1 ms per warp, daily build 23 to 77 ms | The on-die recompute chip (lesson 6, Least Authority's attack) | Its own fixed shape (the 3x factor stays) and its lack of review (lesson 9) | The right lever; additions 2 and 3 are what the history says to do to it next |
|
||||
|
||||
### 4.3 Ranked additions and upgrades
|
||||
|
||||
Ranked by how much the history says each would change the outcome, with the cost to GPUs and the risk. "Genesis" means a rule fixed before the public testnet; "reserve" means a named family or parameter in the genesis reserve, unlockable by height or 90% signal; "nowhere" means do not add.
|
||||
|
||||
| Rank | Addition | What it does | Evidence | Cost to GPUs | Risk | Where |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | **Price the partial-store chip and draw the time-memory curve.** A chip that stores a fraction f of the dataset in HBM or on many narrow DRAM channels, recomputes the rest from a 256 MiB on-die cache under x8, and reads with 4-byte granularity. Rows for f = 0.25, 0.5, 1 at HBM3 and at GDDR7 random-read rates, priced in energy per hash (Rao's metric) and in reads in flight per watt | The only chip class that beat a memory-bound GPU hash: Ethash's 2.1x to 4.8x came from the memory system with no on-die dataset (rows 3, 4); Rao priced a 16-die DAG holder under a GPU board in 2019; Cuckoo's curve was wrong by 50x until drawn [P20]; O-1.6 is open and `MEMHARD.md` section 3 item 2 says the curve was never drawn | None (analysis) | The row may come out over 2x, which would qualify the public claim before anyone else does | Genesis (before the vectors freeze) |
|
||||
| 2 | **A random item-derivation program per day** in place of the fixed-shape mixer: a SuperscalarHash-style generator, integer only, drawn from the day key, with its own acceptance test, compiled once a day by miners and verifiers | RandomX's reason for SuperscalarHash: a fixed derivation is hard-wired by a chip; a random one makes the light-mode chip a CPU (section 2.4). In Igneum's model the fixed shape is the 3x factor that turns 0.31x into 0.92x; removing the factor is worth more than x8 to x16 would be (x16: 0.46x with the factor by M16's table) | None per hash (the daily build is 23 to 77 ms at x8 and would roughly double); the verifier needs a per-day compiled derivation (a JIT, or a round schedule drawn from a fixed set of reviewed rounds), measured against the 10 ms gate | Cryptanalysis of random ARX programs; weak draws; a JIT in the verifier is new attack surface; the vendors must agree bit-exactly on a program they compile | Reserve (named family, unlock by height or signal) now; genesis if the verifier cost is measured under the gate before the freeze |
|
||||
| 3 | **External cryptanalysis of M_r, the chained cache and the acceptance rule before genesis**, with the x8 shape as the target | Lesson 9 (MTP, Catena, Argon2i, Cuckoo); RandomX bought four audits for $141,000 before launch [S60]; the x8 decision multiplies the mixer's weight in the chip model, so a shortcut inside the mixer is now worth 8x more to a chip | None | Finding something late moves the vectors; not finding it in time moves nothing | Genesis gate (ledger M7, raised in priority) |
|
||||
| 4 | **The clock and the detector.** (a) A share-pattern detector on the observer: per-program hash-rate spread, nonce-group patterns and per-card-model rate bands, with an alert when a population behaves like one fixed design (MoneroCrusher's method); (b) a stated trigger: the bounty escrowed and the benchmark live before daily issuance crosses about $50K (Vorick's rule), not on a calendar date | Lesson 10 (85% secret share); section 2.5's table (chips at $20K to $30K a day on compute-bound hashes); D11 (the bounty is unfunded) | None | A detector with false positives; a trigger the project lead has to fund | Not a layer; genesis-independent; do it before the public testnet |
|
||||
| 5 | **Rank layer 9 (the epoch length) up, and measure the FPGA lane**: the compile-ahead cost per card at a 10-minute epoch (the `ca2-epoch` work), plus an estimate of a soft-overlay FPGA miner with HBM (reads in flight per watt against the 5090's 17.5 G/s) | FPGAs were the first adversary of Lyra2REv2 and X16R and came back within weeks of X16Rv2 (rows 7, 9); Xelis forked for FPGA resistance (row 30); a per-hour program is a bitstream target in a way a per-hash program is not | At 10-minute epochs: 6x the compile work per card (measured on `ca2-epoch`); the VDF lead shrinks | A short epoch moves the difficulty window (spec 1.12) and the seed path | Reserve (as decided), with the measurement before the public testnet |
|
||||
| 6 | **Order the reserve by chip-unfriendliness**: families that force a full 32-bit datapath per lane first (byte permute, bit-field extract, variable shifts, popcount, select, the second shuffle form), mm8 last | Least Authority's "watch ML hardware"; int8 matrix blocks are licensable IP at every node; Apple pays 1.6x to 4.7x per emulated dot4 (status 20:38) | None at launch | None | Reserve ordering, genesis |
|
||||
| 7 | **A vendor-share metric and a 3.0 target for the AMD gap**: the share of hashrate by vendor published with the benchmark, and the line-width question kept open as the plan says | Lesson 8: a one-vendor fleet is a softer version of chip capture; Equihash's NVIDIA tilt and Ethash's balance were part of each chain's miner politics (rows 3, 5) | n/a | A width that closes the gap makes the 5090 bandwidth-bound (status 20:27) | Counter ASIC 3.0 |
|
||||
|
||||
Checks the history suggests that are not layers:
|
||||
|
||||
| Check | Why | Source |
|
||||
|---|---|---|
|
||||
| 1. Header grinding for cache locality: can a miner search the pre-PoW header hash H for 32-lane groups whose 128 loads cluster into fewer DRAM rows or cache lines, at a search cost below the gain? | Kik's ProgPoW exploit and Dinur-Nadler's MTP attack were both "the attacker steers the addresses" | [S71] [P18] |
|
||||
| 2. The chip detector's baseline: the per-program spread per card model, from the first week of the public testnet | Needed before addition 4(a) can alert | [S54] |
|
||||
| 3. The 28 nm SRAM density figure in section 2.5 (approximate, from memory) and the node-cost consequence, cited properly | It is the argument that the cache size sets the chip's project cost | [E10] |
|
||||
|
||||
Evaluated and placed nowhere, with the reason:
|
||||
|
||||
| Candidate | Verdict | Reason |
|
||||
|---|---|---|
|
||||
| Program entropy per hash (RandomX) instead of per hour | Nowhere | Per-hash programs need an interpreter or JIT on the GPU, which is the 25x GPU penalty RandomX pays (row 17) and the reason Igneum compiles per epoch. The filtering attack per-hash chaining prevents is already closed by the VDF seed and the acceptance rule. Per-hour's residual exposure is the FPGA lane, which addition 5 addresses with a shorter epoch, not with per-hash programs |
|
||||
| Superscalar dependency-chain design for the program itself | Nowhere, beyond what exists | The program's ALU work is not the defence (lesson 1); the dependency chain that matters is the 8 dependent cache reads per item and the 128 dependent loads per hash, both in place. The superscalar idea belongs in the item derivation (addition 2) |
|
||||
| Verthash's table from the blockchain; a dataset derived from chain history | Nowhere | Against the recompute chip and the partial-store chip it changes nothing: both build the table from the same public inputs the GPU does. The day key already comes from a VDF of chain state, which gives the unpredictability without a history dependency; a history dependency costs the verifier the history (Verthash needs the headers) and ties the hash to pruning (spec 10) |
|
||||
| Grin's dual PoW with a shifting split | Nowhere | It is a scheduled surrender (row 19). Igneum's automatic schedules (dataset growth, reserve unlocks, cache doubling) are the shifting split applied to one hash; a second lane would hand a chip a lane |
|
||||
| Autolykos v1's non-outsourceability | Nowhere | It stops pools, not chips, and Ergo removed it after 19 months because contract pools bypassed it (row 21); Igneum needs pools (spec 09) |
|
||||
| A per-hash VRF against nonce grinding | Nowhere | A signature per attempt is what NexaPow did and it got a 3x to 4x chip (row 24): EC arithmetic is fixed-function work. The grinding Igneum must guard is the header-locality search (check 1), which a VRF does not touch |
|
||||
| Ternary or variable-precision integer ops | Nowhere, beyond the reserve | Every family must be bit-exact on three vendors; dot4 is native on NVIDIA and AMD and emulated on Apple at 1.6x to 4.7x (status 20:38), so each precision added is paid by the weakest vendor. The reserve already holds the integer-exact candidates; adding more does not change lesson 1 |
|
||||
| Cache-timing-bound reads (ProgPoW's 16 KB cache, RandomX's L1 tier) | Nowhere | Measured out tonight at the L2 tier (layer 5) and the per-warp tier (layer 3): the honest card pays and the chip buys SRAM at $0.23 per MB. Rao's audit says the same about ProgPoW's cache reads |
|
||||
| Divergent data-dependent branches | Nowhere (already excluded) | Branches cost a GPU divergence and a chip nothing; RandomX's single predictable branch targets speculative CPUs, which Igneum does not have |
|
||||
| Floating point | Nowhere (already excluded) | Vendor rounding splits the chain (spec 1.14); RandomX could afford it because its target is one ISA family with IEEE semantics |
|
||||
|
||||
## 5. Decisions this raises for the project lead
|
||||
|
||||
| # | Decision | Recommendation |
|
||||
|---|---|---|
|
||||
| 1 | Add the partial-store chip rows to `chip-model-v3.md` and draw the time-memory curve before the public testnet | Yes, before the vectors freeze (addition 1) |
|
||||
| 2 | Name a random item-derivation program as a reserve family, and fund the verifier measurement that would move it to genesis | Reserve now; genesis if the verifier lands under the gate (addition 2) |
|
||||
| 3 | Commission the external cryptanalysis of M_r and the chained cache before genesis, with the x8 shape as the target | Yes (addition 3; ledger M7) |
|
||||
| 4 | Escrow the bounty and set its trigger to daily issuance, not to a date; build the share-pattern detector on the observer | Yes to the detector now; the escrow is the project lead's (D11) |
|
||||
| 5 | Rank the epoch-length reserve above the mm8 reserve, and measure the FPGA lane | Yes (additions 5 and 6) |
|
||||
|
||||
## 6. Sources and limits of this research
|
||||
|
||||
Research was gathered by four sub-agents between 20:10 and 20:45 UTC on 5 October 2026 and checked against the citations below. Fetch failures they reported: eprint.iacr.org PDFs sit behind a challenge page (abstract pages worked), so the Ren-Devadas energy figures, the Alwen-Blocki EuroS&P tables and the Lyra2 exponent come from abstracts; medium.com and bitcointalk.org returned 403 (Vorick's post was read through archive.sia.tech and secondary coverage; the IfDefElse posts through the Veil interview); Bitmain's prospectus PDF was blocked; the Dash iBeLink date and Octopus's "matrix step" are unverified; the 28 nm SRAM bit cell is from memory. Every hash-per-joule gain is derived from the cited rate and watt figures and is approximate.
|
||||
|
||||
Igneum sources: [I1] `docs/analysis/weak-program-census-2026-10-03.md`; `docs/plans/counter-asic-2.md` (be4b295); `docs/plans/counter-asic-2-status.md` and `docs/plans/counter-asic-2-rollout.md` (`ca2-coord`); `docs/analysis/chip-model-v3.md` (`ca2-mixer` 1ab8b21); `docs/analysis/m16-recompute-attacker-2026-10-05.md`; `docs/analysis/sram-mirror.md` revision 2 (`ca2-analysis`); `docs/plans/era-layout.md` (`ca2-era`); `docs/plans/hot-table.md` (`ca2-cache`); `docs/plans/read-width.md` (`readwidth`); `docs/spec/01-lottery-hash.md`, `04-seeds-and-vdf.md`; `docs/bench-log.md` ("the 9070 XT on the eGPU", `opencl-rdna4`); `proto-metal/MEMHARD.md`; `docs/fud-ledger.md` M1, M3, M7, M16, C2, D11.
|
||||
|
||||
History rows:
|
||||
- [S1] https://medium.com/@Linzhi/what-is-memory-hard-45a363b59dfe (Tenebrix's 2011 claim); https://en.wikipedia.org/wiki/Litecoin
|
||||
- [S2] https://jamesachambers.com/early-bitcoin-asic-miner-pictures-history/ (Gridseed); https://www.mikewesson.com/2013/04/29/mining-litecoin-on-ati-radeon-7970s/ (7970 at 700 kH/s)
|
||||
- [S3] https://www.design-reuse.com/news/34403/innosilicon-28nm-litecoin-asic-reference-miner.html (A2, Apr 2014); https://www.coindesk.com/markets/2014/05/14/kncminer-reveals-additional-titan-scrypt-asic-specs (Titan)
|
||||
- [S4] https://www.asicminervalue.com/miners/bitmain/antminer-l3-504mh ; https://cryptoage.com/en/2550-bitmain-antminer-l7-is-a-new-asic-miner-for-litecoin-and-dogecoin.html ; https://www.coindesk.com/markets/2014/09/11/dogecoin-community-celebrates-as-merge-mining-with-litecoin-begins
|
||||
- [S5] https://docs.dash.org/en/stable/docs/user/introduction/features.html ; https://www.dash.org/news/happy-birthday-darkcoin/
|
||||
- [S6] https://cryptomining-blog.com/7117-the-first-x11-mining-asic-ibelink-dm384m-asic-dash-miner/ ; https://cryptomining-blog.com/7493-power-usage-and-noise-of-the-ibelink-dm384m-x11-asic-miner/ ; https://bitcointalk.org/index.php?topic=854257.320 (R9 280X at 4 MH/s)
|
||||
- [S7] https://99bitcoins.com/guides-and-tutorials/dash-mining/antminer-d3-review/ ; https://www.cryptocompare.com/mining/asic-miner-market/baikal-giant-x10-x11-10ghs/
|
||||
- [S8] https://ethereum.org/developers/docs/consensus-mechanisms/pow/mining/mining-algorithms/dagger-hashimoto/
|
||||
- [S9] https://cryptoslate.com/bitmain-e3-asic-ethereum-miner/ (4 Apr 2018: E3 4.44 W/MH against a tuned 1080 Ti and RX 570); https://hothardware.com/news/bitmain-launches-ethereum-asic-miner-hashrate-comparable-8-gtx-1080-gpus
|
||||
- [S10] https://innosilicon.global/product/innosilicon-a10-pro-6gb-ethereum-miner-500-mh-s/ ; https://www.notebookcheck.net/The-NVIDIA-GeForce-RTX-3080-is-an-Ethereum-mining-monster-overclocked-cards-deliver-nearly-100-MH-s-double-the-Radeon-RX-5700-XT.494246.0.html
|
||||
- [S11] https://www.coindesk.com/tech/2020/12/21/linzhi-begins-rollout-of-long-awaited-ethereum-miner-phoenix ; https://www.theblock.co/post/88622/questions-new-ethash-asic-ethereum (F2Pool: 2,733 MH/s at about 3,000 W)
|
||||
- [S12] https://miningnow.com/asic-miner/jasminer-x4-2500mh-s/ ; https://www.asicminervalue.com/miners/bitmain/antminer-e9-2-4gh ; https://2miners.com/blog/asic-miners-for-ethereum-antminer-e3-vs-innosilicon-a10-eth-master-comparison/ (the 3% estimate)
|
||||
- [S13] https://eips.ethereum.org/EIPS/eip-1057 ; https://www.theblock.co/news/ecosystems/2020-02-26-ethereum-community-members-submit-dissenting-progpow-petition-57061 ; https://ethereum.org/roadmap/merge/ ; https://cointelegraph.com/news/bitmains-antminer-e3-to-continue-mining-ether-with-new-update (the E3's 4 GB limit)
|
||||
- [S14] https://ethereumclassic.org/blog/2020-11-27-thanos-hard-fork-upgrade/
|
||||
- [S15] https://www.asicminervalue.com/miners/bitmain/antminer-e9-pro-3-68gh ; https://pool.kryptex.com/device/asic/jasminer/x16-p ; https://whattomine.com/coins/151-eth-ethash/asics
|
||||
- [S16] https://eprint.iacr.org/2015/946 (Equihash); https://en.wikipedia.org/wiki/Zcash
|
||||
- [S17] https://variance.hu/2017/05/08/748-solsec-zcash-equihash-teljesitmeny-egy-gtx-1080-ti-kartyabol/ (1080 Ti at 748 Sol/s)
|
||||
- [S18] https://www.coindesk.com/markets/2018/05/03/bitmains-latest-crypto-asic-can-mine-zcash ; https://coinguides.org/innosilicon-a9-zmaster-50k-sols-equihash-asic/ ; https://support.bitmain.com/hc/en-us/articles/360012223994-Z9-Specifications ; https://www.asicminervalue.com/miners/bitmain/antminer-z11 ; https://d-central.tech/miners/antminer-z15/
|
||||
- [S19] https://github.com/ZcashFoundation/zfnd/blob/master/_posts/blog/2018-05-08-statement-on-asics.md ; https://www.coindesk.com/tech/2018/06/28/zcash-votes-against-asic-resistance-in-boon-for-big-miners ; https://electriccoin.co/blog/ecc-roadmap-calls-for-focus-on-wallet-proof-of-stake-and-interoperability/
|
||||
- [S20] https://blog.horizen.io/zencash-statement-on-double-spend-attack/ ; https://blog.horizen.io/horizen-zen-statement-on-mining-algorithm/ ; https://forum.zcashcommunity.com/t/list-of-all-coins-projects-on-equihash-asic-resistant-not-resistant/29085
|
||||
- [S21] https://en.wikipedia.org/wiki/Bitcoin_Gold ; https://gist.github.com/metalicjames/71321570a105940529e709651d0a9765
|
||||
- [S22] https://fluxofficial.medium.com/zels-custom-pow-algorithm-zelhash-activation-in-mid-june-ad3d14d72135 ; https://uploads-ssl.webflow.com/60c73eaed3399e074029d643/60fd7d883200fcde5ceb7049_ZelHash_v1.0.pdf
|
||||
- [S23] https://github.com/BeamMW/beam/wiki/BEAM-Mining ; https://docs.beam.mw/BeamHashII.pdf ; https://medium.com/minerstat/beamhashiii-beam-forks-to-a-new-algorithm-at-block-777777-dd2aeacc9e5
|
||||
- [S24] https://aion.theoan.com/blog/aion-mainnet-launch-kilimanjaro/ ; https://miningpoolstats.stream/zero
|
||||
- [S25] https://vertcoin.io/history/ ; https://www.newsbtc.com/2014/12/01/vertcoin-introduces-new-pow-algorithm-promises-asic-free-features/
|
||||
- [S26] https://cryptoage.com/en/1231-first-asic-miner-lyra2rev2-dayun-zig-z1.html ; https://www.asicminervalue.com/miners/dayun/zig-z1 ; https://whattomine.com/gpus/36-nvidia-geforce-gtx-1080-ti ; https://arxiv.org/pdf/1905.08792 (FPGA bitstreams)
|
||||
- [S27] https://cryptobriefing.com/vertcoin-vtc-51-percent-attack/ ; https://en.wikipedia.org/wiki/Vertcoin ; https://www.fxstreet.com/cryptocurrencies/news/vertcoin-cryptocurrency-network-fell-victim-to-attack-51-201912030704
|
||||
- [S28] https://github.com/vertcoin-project/vertcoin-core/releases/tag/0.14.0 (Lyra2REv3)
|
||||
- [S29] https://soundcloud.com/vertcoin-talk/vertcoin-talk-episode-24-verthash-fork-happens-january-30th-2021 ; https://crazy-mining.org/en/software/wallets/vertcoin-vtc-instructions-for-mining-on-verthash/
|
||||
- [S30] https://coincub.com/mining/how-to-mine-vertcoin-vtc/
|
||||
- [S31] https://ravencoin.org/assets/documents/X16R-Whitepaper.pdf ; https://tronblack.medium.com/ravencoin-asic-thoughts-e6c0079609e6
|
||||
- [S32] https://cryptoage.com/en/1782-asics-ow-miner-ow1-and-skc-miner-turing-r1-for-the-x16r-algorithm-exist.html ; https://en.cryptonomist.ch/2019/09/17/mining-ravencoin-hashrate/
|
||||
- [S33] https://en.cryptonomist.ch/2019/10/02/ravencoin-rvn-hard-fork/ ; https://cryptomining-blog.com/11320-ravencoin-rvn-getting-fpga-mining-support-for-the-x16rv2-algorithm/
|
||||
- [S34] https://medium.com/minerstat/kawpow-ravencoin-forks-to-a-new-algorithm-2e730cd09fb3 ; https://tronblack.medium.com/ravencoin-kawpow-expectations-a6a063df58f2
|
||||
- [S35] https://github.com/RavenProject/Ravencoin/blob/master/roadmap/README.md ; https://whattomine.com/coins/234-rvn-kawpow/gpus ; https://miningreturns.com/learn/ravencoin-mining-guide
|
||||
- [S36] https://www.neoxa.net/whitepaper/ ; https://woolypooly.com/en/blog/ravencoin-algorithm
|
||||
- [S37] https://arxiv.org/pdf/1606.03588 (Egalitarian computing, MTP)
|
||||
- [S38] https://eprint.iacr.org/2017/497 (Dinur and Nadler); http://blog.zorinaq.com/attacks-on-mtp/ ; https://firo.org/2017/07/21/mtp-audit-and-implementation-bounty.html
|
||||
- [S39] https://firo.org/2018/12/05/mtp-faq-all-you-need-to-know.html
|
||||
- [S40] https://firo.org/2021/10/01/firopow-and-instantsend-release.html
|
||||
- [S41] https://firo.org/2025/11/19/hardfork-successful-nov-2025.html
|
||||
- [S42] https://docs.decred.org/research/blake-256-hash-function/
|
||||
- [S43] https://www.asicminervalue.com/miners/innosilicon/d9-decredmaster ; https://www.asicminervalue.com/miners/obelisk/dcr1 ; https://crypto.news/hardware-companies-are-launching-dedicated-asic-miners-for-decred/ (DCR1 at 28 nm)
|
||||
- [S44] https://cryptoage.com/en/1254-bitmain-antminer-dr3-7,8-th-s-on-the-algorithm-blake-14r-decred.html ; https://medium.com/luxor/bitmain-antminer-dr5-decred-setup-guide-1c417f5f61fc
|
||||
- [S45] https://1stminingrig.com/antminer-a3-review-bitmain-surprises-everyone-with-this-new-siacoin-miner/ ; https://medium.com/obelisk-blog/obelisk-update-may-june-2018-260fce12a825 ; https://www.eastshoremining.com/tutorial-innosilicon-s11-siamaster-3-83th-siacoin-miner/
|
||||
- [S46] https://www.coindesk.com/markets/2018/10/19/sia-network-releases-hard-fork-code-to-block-crypto-mining-giants ; https://siasetup.info/learn/forks
|
||||
- [S47] Vorick, "The state of cryptocurrency mining", 13 May 2018: https://archive.sia.tech/the-state-of-cryptocurrency-mining-538004a37f9b (read through https://davidgerard.co.uk/blockchain/2018/05/14/from-sia-an-incendiary-post-on-the-state-of-cryptocurrency-mining-in-2018/ and https://zycrypto.com/asic-manufacturer-shares-important-information-for-token-creators-and-miners/); Bitmain's reply https://blog.bitmain.com/en/bitmain-sia-state-cryptocurrency-mining/
|
||||
- [S48] https://medium.com/kadena-io/kadena-public-blockchain-releases-fully-public-testnet-v3-hashing-algorithm-and-mining-api-e230a51c7b26 ; https://www.coindesk.com/markets/2019/11/04/kadena-goes-live-announces-new-token-sale-aiming-for-20-million
|
||||
- [S49] https://www.asicminervalue.com/miners/goldshell/kd5 ; https://asicmarketplace.com/product/goldshell-kd2-kadena-miner-6-4-th-s/ ; https://asicmarketplace.com/product/bitmain-antminer-ka3-kadena-miner-166th/
|
||||
- [S50] https://minerstat.com/hardware/nvidia-rtx-3080-lhr
|
||||
- [S51] https://bytecoin.org/old/whitepaper.pdf ; https://docs.getmonero.org/proof-of-work/cryptonight/ ; https://en.wikipedia.org/wiki/CryptoNote
|
||||
- [S52] https://news.8btc.com/bitmain-to-release-antminer-x3-cryptonight-asic-miner-with-220-khs-hashrate ; https://bitcointalk.org/index.php?topic=3127974.0 ; https://www.asicminervalue.com/miners/baikal/bk-n ; https://cointelegraph.com/news/bitmain-announces-new-monero-mining-antminer-x3-cryptos-devs-say-will-not-work
|
||||
- [S53] https://github.com/monero-project/monero/pull/3253 (v7); https://coinguides.org/monero-network-upgrade-v8-cnv2-beryllium-bullet/ ; https://github.com/SChernykh/CryptonightR (CN-R: chip latency up 2.5x)
|
||||
- [S54] https://medium.com/@MoneroCrusher/analysis-more-than-85-of-the-current-monero-hashrate-is-asics-and-each-machine-is-doing-128-kh-s-f39e3dca7d78 ; https://beincrypto.com/hashrate-analysis-reveals-asics-account-for-85-of-monero-mining/
|
||||
- [S55] https://github.com/tevador/randomx (30 Nov 2019)
|
||||
- [S56] https://github.com/tevador/RandomX/blob/master/doc/design.md
|
||||
- [S57] https://github.com/tevador/RandomX/blob/master/doc/specs.md
|
||||
- [S58] https://xmrig.com/benchmark/5kFcJv (3950X); https://whattomine.com/coins/101-xmr-randomx/gpus (RTX 3090 at 2.0 kH/s, 290 W)
|
||||
- [S59] https://bt-miners.com/products/bitmain-antminer-x5-monero-miner-212k-bt-miners/ ; https://bitmain.com.vc/news/bitmain-launches-antminer-x9 ; https://pineconeinibox.shop/product/pinecone-matches-inibox-r1x-xmr-edition/ ; https://github.com/xmrig/xmrig/blob/master/doc/ALGORITHMS.md ; https://rfc.tari.com/RFC-0131_Mining ; https://www.theblock.co/post/353240/tari-privacy-network-merged-monero-mining-launch-mainnet
|
||||
- [S60] https://github.com/tevador/RandomX/blob/master/README.md (the four audits and their cost); https://blog.trailofbits.com/2019/07/02/state/
|
||||
- [S61] https://github.com/mimblewimble/docs/blob/master/docs/about-grin/proof-of-work.md ; https://github.com/tromp/cuckoo/blob/master/README.md ; https://github.com/tromp/cuckoo/blob/master/doc/cuckoo.pdf
|
||||
- [S62] https://forum.grin.mw/t/mid-july-pow-hardfork-cuckaroo29-cuckarood29/5082 ; https://www.cudominer.com/grin-network-update-hard-fork-16th-january-2020/ ; https://forum.grin.mw/t/grin-v5-0-0-network-upgrade-hard-fork-4-january-2021/7895
|
||||
- [S63] https://docs.aeternity.com/aeternity-core-concepts/protocol/consensus-mechanisms/cuckoo-cycle-proof-of-work ; https://medium.com/cortexlabs/miners-can-now-test-mine-on-testnet-dolores-in-preparation-for-the-mainnet-launch-cf851d7b0146
|
||||
- [S64] https://forum.grin.mw/t/introducing-the-grn1-a-cuckatoo31-asic-from-obelisk/2519 ; https://medium.com/obelisk-blog/grn1-cancellation-announcement-54782c6e3e83
|
||||
- [S65] https://bitcointalk.org/index.php?topic=5219851.0 ; https://forum.grin.mw/t/innosilicons-grin-asics-canceled/6932 ; https://ipollo-miners.com/product/ipollo-g1/
|
||||
- [S66] https://forum.grin.mw/t/another-cuckatoo-bounty-succesfully-claimed/11739 (Apr 2025)
|
||||
- [S67] https://eips.ethereum.org/EIPS/eip-1057 ; https://github.com/ifdefelse/ProgPOW
|
||||
- [S68] https://github.com/ethereum/pm/blob/master/AllCoreDevs-EL-Meetings/Meeting%2052.md ; https://www.coindesk.com/markets/2019/01/04/ethereum-developers-give-tentative-greenlight-to-asic-blocking-code ; https://souptacular.github.io/2020-03-02-progpow-the-ethereum-community-speaks/ ; https://www.coindesk.com/tech/2020/03/06/ethereums-progpow-call-features-frustration-but-little-progress
|
||||
- [S69] https://leastauthority.com/static/publications/LeastAuthority-ProgPow-Algorithm-Final-Audit-Report.pdf (9 Sep 2019)
|
||||
- [S70] https://github.com/ethcatherders/progpow-audit ("Bob Rao - ProgPOW Hardware Audit Report Final.pdf", Sep 2019)
|
||||
- [S71] https://github.com/kik/progpow-exploit (4 Mar 2020); https://github.com/Souptacular/linzhi (Linzhi's 3x to 8x claim)
|
||||
- [S72] https://cryptoage.com/en/1238-bitcoin-interest-bci-and-new-mining-algorithm-progpow.html ; https://en.wikipedia.org/wiki/Zano_(blockchain_platform) ; https://github.com/sero-cash/serominer ; https://x.com/QuaiNetwork/status/1880037240149057759 ; https://veil-project.com/blog/2020-OhGodAGirl/
|
||||
- [S73] https://docs.ergoplatform.com/mining/autolykos/ ; https://ergoplatform.org/en/blog/2019_07_09_after_launch/ ; https://bytwork.com/en/news/khardfork-ergo-07
|
||||
- [S74] https://www.hashrate.no/gpus/3090/ERG
|
||||
- [S75] https://mining.confluxnetwork.org/ ; https://github.com/Conflux-Chain/conflux-rust
|
||||
- [S76] https://github.com/Conflux-Chain/CIPs/blob/master/CIPs/cip-102.md
|
||||
- [S77] https://www.kaspafaq.com/sp_accordion_faqs/what-is-kheavyhash/ ; https://github.com/Dagmbisrat/Kaspa-FPGA-Miner
|
||||
- [S78] https://whattomine.com/coins/352-kas-kheavyhash/gpus/49-nvidia-geforce-rtx-3090
|
||||
- [S79] https://www.cryptominerbros.com/product/iceriver-ks0-100gh-s-kas-miner/ ; https://www.asicminervalue.com/miners/iceriver/ks1 ; https://apextomining.com/product/new-bitmain-antminer-ks3-8-3t-3188w-kas-miner-asic-mining-machine-profitable-comining-soon/ ; https://www.asicminervalue.com/miners/bitmain/antminer-ks5-pro-21th
|
||||
- [S80] https://hashdag.medium.com/kaspa-where-to-part-iv-last-c68717a8d309 (May 2023); https://miningreturns.com/news/kaspa-asic-mining-era-what-you-need-to-know
|
||||
- [S81] https://x.com/karlsennetwork/status/1829148683104870534 ; https://www.hashrate.no/c/Algorithm_change_for_Karlsen_and_Pyrin ; https://github.com/spectre-project/rusty-spectre ; https://cryptix-network.org/whitepaper ; https://network.hoosat.fi/public/htn-whitepaper-2.pdf ; https://bugna.org/
|
||||
- [S82] https://spec.nexa.org/mining/NexaPOW/
|
||||
- [S83] https://www.cryptominerbros.com/product/dragonball-miner-a21-nexa-miner/ ; https://whattomine.com/coins/357-nexa-nexapow
|
||||
- [S84] https://docs.alephium.org/frequently-asked-questions/ ; https://medium.com/@alephium/one-year-of-mainnet-b7ed5d3024ee
|
||||
- [S85] https://hashrate.no/gpus/3090/ALPH
|
||||
- [S86] https://www.asicminervalue.com/miners/goldshell/al-box ; https://mineshop.eu/bitmain-antminer-al1 ; https://www.zeusbtc.com/Asic-Miner/Asic-Miner-Details.asp?ID=3719
|
||||
- [S87] https://fips.ironfish.network/fips/fip-3-memory-hard-mining-algorithm ; https://fips.ironfish.network/fips/fip-10-hardfork-1 ; https://github.com/iron-fish/fish-hash ; https://github.com/karlsen-network/fish-hash-plus
|
||||
- [S88] https://medium.com/nervosnetwork/a-decentralized-mainnet-launch-for-nervos-ckb-9cb119d15540
|
||||
- [S89] https://www.asicminervalue.com/miners/bitmain/antminer-k5-1130gh ; https://www.asicminervalue.com/miners/goldshell/ck5 ; https://2miners.com/blog/nervos-ckb-network-hashrate-increased-asics-are-the-cause/
|
||||
- [S90] https://www.coindesk.com/markets/2020/02/04/handshakes-uncensorable-web-domains-go-live-on-mainnet
|
||||
- [S91] https://www.goldshell.com/news/goldshell-announces-best-handshakehns-miner-hs1-coming-soon/ ; https://www.asicminervalue.com/miners/goldshell/hs3
|
||||
- [S92] https://radiantblockchain.org/ ; https://d-central.tech/miners/rxd-rx0/
|
||||
- [S93] https://cryptoage.com/en/2929-video-card-hashrate-based-on-the-sha512-256d-algorithm-cryptocurrency-mining-radiant-rxd.html
|
||||
- [S94] https://cryptomining-blog.com/11865-mining-zano-using-the-progpowz-proof-of-work-algorithm/
|
||||
- [S95] https://github.com/dynexcoin/DynexSolve ; https://minerstat.com/coin/DNX/faq
|
||||
- [S96] https://docs.warthog.network/janushash/ ; https://github.com/CoinFuMasterShifu/Janushash
|
||||
- [S97] https://docs.xelis.io/network-upgrades
|
||||
- [S98] https://docs.verus.io/overview/verus-proof-of-power.html
|
||||
|
||||
Papers:
|
||||
- [P1] https://www.microsoft.com/en-us/research/publication/moderately-hard-memory-bound-functions/
|
||||
- [P2] https://www.wisdom.weizmann.ac.il/~naor/PAPERS/mem.pdf
|
||||
- [P3] https://www.tarsnap.com/scrypt/scrypt.pdf
|
||||
- [P4] https://eprint.iacr.org/2014/238
|
||||
- [P5] https://eprint.iacr.org/2016/115
|
||||
- [P6] https://eprint.iacr.org/2016/759
|
||||
- [P7] https://eprint.iacr.org/2016/989
|
||||
- [P8] https://www.cryptolux.org/images/d/d0/Argon2ESP.pdf
|
||||
- [P9] https://eprint.iacr.org/2016/027
|
||||
- [P10] https://eprint.iacr.org/2015/227
|
||||
- [P11] https://eprint.iacr.org/2013/525
|
||||
- [P12] https://eprint.iacr.org/2015/136
|
||||
- [P13] https://eprint.iacr.org/2017/225
|
||||
- [P14] https://eprint.iacr.org/2018/221
|
||||
- [P15] https://eprint.iacr.org/2017/443
|
||||
- [P16] https://eprint.iacr.org/2015/946
|
||||
- [P17] https://arxiv.org/abs/1606.03588
|
||||
- [P18] https://eprint.iacr.org/2017/497
|
||||
- [P19] https://eprint.iacr.org/2014/059
|
||||
- [P20] https://da-data.blogspot.com/2014/03/a-public-review-of-cuckoo-cycle.html ; http://www.cs.cmu.edu/~dga/crypto/cuckoo/analysis.pdf
|
||||
- [P21] https://arxiv.org/abs/1902.00112
|
||||
- [P22] https://ieeexplore.ieee.org/document/8516911/
|
||||
- [P23] http://www.vldb.org/pvldb/vol13/p898-feng.pdf
|
||||
- [P24] https://arxiv.org/abs/2002.11064
|
||||
|
||||
Economics and silicon:
|
||||
- [E1] https://europractice-ic.com/schedules-prices-2025/
|
||||
- [E2] https://newsletter.semianalysis.com/p/the-dark-side-of-the-semiconductor (24 Jul 2022)
|
||||
- [E3] https://semiengineering.com/big-trouble-at-3nm/ (21 Jun 2018); https://semiengineering.com/the-increasingly-uneven-race-to-3nm-2nm/ (24 May 2021); https://semiengineering.com/what-will-that-chip-cost/ (30 Oct 2023)
|
||||
- [E4] https://siliconanalysts.com/analysis/fabless-startup-tapeout-cost-guide (1 Mar 2026, secondary)
|
||||
- [E5] https://patentpc.com/blog/chip-manufacturing-costs-in-2025-2030-how-much-does-it-cost-to-make-a-3nm-chip (secondary, approximate)
|
||||
- [E6] https://techcrunch.com/2018/09/26/bitmain-hong-kong-ipo/ ; https://bitcoinmagazine.com/markets/bitmain-ipo-prospectus-reveals-offering-may-be-gamble-investors ; https://www.chaincatcher.com/en/article/2057998
|
||||
- [E7] https://www.sec.gov/Archives/edgar/data/1780652/000119312519297270/d773846d424b4.htm
|
||||
- [E8] https://michaeltaylor.org/papers/bitcoin_taylor_cases_2013.pdf ; https://michaeltaylor.org/papers/Taylor_Bitcoin_IEEE_Computer_2017.pdf
|
||||
- [E9] https://www.tomshardware.com/news/amd-unveils-more-ryzen-3d-packaging-and-v-cache-details-at-hot-chips (Aug 2021); https://www.graphcore.ai/posts/introducing-second-generation-ipu-systems-for-ai-at-scale ; https://www.theregister.com/software/2020/09/29/groq-is-hard-to-grok-but-reckons-its-ai-chips-roq-ex-googlers-unorthodox-design-now-shipping-to-customers/1170931
|
||||
- [E10] https://newsletter.semianalysis.com/p/tsmcs-3nm-conundrum-does-it-even (21 Dec 2022); https://fuse.wikichip.org/news/7343/iedm-2022-did-we-just-witness-the-death-of-sram/ ; https://www.tomshardware.com/news/no-sram-scaling-implies-on-more-expensive-cpus-and-gpus ; https://images.nvidia.com/aem-dam/Solutions/geforce/blackwell/nvidia-rtx-blackwell-gpu-architecture.pdf (GB202: 128 MB L2 on the full die, 96 MB on the RTX 5090, 750 mm^2)
|
||||
- [E11] CoinMarketCap historical snapshots, https://coinmarketcap.com/historical/YYYYMMDD/ for the dates in the section 2.5 table (pulled 5 Oct 2026); HBM pricing https://www.trendforce.com/presscenter/news/20240506-12125.html and https://www.nextplatform.com/2024/02/27/he-who-can-pay-top-dollar-for-hbm-memory-controls-ai-training/ ; the E3's DDR3 and the A10's presumed GDDR6 from the ProgPoW FAQ https://medium.com/@ifdefelse/progpow-faq-6d2dce8b5c8b (Jan 2019, read through secondary coverage) and https://coingeek.com/memory-limitations-prompt-bitmain-antminer-e3-to-halt-etc-support/
|
||||
|
||||
Latency:
|
||||
- [L1] https://terpconnect.umd.edu/~blj/papers/memsys2018-dramsim.pdf
|
||||
- [L2] https://arxiv.org/abs/1712.08304
|
||||
- [L3] https://docs.nvidia.com/nsight-compute/ProfilingGuide/index.html ; https://developer.download.nvidia.com/video/gputechconf/gtc/2020/presentations/s21819-optimizing-applications-for-nvidia-ampere-gpu-architecture.pdf
|
||||
- [L4] https://arxiv.org/abs/2604.15751
|
||||
139
docs/analysis/base-fee-floor.md
Normal file
139
docs/analysis/base-fee-floor.md
Normal 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 | Done 5 October 2026 (`docs/plans/fee-switch-devnet.md`): `proving/igneum-prove/core/src/config.rs` mirrors the node's `fees.rs` (both tables, `FeeSchedule::at`), the shard input carries the schedule and the block's DAA score, the guest was re-pinned. The prototype fixtures stay valid (no `fees` field = prototype, never); v1 fixtures were cut from a simnet run across the switch |
|
||||
| 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 |
|
||||
85
docs/analysis/card-lifetime-2026-10-05.md
Normal file
85
docs/analysis/card-lifetime-2026-10-05.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Card lifetime per tier: how many years a card keeps mining
|
||||
|
||||
5 October 2026. Consequences review, sub-agent of the consequences reviewer. Desk arithmetic only; nothing was run.
|
||||
|
||||
## 1. Inputs
|
||||
|
||||
| Input | Source | Value used |
|
||||
|---|---|---|
|
||||
| Dataset schedule | `docs/spec/01-lottery-hash.md` 432 to 437 | 2 GiB at genesis plus 0.5 GiB a year (2,048 + 512 x years MiB) |
|
||||
| Index mapping (a) | same file, line 442 | multiply-shift: the dataset grows every day, continuous |
|
||||
| Index mapping (b) | same file, line 442 | power-of-two steps 2, 4, 8 GiB on the schedule's average: 4 GiB at year 4, 8 GiB at year 12; my extrapolation: 16 GiB at year 28, 32 GiB at year 60 |
|
||||
| Scratch per resident warp | `igneum-wt-ca2-cache/docs/plans/hot-table.md` 66 to 73 | 32 or 128 KiB per warp; 5090 = 170 SMs x 48 warps = 8,160 (approximate, from memory) |
|
||||
| Hot table, buffers | same file, 70 | hot table 32, 64 or 96 MiB (96 used here); buffers 128 MiB |
|
||||
| Cache | hot-table.md 70 (resident, 256 MiB in every total) against `igneum-wt-ca2-era/docs/plans/era-layout.md` 93 ("resident only while the day's dataset is built, then free") | both readings carried: resident = worst case, freed = best case. The two plans disagree and gate 1 should say which |
|
||||
| Cache growth | `igneum-wt-ca2-coord/docs/plans/counter-asic-2-status.md` 79 (layer 6 option C) | 256 MiB at genesis, 512 MiB at year 4, 1 GiB at year 12; by the same rule 2 GiB at year 28, 4 GiB at year 60 |
|
||||
| Budget rule | same file, 17: the whole working set stays under 6 GB on an 8 GB card | my reading: 75% of card memory at every tier. Apple: 50% of unified memory, because macOS, the display and the node share it; that share is my assumption |
|
||||
| Public claims | `site/index.html` 443, 461; `site/litepaper.html` 560; `docs/evidence.md` | quoted in Table 3. evidence.md has no row on card lifetime |
|
||||
|
||||
Card memory is binary (8 GB = 8,192 MiB). The hot-table row "An 8 GB card at 5090 occupancy" (line 73) counts 8,160 warps; a real 8 GB card has 20 to 24 SMs, so its scratch is about a tenth of that row. Resident warps below are SMs x 48 (NVIDIA Ampere and later), SM counts from memory, approximate; Apple uses the 2,048 warps the Metal harness launches (hot-table.md 66).
|
||||
|
||||
## 2. Table 1: non-dataset working set per tier (MiB)
|
||||
|
||||
Worst = scratch 128 KiB, cache resident. Columns g / y4 / y12 = genesis, year 4, year 12 (the cache doublings). Freed = era-layout's reading, constant over the years.
|
||||
|
||||
| Tier | Card assumed (SMs, approximate) | Warps | Scratch 128 KiB | Scratch 32 KiB | Cache resident, 128 KiB: g / y4 / y12 | Cache resident, 32 KiB: g / y4 / y12 | Cache freed: 128 / 32 KiB |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 4 GB | GTX 1650 (14 SMs x 32 warps, Turing) | 448 | 56 | 14 | 536 / 792 / 1,304 | 494 / 750 / 1,262 | 280 / 238 |
|
||||
| 8 GB | RTX 3050 (20) | 960 | 120 | 30 | 600 / 856 / 1,368 | 510 / 766 / 1,278 | 344 / 254 |
|
||||
| 12 GB | RTX 3060 (28) | 1,344 | 168 | 42 | 648 / 904 / 1,416 | 522 / 778 / 1,290 | 392 / 266 |
|
||||
| 16 GB | RTX 5060 Ti (36) | 1,728 | 216 | 54 | 696 / 952 / 1,464 | 534 / 790 / 1,302 | 440 / 278 |
|
||||
| 24 GB | RTX 4090 (128) | 6,144 | 768 | 192 | 1,248 / 1,504 / 2,016 | 672 / 928 / 1,440 | 992 / 416 |
|
||||
| 32 GB | RTX 5090 (170) | 8,160 | 1,020 | 255 | 1,500 / 1,756 / 2,268 | 735 / 991 / 1,503 | 1,244 / 479 |
|
||||
| Apple 8 to 64 GB | M-series, harness launch count | 2,048 | 256 | 64 | 736 / 992 / 1,504 | 544 / 800 / 1,312 | 480 / 288 |
|
||||
|
||||
Every row = scratch + 96 (hot table) + 128 (buffers) + cache (256 / 512 / 1,024 when resident). The freed reading still peaks at dataset + cache during the daily build, but that peak is smaller than the resident total whenever hashing pauses for the build, so the freed column is the steady-state set.
|
||||
|
||||
## 3. Table 2: dataset room and the year the dataset outgrows it
|
||||
|
||||
Room = usable memory (75%, Apple 50%) minus Table 1. Worst = 128 KiB scratch, cache resident (room shrinks at years 4, 12, 28, 60). Best = 32 KiB scratch, cache freed. Option (a): the year 2,048 + 512 x y exceeds the room. Option (b): the first step the room cannot hold; the card mines up to that day.
|
||||
|
||||
| Tier | Usable MiB (share) | Room at genesis, worst / best | (a) ends, years, worst / best | (b) ends, year, worst / best |
|
||||
|---|---|---|---|---|
|
||||
| 4 GB | 3,072 (75%) | 2,536 / 2,834 | 1.0 / 1.5 | 4 / 4 |
|
||||
| 8 GB | 6,144 (75%) | 5,544 / 5,890 | 6.3 / 7.5 | 12 / 12 |
|
||||
| 12 GB | 9,216 (75%) | 8,568 / 8,950 | 12.0 / 13.5 | 12 / 28 |
|
||||
| 16 GB | 12,288 (75%) | 11,592 / 12,010 | 17.1 / 19.5 | 28 / 28 |
|
||||
| 24 GB | 18,432 (75%) | 17,184 / 18,016 | 28.0 / 31.2 | 28 / 60 |
|
||||
| 32 GB | 24,576 (75%) | 23,076 / 24,097 | 37.6 / 43.1 | 60 / 60 |
|
||||
| Apple 8 GB | 4,096 (50%) | 3,360 / 3,808 | 2.6 / 3.4 | 4 / 4 |
|
||||
| Apple 16 GB | 8,192 (50%) | 7,456 / 7,904 | 10.1 / 11.4 | 12 / 12 |
|
||||
| Apple 32 GB | 16,384 (50%) | 15,648 / 16,096 | 25.1 / 27.4 | 28 / 28 |
|
||||
| Apple 64 GB | 32,768 (50%) | 32,032 / 32,480 | 55.1 / 59.4 | 60 / 60 |
|
||||
|
||||
What the table says per tier:
|
||||
|
||||
| Tier | Reading |
|
||||
|---|---|
|
||||
| 4 GB | Mines at genesis with 488 to 786 MiB spare. Under (a) it is out within 1 to 1.5 years. Under (b) it lasts to the year-4 step, as the spec's own remark says (line 442) |
|
||||
| 8 GB | 6 to 7.5 years under (a). 12 years under (b): "more than a decade" is true only under (b), and only just |
|
||||
| 12 GB | The year-12 cache doubling (1 GiB resident) is what ends it, under both options, if the cache stays resident. With the cache freed it reaches year 28 under (b). This tier's lifetime is decided by the cache residency question, not by the dataset |
|
||||
| 16 GB | 17 to 19.5 years under (a), year 28 under (b) |
|
||||
| 24 GB | Under the resident reading the year-28 cache doubling (2 GiB) ends it the same day under both options. Freed: 31 years or year 60 |
|
||||
| 32 GB | 38 to 43 years under (a), year 60 under (b). Not a constraint for any plan |
|
||||
| Apple 8 GB | 2.6 to 3.4 years under (a), year 4 under (b). The base 8 GB Apple laptop is a short-lived miner |
|
||||
| Apple 16 GB | 10 to 11.4 years under (a), year 12 under (b): the same shape as an 8 GB card |
|
||||
| Apple 32 / 64 GB | 25 years and 55 years or more. No constraint |
|
||||
|
||||
Proving is a separate budget (the 15.6 GB peak the 12 GB mine-and-prove question came from); this file covers mining only.
|
||||
|
||||
## 4. Table 3: the public sentences against the numbers
|
||||
|
||||
| Where | Sentence now | What the tables give | Proposed sentence (the project lead decides the wording) |
|
||||
|---|---|---|---|
|
||||
| `site/index.html` 443 | Memory: "2 GB, fixed" (RandomX) / "2 GB, growing" (Igneum) | 2 GiB at genesis, plus 0.5 GiB a year on average under either option | "2 GB, growing 0.5 GB a year". The row is right; the rate is the useful addition |
|
||||
| `site/index.html` 461 | "Any 4 GB card, approximate." | True at genesis (2,584 to 2,834 MiB of a 3,072 MiB budget). Ends at 1 to 1.5 years under (a), year 4 under (b) | "Any 4 GB card at launch, 8 GB for the long run, approximate." |
|
||||
| `site/litepaper.html` 560 | "a 4 GB card mines for about four years and an 8 GB card for more than a decade, approximate." | 4 GB: 1 to 1.5 years (a) or 4 years (b). 8 GB: 6.3 to 7.5 years (a) or 12 years (b). Both numbers hold only under option (b) | If gate 1 picks (b): "a 4 GB card mines until the first dataset step at year 4, an 8 GB card until the second at year 12 and a 16 GB card until year 28, approximate." If (a): "a 4 GB card mines for about a year, an 8 GB card for about seven and a 16 GB card for about seventeen, approximate." |
|
||||
| `site/litepaper.html` 560 | "12 GB or more proves full shards." | Not a lifetime claim; left as is. For mining, 12 GB lasts 12 years with the cache resident, year 28 with it freed under (b) | No change from this file |
|
||||
| `docs/evidence.md` | No row on card lifetime | The litepaper sentence is a public claim with no row | Add a row, label "designed", sources: spec 1.13.3 and this file; status moves to "tested" once a 4 GB and an 8 GB card run the genesis working set under the cap |
|
||||
|
||||
## 5. Reading
|
||||
|
||||
- The two index-mapping options end on the same day where a cache doubling takes the last of the room. With the cache resident that is the 12 GB tier at year 12 and the 24 GB tier at year 28 (Table 2, worst column). Under option (b) every tier ends on a step day by construction, so a tier ends on the same day under both options exactly when option (a) also ends it on a doubling day.
|
||||
- Everywhere else option (b) is kinder: 4 GB gains about 2.5 years, 8 GB about 5, 16 GB about 10. The site and litepaper numbers are option (b) numbers. If gate 1 picks (a), both public sentences are wrong today by 2.5 to 5 years.
|
||||
- The cache residency disagreement (hot-table.md 70 against era-layout.md 93) decides the 12 GB tier's lifetime (12 against 28 years) and nothing else. It should be settled at gate 1 beside the mapping choice.
|
||||
- The 75% rule is my generalisation of "under 6 GB on an 8 GB card"; at 4 GB it leaves 1 GB for the driver and the display, which a headless rig would not need. A 4 GB card on a bare Linux rig might hold out to year 2 under (a). Not measured.
|
||||
100
docs/analysis/chip-model-v3.md
Normal file
100
docs/analysis/chip-model-v3.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# The on-die-cache recompute chip against the RTX 5090, class v2 and class v3, everything combined
|
||||
|
||||
5 October 2026 (night), Counter ASIC 2.0, worker ca2-mixer. The model is M16's
|
||||
(`docs/analysis/m16-recompute-attacker-2026-10-05.md`): the strongest chip the plan has priced holds the whole
|
||||
cache in SRAM and derives every dataset item instead of reading it, so its cost per hash is item derivations,
|
||||
and its rate at a 50 T op/s integer budget (an RTX 5090's, approximate) is `50 T / (ops per hash)`. Nothing here
|
||||
is a measurement of a chip; every GPU figure says where it was measured. "Approximate" marks a figure from memory.
|
||||
|
||||
## 1. Inputs
|
||||
|
||||
| Input | Value | Source |
|
||||
|---|---|---|
|
||||
| Items per hash | 128 (one item per load, 128 loads per hash, median 128.00 distinct) | spec 01 sections 1.4.2 and 1.8.5; the 20,000-program census |
|
||||
| Integer operations per mixer application | about 130 | spec 01 section 1.8.4 |
|
||||
| Mixer applications per item | 9 under v2; 36 under v3 (`m = 4`, `docs/plans/mixer-x4.md`) | `memhard::Shape::mixers_per_item` |
|
||||
| Integer operations per item | 1,170 (v2); 4,680 (v3) | 9 x 130; 36 x 130 |
|
||||
| Integer operations per hash | 149,760 (v2, "150,000"); 599,040 (v3, "600,000") | 128 x the above |
|
||||
| Chip integer budget | 50 T op/s (approximate: 21,760 ALUs at about 2.4 GHz, one 32-bit operation each per clock) | M16 section 3 |
|
||||
| Fixed-function factor | 3x (approximate, from memory: 2x to 5x is the usual credit for a pipeline with no scheduling or divergence) | M16 section 3 |
|
||||
| RTX 5090, version 2 programs, measured | 136.1 MH/s (readwidth, tonight, `docs/plans/read-width.md`, pack w4 on PC 2); 139.7 MH/s (M11, 4 October, `docs/bench-log.md`) | this analysis uses tonight's 136.1 as the denominator and quotes both |
|
||||
| RTX 5090 at w16 (16-byte loads), measured | 139.8 MH/s | readwidth table, tonight (the width stays 4 B: w16 closes nothing) |
|
||||
| Cache mirror, 256 MiB, N5 headline density | 128 mm^2, $46 per good die (64 mm^2, $21 at the bit-cell lower bound) | `docs/analysis/sram-mirror.md` revision 2, sections 4 and 5 (`ca2-analysis` e6085c6) |
|
||||
| Cache mirror plus a 96 MB hot table, N5 headline | 175 mm^2, $68 | same, so a hot table costs 0.49 mm^2 and $0.23 per MB (linear, approximate) |
|
||||
| 512 MiB and 1 GiB mirrors, N5 headline | 255 mm^2 and 510 mm^2; $111 to $306 | same, section 4 (the growth rule's cache at years 4 and 12, priced at today's node) |
|
||||
| GPU-class die | 750 mm^2 (the equal-silicon comparison) | M16 section 3 |
|
||||
| CPU verifier, one M5 Max core (loaded, load average 5.6; ratios are the measurement) | v2 1.31 to 1.36 ms per unit, x4 1.92 to 1.96 (1.45x), x8 2.79 (2.1x); worst cold 1.58 / 2.04 / 2.94 ms | `docs/plans/mixer-x4.md` section 6.4, 5 October 2026 21:40 UTC |
|
||||
|
||||
## 2. The rows
|
||||
|
||||
Chip rate = 50 T op/s / ops per hash. "Bare" = chip rate / 136.1 MH/s. "With the factor" = bare x 3. "Equal
|
||||
silicon" = bare x (750 - SRAM) / 750 x 3: the SRAM takes die area the logic does not get, the M16 convention
|
||||
("minus the area the SRAM takes"). SRAM in mm^2 and dollars at the N5 headline density.
|
||||
|
||||
| Row | Mixer | Ops per hash | Chip rate at 50 T op/s | SRAM the chip holds | mm^2 / $ (N5 headline) | Bare gain against 136.1 MH/s | With the 3x factor | Equal silicon, SRAM deducted, with the factor |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| v2 as shipped (the M16 and scratch-soundness row) | x1 | 149,760 | 334 MH/s | 256 MiB | 128 / $46 | 2.45x (2.39x against 139.7) | 7.4x | 6.1x |
|
||||
| v2 at w16 (not adopted; the chip's cost is items, not bytes: unchanged) | x1 | 149,760 | 334 | 256 MiB | 128 / $46 | 2.39x against 139.8 | 7.2x | 5.9x |
|
||||
| x4 (the candidate measured beside v3; not v3) | x4 | 599,040 | 83.5 MH/s | 256 MiB | 128 / $46 | 0.61x | 1.84x | 1.53x |
|
||||
| MEASURED, NOT ADOPTED (layer 5 decided out of v3 on the PC rows, coordinator 21:40 UTC): v3 plus a 32 MiB hot table, added form (16 dataset loads and k hot loads): the honest card pays the hot loads, this chip pays SRAM only | x4 | 599,040 (a hot load is one SRAM read, no item) | 83.5 | 288 MiB | 144 / $53 | 0.66x at the Mac's g = 0.93 (126.6 MH/s); 0.70x at the 5090's g = 0.87 (118.4); the 9070 XT's g 0.84 | 1.98x (Mac g), 2.11x (5090 g) | 1.60x, 1.71x |
|
||||
| MEASURED, NOT ADOPTED: v3 plus a 64 MiB hot table, added form | x4 | 599,040 | 83.5 | 320 MiB | 160 / $61 | 0.71x at the Mac's g = 0.87 (118.4 MH/s); 0.73x at the 5090's g = 0.84 (114.3); the 9070 XT's g 0.80 | 2.12x (Mac g), 2.19x (5090 g) | 1.67x, 1.73x |
|
||||
| v3 at year 4 (cache 512 MiB under option C, dataset 4 GiB), no hot table | x4 | 599,040 | 83.5 | 512 MiB | 255 / $111 | 0.61x | 1.84x | 1.21x |
|
||||
| v3 at year 12 (cache 1 GiB, dataset 8 GiB) | x4 | 599,040 | 83.5 | 1 GiB | 510 / $306 | 0.61x | 1.84x | 0.59x |
|
||||
| **v3: mixer x8** (decided 22:05 UTC under the delegated rule: verify 2.1 ms per unit on one Mac core against the 10 ms gate, the daily 1 GiB build 23 to 77 ms on the 5090 and the 9070 XT) | x8 | 1,198,080 | 41.7 | 256 MiB | 128 / $46 | 0.31x | 0.92x | 0.76x |
|
||||
| x8 at year 4 | x8 | 1,198,080 | 41.7 | 512 MiB | 255 / $111 | 0.31x | 0.92x | 0.61x |
|
||||
|
||||
The era draws of spec 1.13.1 cost the chip nothing in this model: the mixer round count is not drawn, the op
|
||||
weights and fold rotations change the program, not the item derivation, so the chip's ops per hash stand. The
|
||||
width rule (4-byte loads kept) changes nothing either: w16 would have moved the honest denominator by 2.7% and the
|
||||
chip's cost not at all.
|
||||
|
||||
Arithmetic, row v3: 36 x 130 = 4,680 ops per item; x 128 = 599,040 per hash; 50 x 10^12 / 599,040 = 83.5 x 10^6
|
||||
hashes per second; 83.5 / 136.1 = 0.613; x 3 = 1.84; equal silicon (750 - 128) / 750 = 0.829, x 1.84 = 1.53.
|
||||
Hot table rows: 32 MiB x 0.49 mm^2 per MB = 16 mm^2, 64 MiB = 32 mm^2 (the 96 MB column of `sram-mirror.md`
|
||||
scaled linearly); (750 - 144) / 750 = 0.808 and (750 - 160) / 750 = 0.787. The honest denominator in the added
|
||||
form is the v2 rate times `g`, the card's measured ratio with the hot loads added: on the M5 Max tonight
|
||||
`g = 0.93 / 0.87 / 0.83` at 32 / 64 / 96 MiB (the cache agent, relayed by the coordinator at 21:23 UTC;
|
||||
`docs/plans/hot-table.md` carries the runs); the 5090's and the 9070 XT's `g` are the PC rows, owed, and until they
|
||||
land the row carries the Mac's `g` against the 5090's rate, which is a mixed figure and is marked so. Year 4 and 12 rows: the mirror of
|
||||
`sram-mirror.md` section 4 at N5 for 512 MiB and 1 GiB plus the 64 MiB table, at today's density (the node of
|
||||
those years is denser by about 1.8x at year 10 on the trend the same file cites; the row is a floor on the area,
|
||||
not a forecast).
|
||||
|
||||
## 3. The margin, plainly
|
||||
|
||||
The combined headline row is the mixer row alone (layer 5 is out: the added form costs the 5090 13 to 16 percent
|
||||
and the 9070 XT 16 to 20 percent against the 0.97 bar, coordinator 21:40 UTC; the width stays 4 bytes; the era
|
||||
draws and the cache growth cost this chip nothing at year 0), and class v3 is x8 (decided 22:05 UTC). The headline:
|
||||
**the on-die-cache recompute chip at 50 T op/s reaches 41.7 MH/s against the 5090's 136.1, 0.31x bare, 0.92x with
|
||||
the 3x fixed-function factor, 0.76x with the mirror's area deducted: under 1x with the factor, 0.92x, a margin of 8
|
||||
percent on the factor (a 3.3x factor reads 1.0x) and of 9 percent on the budget (55 T op/s reads 1.0x).** The x4
|
||||
candidate, measured beside it, read 1.84x and 1.53x. The hot-table rows above are kept as measured, not adopted:
|
||||
against THIS chip an added hot table is a cost to the honest card and none to the chip, so it would have moved the
|
||||
row the wrong way by the card's own `g`. The margin, plainly:
|
||||
|
||||
- the 3x fixed-function factor is approximate and from memory; at 3.3x the equal-budget row reads 2.0x;
|
||||
- the denominator is one card's measured rate on one night (136.1 against 139.7 the night before: 2.6% apart);
|
||||
- the 50 T op/s budget is approximate; a chip at 55 T op/s reads 2.0x;
|
||||
- the hot table in the added form lowers the honest denominator by whatever the hot loads cost the GPU (owed from
|
||||
the PC rows), which raises the chip's gain by the same share, 1.84x or more if the hot loads are free, higher if
|
||||
not; the hot table's only cost to this chip is 16 to 32 mm^2 of die.
|
||||
|
||||
What keeps it under 1x is the mixer, and nothing else in Counter ASIC 2.0 moves this chip (the scratch at any share
|
||||
gave 2.4x, `docs/analysis/scratch-soundness.md` section 3.4; the hot table taxes the DRAM-only chip, not this one;
|
||||
the cache growth taxes it only in die area, which is cheap at year 0 and real at year 12). The next levers, in
|
||||
order:
|
||||
|
||||
1. Mixer x16 (the next step of the same lever): 0.16x bare and 0.46x with the factor against 136.1; the verifier
|
||||
by the measured increments (+0.63 ms at x4, +1.46 at x8 on the M5 Max core: about +3.1 ms at x16, 3.7 ms per
|
||||
unit, 9 ms on a 2.5x slower laptop core, approximate) is at the edge of the 10 ms gate, so a 2019-class laptop
|
||||
core measurement (O-1.14) decides it, not this model.
|
||||
2. The hot table: adopted or not on the PC rows (`docs/plans/hot-table.md`); in the added form it costs the GPU
|
||||
7 to 17 percent on the Mac and the chip die area only, so against this chip it is a lever in the wrong
|
||||
direction and against a DRAM-only chip the first lever; if it is adopted, the mixer must carry the extra `1/g`
|
||||
(x8 at g = 0.87 reads 1.06x at the equal budget, 0.84x with the SRAM deducted).
|
||||
|
||||
## 4. What this does not settle
|
||||
|
||||
The items of M16 section 5 stand: the inline kernel on NVIDIA with a 64 MiB cache inside L2 (a measured point
|
||||
under the "50 T op/s" row) is a PC job not yet run; the time-memory curve (O-1.6) is not drawn; the mixer has had
|
||||
no cryptanalysis, and a shortcut inside it cuts the 4,680 directly; no chip has been priced beyond its SRAM.
|
||||
175
docs/analysis/int8-matrix-family.md
Normal file
175
docs/analysis/int8-matrix-family.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Layer 7: the integer matrix family (INT8 x INT8 into INT32) as a reserved instruction family, design
|
||||
|
||||
5 October 2026 (night), Counter ASIC 2.0 (`docs/plans/counter-asic-2.md`, layer 7), branch `ca2-analysis`. Design
|
||||
only: nothing here touches the generator, a vector or a node. Every figure is cited (vendor document, URL, section) or
|
||||
measured (machine, date, command) or labelled approximate.
|
||||
|
||||
## 1. The primitive per vendor, from the vendor documents
|
||||
|
||||
| Vendor, hardware | Per-lane dot4 (4 bytes x 4 bytes into a 32-bit integer) | Warp or wave matrix (int8 tiles, int32 accumulate) | Source |
|
||||
|---|---|---|---|
|
||||
| NVIDIA, sm_61 and later (Pascal on) | PTX `dp4a.atype.btype d, a, b, c` with `.atype = .btype = {.u32, .s32}`: "Four-way byte dot product which is accumulated in 32-bit result"; semantics `d = c; for i in 0..3: d += Va[i] * Vb[i]` with the bytes sign- or zero-extended by type; introduced in PTX ISA 5.0, "Requires sm_61 or higher". CUDA: `__device__ int __dp4a(int srcA, int srcB, int c)` ("Four-way signed int8 dot product with int32 accumulate") and the unsigned form, plus `char4`/`uchar4` overloads | `mma.sync` with `.u8`/`.s8` A and B and `.s32` C and D: shape `.m8n8k16` "requires sm_75 or higher" (Turing on, PTX 6.5); shapes `.m16n8k16` and `.m16n8k32` require sm_80 (Ampere on, PTX 7.0); sparse `.m16n8k32` and `.m16n8k64` with `.u8`/`.s8` also exist | PTX ISA 9.4, section 9.7.1.24 (dp4a) and 9.7.16.5 (mma), https://docs.nvidia.com/cuda/parallel-thread-execution/index.html ; CUDA Math API, integer intrinsics, https://docs.nvidia.com/cuda/cuda-math-api/cuda_math_api/group__CUDA__MATH__INTRINSIC__INT.html ; read 5 October 2026 |
|
||||
| AMD RDNA 3 (gfx11) | `v_dot4_i32_iu8` (VOP3P; each operand signed or unsigned by a per-operand bit, optional clamp) reached from clang/HIP/OpenCL C as `__builtin_amdgcn_sudot4(bool a_signed, int a, bool b_signed, int b, int acc, bool clamp)` (LLVM feature `dot8-insts`: "Has v_dot4_i32_iu8, v_dot8_i32_iu4 instructions"); `v_dot4_u32_u8` as `__builtin_amdgcn_udot4` (`dot7-insts`: "Has v_dot4_u32_u8, v_dot8_u32_u4"); `v_dot4_i32_i8` as `__builtin_amdgcn_sdot4` (`dot1-insts`: "Has v_dot4_i32_i8 and v_dot8_i32_i4"). gfx11's common feature set carries dot7, dot8, dot9, dot10 and dot12 | `V_WMMA_I32_16X16X16_IU8`: `__builtin_amdgcn_wmma_i32_16x16x16_iu8_w32` and `_w64` (feature `wmma-256b-insts`), a 16x16x16 tile per wave | LLVM `clang/include/clang/Basic/BuiltinsAMDGPU.td` (main, read 5 October 2026), lines defining `__builtin_amdgcn_sdot4`, `udot4`, `sudot4`, `wmma_i32_16x16x16_iu8_w32`; AMD GPUOpen, "How to accelerate AI applications on RDNA 3 using WMMA", https://gpuopen.com/learn/wmma_on_rdna3/ ; the RDNA 3 ISA PDF itself did not download tonight (AMD's CDN refused curl and the fetcher timed out), so the instruction names are from the compiler and GPUOpen, not quoted from the ISA guide |
|
||||
| AMD RDNA 4 (gfx12, the 9070 XT) | the same `sudot4` and `udot4` builtins: LLVM's `FeatureISAVersion12_Generic` carries `FeatureDot7Insts` and `FeatureDot8Insts` and not `FeatureDot1Insts`, so `__builtin_amdgcn_sdot4` is NOT exposed on gfx12 and `sudot4` with both operands signed is the signed form to use | `__builtin_amdgcn_wmma_i32_16x16x16_iu8_w32_gfx12` and `_w64_gfx12` (feature `wmma-128b-insts`): the int8 WMMA exists on RDNA 4 with a narrower per-lane operand (2 ints per lane for A and B against 4 on RDNA 3); AMD's RDNA 4 WMMA guide names the same builtin | LLVM `llvm/lib/Target/AMDGPU/AMDGPU.td` (`FeatureISAVersion12_Generic`) and `BuiltinsAMDGPU.td` (main, 5 October 2026); AMD GPUOpen, "WMMA guide for AMD RDNA 4 architecture GPUs, part 2", https://gpuopen.com/learn/wmma-guide-amd-rdna-4-gpus-part-2/ ; the RDNA 4 ISA guide (AMD document 70651, April 2025) was not readable tonight (docs.amd.com returned 401 to a direct fetch) |
|
||||
| AMD CDNA 3 (MI300) | the same VOP3P dot instructions (approximate: not checked in the CDNA 3 guide tonight) | `V_MFMA_I32_16X16X32_I8` and `V_MFMA_I32_32X32X16_I8` (opcodes 87 and 86 in the VOP3P-MFMA table) | AMD Instinct MI300 CDNA 3 ISA Reference Guide, 5 August 2025, https://www.amd.com/content/dam/amd/en/documents/instinct-tech-docs/instruction-set-architectures/amd-instinct-mi300-cdna3-instruction-set-architecture.pdf (downloaded and grepped 5 October 2026) |
|
||||
| AMD, OpenCL on Adrenalin (Windows) | `cl_khr_integer_dot_product` is NOT in the 24 extensions Adrenalin lists for gfx1201 (the list: fp64, the int32 and int64 atomics, 3d image writes, byte addressable store, fp16, gl sharing, amd device attribute query, amd media ops and media ops2, d3d10, d3d11 and dx9 sharing, image2d from buffer, subgroups, gl event, depth images, mipmap image and writes, amd copy buffer p2p); the platform is OpenCL 2.1 so the OpenCL C 3.0 feature macro `__opencl_c_integer_dot_product_input_4x8bit` is not expected. What IS reachable: the Adrenalin OpenCL compiler is clang (driver string `PAL,LC`), and `__builtin_amdgcn_sudot4` from OpenCL C has been shown to emit `V_DOT4_I32_IU8` on a Radeon 780M (gfx1103, RDNA 3, driver 32.0.31041, Windows 11) at 2.7x the scalar fallback (1.27 to 3.42 TMAC/s) | not from OpenCL C | Adrenalin 26.9.2 extension list, https://geeks3d.com/20260904/amd-radeon-adrenalin-26-9-x-graphics-driver/ ; the OpenCL C route: https://github.com/1640675651/CPPminer/pull/1 (third party, one machine; the 9070 XT run of this document's probe is the check) ; `cl_khr_integer_dot_product` itself: OpenCL C 3.0 specification section 6.2.2.16, `int dot(char4, char4)` and `int dot_acc_sat(char4, char4, int)`, https://registry.khronos.org/OpenCL/specs/3.0-unified/html/OpenCL_Ext.html |
|
||||
| Apple, Metal (MSL 4.1, 4 June 2026) | none. MSL has no dp4a or packed byte dot product: the built-in `dot(T x, T y)` is a geometric function on floating-point vectors (section 6.9); the integer functions of section 6.4 have no dot form. A per-lane dot4 is scalar emulation (section 4 below measures it) | `simdgroup_matrix<T, 8, 8>` exists for T = half, bfloat (Metal 3.1 and later) and float only (section 2.4: "T is half, bfloat ... or float"); no integer SIMD-group matrix. BUT Metal 4's tensor operation `mpp::tensor_ops::matmul2d` (section 7.2.1, table 7.3, "MatMul2D data type supported") lists A `char` x B `char` into C `int` (Metal 4) and `uchar` x `uchar` into `int` (Metal 4 and OS 26.4), plus `char` x `int4b_format` into `int`. So Apple has an exact int8 x int8 into int32 matrix path, on tensors (device or threadgroup memory, or a `cooperative_tensor` per SIMD-group or threadgroup), not on registers, and only through Metal 4's tensor API. Which GPU families run it in hardware (the M5's neural accelerators) against emulation is in the Metal Feature Set Tables, which the spec defers to and which were not read tonight | Metal Shading Language Specification version 4.1, https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf , sections 2.4, 6.9, 7.2.1 table 7.3 (PDF downloaded and text-extracted 5 October 2026) |
|
||||
|
||||
The Apple finding, stated plainly: the brief's expectation ("Apple has no int8 matrix or dot path") is half right. There
|
||||
is no per-lane dot4 and no integer `simdgroup_matrix`. There is an exact `char x char -> int` matmul2d in Metal 4
|
||||
(table 7.3). Two things about it are unverified tonight and matter for conformance: whether the int accumulate wraps or
|
||||
saturates (the spec text I extracted says nothing either way; a vector at the int32 edge on the M5 settles it), and the
|
||||
feature-set table (which Apple GPUs run it natively). What is settled: a generator op that is a per-lane dot4 has no
|
||||
Apple intrinsic and costs scalar emulation; a generator op that is a whole-unit 8x8x16 or 16x16x16 int8 tile has a
|
||||
native path on all three vendors (mma.sync on sm_75+, WMMA on RDNA 3 and 4, matmul2d on Metal 4), with Apple's path
|
||||
living in a different API shape (tensors, not register fragments).
|
||||
|
||||
## 2. The family's semantics as a generator op (integer only, bit-exact)
|
||||
|
||||
Two forms are proposed; the reserve can hold both as separate families or one.
|
||||
|
||||
### 2.1 `dot4`: per-lane
|
||||
|
||||
```
|
||||
dot4 dst = dst + dot4_u8(src, src2)
|
||||
where dot4_u8(a, b) = sum over i in 0..3 of byte_i(a) * byte_i(b), bytes zero-extended, sum modulo 2^32
|
||||
```
|
||||
|
||||
- Bytes are UNSIGNED. Reason, measured below: on Apple the unsigned emulation costs 1.6 ALU-chain steps per dot4
|
||||
and the signed one 4.7 (section 4), while NVIDIA (`dp4a.u32.u32`) and AMD (`V_DOT4_U32_U8`, `udot4`, `dot7-insts`)
|
||||
carry the unsigned form natively as they carry the signed one. Signed bytes buy nothing for the hash (the input is a
|
||||
pseudo-random register) and cost the vendor without the intrinsic 3x more.
|
||||
- Accumulation wraps modulo 2^32 like every other op in section 1 (spec 1.14 item 5). The maximum dot of four unsigned
|
||||
bytes is 4 x 255 x 255 = 260,100, so no single dot4 overflows; the wrap is in the running sum, which is why the
|
||||
AMD `clamp` bit and the OpenCL `dot_acc_sat` form are NOT the primitive (saturation would change results).
|
||||
- Operands: `dst`, `src`, `src2` with `src != dst` as for `mad`; `src2` may equal either.
|
||||
- Verifier: one closed-form integer expression per lane; the register-major interpreter of 1.11 adds four byte
|
||||
multiplies and adds per lane. The CPU reference in the probes (`dot4_ref` in `proto-opencl/dot4-probe.c`) is this
|
||||
expression.
|
||||
|
||||
### 2.2 `mm8`: the 32-lane unit as one int8 tile
|
||||
|
||||
The 32 lanes of a unit (spec 1.9) hold, in `src`, a 4-byte row fragment of an 8 x 16 int8 matrix A and, in `src2`,
|
||||
a 4-byte column fragment of a 16 x 8 int8 matrix B, in exactly the layout of PTX `mma.m8n8k16` with `.u8` operands
|
||||
(PTX ISA 9.4 section 9.7.16.5, "Matrix Fragments for mma.m8n8k16", the integer-type layout):
|
||||
|
||||
```
|
||||
lane l (0..31): A[row = l >> 2][k = 4 * (l & 3) .. 4 * (l & 3) + 3] = the 4 bytes of src (byte 0 = lowest k)
|
||||
B[k = 4 * (l & 3) .. +3][col = l >> 2] = the 4 bytes of src2
|
||||
result C[r][c] = sum over k in 0..15 of A[r][k] * B[k][c] (uint8 x uint8, 16 products, exact, at most 1,040,400)
|
||||
mm8 dst = dst + C[l >> 2][2 * (l & 3) + bit] bit = an immediate 0 or 1 drawn by the generator
|
||||
```
|
||||
|
||||
Every lane receives one of the two C elements its lane position owns in the PTX fragment (`c0` for bit 0, `c1` for
|
||||
bit 1), added into `dst` modulo 2^32. The whole op is a function of the unit's `src` and `src2` across all 32 lanes,
|
||||
like `shfl`, so it needs the unit to be exactly 32 logical lanes (the wave64 rule of 1.9 applies: a wave64 device
|
||||
holds two units and the local-memory path is used).
|
||||
|
||||
How each vendor runs it:
|
||||
|
||||
| Vendor | Native form | Cost per `mm8` (approximate until measured) |
|
||||
|---|---|---|
|
||||
| NVIDIA sm_75+ | one `mma.sync.aligned.m8n8k16.row.col.s32.u8.u8.s32` per warp, A and B fragments straight from `src` and `src2`, C = 0 in, `c0`/`c1` out, one add | one tensor instruction plus one add |
|
||||
| AMD RDNA 3 and 4 | one `V_WMMA_I32_16X16X16_IU8` per wave32 with the 8x16 and 16x8 tiles zero-padded into 16x16 (the WMMA fragment layout differs from PTX's: a fixed permutation of bytes between lanes, which is a few `ds_bpermute` or `v_perm` operations, bit-exact) | one WMMA plus the permutation and the pad |
|
||||
| AMD CDNA | `V_MFMA_I32_16X16X32_I8` with padding | as above |
|
||||
| Apple, Metal 4 | `matmul2d<descriptor(8, 8, 16)>` on `uchar` A and B into an `int` cooperative tensor (table 7.3 row "uchar, uchar, int", OS 26.4), the fragments written from registers into a threadgroup tensor first (32 lanes x 8 bytes = 256 bytes), the C element read back per lane | one tensor op plus two threadgroup round trips; on Apple GPUs without the neural accelerators the runtime's emulation, unmeasured |
|
||||
| Any vendor, fallback | 16 scalar byte products per lane after gathering the 16 bytes of B's column from the 4 lanes that hold them (4 shuffles or one 64-byte threadgroup exchange) | 4 shuffles plus 4 `dot4` emulations: on Apple about 4 x 1.6 = 6.4 ALU steps plus the shuffles (approximate, from the probe) |
|
||||
|
||||
Verifier: the unit evaluates C as 8 x 8 x 16 = 1,024 unsigned byte products once per `mm8` instruction and hands
|
||||
each lane its element. That is 1,024 multiply-adds per instruction per unit, against 64 x 8 = 512 instructions per
|
||||
hash: at W_new = 4 (section 3) a program carries about 2.6 `mm8` per iteration, 21 per hash, 21,500 multiply-adds per
|
||||
unit per hash, under 10 microseconds on one core (approximate), far inside the 0.63 ms the verifier already spends per
|
||||
unit (spec 1.11). The simulation stays exact because every product and sum is an integer with a defined wrap.
|
||||
|
||||
### 2.3 Which form to reserve
|
||||
|
||||
`mm8` is the one that takes matrix hardware at GPU scale from a chip (the plan's layer 7 row): a chip without tensor
|
||||
units pays 1,024 products per unit per instruction where a GPU pays one tensor instruction. `dot4` is a per-lane ALU op
|
||||
that a chip matches with four 8-bit multipliers, which is cheap silicon; it adds little chip resistance and costs Apple
|
||||
emulation. Recommendation: reserve `mm8`; keep `dot4` out, or in only as `dot4_u8` behind `mm8`.
|
||||
|
||||
## 3. The genesis reserve entry (spec text for 1.13.2)
|
||||
|
||||
Proposed wording, to go under 1.13.2 as the first named reserve family once the conformance runs of section 5 pass:
|
||||
|
||||
> Reserve family R1, `mm8` (integer matrix). Semantics: section 2.2 of `docs/analysis/int8-matrix-family.md`,
|
||||
> uint8 operands from `src` and `src2` in the m8n8k16 fragment layout, one int32 element of C per lane selected by
|
||||
> the immediate `bit`, added into `dst` modulo 2^32. Weight at unlock `W_new = 4` points, taken proportionally from the
|
||||
> ten live non-load families (the load weight and count are untouched, 1.13.1). Edge vectors, each a hand-built unit
|
||||
> run on every vendor: all bytes 0xFF in A and B (C = 16 x 65,025 = 1,040,400 everywhere); all bytes 0x80 (C = 16 x
|
||||
> 16,384 = 262,144); A all zero (C = 0); `dst` = 0xFFFFFFFF with a nonzero C (the wrap); alternating 0x00 and 0xFF by
|
||||
> lane (the fragment mapping: C[r][c] nonzero only where the row and column bytes meet); `bit` = 0 and 1 on the same
|
||||
> fragments. Unlock: at the start of era n = 4 (two years after genesis, DAA 62,208,000), or earlier by the 90%
|
||||
> signalling path of section 5.7; never by a release.
|
||||
|
||||
The era-4 choice is deliberate: two years is long enough for the three vendors' tensor paths (and Apple's Metal 4
|
||||
feature-set coverage) to be in every miner's driver, and short enough to land before any chip built against the
|
||||
launch instruction set has paid back (approximate; a chip programme is 12 to 24 months, approximate, from memory).
|
||||
|
||||
Reserve rule for a vendor that can only emulate. Spec 1.13.2 as written requires conformance on every vendor; it says
|
||||
nothing about cost. Proposed addition:
|
||||
|
||||
> A family enters the reserve when it is bit-exact on every vendor of 1.15. A vendor that reaches the result only by
|
||||
> emulation (no instruction or library path) does not block entry if the measured penalty of the emulation on that
|
||||
> vendor, on the family's own probe (a dependent chain of the op, G ops/s against the same vendor's integer ALU chain),
|
||||
> is at most 8x per op, AND the family's weight at unlock keeps the emulating vendor's hash-rate loss under 5% on the
|
||||
> memory-hard hash (the hash is latency-bound, so a per-op penalty on 4% of the instructions is a small fraction of a
|
||||
> hash whose time is 128 dependent DRAM reads; the 5% is checked on the vendor's card with the family live, not
|
||||
> computed). A family whose emulation exceeds either bound stays out of the reserve until the vendor ships a path.
|
||||
|
||||
With tonight's numbers: on Apple the unsigned `dot4` emulation is 1.6x per op (inside the bound); the signed one 4.7x
|
||||
(inside, but why pay it); `mm8` through Metal 4's matmul2d is a path, not an emulation, and its cost is owed.
|
||||
|
||||
## 4. dp4a-class throughput, measured so far
|
||||
|
||||
Probe: a dependent chain of one dot4 per step per lane (`acc = dot4(x, y, acc); x = x * K + acc; y = rotl(y, 7) ^
|
||||
(acc + s)`), 1,048,576 lanes x 4,096 steps, best of 3, device time, bit-exact against a CPU reference on two lanes
|
||||
per run, beside the ALU chain of the 9070 XT bench-log entry (`x = x * K + rotl(y, 7); y = (y ^ x) + s`, 5 ops per
|
||||
step counted). Sources: `proto-metal/dot4-probe.swift` (Metal), `proto-opencl/dot4-probe.c` (OpenCL: scalar, the
|
||||
`cl_khr_integer_dot_product` `dot`, AMD `__builtin_amdgcn_sudot4`, NVIDIA inline PTX `dp4a.s32.s32`),
|
||||
`proto-cuda/dot4-probe.cu` (CUDA `__dp4a` and the scalar emulation, for a PC with nvcc). Each OpenCL variant is built on
|
||||
its own and a variant the platform cannot compile prints a "build failed" row.
|
||||
|
||||
| Card, API | Date, command | ALU chain, G steps/s | dot4 signed emulation, G dot4/s | dot4 unsigned emulation, G dot4/s | dot4 intrinsic, G dot4/s | Penalty of the emulation per op (ALU steps per dot4) | ok (bit-exact) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Apple M5 Max, Metal | 5 October 2026 20:0x UTC, `with-lock.sh measure ./dot4-probe` (swiftc -O), GPU start-to-end time | 879.8 (4.882 ms) | 188.2 (22.82 ms) | 548.2 (7.834 ms) | none exists | signed 4.7x, unsigned 1.6x | yes, all three kernels |
|
||||
| Apple M5 Max, Apple OpenCL 1.2 | same, `with-lock.sh measure ./dot4-probe-cl --device 0`, event time | 871.5 (4.928 ms) | 188.4 (22.80 ms) | not in this probe | `cl_khr_integer_dot_product` not listed; the kernel using `dot(char4, char4)` compiled anyway and ran at 846 G/s but MISMATCHED the CPU reference on every lane checked (Apple's `dot` on char4 is not an integer dot; the extension macro must gate it) | signed 4.6x | alu and dot4e yes; dot4_khr NO |
|
||||
| RTX 5090 (PC 1, ae432dc7), NVIDIA OpenCL 3.0 CUDA, driver 617.14 | 5 October 2026 20:29 UTC, job `run-dot4-20261005` (`relay/playbooks/dot4-probe.ps1`, both mining cards switched off in the app first, restored after; `node tools/jobs.mjs run-dot4-20261005`), event time | 8,753.5 (0.491 ms) | 1,239.1 (3.466 ms) | not in the OpenCL probe | 7,453.6 (0.576 ms) via inline PTX `dp4a.s32.s32` | emulation 7.1x; the intrinsic 1.17x (the chain is one dp4a plus 3 ops against 5 ops), so the emulation costs 6.0x the instruction | yes, all three |
|
||||
| RX 9070 XT (PC 1, gfx1201, eGPU), AMD OpenCL 2.0 AMD-APP 3683.0 (PAL,LC) | same job, same time | 701.4 (6.124 ms) | 480.8 (8.932 ms) | not in the OpenCL probe | 664.3 (6.465 ms) via `__builtin_amdgcn_sudot4(true, a, true, b, acc, false)`: the Adrenalin OpenCL C compiler accepts the clang builtin and emits `v_dot4_i32_iu8` | emulation 1.46x; the intrinsic 1.06x, so the emulation costs 1.38x the instruction | yes, all three; the older 3652.0 platform entry for the same card gave 696.2 / 501.7 / 683.6 |
|
||||
| Ryzen 9800X3D gfx1036 (PC 1, integrated RDNA 2, 2 CUs) | same job | 40.6 (105.9 ms) | 15.8 (272.3 ms) | | `sudot4` does not build: "needs target feature dot8-insts" (RDNA 2 has `dot1-insts`' `v_dot4_i32_i8`, the `sdot4` builtin, which the probe did not try) | emulation 2.6x | alu and dot4e yes |
|
||||
| Every PC device | `cl_khr_integer_dot_product` not listed on NVIDIA (OpenCL 3.0) or AMD (2.0); the pragma draws "unknown OpenCL extension" on both and the `dot(char4, char4)` kernel does not build | | | | | | |
|
||||
|
||||
Reading across the three cards. Per dot4 at the hardware rate: the 5090 does 7.45 T dot4/s (one `dp4a` per step, 0.85
|
||||
of its ALU-chain step rate), the 9070 XT 0.66 T (0.95 of its ALU-chain rate), the M5 Max 0.55 T at best (the unsigned
|
||||
emulation; no instruction). On the ALU chain the 5090 is 12.5x the 9070 XT and 10x the M5 Max; on hardware dot4 it is
|
||||
11.2x the 9070 XT, so the family does not widen the AMD gap, and 13.6x the M5 Max, so Apple's emulation widens its gap
|
||||
by 1.4x on this op (approximate: one probe shape, the ratios of best-of-3 numbers). The signed emulation is where the
|
||||
vendors differ most: 7.1x the ALU step on NVIDIA, 4.7x on Apple, 1.46x on AMD (AMD's compiler and byte-permute
|
||||
hardware make the four sign-extended products nearly free; the NVIDIA OpenCL compiler does not pattern-match the
|
||||
emulation into `dp4a`, which the 6x gap between `dot4e` and `dot4_nv` shows). None of this is a hash-rate number: the
|
||||
hash is bound by 128 dependent DRAM reads, and a family at W_new = 4 adds about 21 of these ops per hash per lane
|
||||
against about 1.2 microseconds of memory latency per hash per lane (approximate), so the per-op penalties above turn
|
||||
into hash-rate losses well under 5% on every card, to be measured with the family live.
|
||||
|
||||
Reading of the Mac numbers. The ALU chain's 880 G steps/s on the M5 Max is the integer baseline (5 ops per step
|
||||
counted, so about 4.4 T int ops/s, approximate; the 5090's 8,754 G steps/s is about 43.8 T, against the whitepaper's
|
||||
104.8 peak INT32 TOPS which counts a multiply-add as two). A signed dot4 emulated as `int4(as_type<char4>(a))` products costs
|
||||
4.7 of those steps; the unsigned form 1.6 steps. The 3x gap between the two is the sign extension (Metal lowers the
|
||||
unsigned byte extraction to masks that fold into the multiplies, approximate reading of the result, not of the
|
||||
compiled code). Both are far under the 8x bound of section 3, and the hash spends its time on DRAM reads, so a per-lane
|
||||
`dot4` family would cost Apple a few percent at W_new = 4 (to be measured with the family live, not computed). The
|
||||
Apple OpenCL `dot(char4, char4)` mismatch is the kind of thing the edge vectors of section 3 exist to catch.
|
||||
|
||||
## 5. What is owed or unverified
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| dp4a throughput on the RTX 5090 through NVIDIA OpenCL inline PTX | measured (section 4); the CUDA `__dp4a` form (`proto-cuda/dot4-probe.cu`) is unrun (no nvcc job tonight) and is a cross-check, not a gap |
|
||||
| `sudot4` on the 9070 XT through Adrenalin's OpenCL C; `cl_khr_integer_dot_product` on the 3683.0 platform | measured: the builtin works and emits the instruction; the extension is not listed and the `dot(char4, char4)` kernel does not build |
|
||||
| `sdot4` (`dot1-insts`) on RDNA 2 (gfx1036) | not tried; the probe only carries `sudot4` |
|
||||
| Metal 4 `matmul2d` uchar x uchar into int on the M5 Max: wrap or saturate at the int32 edge, native or emulated, throughput | owed (a second Metal probe; the API needs a tensor set-up the dot4 probe does not have) |
|
||||
| Metal Feature Set Tables: which Apple GPU families run int8 matmul2d natively | not read tonight |
|
||||
| RDNA 3 and RDNA 4 ISA guides: the instruction text itself (names taken from LLVM and GPUOpen) | AMD's CDN refused the downloads tonight |
|
||||
| `mm8` on AMD: the exact byte permutation between the PTX m8n8k16 fragment layout and the RDNA WMMA 16x16x16 layout | design, to be written with the kernel |
|
||||
| The hash-rate cost of the family live at W_new = 4 on each vendor (the 5% rule of section 3) | owed, needs the generator change (not tonight) |
|
||||
| Edge vectors of section 3 as files | owed, with the generator change |
|
||||
115
docs/analysis/m16-recompute-attacker-2026-10-05.md
Normal file
115
docs/analysis/m16-recompute-attacker-2026-10-05.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# M16: the recompute attacker with the 256 MiB cache on a die, a cost model
|
||||
|
||||
5 October 2026 (evening), FUD ledger sweep round 6. Ledger M16, review R3.5, the chip designer's attack 3.
|
||||
|
||||
The claim: "put 256 MiB of SRAM on a die and the dataset is never needed: 128 items per hash at about 1,170 integer
|
||||
operations and 8 near-free reads each; integer operations per dollar is where silicon beats a GPU."
|
||||
|
||||
This file prices that device from the rules in the specification and the rates measured so far. Nothing here is a
|
||||
measurement of a chip. Every figure says where it comes from; "approximate" marks a figure from memory.
|
||||
|
||||
## 1. What the honest miner pays per hash
|
||||
|
||||
| Quantity | Value | Source |
|
||||
|---|---|---|
|
||||
| Loads per hash | 128 (16 load slots x 8 iterations), every accepted program | `igneum-pow/src/generator.rs` (`LOAD_SLOTS`, `ITERATIONS`), spec 01 section 1.4.2 |
|
||||
| Distinct addresses per hash | 120.05 to 128, median 128.00, over 20,000 accepted programs | 20,000-program census, `docs/bench-log.md` "generator version 2"; `docs/analysis/weak-program-census-2026-10-03.md` |
|
||||
| Bytes per load | 4 (one dataset word) | spec 01 section 1.8.5 |
|
||||
| Dataset | 1 GiB (2^28 words), built once a day from the 256 MiB cache | spec 01 section 1.8.5; CLAUDE.md |
|
||||
| Honest rate, RTX 5090, 1 GiB dataset | 228.95 Mhash/s, 23.8 G random loads/s, 95.2 GB/s useful | `docs/bench-log.md`, "3 October 2026, RTX 5090, memory-hard dataset" |
|
||||
| Honest rate, RTX 5090, 64 MiB dataset (fits the 96 MiB L2) | 1,352 Mhash/s, 5.8x the 1 GiB rate | `docs/bench-log.md`, RTX 5090 dataset sweep (cited in ledger M1) |
|
||||
| Dataset build, RTX 5090 | 13.4 ms for 1 GiB (1,253 M items/s); cache fill 0.67 ms | the same entry |
|
||||
| Projected honest rate for version 2 programs, RTX 5090 | 141 Mhash/s (approximate: 18.0 G distinct loads/s at 128 distinct loads per hash; not yet run) | `docs/bench-log.md`, weak-program census, "Hash-rate spread" |
|
||||
|
||||
The honest hash is 128 dependent random 4-byte reads over a buffer larger than any on-chip cache. The ALU work of
|
||||
the program (64 instructions x 8 iterations per lane) is not what bounds it: the 5.8x step between the 64 MiB and
|
||||
1 GiB datasets on the same card is the memory system, not the arithmetic.
|
||||
|
||||
## 2. What the recompute attacker pays per hash
|
||||
|
||||
The attacker holds the 256 MiB cache (on a die, the premise) and derives each dataset word on demand instead of
|
||||
reading it.
|
||||
|
||||
| Quantity | Value | Source |
|
||||
|---|---|---|
|
||||
| Items per hash | 128 (one dataset item of 16 words per load; two loads in one item share it, so at most 128 and in the census's accepted population about 128) | spec 01 section 1.8.5, `dataset[w] = item(w >> 4)[w AND 15]` |
|
||||
| Mixer applications per item | 9 (`ITEM_ROUNDS = 8` dependent cache reads, nine mixer applications) | spec 01 sections 1.8.4 and 1.8.5 |
|
||||
| Operations per mixer application | about 130 integer operations (16 xor-add-multiply steps and 8 ChaCha quarter rounds on a 16-word state) | spec 01 section 1.8.4 |
|
||||
| Operations per item | about 1,170 | 9 x 130 |
|
||||
| Cache reads per item | 8 dependent 64-byte lines (each address depends on every earlier read) | spec 01 section 1.8.5 |
|
||||
| Operations per hash | about 150,000 (128 x 1,170) | arithmetic |
|
||||
| Cache bytes per hash | 65,536 (128 x 8 x 64) in 1,024 dependent reads | arithmetic |
|
||||
|
||||
Measured on Apple silicon (the only inline kernel run so far): the inline kernel does 9.48 Mhash/s on the M5 Max at
|
||||
both a 256 MiB and a 1 GiB dataset, against 94.8 honest at 256 MiB and 45.2 at 1 GiB (`proto-metal/MEMHARD.md`
|
||||
section 2.2: 10x and 4.8x slower). The same inline kernel under heavy load on 3 October ran 17x slower than honest at
|
||||
a 256 MiB dataset (`docs/bench-log.md`, "R3.26 / M15", M16 note). The Mac's 256 MiB cache sits in DRAM, so its
|
||||
inline kernel is bound by the 1,024 dependent cache-line reads per hash and does not price a die; it only shows
|
||||
the kernel exists and is bit-exact.
|
||||
|
||||
## 3. The die, priced in the GPU's own units
|
||||
|
||||
To match ONE RTX 5090 at its honest 1 GiB rate the attacker's chip must deliver, per second:
|
||||
|
||||
| Need | Value | Arithmetic |
|
||||
|---|---|---|
|
||||
| Integer operations | 34 T op/s | 228.95 M hash/s x 150,000 op/hash |
|
||||
| Cache-line reads | 234 G reads/s, 15 TB/s of SRAM bandwidth | 228.95 M x 1,024 reads x 64 B |
|
||||
| SRAM | 256 MiB, with the next day's cache under construction beside it | spec 01 (the cache is per day) |
|
||||
|
||||
What the GPU itself has (approximate, from memory, for scale): the RTX 5090's integer throughput is about 50 T
|
||||
op/s (21,760 ALUs at about 2.4 GHz, one 32-bit operation each per clock), the figure the ledger entry already
|
||||
carries; on-die SRAM at 256 MiB costs about 100 to 300 mm^2 on a current node (the low end from a 0.02 um^2 bit
|
||||
cell with array overhead, the high end from wafer-scale parts at about 1 MB per mm^2), against a 750 mm^2
|
||||
class GPU die; 15 TB/s of on-die SRAM bandwidth is within what wafer-scale parts quote and is not the bound.
|
||||
|
||||
So, at equal silicon and equal integer throughput, the recompute attacker reaches 50 T / 150,000 = about 0.33
|
||||
Ghash/s:
|
||||
|
||||
| Against | Honest 5090 rate | Attacker gain at equal integer budget |
|
||||
|---|---|---|
|
||||
| Closed-form and version 1 programs as measured | 229 Mhash/s | 1.5x |
|
||||
| Version 2 programs as projected (distinct-load bound) | 141 Mhash/s | 2.4x (the figure in the ledger entry) |
|
||||
|
||||
Before any chip-versus-GPU efficiency factor. A fixed-function pipeline with no instruction scheduling and no
|
||||
warp divergence is usually credited with 2x to 5x over a GPU on integer work (approximate, from memory; the
|
||||
honest target in the ledger is "under 2x"). Taking 3x: 4.5x to 7x over a 5090 at equal die area, minus the area
|
||||
the SRAM takes (13% to 40% of the die), so about 3x to 6x. That is the exposure as the parameters stand, and it is
|
||||
arithmetic, not a measurement.
|
||||
|
||||
## 4. The lever, and why it costs the honest miner nothing
|
||||
|
||||
The attacker's cost is linear in operations per item. The honest miner pays the mixer once per day in the
|
||||
dataset build (2^26 items x 1,170 op = 78 G op, 13.4 ms on the 5090 as measured) and never per hash. The
|
||||
verifier pays it per load it checks (spec 01 section 1.11: the CPU verifier derives the distinct items of a warp
|
||||
from the cache, 0.41 to 1.2 ms per warp with the cache on one M5 Max core, `docs/bench-log.md` 3 October).
|
||||
|
||||
| Mixer cost multiplier m | Operations per hash | Attacker rate at 50 T op/s | Gain against 229 Mhash/s (equal silicon, no efficiency factor) | Gain with a 3x fixed-function factor | Honest daily dataset build, 5090 | CPU verify per warp (scaled from 0.41 to 1.2 ms) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 (today, 1,170 op/item) | 150,000 | 0.33 Ghash/s | 1.5x | 4.4x | 13.4 ms | 0.4 to 1.2 ms |
|
||||
| 2 | 300,000 | 0.17 Ghash/s | 0.73x | 2.2x | 27 ms | 0.8 to 2.4 ms |
|
||||
| 4 | 600,000 | 0.083 Ghash/s | 0.36x | 1.1x | 54 ms | 1.6 to 4.8 ms |
|
||||
| 8 | 1,200,000 | 0.042 Ghash/s | 0.18x | 0.55x | 107 ms | 3.3 to 9.6 ms |
|
||||
| 16 | 2,400,000 | 0.021 Ghash/s | 0.09x | 0.27x | 214 ms | 6.6 to 19 ms |
|
||||
|
||||
Reading. The mixer cost is the one parameter that moves the recompute attacker and leaves the honest hash rate
|
||||
untouched; it is bounded by the 10 ms CPU verification gate (ledger M9, spec 1.16), which at today's verify time
|
||||
allows about 8x before the slow core of the gate (a 2019-class laptop core, unmeasured, O-1.14) is at risk. The
|
||||
cache size is the other lever and is linear in SRAM area, which is the cheaper side for the attacker: 256 MiB to
|
||||
1 GiB moves the die from about 100 to 300 mm^2 to 400 to 1,200 mm^2, which is a multi-die part. Both are
|
||||
prototype values of spec 1.16, fixed at gate 1.
|
||||
|
||||
## 5. What this does not settle
|
||||
|
||||
1. The inline kernel on NVIDIA at a 64 MiB cache inside the 5090's 96 MiB L2 (the on-die SRAM emulation the
|
||||
ledger entry names) has not run; it is a PC job. It would put a measured point under the "50 T op/s" row: the
|
||||
rate a real integer engine reaches on the actual mixer with the cache in SRAM-class memory.
|
||||
2. The time-memory curve (O-1.6: store a fraction f of the dataset, recompute the rest) is not drawn; the model
|
||||
above is the f = 0 endpoint. A partial-store attacker with HBM instead of SRAM is a different device and may be
|
||||
the cheaper one.
|
||||
3. The mixer has had no cryptanalysis (spec 1.8.4, `MEMHARD.md` section 3); a shortcut inside the mixer would cut
|
||||
the 1,170 directly.
|
||||
4. Monero's seven years do not price this device (ledger C13).
|
||||
|
||||
Decision at gate 1 (owner: the project lead): the cache size rule "exceeds what one die can hold, and grows", and the mixer
|
||||
cost multiplier, against the CPU verify gate.
|
||||
152
docs/analysis/prover-floor.md
Normal file
152
docs/analysis/prover-floor.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# The prover floor: why a 12 GB card cannot prove on SP1 6.8.1's GPU server, and the patch
|
||||
|
||||
5 October 2026, 22:00 UTC on (the project lead: "execute if it will solve the issue"). Branch `prover-floor`
|
||||
(worktree `igneum-wt-prover-floor`). The measured facts this starts from: `docs/plans/proving-v1.md` and the
|
||||
bench-log entry "proving v1" (branch proving-v1): the GPU server holds 13.9 GB for an empty shard, 20.4 GB at the
|
||||
adopted v1 shard, 28.3 GB flat from 20 M to 60 M cycles, and no environment knob moved the floor. Every figure
|
||||
below is from the source at tag v6.8.1 (cloned to `vendor/sp1-6.8.1`, gitignored; the fork is the patch
|
||||
`proving/prover-floor/sp1-gpu-6.8.1-floor.patch`) or from a PC 2 run named in `docs/bench-log.md`
|
||||
("prover floor"). Sizes in GiB are computed from the source constants (4-byte field elements); sizes in MiB are
|
||||
measured by `nvidia-smi` at 1 s.
|
||||
|
||||
## Where the server is built and what it reads
|
||||
|
||||
The SDK downloads `sp1_gpu_server_v6.8.1_x86_64.tar.gz` (133,750,780 bytes) from the SP1 release and runs it from
|
||||
`$HOME/.sp1/bin/sp1-gpu-server` (`crates/cuda/src/server.rs` 19 to 30, 80 to 99). The source is in the same
|
||||
repository: `sp1-gpu/crates/server` (the binary), built by `.github/workflows/release.yml` 234 to 314 on CUDA
|
||||
12.8.1 with Go and protoc (`cargo build --release --bin sp1-gpu-server`). The binary takes no options
|
||||
(`sp1-gpu/crates/server/src/main.rs` 15 to 18: `--version` only) and reads `CUDA_VISIBLE_DEVICES` (32 to 35);
|
||||
everything else comes from the environment the host process passes it, through `SP1CoreOpts::default()`
|
||||
(`crates/core/executor/src/opts.rs` 99 to 140: `SHARD_SIZE`, `ELEMENT_THRESHOLD`, `HEIGHT_THRESHOLD`,
|
||||
`MINIMAL_TRACE_CHUNK_THRESHOLD`, `TRACE_CHUNK_SLOTS`, `FULL_SIZE_SHARDS`) and the worker counts
|
||||
(`crates/prover/src/worker/config.rs`).
|
||||
|
||||
## The memory model, term by term
|
||||
|
||||
Every device buffer is sized at construction from constants, not from the shard. The server builds the prover at
|
||||
the first `Setup` request (`sp1-gpu/crates/server/src/server.rs` 126 to 137) through
|
||||
`cuda_worker_builder_with_machine` (`sp1-gpu/crates/prover_components/src/builder.rs` 102 to 148):
|
||||
|
||||
| Term | Where | Size | On the device | Moves with the shard |
|
||||
|---|---|---|---|---|
|
||||
| The gate | `builder.rs` 35 to 39: `gpu_memory_gb = ceil(total / GiB) + 4`; `panic!("Unsupported GPU memory: {gpu_memory_gb}, must be at least 24GB")` when under 24 | a 12 GB card reads 16, a 16 GB card 20: both refused before any allocation | | no |
|
||||
| The core element threshold | `builder.rs` 41 to 48: `ELEMENT_THRESHOLD` = 2^28 + 2^27 = 402,653,184 elements (`opts.rs` 12) on a card reading over 30 (a 32 GB card reads 36); minus 2^26 + 2^25 + 2^24 = 285,212,672 on a card reading 24 to 30 (a 24 GB card). The environment's `ELEMENT_THRESHOLD` is read at `opts.rs` 129 and then OVERWRITTEN at `builder.rs` 48, which is why the sweep's `ELEMENT_THRESHOLD` rows changed nothing; `HEIGHT_THRESHOLD` survives (it is not overwritten), which is why the 2^25 + 2^20 row did | sets the next two terms | | no |
|
||||
| The core trace area, one per shard in flight | `builder.rs` 70 to 71: `num_elts = element_threshold + 2^21` (`CORE_LOG_STACKING_HEIGHT` 21, `crates/prover/src/components.rs` 16) = 404,750,336; allocated on the device at `sp1-gpu/crates/jagged_tracegen/src/lib.rs` 484 to 500 (`allocate_and_initialize_traces`: `max_trace_size` felts + `max_trace_size / 2` u32 column index + 2^14 u32) | 6 bytes an element: **2.26 GiB** for the full threshold, 1.59 GiB for the 24 GB threshold | yes, in full, whatever the shard holds | no |
|
||||
| The program's preprocessed traces (the proving key) | `sp1-gpu/crates/shard_prover/src/setup.rs` 47 to 58 and 107: the same `allocate_and_initialize_traces(max_trace_size)` at `Setup`, kept in the key cache for the connection's life (`server.rs` 139 to 143) | another **2.26 GiB**, held from `Setup` on | yes | no |
|
||||
| The pinned host trace buffers | `sp1-gpu/crates/prover_components/src/components.rs` 99 to 103: 4 `PinnedBuffer` of `max_trace_size` felts per prover (core 4 x 1.51 GiB, recursion 4 x 0.5 GiB, shrink 4 x 0.125 GiB, wrap 4 x 0.32 GiB) | 9.8 GiB of pinned host RAM, not device memory (the WSL2 working set the bench saw) | no | no |
|
||||
| The recursion trace area | `builder.rs` 15 and 95: `RECURSION_TRACE_ALLOCATION` = 2^27 elements, one per recursion tracegen (the recursion program's key at setup and its shard at prove) | 0.75 GiB each | yes | no |
|
||||
| The shrink and wrap provers | `builder.rs` 16, 19, 117 to 127: 2^25 and 85,376,340 elements, built at `Setup` for every proof mode, used only by the Groth16 and PLONK path | host pinned at build; device only when a wrap runs (never, for a compressed proof) | no | no |
|
||||
| The codewords (LDE) and the Merkle trees | `sp1-gpu/crates/basefold/src/fri.rs` 92 to 97: every stacked column of 2^21 rows encoded to 2^(21 + 1) rows (`log_blowup` 1); kept until the query phase unless `drop_ldes` (`builder.rs` 52: only on a 24 GB card with `FULL_SIZE_SHARDS`); the preprocessed codewords live in the key | 2 x the padded trace, so up to 2 x the term above | yes | yes, with the padded trace |
|
||||
| The LogUp GKR layers | `builder.rs` 51: `recompute_gkr_trace = false`, so the first layer stays materialised (`sp1-gpu/crates/logup_gkr/src/tracegen.rs` 169 to 224) | of the order of the interaction count | yes | yes |
|
||||
| The allocator | `sp1-gpu/crates/cuda/src/task.rs` 152 and 196: the device's default `cudaMallocAsync` pool with its release threshold at `u64::MAX`, so nothing freed is ever returned to the driver: `nvidia-smi` reads the high-water mark of everything live at once | | | |
|
||||
|
||||
So at zero cycles the server already holds the proving key's 2.26 GiB, the shard's 2.26 GiB (both allocated at the
|
||||
threshold, not at the shard's rows), their codewords and trees, and the recursion program's key and traces (2 x
|
||||
0.75 GiB and their codewords): the 13.9 GB floor. The shard's own content only adds to the codewords, the GKR
|
||||
layers and the working buffers, which is the 13.9 to 20.4 GB step from 0.3 M to 4.7 M cycles, and the flat 28.3 GB
|
||||
from 20 M cycles is the threshold's padded area reached. The witness (5 to 22 KB) never appears.
|
||||
|
||||
## What the patch does (`proving/prover-floor/sp1-gpu-6.8.1-floor.patch`, three files)
|
||||
|
||||
1. `builder.rs`: the panic is gone; the card's memory (or `SP1_GPU_MEMORY_BUDGET_GB`) picks the element threshold
|
||||
from a tier table (`element_threshold_for_budget`: over 30 as read, the full 402.6 M; 24 to 30, upstream's 24 GB
|
||||
figure; 18 to 24 (a 16 GB card), 2^27 + 2^26 = 201.3 M; under 18 (a 12 GB card), 2^27 = 134.2 M);
|
||||
`SP1_GPU_ELEMENT_THRESHOLD` sets it directly and `SP1_GPU_RECURSION_TRACE_ALLOCATION` the recursion buffer.
|
||||
The chosen numbers are printed as a `FLOOR opts` line. Every other option is as upstream.
|
||||
2. `jagged_tracegen/src/lib.rs`: with `SP1_GPU_FLOOR_LOG` set, every trace allocation prints its capacity and,
|
||||
after the shard's traces are in, the elements actually used and the device memory in use.
|
||||
3. `server.rs`: a `FLOOR memory` line (device used, free, total) after `Setup` and after every proof, with the
|
||||
proof's time.
|
||||
|
||||
Nothing in the proof changes: the element threshold only moves where the executor splits shards, exactly what
|
||||
upstream's own 24 GB tier does with the same verifier and the same keys; the recursion program, the verifying key
|
||||
and the pinned guest ids are untouched. The unpatched verifier (the pv1 host's SDK) is the one that verifies every
|
||||
measured proof below.
|
||||
|
||||
## The build (PC 2, WSL2 Ubuntu-24.04, job `floor-toolchain-1` then the build job)
|
||||
|
||||
Toolchain found 22:10Z (job `floor-toolchain-1`, 4 s): nvcc 12.8 at `/usr/local/cuda-12.8`, cmake 3.28.3, gcc 13.3,
|
||||
clang 18, protoc 3.21.12, cargo 1.99.0, no Go. The release workflow installs Go for the server's `native-gnark`
|
||||
feature (the Groth16 and PLONK wrap through gnark), which a compressed proof never runs, so the build drops that
|
||||
feature from `sp1-gpu/crates/server/Cargo.toml` and nothing else. `CUDA_ARCHS=86,89,120` (consequences reviewer
|
||||
C26): the 12 GB tier is sm_86 (RTX 3060) and sm_89 (RTX 4070), the 16 GB tier sm_89 and sm_120 (RTX 5080), PC 2's
|
||||
5090 is sm_120; the stock server lists sm_80, 86, 89, 90, 100 and 120, which a shipped build repeats. The recipe:
|
||||
`tools/prover-floor/pc2-build-server.ps1` (generated by `make-build-playbook.sh` from the patch, so the two cannot
|
||||
drift): clone the tag, `git apply` the patch, `touch` the three files, `cargo build --release --bin sp1-gpu-server`
|
||||
niced with 8 jobs into `/opt/igneum-floor/target`, the binary copied to `/opt/igneum-floor/home/.sp1/bin/` (the SDK
|
||||
spawns the server it finds under `$HOME/.sp1/bin`, so `HOME=/opt/igneum-floor/home` selects it and the live
|
||||
`/root/.sp1/bin/sp1-gpu-server` stays as it is).
|
||||
|
||||
What a measurement on PC 2 can and cannot say (C26). The server's allocation pattern is deterministic in the
|
||||
budget it is given, so a run with `SP1_GPU_MEMORY_BUDGET_GB=12` on the 5090 shows the peak a 12 GB card's build
|
||||
would ask for; it does not show that a 3060 proves it in time, nor what the card's display and driver hold. The
|
||||
public line keeps "24 GB" until the on-order 12 GB card runs the same fixture. Every row names the arch list and
|
||||
the card.
|
||||
|
||||
What shipping it costs (C26). A patched server means the project signs and distributes its own build of SP1's
|
||||
prover: the WSL2 package, the DMG's prover inputs, the K1-signed inputs and `evidence.md` carry it, and every SP1
|
||||
upgrade repeats the clone, patch, build and measurement. The verifying key and the pinned guest ids do not move
|
||||
(the patch changes buffer sizes and the shard split, not the circuits), which the `verify-segment` and `--mode
|
||||
compressed` VERIFIED lines of the unpatched host show on every row below. The packaging path is a row for the
|
||||
proving plan before 0.3.12, not this branch.
|
||||
|
||||
## Step 4 contingency, read not measured: RISC Zero's CUDA prover and its memory per segment
|
||||
|
||||
If SP1 could not be brought under 11 GB, the alternative's floor is read from its operators' documentation (not
|
||||
measured here; a PC 2 run would be the measurement): Boundless' prover guide
|
||||
(https://docs.boundless.network/provers/performance-optimization) sets the segment size cap by VRAM as 8 GB:
|
||||
po2 19, 16 GB: po2 20, 20 GB: po2 21, 40 GB: po2 22, with measured peaks po2 20: 13,835 MiB, po2 21: 22,905 MiB,
|
||||
po2 22: 41,089 MiB; RISC Zero's PR 3761 adds `low_vram` and `pinned_witgen` to fit po2 22 on a 24 GB 4090. So
|
||||
RISC Zero proves a 2^19-cycle segment inside 8 GB and a 2^20 one inside 16 GB, and a shard of 4.7 M cycles is
|
||||
9 segments at po2 19 plus lift and join steps (times not on the page). Adopting it would cost a second guest (the
|
||||
chain rule in the RISC Zero zkVM), a second pinned program id, a second verifier in the node and no shared
|
||||
aggregation between the two formats: `docs/analysis/amd-proving.md` and the proving plan carry that row already.
|
||||
|
||||
### The build, as it ran (job `floor-build-3`, 22:28:24 to 22:32:29Z)
|
||||
|
||||
Three runs: `floor-build-1` (22:17Z) and `floor-build-2` (22:24Z) failed in 2 to 4 minutes on
|
||||
`crates/recursion/gnark-ffi/build.rs:70`, "Failed to build Go library: NotFound" (no `go` on PC 2; the first run's
|
||||
playbook lost its own log, a bug fixed before the second). `floor-build-3` fetched go1.27.1 (tarball sha256
|
||||
`63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445`, checked before unpacking under
|
||||
`/opt/igneum-floor/go`, on the job's PATH only) and built in **240 s** (46 crates on the warm target of run 2, 8
|
||||
niced jobs, 16 cores). The binary: `/opt/igneum-floor/bin/sp1-gpu-server`, **166,768,224 bytes, sha256
|
||||
`5568108bf7fb9b0e525d8a08926b7046e51136ffaea53f0ca858631d0e938878`**, `--version` 6.8.1, `cuobjdump --list-elf`
|
||||
sm_86, sm_89, sm_120 (the stock 251,306,680-byte server lists sm_80, 86, 89, 90, 100, 120 and compute_120 PTX).
|
||||
The live `/root/.sp1/bin/sp1-gpu-server` (c2642ad1...) was never touched; the miners mined throughout.
|
||||
|
||||
## Sweep 1 (job `floor-sweep-1`, 22:34:56 to 22:37:55Z): the shard term gone, a second floor found
|
||||
|
||||
PC 2's RTX 5090 (32,607 MiB, idle 1,755 MiB with the miners stopped and the live prover off), the patched server
|
||||
`5568108b...` (sm_86, sm_89, sm_120; the build above), the unpatched pv1 host `dae6b006...` as client and
|
||||
verifier, one `--mode compressed --shard 0` per point, every server killed and its socket unlinked around every
|
||||
point, peak = `nvidia-smi memory.used` at 1 s (the idle 1,755 MiB inside it), time = the compressed proof.
|
||||
Every proof VERIFIED (1,272,897 bytes, verify 0.037 to 0.040 s), so the unpatched verifier accepts every proof
|
||||
of the patched server.
|
||||
|
||||
| Config (environment to the patched server) | Fixture | Cycles | Peak MiB | Prove s | Verified |
|
||||
|---|---|---|---|---|---|
|
||||
| control: `SP1_GPU_MEMORY_BUDGET_GB=32` (upstream's sizes) | empty live shard (block 83616) | 280,706 | 13,892 | 2.2 | yes |
|
||||
| control | v1 shard (fees-v1-shards2 shard 0) | 4,717,439 | 20,516 | 4.2 | yes |
|
||||
| 12 GB tier: budget 12 (threshold 2^27) | empty | 280,706 | 12,740 | 2.4 | yes |
|
||||
| 12 GB tier | v1 shard | 4.7 M | 15,396 | 4.1 | yes |
|
||||
| 12 GB tier + `SP1_WORKER_NORMALIZE_PROGRAM_CACHE_SIZE=1` | v1 shard | 4.7 M | 15,428 | 4.0 | yes |
|
||||
| 16 GB tier: budget 16 (2^27 + 2^26) | v1 shard | 4.7 M | 18,628 | 3.8 | yes |
|
||||
| `SP1_GPU_ELEMENT_THRESHOLD=67108864` (2^26) | empty | 280,706 | 12,772 | 3.1 | yes |
|
||||
| 2^26 | v1 shard (split into 4 core shards) | 4.7 M | **12,708** | 5.3 | yes |
|
||||
| `SP1_GPU_ELEMENT_THRESHOLD=33554432` (2^25) | v1 shard | 4.7 M | 12,836 | 8.5 | yes |
|
||||
|
||||
Reading. The control reproduces the proving agent's curve (13.9 and 20.4 GB), so the patched server behaves as
|
||||
the stock one at the stock sizes. The shard's term follows the threshold as the model says (20.5 GB at 402 M
|
||||
elements, 15.4 at 134 M, 12.7 at 67 M), and then stops: 2^26 and 2^25 both sit at 12.7 to 12.8 GB for the empty
|
||||
shard and the v1 shard alike. The `FLOOR memory after setup` line names the rest: **9,703 MiB in use before the
|
||||
first shard** (2^26; 11,623 at the stock sizes), and the `FLOOR tracegen alloc` lines at Setup are five
|
||||
allocations of 134,217,728 elements (the recursion keys, 0.75 GB each, each using 90,177,536 elements: 35.6 M
|
||||
preprocessed and 54.5 M main at prove time), one of 33,554,432 (the shrink key, 0.19 GB) and one core key at the
|
||||
threshold. The `NORMALIZE_PROGRAM_CACHE_SIZE` knob does not reach them (they are keys built at `Setup`, not the
|
||||
program LRU). The time cost of the split: the v1 shard at 2^26 is 4 core shards and 5.3 s against 4.2 s (1.26x);
|
||||
at 2^27 it is 4.1 s with no split.
|
||||
|
||||
So after sweep 1 the binding term is the Setup-time keys allocated at full capacity, and patch v2 sizes every
|
||||
trace buffer (keys and shards) to its padded need: `padded_trace_elements` in `jagged_tracegen/src/lib.rs`
|
||||
(each phase pads to the next multiple of 2^21 rows, `generate_jagged_traces`'s "final padding"), applied in
|
||||
`setup_tracegen` and `full_tracegen`, one stacking height of slack, `SP1_GPU_FLOOR_EXACT=0` restoring upstream.
|
||||
411
docs/analysis/proving-methods.md
Normal file
411
docs/analysis/proving-methods.md
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
# Proving methods: why the prover needs 14 GB, what else exists, and how a 12 GB card gets to prove
|
||||
|
||||
5 October 2026, from the project lead at 22:05 UTC: "if this doesn't enable 12 GB cards, then do a full deep research task on proving
|
||||
and see if there are different methods." "This" is the prover-floor agent's patch of SP1's GPU server (branch
|
||||
`prover-floor`), running tonight. This document is research and reading, not measurement: every number of ours is from
|
||||
`docs/bench-log.md` with its entry named; every claim about another system cites its repository file, its documentation
|
||||
page or its paper, or is labelled approximate. Status words follow `docs/spec/00-overview.md` 0.2. Day estimates follow
|
||||
the project lead's rule of 3 October 2026: hours of agent time, never weeks.
|
||||
|
||||
The facts this starts from (bench-log, "proving v1", 5 October 2026; `docs/plans/proving-v1.md`; `docs/analysis/amd-proving.md`):
|
||||
|
||||
| Fact | Number |
|
||||
|---|---|
|
||||
| SP1 6.8.1's GPU server, an empty shard (280,706 cycles), the card to itself | 13,874 MiB peak, 2.2 s compressed |
|
||||
| The adopted v1 shard (`S_p` 30,000 pgas, 4,717,439 cycles) | 20,434 MiB, 4.3 s; 22,210 MiB and 13.2 s beside the miner |
|
||||
| The prototype shard (6.75 M pgas, 60.4 M cycles) | 28,307 MiB, 10.8 s; flat at 28.3 GB from 20 M cycles up |
|
||||
| Aggregation, chained, per block, on a mining 5090 | 9.6 to 9.7 s; 2.2 to 2.5 s with the card to itself |
|
||||
| The CPU path (PC 1, 16 cores) | 282 s a shard whatever its size, 29.5 to 30.5 GB RSS |
|
||||
| AMD and Apple GPUs | no zkVM proves on AMD; RISC Zero has a Metal prover, SP1 does not |
|
||||
| The promise | `site/litepaper.html`: "Target: shard size will be set so a 12 GB card proves one shard in about 20 seconds"; the design goal is every block proven within about a minute by the miners' own cards |
|
||||
|
||||
## 1. The memory anatomy of a STARK-based zkVM prover, and why the floor is where it is
|
||||
|
||||
### 1.1 What SP1 6.8.1 is
|
||||
|
||||
SP1 6.x is not the univariate FRI STARK of the earlier SP1 releases (Succinct calls Hypercube the "first zkVM built entirely on a multilinear polynomial-based proof system", blog.succinct.xyz, sp1-hypercube, 20 May 2025). The crates it pulls say what it is: `slop-multilinear`, `slop-sumcheck`,
|
||||
`slop-jagged`, `slop-stacked`, `slop-basefold`, `slop-whir` (the `~/.cargo/registry` of this Mac; `proving/igneum-prove/Cargo.toml`
|
||||
pins `sp1-sdk = "=6.8.1"`). The architecture Succinct calls Hypercube: the execution trace is a set of multilinear
|
||||
polynomials over the 31-bit KoalaBear field (`sp1-hypercube-6.8.1/src/verifier/config.rs`: `SP1BasefoldConfig =
|
||||
Poseidon2KoalaBear16BasefoldConfig`), the constraints are checked by a zerocheck sumcheck and the lookups by a LogUp GKR
|
||||
(`sp1-gpu/crates/zerocheck`, `sp1-gpu/crates/logup_gkr`), and the polynomial commitment is "jagged": every table's
|
||||
columns, whatever their heights, are concatenated into one long vector, stacked into rows of height `2^log_stacking_height`
|
||||
and committed with BaseFold, a FRI-like folding over a Reed-Solomon code (`sp1-gpu/crates/basefold/src/fri.rs`,
|
||||
`slop-basefold-6.8.1/src/verifier.rs`). The proof system parameters, from `sp1-primitives-6.8.1/src/fri_params.rs` and
|
||||
`sp1-prover-6.8.1/src/components.rs`:
|
||||
|
||||
| Parameter | Value | Where |
|
||||
|---|---|---|
|
||||
| Field | KoalaBear, 31 bits, 4 bytes an element; extension degree 4 (16 bytes) | `sp1-primitives` |
|
||||
| Core stage: Reed-Solomon blowup | `CORE_LOG_BLOWUP = 2`, so the codeword is 4x the data | `fri_params.rs:5` |
|
||||
| Core stage: stacking height, maximum rows per table | `CORE_LOG_STACKING_HEIGHT = 21`, `CORE_MAX_LOG_ROW_COUNT = 22` | `components.rs:16,17` |
|
||||
| Core shard limits (the executor's cut) | `MAX_SHARD_SIZE = 2^24` cycles, `ELEMENT_THRESHOLD = 2^28 + 2^27 = 402,653,184` trace elements, `HEIGHT_THRESHOLD = 2^22` rows | `sp1-core-executor-6.8.1/src/opts.rs:9-12` |
|
||||
| Recursion (compress) stage | blowup 2 (`RECURSION_LOG_BLOWUP = 2`), stacking height 20, max rows 2^21 | `fri_params.rs:6`, `sp1-verifier-6.8.1/src/compressed/config.rs:1,2` |
|
||||
| Shrink and wrap stages | blowup 3 (8x), 22 bits of grinding, stacking 18 and 21 | `fri_params.rs:17,18,7`, `components.rs:37-40` |
|
||||
| Recursion arity | 4 proofs per compose step (`DEFAULT_MAX_COMPOSE_ARITY = 4`, `DEFAULT_MAX_REDUCE_ARITY = 4`) | `sp1-prover-6.8.1/src/worker/config.rs:183,193` |
|
||||
| Workers | 4 core workers, 8 recursion prover workers, 4 recursion executors, 4 deferred workers, buffers of 4 to 8 | `worker/config.rs:188-205` |
|
||||
|
||||
The stages a shard goes through (`sp1-prover-6.8.1/src/worker/controller/*.rs`): execute (the RISC-V executor cuts the
|
||||
run into core shards at the thresholds above); core (one jagged-PCS proof per core shard, on the GPU); normalize and
|
||||
compose (each core proof is verified inside a recursion program, then proofs are folded 4 at a time until one remains,
|
||||
the "compressed" proof, 1,272,897 bytes for every shard we have proven, bench-log 4 and 5 October); deferred (what the
|
||||
aggregator uses: `verify_sp1_proof` inside a guest, `proving/igneum-prove/aggregator/src/main.rs`); shrink and wrap
|
||||
(to a BN254 STARK, then Groth16 or Plonk; not run here, ledger P3).
|
||||
|
||||
### 1.2 The terms, and which scale with the shard
|
||||
|
||||
Every STARK-family prover holds these buffers on the device at its peak, in some order and with some overlap. The
|
||||
sizes below are from the constants of 1.1 and the allocation code of `sp1-gpu`; where a buffer's size is the actual
|
||||
trace rather than the maximum, the row says so.
|
||||
|
||||
| Term | What it is | Size rule | SP1 6.8.1 on a 32 GB card | Scales with the shard? |
|
||||
|---|---|---|---|---|
|
||||
| Main trace | the witness: one element per cell of every table the shard touched | `cells x 4 bytes`, where cells = sum over tables of rows x columns; the executor cuts a new core shard at 402,653,184 cells | the device buffer is allocated at the MAXIMUM, not the actual trace: `allocate_and_initialize_traces` takes `max_trace_size` and allocates `max_trace_size` felts plus `max_trace_size / 2` u32 of index (`sp1-gpu/crates/jagged_tracegen/src/lib.rs:484-503`), 6 bytes a cell; the core prover's `max_trace_size` is `element_threshold + 2^21` (`prover_components/src/builder.rs:70-71`): **2.26 GiB** on a card over 30 GB, 1.61 GiB on a 24 GB card (the threshold drops by 2^26 + 2^25 + 2^24 when memory is 30 GB or under, `builder.rs:41-45`) | no: fixed at the maximum shard, whatever the trace |
|
||||
| Preprocessed trace | the program's own tables (the ELF as a `Program` AIR, the byte and range tables) | program size x its columns plus 2 x 2^16-class tables | small for a 2.8 MB guest ELF (`elf/manifest.json`); not isolated | with the guest, not the shard |
|
||||
| Codeword (the LDE) | the stacked polynomial encoded at rate 1/4 for BaseFold | `stacked cells x 4 (blowup) x 4 bytes`; the stacked length is the actual cell count padded to a multiple of 2^21 | 4.7 M cycles: approximate, the actual trace; 60 M cycles: the shard is 7 to 15 core shards of up to 402 M cells, each encoded to 6 GiB at the blowup, one or more in flight | yes, up to the core-shard cap; past the cap the shard count grows and the per-shard term stays |
|
||||
| Merkle commitment | Poseidon2 hashes of the codeword rows | `rows x 8 elements x 4 bytes x 2`, rows = 2^21 x blowup | about 0.5 GiB at full stacking, approximate | with the stacked rows |
|
||||
| Zerocheck and GKR | the constraint sumcheck over the extension field, and the LogUp GKR layers | extension elements are 16 bytes; the sumcheck holds a folded copy of the trace in the extension field, which is 4x the base trace at the first round and halves each round | up to about 4x the live trace in the first round, approximate (`sp1-gpu/crates/zerocheck/src/primitives.rs:174,287`: `Buffer<Ext>` of `new_total_length`) | yes |
|
||||
| Recursion traces | the normalize and compose programs' own traces, verifying core proofs | fixed-shape programs: `RECURSION_TRACE_ALLOCATION = 2^27` cells (`builder.rs:15`), allocated at 6 bytes a cell: **0.75 GiB** per recursion prove, at blowup 4 a 2 GiB codeword plus its own zerocheck | fixed per recursion step; the number of steps is log4 of the core-shard count | no (per step) |
|
||||
| Shrink and wrap traces | the two last stages, not run by us | 2^25 and 85,376,340 cells (`builder.rs:16,19`): 0.19 and 0.48 GiB | only when wrapping | no |
|
||||
| Proving keys and program cache | the recursion programs (`vk_map.bin`, the normalize cache of 5 programs) and the shard program's setup | `DEFAULT_NORMALIZE_PROGRAM_CACHE_SIZE = 5` (`worker/config.rs:192`); the key setup took 14.6 s on the 5090 (bench-log 4 October) | not isolated | no |
|
||||
| Pinned host buffers | the staging copies on the PC side | 4 core workers x `max_trace_size` x 4 bytes = **6.0 GiB** of pinned RAM, plus 4 x 0.5 GiB for recursion (`prover_components/src/components.rs:99-103`, `builder.rs:76,95`) | this is the 7.9 GB WSL2 working set measured on 5 October | no |
|
||||
| The allocator | CUDA's default memory pool with its release threshold set to `u64::MAX` (`sp1-gpu/crates/cuda/src/task.rs:152,190-199`): freed blocks are never returned to the driver | `nvidia-smi` therefore reports the high-water mark of everything above, and it stays until the server exits | this is why the memory curve is flat between shards of different size | no |
|
||||
|
||||
Two facts from this table explain the measurements:
|
||||
|
||||
1. **The server refuses small cards by code.** `local_gpu_opts()` reads the card's total memory, adds 4 GB, and panics
|
||||
under 24: `"Unsupported GPU memory: {gpu_memory_gb}, must be at least 24GB"` (`sp1-gpu/crates/prover_components/src/builder.rs:35-38`).
|
||||
A 16 GB card (16 + 4 = 20) and a 12 GB card (16) never start; a 20 GB card is the smallest that does. The 13.9 GB
|
||||
floor measured on the 5090 is therefore not the whole story for a 12 GB card: on this build the card is refused
|
||||
before any buffer is allocated. Any route through SP1's GPU server starts by removing this line.
|
||||
2. **The environment knobs do not reach the floor** because the same function overwrites `element_threshold` with the
|
||||
compile-time constant (`builder.rs:41-48`); only `HEIGHT_THRESHOLD` passes through, which is why the sweep's
|
||||
`ELEMENT_THRESHOLD 2^26` rows changed nothing and `HEIGHT_THRESHOLD 2^20` took 5.4 GB off the 60 M-cycle shard
|
||||
(bench-log, "the 12 GB requirement", 5 October 2026). The worker counts only slow the proof (11.4 s to 20.8 s)
|
||||
because the device buffers are sized by `max_trace_size`, not by the worker count.
|
||||
|
||||
### 1.3 Why the floor is 13.9 GB for an empty shard
|
||||
|
||||
With the release threshold at `u64::MAX`, the peak is the high-water mark over the whole pipeline. For an empty shard
|
||||
the core trace is small (280,706 cycles), so the fixed-shape terms dominate: the 2.26 GiB main-trace buffer allocated
|
||||
at the maximum, the recursion step over a fixed-shape normalize program (a 0.75 GiB trace buffer, its 4x codeword in
|
||||
the extension field for the zerocheck, its Merkle tree), the proving-key and program caches, and the deferred and
|
||||
compose machinery that a compressed proof always runs once. The decomposition of the 13.9 GB into those terms is
|
||||
approximate until the prover-floor agent's profile lands (branch `prover-floor`, tonight): the figure that is not
|
||||
approximate is that none of it is the witness (5 to 22 KB a shard) and none of it is the shard's cycles (the same
|
||||
13.9 GB at 280 k cycles and 556 k cycles, bench-log "the S_p curve").
|
||||
|
||||
The step from 13.9 GB (empty) to 20.4 GB (4.7 M cycles) is the live trace: the 4.7 M-cycle shard is one core shard
|
||||
(its trace area is under 402 M cells, so it was not split; approximate from the memory curve, the cell count is not
|
||||
logged by the host), and its codeword, zerocheck and GKR buffers are sized by its actual cells. The step from 20.4 GB
|
||||
to 28.3 GB (20 M cycles and up) is the second and later core shards in flight at once: 4 core workers with a buffer of
|
||||
4 (`worker/config.rs:188,189`) let several core shards' codewords exist at the same time; past 20 M cycles the pipeline
|
||||
is full and the peak is flat, which is what the curve shows (28,371 MiB at 20 M cycles, 28,307 at 40 M and 60 M).
|
||||
|
||||
### 1.4 The theoretical floor for our guest at the adopted shard
|
||||
|
||||
If every buffer were sized to the shard rather than to the maximum, the adopted v1 shard (4.7 M cycles) would need,
|
||||
approximate, from the rules of 1.2:
|
||||
|
||||
| Term | Rule | Approximate bytes |
|
||||
|---|---|---|
|
||||
| Main trace, actual | 4.7 M cycles x about 60 cells a cycle (the `Add` and `Addi` tables cost 33 and 30 columns a row, a memory access adds 20 and a global interaction 241: `sp1-core-executor-6.8.1/src/artifacts/rv64im_costs.json`) | about 280 M cells, 1.1 GB |
|
||||
| Codeword at blowup 4 | 4x | 4.5 GB |
|
||||
| Zerocheck first round in the extension field | 4x base, halving each round | 4.5 GB at the peak round, falling |
|
||||
| Merkle tree | rows x 32 bytes x 2 | 0.3 GB |
|
||||
| Recursion step, fixed | 2^27 cells x 6 bytes plus its 4x codeword and extension copies | 2 to 3 GB, approximate |
|
||||
| Keys and caches | | under 1 GB, approximate |
|
||||
| Peak, if the core stage and the recursion stage do not overlap and the pool releases | | **about 10 to 11 GB**; about 6 GB if the blowup-4 codeword is replaced by a rate the sumcheck does not need (see 2.4) |
|
||||
|
||||
So the adopted shard is, on paper, a 12 GB card's shard with the server re-sized and nothing else changed, and it is a
|
||||
12 GB card's shard with 4 GB to spare if the shard is halved (`S_p` 15,000 pgas, 2.4 M cycles: the planner cuts at
|
||||
transaction boundaries to any budget, `core/src/plan.rs`, and the fee switch of 5 October already moved `S_p` once).
|
||||
What the paper figure does not say is the time: a smaller card proves slower, and 60 s with the miner running is the
|
||||
bound (section 3). The prover-floor agent is measuring the real figure; this section says what it should find and why.
|
||||
|
||||
### 1.5 RISC Zero's anatomy, for comparison
|
||||
|
||||
RISC Zero is the FRI STARK the textbooks describe, and its constants make the same table easy to read
|
||||
(`~/.cargo/registry`, `risc0-zkp-3.0.4/src/lib.rs`, `risc0-circuit-rv32im-4.0.4/src/zirgen/defs.rs.inc`):
|
||||
|
||||
| Term | Value | Where |
|
||||
|---|---|---|
|
||||
| Field | BabyBear, 31 bits; extension degree 4 | `risc0-core` |
|
||||
| Segment size | `DEFAULT_SEGMENT_LIMIT_PO2 = 20` (1,048,576 cycles), `MIN_CYCLES_PO2 = 13`, `MAX_CYCLES_PO2 = 24`; `DEFAULT_MAX_PO2 = 22` for the verifier | `risc0-circuit-rv32im-4.0.4/src/execute/mod.rs:39`, `risc0-zkp-3.0.4/src/lib.rs:35-38`, `risc0-zkvm-3.0.4/src/receipt.rs:884` |
|
||||
| Trace width | data 211 + accum 103 + code 1 = 315 columns; globals 90, mix 36 | `defs.rs.inc:7-11` |
|
||||
| Blowup | `INV_RATE = 4`; 50 queries; FRI fold 16 | `risc0-zkp-3.0.4/src/lib.rs:41-51` |
|
||||
| Recursion | lift, join and resolve programs at `RECURSION_PO2 = 18` rows | `risc0-zkvm-3.0.4/src/host/recursion/prove/mod.rs:58` |
|
||||
| The GPU buffers | `check + ctrl + data + accum + mix + out` elements x 4 bytes, printed by the CUDA HAL at `eval_check` | `risc0-circuit-rv32im-4.0.4/src/prove/hal/cuda.rs:181-200` |
|
||||
|
||||
From those constants the trace of a segment is `2^po2 x 315 x 4` bytes and its LDE 4x that, so, approximate: po2 18 is
|
||||
0.3 GB of trace and 1.5 GB with the LDE, po2 19 is 3.1 GB, po2 20 is 6.2 GB, po2 21 is 12.3 GB, before the check
|
||||
polynomial, the extension-field accumulators and the Merkle trees. Two things follow. A RISC Zero segment at the
|
||||
default 2^20 is in the same class as one SP1 core shard, not smaller. And a RISC Zero segment at 2^18 or 2^19 is a
|
||||
2 to 4 GB object: the only reason a 12 GB card could not prove one is the fixed overhead of the recursion circuits
|
||||
(2^18 rows each) and the allocator, which is the measurement the prover-floor agent takes on PC 2 if SP1 cannot go
|
||||
under 11 GB. Section 2.2 carries the documented numbers.
|
||||
|
||||
### 1.6 Which terms the shard size can move, and which it cannot
|
||||
|
||||
| Lever | Moves | Does not move |
|
||||
|---|---|---|
|
||||
| Our `S_p` (pgas per shard) | the live trace, the codeword, the zerocheck: everything in 1.2 marked "yes" | the maximum-sized buffers, the recursion step, the keys, the pool |
|
||||
| SP1's `HEIGHT_THRESHOLD` (the one knob the server honours) | the rows per table in one core shard, so the live buffers | the fixed terms (measured: 13,861 MiB on the empty shard with every knob at its minimum) |
|
||||
| A server patch: size `max_trace_size` to the shard, release the pool, one core worker | the 2.26 GiB buffer, the high-water mark, the in-flight count | the recursion step's fixed shape and the key caches |
|
||||
| A different proof system | the blowup (sumcheck-only and linear-code systems have none, 2.4), the recursion shape | the trace itself: a RISC-V cycle costs tens of cells in every zkVM |
|
||||
|
||||
## 2. Every current proving route
|
||||
|
||||
Read 5 October 2026, 22:10 to 23:00 UTC, by four research agents and this one; every cell names its page or file.
|
||||
"Not documented" means the project publishes no figure, which for a memory floor is itself the finding.
|
||||
|
||||
### 2.1 The zkVMs with a GPU prover
|
||||
|
||||
| Prover | Proof system, field, chunk | GPU support and the documented minimum memory | Throughput, on what | Verification of the recursive proof; wrapper | Licence | State, October 2026 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **SP1 6.8.1** (ours) | Hypercube: multilinear, jagged PCS, BaseFold, LogUp GKR; KoalaBear; core shards of up to 2^24 cycles and 402 M cells (section 1) | CUDA only. Docs: "24GB or more VRAM", compute capability 8.0+, Linux x86_64 (docs.succinct.xyz, hardware-acceleration page). Code: panic under 20 GB physical (`builder.rs:35-38`). Measured here: 13.9 GB floor, 20.4 GB at the adopted shard, 28.3 GB at the prototype shard. Issue #2950: two clients on a 48 GB L40S hold 41 to 43 GB; a single 6 GiB tensor allocation failed | 4.3 s for the adopted shard, 10.8 s for the prototype one on a 5090 (bench-log); Succinct: 99.7% of Ethereum blocks under 12 s on 16 x RTX 5090 (blog.succinct.xyz, 18 Nov 2025) | compressed proof 1,272,897 bytes, verified in 0.032 to 0.040 s here (`--mode verify-segment`); Groth16 about 260 bytes and about 270 k gas, Plonk about 868 bytes and 300 k gas (docs, proof-types page); the Groth16 wrap needs about 14 GB of host RAM, Plonk about 60 GB (hardware-requirements page) | Apache-2.0 or MIT for the repository including `sp1-gpu/` (`LICENSE-APACHE`, `LICENSE-MIT` at the root; no separate licence under `sp1-gpu/`); `sp1-cluster` is Business Source 1.1 | v6.8.1 of 24 Sep 2026 is the latest tag; mainnet for Ethereum proving since 19 Feb 2026 (blog); AMD port PR #2668 closed unmerged 20 Mar 2026; no Metal, Vulkan or WebGPU |
|
||||
| **RISC Zero 3.0.x** | FRI STARK (DEEP-ALI), BabyBear, Poseidon2, blowup 4, 50 queries; segments of `2^po2` cycles, default po2 20, allowed 13 to 24 (section 1.5); lift, join, resolve recursion at 2^18 rows; keccak as a separate circuit | CUDA and **Metal** (`risc0/sys/kernels/zkp/metal/`; on Apple silicon the Metal path is on automatically, `risc0/zkvm/build.rs`). Documented memory per segment: Bento design page, 1 M cycles 9 to 10 GB, 2 M 17 to 18 GB, 4 M 32 to 34 GB; Boundless performance page, the largest `SEGMENT_SIZE` per card: 8 GB card po2 19, 16 GB po2 20, 20 GB po2 21, 40 GB po2 22; docs: "less than 10 GB available: change the segment size limit" (dev.risczero.com, local proving); `env.rs:190-192`: "lowering this value by 1 will cut memory consumption by about half". PR #3761 (June 2026): po2 22 did not fit a 24 GB 4090 until the `low_vram` buffer reuse | 4090: 808 kHz at po2 21, 1,207 kHz at po2 22 with PR #3761 (end to end to a succinct receipt); Apple M2 Pro about 14 kHz on the 2023 datasheet, approximate (the page was unreachable tonight); real-time Ethereum on about 160 x 4090 (blog, approximate) | succinct receipt 222,668 bytes, constant; about 100 ms to verify, approximate (`gsr-stark-verifier` PR #5, mirrored docs); Groth16 seal 256 bytes, about 200 to 300 k gas, approximate; the Groth16 wrapper is x86 only, not on Apple silicon (docs) | Apache-2.0 or MIT, CUDA and Metal kernels included (`risc0/sys/kernels/zkp/cuda/eltwise.cu:1-13`); Bento is BSL 1.1 with a change date already passed | v3.0.6 of 17 Jul 2026 on the maintained line; `main` is 5.0.0 with no release body; `RISC0_PROVER=actor` multi-GPU scheduler experimental since 3.0.1 (`r0vm/src/actors/factory.rs:183-195` carries measured per-po2 memory tokens: po2 18 = 8, 19 = 10, 20 = 15, 21 = 24; lift and join = 3) |
|
||||
| **Airbender** (Matter Labs) | DEEP STARK, FRI, Mersenne31; chunks of 2^22 cycles; Boojum then FFLONK wrap (docs.zksync.io, airbender page) | CUDA only. "any GPU with 22GB RAM" for production (zksync.io/airbender); the code has memory presets `GiB21` (24 GB cards) and `GiB30`, raised from 29 because Ethereum blocks failed to allocate at 29 GiB (PR #448, `gpu/execution_prover/src/prover/config.rs`); the final SNARK is CPU with about 150 GB of RAM (`docs/gpu.md`) | one H100: 21.8 MHz base layer, 8.5 MHz end to end, about 35 s an Ethereum block (June 2025 post); ethproofs.org today: 4 x 5090 2.3 s average | FFLONK over BN254 on chain; gas not published | MIT or Apache-2.0 | v0.6.0-rc.2; Veridise audit Feb to Apr 2026; live for ZKsync Atlas chains |
|
||||
| **ZisK** (Polygon spin-out) | eSTARK over Goldilocks (pil2-stark), Poseidon2, approximate; main instance 2^22 to 2^23 rows, chunks of up to 2^22 steps (PR #1238) | CUDA only, CUDA 12.9+; **no VRAM floor documented**; workers need about 32 GB of host RAM, the assembly emulator 64 GB (docs, limits and distributed pages); Cysic's Venus fork submits from one RTX 4090 | 4 x 5090: p99 9.62 s on Ethereum blocks (Aug 2026); 24 x 5090 6.56 s average (Nov 2025) | PLONK wrapper verified by Solidity (`zisk-contracts`); 128-bit claimed | Apache-2.0 or MIT | v1.3.1-alpha, 30 Sep 2026, "undergoing security and correctness audits" (README) |
|
||||
| **OpenVM 2.0** (Axiom) | SWIRL: sumcheck, zerocheck, LogUp GKR, stacked reduction into WHIR; BabyBear; segments by metered trace height (blog.openvm.dev/2.0) | CUDA only; "at least 24GB of VRAM": L40, 4090, L40S, 5090 (blog.openvm.dev/openvm-gpu) | 11.4 MHz on one 5090, 139 MHz on 16; 2.1 preview: 4 x 5090 p99 9.7 s | STARK proof under 300 KB; Halo2-KZG wrapper, 316 k gas; the Halo2 wrap 8.1 s on a 5090 | MIT or Apache-2.0, GPU prover included | v2.0.2 of 14 Aug 2026; zkSecurity audit of SWIRL; Scroll's prover builds on it |
|
||||
| **Pico** (Brevis) | Plonky3 STARK, KoalaBear default; chunk size a parameter with no documented default | CUDA via `pico-gpu`; **no VRAM figure published**; every run on 32 GB 5090s | Prism 2.1: 16 x 5090 over two machines, 4.87 s average on Ethereum blocks | Groth16 via gnark | core MIT or Apache; **`pico-gpu` is BUSL-1.1** and "not recommended for production" (its README) | v2.1.2, Aug 2026; Sherlock audit |
|
||||
| **Ziren** (ZKM, MIPS) | Plonky3-class, KoalaBear, LogUp GKR, WHIR | CUDA 12, compute capability 8.6+, "24 GB VRAM or higher"; the GPU prover is a Docker image pinned by digest, source "planned H1 2026" (docs.zkm.io prover page; an independent evaluation of v1.1.4 says the GPU path is not open) | one 5090: 5.9 MHz on a 288 M-cycle block; 4 GPUs 3.1 to 3.3x | compressed proof 603 KiB; Groth16 or PLONK | core MIT or Apache; GPU image licence unspecified | v1.2.7; no public audit cited |
|
||||
| **Stwo / S-two** (StarkWare) | Circle STARK over Mersenne31; blowup 1 (rate 1/2), 70 queries, 26 bits of grinding | **CPU SIMD first** (AVX2, AVX-512, NEON, WASM); GPU through ICICLE-Stwo (Ingonyama): about 3 GB of trace in GPU memory, out of memory from 2^23 rows; a WebGPU port of the constraint evaluation (zkSecurity blog, April 2025); client-side proving under 1 GB after a spill allocator (third-party PR) | 620 k Poseidon2 a second on an M3 laptop | via a Cairo verifier (proofs of proofs); sizes not published here | Apache-2.0 | live on Starknet mainnet since 3 Nov 2025; no RISC-V guest of its own (Nexus 3.0 is the RISC-V zkVM on it, BUSL-1.1 until 2029) |
|
||||
| **Jolt** (a16z) | sumcheck and lookups (Lasso lineage, Twist and Shout memory checking); PCS Dory over BN254 by default, or **Akita**, a lattice commitment over a 128-bit prime field (Sep 2026, "Lattice Jolt"); RV64IMAC; no continuations (the book's recursion page is "under construction") | **No CUDA in the public repository** (LayerZero's "Jolt Pro" CUDA port is private); **Metal**: PR #1938 merged 30 Sep 2026 (the `jolt-metal` runtime crate), PR #1733 (the full prover on Metal) still a draft. Memory: "about 200 bytes per cycle" with Akita (a16z substack, Sep 2026); the book: "under 2 GB of memory per million cycles"; a streaming prover bounded to "a few GBs" is planned, not shipped | over 2 M cycles a second on a laptop CPU with Akita, over 10 M with Metal on a Apple laptop (a16z substack, Sep 2026); PR #1733: M5 Max, 2^25 cycles in 19.8 s, 2^27 in 77 s at an 89.4 GiB footprint | proof about 50 KB (Dory) or 65 to 80 KB (Akita); verify sub-second, approximate; on-chain 1.3 to 2 M gas estimated in 2024; no Groth16 wrapper shipped | MIT or Apache-2.0 | `v0.3.0-alpha` is the last tag (1 Oct 2025); README: "not suitable for production use"; no audit |
|
||||
| **Ceno** (Scroll) | GKR tower prover, BabyBear, WHIR or BaseFold PCS; RV32IM | CUDA, but the real HAL is in a **private** `ceno-gpu` repository (the public one is a mock); no memory numbers | 2 GPUs 1.6x over one (PR #1403, Sep 2026) | via OpenVM recursion to Halo2 | Apache-2.0 | README: "under construction and not suitable for use in production" |
|
||||
| **Nexus 3.0** | on Stwo (Circle STARK, M31) | no GPU path documented | none published | not published | **BUSL-1.1** until 10 Feb 2029 | last push 6 Jan 2026; folding (Nova family) abandoned June 2025 for the STARK |
|
||||
| **Valida** (Lita) | Plonky3 STARK | no GPU; a CUDA port "underway" in July 2025 | none current | not published | Apache or MIT | dormant since Sep 2025; documented soundness issues in its own benchmarks page |
|
||||
| **Powdr** | no longer a zkVM: `powdrVM` archived; powdr is autoprecompiles on OpenVM | OpenVM's | OpenVM's | OpenVM's | MIT or Apache | tooling layer; "DO NOT USE FOR PRODUCTION" |
|
||||
| **Binius / Binius64** (Irreducible) | binary-field SNARK, BaseFold-style FRI over GF(2^64) words | CPU SIMD only; the FPGA work was dropped 9 Sep 2025 ("FPGAs underperformed GPUs"); no GPU | ECDSA aggregation about 5x over SP1 and R0VM on L40S GPUs, on CPU (the page carries methodology corrections) | hash-based; no recursion shipped | Apache-2.0 or MIT | **the company shut down 12 Nov 2025**; the only zkVM on it (PetraVM) is archived |
|
||||
| 2026 entrants | Cysic Venus (a ZisK fork with cudaGraph tuning and an FPGA backend, Apache or MIT, "do not use in production"); Zilkworm (Erigon's C++ guest on Airbender, 2 x 5090 9.3 s); zkDTVM (evmone guest, 4 x 5090 4.7 s, no public docs); Delphinus zkWasm (Halo2 on BN254, a 4090 minimum plus 58 GB of host RAM); Miden (Goldilocks STARK, client-side, Metal via `miden-gpu`, mainnet alpha planned); Boojum (2023 claim of proving on a 16 GB card, superseded by Airbender) | none states a floor under 24 GB on a GPU | | | | |
|
||||
|
||||
The reading of the table. No shipped zkVM documents a GPU floor under 24 GB except RISC Zero, whose memory is a
|
||||
function of a runtime knob (`segment_limit_po2`) and is published per card size by Boundless. SP1's 24 GB is a line
|
||||
of code, not a property of the proof system: Airbender, OpenVM and Pico all pad to the card they tune on, and all
|
||||
three say 24 or 32 GB because their market is Ethereum blocks on 5090 clusters. The real-time race has collapsed to 2
|
||||
to 4 consumer cards per block (ethproofs.org, 5 October 2026), which is why nobody is tuning for a 12 GB card: the
|
||||
customer buys 5090s. Igneum's customer is the miner who already owns the card, so Igneum has to do the tuning itself.
|
||||
|
||||
### 2.2 The sumcheck and GKR family against FRI STARKs, in memory terms
|
||||
|
||||
| Family | What it holds at the peak | Blowup | The GPU figure today | Source |
|
||||
|---|---|---|---|---|
|
||||
| FRI STARK (RISC Zero, Airbender, ZisK, Pico, Stwo, SP1 3 and 4) | trace, its Reed-Solomon codeword at the blowup, the Merkle trees, the DEEP quotient in the extension field | 4x (RISC Zero, Airbender), 2x (SP1 3 and 4, approximate), 2x (Stwo at rate 1/2) | RISC Zero: 9 to 10 GB per 1 M cycles (Bento) | section 1.5; Boundless pages |
|
||||
| Sumcheck with a hash-based PCS (SP1 Hypercube, OpenVM SWIRL, Ceno, Ziren) | the trace as multilinears, the extension-field folded copies of the zerocheck and GKR, and the BaseFold or WHIR codeword of the stacked polynomial (still a Reed-Solomon encoding, at 4x in SP1, section 1.1) | 4x of the stacked data in SP1; WHIR's rate is a parameter | SP1: the fixed 13.9 GB plus about 6.5 GB for a 4.7 M-cycle shard (measured) | section 1 |
|
||||
| Sumcheck with a curve or lattice PCS (Jolt) | the trace and the one-hot columns; **no codeword at all**: Dory commits by MSM and Akita by lattice hashing, so memory is bytes per cycle with no blowup | none | no GPU figure: 200 bytes a cycle on CPU (Akita), so the adopted 4.7 M-cycle shard is about 0.9 GB of prover RAM, approximate (derived) | a16z substack, Sep 2026; the Jolt book, streaming page |
|
||||
| GKR (Expander, Ceno) | the circuit witness layer by layer; no codeword for the inner layers | none inside; a PCS for the inputs | Expander: 16 MB per Keccak, approximate | Polyhedra blog (returned 530 tonight) |
|
||||
| Linear-code PCS (Ligero, Brakedown, Ligerito, Blaze) | one encoded matrix and one Merkle tree; linear time, no FFT | rate 1/2 to 1/4 | no prover memory benchmarks found; Linea's Vortex is the only production use | eprint 2021/1043, 2025/1187, 2024/1609; `linea-monorepo/prover/protocol/compiler/vortex` |
|
||||
| Binius (binary field) | words of GF(2^64) and a BaseFold FRI | 2x to 4x | none; CPU only; company closed | irreducible.com posts |
|
||||
|
||||
The memory law in one line: a FRI or BaseFold prover holds `blowup x trace` plus the trace itself plus extension-field
|
||||
working copies, so 8 to 12 bytes per cell at the peak; a Jolt-class prover holds the trace and its lookups at about 4
|
||||
bytes per cell and commits without encoding. The figure that matters for us is not the ratio but the absolute: our
|
||||
adopted shard is small enough (about 280 M cells, section 1.4) that a FRI-class prover sized to it fits a 12 GB card,
|
||||
and a Jolt-class one fits a phone. The reason SP1 does not fit today is section 1.3, not the proof system.
|
||||
|
||||
### 2.3 Folding schemes
|
||||
|
||||
| Scheme | Prover memory per step | The verifier at the end | Field | GPU | Fit for Igneum |
|
||||
|---|---|---|---|---|---|
|
||||
| Nova, SuperNova, HyperNova, ProtoStar, Mova; Sonobe as the library | one step's witness plus the running instance: tiny by construction (eprint 2021/370) | an IVC proof of O(F) group elements, compressed by a SNARK: Sonobe's decider is Groth16 over BN254 with KZG, about 11.9 M constraints for a 500 k-constraint step (sonobe.pse.dev, decider page); MicroNova about 2.2 M gas (eprint 2024/2099) | curve cycles (Pasta, BN254 and Grumpkin) | partial: sppark MSM on Pasta, a GPL-3 `cuda-nova` for BN254; Sonobe lists GPU as a plan | **no**: a RISC-V step over a 256-bit curve cycle costs two MSMs per step, the opposite of the hash-based consumer-card design decision (design 5.6), and the verifier changes to pairings |
|
||||
| Nexus zkVM 1 and 2 | the prover ran "on as little as 1 GB of RAM" (whitepaper, approximate) | a curve SNARK | curve cycle | none | **abandoned by its own author**: Nexus 3.0 (25 June 2025) moved to a Circle STARK, "proofs are smaller, faster to generate" (StarkWare blog) |
|
||||
| LatticeFold, LatticeFold+, Neo, SuperNeo; Nightstream as the zkVM | one step plus an accumulator, lattice commitments over 64-bit fields | a Spartan-class decider | Goldilocks named; BabyBear and KoalaBear not | none; Nethermind's LatticeFold is a "proof-of-concept prototype" whose benches take 48 h; Nightstream is "research software, not production-ready" with its RV32IM prototype removed | **not before 2027 at the earliest**; the first candidate that folds small-field STARK steps |
|
||||
| Arc, WARP (hash-based accumulation of Reed-Solomon proximity claims) | small: Merkle openings per step (eprint 2024/1731, 2025/753) | a FRI-style accumulator check | any STARK field | none; no public implementation found | the right primitive on paper for folding RISC-V STARK shards with a hash-based verifier; nothing to adopt |
|
||||
| Mangrove, Nebula | 390 MB peak at 2^24 gates (Mangrove, eprint 2024/416); pay-per-use steps (Nebula) | curve SNARK | curve cycle | none | research |
|
||||
|
||||
What folding would mean for a shard: the shard prover would hold one transaction's step at a time and the memory
|
||||
floor would vanish; the price is a curve-based decider at the end of every shard (seconds on a CPU, a different
|
||||
verifier in the node, pairings on the light-client path), and no production code over our field. Today's small-field
|
||||
zkVMs get their bounded memory from segmenting and recursion (2.4), not from folding. Folding is a watch item, not a
|
||||
route.
|
||||
|
||||
### 2.4 Continuations and segment proving at small sizes
|
||||
|
||||
| Prover | The segment knob | What a 2^18 or 2^19 segment costs | Can our shard be cut that way inside the guest? |
|
||||
|---|---|---|---|
|
||||
| RISC Zero | `segment_limit_po2`, runtime, 13 to 24 (`env.rs:181-186`); the recursion lifts every segment at 2^18 rows and joins them in a tree | po2 19 is the documented fit for an 8 GB card and po2 20 for a 16 GB card (Boundless); the scheduler's measured tokens put po2 18 at about a third of po2 21 and a lift or join at an eighth (`factory.rs`); the 4.7 M-cycle shard at po2 19 is 9 segments, 9 lifts and 8 joins | yes, with no guest change: the zkVM cuts at the limit on its own; the shard statement is unchanged and one succinct receipt comes out |
|
||||
| SP1 | `HEIGHT_THRESHOLD` (honoured) and `ELEMENT_THRESHOLD` (overwritten by the server, section 1.2); `SHARD_SIZE` up to 2^24 cycles | the live buffers shrink (22.9 GB against 28.3 GB on the prototype shard at `HEIGHT_THRESHOLD 2^20`, bench-log) and the fixed 13.9 GB does not; the compose tree folds 4 proofs at a time | yes, the same way; but the floor is the server's, so the cut buys nothing until the server is re-sized (route A) |
|
||||
| Our own planner | `S_p` in pgas, a consensus parameter changed by the fee-switch pattern (`docs/plans/fee-switch-devnet.md`); the cut is at transaction boundaries (`core/src/plan.rs`) | halving `S_p` halves the live trace and doubles the shard count; the aggregator verifies one deferred proof per shard (1.66 M cycles for 4 shards, bench-log 4 October) and the chained aggregation is one per block whatever the count (9.7 s on a mining 5090) | yes, already implemented; a transaction above `S_p` stays one shard and the zkVM's own continuations cover it (spec 7.6 item 1) |
|
||||
| Jolt | none: monolithic; streaming planned | n/a | no |
|
||||
|
||||
### 2.5 Distributed proving across several small cards
|
||||
|
||||
| System | How one execution is split | Per-card memory | Several cards on one host | What it means for four 12 GB cards |
|
||||
|---|---|---|---|---|
|
||||
| SP1 cluster (`sp1-cluster`, BSL 1.1) | by core shard: `ProveShard`, `RecursionReduce`, `RecursionDeferred` and `ShrinkWrap` tasks go to GPU workers, `CoreExecute` and the Groth16 or Plonk wrap to CPU workers (`crates/prover-types/src/lib.rs:31-41`); artifacts through Redis and S3 | "only certain GPUs with >= 24GB RAM are supported" (`infra/charts/sp1-cluster/values-example.yaml`); one task holds one whole card | yes: one GPU node process per card (`gpu{0..7}` services in the docker-compose deployment page); the local server itself supports device 0 only (`task.rs:160`, "only device 0 is supported at the moment"), one server per `CUDA_VISIBLE_DEVICES` | the split is by core shard, and the adopted shard is ONE core shard (section 1.3), so there is nothing to split across cards; the floor per card is unchanged. The cluster is a throughput tool, and its code is BSL |
|
||||
| RISC Zero Bento (Boundless) | by segment onto Redis; `gpu_prove_agent` spawns one prove agent per card with `CUDA_VISIBLE_DEVICES`; the same `SEGMENT_SIZE` for every card, "the lowest common denominator"; joins form a tree (docs.boundless.network, performance-optimization and bento pages; `compose.yml:62-113`) | by `SEGMENT_SIZE` (2.1): 8 GB po2 19, 16 GB po2 20 | yes, documented: one 16 GB card 264 kHz, two 431 kHz (sub-linear, "bound by bus bandwidth, memory") | **the one documented configuration**: four 12 GB cards at po2 19 or 20 take segments off one queue and the joins fold them; the per-card floor is the segment, and the cost of small segments is the lift and join count (9 lifts and 8 joins for the adopted shard at po2 19) |
|
||||
| RISC Zero `RISC0_PROVER=actor` | one process, several cards, a token budget per card from measured memory per po2 (`r0vm/src/actors/factory.rs`) | per po2 | yes, experimental since 3.0.1 | the same model without Bento's services |
|
||||
| Pico Prism 2.0 | a global task queue across two machines, 16 x 5090, 100 Gbps between them (Brevis blog, May 2026) | not published | yes | no figure |
|
||||
| OpenVM | metered execution on the CPU, segments to GPUs, an aggregation tree, "clusters with hundreds of GPUs" (docs, distributed-proving page) | 24 GB | yes | no 12 GB path |
|
||||
| ZisK | coordinator and stateless workers; "splits the trace into pieces, proves each in parallel on separate machines, and aggregates"; the first worker aggregates a binary tree (docs, distributed execution page) | not documented | yes, `--gpu` per worker | no figure |
|
||||
| Ceno | shards round-robin by `shard_id % device_count`; a shard never split across cards; one CUDA context per device (PR #1403) | not stated | yes | the same model |
|
||||
| Column-split of one trace across cards (FRIttata eprint 2025/1285, HyperFond 2025/1349, deVirgo arXiv 2210.00264, Pianist 2023/1271, Cirrus 2024/1873, SumFold 2025/1653) | the sumcheck or FRI itself is distributed, each worker holding a slice of the columns or rows and exchanging small messages | a slice | research code or CPU clusters only | nothing shipped; the one route that would let four 12 GB cards hold what one 32 GB card holds for a SINGLE core shard, and nobody has it in a zkVM |
|
||||
|
||||
The reading. Every shipping system splits by rows (segments, shards, chunks), proves each on one card, and folds
|
||||
with recursion. So "four 12 GB cards do what one 32 GB card does" is true for throughput (four shards in flight, or
|
||||
four segments of one shard, then a join tree) and false for a single unit that exceeds one card: that unit must be cut
|
||||
smaller, by the zkVM's segment knob (RISC Zero) or by our planner (`S_p`). For Igneum the units are already small and
|
||||
independent (a shard, assigned by sortition), so the rig's natural mode is one prover process per card, each taking
|
||||
its own shard. The distributed route therefore costs nothing in protocol and lands as an app change (section 3, route C).
|
||||
|
||||
### 2.6 Proof systems that run on AMD or Apple
|
||||
|
||||
| Target | What exists | Status | Source |
|
||||
|---|---|---|---|
|
||||
| Apple, RISC Zero Metal | the full STARK prover (rv32im, keccak, recursion) on Metal, automatic on Apple silicon; the Groth16 wrap x86 only | shipped, maintained (PR #3761's June 2026 matrix lists "metal (Mac M-series): build, run"); the only speed published is a 2023 M2 datasheet (14 to 93 kHz, approximate); nothing for M3, M4 or M5 | `risc0/sys/kernels/zkp/metal/`, dev.risczero.com local-proving page |
|
||||
| Apple, ICICLE Metal (Ingonyama) | MSM, NTT, sumcheck on Metal since v3.6.0 (Mar 2025); "missing API implementations for Poseidon and Poseidon2 hashes, Merkle tree" at that release; v4.0.0 of 11 Jul 2025 is the latest | a library, closed-source backends under a free research licence (dev.ingonyama.com, install_gpu_backend page); no STARK prover built on it for Metal | ICICLE releases, the Metal blog |
|
||||
| Apple, Jolt Metal | PR #1938 merged 30 Sep 2026 (the runtime and field kernels); PR #1733, the prover itself, a draft: M5 Max 2^25 cycles in 19.8 s, 3.2x over its CPU | the fastest Apple number anyone has published, in a draft | github.com/a16z/jolt pulls 1733 and 1938 |
|
||||
| Apple, Stwo | CPU SIMD with NEON; ICICLE-Stwo promises Metal | CPU path shipped; no RISC-V guest of its own | stwo README, Ingonyama blog |
|
||||
| Apple, Miden | `miden-gpu` on Metal | Cairo-class VM, not RISC-V | hackmd (bobbinth) |
|
||||
| AMD, sppark | "A limited support for AMD's RDNA and CDNA GPUs" (README); SP1's tree carries no HIP build | a library | github.com/supranational/sppark |
|
||||
| AMD, SP1 PR #2668 | an external port to RDNA3 and RDNA4 with "a caching memory allocator to work around hipMallocAsync leak bug" | **closed unmerged 20 Mar 2026** | github.com/succinctlabs/sp1/pull/2668 |
|
||||
| AMD, OpenVM stark-backend HIP fork | `cuda2hip.hpp` so the same `.cu` builds under nvcc and hipcc, native `mont32_t.hip`, tested on gfx1100, targets MI300X and 7900 XTX | merged 15 Sep 2026 in a fork (Okm165/stark-backend PR #2), not upstream | the PR |
|
||||
| AMD, Goldilocks NTT and STARK on ROCm | 19.19 ms NTT at 2^27 on an RX 7900 XTX; a Goldilocks STARK backend on HIP | research posts | ethresear.ch, qingming-g64-ntt and stark-g64 |
|
||||
| Vulkan and WebGPU | ICICLE's Vulkan build (Jan 2025) with no installable backend; zkSecurity's WebGPU Stwo (5x on constraint evaluation, 2x end to end, no 64-bit integers in WGSL); ZPrize WebGPU MSM | prototypes; nothing proves a RISC-V shard | the pages named |
|
||||
|
||||
Said plainly, as `docs/analysis/amd-proving.md` said it: on 5 October 2026 no zkVM proves on an AMD GPU, and the only
|
||||
Apple prover that ships is RISC Zero's. The AMD work that exists is two ports of CUDA STARK kernels through a HIP shim,
|
||||
one closed, one in a fork; both are days of agent work to revive against a given tree, and PC 1's RX 9070 XT (gfx1201)
|
||||
is the card to measure on.
|
||||
|
||||
## 3. For each route: the change to our guest, the aggregator and the node's verifier; the cost; the risk; 12 GB under 60 s
|
||||
|
||||
What the node verifies today: SP1 compressed proofs through `igneum-prove-host --mode verify` and `verify-segment`
|
||||
(`vendor/igneum-node-pv1/igneum/exec/src/proving.rs:41-46, 878-926`), the pinned ids read at start and named in the
|
||||
native statement (`program_ids`, `IGNEUM_PROOF_PROGRAM_IDS`), the record bound in a BLS-signed `ProofRecord` (version
|
||||
1) or `SegmentRecord` (version 2) with the proof's SHA-256 (spec 7.7 item 1, 7.8 item 3). A different proof system
|
||||
means a new `ProofSystem` version (design 5.6), a new pinned id, a second verifier command, and the record's version
|
||||
field telling the node which. The swap procedure of design 5.6 (test vectors, 90% signalling, a 3-month overlap with
|
||||
both verifiers, a wrap of the last old proof) is the path for any of the rows below that change the family.
|
||||
|
||||
The 60-s test. The litepaper's minute, the launch target of 20 to 60 s behind the tip, and the mine-and-prove
|
||||
measurement that a shared card proves 3 to 4x slower (bench-log, `chain-pc2-pv1c`). No 12 GB card has run any
|
||||
prover in this repository; the 12 GB times below are approximate, scaled from the 5090 by memory bandwidth (an RTX
|
||||
3060 at 360 GB/s and an RTX 4070 at 504 GB/s against the 5090's 1,792 GB/s, NVIDIA's published figures, approximate),
|
||||
which is the term a STARK prover is bound by. They are the numbers the first 3060-class run replaces.
|
||||
|
||||
| Route | Guest | Aggregator | Node verifier and record | Cost (agent time) | Risk | Reaches 12 GB with a real shard under 60 s? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A. Re-size SP1's GPU server** (the prover-floor agent's patch, running tonight): remove the 20 GB panic (`builder.rs:37`), size `max_trace_size` to the shard (honour `ELEMENT_THRESHOLD`, or set the core allocation from the shard's measured cells), one core worker and a buffer of 1, a release threshold so the pool returns memory between stages, `drop_ldes` on; build with `CUDA_ARCHS` for Ampere, Ada and Blackwell | none: the same ELF, the same pinned id (the verifying key hashes the program and its preprocessed tables, not the server's buffer sizes; `HEIGHT_THRESHOLD` only shortens tables below the verifier's 2^22 maximum) | none: the compressed proof format and the aggregator guest are unchanged | none: the same `--mode verify`; the record format unchanged | hours to one day: a fork of `sp1-gpu/crates/prover_components` and `jagged_tracegen` (Apache or MIT), the 11-min cross-build, a per-card profile in `provedefault.rs`, a CI check that the fork's constants match the pinned verifier's | low on the protocol, medium on the build: the fixed recursion stage may hold the floor near 8 to 9 GB (section 1.3, approximate) and the first measurement says whether 11 GB is reached; a fork of `sp1-gpu` to carry forward on every SP1 release; the server rejects nothing it cannot hold, so an out-of-memory shard must fail cleanly and be left (the pool's rule today) | **memory: likely for the adopted shard** (10 to 11 GB on paper, section 1.4), **not** for the prototype shard (28 GB of live trace). **Time: prove-only yes** (4.3 s on the 5090 scales to about 15 to 22 s on a 3060 and 10 to 15 s on a 4070, approximate); **mine-and-prove on a 12 GB card: no at `S_p`** (3 to 4x on a shared card puts a 3060 at 45 to 90 s, approximate, and the miner's 1.7 GB on top of 11 GB does not fit), yes at `S_p/2` on a 4070 if the floor lands under 9 GB (approximate). The measurement decides; this is the route the gate waits on |
|
||||
| **B. Halve `S_p`** (30,000 to 15,000 pgas, the fee-switch pattern): more and smaller shards | none | none: one deferred proof per shard, so 2x the shards per block; the chained aggregation stays one per block (9.7 s mining, 2.5 s alone) | none | hours: a fee-table change and a rollout plan like `fee-switch-devnet.md` | low: more records per block (the coinbase carries at most 8 shard records, spec 7.7 item 2, so `B_p / S_p` must stay at 8 or under); the assignment window and sortition unchanged | **alone, no**: the floor is the server's (13.9 GB at 0 cycles). **With A, it is the dial** that moves a 12 GB card from prove-only to mine-and-prove, and a 16 GB card to a comfortable fit |
|
||||
| **C. One prover process per card on a rig** (the distributed route): the app runs one `sp1-gpu-server` per NVIDIA card (`CUDA_VISIBLE_DEVICES`, the per-device socket of `sp1-cuda/src/client.rs:211`, `.cuda().with_device_id(n)`), one host process per card, each taking its own assigned shard; the rig installer already picks cards (`igneum-rig-lib.sh`, `prover_decision`) | none | none: shards are independent units by design (spec 7.2); the aggregator runs on the biggest card | none | one day: the app's prover loop per card (`prover.rs` runs one loop today), the Settings and tile per card, the rig installer's prover unit per card, the socket cleanup per device (the root-socket rule of 5 October) | low; the throughput is per card, the host RAM 6 GB of pinned buffers per server (section 1.2), so a 4-card rig needs 32 GB of RAM or route A's smaller buffers | **it does not move the floor**: each card still needs A. It is the route that makes four 12 GB cards worth four shards a cycle, and it ships with A, not instead of it. Splitting ONE shard across cards is not a route: the adopted shard is one core shard (2.5), and column-split provers are research |
|
||||
| **D. RISC Zero as proof system version 2** (CUDA and Metal; segments at po2 19 or 20) | a second guest: `core/` is plain Rust and ports as is; the precompile patches differ (SP1's `sha3` and `k256` patches against RISC Zero's `sha2`, `k256` and keccak circuit); the shard statement bytes unchanged; a second pinned ELF and image id in `elf/manifest.json` | a RISC Zero aggregator guest using composition (`env::verify` of the shard receipts, dev.risczero.com composition page); the chain rule (N verifies N-1) inside the family; **a block's shards must be one family**, and a chain cannot cross families inside the proof: a family switch lands at a segment boundary as a fresh chain (spec 7.8 item 6 already allows one after an unproven segment; the rule gains "or at a proof-system version change") | a second verifier mode (`--mode verify-r0`, the `risc0-zkvm` verifier, pure Rust, about 100 ms, 222 KB receipts); the record's `version` selects the family; the native statement names the family's pinned id; both verifiers in the node through the overlap of design 5.6 | 3 to 4 days: guest port and pinning 1, aggregator and chain rule 1, node verifier and record version 1, app profile and host modes 0.5, test vectors and the fast-time harness 0.5; plus the measurement day on PC 2 | medium: two proof systems in consensus for the overlap; RISC Zero's Groth16 wrap is x86 only (the light-client path of ledger P3 stays on SP1 or waits); a 222 KB receipt per shard against 1.27 MB today is a gain; the recursion tree per shard (9 lifts and 8 joins at po2 19) is extra time on small cards; `main` is at 5.0.0 with no release body, so the pin is 3.0.6 | **memory: yes by documentation** (po2 19 for an 8 GB card, po2 20 for 16 GB; 9 to 10 GB per 1 M cycles), the first documented sub-12 GB prover. **Time: approximate**: a 4090 does 808 kHz at po2 21, so the adopted shard is about 6 s on a 4090-class card and about 20 to 30 s on a 3060-class one at po2 19, prove-only; beside the miner over 60 s on a 3060, near it on a 4070. The prover-floor agent's PC 2 run is the first real number |
|
||||
| **E. Airbender, OpenVM, ZisK, Pico, Ziren** as version 2 | a new guest each (RISC-V, except Ziren's MIPS); OpenVM's and ZisK's toolchains are the most complete | each has its own recursion; OpenVM's aggregation and Halo2 wrap are the most documented | a new verifier each (STARK under 300 KB for OpenVM; PLONK or FFLONK for ZisK and Airbender) | 4 to 6 days each | the same two-family cost as D with no memory gain: 21 GiB (Airbender), 24 GB (OpenVM, Ziren), undocumented (ZisK, Pico); Pico's and Ziren's GPU code is BUSL or closed | **no**: none documents a floor under 21 GiB; the race is tuned for 5090 clusters |
|
||||
| **F. Jolt (Lattice Jolt) as version 2**: a sumcheck prover with no codeword; CPU and Metal | a new guest (RV64IMAC, Jolt's toolchain; no keccak precompile today, approximate, so the trie hashing costs more cycles than in SP1) | **none exists**: no recursion or continuation shipped, so the aggregator would verify N shard proofs natively and the chain rule would live in the native statement until Jolt's recursion lands | a Dory verifier (BN254 pairings, about 50 KB, sub-second, approximate) or an Akita verifier (lattice, 65 to 80 KB); no on-chain verifier shipped | 5 to 8 days for the guest, the verifier and the record; the aggregator question has no answer in the code | high: alpha software, no audit, no production user, no recursion; the proof system of the miner's CPU, not of its card | **memory: yes by a wide margin** (about 0.9 GB for the adopted shard at 200 bytes a cycle, approximate). **Time on a CPU: about 2 to 3 s** for 4.7 M cycles at over 2 M cycles a second (a16z, Sep 2026, laptop CPU; approximate for our guest), **on Metal under 1 s** (PR #1733's 2^25 in 19.8 s on an M5 Max, approximate). The numbers are the best in this document and the software is not shippable |
|
||||
| **G. Folding** (Nova family, lattice folding) | a step circuit per transaction or per opcode group | a decider per shard | pairing or lattice verifier | weeks of research, no code over our field | the family that Nexus left | **no** today; the watch item for 2027 |
|
||||
| **H. AMD through a HIP port of SP1's kernels** (PR #2668 revived against 6.8.1, or the `cuda2hip` shim of the OpenVM fork) | none | none | none: the same SP1 proofs | 3 to 5 days plus PC 1's RX 9070 XT to measure; the `hipMallocAsync` leak needs the caching allocator the PR carried | medium: a kernel port with no upstream; the sppark NTT has a limited HIP path and cuPQC none | memory as route A (the same buffers); **time unmeasured on any AMD card**; the one route that gives AMD miners the 20% pool share |
|
||||
| **I. Apple through RISC Zero Metal** (route D's Metal half) | as D | as D | as D | inside D's 3 to 4 days | the 2023 M2 figure (14 kHz, approximate) says 5 minutes for the adopted shard; an M5 Max is not measured by anyone | **memory: yes** (unified memory, 64 GB on the M5 Max). **Time: unknown**; the Mac measure lock run is the number |
|
||||
|
||||
## 4. The ranked recommendation
|
||||
|
||||
| Rank | Route | Why | Gate |
|
||||
|---|---|---|---|
|
||||
| **1. Soonest to 12 GB with the least change: A, with B as the dial and C for rigs** | re-size SP1's GPU server; keep the guest, the aggregator, the verifier and the pinned ids exactly as they are; set `S_p` from the first 12 GB measurement; one prover per card on rigs | nothing in consensus moves; the work is a fork of two Apache crates and an app profile; it is already running tonight; every other route costs days and adds a second verifier | the prover-floor agent's rows: the adopted shard under 11 GB alone and the time on the first 3060-class or 4070-class card, prove-only and beside the miner. If under 11 GB and under 60 s prove-only: ship 0.3.12 with the 12 GB tier as prove-only and `S_p/2` measured for mine-and-prove. If not under 11 GB: route D |
|
||||
| **2. The fallback if A misses 11 GB, and the Apple route either way: D, RISC Zero as version 2** | the only shipped prover with a documented sub-12 GB configuration and a shipped Metal path; Apache or MIT including the kernels; 222 KB receipts | the swappable interface was built for this (design 5.6) and the node already names the pinned id in the statement, so a second family is a version, not a redesign; the cost is 3 to 4 days plus the overlap | PC 2's po2 19 and 20 rows (memory, time per segment, lift and join) tonight; the Mac's Metal row |
|
||||
| **3. Best in five years: the sumcheck family without a codeword (Jolt-class), or the sumcheck-plus-WHIR family SP1 and OpenVM already converge on** | Jolt proves the adopted shard in seconds on a laptop CPU at under 1 GB of memory, which is the only route that gives AMD-only, Apple and 8 GB machines the proving share with their existing hardware; its verifier is small (50 to 80 KB); its licence is MIT or Apache. It is alpha with no recursion, so not before it ships a stable release with continuations and an audit. SP1 Hypercube and OpenVM SWIRL are the same mathematics with a hash-based PCS and a GPU today, which is why staying on SP1 now loses nothing in that direction | do not adopt now; re-read Jolt and the Arc or WARP accumulation line at every 6-month era draw (design 5.6's swap procedure needs 90% signalling and a 3-month overlap, so the lead time is the schedule) | a stable Jolt tag with recursion, an audit, and a CUDA or merged Metal prover |
|
||||
| **The interface question** | yes: `ProofSystem` is versioned (`VERSION`, `program_id`, `verify_segment`), the record carries `version`, the node reads pinned ids at start and names them in the native statement, and the overlap procedure keeps both verifiers in the node for 3 months with `B_p` from the stricter table. What is missing for two families at once is small and named: the record version selecting the verifier command, the fresh-chain rule at a version change, and the shard plan carrying the family per block so a block's shards are homogeneous (the aggregator folds one family). Those three items are in route D's day of node work | so the answer to "ship one now and move to the other later" is yes, and route A ships nothing that has to be undone | |
|
||||
|
||||
The honest statement of what this ranking does not know: no 12 GB card has run any prover here. Route A's time
|
||||
figures are bandwidth scaling, labelled approximate; route D's are a 4090 figure scaled the same way. The first 3060
|
||||
or 4070 in this repository replaces both columns, and the plan is to borrow or buy one this week (a 4070 is the
|
||||
common 12 GB card of 2026; a 3060 the common older one; both are the gate's named class, design R2).
|
||||
|
||||
## 5. The tier consequences, and the public line while the change is made
|
||||
|
||||
Every number carries its consequences (CLAUDE.md, 5 October 2026). The table says what each tier has today on SP1
|
||||
6.8.1, what route 1 (A plus B plus C) gives it if the gate is met, what route 2 (D) adds, and what only route 3 would
|
||||
give. "Today" is measured; the rest is the routes' expected outcome, labelled, until the measurement.
|
||||
|
||||
| Tier | Today (measured, bench-log 5 October) | Route 1: re-sized SP1 server, `S_p` as the dial, one server per card | Route 2: RISC Zero version 2 | Only route 3 (sumcheck without a codeword) |
|
||||
|---|---|---|---|---|
|
||||
| Home miner, one 8 GB NVIDIA card | mines; proves nothing (the server panics under 20 GB) | proves nothing at `S_p` (the floor's fixed terms, 8 to 9 GB approximate, leave no room); perhaps empty shards | prove-only at po2 19 (Boundless' 8 GB tier), the miner paused per shard; time approximate 30 to 60 s | mines and proves on its CPU |
|
||||
| Home miner, one 12 GB card (3060, 4070) | mines; proves nothing; the litepaper's gate card | **prove-only at `S_p`** if the floor lands under 11 GB (expected, section 1.4): about 15 to 22 s a shard, approximate; **mine-and-prove at `S_p/2`** on a 4070 if the floor is under 9 GB, approximate; on a 3060 the shared card misses 60 s, approximate, so its default is prove-only with the miner paused per shard (the 16 GB rule of `provedefault.rs` today, moved down a tier) | prove-only at po2 20 (16 GB tier) or po2 19; mine-and-prove not inside 60 s on a 3060, approximate | mines and proves, CPU |
|
||||
| Home miner, one 16 GB card (5080, 4080, 4060 Ti 16 GB) | an empty shard alone (13.9 GB); nothing beside the miner | **mine-and-prove at `S_p`** (11 GB plus the miner's 1.7 GB), about 7 to 12 s a shard alone and 20 to 40 s beside the miner, approximate | mine-and-prove at po2 20 | the same |
|
||||
| Home miner, one 24 GB card (4090, 3090) | the adopted shard alone (20.4 GB) and beside the miner (22.2 GB, approximate for the card); the prototype shard never | mine-and-prove at `S_p` with 10 GB to spare; the prototype shard (28 GB live) only if the devnet's fee switch has passed, which it has from DAA 210,000 | the same with Metal irrelevant | the same |
|
||||
| Home miner, one 32 GB card (5090) | everything, measured | everything, with more shards in flight if the pool releases between stages | the same | the same |
|
||||
| Rig, several NVIDIA cards | one prover on the biggest card (`prover_decision`) | **one server per card**, each its own shard; the aggregator on the biggest card; host RAM 6 GB pinned per server today, under 2 GB with route A's buffers | the same model (Bento's) | the same |
|
||||
| Pool user | through the pool; who proves is open (spec 09) | unchanged | unchanged | unchanged |
|
||||
| AMD-only (RX 9070 XT, 7900 XTX) | mines; proves nothing on the card; the CPU path 282 s a shard at 30 GB | unchanged until route H (a HIP port, 3 to 5 days, measured on PC 1's 9070 XT) | unchanged: RISC Zero is CUDA and Metal only | mines and proves on its CPU |
|
||||
| Apple silicon (M-series) | mines (26.7 MH/s on the M5 Max); the SP1 CPU prover 41 to 55 s for an empty shard, 272 s for a small one | unchanged | **proves on the GPU through Metal** (64 GB unified memory on an M5 Max holds any segment); the time is the measurement | proves in seconds on Metal (Jolt's draft PR figure, approximate) |
|
||||
| Windows under 32 GB of RAM | off (the WSL2 prover held 7.9 GB) | the pinned buffers fall with `max_trace_size`, so a 16 GB PC likely qualifies, approximate; measure | RISC Zero's CUDA path also runs in WSL2 | |
|
||||
|
||||
The deadlines these fit (spec 7.2 item 3, the litepaper, `proving-v1.md`): the 10-s exclusive window is the 5090's
|
||||
alone; a 12 GB card at 15 to 22 s proves its assigned shards in the open phase and is paid when no faster card took
|
||||
them, which on a chain with few 5090s is most of the time; the minute of the litepaper holds for prove-only 12 GB
|
||||
cards and for mine-and-prove 16 GB cards; the 600-s unproven deadline holds for every tier above the CPU path.
|
||||
|
||||
### The public line while the change is made
|
||||
|
||||
The litepaper's sentence today ("Target: shard size will be set so a 12 GB card proves one shard in about 20
|
||||
seconds") is a target and says so (fud-ledger P1, overclaim 27). What this document adds, for `site/litepaper.html`,
|
||||
`site/miner.html` and the app's Proving tile, in the copy law:
|
||||
|
||||
> Proving runs on NVIDIA cards with 24 GB or more today. A build for 12 GB and 16 GB cards is being measured: the
|
||||
> memory is the prover's buffers, not the shard, and the fix is a smaller build of the same prover. AMD and Apple
|
||||
> cards mine. A second prover with an Apple path exists and is the fallback.
|
||||
|
||||
And the rule for the next status line, whichever way the measurement goes: the number, the card it was taken on, and
|
||||
the tier it moves, in one sentence, the day it is taken.
|
||||
|
||||
### What this document does about it
|
||||
|
||||
| Consequence | Action | Owner |
|
||||
|---|---|---|
|
||||
| The gate card has never run a prover here | get a 4070 or 3060 into the measurement loop this week; until then every 12 GB figure stays approximate | coordinator; the project lead for the card |
|
||||
| Route A's gate | the prover-floor agent's rows (asked for by message tonight); if under 11 GB, `provedefault.rs` gains the 12 GB prove-only and 16 GB mine-and-prove tiers and the rig installer one server per card | prover-floor agent, then the proving engineer |
|
||||
| Route D's measurement | RISC Zero 3.0.6 at po2 19 and 20 on PC 2 (CUDA) and on this Mac (Metal), the same shard statement run natively: memory, time per segment, lift and join, receipt size | prover-floor agent (PC 2); a Mac measure job for Metal |
|
||||
| The two-family node items (record version selects the verifier, fresh chain at a version change, one family per block) | spec 7.8 gains the three rules when route D starts; nothing changes before | execution engineer |
|
||||
| AMD | route H is a 3-to-5-day job with a measurement on PC 1's 9070 XT; opened as a plan when route A's result is in | execution engineer |
|
||||
| The public line | the paragraph above to the site and the tile with the next site pass | site-pages owner |
|
||||
|
||||
## Sources
|
||||
|
||||
Our own: `docs/bench-log.md` entries "proving v1: segment records, the chain rule, the unproven rule" (5 October 2026),
|
||||
"the SP1 CPU prover on PC 1" (5 October), "shard proving on the RTX 5090" (4 October); `docs/plans/proving-v0.md`,
|
||||
`proving-v1.md`; `docs/analysis/amd-proving.md`; `docs/spec/07-execution.md` 7.2, 7.6, 7.7, 7.8; `docs/design/execution-layer.md`
|
||||
5.1 to 5.7; `proving/igneum-prove` (`host/src/proof_system.rs`, `program/src/main.rs`, `aggregator/src/main.rs`,
|
||||
`elf/manifest.json`); `vendor/igneum-node-pv1/igneum/exec/src/proving.rs`; `app/igneum-app/src/provedefault.rs`, `prover.rs`.
|
||||
|
||||
SP1 6.8.1, read from `~/.cargo/registry/src/index.crates.io-*/` and the vendored tree `vendor/sp1-6.8.1` (commit
|
||||
c84ada1e, 24 Sep 2026) on the `prover-floor` worktree: `sp1-core-executor-6.8.1/src/opts.rs`, `src/utils.rs`,
|
||||
`src/artifacts/rv64im_costs.json`; `sp1-prover-6.8.1/src/components.rs`, `src/worker/config.rs`, `src/shapes.rs`;
|
||||
`sp1-primitives-6.8.1/src/fri_params.rs`; `sp1-verifier-6.8.1/src/compressed/config.rs`; `sp1-hypercube-6.8.1/src/verifier/config.rs`;
|
||||
`sp1-cuda-6.8.1/src/server.rs`, `src/client.rs`; `sp1-gpu/README.md`, `sp1-gpu/crates/prover_components/src/builder.rs`,
|
||||
`src/components.rs`, `sp1-gpu/crates/jagged_tracegen/src/lib.rs`, `sp1-gpu/crates/shard_prover/src/prover.rs`,
|
||||
`sp1-gpu/crates/cuda/src/task.rs`, `src/device.rs`, `sp1-gpu/crates/sys/lib/runtime/mem_pool.cu`, `sp1-gpu/crates/zerocheck/src/primitives.rs`.
|
||||
Web: docs.succinct.xyz (hardware-acceleration, hardware-requirements, proof-types, security-model, provers introduction,
|
||||
cluster architecture, docker-compose deployment); blog.succinct.xyz (sp1-hypercube, real-time-proving-16-gpus,
|
||||
sp1-hypercube-is-now-live-on-mainnet); github.com/succinctlabs/sp1 releases v6.0.0 to v6.8.1, issues #2674, #2930,
|
||||
#2950, #2969, pulls #2631, #2668, #2723, #2917, #2974; github.com/succinctlabs/sp1-cluster (README, LICENSE,
|
||||
`infra/charts/sp1-cluster/values-example.yaml`, `crates/worker/src/config.rs`); eprint 2025/917 (jagged polynomial commitments).
|
||||
|
||||
RISC Zero: `~/.cargo/registry` crates `risc0-zkp-3.0.4/src/lib.rs`, `risc0-zkvm-3.0.4/src/receipt.rs`, `src/host/recursion/prove/mod.rs`,
|
||||
`risc0-circuit-rv32im-4.0.4/src/execute/mod.rs`, `src/zirgen/defs.rs.inc`, `src/prove/hal/cuda.rs`; github.com/risc0/risc0
|
||||
`risc0/zkvm/src/host/client/env.rs`, `risc0/zkvm/Cargo.toml`, `risc0/zkvm/build.rs`, `risc0/sys/kernels/zkp/{cuda,metal}/`,
|
||||
`risc0/r0vm/src/actors/factory.rs`, `risc0/circuit/recursion/src/lib.rs`, releases v2.0.0, v3.0.1, v3.0.6, pull #3761;
|
||||
dev.risczero.com (local-proving, composition); docs.boundless.network (bento, performance-optimization, quick-start);
|
||||
github.com/boundless-xyz/boundless (`compose.yml`, `bento/README.md`, `bento/LICENSE-BSL`); github.com/ekrembal/gsr-stark-verifier pull 5; l2beat.com/zk-catalog/risc0.
|
||||
|
||||
Others: zksync.io/airbender, docs.zksync.io airbender and proving pages, github.com/matter-labs/zksync-airbender (README,
|
||||
`docs/gpu.md`, pull #448), veridise.com (the Airbender audit); 0xpolygonhermez.github.io/zisk (introduction, limits,
|
||||
distributed execution, installation), github.com/0xPolygonHermez/zisk (README, pull #1238, `zisk-contracts`);
|
||||
blog.openvm.dev (2.0, 2.0-production, 2.1, openvm-gpu, v1), docs.openvm.dev (security-model, distributed-proving, sdk);
|
||||
pico-docs.brevis.network, github.com/brevis-network/pico and pico-gpu (README, LICENSE), blog.brevis.network (Prism 1.0,
|
||||
2.0, 2.1); docs.zkm.io (prover, performance), github.com/ProjectZKM/Ziren, zkm.io (the independent evaluation of v1.1.4),
|
||||
eprint 2026/2330; github.com/starkware-libs/stwo and stwo-cairo (README), ingonyama.com (ICICLE-Stwo, the Starknet
|
||||
partnership, ICICLE Metal v3.6), dev.ingonyama.com (install_gpu_backend), blog.zksecurity.xyz/posts/webgpu, starkware.co
|
||||
(S-two 2.0.0, Nexus on S-two), theblock.co (S-two on Starknet); github.com/a16z/jolt (README, book: intro, dory, akita,
|
||||
streaming, recursion, blindfold; pulls #1733, #1938; tags), a16zcrypto.substack.com ("How to prove software ran
|
||||
correctly", Sep 2026), a16zcrypto.com (jolt-6x-speedup, 64-bit-proving-jolt, zkvm-jolt-zero-knowledge, faqs-on-jolts-initial-implementation),
|
||||
eprint 2025/611; github.com/scroll-tech/ceno (README, Cargo.toml, pull #1403), ceno-gpu-mock, scroll.io (Ceno post),
|
||||
osec.io ("zkVMs' unfaithful claims"); github.com/nexus-xyz/nexus-zkvm (README, LICENSE), blog.nexus.xyz (roadmap);
|
||||
lita.gitbook.io (Valida architecture, benchmarks); github.com/powdr-labs/powdr; irreducible.com (announcing-binius64,
|
||||
reinventing-irreducible, irreducible-shutting-down), github.com/binius-zk/binius64, eprint 2026/1656; eprint 2021/1043,
|
||||
2022/1010, 2024/1609, 2025/1187, 2024/1586, 2024/185 (linear-code commitments, WHIR, Vortex), github.com/Consensys/linea-monorepo;
|
||||
PolyhedraZK/Expander and blog.polyhedra.network (returned 530 tonight); eprint 2021/370, 2024/2099, 2024/1220, 2024/416,
|
||||
2024/1605, 2025/247, 2025/294, 2026/242, 2024/1731, 2025/753, 2026/1371 (folding and accumulation), sonobe.pse.dev,
|
||||
github.com/privacy-scaling-explorations/sonobe, NethermindEth/latticefold, LFDT-Nightstream/Nightstream; eprint 2023/1271,
|
||||
2024/1208, 2024/1873, 2025/1349, 2025/1653, 2025/1285, 2018/691, arXiv 2210.00264, 2602.16338 (distributed proving);
|
||||
github.com/supranational/sppark, github.com/Okm165/stark-backend pull 2, ethresear.ch (qingming G64 NTT and STARK on ROCm);
|
||||
github.com/cysic-labs/venus, erigon.tech (Zilkworm), github.com/DelphinusLab/prover-node-docker, hackmd.io/@bobbinth
|
||||
(Miden), ethproofs.org/clusters (5 October 2026).
|
||||
408
docs/analysis/scratch-soundness.md
Normal file
408
docs/analysis/scratch-soundness.md
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
# Layer 3 soundness: the per-warp scratch with read-modify-writes
|
||||
|
||||
5 October 2026 (night), cryptographer role, Counter ASIC 2.0 plan step 4 (`docs/plans/counter-asic-2.md`). Branch
|
||||
`ca2-soundness` on top of `readwidth` b970dda (the scratch as a class parameter, 32 or 128 KiB per warp). Tests:
|
||||
`igneum-pow/tests/scratch.rs`; Metal runs through `proto-metal/packbench` on the M5 Max; commands and counts in
|
||||
`docs/bench-log.md` (entry of the same date). Nothing here touches the lottery hash as shipped: variant 5 is behind
|
||||
`LoadClass::scratch(k, kb)` and is never emitted by generator version 2.
|
||||
|
||||
Every figure below is measured (machine, date, command named) or cited; "approximate" marks a figure from memory.
|
||||
|
||||
## 0. The five findings
|
||||
|
||||
| # | Question | Finding | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | Is what is written uniform and beyond a chip's precomputation? | The fill is a bijection of the lane nonce, the rewrite a bijection of the fold value in each word; written words show no bit bias over 3 to 12 million rewrites per class (worst 3.63 sigma of 6). The fill IS precomputable, by design, and at 64 slots 78.5 percent of reads are fill reads. | sound as a function; see 2 for what that means |
|
||||
| 2 | Does any short cut avoid the writes? | No short cut inside a unit: a slot after d read-modify-writes needs all d fold values (replay test). But the live state is bounded by the read-modify-write count, not by the scratch size, because CPU verification resets the scratch per unit: 64 to 320 bytes per lane at scr2 to scr8, whatever the nominal 32 KiB, 128 KiB or 1 MiB. The named chip (cache mirror plus recompute) keeps that in SRAM at under 5 percent of its mirror and its gain does not move at any share under the 6 GB cap. | NOT sound as an anti-chip layer |
|
||||
| 3 | Is the verifier's one-warp simulation exact? | Exact when the GPU's lazy per-unit tag is unique over the arena's life and the arena holds no stale tag. The kernels rely on this and neither host guarantees it (no clear at allocation, no clear at the 32-bit wrap of the tag counter, 16.4 minutes on a 5090). With the host contract of section 4.3 the simulation is exact: 14 edge packs twice, 200 fuzz packs, consecutive units on one warp and the wrap inside a launch all match the CPU on Metal (228 of 228); a broken tag and a broken fill are caught (3 of 3). | sound with a host contract; today it is luck |
|
||||
| 4 | The attack surface of the writes | Out of bounds: impossible by the mask, 42 of 42 emitted kernels pass the static check, which catches six deliberate breaks. Aliasing: none, lane-major arenas disjoint by (warp, lane), two logical units of a wave64 get two arenas. Ordering: one lane, one slot, program order; no cross-lane sharing, no atomics needed. Alignment: 16-byte slots at 16-byte offsets from a 256-byte-aligned base. Wrap: identical to the CPU, tested at the launch level. | sound |
|
||||
| 5 | What a conformance vector must carry | The class and geometry, the fill and rewrite, the host contract (tags, clearing, groups a multiple of warps), two consecutive units on one warp with a forced slot collision, a unit in the top 256 nonces with the wrap inside the launch, and the fingerprint declared independent of the warp count. The standard three-unit vectors catch a broken tag only through base 1,000,000 and would miss it at a 1 MiB scratch. | defined in section 6 |
|
||||
|
||||
Recommendation (section 10): do not adopt layer 3 as the plan states it (read-modify-writes taken from the 16
|
||||
dataset loads). It replaces latency-bound dataset reads with cache-bound ones for the GPU, costs the named chip
|
||||
nothing it cannot keep in a few megabytes of SRAM, and leaves that chip's gain at 2.4x at every share. The lever
|
||||
that moves that chip is the mixer multiplier of the M16 analysis (x2 brings it to 1.2x, x4 to 0.6x, under the
|
||||
verifier's 10 ms gate). If a scratch is kept for another reason, add the read-modify-writes beside the 128 loads,
|
||||
never in their place, and ship the host contract and the vector of section 6 with it.
|
||||
|
||||
## 1. What the branch implements
|
||||
|
||||
| Piece | Where | What |
|
||||
|---|---|---|
|
||||
| Class | `igneum-pow/src/generator.rs:170-230` | `LoadClass { scratch: Some(k), scratch_kb }`: `k` of the 16 memory slots are `Op::Scratch`; `scratch_kb` KiB per warp of 16-byte slots, lane-major, `slots = kb x 2` per lane (32 KiB: 64, 128 KiB: 256); `scratch_slot_mask() = slots - 1` |
|
||||
| Draw | `generator.rs:488-491` | the first `k` of the 16 drawn load slots become scratch ops (a uniform k-subset); the source register follows the fresh-source rule like a load |
|
||||
| Fill | `igneum-pow/src/verify.rs:30` | `scratch_fill(seed, base, lane, slot, j) = splitmix32(((base + lane) ^ seed[j]) + slot x 0x9e3779b1 + (j + 1) x 0x85ebca77)`, j in 0..2 |
|
||||
| Fold | `verify.rs:18` | `x = dst ^ w0; x = (rotl(x, 11) x 0x9e3779b1) ^ w1; x = (rotl(x, 11) x 0x9e3779b1) ^ w2; dst = x` (the read-width fold over the three data words) |
|
||||
| Rewrite | `verify.rs:41` | the slot becomes `(x ^ w1, rotl(x, 7) ^ w2, x + w0)` |
|
||||
| CPU model | `verify.rs:48-100`, `:305-312` | `ScratchModel`: per (lane, slot) a written bit and three words; an unwritten slot reads as its fill; one model per unit, so a unit starts from the fill |
|
||||
| Acceptance | `igneum-pow/src/accept.rs:202-215, 374` | a scratch site that reads one slot in all 32 lanes rejects the program (lane-constant site); scratch slots carry bit 31 in the address list and are left out of the distinct-address bound, which now covers the dataset loads only |
|
||||
| GPU statement | `igneum-pow/src/emit.rs:143-157` | `s_ = rN & mask; v_ = 16-byte load of slot s_; m_ = (v_.x == tag) ? ~0 : 0; w = (v_.yzw & m_) \| (fill & ~m_); fold; dst = x_; 16-byte store of (tag, x_ ^ w1_, rotl(x_, 7) ^ w2_, x_ + w0_)` in Metal, CUDA and OpenCL |
|
||||
| Persistent prologue | `emit.rs:159-175` | `lane = tid & 31; warp_ = tid >> 5; arena = scratch + (warp_ x 32 + lane) x words_per_lane; for (g_ = warp_; g_ < groups; g_ += nwarps_) { gbase = baseNonce + g_ x 32; tag = salt + g_; ... }` |
|
||||
| Hosts | `proto-metal/packbench.swift:144-164`, `proto-opencl/host.c:1025-1033, 1268` | the arena is allocated and never written by the host; `salt` starts at 1 and advances by the launch's unit count; no clear at allocation, none at the wrap |
|
||||
|
||||
The constraint of the night (coordinator, 5 October 2026): the whole working set on an 8 GB card stays under 6 GB
|
||||
(1 GiB table, the layer 5 hot table, the scratch of every resident warp, buffers), which caps the scratch at tens of
|
||||
KiB per warp. On an RTX 5090 at full occupancy (170 SMs x 64 warps = 10,880 warps, approximate hardware maximum;
|
||||
the measured version 2 kernel ran 24 warps per SM, 4,080 warps, `docs/bench-log.md` M11, 4 October 2026):
|
||||
|
||||
| Scratch per warp | 10,880 warps | 4,080 warps (measured occupancy) | Table + scratch at 10,880 | Under 6 GB with a 1 GiB table |
|
||||
|---|---|---|---|---|
|
||||
| 32 KiB | 340 MiB | 128 MiB | 1,364 MiB | yes |
|
||||
| 128 KiB | 1,360 MiB | 510 MiB | 2,384 MiB | yes |
|
||||
| 1 MiB (the first experiment) | 10,880 MiB | 4,080 MiB | 11,904 MiB | no |
|
||||
|
||||
## 2. Question 1: uniformity of what is written
|
||||
|
||||
### 2.1 As functions
|
||||
|
||||
The fill of word j of slot s for lane nonce n is `splitmix32(((n ^ seed[j]) + s x 0x9e3779b1 + (j + 1) x 0x85ebca77))`.
|
||||
`splitmix32` is a bijection of its 32-bit input; for fixed (seed, s, j) the input is a bijection of n. So over any
|
||||
2^32 consecutive nonces every 32-bit value appears once as the fill of (s, j): uniform. Test
|
||||
`fill_is_a_bijection_of_the_nonce`: 2^16 consecutive nonces give 2^16 distinct words for 7 slots x 3 word
|
||||
positions; the fill of lane l at base b equals the fill of lane 0 at base b + l; it wraps with the nonce
|
||||
(base 0xffffffe0, lane 32 equals nonce 0).
|
||||
|
||||
The rewrite `(x ^ w1, rotl(x, 7) ^ w2, x + w0)` is, for fixed old content w, a bijection of the fold value x in
|
||||
EACH word. Test `rewrite_is_a_bijection_of_the_fold_value`: 2^16 consecutive x give 2^16 distinct words in each
|
||||
position for 16 random w. Consequence: a uniform x gives a uniform word in every position, and the three words
|
||||
are three images of the same x, so a rewritten slot carries exactly 32 bits of new state behind 96 bits of
|
||||
storage (from w and any one written word, x is recovered; the test checks all three inversions).
|
||||
|
||||
The fold value x is `fold(dst, w)`, a bijection of `dst` for fixed w (xor, then rotate-multiply-xor twice; the
|
||||
multiplier is odd). So the written words are uniform whenever `dst` is, and `dst` is a register of the running
|
||||
program.
|
||||
|
||||
### 2.2 The attack: what a chip can precompute
|
||||
|
||||
The fill is a pure function of (seed, nonce, slot): precomputable, and meant to be (the verifier computes it too).
|
||||
A chip never stores a fill; it computes it in about 10 integer operations when a slot is first touched. The written
|
||||
words depend on `dst`, the register state at that instruction, which depends on every earlier instruction of the
|
||||
hash, including the dataset loads. Nothing about them is precomputable before the hash runs. This is the whole of
|
||||
what question 1 can give: the writes are as unpredictable as the registers. What that is worth is question 2.
|
||||
|
||||
### 2.3 The stats run (the `TESTS.md` section 3 shape)
|
||||
|
||||
Test `written_words_unbiased_and_rehit_rates`, M5 Max, 5 October 2026, `cargo test --test scratch`: for each
|
||||
class, programs of `igneum-genesis`, `igneum-genesis/stats1`, `igneum-genesis/stats2`, 2^11 units each (196,608
|
||||
hashes per class), closed-form dataset, every read-modify-write traced (`verify::interpret_warp_scratch`). Ones
|
||||
count per bit of every written word and of the change each rewrite makes (written XOR read), sigma = sqrt(N)/2,
|
||||
limit 6 sigma like the acceptance rule's output check.
|
||||
|
||||
| Class | Slots per lane | RMW per hash per lane | Rewrites traced | Max bias, written words (sigma) | Max bias, written XOR read (sigma) |
|
||||
|---|---|---|---|---|---|
|
||||
| scr2k32 | 64 | 16 | 3,145,728 | 2.61 | 3.40 |
|
||||
| scr4k32 | 64 | 32 | 6,291,456 | 2.18 | 3.81 |
|
||||
| scr8k32 | 64 | 64 | 12,582,912 | 3.63 | 2.25 |
|
||||
| scr2k128 | 256 | 16 | 3,145,728 | 3.36 | 2.19 |
|
||||
| scr4k128 | 256 | 32 | 6,291,456 | 2.71 | 3.68 |
|
||||
| scr8k128 | 256 | 64 | 12,582,912 | 2.73 | 2.60 |
|
||||
|
||||
576 bit positions (6 classes x 3 words x 32 bits) at under 4 sigma is what fair coins give. Verdict: no structural
|
||||
bias in what is written. Like `TESTS.md` section 3 this is a sanity check, not a proof of strength.
|
||||
|
||||
## 3. Question 2: no short cut avoids the writes
|
||||
|
||||
### 3.1 Inside a unit: the chain is dependent
|
||||
|
||||
Slot s of lane l, touched d times in a unit, holds `w_d = rewrite(x_d, w_{d-1})`, `w_0 = fill`, with
|
||||
`x_i = fold(dst_i, w_{i-1})`. `x_i` depends on the slot content before it, which depends on every earlier fold
|
||||
value of that slot; and `dst_i` is the register state, which the earlier fold values entered. Test
|
||||
`slot_is_replayable_from_its_fold_values`: a slot after 64 read-modify-writes is reproduced from the fill and the
|
||||
64 fold values; dropping one diverges. So a chip cannot skip a write and still read the slot later. It has three
|
||||
ways to hold a slot, all exact:
|
||||
|
||||
| Store | Bytes per lane | Cost on a re-hit |
|
||||
|---|---|---|
|
||||
| Dense: every slot, 12 data bytes plus a valid bit | 12 x slots: 776 (64 slots), 3,104 (256), 24,832 (2,048) | one SRAM read |
|
||||
| Sparse: only touched slots, 12 bytes plus a slot index | about 13 x distinct: 185 to 820 (table below) | one lookup |
|
||||
| Implicit: only the fold values, 4 bytes plus a slot index per read-modify-write, replay on a re-hit | 5 x 8k: 80 (scr2), 160 (scr4), 320 (scr8) | d rewrites of 5 integer ops |
|
||||
|
||||
The implicit store is smaller than the dense one whenever `slots > 8k / 3`: at scr4 above 10.7 slots, at scr8
|
||||
above 21.3. So "the smallest scratch at which keeping it implicitly is dearer than storing it" is 8k/3 slots per
|
||||
lane, 2.7 to 5.3 KiB per warp at scr4 to scr8. Every size on the table, 32 KiB and above, is past it: a chip
|
||||
keeps the scratch implicitly in 80 to 320 bytes per lane at any nominal size, and the replay cost is bounded by
|
||||
the re-hit depth, which the next table measures.
|
||||
|
||||
### 3.2 The re-hit rate at 64 and 256 slots (and at 2,048)
|
||||
|
||||
Measured in the same test run (every read-modify-write of 196,608 hashes per class traced; a re-hit is a read of a
|
||||
slot the same unit wrote earlier). Birthday: `distinct = S (1 - (1 - 1/S)^n)` for n uniform draws from S slots.
|
||||
|
||||
| Class | S | n = RMW per hash | Distinct slots, birthday | Re-hits, birthday | Re-hit %, birthday | Re-hit %, measured | Max chain depth seen | Slot histogram against uniform |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| scr2k32 | 64 | 16 | 14.26 | 1.74 | 10.9 | 12.58 | 7 | chi2 z 22,023; hottest slot 2.74x, coldest 0.83x |
|
||||
| scr4k32 | 64 | 32 | 25.33 | 6.67 | 20.8 | 21.47 | 8 | z 10,880; 1.87x, 0.92x |
|
||||
| scr8k32 | 64 | 64 | 40.64 | 23.36 | 36.5 | 36.99 | 9 | z 7,587; 1.39x, 0.91x |
|
||||
| scr2k128 | 256 | 16 | 15.54 | 0.46 | 2.9 | 3.84 | 5 | z 19,146; 5.10x, 0.82x |
|
||||
| scr4k128 | 256 | 32 | 30.14 | 1.86 | 5.8 | 6.25 | 6 | z 9,632; 3.06x, 0.90x |
|
||||
| scr8k128 | 256 | 64 | 56.72 | 7.28 | 11.4 | 11.89 | 6 | z 5,873; 1.98x, 0.90x |
|
||||
| 1 MiB (not run) | 2,048 | 32 | 31.76 | 0.24 | 0.8 | | | |
|
||||
|
||||
Two readings. First, the slot a read-modify-write addresses is the low 6 or 8 bits of a program register, and
|
||||
those bits are not uniform: `or` sets them, `mul` clears them, so one slot of 256 is addressed 5.1 times as often
|
||||
as the mean and the re-hit rate runs 2 to 33 percent above the birthday rate. For the dataset the same bias on the
|
||||
low bits of a 28-bit address is harmless (it moves the read inside an item); for a 64-slot scratch it concentrates
|
||||
the chain. Second, the chain depth is small: at scr4k32 the deepest slot in 196,608 hashes saw 8 earlier
|
||||
read-modify-writes; a replay costs at most 8 x 5 integer operations, against about 1,170 for one dataset item.
|
||||
|
||||
### 3.3 The live state is bounded by the read-modify-write count, not by the size
|
||||
|
||||
The verifier evaluates one unit from nothing but (program, day, nonce group): `ScratchModel::new` per unit,
|
||||
`verify.rs:296`. Every conforming GPU must therefore start every unit from the fill, which the tag does
|
||||
(section 4). So no state crosses a unit boundary, and the state a unit can ever read back is what it wrote itself:
|
||||
at most 8k slots per lane. The nominal size only sets how often those 8k writes land on the same slot (the table
|
||||
above). The scratch's "memory" is 8k x 16 bytes per lane of touched slots, 256 bytes to 1 KiB at scr2 to scr8,
|
||||
and a chip holds it implicitly in 80 to 320 bytes.
|
||||
|
||||
The attack of rolling back or sharing scratch between units has nothing to take: a unit starts from the fill
|
||||
whatever ran before it, so a chip that clears 64 valid bits per unit has rolled back, and nothing one unit wrote
|
||||
is readable by another. The CPU verifier is that chip.
|
||||
|
||||
### 3.4 The named chip, and what the scratch costs it
|
||||
|
||||
The strongest chip the plan has priced (coordinator, 5 October 2026): the whole 256 MiB cache on the die, computing
|
||||
every dataset item on the fly. Its cache SRAM, from `docs/analysis/sram-mirror.md` revision 2 (`ca2-analysis`
|
||||
e6085c6), headline at shipped-product density / bit-cell lower bound, dollars per good die approximate: 164 / 83
|
||||
mm^2 and $30 / $13 at N7 (shipped density from AMD 3D V-Cache, 64 MB on 41 mm^2, Hot Chips 2021); 128 / 64 mm^2 and
|
||||
$46 / $21 at N5, N3E and Intel 18A (TSMC N5 HD macro 31.8 Mib/mm^2 after assist overhead, SemiAnalysis, December
|
||||
2022); 106 / 54 mm^2 and $56 / $26 at N2; with a 96 MB hot table 226 / 114 at N7, 175 / 89 at N5, 146 / 74 at N2.
|
||||
The chip's cache cost in the table below is the N5 headline, 128 mm^2 and $46 per good die. It computes every item
|
||||
through the mixer (`docs/analysis/m16-recompute-attacker-2026-10-05.md`: 128 items per hash, about 1,170 integer
|
||||
operations per item, 150,000 per hash; at a 50 T op/s integer budget equal to a 5090's, approximate, 0.33 Ghash/s).
|
||||
Against the measured version 2 rate of the RTX 5090, 139.7 MH/s (`docs/bench-log.md` M11, 4 October 2026), that is
|
||||
2.4x before any fixed-function factor, 7x with the 3x the M16 analysis allows (approximate).
|
||||
|
||||
Units in flight on that chip. It has no DRAM latency to cover: every one of its 1,024 cache reads per hash is an
|
||||
on-die SRAM read. Its hash latency is the dependent chain: 128 items x (8 dependent SRAM reads plus 9 mixer
|
||||
applications). At about 10 ns per on-die read and about 40 ns per 130-operation mixer on a 16-wide integer
|
||||
pipeline at 2 GHz (both approximate), an item is about 0.4 us and a hash about 50 us; at 0.33 Ghash/s that is
|
||||
about 17,000 hashes in flight, 530 units of 32 lanes. A tighter pipeline halves it. The GPU covers DRAM latency (40 to 48 ns row
|
||||
cycle, MEMSYS 2018, more under load) with 130,560 lanes in flight at the measured occupancy (4,080 warps x 32), 348,160 at
|
||||
full occupancy, that is 8 to 20 times more lanes than the chip needs.
|
||||
|
||||
What the scratch costs that chip, per variant, with the arithmetic:
|
||||
|
||||
Chip cache mirror: 128 mm^2, $46 per good die (N5 headline; 64 mm^2, $21 bit-cell lower bound). Chip scratch SRAM at
|
||||
the same two densities (2.1 MB/mm^2 headline, 4.2 MB/mm^2 lower bound at N5):
|
||||
|
||||
| Variant | Dataset loads per hash | Chip ops per hash | Chip rate at 50 T op/s | 5090 rate | Chip gain | Chip scratch SRAM at 17,000 lanes, implicit store | Same, dense 64-slot store | Dense store as mm^2, headline / lower bound (N5) | Share of the 256 MiB mirror (any density) |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| scr0 (control), 128 loads | 128 | 150,000 | 333 MH/s | 139.7 measured | 2.4x | 0 | 0 | 0 | 0 |
|
||||
| 12.5% replaced (scr2) | 112 | 131,400 | 381 | 160 projected (128/112 x 139.7) | 2.4x | 1.4 MB | 13 MB | 6.2 / 3.1 mm^2 | 4.9% |
|
||||
| 25% replaced (scr4) | 96 | 112,800 | 443 | 186 projected | 2.4x | 2.7 MB | 13 MB | 6.2 / 3.1 | 4.9% |
|
||||
| 50% replaced (scr8) | 64 | 75,600 | 661 | 279 projected | 2.4x | 5.4 MB | 13 MB | 6.2 / 3.1 | 4.9% |
|
||||
| 12.5% added (16 RMW beside 128 loads) | 128 | 150,200 | 333 | 139.7 or below | 2.4x or more | 1.4 MB | 13 MB | 6.2 / 3.1 | 4.9% |
|
||||
| 25% added | 128 | 150,400 | 332 | 139.7 or below | 2.4x or more | 2.7 MB | 13 MB | 6.2 / 3.1 | 4.9% |
|
||||
| 50% added | 128 | 150,800 | 332 | 139.7 or below | 2.4x or more | 5.4 MB | 13 MB | 6.2 / 3.1 | 4.9% |
|
||||
| 256-slot dense store (128 KiB class), any share | | | | | | | 53 MB | 25 / 12.6 | 20% |
|
||||
|
||||
How the rows are computed: a read-modify-write costs the chip about 12 integer operations (fold and rewrite) and
|
||||
one SRAM access; replacing a load removes an item derivation (1,170 operations); the 5090's rate for a replaced
|
||||
load is projected from the measured distinct-load bound (the card's rate tracks distinct dataset loads per hash,
|
||||
`docs/bench-log.md` 3 October, 23.7 G loads/s at 1 GiB; the readwidth agent's M5 Max measurement of the night,
|
||||
relayed by the coordinator, shows the same: 27.7 MH/s at v2 to 29.4-31.7 at 25 percent replaced and 44.4-49.1 at 50
|
||||
percent, 32 KiB per warp). The scratch SRAM is 17,000 lanes x 80 to 320 bytes (implicit) or x 776 bytes (dense at
|
||||
64 slots) or x 3,104 bytes (dense at 256 slots); its share of the mirror is a ratio of bytes, 4.9 or 20 percent,
|
||||
whichever density is used for both; the implicit store (the chip's cheaper choice at every size, section 3.1) is
|
||||
0.5 to 2 percent. The chip's gain is set by operations per dataset item and the GPU's distinct-load bound, and the
|
||||
scratch touches neither.
|
||||
|
||||
Plain answer to the coordinator's question: no read-modify-write share under the 6 GB cap, replaced or added,
|
||||
brings the named chip under 2x. The share would be chosen as the smallest at which the chip falls under 1.5x, and
|
||||
there is none: the gain is 2.4x at 0, 12.5, 25 and 50 percent, 32 or 128 KiB. This changes nothing about the public
|
||||
claim that layer 3 would have changed: the claim must rest on the mixer, not on the scratch.
|
||||
|
||||
The lever that does move that chip, from the M16 table, beside it:
|
||||
|
||||
| Mixer cost multiplier | Chip ops per hash | Chip rate | Gain against 139.7 MH/s, no fixed-function factor | With a 3x factor (approximate) | CPU verify per warp (M16 table, scaled from 0.41 to 1.2 ms) | 5090 daily dataset build |
|
||||
|---|---|---|---|---|---|---|
|
||||
| x1 (today) | 150,000 | 333 MH/s | 2.4x | 7.2x | 0.4 to 1.2 ms | 13.4 ms |
|
||||
| x2 | 300,000 | 167 | 1.2x | 3.6x | 0.8 to 2.4 ms | 27 ms |
|
||||
| x4 | 600,000 | 83 | 0.6x | 1.8x | 1.6 to 4.8 ms | 54 ms |
|
||||
| x8 | 1,200,000 | 42 | 0.3x | 0.9x | 3.3 to 9.6 ms | 107 ms |
|
||||
|
||||
The mixer multiplier leaves the honest hash rate untouched (the miner pays the mixer once a day), costs the chip
|
||||
linearly, and is bounded by the 10 ms verification gate (x8 is at the gate's edge on this core, and the 2019-class
|
||||
core of O-1.14 is unmeasured). The scratch costs the honest GPU a measured share of its rate when it spills the
|
||||
cache and nothing when it does not, and costs the chip a few megabytes. The comparison is not close.
|
||||
|
||||
### 3.5 Where the GPU's writes would cost DRAM latency, and why that does not help
|
||||
|
||||
The GPU's hot scratch footprint is not the nominal size either: it is the slots in-flight units have touched,
|
||||
about `warps x 32 lanes x distinct slots x 16 bytes` (x 2 at a 32-byte sector, approximate): on the 5090 at 4,080
|
||||
resident warps and scr4, 25.3 slots at 64 or 30.1 at 256, 53 to 63 MB of slots, 100 to 125 MB in sectors, around
|
||||
the card's 96 MiB L2 (`docs/bench-log.md`, 3 October). The readwidth agent's M5 Max rows (coordinator's message:
|
||||
the rate rises with the share at 32 and 128 KiB) show the scratch sitting in that chip's caches at 4,096 warps.
|
||||
To push the writes to DRAM latency the hot footprint must pass the last-level cache at the resident count:
|
||||
`96 MiB / 4,080 warps = 24 KiB per warp`, which at 512 bytes of touched slots per lane per read-modify-write slot
|
||||
means `8k x 512 B > 24 KiB`, k above 6 (above 48 read-modify-writes per hash) at ANY nominal size on the table, or
|
||||
a higher resident count. That fits the 6 GB cap (it is the hot set, not the arena, that matters), and it costs
|
||||
the honest miner a DRAM-latency read-modify-write per slot (a DRAM row cycle is 40 to 48 ns across DDR4, GDDR5 and
|
||||
HBM2, Li, Reddy and Jacob, MEMSYS 2018; the loaded latency a GPU kernel sees is higher, approximate; DRAM latency
|
||||
improved 1.3x in two decades while bandwidth improved 20x, Chang 2017, so no memory technology an attacker could
|
||||
buy removes it, and no shipped mining chip has used HBM or stacked memory) while the named chip still keeps the same
|
||||
hot set in a few megabytes of SRAM at 8 to 20 times fewer lanes in flight. The write path cannot be made to cost the
|
||||
chip more than the GPU, because the GPU must keep 8 to 20 times more of it live.
|
||||
|
||||
## 4. Question 3: the verifier's one-warp simulation is exact
|
||||
|
||||
### 4.1 Lazy fill on both sides
|
||||
|
||||
The CPU initialises lazily with a written bit per (lane, slot), one model per unit. The GPU initialises lazily with
|
||||
a 32-bit tag in word 0 of each 16-byte slot: a slot whose tag equals the unit's tag reads as written, any other
|
||||
reads as the fill (`emit.rs:143-157`). There is no explicit fill and no reset between units of a persistent warp
|
||||
(`emit.rs:159-175`: the loop over `g_` keeps the arena). The two agree if and only if, when a unit first touches a
|
||||
slot, that slot does not already carry the unit's tag. That is:
|
||||
|
||||
1. Tags are unique over the life of the arena's contents (`tag = salt + g_`, `salt` the host's running counter).
|
||||
2. The arena holds no word equal to a live tag in a slot's tag position before the unit writes it.
|
||||
|
||||
### 4.2 The attacks (the bug classes)
|
||||
|
||||
| Case | What happens | Today |
|
||||
|---|---|---|
|
||||
| Recycled allocation | A fresh process starts `salt` at 1 (`packbench.swift:144`, `host.c:1027`). If the driver hands back the previous process's arena with its contents (Metal, CUDA and OpenCL do not promise zeroed memory, approximate), slots tagged 1..N from the old run match the new run's first units exactly, and those units read stale words instead of the fill: a CPU mismatch on every colliding slot. | not guarded; passes on this Mac because fresh allocations read as zero in practice and tag 0 is never issued (luck, not contract) |
|
||||
| Tag counter wrap | `salt` is 32 bits and advances by units per launch. A 5090 at 139.7 MH/s runs 4.37 M units/s, 2^32 units in 984 s: the counter wraps every 16.4 minutes on one card (81.8 minutes on the M5 Max at 28 MH/s). After the wrap a slot whose LAST writer carried the repeated tag reads as written. With 10,880 arenas each slot is rewritten about 395,000 times between two uses of one tag (at 64 slots a unit leaves a slot untouched with probability 0.60; 0.60^395,000 is 0), so on a full card the wrap is harmless in practice; on a one-warp launch repeated 2^32 times it is not. | not guarded |
|
||||
| Tag 0 on zeroed memory | A host that starts `salt` at 0 gives unit 0 the tag 0, which a zeroed arena carries in every slot: unit 0 reads zeros for every first touch. | both hosts start at 1; nothing in the pack says they must |
|
||||
| `groups` not a multiple of the warp count | Warps run different trip counts; the OpenCL local-memory exchange path carries a barrier inside the loop (spec 1.9), so a short warp hangs or desynchronises. | `packbench` refuses it; `host.c` rounds the batch |
|
||||
|
||||
### 4.3 The host contract that makes the simulation exact
|
||||
|
||||
A host of a scratch class MUST: allocate the arena as `warps x 32 x words_per_lane` words and zero it; issue tags
|
||||
from a 32-bit counter that starts at 1 and advances by the unit count of every launch; zero the arena again before
|
||||
any launch whose tags would pass 2^32 - 1 (tag 0 is never issued); launch `groups` as a multiple of the warp count.
|
||||
The zeroing costs one memset of the arena (340 MiB at 32 KiB x 10,880 warps) every 2^32 units, 16 minutes on a
|
||||
5090. This is the class fix for all four rows: with it the GPU's tag test and the CPU's written bit are the same
|
||||
predicate.
|
||||
|
||||
### 4.4 The tests (Metal, M5 Max, 5 October 2026)
|
||||
|
||||
Two consecutive units on one persistent warp and the wrap inside a launch (`packbench --warps 1`,
|
||||
`--batch-base 4294967040`, the option added on this branch); the hand-built edge programs that force every
|
||||
read-modify-write of a hash onto one slot (so two consecutive units on one arena collide on every slot); the
|
||||
deliberate breaks. Results in section 7.2. On the CPU, the same edge programs against an independent hand model
|
||||
(a second interpreter with its own slot store, `tests/scratch.rs`): 56 of 56 cases match, and the hand model with
|
||||
its rewrite words swapped mismatches on every case (the comparison has teeth).
|
||||
|
||||
## 5. Question 4: the attack surface of the writes
|
||||
|
||||
| Surface | Argument | Test |
|
||||
|---|---|---|
|
||||
| Out of bounds | `s_ = rN & (slots - 1)`, so `s_ < slots`; the lane's arena is `(warp_ x 32 + lane) x 4 x slots` words from the base, the access is `arena + 4 x s_ + 0..3`, the largest index is `warps x 32 x 4 x slots - 1`, the host's allocation. The emitter has one scratch template (`emit.rs:143`) and it masks. | `scr_packs_regenerate_and_pass_the_static_scratch_check`: 42 of 42 emitted kernels (7 scr packs x 6 files, the OpenCL bound file carrying two kernels) regenerate byte for byte from program.json and pass the text check: k masked slot definitions with the class mask, k tagged stores, 3k fill calls, one arena definition with the class stride, one tag definition, no `scratch[`; six deliberate breaks caught (section 8) |
|
||||
| Aliasing between lanes | Lane-major: lane l of warp w owns words `[(32w + l) x 4S, (32w + l + 1) x 4S)`; two (w, l) pairs give disjoint ranges. Inside the range a slot is 4 words at `4 x s_`, so two slots of one lane are disjoint too. | the `lanevar` edge program: one init-dependent slot per lane, 32 lanes at 64 slots share slots in pairs by the birthday bound; any cross-lane aliasing would change the fold; 128 of 128 lanes on Metal (section 7.2) |
|
||||
| Wave64 (two logical units in one hardware wave) | `warp_ = tid >> 5`, so the two halves get `warp_ = 2w` and `2w + 1`, two arenas; `gbase` and `tag` are per `g_`, per half. | not run on wave64 hardware (the OpenCL emulator's persistent launch is on the readwidth commit; unverified here) |
|
||||
| Determinism: alignment | A slot is 16 bytes at byte offset `16 x (lane_base + s_)`; the arena base is the buffer base: Metal, CUDA and OpenCL allocations are at least 128-byte aligned (CUDA 256, OpenCL `CL_DEVICE_MEM_BASE_ADDR_ALIGN` at least the largest built-in type, approximate from memory), so every 16-byte vector access is aligned. | Metal: every run of section 7 |
|
||||
| Determinism: ordering | A lane's two read-modify-writes of the same slot in one hash are a load and a store, then a load and a store, from one thread to one address: program order within a thread holds in every model. No other thread touches the slot (aliasing row), so no atomics, fences or barriers are needed and none are emitted. | `slot0` and `sixteen` edge programs: 64 and 128 dependent read-modify-writes on one slot per lane per hash, standalone and as the second unit on a warp |
|
||||
| Determinism: vendors | The statement is integer only: xor, rotate by immediate, multiply, add, a 16-byte load and store. Bit-exact across Metal, CUDA and OpenCL by construction; measured only on Metal here. | Metal; CUDA and OpenCL runs are PC jobs (not mine tonight) |
|
||||
| 32-bit nonce wrap | `gbase = baseNonce + g_ x 32` and `nonce = baseNonce + gid` wrap in 32-bit arithmetic; `scr_fill(gbase + lane)` wraps like the CPU's `base.wrapping_add(lane)`; `out[gid]` indexes by launch position, not by nonce. An aligned unit never straddles 2^32 (spec 1.9), so the wrap case is a launch whose unit SEQUENCE crosses it. | `packbench --batch-base 4294967040 --batch-log2 9`: 16 units from 0xffffff00, the ninth at gbase 0; fingerprint identical at 1 and 4 warps (section 7.2); every fuzz pack runs that launch |
|
||||
|
||||
## 6. Question 5: what a vector for the scratch class must carry
|
||||
|
||||
Before a scratch pack can be a conformance vector (plan step 4, "only then a vector"), it must carry, beyond what
|
||||
`igneum-program-pack-3` carries today:
|
||||
|
||||
1. The class in the program id and the pack (`scr<k>k<kb>`: it is, `program_id_class`, `generator.rs:400-412`)
|
||||
and the geometry (slots per lane, words per lane, bytes per warp: it is, `program.h`).
|
||||
2. The fill and the rewrite as text (it is, `program.json` "scratch").
|
||||
3. The host contract of section 4.3 as text in `program.h` and `program.json`: tag counter from 1, zero at
|
||||
allocation and at the wrap, `groups` a multiple of the warp count. Not there today.
|
||||
4. Vectors that exercise the tag path, which the three standard units do not reliably: two consecutive units on
|
||||
one warp (bases 0 and 32 in one one-warp launch) for a program whose consecutive units collide on a slot. At
|
||||
64 slots any generated program collides (25 touched of 64 per unit; the broken-tag run of section 8 was caught by
|
||||
base 1,000,000, a warp's 16th unit, and NOT by a two-unit launch whose vectors lack base 32). At 2,048 slots two
|
||||
consecutive units share a touched slot with probability about 0.4 (32 x 32 / 2,048 expected overlaps = 0.5), so
|
||||
the standard vectors would miss a broken tag at the 1 MiB size with probability about 0.6 per unit pair. The
|
||||
edge programs `slot0` and `sixteen` collide on every slot at every size: a vector set should carry one.
|
||||
5. A unit in the top 256 nonces with the launch crossing 2^32 (`--batch-base` near the top, at least two warps).
|
||||
6. The batch fingerprint declared independent of the warp count (`8c07620f4d9adefd` for scr4k32 at 2^12 nonces
|
||||
from base 0 at 1, 2 and 128 warps, section 7.2): unit independence is the property the per-unit reset gives, and
|
||||
a fingerprint that moved with the warp count would mean a unit read another unit's slot.
|
||||
|
||||
## 7. Tests and results
|
||||
|
||||
### 7.1 CPU (`igneum-pow/tests/scratch.rs`, `cargo test -j4 --test scratch`, M5 Max, 5 October 2026, 3.6 s)
|
||||
|
||||
| Test | What | Result |
|
||||
|---|---|---|
|
||||
| `rewrite_is_a_bijection_of_the_fold_value` | 16 random slot contents x 2^16 consecutive fold values, each written word distinct; the three inversions | pass |
|
||||
| `fill_is_a_bijection_of_the_nonce` | 7 slots x 3 words x 2^16 nonces distinct; lane and base interchange; wrap | pass |
|
||||
| `written_words_unbiased_and_rehit_rates` | 6 classes x 3 seeds x 2^11 units, every rewrite traced: bias within 6 sigma (worst 3.63), re-hit rate within 0.9x to 2x of birthday, slot histogram, depth histogram | pass (tables of sections 2.3 and 3.2) |
|
||||
| `edge_programs_match_the_hand_model` | 7 edge programs x 2 geometries x 4 bases (0, 32, 0x7ffffff0, 0xffffffe0) against an independent hand model; the slots driven and the re-hit counts as built; the mutated hand model mismatches | 56 of 56 pass, 56 of 56 teeth |
|
||||
| `scr_packs_regenerate_and_pass_the_static_scratch_check` | 7 scr packs: program and program id from program.json, 6 kernel texts byte for byte, static scratch check on all 42, the pack's vectors from the CPU; six deliberate breaks caught | pass |
|
||||
| `fuzz_scr_programs_cpu` | 200 generated programs over the six classes, generator contract and acceptance on every one, 4 units each (one in 0..224, one around 2^31, one in the top 256 nonces, one uniform), traced run equal to the untraced run, every slot inside the lane; writes the 214 packs for Metal with `IGNEUM_SCRATCH_PACKS_OUT` | pass; 200 of 200 have a unit in the top 256 |
|
||||
| `slot_is_replayable_from_its_fold_values` | 64 dependent read-modify-writes replayed from the fill and the fold values; one dropped diverges | pass |
|
||||
|
||||
The rest of the crate: 33 of 34 lib tests and all pack tests pass; `verify::tests::fold_and_wide_fetch` fails on the
|
||||
readwidth tip itself (`verify.rs:508`, `k as u32 * 0x9E37_79B1` overflows under the test profile's overflow
|
||||
checks; the readwidth agent's test, reported to its owner, not touched here).
|
||||
|
||||
### 7.2 Metal (`proto-metal/packbench` built from this branch, M5 Max, 5 October 2026, under `with-lock.sh run`)
|
||||
|
||||
| Run | Launch | Expected | Result |
|
||||
|---|---|---|---|
|
||||
| scr4k32, standard pack | 2,048 warps, 2^24 nonces, 1 batch | 3 of 3 standalone, 3 of 3 in batch | PASS, fingerprint `3d1af881bd978fb9`; 1.8 s wall for the whole run (compile, cache, 1 GiB build, vectors, batch) |
|
||||
| scr4k32, warp-count independence | 2^12 nonces (128 units) at 1, 2 and 128 warps | one fingerprint | `8c07620f4d9adefd` at all three, PASS |
|
||||
| scr4k32, wrap inside the launch | 512 nonces from 0xffffff00 at 1 and 4 warps | one fingerprint, the base-0 vector inside the window after the wrap | `8e9e233234d3a297` at both, in-batch 1 of 1, PASS |
|
||||
| scr4k32, broken tag (`tag = salt`), standard vectors | 2,048 warps, 2^24 | the base-1,000,000 vector (warp 530's 16th unit) fails | standalone 3 of 3, in batch 2 of 3, overall FAIL (caught) |
|
||||
| scr4k32, broken tag, two units on one warp | 1 warp, 2^6 | nothing to catch it: the standard vectors have no base 32 | standalone 3 of 3, in batch 1 of 1, PASS (missed: the point of section 6 item 4) |
|
||||
| 14 edge packs (7 programs x 32 and 128 KiB), run A | 1 warp, 2^6 (units at bases 0 and 32 on one arena) | 4 of 4 standalone, 2 of 2 in batch each | 14 of 14 PASS (56 of 56 standalone units, 28 of 28 in batch) |
|
||||
| 14 edge packs, run B | 1 warp, 2^9 from 0xffffff00 (16 units on one arena, the wrap inside) | 4 of 4 standalone, 3 of 3 in batch each | 14 of 14 PASS (56 of 56, 42 of 42) |
|
||||
| edge `slot0` at 32 and 128 KiB, broken tag (`tag = salt`) | 1 warp, 2^6 | standalone 4 of 4, in batch 1 of 2, FAIL | as expected at both geometries: the second unit read the first's slot 0 and FAILED; the standalone units passed |
|
||||
| edge `slot0` at 32 KiB, broken lazy fill (`m_` forced to all ones: a first touch reads the stale words) | 1 warp, 2^6 | standalone fails | 0 of 4 standalone, 0 of 2 in batch, FAIL (lane 0 of base 0: GPU `64b49aeb987dae69`, expected `9ff3a2021f66b5be`) |
|
||||
| 200 fuzz packs (scr2k32 29, scr4k32 26, scr8k32 42, scr2k128 32, scr4k128 26, scr8k128 45; datasets 64 MiB, 256 MiB, 1 GiB) | 2 warps, 2^9 from 0xffffff00 (8 units per warp, the wrap inside) | 4 of 4 standalone, 2 of 2 in the window, 200 of 200 PASS | 200 of 200 PASS: 800 of 800 standalone units (25,600 hashes), 400 of 400 in batch; 91 s for the 200 runs |
|
||||
|
||||
Totals on Metal: 228 of 228 runs PASS where a pass was expected, 3 of 3 FAIL where a failure was built in.
|
||||
|
||||
## 8. Deliberate breaks (the watcher rule)
|
||||
|
||||
| Break | Where | Caught by | Evidence |
|
||||
|---|---|---|---|
|
||||
| One slot mask dropped (Metal) | copy of scr4k32 `program.metal` | static check: "masked slot followed by the load: 3, expected 4" | test output |
|
||||
| Mask 63 changed to 127 on every RMW (Metal) | same | "masked slot followed by the load: 0, expected 4" | test output |
|
||||
| Arena stride 256 changed to 128 words (Metal) | same | "arena definition: 0, expected 1" | test output |
|
||||
| A stray `arena[0]` and `scratch[1]` access (Metal) | same | "arena mentions: 10, expected 9; direct scratch indexing: 1, expected 0" | test output |
|
||||
| One slot mask dropped (OpenCL, CUDA) | copies of scr4k32 `kernel.cl`, `kernel.cu` | "masked slot followed by the load: 3, expected 4" | test output |
|
||||
| Wrong class geometry or RMW count or kernel count passed against a right text | the same text | the check fails | test output |
|
||||
| `tag = salt` (every unit of a launch shares the tag) | copy of scr4k32 `program.metal`, on the GPU | the base-1,000,000 vector in a 2,048-warp batch | `vectors standalone 3/3, in batch 2/3`, overall FAIL |
|
||||
| the same on the `slot0` edge pack, two units on one warp | on the GPU | in-batch 1 of 2 | bench-log entry |
|
||||
| lazy fill broken (`m_` all ones) | copy of the `slot0` edge pack, on the GPU | standalone vectors | bench-log entry |
|
||||
| The hand model's rewrite words swapped | `tests/scratch.rs` | every edge case mismatches | 56 of 56 |
|
||||
|
||||
The out-of-bounds break (mask dropped) was not run on the GPU on purpose: Metal does not bounds-check device
|
||||
buffers (`TESTS.md` section 5), so a run would read another lane's or another buffer's words and "did not crash" would
|
||||
prove nothing. The static check is the guard, as it is for the dataset mask.
|
||||
|
||||
## 9. What is unverified
|
||||
|
||||
1. CUDA and OpenCL runs of the scratch packs on NVIDIA and AMD (PC jobs, reserved for the readwidth agent tonight);
|
||||
the 5090's rate per variant, so the "projected" column of section 3.4 is the distinct-load bound, not a
|
||||
measurement. Wave64 hardware for the two-arena argument.
|
||||
2. The chip-side latency figures of section 3.4 (10 ns SRAM read, 40 ns mixer) are approximate; the conclusion
|
||||
does not depend on them: at ten times the in-flight count the scratch is still under a sixth of the mirror.
|
||||
3. The recycled-allocation case was not reproduced (it needs a driver that hands back live contents); the argument
|
||||
is that nothing forbids it and the contract of 4.3 removes it.
|
||||
4. The slot-bias finding (section 3.2) was measured on three seeds per class; the hottest-slot ratio will vary by
|
||||
program.
|
||||
5. `verify::tests::fold_and_wide_fetch` on the readwidth tip (section 7.1).
|
||||
|
||||
## 10. Recommendation
|
||||
|
||||
1. Layer 3 is sound as a construct: the written words are uniform, the chain inside a unit has no short cut, the
|
||||
kernels cannot write out of bounds, and with the host contract of section 4.3 the CPU's one-warp simulation is
|
||||
exact (14 edge packs, 200 fuzz packs, the wrap, consecutive units on one arena, on Metal).
|
||||
2. Layer 3 is not sound as a chip-resistance layer, at the capped size or at any size: CPU verification resets the
|
||||
scratch per unit, so its live state is 8k slots per lane whatever the arena, a chip keeps it implicitly in 80 to
|
||||
320 bytes per lane, and the named chip (on-die cache mirror plus recompute) keeps its whole scratch in 1.4 to
|
||||
13 MB of SRAM at 530 units in flight, 3 to 5 percent of its mirror. Its gain stays at 2.4x (7x with a 3x
|
||||
fixed-function factor, approximate) at 0, 12.5, 25 and 50 percent, replaced or added, 32 or 128 KiB. No share
|
||||
under the 6 GB cap brings it under 2x.
|
||||
3. Taking the read-modify-writes from the 16 dataset loads makes the hash less memory-hard for everyone: the GPU
|
||||
measured faster at every share on the M5 Max (readwidth rows), and the chip's operations per hash fall with the
|
||||
loads. If a scratch is kept at all, add it beside the 128 loads. There is no reason found here to keep one.
|
||||
4. The lever that moves the named chip is the M16 mixer multiplier: x2 to 1.2x, x4 to 0.6x against the measured
|
||||
5090 rate, at 0.8 to 4.8 ms of verification per warp against the 10 ms gate. Decision 2 should price that
|
||||
against the gate on the 2019-class core (O-1.14) rather than layer 3.
|
||||
5. If the project lead keeps layer 3 for a reason outside this analysis: ship the host contract in the pack, add the four
|
||||
vector items of section 6 (consecutive units with a forced collision, the wrap launch, the warp-count-independent
|
||||
fingerprint, the contract text), and run the CUDA and OpenCL twins of section 7.2 on the PCs before the class
|
||||
becomes a genesis rule.
|
||||
283
docs/analysis/sram-mirror.md
Normal file
283
docs/analysis/sram-mirror.md
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
# Layer 6: the SRAM mirror of the cache against published SRAM density, year 0 to 10
|
||||
|
||||
5 October 2026 (night), Counter ASIC 2.0 (`docs/plans/counter-asic-2.md`, layer 6), branch `ca2-analysis`. Every figure
|
||||
below is either cited (paper, vendor document, URL, date) or labelled approximate. Nothing here is a measurement of a
|
||||
chip. Numbers in this file were computed with the arithmetic shown; the script is in section 10.
|
||||
|
||||
Revision 2 (same night): the first draft priced the mirror from bit-cell area times a 0.70 array factor. The
|
||||
coordinator's chip-economics research (sources below) showed that shipped cache-only dies land at about half that
|
||||
density once assist circuits, redundancy, TSVs, power and test are in. Every table now carries two columns: the
|
||||
shipped-product density as the headline and the bit-cell figure as the lower bound. The conclusion did not move; the
|
||||
cost per die rose 2 to 3x.
|
||||
|
||||
## 1. The question
|
||||
|
||||
The lottery hash derives every dataset item from a 256 MiB cache (spec 01 sections 1.5 and 1.8). A chip that holds the
|
||||
cache in on-die SRAM can recompute items instead of reading the dataset (ledger M16, the recompute attacker). Layer 6
|
||||
asks whether the cache size, as the specification schedules it, keeps that SRAM mirror unaffordable for ten years of
|
||||
the genesis schedule, and if not what growth rule would.
|
||||
|
||||
Two things also sit in a chip's SRAM budget if it mirrors the full read-only working set: the layer 5 hot table (32,
|
||||
64 or 96 MB, a class parameter on `readwidth` b970dda, coordinator's note of 5 October) beside the 256 MiB cache. The
|
||||
per-warp scratch of layer 3 (32 or 128 KB per warp, written, not read-only) is not mirrorable and is left out of the
|
||||
mirror; it is counted in the 6 GB working-set budget in section 7.
|
||||
|
||||
## 2. What the specification schedules for the cache
|
||||
|
||||
| Quantity | Rule | Where |
|
||||
|---|---|---|
|
||||
| Dataset | 2 GiB at genesis plus 0.5 GiB per year (`N_d` grows about 23 KiB per day) | spec 01 section 1.13.3, Designed |
|
||||
| Cache | 256 MiB, "prototype value, to be fixed at gate 1"; the rule that fixes it: "the cache must exceed the largest on-chip cache of any card that mines, and 96 MiB of L2 on the 5090 is the figure to beat" | spec 01 sections 1.5 and 1.16 |
|
||||
| Cache growth | None. No section of `docs/spec/` grows the cache (grep of `docs/spec` for cache growth, schedule, doubling: only the dataset rule of 1.13.3 and the README's "growth" word, which refers to it) | this analysis, 5 October 2026 |
|
||||
|
||||
So the plan's layer 6 row ("already in the design; confirm the schedule") is half right: dataset growth is in the
|
||||
design, cache growth is not. The cache is flat at 256 MiB for every year of the schedule as the spec stands. M16's
|
||||
closing line names the rule the cache should get ("exceeds what one die can hold, and grows") as a gate 1 decision
|
||||
that has not been taken.
|
||||
|
||||
## 3. SRAM density, cited: bit cells per node and shipped cache dies
|
||||
|
||||
### 3.1 Bit cells
|
||||
|
||||
| Node (vendor) | HD 6T bit cell, um^2 | Raw density, Mbit/mm^2 (1/cell) | Year of volume (approximate) | Source |
|
||||
|---|---|---|---|---|
|
||||
| N7 (TSMC) | 0.027 | 37.0 | 2018 | WikiChip, "TSMC Details 5 nm" (ISSCC/IEDM disclosures), https://fuse.wikichip.org/news/3398/tsmc-details-5-nm/ |
|
||||
| N5 (TSMC) | 0.021 | 47.6 | 2020 | same (two N5 cells: HD 0.021, HP 0.025) |
|
||||
| N3B (TSMC) | 0.0199 | 50.3 | 2022 to 2023 | WikiChip, "IEDM 2022: Did We Just Witness The Death Of SRAM?", https://fuse.wikichip.org/news/7343/iedm-2022-did-we-just-witness-the-death-of-sram/ (TSMC's IEDM 2022 N3 paper) |
|
||||
| N3E (TSMC) | 0.021 | 47.6 | 2023 | same; Tom's Hardware, "TSMC's 3nm Node: No SRAM Scaling", https://www.tomshardware.com/news/no-sram-scaling-implies-on-more-expensive-cpus-and-gpus |
|
||||
| N2 (TSMC) | 0.0175 | 57.1 | 2025 to 2026 | TSMC at IEDM 2024, reported by Tom's Hardware, https://www.tomshardware.com/tech-industry/tsmc-shares-deep-dive-details-about-its-cutting-edge-2nm-process-node-at-iedm-2024-35-percent-less-power-or-15-percent-more-performance ; ISSCC 2025 paper "A 38.1Mb/mm2 SRAM in a 2nm-CMOS-Nanosheet Technology", https://research.tsmc.com/page/memory/4.html |
|
||||
| Intel 18A | 0.021 | 47.6 | 2025 to 2026 | ISSCC 2025 paper 29.2, "A 0.021 um^2 High-Density SRAM in Intel 18A RibbonFET Technology with PowerVia", https://www.researchgate.net/publication/389644177 ; IEEE Spectrum 26 Feb 2025, https://spectrum.ieee.org/sram-intel-tsmc |
|
||||
| Samsung SF3 / SF2 | not disclosed as a bit cell area in anything found tonight (Samsung's ISSCC papers give assist circuits and macro figures, not the HD cell) | | | search of ISSCC 2021 to 2025 coverage, 5 October 2026; left out of the tables |
|
||||
|
||||
The stall. N3B's cell is 5% smaller than N5's and N3E's is the same size as N5's (0.021 um^2 both): zero SRAM
|
||||
scaling from N5 to N3E (WikiChip IEDM 2022 article above; Tom's Hardware above; SemiAnalysis "TSMC's 3nm Conundrum",
|
||||
https://newsletter.semianalysis.com/p/tsmcs-3nm-conundrum-does-it-even). N2's nanosheet cell recovers 17% (0.021 to
|
||||
0.0175 um^2). So across 2020 to 2026 the HD bit cell shrank once, by 17%.
|
||||
|
||||
Macro density from the bit cell. WikiChip's and SemiAnalysis's convention is bit-cell density times about 0.70 for
|
||||
the assist and periphery overhead (SemiAnalysis, December 2022: TSMC N5 HD SRAM macro 31.8 Mib/mm^2 after about 30%
|
||||
assist overhead; WikiChip's 31.8 Mib/mm^2 for the 0.021 um^2 cell is the same arithmetic). The two ISSCC 2025 macros
|
||||
bracket it: TSMC N2 38.1 Mb/mm^2 at a 0.0175 um^2 cell is 67%; Intel 18A 38.1 Mb/mm^2 array density and 34.3 Mb/mm^2
|
||||
for the volume macro at a 0.021 um^2 cell are 80% and 72%. That is a macro on a test chip. It is the LOWER BOUND on
|
||||
die area, not the die.
|
||||
|
||||
### 3.2 Shipped cache dies (what a whole die of SRAM really holds)
|
||||
|
||||
| Product | SRAM | Die | Node | MB per mm^2 | Source |
|
||||
|---|---|---|---|---|---|
|
||||
| AMD 3D V-Cache (Zen 3 SRAM chiplet) | 64 MB | 41 mm^2 | TSMC 7 nm | 1.56 | AMD at Hot Chips 33, reported by Tom's Hardware, August 2021, https://www.tomshardware.com/news/amd-unveils-more-ryzen-3d-packaging-and-v-cache-details-at-hot-chips ("the 3D V-Cache SRAM measures 41 mm^2", "64 MB of 7 nm SRAM"); the densest cache-only die that has shipped |
|
||||
| Graphcore GC200 (with compute) | 900 MB | 823 mm^2 | 7 nm | 1.09 | coordinator's chip-economics research, 5 October 2026 (vendor figures) |
|
||||
| Groq TSP | 220 MB | 725 mm^2 | 14 nm | 0.30 | same |
|
||||
|
||||
The V-Cache die is a pure SRAM die with its TSVs, redundancy, test and power: 1.56 MB/mm^2 at N7 against the bit-cell
|
||||
figure 37.0 Mbit/mm^2 = 4.6 MB/mm^2 and the 0.70-macro figure 3.2 MB/mm^2. The shipped die is 0.48 of the macro
|
||||
figure. The headline column below scales the V-Cache density to other nodes by the bit-cell ratio (0.027 / cell), an
|
||||
approximation that assumes the periphery and TSV overheads scale with the cell, which they do not fully (so the
|
||||
headline column is itself slightly optimistic for the attacker at N5 and below).
|
||||
|
||||
### 3.3 GPU on-die SRAM, the reticle, wafer prices
|
||||
|
||||
GPU on-die SRAM for scale: the RTX 5090 carries 96 MB of L2 (98,304 KB) on a 750 mm^2 TSMC 4N die with 92.2 billion
|
||||
transistors; the full GB202 has 128 MB; the RTX 4090 had 72 MB and the RTX 3090 6 MB (NVIDIA, "RTX Blackwell GPU
|
||||
Architecture" whitepaper v1.1, appendix table "L2 Cache Size", https://images.nvidia.com/aem-dam/Solutions/geforce/blackwell/nvidia-rtx-blackwell-gpu-architecture.pdf).
|
||||
At the V-Cache density scaled to N5 (2.0 MB/mm^2) that L2 is about 48 mm^2 of the 750 (6%), approximate. The
|
||||
RX 9070 XT carries 64 MB of Infinity Cache plus 8 MB of L2 (vendor figures, approximate, bench-log "the 9070 XT on the
|
||||
eGPU").
|
||||
|
||||
Reticle: the EUV field is 26 x 33 mm = 858 mm^2, about 830 mm^2 usable after scribe lanes (SemiAnalysis, "Die Size
|
||||
And Reticle Conundrum", https://newsletter.semianalysis.com/p/die-size-and-reticle-conundrum-cost ; WikiChip "Mask",
|
||||
https://en.wikichip.org/wiki/mask). The 5090's 750 mm^2 is 90% of it.
|
||||
|
||||
Wafer prices (approximate; TSMC publishes none, every figure is supply-chain reporting): N7 about $9,500, N5 and N3
|
||||
about $20,000 (Silicon Analysts, "Wafer Pricing by Node", September 2026, https://siliconanalysts.com/data/wafer-pricing);
|
||||
N2 about $30,000 (Tom's Hardware, https://www.tomshardware.com/tech-industry/semiconductors/tsmc-could-charge-up-to-usd45-000-for-1-6nm-wafers-rumors-allege-a-50-percent-increase-in-pricing-over-prior-gen-wafers).
|
||||
|
||||
## 4. Die area to mirror the cache, per node, two columns
|
||||
|
||||
Headline = V-Cache density (41 mm^2 per 64 MiB at N7) scaled by the bit-cell ratio. Lower bound = bits / (raw
|
||||
density x 0.70). Columns: the 256 MiB cache alone, the cache plus the 96 MB hot table of layer 5 (as MiB), and the
|
||||
larger caches of the options in section 7. Area in mm^2; a figure over 830 is split into the dies shown.
|
||||
|
||||
| Node | 256 MiB, headline | 256 MiB, lower bound | 256 + 96, headline | 256 + 96, lower bound | 512 MiB, headline / lower | 1 GiB, headline / lower | 4 GiB, headline / lower |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| N7 | 164 | 83 | 226 | 114 | 328 / 166 | 656 / 331 | 2,624 (4 dies) / 1,325 (2 dies) |
|
||||
| N5 | 128 | 64 | 175 | 89 | 255 / 129 | 510 / 258 | 2,041 (3 dies) / 1,031 (2 dies) |
|
||||
| N3B | 121 | 61 | 166 | 84 | 242 / 122 | 483 / 244 | 1,934 (3 dies) / 977 (2 dies) |
|
||||
| N3E, Intel 18A | 128 | 64 | 175 | 89 | 255 / 129 | 510 / 258 | 2,041 (3 dies) / 1,031 (2 dies) |
|
||||
| N2 | 106 | 54 | 146 | 74 | 213 / 107 | 425 / 215 | 1,701 (3 dies) / 859 (2 dies) |
|
||||
|
||||
One reticle (830 mm^2) holds, at the headline density, 1.3 GiB of SRAM at N7, 1.6 GiB at N5, N3E and 18A, 1.9 GiB at
|
||||
N2 (lower-bound column: 2.5, 3.2, 3.9 GiB).
|
||||
|
||||
Against the figures the ledger carries: M16's "100 to 300 mm^2" (low end from a 0.02 um^2 cell with overhead, high
|
||||
end from wafer-scale parts at about 1 MB per mm^2) brackets the headline 106 to 164 mm^2 well; the plan's "about
|
||||
45 mm^2 at a leading node" is below even the lower bound and should be read as the bit-cell area with no overhead.
|
||||
The right figures for the ledger are 106 to 164 mm^2 (shipped density) with 54 to 83 mm^2 as the floor.
|
||||
|
||||
## 5. Cost per good die, two columns
|
||||
|
||||
Dies per 300 mm wafer by the usual approximation pi x 150^2 / A minus the edge term pi x 300 / sqrt(2A); yield by
|
||||
Poisson exp(-A x D0) with D0 = 0.1 defects per cm^2 (an assumption, approximate; SRAM arrays carry redundancy so
|
||||
real yield is higher, which lowers these costs). Cost per good die = wafer price / (dies x yield). Packaging, test,
|
||||
the logic beside the SRAM and the design (masks at N5 and below run into the tens of millions of dollars,
|
||||
approximate) are not in these numbers; they are per-die silicon only. Headline / lower bound in each cell.
|
||||
|
||||
| Node, wafer price | 256 MiB | 256 + 96 MiB | 1 GiB | 4 GiB |
|
||||
|---|---|---|---|---|
|
||||
| N7, $9,500 | 164 mm^2, 379 dies, yield 0.85: $30 / $13 | $44 / $19 | $224 / $75 | $896 (4 dies) / $456 (2 dies) |
|
||||
| N5, $20,000 | 128 mm^2, 495 dies, 0.88: $46 / $21 | $68 / $30 | $306 / $111 | $1,512 (3 dies) / $621 (2 dies) |
|
||||
| N3B, $20,000 | 121 mm^2, 524 dies, 0.89: $43 / $20 | $63 / $28 | $280 / $103 | $1,371 (3 dies) / $569 (2 dies) |
|
||||
| N3E, 18A, $20,000 | $46 / $21 | $68 / $30 | $306 / $111 | $1,512 / $621 |
|
||||
| N2, $30,000 | 106 mm^2, 600 dies, 0.90: $56 / $26 | $81 / $37 | $343 / $131 | $1,641 (3 dies) / $696 (2 dies) |
|
||||
|
||||
Reading. The silicon for a 256 MiB mirror is $30 to $56 per die at shipped density (2 to 3x the first draft's
|
||||
figure), under $90 with the hot table. A funded chip programme pays that without noticing: it was never the SRAM
|
||||
that priced the recompute attacker out, and the plan's premise for layer 6 ("the SRAM mirror stays unaffordable")
|
||||
does not hold for the cache as a mirror and did not hold at genesis either. A 1 GiB cache is a 425 to 656 mm^2 die
|
||||
($224 to $343), affordable too; 4 GiB is a 3 to 4 die part at about $900 to $1,600 of silicon, which is a different
|
||||
product but not an impossible one (the attacker's problem at that size is the 1,024 dependent cross-die reads per
|
||||
hash, section 6).
|
||||
|
||||
## 6. What the mirror buys the attacker, year by year
|
||||
|
||||
From M16 (`docs/analysis/m16-recompute-attacker-2026-10-05.md`): with the cache on die the attacker recomputes 128
|
||||
items per hash at about 1,170 integer operations and 8 dependent 64-byte cache reads each, about 150,000 operations
|
||||
and 1,024 dependent SRAM reads per hash. At a 5090-class integer budget (about 50 T op/s, approximate) that is
|
||||
0.33 Ghash/s against the honest 141 Mhash/s projected for version 2 programs: 2.4x at equal silicon before any
|
||||
fixed-function factor, 3x to 6x with one (approximate). The SRAM is 106 to 164 mm^2 of that chip at the headline
|
||||
density (14 to 22% of a 750 mm^2 die; the m16 model's 13 to 40% band holds), so the mirror is cheap and the recompute
|
||||
route is bound by integer throughput, not by SRAM.
|
||||
|
||||
The layer 5 hot table changes nothing in that arithmetic: the hot table is read-only and derived from the day key
|
||||
like the cache, so a chip mirrors it in the same SRAM (another 32 to 96 MB, 24 to 48 mm^2 at N5 headline) and reads
|
||||
it at SRAM latency, which is exactly what a GPU's L2 does with it. Layer 5 taxes the DRAM-only chip (the one without
|
||||
SRAM); it does not tax the SRAM chip.
|
||||
|
||||
Dataset growth does not touch the recompute attacker: the attacker never holds the dataset. It taxes the
|
||||
partial-store attacker (O-1.6, the time-memory curve, not drawn) and the honest card.
|
||||
|
||||
Year by year under the schedule as it stands (flat 256 MiB), the mirror's area at the best node available that
|
||||
year, headline density. Node years are approximate; the density trend from 2018 to 2025 is 37.0 to 57.1 Mbit/mm^2
|
||||
raw, 1.54x in 7 years, about 6% per year, and it came in one step (N2); the extrapolation past 2026 assumes that
|
||||
average holds (approximate, and optimistic for the attacker: A16 and A14 have no disclosed SRAM cell yet).
|
||||
|
||||
| Year | Calendar (approximate) | Dataset, GiB | Cache (spec) | Best node | Mirror of the cache, headline (lower bound), mm^2 | With a 96 MiB hot table, headline, mm^2 | Mirror as a share of a 750 mm^2 die |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0 | 2027 | 2.0 | 256 MiB | N2 (cited) | 106 (54) | 146 | 14% |
|
||||
| 1 | 2028 | 2.5 | 256 MiB | N2 or A16 | 103 (52) | 142 | 14% |
|
||||
| 2 | 2029 | 3.0 | 256 MiB | trend | 95 (48) | 130 | 13% |
|
||||
| 3 | 2030 | 3.5 | 256 MiB | trend | 89 (45) | 123 | 12% |
|
||||
| 4 | 2031 | 4.0 | 256 MiB | trend | 84 (43) | 116 | 11% |
|
||||
| 5 | 2032 | 4.5 | 256 MiB | trend | 79 (40) | 109 | 11% |
|
||||
| 6 | 2033 | 5.0 | 256 MiB | trend | 75 (38) | 103 | 10% |
|
||||
| 7 | 2034 | 5.5 | 256 MiB | trend | 71 (36) | 97 | 9% |
|
||||
| 8 | 2035 | 6.0 | 256 MiB | trend | 67 (34) | 92 | 9% |
|
||||
| 9 | 2036 | 6.5 | 256 MiB | trend | 63 (32) | 87 | 8% |
|
||||
| 10 | 2037 | 7.0 | 256 MiB | trend | 59 (30) | 82 | 8% |
|
||||
|
||||
Reading. A flat cache's mirror shrinks from 14% to 8% of a large die over the decade, and a 5090-class consumer GPU
|
||||
already carries 96 MB of L2 on one die with the full GB202 at 128 MB; at the 2020 to 2025 pace of GPU L2 growth
|
||||
(6 MB, 72 MB, 96 MB on the three NVIDIA flagships in the whitepaper table) a consumer GPU could hold 256 MiB on die
|
||||
within the decade. The spec's own rule for the cache ("must exceed the largest on-chip cache of any card that
|
||||
mines") would then be broken by a flat cache. That is the real reason to grow it: not to price a chip out (section
|
||||
5 shows the SRAM cannot do that) but to keep the cache out of every GPU's own cache, so the honest hash stays
|
||||
DRAM-latency-bound and the recompute route stays a route only a custom chip can take.
|
||||
|
||||
## 7. Answer to the layer 6 question, and the options
|
||||
|
||||
Does the flat 256 MiB cache keep the SRAM mirror unaffordable through year 10? No. It is affordable at year 0 ($30 to
|
||||
$56 of silicon per die at shipped density, section 5) and gets cheaper. What keeps the recompute attacker near 1x is
|
||||
M16's integer arithmetic and the mixer-cost lever (4x the mixer cost puts the equal-silicon gain at 0.36x, bounded
|
||||
by the CPU verify gate), not the cache size. The cache size does one other job, keeping the cache larger than any
|
||||
GPU's L2, and that job needs growth.
|
||||
|
||||
Options for the cache rule, with the honest costs each implies. Verifier fill time is 0.2 s per 256 MiB on one core
|
||||
(spec 1.12: "a 0.2 s CPU cache fill", from the measured 175 to 190 ms of section 1.8.3), scaled linearly; the
|
||||
verifier holds the whole cache (section 1.11), so its memory is the cache size plus the program and the interpreter.
|
||||
GPU fill: 0.67 ms per 256 MiB on the 5090 (section 1.8.3), linear. The GPU dataset build (13.4 ms per 1 GiB on the
|
||||
5090, section 1.8.3) depends on the dataset size, not the cache size; a larger cache spreads the build's 8 dependent
|
||||
reads per item over more memory, which on a GPU means more of them miss L2 and the build slows by some factor
|
||||
between 1x and the L2-to-DRAM latency ratio, which is a measurement to take (approximate; owed). Mirror area is at N2
|
||||
headline density (lower bound in brackets), the node of the first years; at the trend's year-10 density divide by
|
||||
about 1.8.
|
||||
|
||||
| Option | Rule | Cache at year 0 / 4 / 10 | Mirror at N2, headline (lower bound), year 0 / 4 / 10, mm^2 | Dies at year 10 (830 mm^2 reticle), headline | Verifier fill, one core, year 0 / 10 | Verifier memory, year 10 | GPU cache fill (5090), year 10 | Keeps the cache above a 96 MB L2 at year 10 | Keeps it above a 256 MB L2 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| A, as specified | flat 256 MiB | 256 / 256 / 256 MiB | 106 (54) / 106 / 106 | 1 | 0.2 / 0.2 s | 256 MiB | 0.7 ms | yes, 2.7x | no |
|
||||
| B | cache = dataset / 8 (today's ratio) | 256 / 512 / 896 MiB | 106 (54) / 213 (107) / 372 (188) | 1 | 0.2 / 0.7 s | 896 MiB | 2.3 ms | yes, 9.3x | yes, 3.5x |
|
||||
| C | cache doubles when the dataset doubles (the dataset's own clock: year 4, then year 12) | 256 / 512 / 512 MiB | 106 (54) / 213 (107) / 213 (107) | 1 | 0.2 / 0.4 s | 512 MiB | 1.3 ms | yes, 5.3x | yes, 2x |
|
||||
| D | cache = dataset / 4 | 512 / 1,024 / 1,792 MiB | 213 (107) / 425 (215) / 744 (376) | 1 | 0.4 / 1.4 s | 1.75 GiB | 4.7 ms | yes | yes, 7x |
|
||||
| E, one reticle | cache sized so the mirror exceeds one reticle at the node of the day: 2 GiB at N2 headline density (section 4; 4 GiB on the lower bound), growing with density | 2 GiB / about 2.3 / about 3.5 GiB | 850 / 850 / 850 (by construction) | 2 | 1.6 / 2.8 s | 3.5 GiB | 5.4 / 9.4 ms | yes | yes |
|
||||
|
||||
Where the working set enters (coordinator's budget: 1 GiB table + hot table + scratch for every resident warp +
|
||||
buffers under 6 GB on an 8 GB card): the cache is not in the miner's working set at hash time (the dataset is built
|
||||
from it once a day and the cache can be dropped or kept), so options A to D do not move that budget; the dataset's own
|
||||
growth does (2 GiB at genesis, 4 GiB at year 4, 7 GiB at year 10, which is past an 8 GB card at about year 8 on its
|
||||
own). Option E's 2 GiB cache would have to be built on the card and dropped, which is fine for a 16 GB card and tight
|
||||
on an 8 GB one at build time (2 GiB cache + 2 GiB dataset + hot table). The per-warp scratch at 170 SMs x 64 warps
|
||||
(approximate, readwidth) is 340 MB at 32 KB and 1.36 GB at 128 KB per warp; with the 1 GiB table, a 96 MB hot table
|
||||
and buffers that is 1.5 to 2.5 GB at the prototype dataset size, 2.5 to 3.5 GB at the 2 GiB genesis size, inside
|
||||
6 GB either way.
|
||||
|
||||
Recommendation. Option C (the cache doubles when the dataset doubles) is the one that keeps the spec's own rule true
|
||||
with the smallest verifier cost: it ties the cache to a clock the spec already has, keeps `AND MASK` (a power of two
|
||||
every step, which is the 1.13.3 option (b) argument again), costs the verifier 0.4 s and 512 MiB at year 4 and nothing
|
||||
more until year 12, and keeps the cache 2x above a 256 MB GPU L2 if one appears. It does not price a chip out; nothing
|
||||
about cache size does (section 5). The lever that does is the mixer cost multiplier of M16, which is the gate 1
|
||||
decision to take beside this one. Option B is the same idea in a smooth form and costs the verifier 0.7 s at year 10.
|
||||
Option E is the only one that makes the mirror a multi-die part and it costs every verifier 1.6 s and 2 GiB at
|
||||
genesis (at the headline density; the lower-bound density would ask for 4 GiB and 3.2 s), which fails the spirit of
|
||||
the 10 ms verify gate (the fill is once a day, but a light node joining pays it on every day it syncs across).
|
||||
|
||||
Decision for the project lead, at gate 1: A, B, C, D or E above, together with M16's mixer multiplier. Nothing here changes a
|
||||
vector today: the cache size is a prototype value of spec 1.16 and the growth rule would be a new sentence in 1.13.3.
|
||||
|
||||
## 8. Why the latency bound is the property to lean on (citations behind the plan's rule)
|
||||
|
||||
The plan's "what stays true" paragraph says DRAM latency is the same physics for everyone and bandwidth per watt is
|
||||
what a custom memory chip buys. The sources behind that:
|
||||
|
||||
| Claim | Figure | Source |
|
||||
|---|---|---|
|
||||
| Random-access DRAM latency is the same across memory types | Row cycle time 40 to 48 ns across DDR4, GDDR5 and HBM2 | Li, Reddy and Jacob, "A Performance and Power Comparison of Contemporary DRAM Architectures", MEMSYS 2018 (coordinator's chip-economics research, 5 October 2026) |
|
||||
| Latency does not scale, bandwidth does | DRAM latency improved about 1.3x in two decades while bandwidth improved about 20x | K. Chang, "Understanding and Improving the Latency of DRAM-Based Memory Systems", PhD thesis, CMU, 2017 (same research) |
|
||||
| No mining chip has bought latency with exotic memory | No shipped mining chip has used HBM or stacked memory; the Ethash chips used DDR3, GDDR6 and undisclosed types | same research; the Ethash chip gain of about 3x in the plan came from bandwidth per watt, not latency |
|
||||
| The honest hash is latency-bound on every card measured | The hash runs within a few percent of 1/128 of each card's dependent random-read ceiling (5090, 9070 XT, M5 Max) | `docs/bench-log.md`, "the 9070 XT on the eGPU", 5 October 2026 (measured) |
|
||||
|
||||
Reading for layer 6: an SRAM mirror beats DRAM latency by about 10x per read (a 64 MiB buffer inside the 9070 XT's
|
||||
Infinity Cache chased at 9.2 G loads/s against 2.5 in GDDR6, the same bench-log entry; the 5090's L2 at 5.8x the
|
||||
hash rate of its 1 GiB dataset, M16), which is why the recompute attacker is bound by the 1,024 dependent SRAM reads
|
||||
and the 150,000 integer operations per hash and not by the SRAM's size or price. The cache size decides whether the
|
||||
mirror is one die or several (section 4); it does not decide whether the mirror exists.
|
||||
|
||||
## 9. What is cited, what is approximate, what is owed
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Bit cells for N7, N5, N3B, N3E, N2, Intel 18A | cited (section 3.1) |
|
||||
| Shipped cache-die density (AMD V-Cache 64 MB on 41 mm^2 at 7 nm; Graphcore GC200; Groq TSP) | cited (section 3.2; V-Cache checked against Tom's Hardware's Hot Chips 33 report, 5 October 2026; the Graphcore and Groq rows are from the coordinator's research and were not re-checked tonight) |
|
||||
| Scaling the V-Cache density to other nodes by the bit-cell ratio | approximate, stated |
|
||||
| Samsung SF2 or SF3 bit cell | not found; left out |
|
||||
| Array efficiency 0.70 | WikiChip's and SemiAnalysis's convention, bracketed by two ISSCC 2025 macros (67 to 80%); a macro figure, used only as the lower bound |
|
||||
| Wafer prices | approximate, supply-chain reporting, cited |
|
||||
| D0 = 0.1 per cm^2, Poisson yield | assumption, stated |
|
||||
| Node years and the 6% per year density trend past 2026 | approximate, extrapolated from cited 2018 to 2025 points |
|
||||
| GPU L2 sizes | cited (NVIDIA whitepaper); AMD Infinity Cache approximate |
|
||||
| Latency citations (MEMSYS 2018, Chang 2017, mining-chip memory types) | from the coordinator's research, not re-read tonight |
|
||||
| Recompute attacker arithmetic | M16, which is itself arithmetic on measured rates, not a chip measurement |
|
||||
| Dataset-build slowdown at a larger cache on a GPU | owed, a measurement (5090 at a 512 MiB and 1 GiB cache) |
|
||||
| The on-die emulation of M16 (inline kernel with a 64 MiB cache inside the 5090's L2) | still a PC job (M16) |
|
||||
|
||||
## 10. The arithmetic
|
||||
|
||||
```
|
||||
MiB = 2^20; bits = cache_MiB * MiB * 8
|
||||
headline_mm2 = cache_MiB * (41 / 64) * (cell_um2 / 0.027) (V-Cache: 41 mm2 per 64 MiB at N7, scaled by cell)
|
||||
raw_Mbit_per_mm2 = 1 / cell_um2 (1e6 cells per mm2 per um2 of cell)
|
||||
lower_bound_mm2 = bits / (raw * 0.70 * 1e6)
|
||||
dies_per_wafer = pi * 150^2 / area - pi * 300 / sqrt(2 * area)
|
||||
yield = exp(-area_mm2 * 0.001) (D0 = 0.1 per cm2)
|
||||
cost_per_good_die = wafer_price / (dies * yield); over 830 mm2: k = ceil(area / 830) dies of area / k, cost x k
|
||||
reticle_GiB = 830 / (mm2 per MiB) / 1024
|
||||
```
|
||||
Run on 5 October 2026 with Python 3 on the M5 Max; the printed tables are the ones above, rounded.
|
||||
227
docs/api/public-stats.md
Normal file
227
docs/api/public-stats.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# Public stats API
|
||||
|
||||
5 October 2026. Two JSON endpoints on the site for profitability sites, pool software and anyone who wants the
|
||||
network numbers without running a node: `/api/stats` and `/api/supply`. WhatToMine's listing form asks for an
|
||||
explorer or pool with an API, the reward halving schedule and a source to fetch total coins from; this is that source.
|
||||
A third endpoint, `/api/explorer`, feeds the explorer pages (docs/plans/explorer.md).
|
||||
|
||||
Both are served by Vercel functions (`site/api/stats.mjs`, `site/api/supply.mjs`) that read what the devnet observer
|
||||
(`tools/observer/observer.mjs`) wrote to Neon; no secret is involved beyond the database connection the site already
|
||||
holds. Cached 10 s at the edge (`Cache-Control: public, max-age=10, s-maxage=10`), CORS open (`Access-Control-Allow-Origin: *`),
|
||||
GET only. A failed read answers 500 with `{ok: false, error}` and `Cache-Control: no-store`.
|
||||
|
||||
The contract is the `FIELDS` list exported by each handler. `site/api/public-stats.test.mjs` checks a fixture against
|
||||
it without a database, and `tools/ci/public-api-check.mjs <url>` checks a deployment (CI runs it against
|
||||
https://igneum.network on master).
|
||||
|
||||
## /api/stats
|
||||
|
||||
| Field | Meaning | Source |
|
||||
|---|---|---|
|
||||
| `network`, `chain_id`, `node_version` | Network name, EVM chain id (4461 mainnet, 4462 testnet, 4463 devnet, design 8.1), the node's version | observer `live_state` |
|
||||
| `algorithm` | The lottery hash, named | fixed text |
|
||||
| `stale`, `age_s`, `observer_updated_at` | `stale` when the observer has not written for 30 s; treat every number as last known then | observer |
|
||||
| `height` | The chain block number (the EVM block number): the number of the newest chain block the observer has a shard plan for | `live_blocks.number` |
|
||||
| `block_count`, `header_count` | Every DAG block the node holds | `getBlockDagInfo` |
|
||||
| `daa`, `blue_score` | DAA score and blue score of the newest block | `live_blocks`, `getSinkBlueScore` |
|
||||
| `difficulty` | The node's difficulty (target per block) | `getBlockDagInfo` |
|
||||
| `hashrate`, `hashrate_unit`, `hashrate_source` | H/s: the node's `estimateNetworkHashesPerSecond` over 1,000 blocks, else blue work added per second over 10 min | observer |
|
||||
| `block_time_target_s`, `block_time_measured_s` | 1 s by design (spec 2.1); 60 / DAG blocks in the last 60 s | observer |
|
||||
| `blocks_per_day_target`, `blocks_per_day_measured` | 86,400; the last hour's DAG blocks x 24 (null until the observer has an hour) | observer |
|
||||
| `block_reward` | `E(daa)` of spec 2.5 at the newest DAA score: `sompi` (8 decimals), `ign`, the 80% `miner_ign` and 20% `proving_pool_ign`, `ramp_factor`, `halving_period`, `next_halving_daa`, `next_halving_in_s` | `site/lib/emission.mjs` |
|
||||
| `last_block` | Hash, time, age, blue score, DAA, coinbase address, vote key id, EVM transaction count, whether it is a chain block | `live_blocks` |
|
||||
| `finality` | Finality v2: active, the latest locked checkpoint index and blue score | observer |
|
||||
| `peers`, `mempool`, `miners_10m` | Connected peers, mempool size, distinct vote keys in 10 min (a card runs several) | observer |
|
||||
|
||||
Note for a profitability calculator: `block_reward` is per blue block merged; at the 1 block per second target that is
|
||||
also the reward per DAA second. The 20% proving-pool part is paid to provers, not to the miner of the block, so a
|
||||
miner's expected income per block is `miner_ign`. During the 30-day launch ramp the reward climbs from 10% to 100%
|
||||
of the schedule (`ramp_factor`); `/api/supply` shows where the ramp stands.
|
||||
|
||||
Example, the devnet on 5 October 2026 (through the local preview against a test observer, so `block_time_measured_s`
|
||||
reflects a one-minute window):
|
||||
|
||||
```
|
||||
{
|
||||
"ok": true,
|
||||
"now": "2026-10-05T19:32:09.321Z",
|
||||
"network": "igneum-devnet",
|
||||
"chain_id": 4463,
|
||||
"node_version": "2.1.0",
|
||||
"algorithm": "Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)",
|
||||
"stale": false,
|
||||
"age_s": 0.9,
|
||||
"height": 82145,
|
||||
"block_count": 126358,
|
||||
"header_count": 126358,
|
||||
"daa": 126357,
|
||||
"blue_score": 123504,
|
||||
"difficulty": 125543017.00697394,
|
||||
"hashrate": 258756880,
|
||||
"hashrate_unit": "H/s",
|
||||
"hashrate_source": "the node's estimateNetworkHashesPerSecond over a 1,000-block window; blue work added per second over 10 min when the node refuses the window",
|
||||
"block_time_target_s": 1,
|
||||
"block_time_measured_s": 0.952,
|
||||
"blocks_per_day_target": 86400,
|
||||
"blocks_per_day_measured": 14040,
|
||||
"block_reward": {
|
||||
"sompi": "455909062",
|
||||
"ign": "4.55909062",
|
||||
"miner_ign": "3.6472725",
|
||||
"proving_pool_ign": "0.91181812",
|
||||
"split": "80% block producer, 20% proving pool",
|
||||
"daa_used": 126357,
|
||||
"ramp_factor": 0.143874,
|
||||
"halving_period": 0,
|
||||
"next_halving_daa": 63115200,
|
||||
"next_halving_in_s": 62988843
|
||||
},
|
||||
"last_block": {
|
||||
"hash": "bbec3139d3f38e3517716374707998e77f536a67813edfb619c5bed93a5779ab",
|
||||
"time": "2026-10-05T19:32:06.157Z",
|
||||
"ts_ms": 1791228726157,
|
||||
"age_s": 3.2,
|
||||
"blue_score": 123504,
|
||||
"daa": 126357,
|
||||
"miner": "igneumdev:qrt8nzgrghr2a2xuc7lstzclcnt3n632d2chhc946rphkc6flcyswvkrym8s4",
|
||||
"miner_id": "7b8ef6fd",
|
||||
"tx_count": 0,
|
||||
"chain": true
|
||||
},
|
||||
"finality": {
|
||||
"active": true,
|
||||
"latest_locked_index": 4116,
|
||||
"latest_locked_blue_score": 123480,
|
||||
"chain_id": "igneum-devnet"
|
||||
},
|
||||
"peers": 4,
|
||||
"mempool": 0,
|
||||
"miners_10m": 21,
|
||||
"observer_updated_at": "2026-10-05T19:32:08.394726+00:00",
|
||||
"source": "tools/observer reading one node every 2 s; reward from docs/spec/02-consensus.md 2.5 at the node's DAA score"
|
||||
}
|
||||
```
|
||||
|
||||
## /api/supply
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `unit` | IGN; the coinbase pays in 8-decimal units (open item O-2.6), the EVM shows 18 |
|
||||
| `daa` | The newest DAA score the observer stored |
|
||||
| `max_supply_ign` | 4,000,000,000, the hard cap (spec 2.5, no tail emission: spec 5.10) |
|
||||
| `circulating_ign`, `circulating_sompi` | Minted so far by the rule: `E(t)` summed over every DAA second from 0 to `daa`, exact (floor sum, `mintedByRule`) |
|
||||
| `minted_at_end_ign`, `never_minted_ign` | What the schedule reaches when the per-second rate hits 0 (period 32), and the part of the cap the ramp and the floors never mint |
|
||||
| `emission_per_second_ign`, `block_reward_ign` | `E(daa)` now |
|
||||
| `halving` | Interval 63,115,200 DAA s (two years), current period, the next halving's DAA score, seconds to it, a date estimate at one DAA second per second |
|
||||
| `ramp` | 10% at genesis to 100% at DAA 2,592,000 (30 days), the factor now, whether it is complete |
|
||||
| `schedule` | 33 rows: period, start and end DAA, years from genesis, IGN per second, IGN per block at 1 BPS, minted by the end of the period, share of the cap |
|
||||
| `check` | The observer's hourly comparison of the chain against the rule over its newest 500 blocks: `rule_match` counts blocks whose coinbase payload declares exactly `E(daa)`; `sum_match` counts blocks whose coinbase outputs equal the declared subsidies of the blocks they merge; `examples` names mismatches |
|
||||
| `rule`, `source`, `note` | The formula, where it lives, and the caveat: the chain pays per block merged, so a block rate above target mints above the schedule for as long as it lasts |
|
||||
|
||||
Example (schedule cut to five rows here):
|
||||
|
||||
```
|
||||
{
|
||||
"ok": true,
|
||||
"now": "2026-10-05T19:32:09.374Z",
|
||||
"network": "igneum-devnet",
|
||||
"chain_id": 4463,
|
||||
"unit": {
|
||||
"symbol": "IGN",
|
||||
"decimals_consensus": 8,
|
||||
"decimals_evm": 18,
|
||||
"note": "the coinbase pays in 8-decimal units (open item O-2.6 keeps Kaspa's SOMPI_PER_KASPA); the EVM shows the same amount at 18 decimals"
|
||||
},
|
||||
"daa": 126357,
|
||||
"max_supply_ign": "4000000000",
|
||||
"circulating_ign": "488236.39686436",
|
||||
"circulating_sompi": "48823639686436",
|
||||
"minted_at_end_ign": "3963038988.86765648",
|
||||
"never_minted_ign": "36961011.13234352",
|
||||
"emission_per_second_ign": "4.55909062",
|
||||
"block_reward_ign": "4.55909062",
|
||||
"halving": {
|
||||
"interval_daa_s": 63115200,
|
||||
"interval_years": 2,
|
||||
"period": 0,
|
||||
"next_halving_daa": 63115200,
|
||||
"next_halving_in_s": 62988843,
|
||||
"next_halving_estimate": "2028-10-03T20:26:12.374Z",
|
||||
"estimate_note": "the estimate assumes one DAA second per wall-clock second from now"
|
||||
},
|
||||
"ramp": {
|
||||
"start_percent": 10,
|
||||
"length_daa_s": 2592000,
|
||||
"length_days": 30,
|
||||
"factor_now": 0.143874,
|
||||
"complete": false,
|
||||
"remaining_s": 2465643,
|
||||
"withheld_ign": "36961011.13234352",
|
||||
"withheld_note": "the ramp withholds about 37 million IGN that are never minted; integer floors withhold the rest (spec 2.5)"
|
||||
},
|
||||
"schedule": [
|
||||
{
|
||||
"period": 0,
|
||||
"start_daa": 0,
|
||||
"end_daa": 63115200,
|
||||
"years_from_genesis": "0 to 2",
|
||||
"per_second_ign": "31.68808781",
|
||||
"per_block_ign_at_1bps": "31.68808781",
|
||||
"minted_by_end_ign": "1963038999.85152848",
|
||||
"share_of_cap_by_end": 49.0759
|
||||
},
|
||||
{
|
||||
"period": 1,
|
||||
"start_daa": 63115200,
|
||||
"end_daa": 126230400,
|
||||
"years_from_genesis": "2 to 4",
|
||||
"per_second_ign": "15.8440439",
|
||||
"per_block_ign_at_1bps": "15.8440439",
|
||||
"minted_by_end_ign": "2963038999.40880848",
|
||||
"share_of_cap_by_end": 74.0759
|
||||
},
|
||||
{
|
||||
"period": 2,
|
||||
"start_daa": 126230400,
|
||||
"end_daa": 189345600,
|
||||
"years_from_genesis": "4 to 6",
|
||||
"per_second_ign": "7.92202195",
|
||||
"per_block_ign_at_1bps": "7.92202195",
|
||||
"minted_by_end_ign": "3463038999.18744848",
|
||||
"share_of_cap_by_end": 86.5759
|
||||
},
|
||||
"... 29 more rows ...",
|
||||
{
|
||||
"period": 32,
|
||||
"start_daa": 2019686400,
|
||||
"end_daa": 2082801600,
|
||||
"years_from_genesis": "64 to 66",
|
||||
"per_second_ign": "0",
|
||||
"per_block_ign_at_1bps": "0",
|
||||
"minted_by_end_ign": "3963038988.86765648",
|
||||
"share_of_cap_by_end": 99.0759
|
||||
}
|
||||
],
|
||||
"check": {
|
||||
"bps": 1,
|
||||
"sampled": 470,
|
||||
"examples": [],
|
||||
"sum_match": 466,
|
||||
"checked_at": "2026-10-05T19:30:28.385Z",
|
||||
"rule_match": 470,
|
||||
"sum_skipped": 4,
|
||||
"sum_mismatch": 0,
|
||||
"rule_mismatch": 0
|
||||
},
|
||||
"rule": "E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200); ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000); t = DAA score / bps, bps = 1",
|
||||
"source": "docs/spec/02-consensus.md 2.5; vendor/igneum-node consensus/core/src/igneum.rs block_subsidy and launch_ramp; circulating = the rule summed over every DAA second from 0 to the node's DAA score (site/lib/emission.mjs mintedByRule, exact)",
|
||||
"note": "circulating is the schedule at this DAA score. The chain pays E per blue block it merges and per red inside the DAA window, which tracks the schedule one block per DAA step; the devnet of 3 October 2026 ran 4.7x the schedule for eight minutes during a retarget lag (spec 2.5). check reports what the observer measured on the newest blocks."
|
||||
}
|
||||
```
|
||||
|
||||
## /api/explorer
|
||||
|
||||
The explorer's own feed, cached 5 s: `?blocks=N[&before=ms]` (latest blocks), `?block=hash`, `?height=N`,
|
||||
`?address=0x..|igneumdev:..`, `?search=q`. Shapes are in `site/api/explorer.mjs`; the pages are the reference client.
|
||||
Balances need `EXPLORER_EVM_RPC` on the deployment (a public EVM JSON-RPC); without it `balance.available` is false
|
||||
with the reason.
|
||||
1207
docs/bench-log.md
1207
docs/bench-log.md
File diff suppressed because it is too large
Load diff
301
docs/benchmarks/evm-relay-2026-10-05/relay-report.json
Normal file
301
docs/benchmarks/evm-relay-2026-10-05/relay-report.json
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
{
|
||||
"tool": "tools/txgen/relay-net.mjs",
|
||||
"node": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-txgossip/release/igneumd",
|
||||
"topology": "A - B - C (B dials A and C); generator on A; vmine on B and C",
|
||||
"params": {
|
||||
"rate_per_s": 2,
|
||||
"duration_s": 120,
|
||||
"wallets": 16,
|
||||
"fund_ign": 2,
|
||||
"fast_time": true
|
||||
},
|
||||
"chain_blocks_in_window": 149,
|
||||
"executed": 256,
|
||||
"skipped": 0,
|
||||
"by_miner": {
|
||||
"B": {
|
||||
"blocks": 79,
|
||||
"blocks_with_txs": 59,
|
||||
"txs_carried": 151,
|
||||
"executed": 151,
|
||||
"skipped": 0
|
||||
},
|
||||
"C": {
|
||||
"blocks": 70,
|
||||
"blocks_with_txs": 42,
|
||||
"txs_carried": 105,
|
||||
"executed": 105,
|
||||
"skipped": 0
|
||||
}
|
||||
},
|
||||
"generator": {
|
||||
"sent": 240,
|
||||
"included": 240,
|
||||
"pending_at_end": 0,
|
||||
"included_per_s": 1.975,
|
||||
"latency_ms": {
|
||||
"p50": 1545,
|
||||
"p90": 3058,
|
||||
"p99": 5033,
|
||||
"max": 6017,
|
||||
"mean": 1859
|
||||
},
|
||||
"blocks_with_content": 100,
|
||||
"errors": {},
|
||||
"stop_reason": "duration"
|
||||
},
|
||||
"pools": {
|
||||
"samples": [
|
||||
{
|
||||
"t": 33.5,
|
||||
"A": 0,
|
||||
"A_chain": 37,
|
||||
"B": 0,
|
||||
"B_chain": 37,
|
||||
"C": 0,
|
||||
"C_chain": 37
|
||||
},
|
||||
{
|
||||
"t": 38.5,
|
||||
"A": 6,
|
||||
"A_chain": 38,
|
||||
"B": 6,
|
||||
"B_chain": 38,
|
||||
"C": 6,
|
||||
"C_chain": 38
|
||||
},
|
||||
{
|
||||
"t": 43.5,
|
||||
"A": 2,
|
||||
"A_chain": 44,
|
||||
"B": 2,
|
||||
"B_chain": 44,
|
||||
"C": 2,
|
||||
"C_chain": 44
|
||||
},
|
||||
{
|
||||
"t": 48.5,
|
||||
"A": 2,
|
||||
"A_chain": 50,
|
||||
"B": 2,
|
||||
"B_chain": 50,
|
||||
"C": 2,
|
||||
"C_chain": 50
|
||||
},
|
||||
{
|
||||
"t": 53.5,
|
||||
"A": 1,
|
||||
"A_chain": 58,
|
||||
"B": 1,
|
||||
"B_chain": 58,
|
||||
"C": 1,
|
||||
"C_chain": 58
|
||||
},
|
||||
{
|
||||
"t": 58.5,
|
||||
"A": 2,
|
||||
"A_chain": 66,
|
||||
"B": 2,
|
||||
"B_chain": 66,
|
||||
"C": 2,
|
||||
"C_chain": 66
|
||||
},
|
||||
{
|
||||
"t": 63.6,
|
||||
"A": 4,
|
||||
"A_chain": 71,
|
||||
"B": 4,
|
||||
"B_chain": 71,
|
||||
"C": 4,
|
||||
"C_chain": 71
|
||||
},
|
||||
{
|
||||
"t": 68.6,
|
||||
"A": 2,
|
||||
"A_chain": 77,
|
||||
"B": 2,
|
||||
"B_chain": 77,
|
||||
"C": 2,
|
||||
"C_chain": 77
|
||||
},
|
||||
{
|
||||
"t": 73.6,
|
||||
"A": 0,
|
||||
"A_chain": 80,
|
||||
"B": 0,
|
||||
"B_chain": 80,
|
||||
"C": 0,
|
||||
"C_chain": 80
|
||||
},
|
||||
{
|
||||
"t": 78.6,
|
||||
"A": 3,
|
||||
"A_chain": 83,
|
||||
"B": 3,
|
||||
"B_chain": 83,
|
||||
"C": 3,
|
||||
"C_chain": 83
|
||||
},
|
||||
{
|
||||
"t": 83.6,
|
||||
"A": 0,
|
||||
"A_chain": 92,
|
||||
"B": 0,
|
||||
"B_chain": 92,
|
||||
"C": 0,
|
||||
"C_chain": 92
|
||||
},
|
||||
{
|
||||
"t": 88.6,
|
||||
"A": 0,
|
||||
"A_chain": 96,
|
||||
"B": 0,
|
||||
"B_chain": 96,
|
||||
"C": 0,
|
||||
"C_chain": 96
|
||||
},
|
||||
{
|
||||
"t": 93.6,
|
||||
"A": 2,
|
||||
"A_chain": 101,
|
||||
"B": 2,
|
||||
"B_chain": 101,
|
||||
"C": 2,
|
||||
"C_chain": 101
|
||||
},
|
||||
{
|
||||
"t": 98.6,
|
||||
"A": 1,
|
||||
"A_chain": 114,
|
||||
"B": 1,
|
||||
"B_chain": 114,
|
||||
"C": 1,
|
||||
"C_chain": 114
|
||||
},
|
||||
{
|
||||
"t": 103.6,
|
||||
"A": 5,
|
||||
"A_chain": 117,
|
||||
"B": 5,
|
||||
"B_chain": 117,
|
||||
"C": 5,
|
||||
"C_chain": 117
|
||||
},
|
||||
{
|
||||
"t": 108.6,
|
||||
"A": 4,
|
||||
"A_chain": 121,
|
||||
"B": 4,
|
||||
"B_chain": 121,
|
||||
"C": 4,
|
||||
"C_chain": 121
|
||||
},
|
||||
{
|
||||
"t": 113.6,
|
||||
"A": 2,
|
||||
"A_chain": 125,
|
||||
"B": 2,
|
||||
"B_chain": 125,
|
||||
"C": 2,
|
||||
"C_chain": 125
|
||||
},
|
||||
{
|
||||
"t": 118.6,
|
||||
"A": 1,
|
||||
"A_chain": 128,
|
||||
"B": 1,
|
||||
"B_chain": 128,
|
||||
"C": 1,
|
||||
"C_chain": 128
|
||||
},
|
||||
{
|
||||
"t": 123.6,
|
||||
"A": 0,
|
||||
"A_chain": 135,
|
||||
"B": 0,
|
||||
"B_chain": 135,
|
||||
"C": 0,
|
||||
"C_chain": 135
|
||||
},
|
||||
{
|
||||
"t": 128.6,
|
||||
"A": 3,
|
||||
"A_chain": 140,
|
||||
"B": 3,
|
||||
"B_chain": 140,
|
||||
"C": 3,
|
||||
"C_chain": 140
|
||||
},
|
||||
{
|
||||
"t": 133.6,
|
||||
"A": 0,
|
||||
"A_chain": 144,
|
||||
"B": 0,
|
||||
"B_chain": 144,
|
||||
"C": 0,
|
||||
"C_chain": 144
|
||||
},
|
||||
{
|
||||
"t": 138.6,
|
||||
"A": 1,
|
||||
"A_chain": 155,
|
||||
"B": 1,
|
||||
"B_chain": 155,
|
||||
"C": 1,
|
||||
"C_chain": 155
|
||||
},
|
||||
{
|
||||
"t": 143.6,
|
||||
"A": 3,
|
||||
"A_chain": 163,
|
||||
"B": 3,
|
||||
"B_chain": 163,
|
||||
"C": 3,
|
||||
"C_chain": 163
|
||||
},
|
||||
{
|
||||
"t": 148.6,
|
||||
"A": 2,
|
||||
"A_chain": 167,
|
||||
"B": 2,
|
||||
"B_chain": 167,
|
||||
"C": 2,
|
||||
"C_chain": 167
|
||||
},
|
||||
{
|
||||
"t": 153.6,
|
||||
"A": 1,
|
||||
"A_chain": 175,
|
||||
"B": 1,
|
||||
"B_chain": 175,
|
||||
"C": 1,
|
||||
"C_chain": 175
|
||||
},
|
||||
{
|
||||
"t": 158.6,
|
||||
"A": 0,
|
||||
"A_chain": 179,
|
||||
"B": 0,
|
||||
"B_chain": 179,
|
||||
"C": 0,
|
||||
"C_chain": 179
|
||||
},
|
||||
{
|
||||
"t": 163.6,
|
||||
"A": 0,
|
||||
"A_chain": 184,
|
||||
"B": 0,
|
||||
"B_chain": 184,
|
||||
"C": 0,
|
||||
"C_chain": 184
|
||||
}
|
||||
],
|
||||
"max": {
|
||||
"A": 6,
|
||||
"B": 6,
|
||||
"C": 6
|
||||
}
|
||||
},
|
||||
"sinks_agree": true,
|
||||
"wall_s": 168.8
|
||||
}
|
||||
269
docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json
Normal file
269
docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
{
|
||||
"tool": "tools/txgen/run.mjs",
|
||||
"rpc": "http://127.0.0.1:29703",
|
||||
"chain_id": 4463,
|
||||
"started": "2026-10-05T17:49:40.914Z",
|
||||
"ended": "2026-10-05T17:51:44.556Z",
|
||||
"stop_reason": "duration",
|
||||
"params": {
|
||||
"wallets": 16,
|
||||
"rate_per_s": 2,
|
||||
"duration_s": 120,
|
||||
"fund_ign": "2.000000",
|
||||
"cap_ign": "74.000000",
|
||||
"gas": 59650,
|
||||
"stale_s": 60
|
||||
},
|
||||
"fees_last": {
|
||||
"base_gwei": "100.00",
|
||||
"proving_base_gwei": "10000.00",
|
||||
"tip_gwei": "1.00",
|
||||
"node_quote_gwei": "243.86",
|
||||
"fee_cap_gwei": "243.86"
|
||||
},
|
||||
"counts": {
|
||||
"sent": 240,
|
||||
"included": 240,
|
||||
"failed": 0,
|
||||
"dropped": 0,
|
||||
"skipped": 0,
|
||||
"reverted": 0,
|
||||
"nonceRetries": 0,
|
||||
"deferred": 0,
|
||||
"throttled": 0,
|
||||
"fundingTx": 16,
|
||||
"pending_at_end": 0
|
||||
},
|
||||
"throughput": {
|
||||
"included_per_s": 1.975,
|
||||
"send_span_s": 121.5,
|
||||
"sent_per_s_target": 2
|
||||
},
|
||||
"latency_ms": {
|
||||
"p50": 1545,
|
||||
"p90": 3058,
|
||||
"p99": 5033,
|
||||
"max": 6017,
|
||||
"mean": 1859
|
||||
},
|
||||
"blocks": {
|
||||
"with_content": 100,
|
||||
"first": 38,
|
||||
"last": 176,
|
||||
"max_tx_in_one": 10,
|
||||
"per_block": {
|
||||
"38": 8,
|
||||
"39": 1,
|
||||
"40": 2,
|
||||
"42": 3,
|
||||
"44": 5,
|
||||
"45": 3,
|
||||
"46": 1,
|
||||
"48": 1,
|
||||
"50": 2,
|
||||
"51": 1,
|
||||
"52": 3,
|
||||
"54": 2,
|
||||
"57": 3,
|
||||
"58": 2,
|
||||
"59": 1,
|
||||
"61": 2,
|
||||
"62": 2,
|
||||
"63": 1,
|
||||
"65": 1,
|
||||
"66": 3,
|
||||
"68": 4,
|
||||
"70": 1,
|
||||
"71": 5,
|
||||
"72": 1,
|
||||
"73": 2,
|
||||
"74": 2,
|
||||
"75": 2,
|
||||
"77": 2,
|
||||
"79": 10,
|
||||
"80": 1,
|
||||
"81": 4,
|
||||
"82": 2,
|
||||
"83": 3,
|
||||
"84": 2,
|
||||
"85": 3,
|
||||
"87": 1,
|
||||
"88": 2,
|
||||
"89": 1,
|
||||
"91": 1,
|
||||
"92": 4,
|
||||
"93": 1,
|
||||
"94": 3,
|
||||
"95": 2,
|
||||
"96": 2,
|
||||
"97": 1,
|
||||
"98": 5,
|
||||
"101": 2,
|
||||
"103": 1,
|
||||
"104": 1,
|
||||
"105": 2,
|
||||
"106": 2,
|
||||
"109": 1,
|
||||
"110": 1,
|
||||
"112": 1,
|
||||
"114": 3,
|
||||
"116": 3,
|
||||
"117": 8,
|
||||
"119": 2,
|
||||
"120": 1,
|
||||
"121": 6,
|
||||
"122": 1,
|
||||
"123": 3,
|
||||
"124": 2,
|
||||
"125": 6,
|
||||
"126": 1,
|
||||
"127": 4,
|
||||
"128": 3,
|
||||
"129": 1,
|
||||
"130": 4,
|
||||
"132": 1,
|
||||
"133": 1,
|
||||
"134": 1,
|
||||
"135": 2,
|
||||
"138": 4,
|
||||
"139": 1,
|
||||
"140": 4,
|
||||
"141": 6,
|
||||
"142": 3,
|
||||
"146": 1,
|
||||
"147": 1,
|
||||
"148": 3,
|
||||
"151": 2,
|
||||
"152": 1,
|
||||
"153": 1,
|
||||
"155": 4,
|
||||
"157": 1,
|
||||
"158": 1,
|
||||
"160": 1,
|
||||
"162": 1,
|
||||
"163": 5,
|
||||
"164": 1,
|
||||
"165": 4,
|
||||
"166": 1,
|
||||
"167": 5,
|
||||
"168": 1,
|
||||
"169": 1,
|
||||
"172": 3,
|
||||
"174": 1,
|
||||
"175": 3,
|
||||
"176": 2
|
||||
}
|
||||
},
|
||||
"spend_ign": {
|
||||
"funding": "32.000000",
|
||||
"fees_actual": "0.542976",
|
||||
"fees_max_committed": "38.769773",
|
||||
"value_moved_between_wallets": "0.132776",
|
||||
"cap": "74.000000",
|
||||
"cap_hit": false
|
||||
},
|
||||
"wallets": [
|
||||
{
|
||||
"index": 0,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.921973",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.922278",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.923568",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.924152",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.920937",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.922478",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.924611",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.930741",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.923430",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.926285",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.922495",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.918612",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.921628",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.921379",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 14,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.923181",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 15,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.923212",
|
||||
"nonce": 15
|
||||
}
|
||||
],
|
||||
"funder": {
|
||||
"balance_ign": "203.751501"
|
||||
},
|
||||
"lost": [],
|
||||
"pending_at_end": [],
|
||||
"errors": {}
|
||||
}
|
||||
12
docs/benchmarks/finality-v3-2026-10-04/fold-v2.md
Normal file
12
docs/benchmarks/finality-v3-2026-10-04/fold-v2.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
### fold-v2: 480 s, 1 blocks/s in all, 6 voters, one-way delay 300 ms per proxied link, rule v2
|
||||
|
||||
| node | locked indices | with a held certificate in the log | signers in the first-built certificate (count:indices) | signers in the certificate held at the end | all 6 | at least 95% (6 of 6) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| n0 | 12 | 12 | 4:2 5:8 (mean 4.80) | 4:4 5:8 (mean 4.67) | 0 (0%) | 0 (0%) |
|
||||
| n1 | 12 | 12 | 4:6 5:6 (mean 4.50) | 4:6 5:6 (mean 4.50) | 0 (0%) | 0 (0%) |
|
||||
| n2 | 12 | 12 | 4:9 5:3 (mean 4.25) | 4:9 5:3 (mean 4.25) | 0 (0%) | 0 (0%) |
|
||||
|
||||
conflicting certificates 0/0/0; median lock latency over the miners 1008 ms (proposed to locked, polled once a second)
|
||||
|
||||
[PASS] fold-v2
|
||||
12
docs/benchmarks/finality-v3-2026-10-04/fold-v3.md
Normal file
12
docs/benchmarks/finality-v3-2026-10-04/fold-v3.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
### fold-v3: 480 s, 1 blocks/s in all, 6 voters, one-way delay 300 ms per proxied link, rule v3
|
||||
|
||||
| node | locked indices | with a held certificate in the log | signers in the first-built certificate (count:indices) | signers in the certificate held at the end | all 6 | at least 95% (6 of 6) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| n0 | 11 | 11 | 4:2 5:6 (mean 4.75) | 6:11 (mean 6.00) | 11 (100%) | 11 (100%) |
|
||||
| n1 | 11 | 11 | 4:7 5:4 (mean 4.36) | 6:11 (mean 6.00) | 11 (100%) | 11 (100%) |
|
||||
| n2 | 11 | 11 | 4:6 5:5 (mean 4.45) | 6:11 (mean 6.00) | 11 (100%) | 11 (100%) |
|
||||
|
||||
conflicting certificates 0/0/0; median lock latency over the miners 1008 ms (proposed to locked, polled once a second)
|
||||
|
||||
[PASS] fold-v3
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
n0 2026-10-04 19:44:55.628+01:00 [INFO ] Finality: certificate built for checkpoint 5 (bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d) by 4 of 6 voters, weight 86 (active 119.0, total 119), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71
|
||||
n0 2026-10-04 19:44:55.629+01:00 [INFO ] Finality: checkpoint 5 LOCKED: block bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d (blue score 151), signed 86 = 72.3% of active, 72.3% of total, no frozen table (no lock on this chain inside the window)
|
||||
n0 2026-10-04 19:44:56.882+01:00 [INFO ] Finality: certificate for checkpoint 5 folded: 6 of 6 voters, weight 119 of 119 (held 4 voters, weight 86), every voter signed after determination
|
||||
n0 2026-10-04 19:45:25.881+01:00 [INFO ] Finality: certificate built for checkpoint 6 (c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a) by 5 of 6 voters, weight 93 (active 119.0, total 119), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71
|
||||
n0 2026-10-04 19:45:25.882+01:00 [INFO ] Finality: checkpoint 6 LOCKED: block c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a (blue score 180), signed 93 = 78.2% of active, 78.2% of total, 72.3% of the table frozen at lock 5 (86 of 119)
|
||||
n0 2026-10-04 19:45:25.888+01:00 [INFO ] Finality: certificate for checkpoint 6 folded: 6 of 6 voters, weight 119 of 119 (held 5 voters, weight 93), every voter signed after determination
|
||||
n0 2026-10-04 19:49:18.032+01:00 [INFO ] Finality: certificate built for checkpoint 7 (453ca28b04d8356f0181504bfd89d7dce695e797836c4e4aa4029aa07548c0de) by 4 of 6 voters, weight 83 (active 120.0, total 120), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71
|
||||
n0 2026-10-04 19:49:18.032+01:00 [INFO ] Finality: checkpoint 7 LOCKED: block 453ca28b04d8356f0181504bfd89d7dce695e797836c4e4aa4029aa07548c0de (blue score 210), signed 83 = 69.2% of active, 69.2% of total, 70.6% of the table frozen at lock 6 (84 of 119)
|
||||
n0 2026-10-04 19:49:18.645+01:00 [INFO ] Finality: certificate built for checkpoint 11 (a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf) by 4 of 4 voters, weight 114 (active 114.0, total 114), aggregator none (fallback: any node may aggregate)
|
||||
n0 2026-10-04 19:49:18.645+01:00 [INFO ] Finality: checkpoint 11 LOCKED: block a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf (blue score 330), signed 114 = 100.0% of active, 100.0% of total, no frozen table (no lock on this chain inside the window)
|
||||
n0 2026-10-04 19:49:26.493+01:00 [INFO ] Finality: certificate built for checkpoint 12 (7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43) by 4 of 4 voters, weight 119 (active 119.0, total 119), aggregator none (fallback: any node may aggregate)
|
||||
n0 2026-10-04 19:49:26.493+01:00 [INFO ] Finality: checkpoint 12 LOCKED: block 7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43 (blue score 360), signed 119 = 100.0% of active, 100.0% of total, 100.0% of the table frozen at lock 11 (114 of 114)
|
||||
n0 2026-10-04 19:49:51.129+01:00 [INFO ] Finality: certificate built for checkpoint 13 (2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa) by 3 of 4 voters, weight 80 (active 118.0, total 118), aggregator none (fallback: any node may aggregate)
|
||||
n0 2026-10-04 19:49:51.129+01:00 [INFO ] Finality: checkpoint 13 LOCKED: block 2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa (blue score 392), signed 80 = 67.8% of active, 67.8% of total, 67.2% of the table frozen at lock 12 (80 of 119)
|
||||
n0 2026-10-04 19:49:51.132+01:00 [INFO ] Finality: certificate at index 13 replaced by a heavier one: 4 of 4 voters, weight 118 (held 3 voters, weight 80)
|
||||
n0 2026-10-04 19:50:17.379+01:00 [INFO ] Finality: certificate built for checkpoint 14 (8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40) by 4 of 5 voters, weight 81 (active 115.0, total 115), aggregator e3b7bd178e7744facca424e712d8d08148e957ce3a1ae3e5b7930e16fe6d9ca3
|
||||
n0 2026-10-04 19:50:17.379+01:00 [INFO ] Finality: checkpoint 14 LOCKED: block 8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40 (blue score 420), signed 81 = 70.4% of active, 70.4% of total, 67.8% of the table frozen at lock 13 (80 of 118)
|
||||
n0 2026-10-04 19:50:17.381+01:00 [INFO ] Finality: certificate for checkpoint 14 folded: 5 of 5 voters, weight 115 of 115 (held 4 voters, weight 81), every voter signed after determination
|
||||
n0 2026-10-04 19:50:46.427+01:00 [INFO ] Finality: certificate built for checkpoint 15 (062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d) by 4 of 6 voters, weight 83 (active 119.0, total 119), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71
|
||||
n0 2026-10-04 19:50:46.427+01:00 [INFO ] Finality: checkpoint 15 LOCKED: block 062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d (blue score 450), signed 83 = 69.7% of active, 69.7% of total, 71.3% of the table frozen at lock 14 (82 of 115)
|
||||
n0 2026-10-04 19:50:46.631+01:00 [INFO ] Finality: certificate at index 15 replaced by a heavier one: 4 of 6 voters, weight 97 (held 4 voters, weight 83)
|
||||
n0 2026-10-04 19:50:47.214+01:00 [INFO ] Finality: certificate for checkpoint 15 folded: 6 of 6 voters, weight 119 of 119 (held 4 voters, weight 97), every voter signed after determination
|
||||
n0 2026-10-04 19:51:13.634+01:00 [INFO ] Finality: certificate built for checkpoint 16 (1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837) by 5 of 6 voters, weight 100 (active 120.0, total 120), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71
|
||||
n0 2026-10-04 19:51:13.634+01:00 [INFO ] Finality: checkpoint 16 LOCKED: block 1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837 (blue score 480), signed 100 = 83.3% of active, 83.3% of total, 84.0% of the table frozen at lock 15 (100 of 119)
|
||||
n0 2026-10-04 19:51:13.879+01:00 [INFO ] Finality: certificate for checkpoint 16 folded: 6 of 6 voters, weight 120 of 120 (held 5 voters, weight 100), every voter signed after determination
|
||||
n0 2026-10-04 19:51:36.631+01:00 [INFO ] Finality: certificate built for checkpoint 17 (eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131) by 5 of 6 voters, weight 102 (active 120.0, total 120), aggregator cd22966d67b5602725d680b91bc046dfef7a490e84e341e0a6fa2e64afde6b71
|
||||
n0 2026-10-04 19:51:36.631+01:00 [INFO ] Finality: checkpoint 17 LOCKED: block eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131 (blue score 511), signed 102 = 85.0% of active, 85.0% of total, 83.3% of the table frozen at lock 16 (100 of 120)
|
||||
n0 2026-10-04 19:51:36.633+01:00 [INFO ] Finality: certificate for checkpoint 17 folded: 6 of 6 voters, weight 120 of 120 (held 5 voters, weight 102), every voter signed after determination
|
||||
n1 2026-10-04 19:44:55.201+01:00 [INFO ] Finality: certificate built for checkpoint 5 (bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d) by 4 of 6 voters, weight 86 (active 119.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:44:55.202+01:00 [INFO ] Finality: checkpoint 5 LOCKED: block bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d (blue score 151), signed 86 = 72.3% of active, 72.3% of total, no frozen table (no lock on this chain inside the window)
|
||||
n1 2026-10-04 19:44:56.519+01:00 [INFO ] Finality: certificate for checkpoint 5 folded: 6 of 6 voters, weight 119 of 119 (held 4 voters, weight 86), every voter signed after determination
|
||||
n1 2026-10-04 19:45:25.351+01:00 [INFO ] Finality: certificate built for checkpoint 6 (c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a) by 5 of 6 voters, weight 93 (active 119.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:45:25.351+01:00 [INFO ] Finality: checkpoint 6 LOCKED: block c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a (blue score 180), signed 93 = 78.2% of active, 78.2% of total, 72.3% of the table frozen at lock 5 (86 of 119)
|
||||
n1 2026-10-04 19:45:25.403+01:00 [INFO ] Finality: certificate for checkpoint 6 folded: 6 of 6 voters, weight 119 of 119 (held 5 voters, weight 93), every voter signed after determination
|
||||
n1 2026-10-04 19:46:26.015+01:00 [INFO ] Finality: certificate built for checkpoint 8 (889befb24af80cf7606e3f25f9e5df06cc63b58275ad84bb71a35b704314d32f) by 4 of 6 voters, weight 80 (active 80.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:46:26.015+01:00 [INFO ] Finality: checkpoint 8 LOCKED: block 889befb24af80cf7606e3f25f9e5df06cc63b58275ad84bb71a35b704314d32f (blue score 242), signed 80 = 100.0% of active, 67.2% of total, 70.6% of the table frozen at lock 6 (84 of 119)
|
||||
n1 2026-10-04 19:47:02.150+01:00 [INFO ] Finality: certificate built for checkpoint 9 (aec7d980b86753906ab01684512e0f1d8e25d1bad522c6b604662f713a0e4a3e) by 4 of 6 voters, weight 90 (active 90.0, total 120), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:47:02.150+01:00 [INFO ] Finality: checkpoint 9 LOCKED: block aec7d980b86753906ab01684512e0f1d8e25d1bad522c6b604662f713a0e4a3e (blue score 270), signed 90 = 100.0% of active, 75.0% of total, 67.2% of the table frozen at lock 8 (80 of 119)
|
||||
n1 2026-10-04 19:47:58.272+01:00 [INFO ] Finality: certificate built for checkpoint 10 (af9403b4de209788c56425dad5b2eac727171edf610ca8aef02801f879b7b746) by 4 of 6 voters, weight 102 (active 102.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:47:58.272+01:00 [INFO ] Finality: checkpoint 10 LOCKED: block af9403b4de209788c56425dad5b2eac727171edf610ca8aef02801f879b7b746 (blue score 300), signed 102 = 100.0% of active, 85.7% of total, 75.0% of the table frozen at lock 9 (90 of 120)
|
||||
n1 2026-10-04 19:48:42.578+01:00 [INFO ] Finality: certificate built for checkpoint 11 (a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf) by 3 of 4 voters, weight 87 (active 114.0, total 114), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:48:42.578+01:00 [INFO ] Finality: checkpoint 11 LOCKED: block a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf (blue score 330), signed 87 = 76.3% of active, 76.3% of total, 67.2% of the table frozen at lock 10 (80 of 119)
|
||||
n1 2026-10-04 19:48:42.875+01:00 [INFO ] Finality: certificate for checkpoint 11 folded: 4 of 4 voters, weight 114 of 114 (held 3 voters, weight 87), every voter signed after determination
|
||||
n1 2026-10-04 19:49:18.449+01:00 [INFO ] Finality: checkpoint 7 LOCKED: block 453ca28b04d8356f0181504bfd89d7dce695e797836c4e4aa4029aa07548c0de (blue score 210), signed 0 = 0.0% of active, 0.0% of total, 0.0% of the table frozen at lock 6 (0 of 119)
|
||||
n1 2026-10-04 19:49:24.764+01:00 [INFO ] Finality: certificate built for checkpoint 12 (7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43) by 3 of 4 voters, weight 80 (active 119.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:49:24.764+01:00 [INFO ] Finality: checkpoint 12 LOCKED: block 7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43 (blue score 360), signed 80 = 67.2% of active, 67.2% of total, 74.6% of the table frozen at lock 11 (85 of 114)
|
||||
n1 2026-10-04 19:49:24.767+01:00 [INFO ] Finality: certificate for checkpoint 12 folded: 4 of 4 voters, weight 119 of 119 (held 3 voters, weight 80), every voter signed after determination
|
||||
n1 2026-10-04 19:49:50.765+01:00 [INFO ] Finality: certificate built for checkpoint 13 (2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa) by 3 of 4 voters, weight 80 (active 118.0, total 118), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:49:50.765+01:00 [INFO ] Finality: checkpoint 13 LOCKED: block 2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa (blue score 392), signed 80 = 67.8% of active, 67.8% of total, 67.2% of the table frozen at lock 12 (80 of 119)
|
||||
n1 2026-10-04 19:49:50.768+01:00 [INFO ] Finality: certificate at index 13 replaced by a heavier one: 4 of 4 voters, weight 118 (held 3 voters, weight 80)
|
||||
n1 2026-10-04 19:50:17.016+01:00 [INFO ] Finality: certificate built for checkpoint 14 (8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40) by 4 of 5 voters, weight 81 (active 115.0, total 115), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:50:17.016+01:00 [INFO ] Finality: checkpoint 14 LOCKED: block 8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40 (blue score 420), signed 81 = 70.4% of active, 70.4% of total, 67.8% of the table frozen at lock 13 (80 of 118)
|
||||
n1 2026-10-04 19:50:17.018+01:00 [INFO ] Finality: certificate for checkpoint 14 folded: 5 of 5 voters, weight 115 of 115 (held 4 voters, weight 81), every voter signed after determination
|
||||
n1 2026-10-04 19:50:46.117+01:00 [INFO ] Finality: certificate built for checkpoint 15 (062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d) by 4 of 6 voters, weight 97 (active 119.0, total 119), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:50:46.117+01:00 [INFO ] Finality: checkpoint 15 LOCKED: block 062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d (blue score 450), signed 97 = 81.5% of active, 81.5% of total, 93.9% of the table frozen at lock 14 (108 of 115)
|
||||
n1 2026-10-04 19:50:47.546+01:00 [INFO ] Finality: certificate for checkpoint 15 folded: 5 of 6 voters, weight 105 of 119 (held 4 voters, weight 97), 3 DAA s after determination
|
||||
n1 2026-10-04 19:50:47.704+01:00 [INFO ] Finality: certificate at index 15 replaced by a heavier one: 6 of 6 voters, weight 119 (held 5 voters, weight 105)
|
||||
n1 2026-10-04 19:51:13.531+01:00 [INFO ] Finality: certificate built for checkpoint 16 (1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837) by 4 of 6 voters, weight 85 (active 120.0, total 120), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:51:13.531+01:00 [INFO ] Finality: checkpoint 16 LOCKED: block 1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837 (blue score 480), signed 85 = 70.8% of active, 70.8% of total, 81.5% of the table frozen at lock 15 (97 of 119)
|
||||
n1 2026-10-04 19:51:13.951+01:00 [INFO ] Finality: certificate for checkpoint 16 folded: 6 of 6 voters, weight 120 of 120 (held 4 voters, weight 85), every voter signed after determination
|
||||
n1 2026-10-04 19:51:36.268+01:00 [INFO ] Finality: certificate built for checkpoint 17 (eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131) by 5 of 6 voters, weight 102 (active 120.0, total 120), aggregator dfdcbf58e8dcc22bc6a9a237a4154e922c0c2775fa2603f7884c0bbc7066b0ae
|
||||
n1 2026-10-04 19:51:36.268+01:00 [INFO ] Finality: checkpoint 17 LOCKED: block eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131 (blue score 511), signed 102 = 85.0% of active, 85.0% of total, 83.3% of the table frozen at lock 16 (100 of 120)
|
||||
n1 2026-10-04 19:51:36.272+01:00 [INFO ] Finality: certificate at index 17 replaced by a heavier one: 5 of 6 voters, weight 103 (held 5 voters, weight 102)
|
||||
n1 2026-10-04 19:51:36.517+01:00 [INFO ] Finality: certificate at index 17 replaced by a heavier one: 6 of 6 voters, weight 120 (held 5 voters, weight 103)
|
||||
n2 2026-10-04 19:44:55.630+01:00 [INFO ] Finality: checkpoint 5 LOCKED: block bfd1156e0e31f301b1e886a205bf32dae24bb5944b9ddb426b168ba8b1e1831d (blue score 151), signed 86 = 72.3% of active, 72.3% of total, no frozen table (no lock on this chain inside the window)
|
||||
n2 2026-10-04 19:44:56.200+01:00 [INFO ] Finality: certificate for checkpoint 5 folded: 6 of 6 voters, weight 119 of 119 (held 4 voters, weight 86), every voter signed after determination
|
||||
n2 2026-10-04 19:45:25.882+01:00 [INFO ] Finality: certificate built for checkpoint 6 (c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a) by 5 of 6 voters, weight 93 (active 119.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:45:25.882+01:00 [INFO ] Finality: checkpoint 6 LOCKED: block c8ca2583f164564d19173d98a30b1b12b4a8ff02b10a85e22da7eac35c57221a (blue score 180), signed 93 = 78.2% of active, 78.2% of total, 72.3% of the table frozen at lock 5 (86 of 119)
|
||||
n2 2026-10-04 19:45:25.889+01:00 [INFO ] Finality: certificate for checkpoint 6 folded: 6 of 6 voters, weight 119 of 119 (held 5 voters, weight 93), every voter signed after determination
|
||||
n2 2026-10-04 19:46:26.130+01:00 [INFO ] Finality: certificate built for checkpoint 8 (889befb24af80cf7606e3f25f9e5df06cc63b58275ad84bb71a35b704314d32f) by 4 of 6 voters, weight 80 (active 80.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:46:26.130+01:00 [INFO ] Finality: checkpoint 8 LOCKED: block 889befb24af80cf7606e3f25f9e5df06cc63b58275ad84bb71a35b704314d32f (blue score 242), signed 80 = 100.0% of active, 67.2% of total, 70.6% of the table frozen at lock 6 (84 of 119)
|
||||
n2 2026-10-04 19:47:02.639+01:00 [INFO ] Finality: certificate built for checkpoint 9 (aec7d980b86753906ab01684512e0f1d8e25d1bad522c6b604662f713a0e4a3e) by 4 of 6 voters, weight 90 (active 90.0, total 120), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:47:02.639+01:00 [INFO ] Finality: checkpoint 9 LOCKED: block aec7d980b86753906ab01684512e0f1d8e25d1bad522c6b604662f713a0e4a3e (blue score 270), signed 90 = 100.0% of active, 75.0% of total, 67.2% of the table frozen at lock 8 (80 of 119)
|
||||
n2 2026-10-04 19:47:57.913+01:00 [INFO ] Finality: certificate built for checkpoint 10 (af9403b4de209788c56425dad5b2eac727171edf610ca8aef02801f879b7b746) by 4 of 6 voters, weight 102 (active 102.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:47:57.913+01:00 [INFO ] Finality: checkpoint 10 LOCKED: block af9403b4de209788c56425dad5b2eac727171edf610ca8aef02801f879b7b746 (blue score 300), signed 102 = 100.0% of active, 85.7% of total, 75.0% of the table frozen at lock 9 (90 of 120)
|
||||
n2 2026-10-04 19:48:43.133+01:00 [INFO ] Finality: certificate built for checkpoint 11 (a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf) by 3 of 4 voters, weight 87 (active 114.0, total 114), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:48:43.133+01:00 [INFO ] Finality: checkpoint 11 LOCKED: block a709b3df2649577e86bf729f4f3dccdd82bf1fb53bef9dc09c44d0da3393aedf (blue score 330), signed 87 = 76.3% of active, 76.3% of total, 67.2% of the table frozen at lock 10 (80 of 119)
|
||||
n2 2026-10-04 19:48:43.385+01:00 [INFO ] Finality: certificate at index 11 replaced by a heavier one: 4 of 4 voters, weight 114 (held 3 voters, weight 87)
|
||||
n2 2026-10-04 19:49:18.879+01:00 [INFO ] Finality: checkpoint 7 LOCKED: block 453ca28b04d8356f0181504bfd89d7dce695e797836c4e4aa4029aa07548c0de (blue score 210), signed 0 = 0.0% of active, 0.0% of total, 0.0% of the table frozen at lock 6 (0 of 119)
|
||||
n2 2026-10-04 19:49:24.294+01:00 [INFO ] Finality: certificate built for checkpoint 12 (7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43) by 3 of 4 voters, weight 80 (active 119.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:49:24.294+01:00 [INFO ] Finality: checkpoint 12 LOCKED: block 7ed41b712afc0e7ae4a013fe2517b68d88e541d06f842b0486017708a7504a43 (blue score 360), signed 80 = 67.2% of active, 67.2% of total, 74.6% of the table frozen at lock 11 (85 of 114)
|
||||
n2 2026-10-04 19:49:24.299+01:00 [INFO ] Finality: certificate for checkpoint 12 folded: 4 of 4 voters, weight 119 of 119 (held 3 voters, weight 80), every voter signed after determination
|
||||
n2 2026-10-04 19:49:50.405+01:00 [INFO ] Finality: certificate built for checkpoint 13 (2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa) by 3 of 4 voters, weight 80 (active 118.0, total 118), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:49:50.405+01:00 [INFO ] Finality: checkpoint 13 LOCKED: block 2fa2dbe7a3e4a553832f16b8dca652e7c1bb654a83c7e6bdf4218260627ffffa (blue score 392), signed 80 = 67.8% of active, 67.8% of total, 67.2% of the table frozen at lock 12 (80 of 119)
|
||||
n2 2026-10-04 19:49:50.410+01:00 [INFO ] Finality: certificate for checkpoint 13 folded: 4 of 4 voters, weight 118 of 118 (held 3 voters, weight 80), every voter signed after determination
|
||||
n2 2026-10-04 19:50:16.518+01:00 [INFO ] Finality: certificate built for checkpoint 14 (8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40) by 4 of 5 voters, weight 81 (active 115.0, total 115), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:50:16.518+01:00 [INFO ] Finality: checkpoint 14 LOCKED: block 8a54de8afefa6c10df10f367541db7a0c7f89174ea17cf9e2e73a997c4bdef40 (blue score 420), signed 81 = 70.4% of active, 70.4% of total, 67.8% of the table frozen at lock 13 (80 of 118)
|
||||
n2 2026-10-04 19:50:16.521+01:00 [INFO ] Finality: certificate for checkpoint 14 folded: 5 of 5 voters, weight 115 of 115 (held 4 voters, weight 81), every voter signed after determination
|
||||
n2 2026-10-04 19:50:46.630+01:00 [INFO ] Finality: certificate built for checkpoint 15 (062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d) by 4 of 6 voters, weight 97 (active 119.0, total 119), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:50:46.630+01:00 [INFO ] Finality: checkpoint 15 LOCKED: block 062b79ae46af0711f581dc6c7134e2708de6ecd88532268e8e9c4866589edf4d (blue score 450), signed 97 = 81.5% of active, 81.5% of total, 93.9% of the table frozen at lock 14 (108 of 115)
|
||||
n2 2026-10-04 19:50:47.880+01:00 [INFO ] Finality: certificate at index 15 replaced by a heavier one: 5 of 6 voters, weight 105 (held 4 voters, weight 97)
|
||||
n2 2026-10-04 19:50:48.133+01:00 [INFO ] Finality: certificate at index 15 replaced by a heavier one: 6 of 6 voters, weight 119 (held 5 voters, weight 105)
|
||||
n2 2026-10-04 19:51:13.879+01:00 [INFO ] Finality: certificate built for checkpoint 16 (1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837) by 4 of 6 voters, weight 85 (active 120.0, total 120), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:51:13.879+01:00 [INFO ] Finality: checkpoint 16 LOCKED: block 1df211a5e2a2b1851292d1d63f81bedc77dee504c788db0fab4699cc441e8837 (blue score 480), signed 85 = 70.8% of active, 70.8% of total, 81.5% of the table frozen at lock 15 (97 of 119)
|
||||
n2 2026-10-04 19:51:14.380+01:00 [INFO ] Finality: certificate for checkpoint 16 folded: 6 of 6 voters, weight 120 of 120 (held 4 voters, weight 85), every voter signed after determination
|
||||
n2 2026-10-04 19:51:35.879+01:00 [INFO ] Finality: certificate built for checkpoint 17 (eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131) by 5 of 6 voters, weight 103 (active 120.0, total 120), aggregator e2230237a914db9a8f44978fb0b3b95ea362e4283ea3cf713e43476bfa845e1d
|
||||
n2 2026-10-04 19:51:35.879+01:00 [INFO ] Finality: checkpoint 17 LOCKED: block eff288a0d19b9f0c19d42e56b00f254bb550408e3083a0b7334c99f9caeae131 (blue score 511), signed 103 = 85.8% of active, 85.8% of total, 83.3% of the table frozen at lock 16 (100 of 120)
|
||||
n2 2026-10-04 19:51:36.130+01:00 [INFO ] Finality: certificate for checkpoint 17 folded: 6 of 6 voters, weight 120 of 120 (held 5 voters, weight 103), every voter signed after determination
|
||||
31
docs/benchmarks/finality-v3-2026-10-04/split-v3.md
Normal file
31
docs/benchmarks/finality-v3-2026-10-04/split-v3.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
|
||||
### split50-v3: warm 230 s, split 150 s, heal window 200 s, 1 blocks/s in all, delay 300 ms, rule v3; old bound W / (3 R) = 80 s, frozen table expires 240 s after the last lock (W = 120 DAA, R = 0.5 blocks/s per side of a 3/3 split)
|
||||
|
||||
| measure | n0 (side A) | n1 (side B) | n2 (side B) |
|
||||
|---|---|---|---|
|
||||
| max locked index at the cut | 6 | 6 | 6 |
|
||||
| new locks during the split (index above 6) | 0 | 0 | 0 |
|
||||
| first new lock, s after the cut | none | none | none |
|
||||
| max locked index at the end of the heal window | 13 | 13 | 13 |
|
||||
| locking resumed after the heal | true | true | true |
|
||||
| conflicting certificates logged | 0 | 0 | 0 |
|
||||
| checkpoints held back by the frozen table (debug lines) | 0 | 0 | 0 |
|
||||
|
||||
n0 reconnected 72 s after the gate reopened; locked indices disagreeing across the three nodes at the end: 0; weights at the cut: window daa 180, voters 6
|
||||
|
||||
### split70-v3: warm 230 s, split 150 s, heal window 200 s, 1 blocks/s in all, delay 300 ms, rule v3; old bound W / (3 R) = 80 s, frozen table expires 240 s after the last lock (W = 120 DAA, R = 0.5 blocks/s per side of a 3/3 split)
|
||||
|
||||
| measure | n0 (side A) | n1 (side B) | n2 (side B) |
|
||||
|---|---|---|---|
|
||||
| max locked index at the cut | 6 | 6 | 6 |
|
||||
| new locks during the split (index above 6) | 0 | 4 | 4 |
|
||||
| first new lock, s after the cut | none | 30 | 30 |
|
||||
| max locked index at the end of the heal window | 17 | 17 | 17 |
|
||||
| locking resumed after the heal | true | true | true |
|
||||
| conflicting certificates logged | 0 | 0 | 0 |
|
||||
| checkpoints held back by the frozen table (debug lines) | 0 | 0 | 0 |
|
||||
|
||||
n0 reconnected 42 s after the gate reopened; locked indices disagreeing across the three nodes at the end: 0; weights at the cut: window daa 179, voters 6
|
||||
|
||||
[PASS] split50-v3
|
||||
[PASS] split70-v3
|
||||
16
docs/benchmarks/finality-v3-2026-10-04/split50-v2.md
Normal file
16
docs/benchmarks/finality-v3-2026-10-04/split50-v2.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
### split50-v2: warm 230 s, split 150 s, heal window 200 s, 1 blocks/s in all, delay 300 ms, rule v2; old bound W / (3 R) = 80 s, frozen table expires 240 s after the last lock (W = 120 DAA, R = 0.5 blocks/s per side of a 3/3 split)
|
||||
|
||||
| measure | n0 (side A) | n1 (side B) | n2 (side B) |
|
||||
|---|---|---|---|
|
||||
| max locked index at the cut | 7 | 7 | 7 |
|
||||
| new locks during the split (index above 7) | 0 | 3 | 3 |
|
||||
| first new lock, s after the cut | none | 126 | 126 |
|
||||
| max locked index at the end of the heal window | 13 | 13 | 13 |
|
||||
| locking resumed after the heal | true | true | true |
|
||||
| conflicting certificates logged | 4 | 3 | 0 |
|
||||
| checkpoints held back by the frozen table (debug lines) | 0 | 0 | 0 |
|
||||
|
||||
n0 reconnected 72 s after the gate reopened; locked indices disagreeing across the three nodes at the end: 4; weights at the cut: window daa 209, voters 6
|
||||
|
||||
[PASS] split50-v2
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
{
|
||||
"rows": [
|
||||
{
|
||||
"scenario": "6 resource exhaustion (50x template, submit and mempool floods from one peer)",
|
||||
"criterion": "honest template p95 < 200 ms and both nodes under baseline RSS + 512 MB, alive, one sink",
|
||||
"result": "honest template p95 worst 101.7 ms across loads (baseline 89.5 ms); template 500ps 500/s, submit 50ps 50/s, mempool 500ps 500/s; RSS growth per load template +6MB (0/0 cache builds), submit +9MB (0/0 cache builds), mempool +1MB (0/0 cache builds), cumulative +16MB over baseline 304/304; alive true; same sink true",
|
||||
"pass": true
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"baseline_template_ms": {
|
||||
"a": {
|
||||
"n": 75,
|
||||
"p50": 6.5,
|
||||
"p95": 89.6,
|
||||
"p99": 96.4,
|
||||
"max": 96.4,
|
||||
"mean": 26.8
|
||||
},
|
||||
"b": {
|
||||
"n": 76,
|
||||
"p50": 10.1,
|
||||
"p95": 89.5,
|
||||
"p99": 107.5,
|
||||
"max": 107.5,
|
||||
"mean": 25.2
|
||||
}
|
||||
},
|
||||
"rss_baseline": {
|
||||
"a": 304,
|
||||
"b": 304
|
||||
},
|
||||
"loads": {
|
||||
"template_flood_500ps": {
|
||||
"requests_sent": 14993,
|
||||
"accepted": 14993,
|
||||
"rejected_or_error": 0,
|
||||
"rate_per_s": 500,
|
||||
"request_latency_ms": {
|
||||
"n": 14993,
|
||||
"p50": 23.9,
|
||||
"p95": 168.2,
|
||||
"p99": 670.8,
|
||||
"max": 807.1,
|
||||
"mean": 54.4
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 170,
|
||||
"p50": 3.6,
|
||||
"p95": 101.7,
|
||||
"p99": 698.7,
|
||||
"max": 700.7,
|
||||
"mean": 44.8
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 167,
|
||||
"p50": 4.3,
|
||||
"p95": 93.7,
|
||||
"p99": 698.4,
|
||||
"max": 701,
|
||||
"mean": 43.5
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.673,
|
||||
"a": 305,
|
||||
"b": 304
|
||||
},
|
||||
{
|
||||
"t_s": 4.69,
|
||||
"a": 306,
|
||||
"b": 305
|
||||
},
|
||||
{
|
||||
"t_s": 6.706,
|
||||
"a": 306,
|
||||
"b": 305
|
||||
},
|
||||
{
|
||||
"t_s": 8.707,
|
||||
"a": 306,
|
||||
"b": 305
|
||||
},
|
||||
{
|
||||
"t_s": 10.71,
|
||||
"a": 306,
|
||||
"b": 306
|
||||
},
|
||||
{
|
||||
"t_s": 12.723,
|
||||
"a": 307,
|
||||
"b": 306
|
||||
},
|
||||
{
|
||||
"t_s": 14.727,
|
||||
"a": 307,
|
||||
"b": 306
|
||||
},
|
||||
{
|
||||
"t_s": 16.728,
|
||||
"a": 308,
|
||||
"b": 306
|
||||
},
|
||||
{
|
||||
"t_s": 18.748,
|
||||
"a": 308,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 20.749,
|
||||
"a": 308,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 22.752,
|
||||
"a": 308,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 24.753,
|
||||
"a": 308,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 26.754,
|
||||
"a": 309,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 28.754,
|
||||
"a": 310,
|
||||
"b": 308
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 304,
|
||||
"b": 304
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 310,
|
||||
"b": 308
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 6,
|
||||
"b": 4
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0
|
||||
}
|
||||
},
|
||||
"submit_flood_50ps": {
|
||||
"requests_sent": 1499,
|
||||
"accepted": 1499,
|
||||
"rejected_or_error": 0,
|
||||
"rate_per_s": 50,
|
||||
"request_latency_ms": {
|
||||
"n": 1499,
|
||||
"p50": 387.7,
|
||||
"p95": 1435.5,
|
||||
"p99": 1887.7,
|
||||
"max": 2166.4,
|
||||
"mean": 481.3
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 175,
|
||||
"p50": 5,
|
||||
"p95": 66.1,
|
||||
"p99": 91.4,
|
||||
"max": 94.1,
|
||||
"mean": 19.9
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 174,
|
||||
"p50": 6.2,
|
||||
"p95": 67.4,
|
||||
"p99": 90.2,
|
||||
"max": 91.5,
|
||||
"mean": 21.3
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.49,
|
||||
"a": 312,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 4.49,
|
||||
"a": 312,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 6.491,
|
||||
"a": 314,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 8.493,
|
||||
"a": 315,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 10.497,
|
||||
"a": 315,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 12.505,
|
||||
"a": 316,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 14.513,
|
||||
"a": 316,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 16.524,
|
||||
"a": 317,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 18.525,
|
||||
"a": 318,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 20.525,
|
||||
"a": 318,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 22.525,
|
||||
"a": 318,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 24.544,
|
||||
"a": 318,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 26.544,
|
||||
"a": 318,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 28.544,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 30.545,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 310,
|
||||
"b": 308
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 9,
|
||||
"b": 2
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0
|
||||
}
|
||||
},
|
||||
"mempool_flood_500ps": {
|
||||
"requests_sent": 14999,
|
||||
"accepted": 0,
|
||||
"rejected_or_error": 14999,
|
||||
"rate_per_s": 500,
|
||||
"request_latency_ms": {
|
||||
"n": 14999,
|
||||
"p50": 6.9,
|
||||
"p95": 30.6,
|
||||
"p99": 101.6,
|
||||
"max": 823,
|
||||
"mean": 13.2
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 171,
|
||||
"p50": 6.2,
|
||||
"p95": 84.4,
|
||||
"p99": 557,
|
||||
"max": 665.4,
|
||||
"mean": 32
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 171,
|
||||
"p50": 7.4,
|
||||
"p95": 74.2,
|
||||
"p99": 103,
|
||||
"max": 488.7,
|
||||
"mean": 25.2
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.649,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 4.65,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 6.65,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 8.649,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 10.65,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 12.662,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 14.662,
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 16.662,
|
||||
"a": 320,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 18.663,
|
||||
"a": 320,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 20.736,
|
||||
"a": 320,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 22.748,
|
||||
"a": 320,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 24.749,
|
||||
"a": 320,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 26.75,
|
||||
"a": 320,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 28.749,
|
||||
"a": 320,
|
||||
"b": 310
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 319,
|
||||
"b": 310
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 320,
|
||||
"b": 310
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 1,
|
||||
"b": 0
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"recovery_template_ms": {
|
||||
"n": 210,
|
||||
"p50": 6.2,
|
||||
"p95": 82.5,
|
||||
"p99": 488.6,
|
||||
"max": 665.4,
|
||||
"mean": 29.9
|
||||
},
|
||||
"final": {
|
||||
"blocks_a": 121,
|
||||
"blocks_b": 121,
|
||||
"same_sink": true
|
||||
},
|
||||
"alive": true,
|
||||
"mem_bound_mb": 512
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
{
|
||||
"rows": [
|
||||
{
|
||||
"scenario": "7 fast-miner flood, controller trajectory (sim)",
|
||||
"criterion": "trajectory recorded",
|
||||
"result": "skipped (--live-only)",
|
||||
"pass": null
|
||||
},
|
||||
{
|
||||
"scenario": "7 fast-miner flood, live (50 blocks/s from one peer)",
|
||||
"criterion": "node stays responsive: honest template p95 < 200 ms, both nodes alive, same sink",
|
||||
"result": "flood accepted 203 blocks in 60 s (3.4/s); honest template p50/p95/max 12.4/75.4/895.1 ms under flood (baseline 7/69.4/107.3); rss a 300->315 MB, b 302->315 MB (cache builds 0/0); alive true; same sink true",
|
||||
"pass": true
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"live": {
|
||||
"baseline_template_ms": {
|
||||
"n": 79,
|
||||
"p50": 7,
|
||||
"p95": 69.4,
|
||||
"p99": 107.3,
|
||||
"max": 107.3,
|
||||
"mean": 24.1
|
||||
},
|
||||
"under_flood_template_ms": {
|
||||
"n": 465,
|
||||
"p50": 12.4,
|
||||
"p95": 75.4,
|
||||
"p99": 123.8,
|
||||
"max": 895.1,
|
||||
"mean": 27
|
||||
},
|
||||
"after_flood_template_ms": {
|
||||
"n": 39,
|
||||
"p50": 1.7,
|
||||
"p95": 88.3,
|
||||
"p99": 100.2,
|
||||
"max": 100.2,
|
||||
"mean": 23.1
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"t_s": 10,
|
||||
"blocks_a": 110,
|
||||
"blocks_b": 110,
|
||||
"difficulty_a": 616132003.4293169,
|
||||
"sink_same": true,
|
||||
"rss_a": 312,
|
||||
"rss_b": 311,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 100,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 10
|
||||
},
|
||||
{
|
||||
"t_s": 20,
|
||||
"blocks_a": 151,
|
||||
"blocks_b": 150,
|
||||
"difficulty_a": 1634372941.381798,
|
||||
"sink_same": false,
|
||||
"rss_a": 313,
|
||||
"rss_b": 313,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 139,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 12
|
||||
},
|
||||
{
|
||||
"t_s": 30,
|
||||
"blocks_a": 176,
|
||||
"blocks_b": 176,
|
||||
"difficulty_a": 3422770765.9742208,
|
||||
"sink_same": true,
|
||||
"rss_a": 314,
|
||||
"rss_b": 313,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 164,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 12
|
||||
},
|
||||
{
|
||||
"t_s": 40,
|
||||
"blocks_a": 197,
|
||||
"blocks_b": 196,
|
||||
"difficulty_a": 5026862033.766903,
|
||||
"sink_same": false,
|
||||
"rss_a": 314,
|
||||
"rss_b": 314,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 184,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 13
|
||||
},
|
||||
{
|
||||
"t_s": 50,
|
||||
"blocks_a": 207,
|
||||
"blocks_b": 207,
|
||||
"difficulty_a": 6515322825.578815,
|
||||
"sink_same": true,
|
||||
"rss_a": 315,
|
||||
"rss_b": 314,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 194,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 13
|
||||
},
|
||||
{
|
||||
"t_s": 60,
|
||||
"blocks_a": 216,
|
||||
"blocks_b": 216,
|
||||
"difficulty_a": 6949902898.525323,
|
||||
"sink_same": true,
|
||||
"rss_a": 315,
|
||||
"rss_b": 315,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 203,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 13
|
||||
}
|
||||
],
|
||||
"rss_before": {
|
||||
"a": 300,
|
||||
"b": 302
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 315,
|
||||
"b": 315
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0,
|
||||
"before_flood": {
|
||||
"a": 1,
|
||||
"b": 1
|
||||
}
|
||||
},
|
||||
"flood": {
|
||||
"accepted": 203,
|
||||
"rejected": 0,
|
||||
"errors": 0
|
||||
},
|
||||
"honest": {
|
||||
"accepted": 13,
|
||||
"rejected": 0
|
||||
},
|
||||
"final": {
|
||||
"blocks_a": 216,
|
||||
"blocks_b": 216,
|
||||
"same_sink": true,
|
||||
"difficulty_a": 6949902898.525323,
|
||||
"ratio": null
|
||||
},
|
||||
"alive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
{
|
||||
"rows": [
|
||||
{
|
||||
"scenario": "6 resource exhaustion (50x template, submit and mempool floods from one peer)",
|
||||
"criterion": "honest template p95 < 200 ms and both nodes under baseline RSS + 512 MB, alive, one sink",
|
||||
"result": "honest template p95 worst 78.7 ms across loads (baseline 0.7 ms); template 500ps 500/s, submit 50ps 50/s, mempool 500ps 500/s; RSS growth per load template +6MB (0/0 cache builds), submit +3MB (0/0 cache builds), mempool +1MB (0/0 cache builds), cumulative +11MB over baseline 305/307; alive true; same sink false",
|
||||
"pass": false
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"baseline_template_ms": {
|
||||
"a": {
|
||||
"n": 98,
|
||||
"p50": 0.4,
|
||||
"p95": 0.6,
|
||||
"p99": 0.9,
|
||||
"max": 0.9,
|
||||
"mean": 0.4
|
||||
},
|
||||
"b": {
|
||||
"n": 98,
|
||||
"p50": 0.5,
|
||||
"p95": 0.7,
|
||||
"p99": 1.5,
|
||||
"max": 1.5,
|
||||
"mean": 0.5
|
||||
}
|
||||
},
|
||||
"rss_baseline": {
|
||||
"a": 305,
|
||||
"b": 307
|
||||
},
|
||||
"loads": {
|
||||
"template_flood_500ps": {
|
||||
"requests_sent": 14999,
|
||||
"accepted": 14999,
|
||||
"rejected_or_error": 0,
|
||||
"rate_per_s": 500,
|
||||
"request_latency_ms": {
|
||||
"n": 14999,
|
||||
"p50": 0.2,
|
||||
"p95": 0.7,
|
||||
"p99": 3.7,
|
||||
"max": 102.4,
|
||||
"mean": 0.4
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 285,
|
||||
"p50": 0.3,
|
||||
"p95": 0.5,
|
||||
"p99": 1.4,
|
||||
"max": 1.9,
|
||||
"mean": 0.3
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 285,
|
||||
"p50": 0.3,
|
||||
"p95": 0.4,
|
||||
"p99": 0.9,
|
||||
"max": 1.7,
|
||||
"mean": 0.3
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.102,
|
||||
"a": 305,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 4.102,
|
||||
"a": 306,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 6.101,
|
||||
"a": 306,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 8.102,
|
||||
"a": 307,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 10.102,
|
||||
"a": 307,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 12.102,
|
||||
"a": 308,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 14.103,
|
||||
"a": 309,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 16.103,
|
||||
"a": 309,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 18.104,
|
||||
"a": 310,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 20.104,
|
||||
"a": 310,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 22.104,
|
||||
"a": 310,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 24.104,
|
||||
"a": 310,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 26.104,
|
||||
"a": 311,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 28.104,
|
||||
"a": 311,
|
||||
"b": 310
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 305,
|
||||
"b": 307
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 311,
|
||||
"b": 310
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 6,
|
||||
"b": 3
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0
|
||||
}
|
||||
},
|
||||
"submit_flood_50ps": {
|
||||
"requests_sent": 1499,
|
||||
"accepted": 1499,
|
||||
"rejected_or_error": 0,
|
||||
"rate_per_s": 50,
|
||||
"request_latency_ms": {
|
||||
"n": 1499,
|
||||
"p50": 0.7,
|
||||
"p95": 1.4,
|
||||
"p99": 3.6,
|
||||
"max": 5.4,
|
||||
"mean": 0.8
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 285,
|
||||
"p50": 0.3,
|
||||
"p95": 0.6,
|
||||
"p99": 0.8,
|
||||
"max": 0.8,
|
||||
"mean": 0.4
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 285,
|
||||
"p50": 0.4,
|
||||
"p95": 0.6,
|
||||
"p99": 0.8,
|
||||
"max": 0.8,
|
||||
"mean": 0.4
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.114,
|
||||
"a": 311,
|
||||
"b": 310
|
||||
},
|
||||
{
|
||||
"t_s": 4.114,
|
||||
"a": 312,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 6.114,
|
||||
"a": 312,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 8.114,
|
||||
"a": 312,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 10.114,
|
||||
"a": 313,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 12.114,
|
||||
"a": 313,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 14.115,
|
||||
"a": 313,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 16.115,
|
||||
"a": 313,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 18.116,
|
||||
"a": 313,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 20.117,
|
||||
"a": 314,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 22.117,
|
||||
"a": 314,
|
||||
"b": 311
|
||||
},
|
||||
{
|
||||
"t_s": 24.117,
|
||||
"a": 314,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 26.117,
|
||||
"a": 314,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 28.118,
|
||||
"a": 314,
|
||||
"b": 312
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 311,
|
||||
"b": 310
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 314,
|
||||
"b": 312
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 3,
|
||||
"b": 2
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0
|
||||
}
|
||||
},
|
||||
"mempool_flood_500ps": {
|
||||
"requests_sent": 14995,
|
||||
"accepted": 0,
|
||||
"rejected_or_error": 14995,
|
||||
"rate_per_s": 500,
|
||||
"request_latency_ms": {
|
||||
"n": 14995,
|
||||
"p50": 4.5,
|
||||
"p95": 23.2,
|
||||
"p99": 42.3,
|
||||
"max": 812.8,
|
||||
"mean": 8.7
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 187,
|
||||
"p50": 5.3,
|
||||
"p95": 78.7,
|
||||
"p99": 629.7,
|
||||
"max": 832.9,
|
||||
"mean": 31
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 194,
|
||||
"p50": 4.9,
|
||||
"p95": 65.4,
|
||||
"p99": 528.9,
|
||||
"max": 833,
|
||||
"mean": 26.4
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.228,
|
||||
"a": 315,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 4.229,
|
||||
"a": 315,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 6.229,
|
||||
"a": 315,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 8.231,
|
||||
"a": 315,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 10.242,
|
||||
"a": 315,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 12.241,
|
||||
"a": 316,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 14.242,
|
||||
"a": 316,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 16.243,
|
||||
"a": 316,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 18.243,
|
||||
"a": 316,
|
||||
"b": 312
|
||||
},
|
||||
{
|
||||
"t_s": 20.251,
|
||||
"a": 316,
|
||||
"b": 313
|
||||
},
|
||||
{
|
||||
"t_s": 22.251,
|
||||
"a": 316,
|
||||
"b": 313
|
||||
},
|
||||
{
|
||||
"t_s": 24.269,
|
||||
"a": 316,
|
||||
"b": 313
|
||||
},
|
||||
{
|
||||
"t_s": 26.269,
|
||||
"a": 316,
|
||||
"b": 313
|
||||
},
|
||||
{
|
||||
"t_s": 28.275,
|
||||
"a": 316,
|
||||
"b": 313
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 315,
|
||||
"b": 312
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 316,
|
||||
"b": 313
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 1,
|
||||
"b": 1
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"recovery_template_ms": {
|
||||
"n": 226,
|
||||
"p50": 6.1,
|
||||
"p95": 78.7,
|
||||
"p99": 543.6,
|
||||
"max": 832.9,
|
||||
"mean": 29.8
|
||||
},
|
||||
"final": {
|
||||
"blocks_a": 121,
|
||||
"blocks_b": 122,
|
||||
"same_sink": false
|
||||
},
|
||||
"alive": true,
|
||||
"mem_bound_mb": 512
|
||||
}
|
||||
}
|
||||
163
docs/benchmarks/memory-floods-2026-10-04/after-s7-flood.json
Normal file
163
docs/benchmarks/memory-floods-2026-10-04/after-s7-flood.json
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
{
|
||||
"rows": [
|
||||
{
|
||||
"scenario": "7 fast-miner flood, controller trajectory (sim)",
|
||||
"criterion": "trajectory recorded",
|
||||
"result": "skipped (--live-only)",
|
||||
"pass": null
|
||||
},
|
||||
{
|
||||
"scenario": "7 fast-miner flood, live (50 blocks/s from one peer)",
|
||||
"criterion": "node stays responsive: honest template p95 < 200 ms, both nodes alive, same sink",
|
||||
"result": "flood accepted 202 blocks in 60 s (3.4/s); honest template p50/p95/max 5.9/81.3/1252.2 ms under flood (baseline 12.6/104.6/120.5); rss a 302->318 MB, b 302->315 MB (cache builds 0/0); alive true; same sink true",
|
||||
"pass": true
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"live": {
|
||||
"baseline_template_ms": {
|
||||
"n": 76,
|
||||
"p50": 12.6,
|
||||
"p95": 104.6,
|
||||
"p99": 120.5,
|
||||
"max": 120.5,
|
||||
"mean": 27.8
|
||||
},
|
||||
"under_flood_template_ms": {
|
||||
"n": 469,
|
||||
"p50": 5.9,
|
||||
"p95": 81.3,
|
||||
"p99": 150,
|
||||
"max": 1252.2,
|
||||
"mean": 30
|
||||
},
|
||||
"after_flood_template_ms": {
|
||||
"n": 39,
|
||||
"p50": 2.8,
|
||||
"p95": 121.2,
|
||||
"p99": 1106.2,
|
||||
"max": 1106.2,
|
||||
"mean": 50
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"t_s": 10,
|
||||
"blocks_a": 124,
|
||||
"blocks_b": 124,
|
||||
"difficulty_a": 687251814.6297691,
|
||||
"sink_same": true,
|
||||
"rss_a": 313,
|
||||
"rss_b": 311,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 103,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 21
|
||||
},
|
||||
{
|
||||
"t_s": 20,
|
||||
"blocks_a": 166,
|
||||
"blocks_b": 166,
|
||||
"difficulty_a": 2176913973.013581,
|
||||
"sink_same": true,
|
||||
"rss_a": 314,
|
||||
"rss_b": 313,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 144,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 22
|
||||
},
|
||||
{
|
||||
"t_s": 30,
|
||||
"blocks_a": 187,
|
||||
"blocks_b": 187,
|
||||
"difficulty_a": 3197271306.1776547,
|
||||
"sink_same": true,
|
||||
"rss_a": 316,
|
||||
"rss_b": 314,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 164,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 23
|
||||
},
|
||||
{
|
||||
"t_s": 40,
|
||||
"blocks_a": 208,
|
||||
"blocks_b": 208,
|
||||
"difficulty_a": 5948600103.681134,
|
||||
"sink_same": true,
|
||||
"rss_a": 317,
|
||||
"rss_b": 314,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 185,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 23
|
||||
},
|
||||
{
|
||||
"t_s": 50,
|
||||
"blocks_a": 218,
|
||||
"blocks_b": 218,
|
||||
"difficulty_a": 6763165668.73272,
|
||||
"sink_same": true,
|
||||
"rss_a": 317,
|
||||
"rss_b": 314,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 193,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 25
|
||||
},
|
||||
{
|
||||
"t_s": 60,
|
||||
"blocks_a": 228,
|
||||
"blocks_b": 228,
|
||||
"difficulty_a": 6313391779.974011,
|
||||
"sink_same": true,
|
||||
"rss_a": 318,
|
||||
"rss_b": 315,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"flood_accepted": 202,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 26
|
||||
}
|
||||
],
|
||||
"rss_before": {
|
||||
"a": 302,
|
||||
"b": 302
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 318,
|
||||
"b": 315
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0,
|
||||
"before_flood": {
|
||||
"a": 1,
|
||||
"b": 1
|
||||
}
|
||||
},
|
||||
"flood": {
|
||||
"accepted": 202,
|
||||
"rejected": 0,
|
||||
"errors": 1
|
||||
},
|
||||
"honest": {
|
||||
"accepted": 26,
|
||||
"rejected": 0
|
||||
},
|
||||
"final": {
|
||||
"blocks_a": 228,
|
||||
"blocks_b": 228,
|
||||
"same_sink": true,
|
||||
"difficulty_a": 6313391779.974011,
|
||||
"ratio": null
|
||||
},
|
||||
"alive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
506
docs/benchmarks/memory-floods-2026-10-04/after-s8-steady.json
Normal file
506
docs/benchmarks/memory-floods-2026-10-04/after-s8-steady.json
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
{
|
||||
"rows": [
|
||||
{
|
||||
"scenario": "8 steady state, one honest miner at 1 block/s, no flood (RSS per 1,000 blocks)",
|
||||
"criterion": "recorded: RSS of the mining node / the follower at 0, 500, 1,000 and 1,500 blocks, cache builds",
|
||||
"result": "0: 41/42 MB at 0 blocks (0/0 builds); 500: 319/317 MB at 510 blocks (1/1 builds); 1000: 589/588 MB at 1029 blocks (2/2 builds); 1500: 603/602 MB at 1526 blocks (2/2 builds); end 1526 blocks in 1521 s: 604/602 MB; honest accepted 1526 rejected 0 errors 0; alive true",
|
||||
"pass": null
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"samples": [
|
||||
{
|
||||
"t_s": 0,
|
||||
"blocks_a": 0,
|
||||
"blocks_b": 0,
|
||||
"daa_a": 0,
|
||||
"rss_a": 41,
|
||||
"rss_b": 42,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"same_sink": true,
|
||||
"load": "70.63 67.18 58.38",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "22.8M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-0.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "23.0M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-0.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t_s": 69,
|
||||
"blocks_a": 80,
|
||||
"blocks_b": 80,
|
||||
"daa_a": 80,
|
||||
"rss_a": 305,
|
||||
"rss_b": 305,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "83.54 72.66 61.20"
|
||||
},
|
||||
{
|
||||
"t_s": 130,
|
||||
"blocks_a": 143,
|
||||
"blocks_b": 143,
|
||||
"daa_a": 143,
|
||||
"rss_a": 307,
|
||||
"rss_b": 307,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "85.99 75.42 63.01"
|
||||
},
|
||||
{
|
||||
"t_s": 191,
|
||||
"blocks_a": 204,
|
||||
"blocks_b": 203,
|
||||
"daa_a": 204,
|
||||
"rss_a": 311,
|
||||
"rss_b": 309,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": false,
|
||||
"load": "78.72 75.80 64.03"
|
||||
},
|
||||
{
|
||||
"t_s": 251,
|
||||
"blocks_a": 269,
|
||||
"blocks_b": 269,
|
||||
"daa_a": 269,
|
||||
"rss_a": 313,
|
||||
"rss_b": 311,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "32.71 63.22 60.12"
|
||||
},
|
||||
{
|
||||
"t_s": 311,
|
||||
"blocks_a": 332,
|
||||
"blocks_b": 332,
|
||||
"daa_a": 332,
|
||||
"rss_a": 314,
|
||||
"rss_b": 313,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "13.79 52.21 56.21"
|
||||
},
|
||||
{
|
||||
"t_s": 371,
|
||||
"blocks_a": 392,
|
||||
"blocks_b": 392,
|
||||
"daa_a": 392,
|
||||
"rss_a": 316,
|
||||
"rss_b": 314,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "6.30 43.08 52.52"
|
||||
},
|
||||
{
|
||||
"t_s": 431,
|
||||
"blocks_a": 456,
|
||||
"blocks_b": 456,
|
||||
"daa_a": 456,
|
||||
"rss_a": 317,
|
||||
"rss_b": 316,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "4.83 35.97 49.22"
|
||||
},
|
||||
{
|
||||
"t_s": 492,
|
||||
"blocks_a": 510,
|
||||
"blocks_b": 510,
|
||||
"daa_a": 510,
|
||||
"rss_a": 319,
|
||||
"rss_b": 317,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "3.65 29.96 46.07",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "299.2M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-500.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "298.4M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-500.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t_s": 554,
|
||||
"blocks_a": 571,
|
||||
"blocks_b": 571,
|
||||
"daa_a": 571,
|
||||
"rss_a": 320,
|
||||
"rss_b": 319,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "3.15 24.72 42.91"
|
||||
},
|
||||
{
|
||||
"t_s": 614,
|
||||
"blocks_a": 635,
|
||||
"blocks_b": 635,
|
||||
"daa_a": 635,
|
||||
"rss_a": 322,
|
||||
"rss_b": 320,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "3.39 20.87 40.23"
|
||||
},
|
||||
{
|
||||
"t_s": 674,
|
||||
"blocks_a": 687,
|
||||
"blocks_b": 687,
|
||||
"daa_a": 687,
|
||||
"rss_a": 323,
|
||||
"rss_b": 322,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "2.77 17.54 37.67"
|
||||
},
|
||||
{
|
||||
"t_s": 734,
|
||||
"blocks_a": 746,
|
||||
"blocks_b": 746,
|
||||
"daa_a": 746,
|
||||
"rss_a": 325,
|
||||
"rss_b": 323,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "4.99 15.57 35.56"
|
||||
},
|
||||
{
|
||||
"t_s": 795,
|
||||
"blocks_a": 797,
|
||||
"blocks_b": 797,
|
||||
"daa_a": 797,
|
||||
"rss_a": 326,
|
||||
"rss_b": 325,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "4.22 13.41 33.39"
|
||||
},
|
||||
{
|
||||
"t_s": 855,
|
||||
"blocks_a": 864,
|
||||
"blocks_b": 864,
|
||||
"daa_a": 864,
|
||||
"rss_a": 328,
|
||||
"rss_b": 326,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "5.90 12.34 31.64"
|
||||
},
|
||||
{
|
||||
"t_s": 915,
|
||||
"blocks_a": 920,
|
||||
"blocks_b": 920,
|
||||
"daa_a": 920,
|
||||
"rss_a": 330,
|
||||
"rss_b": 328,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "4.99 10.91 29.79"
|
||||
},
|
||||
{
|
||||
"t_s": 975,
|
||||
"blocks_a": 981,
|
||||
"blocks_b": 981,
|
||||
"daa_a": 981,
|
||||
"rss_a": 588,
|
||||
"rss_b": 586,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "4.12 9.61 28.02"
|
||||
},
|
||||
{
|
||||
"t_s": 1035,
|
||||
"blocks_a": 1029,
|
||||
"blocks_b": 1029,
|
||||
"daa_a": 1029,
|
||||
"rss_a": 589,
|
||||
"rss_b": 588,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "3.05 8.28 26.26",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "569.6M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-1000.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "568.6M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-1000.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t_s": 1098,
|
||||
"blocks_a": 1095,
|
||||
"blocks_b": 1095,
|
||||
"daa_a": 1095,
|
||||
"rss_a": 591,
|
||||
"rss_b": 589,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "2.31 7.01 24.46"
|
||||
},
|
||||
{
|
||||
"t_s": 1158,
|
||||
"blocks_a": 1163,
|
||||
"blocks_b": 1163,
|
||||
"daa_a": 1163,
|
||||
"rss_a": 592,
|
||||
"rss_b": 591,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "2.19 6.11 22.93"
|
||||
},
|
||||
{
|
||||
"t_s": 1218,
|
||||
"blocks_a": 1219,
|
||||
"blocks_b": 1219,
|
||||
"daa_a": 1219,
|
||||
"rss_a": 594,
|
||||
"rss_b": 592,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "1.84 5.33 21.50"
|
||||
},
|
||||
{
|
||||
"t_s": 1279,
|
||||
"blocks_a": 1281,
|
||||
"blocks_b": 1281,
|
||||
"daa_a": 1281,
|
||||
"rss_a": 596,
|
||||
"rss_b": 594,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "1.90 4.76 20.18"
|
||||
},
|
||||
{
|
||||
"t_s": 1339,
|
||||
"blocks_a": 1341,
|
||||
"blocks_b": 1341,
|
||||
"daa_a": 1341,
|
||||
"rss_a": 597,
|
||||
"rss_b": 596,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "1.25 4.04 18.86"
|
||||
},
|
||||
{
|
||||
"t_s": 1399,
|
||||
"blocks_a": 1406,
|
||||
"blocks_b": 1406,
|
||||
"daa_a": 1406,
|
||||
"rss_a": 599,
|
||||
"rss_b": 598,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "1.50 3.58 17.68"
|
||||
},
|
||||
{
|
||||
"t_s": 1459,
|
||||
"blocks_a": 1465,
|
||||
"blocks_b": 1465,
|
||||
"daa_a": 1465,
|
||||
"rss_a": 601,
|
||||
"rss_b": 600,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "2.19 3.41 16.65"
|
||||
},
|
||||
{
|
||||
"t_s": 1519,
|
||||
"blocks_a": 1526,
|
||||
"blocks_b": 1526,
|
||||
"daa_a": 1526,
|
||||
"rss_a": 603,
|
||||
"rss_b": 602,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "2.04 3.14 15.65",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "584.0M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-1500.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "582.8M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-1500.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t_s": 1521,
|
||||
"blocks_a": 1526,
|
||||
"blocks_b": 1526,
|
||||
"daa_a": 1526,
|
||||
"rss_a": 604,
|
||||
"rss_b": 602,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "2.04 3.14 15.65",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "584.0M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-end.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "582.8M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-end.txt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"milestones": {
|
||||
"0": {
|
||||
"t_s": 0,
|
||||
"blocks_a": 0,
|
||||
"blocks_b": 0,
|
||||
"daa_a": 0,
|
||||
"rss_a": 41,
|
||||
"rss_b": 42,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"same_sink": true,
|
||||
"load": "70.63 67.18 58.38",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "22.8M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-0.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "23.0M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-0.txt"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"t_s": 492,
|
||||
"blocks_a": 510,
|
||||
"blocks_b": 510,
|
||||
"daa_a": 510,
|
||||
"rss_a": 319,
|
||||
"rss_b": 317,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"same_sink": true,
|
||||
"load": "3.65 29.96 46.07",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "299.2M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-500.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "298.4M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-500.txt"
|
||||
}
|
||||
},
|
||||
"1000": {
|
||||
"t_s": 1035,
|
||||
"blocks_a": 1029,
|
||||
"blocks_b": 1029,
|
||||
"daa_a": 1029,
|
||||
"rss_a": 589,
|
||||
"rss_b": 588,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "3.05 8.28 26.26",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "569.6M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-1000.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "568.6M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-1000.txt"
|
||||
}
|
||||
},
|
||||
"1500": {
|
||||
"t_s": 1519,
|
||||
"blocks_a": 1526,
|
||||
"blocks_b": 1526,
|
||||
"daa_a": 1526,
|
||||
"rss_a": 603,
|
||||
"rss_b": 602,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "2.04 3.14 15.65",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "584.0M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-1500.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "582.8M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-1500.txt"
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"t_s": 1521,
|
||||
"blocks_a": 1526,
|
||||
"blocks_b": 1526,
|
||||
"daa_a": 1526,
|
||||
"rss_a": 604,
|
||||
"rss_b": 602,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "2.04 3.14 15.65",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "584.0M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8a-end.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "582.8M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-after/vmmap/s8b-end.txt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"honest": {
|
||||
"accepted": 1526,
|
||||
"rejected": 0,
|
||||
"errors": 0
|
||||
},
|
||||
"alive": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
{
|
||||
"rows": [
|
||||
{
|
||||
"scenario": "6 resource exhaustion (50x template, submit and mempool floods from one peer)",
|
||||
"criterion": "honest template p95 < 200 ms and both nodes under baseline RSS + 512 MB, alive, one sink",
|
||||
"result": "honest template p95 worst 130.8 ms across loads (baseline 84.7 ms); template 500ps 500/s, submit 50ps 50/s, mempool 500ps 500/s; RSS growth per load template +5MB (0/0 cache builds), submit +263MB (1/1 cache builds), mempool +1MB (0/0 cache builds), cumulative +270MB over baseline 303/304; alive true; same sink false",
|
||||
"pass": false
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"baseline_template_ms": {
|
||||
"a": {
|
||||
"n": 77,
|
||||
"p50": 3.8,
|
||||
"p95": 81.5,
|
||||
"p99": 84.5,
|
||||
"max": 84.5,
|
||||
"mean": 25.6
|
||||
},
|
||||
"b": {
|
||||
"n": 76,
|
||||
"p50": 5.5,
|
||||
"p95": 84.7,
|
||||
"p99": 104.2,
|
||||
"max": 104.2,
|
||||
"mean": 27.1
|
||||
}
|
||||
},
|
||||
"rss_baseline": {
|
||||
"a": 303,
|
||||
"b": 304
|
||||
},
|
||||
"loads": {
|
||||
"template_flood_500ps": {
|
||||
"requests_sent": 14995,
|
||||
"accepted": 14995,
|
||||
"rejected_or_error": 0,
|
||||
"rate_per_s": 500,
|
||||
"request_latency_ms": {
|
||||
"n": 14995,
|
||||
"p50": 36.7,
|
||||
"p95": 439.3,
|
||||
"p99": 1215.9,
|
||||
"max": 1501.2,
|
||||
"mean": 109.1
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 159,
|
||||
"p50": 11,
|
||||
"p95": 130.8,
|
||||
"p99": 754,
|
||||
"max": 858.2,
|
||||
"mean": 47
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 160,
|
||||
"p50": 15.7,
|
||||
"p95": 130.3,
|
||||
"p99": 760.6,
|
||||
"max": 858.1,
|
||||
"mean": 50.2
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.695,
|
||||
"a": 305,
|
||||
"b": 306
|
||||
},
|
||||
{
|
||||
"t_s": 4.705,
|
||||
"a": 306,
|
||||
"b": 306
|
||||
},
|
||||
{
|
||||
"t_s": 6.706,
|
||||
"a": 307,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 8.706,
|
||||
"a": 307,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 10.706,
|
||||
"a": 307,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 12.708,
|
||||
"a": 307,
|
||||
"b": 307
|
||||
},
|
||||
{
|
||||
"t_s": 14.714,
|
||||
"a": 308,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 16.723,
|
||||
"a": 308,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 18.724,
|
||||
"a": 308,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 20.735,
|
||||
"a": 309,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 22.734,
|
||||
"a": 309,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 24.736,
|
||||
"a": 309,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 26.736,
|
||||
"a": 309,
|
||||
"b": 308
|
||||
},
|
||||
{
|
||||
"t_s": 28.736,
|
||||
"a": 309,
|
||||
"b": 309
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 304,
|
||||
"b": 304
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 309,
|
||||
"b": 309
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 5,
|
||||
"b": 5
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0
|
||||
}
|
||||
},
|
||||
"submit_flood_50ps": {
|
||||
"requests_sent": 1499,
|
||||
"accepted": 1499,
|
||||
"rejected_or_error": 0,
|
||||
"rate_per_s": 50,
|
||||
"request_latency_ms": {
|
||||
"n": 1499,
|
||||
"p50": 401.9,
|
||||
"p95": 1120.6,
|
||||
"p99": 1405.9,
|
||||
"max": 1610.1,
|
||||
"mean": 453.6
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 166,
|
||||
"p50": 5.9,
|
||||
"p95": 86.7,
|
||||
"p99": 600.8,
|
||||
"max": 689.4,
|
||||
"mean": 36.7
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 166,
|
||||
"p50": 6.6,
|
||||
"p95": 84.9,
|
||||
"p99": 689.3,
|
||||
"max": 743.1,
|
||||
"mean": 38.1
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.743,
|
||||
"a": 310,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 4.752,
|
||||
"a": 311,
|
||||
"b": 309
|
||||
},
|
||||
{
|
||||
"t_s": 6.802,
|
||||
"a": 311,
|
||||
"b": 565
|
||||
},
|
||||
{
|
||||
"t_s": 8.804,
|
||||
"a": 311,
|
||||
"b": 565
|
||||
},
|
||||
{
|
||||
"t_s": 10.804,
|
||||
"a": 568,
|
||||
"b": 565
|
||||
},
|
||||
{
|
||||
"t_s": 12.805,
|
||||
"a": 568,
|
||||
"b": 565
|
||||
},
|
||||
{
|
||||
"t_s": 14.812,
|
||||
"a": 569,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 16.817,
|
||||
"a": 569,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 18.823,
|
||||
"a": 569,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 20.823,
|
||||
"a": 570,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 22.829,
|
||||
"a": 571,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 24.829,
|
||||
"a": 571,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 26.829,
|
||||
"a": 571,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 28.845,
|
||||
"a": 572,
|
||||
"b": 566
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 309,
|
||||
"b": 309
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 263,
|
||||
"b": 257
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 1,
|
||||
"b": 1
|
||||
}
|
||||
},
|
||||
"mempool_flood_500ps": {
|
||||
"requests_sent": 14993,
|
||||
"accepted": 0,
|
||||
"rejected_or_error": 14993,
|
||||
"rate_per_s": 500,
|
||||
"request_latency_ms": {
|
||||
"n": 14993,
|
||||
"p50": 7.1,
|
||||
"p95": 49.9,
|
||||
"p99": 126.6,
|
||||
"max": 815.9,
|
||||
"mean": 15.8
|
||||
},
|
||||
"honest_template_ms": {
|
||||
"n": 164,
|
||||
"p50": 7.9,
|
||||
"p95": 104.7,
|
||||
"p99": 735.5,
|
||||
"max": 815.8,
|
||||
"mean": 44.5
|
||||
},
|
||||
"attacked_node_template_ms": {
|
||||
"n": 166,
|
||||
"p50": 6.8,
|
||||
"p95": 90.8,
|
||||
"p99": 745.9,
|
||||
"max": 815.9,
|
||||
"mean": 37.3
|
||||
},
|
||||
"rss_series": [
|
||||
{
|
||||
"t_s": 2.533,
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 4.534,
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 6.537,
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 8.546,
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 10.546,
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 12.547,
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 14.548,
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 16.549,
|
||||
"a": 573,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 18.548,
|
||||
"a": 573,
|
||||
"b": 566
|
||||
},
|
||||
{
|
||||
"t_s": 20.559,
|
||||
"a": 573,
|
||||
"b": 567
|
||||
},
|
||||
{
|
||||
"t_s": 22.57,
|
||||
"a": 573,
|
||||
"b": 567
|
||||
},
|
||||
{
|
||||
"t_s": 24.57,
|
||||
"a": 573,
|
||||
"b": 567
|
||||
},
|
||||
{
|
||||
"t_s": 26.57,
|
||||
"a": 573,
|
||||
"b": 567
|
||||
},
|
||||
{
|
||||
"t_s": 28.57,
|
||||
"a": 573,
|
||||
"b": 567
|
||||
}
|
||||
],
|
||||
"rss_start": {
|
||||
"a": 572,
|
||||
"b": 566
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 573,
|
||||
"b": 567
|
||||
},
|
||||
"both_alive": true,
|
||||
"rss_delta": {
|
||||
"a": 1,
|
||||
"b": 1
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 0,
|
||||
"b": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"recovery_template_ms": {
|
||||
"n": 203,
|
||||
"p50": 7.9,
|
||||
"p95": 96.1,
|
||||
"p99": 731.6,
|
||||
"max": 815.8,
|
||||
"mean": 40.4
|
||||
},
|
||||
"final": {
|
||||
"blocks_a": 120,
|
||||
"blocks_b": 121,
|
||||
"same_sink": false
|
||||
},
|
||||
"alive": true,
|
||||
"mem_bound_mb": 512
|
||||
}
|
||||
}
|
||||
163
docs/benchmarks/memory-floods-2026-10-04/before-s7-flood.json
Normal file
163
docs/benchmarks/memory-floods-2026-10-04/before-s7-flood.json
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
{
|
||||
"rows": [
|
||||
{
|
||||
"scenario": "7 fast-miner flood, controller trajectory (sim)",
|
||||
"criterion": "trajectory recorded",
|
||||
"result": "skipped (--live-only)",
|
||||
"pass": null
|
||||
},
|
||||
{
|
||||
"scenario": "7 fast-miner flood, live (50 blocks/s from one peer)",
|
||||
"criterion": "node stays responsive: honest template p95 < 200 ms, both nodes alive, same sink",
|
||||
"result": "flood accepted 198 blocks in 60 s (3.3/s); honest template p50/p95/max 7.7/91.5/1454.3 ms under flood (baseline 23.5/92.8/188.8); rss a 302->1085 MB, b 303->1083 MB (cache builds 3/3); alive true; same sink true",
|
||||
"pass": true
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"live": {
|
||||
"baseline_template_ms": {
|
||||
"n": 76,
|
||||
"p50": 23.5,
|
||||
"p95": 92.8,
|
||||
"p99": 188.8,
|
||||
"max": 188.8,
|
||||
"mean": 27.7
|
||||
},
|
||||
"under_flood_template_ms": {
|
||||
"n": 458,
|
||||
"p50": 7.7,
|
||||
"p95": 91.5,
|
||||
"p99": 116.7,
|
||||
"max": 1454.3,
|
||||
"mean": 31.9
|
||||
},
|
||||
"after_flood_template_ms": {
|
||||
"n": 39,
|
||||
"p50": 7.9,
|
||||
"p95": 87.6,
|
||||
"p99": 91.2,
|
||||
"max": 91.2,
|
||||
"mean": 24.8
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"t_s": 10,
|
||||
"blocks_a": 94,
|
||||
"blocks_b": 93,
|
||||
"difficulty_a": 457438921.53559726,
|
||||
"sink_same": false,
|
||||
"rss_a": 569,
|
||||
"rss_b": 567,
|
||||
"cache_builds_a": 1,
|
||||
"cache_builds_b": 1,
|
||||
"flood_accepted": 84,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 10
|
||||
},
|
||||
{
|
||||
"t_s": 20,
|
||||
"blocks_a": 137,
|
||||
"blocks_b": 137,
|
||||
"difficulty_a": 1287295920.1241035,
|
||||
"sink_same": true,
|
||||
"rss_a": 827,
|
||||
"rss_b": 825,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"flood_accepted": 126,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 11
|
||||
},
|
||||
{
|
||||
"t_s": 30,
|
||||
"blocks_a": 167,
|
||||
"blocks_b": 167,
|
||||
"difficulty_a": 3125416130.4758606,
|
||||
"sink_same": true,
|
||||
"rss_a": 828,
|
||||
"rss_b": 826,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"flood_accepted": 155,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 12
|
||||
},
|
||||
{
|
||||
"t_s": 40,
|
||||
"blocks_a": 183,
|
||||
"blocks_b": 180,
|
||||
"difficulty_a": 4327152934.829311,
|
||||
"sink_same": false,
|
||||
"rss_a": 1084,
|
||||
"rss_b": 1082,
|
||||
"cache_builds_a": 3,
|
||||
"cache_builds_b": 3,
|
||||
"flood_accepted": 169,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 14
|
||||
},
|
||||
{
|
||||
"t_s": 50,
|
||||
"blocks_a": 196,
|
||||
"blocks_b": 196,
|
||||
"difficulty_a": 4435640076.843163,
|
||||
"sink_same": true,
|
||||
"rss_a": 1084,
|
||||
"rss_b": 1083,
|
||||
"cache_builds_a": 3,
|
||||
"cache_builds_b": 3,
|
||||
"flood_accepted": 181,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 15
|
||||
},
|
||||
{
|
||||
"t_s": 60,
|
||||
"blocks_a": 213,
|
||||
"blocks_b": 213,
|
||||
"difficulty_a": 6255943304.461032,
|
||||
"sink_same": true,
|
||||
"rss_a": 1085,
|
||||
"rss_b": 1083,
|
||||
"cache_builds_a": 3,
|
||||
"cache_builds_b": 3,
|
||||
"flood_accepted": 198,
|
||||
"flood_rejected": 0,
|
||||
"honest_accepted": 15
|
||||
}
|
||||
],
|
||||
"rss_before": {
|
||||
"a": 302,
|
||||
"b": 303
|
||||
},
|
||||
"rss_peak": {
|
||||
"a": 1085,
|
||||
"b": 1083
|
||||
},
|
||||
"cache_builds": {
|
||||
"a": 3,
|
||||
"b": 3,
|
||||
"before_flood": {
|
||||
"a": 1,
|
||||
"b": 1
|
||||
}
|
||||
},
|
||||
"flood": {
|
||||
"accepted": 198,
|
||||
"rejected": 0,
|
||||
"errors": 0
|
||||
},
|
||||
"honest": {
|
||||
"accepted": 16,
|
||||
"rejected": 0
|
||||
},
|
||||
"final": {
|
||||
"blocks_a": 214,
|
||||
"blocks_b": 214,
|
||||
"same_sink": true,
|
||||
"difficulty_a": 5868605790.480026,
|
||||
"ratio": null
|
||||
},
|
||||
"alive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
506
docs/benchmarks/memory-floods-2026-10-04/before-s8-steady.json
Normal file
506
docs/benchmarks/memory-floods-2026-10-04/before-s8-steady.json
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
{
|
||||
"rows": [
|
||||
{
|
||||
"scenario": "8 steady state, one honest miner at 1 block/s, no flood (RSS per 1,000 blocks)",
|
||||
"criterion": "recorded: RSS of the mining node / the follower at 0, 500, 1,000 and 1,500 blocks, cache builds",
|
||||
"result": "0: 41/42 MB at 0 blocks (0/0 builds); 500: 1342/1342 MB at 514 blocks (9/9 builds); 1000: 1355/1355 MB at 1008 blocks (18/18 builds); 1500: 1371/1372 MB at 1529 blocks (27/27 builds); end 1529 blocks in 1527 s: 1371/1372 MB; honest accepted 1529 rejected 0 errors 0; alive true",
|
||||
"pass": null
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"samples": [
|
||||
{
|
||||
"t_s": 0,
|
||||
"blocks_a": 0,
|
||||
"blocks_b": 0,
|
||||
"daa_a": 0,
|
||||
"rss_a": 41,
|
||||
"rss_b": 42,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"same_sink": true,
|
||||
"load": "80.88 75.23 63.38",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "22.5M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-0.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "23.7M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-0.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t_s": 67,
|
||||
"blocks_a": 83,
|
||||
"blocks_b": 83,
|
||||
"daa_a": 83,
|
||||
"rss_a": 564,
|
||||
"rss_b": 563,
|
||||
"cache_builds_a": 2,
|
||||
"cache_builds_b": 2,
|
||||
"same_sink": true,
|
||||
"load": "48.05 68.48 61.83"
|
||||
},
|
||||
{
|
||||
"t_s": 127,
|
||||
"blocks_a": 143,
|
||||
"blocks_b": 143,
|
||||
"daa_a": 143,
|
||||
"rss_a": 821,
|
||||
"rss_b": 820,
|
||||
"cache_builds_a": 3,
|
||||
"cache_builds_b": 3,
|
||||
"same_sink": true,
|
||||
"load": "19.61 56.55 57.81"
|
||||
},
|
||||
{
|
||||
"t_s": 187,
|
||||
"blocks_a": 203,
|
||||
"blocks_b": 203,
|
||||
"daa_a": 203,
|
||||
"rss_a": 1079,
|
||||
"rss_b": 1078,
|
||||
"cache_builds_a": 4,
|
||||
"cache_builds_b": 4,
|
||||
"same_sink": true,
|
||||
"load": "8.59 46.69 54.04"
|
||||
},
|
||||
{
|
||||
"t_s": 248,
|
||||
"blocks_a": 268,
|
||||
"blocks_b": 268,
|
||||
"daa_a": 268,
|
||||
"rss_a": 1080,
|
||||
"rss_b": 1079,
|
||||
"cache_builds_a": 5,
|
||||
"cache_builds_b": 5,
|
||||
"same_sink": true,
|
||||
"load": "5.31 38.76 50.57"
|
||||
},
|
||||
{
|
||||
"t_s": 308,
|
||||
"blocks_a": 329,
|
||||
"blocks_b": 329,
|
||||
"daa_a": 329,
|
||||
"rss_a": 1081,
|
||||
"rss_b": 1081,
|
||||
"cache_builds_a": 6,
|
||||
"cache_builds_b": 6,
|
||||
"same_sink": true,
|
||||
"load": "4.13 32.34 47.36"
|
||||
},
|
||||
{
|
||||
"t_s": 368,
|
||||
"blocks_a": 398,
|
||||
"blocks_b": 398,
|
||||
"daa_a": 398,
|
||||
"rss_a": 1083,
|
||||
"rss_b": 1083,
|
||||
"cache_builds_a": 7,
|
||||
"cache_builds_b": 7,
|
||||
"same_sink": true,
|
||||
"load": "4.16 26.78 44.16"
|
||||
},
|
||||
{
|
||||
"t_s": 428,
|
||||
"blocks_a": 461,
|
||||
"blocks_b": 461,
|
||||
"daa_a": 461,
|
||||
"rss_a": 1085,
|
||||
"rss_b": 1084,
|
||||
"cache_builds_a": 8,
|
||||
"cache_builds_b": 8,
|
||||
"same_sink": true,
|
||||
"load": "3.37 22.41 41.34"
|
||||
},
|
||||
{
|
||||
"t_s": 488,
|
||||
"blocks_a": 514,
|
||||
"blocks_b": 514,
|
||||
"daa_a": 514,
|
||||
"rss_a": 1342,
|
||||
"rss_b": 1342,
|
||||
"cache_builds_a": 9,
|
||||
"cache_builds_b": 9,
|
||||
"same_sink": true,
|
||||
"load": "3.05 18.88 38.73",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-500.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-500.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t_s": 551,
|
||||
"blocks_a": 569,
|
||||
"blocks_b": 569,
|
||||
"daa_a": 569,
|
||||
"rss_a": 1343,
|
||||
"rss_b": 1343,
|
||||
"cache_builds_a": 10,
|
||||
"cache_builds_b": 10,
|
||||
"same_sink": true,
|
||||
"load": "5.64 16.62 36.52"
|
||||
},
|
||||
{
|
||||
"t_s": 611,
|
||||
"blocks_a": 633,
|
||||
"blocks_b": 633,
|
||||
"daa_a": 633,
|
||||
"rss_a": 1345,
|
||||
"rss_b": 1345,
|
||||
"cache_builds_a": 11,
|
||||
"cache_builds_b": 11,
|
||||
"same_sink": true,
|
||||
"load": "4.37 14.24 34.27"
|
||||
},
|
||||
{
|
||||
"t_s": 671,
|
||||
"blocks_a": 699,
|
||||
"blocks_b": 699,
|
||||
"daa_a": 699,
|
||||
"rss_a": 1347,
|
||||
"rss_b": 1346,
|
||||
"cache_builds_a": 12,
|
||||
"cache_builds_b": 12,
|
||||
"same_sink": true,
|
||||
"load": "6.78 13.04 32.45"
|
||||
},
|
||||
{
|
||||
"t_s": 731,
|
||||
"blocks_a": 761,
|
||||
"blocks_b": 761,
|
||||
"daa_a": 761,
|
||||
"rss_a": 1348,
|
||||
"rss_b": 1348,
|
||||
"cache_builds_a": 13,
|
||||
"cache_builds_b": 13,
|
||||
"same_sink": true,
|
||||
"load": "5.68 11.55 30.57"
|
||||
},
|
||||
{
|
||||
"t_s": 791,
|
||||
"blocks_a": 823,
|
||||
"blocks_b": 823,
|
||||
"daa_a": 823,
|
||||
"rss_a": 1350,
|
||||
"rss_b": 1350,
|
||||
"cache_builds_a": 14,
|
||||
"cache_builds_b": 14,
|
||||
"same_sink": true,
|
||||
"load": "4.46 10.14 28.75"
|
||||
},
|
||||
{
|
||||
"t_s": 852,
|
||||
"blocks_a": 894,
|
||||
"blocks_b": 894,
|
||||
"daa_a": 894,
|
||||
"rss_a": 1352,
|
||||
"rss_b": 1352,
|
||||
"cache_builds_a": 16,
|
||||
"cache_builds_b": 16,
|
||||
"same_sink": true,
|
||||
"load": "3.31 8.79 26.97"
|
||||
},
|
||||
{
|
||||
"t_s": 912,
|
||||
"blocks_a": 958,
|
||||
"blocks_b": 958,
|
||||
"daa_a": 958,
|
||||
"rss_a": 1354,
|
||||
"rss_b": 1354,
|
||||
"cache_builds_a": 17,
|
||||
"cache_builds_b": 17,
|
||||
"same_sink": true,
|
||||
"load": "2.38 7.53 25.26"
|
||||
},
|
||||
{
|
||||
"t_s": 972,
|
||||
"blocks_a": 1008,
|
||||
"blocks_b": 1008,
|
||||
"daa_a": 1008,
|
||||
"rss_a": 1355,
|
||||
"rss_b": 1355,
|
||||
"cache_builds_a": 18,
|
||||
"cache_builds_b": 18,
|
||||
"same_sink": true,
|
||||
"load": "2.17 6.53 23.68",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-1000.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-1000.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t_s": 1034,
|
||||
"blocks_a": 1069,
|
||||
"blocks_b": 1069,
|
||||
"daa_a": 1069,
|
||||
"rss_a": 1357,
|
||||
"rss_b": 1357,
|
||||
"cache_builds_a": 19,
|
||||
"cache_builds_b": 19,
|
||||
"same_sink": true,
|
||||
"load": "2.37 5.73 22.11"
|
||||
},
|
||||
{
|
||||
"t_s": 1095,
|
||||
"blocks_a": 1139,
|
||||
"blocks_b": 1139,
|
||||
"daa_a": 1139,
|
||||
"rss_a": 1359,
|
||||
"rss_b": 1358,
|
||||
"cache_builds_a": 20,
|
||||
"cache_builds_b": 20,
|
||||
"same_sink": true,
|
||||
"load": "2.46 5.10 20.76"
|
||||
},
|
||||
{
|
||||
"t_s": 1155,
|
||||
"blocks_a": 1192,
|
||||
"blocks_b": 1192,
|
||||
"daa_a": 1192,
|
||||
"rss_a": 1360,
|
||||
"rss_b": 1360,
|
||||
"cache_builds_a": 21,
|
||||
"cache_builds_b": 21,
|
||||
"same_sink": true,
|
||||
"load": "1.41 4.31 19.39"
|
||||
},
|
||||
{
|
||||
"t_s": 1215,
|
||||
"blocks_a": 1247,
|
||||
"blocks_b": 1247,
|
||||
"daa_a": 1247,
|
||||
"rss_a": 1362,
|
||||
"rss_b": 1362,
|
||||
"cache_builds_a": 22,
|
||||
"cache_builds_b": 22,
|
||||
"same_sink": true,
|
||||
"load": "1.19 3.72 18.14"
|
||||
},
|
||||
{
|
||||
"t_s": 1275,
|
||||
"blocks_a": 1294,
|
||||
"blocks_b": 1294,
|
||||
"daa_a": 1294,
|
||||
"rss_a": 1363,
|
||||
"rss_b": 1363,
|
||||
"cache_builds_a": 23,
|
||||
"cache_builds_b": 23,
|
||||
"same_sink": true,
|
||||
"load": "1.59 3.40 17.04"
|
||||
},
|
||||
{
|
||||
"t_s": 1335,
|
||||
"blocks_a": 1352,
|
||||
"blocks_b": 1352,
|
||||
"daa_a": 1352,
|
||||
"rss_a": 1365,
|
||||
"rss_b": 1364,
|
||||
"cache_builds_a": 24,
|
||||
"cache_builds_b": 24,
|
||||
"same_sink": true,
|
||||
"load": "1.87 3.20 16.04"
|
||||
},
|
||||
{
|
||||
"t_s": 1395,
|
||||
"blocks_a": 1401,
|
||||
"blocks_b": 1401,
|
||||
"daa_a": 1401,
|
||||
"rss_a": 1367,
|
||||
"rss_b": 1366,
|
||||
"cache_builds_a": 25,
|
||||
"cache_builds_b": 25,
|
||||
"same_sink": true,
|
||||
"load": "7.11 4.19 15.52"
|
||||
},
|
||||
{
|
||||
"t_s": 1456,
|
||||
"blocks_a": 1468,
|
||||
"blocks_b": 1468,
|
||||
"daa_a": 1468,
|
||||
"rss_a": 1369,
|
||||
"rss_b": 1369,
|
||||
"cache_builds_a": 26,
|
||||
"cache_builds_b": 26,
|
||||
"same_sink": true,
|
||||
"load": "43.45 13.68 18.21"
|
||||
},
|
||||
{
|
||||
"t_s": 1517,
|
||||
"blocks_a": 1529,
|
||||
"blocks_b": 1529,
|
||||
"daa_a": 1529,
|
||||
"rss_a": 1371,
|
||||
"rss_b": 1372,
|
||||
"cache_builds_a": 27,
|
||||
"cache_builds_b": 27,
|
||||
"same_sink": true,
|
||||
"load": "92.08 33.71 25.42",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-1500.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-1500.txt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"t_s": 1527,
|
||||
"blocks_a": 1529,
|
||||
"blocks_b": 1529,
|
||||
"daa_a": 1529,
|
||||
"rss_a": 1371,
|
||||
"rss_b": 1372,
|
||||
"cache_builds_a": 27,
|
||||
"cache_builds_b": 27,
|
||||
"same_sink": true,
|
||||
"load": "90.06 35.17 26.04",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-end.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-end.txt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"milestones": {
|
||||
"0": {
|
||||
"t_s": 0,
|
||||
"blocks_a": 0,
|
||||
"blocks_b": 0,
|
||||
"daa_a": 0,
|
||||
"rss_a": 41,
|
||||
"rss_b": 42,
|
||||
"cache_builds_a": 0,
|
||||
"cache_builds_b": 0,
|
||||
"same_sink": true,
|
||||
"load": "80.88 75.23 63.38",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "22.5M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-0.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "23.7M",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-0.txt"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"t_s": 488,
|
||||
"blocks_a": 514,
|
||||
"blocks_b": 514,
|
||||
"daa_a": 514,
|
||||
"rss_a": 1342,
|
||||
"rss_b": 1342,
|
||||
"cache_builds_a": 9,
|
||||
"cache_builds_b": 9,
|
||||
"same_sink": true,
|
||||
"load": "3.05 18.88 38.73",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-500.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-500.txt"
|
||||
}
|
||||
},
|
||||
"1000": {
|
||||
"t_s": 972,
|
||||
"blocks_a": 1008,
|
||||
"blocks_b": 1008,
|
||||
"daa_a": 1008,
|
||||
"rss_a": 1355,
|
||||
"rss_b": 1355,
|
||||
"cache_builds_a": 18,
|
||||
"cache_builds_b": 18,
|
||||
"same_sink": true,
|
||||
"load": "2.17 6.53 23.68",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-1000.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-1000.txt"
|
||||
}
|
||||
},
|
||||
"1500": {
|
||||
"t_s": 1517,
|
||||
"blocks_a": 1529,
|
||||
"blocks_b": 1529,
|
||||
"daa_a": 1529,
|
||||
"rss_a": 1371,
|
||||
"rss_b": 1372,
|
||||
"cache_builds_a": 27,
|
||||
"cache_builds_b": 27,
|
||||
"same_sink": true,
|
||||
"load": "92.08 33.71 25.42",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-1500.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-1500.txt"
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"t_s": 1527,
|
||||
"blocks_a": 1529,
|
||||
"blocks_b": 1529,
|
||||
"daa_a": 1529,
|
||||
"rss_a": 1371,
|
||||
"rss_b": 1372,
|
||||
"cache_builds_a": 27,
|
||||
"cache_builds_b": 27,
|
||||
"same_sink": true,
|
||||
"load": "90.06 35.17 26.04",
|
||||
"vmmap_a": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8a-end.txt"
|
||||
},
|
||||
"vmmap_b": {
|
||||
"physical_footprint": "1.3G",
|
||||
"malloc_total": "0K",
|
||||
"file": "/tmp/igneum-fud-mem/steady-before/vmmap/s8b-end.txt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"honest": {
|
||||
"accepted": 1529,
|
||||
"rejected": 0,
|
||||
"errors": 0
|
||||
},
|
||||
"alive": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
Process: igneumd [55063]
|
||||
Path: /Users/USER/*/igneumd
|
||||
Load Address: 0x100a4c000
|
||||
Identifier: igneumd
|
||||
Version: 0
|
||||
Code Type: ARM64
|
||||
Platform: macOS
|
||||
Parent Process: node [53092]
|
||||
Target Type: live task
|
||||
|
||||
Date/Time: 2026-10-05 02:20:03.953 <local>
|
||||
Launch Time: 2026-10-05 02:19:55.547 <local>
|
||||
OS Version: macOS 26.6.2 (25G83)
|
||||
Report Version: 7
|
||||
Analysis Tool: /usr/bin/vmmap
|
||||
|
||||
Physical footprint: 22.8M
|
||||
Physical footprint (peak): 22.8M
|
||||
Idle exit: untracked
|
||||
----
|
||||
|
||||
ReadOnly portion of Libraries: Total=898.4M resident=211.2M(24%) swapped_out_or_unallocated=687.2M(76%)
|
||||
Writable regions: Total=3.3G written=20.8M(1%) resident=20.8M(1%) swapped_out=0K(0%) unallocated=3.2G(99%)
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED VOLATILE NONVOL EMPTY REGION
|
||||
REGION TYPE SIZE SIZE SIZE SIZE SIZE SIZE SIZE COUNT (non-coalesced)
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
IOAccelerator 2.1G 14.2M 14.2M 0K 0K 0K 0K 80
|
||||
IOAccelerator (reserved) 896.0M 0K 0K 0K 0K 0K 0K 1 reserved VM address space (unallocated)
|
||||
Kernel Alloc Once 32K 16K 16K 0K 0K 0K 0K 1
|
||||
MALLOC guard page 3632K 0K 0K 0K 0K 0K 0K 4
|
||||
MALLOC metadata 880K 480K 480K 0K 0K 0K 0K 4
|
||||
MALLOC_SMALL 32.0M 2624K 2624K 0K 0K 0K 0K 8 see MALLOC ZONE table below
|
||||
MALLOC_SMALL (empty) 4096K 32K 32K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
MALLOC_TINY 4096K 208K 208K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
Memory Tag 22 64.0M 16K 16K 0K 0K 0K 0K 1
|
||||
STACK GUARD 1424K 0K 0K 0K 0K 0K 0K 89
|
||||
Stack 154.2M 1760K 1760K 0K 0K 0K 0K 90
|
||||
Stack Guard 56.0M 0K 0K 0K 0K 0K 0K 1
|
||||
VM_ALLOCATE 1088K 0K 0K 0K 0K 0K 0K 68
|
||||
VM_ALLOCATE (reserved) 8576K 0K 0K 0K 0K 0K 0K 67 reserved VM address space (unallocated)
|
||||
__AUTH 1321K 925K 0K 0K 0K 0K 0K 149
|
||||
__AUTH_CONST 17.7M 11.5M 0K 0K 0K 0K 0K 340
|
||||
__CTF 824 824 0K 0K 0K 0K 0K 1
|
||||
__DATA 4453K 2379K 346K 0K 0K 0K 0K 297
|
||||
__DATA_CONST 17.6M 12.3M 1344K 0K 0K 0K 0K 339
|
||||
__DATA_DIRTY 1320K 1056K 351K 0K 0K 0K 0K 284
|
||||
__FONT_DATA 2352 2352 0K 0K 0K 0K 0K 1
|
||||
__LINKEDIT 573.7M 32.9M 0K 0K 0K 0K 0K 2
|
||||
__OBJC_RO 79.2M 59.8M 0K 0K 0K 0K 0K 1
|
||||
__OBJC_RW 2599K 2375K 39K 0K 0K 0K 0K 1
|
||||
__TEXT 324.7M 178.3M 0K 0K 0K 0K 0K 348
|
||||
__TPRO_CONST 128K 112K 48K 0K 0K 0K 0K 2
|
||||
page table in kernel 1474K 1474K 1474K 0K 0K 0K 0K 1
|
||||
shared memory 48K 48K 48K 0K 0K 0K 0K 2
|
||||
unused but dirty shlib __DATA 79K 79K 79K 0K 0K 0K 0K 39
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
TOTAL 4.3G 322.2M 22.8M 0K 0K 0K 0K 2223
|
||||
TOTAL, minus reserved VM space 3.4G 322.2M 22.8M 0K 0K 0K 0K 2223
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED ALLOCATION BYTES DIRTY+SWAP REGION
|
||||
MALLOC ZONE SIZE SIZE SIZE SIZE COUNT ALLOCATED FRAG SIZE % FRAG COUNT
|
||||
=========== ======= ========= ========= ========= ========= ========= ========= ====== ======
|
||||
DefaultMallocZone_0x10345c000 40.8M 3312K 3312K 0K 5443 3008K 304K 10% 12
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
Process: igneumd [55063]
|
||||
Path: /Users/USER/*/igneumd
|
||||
Load Address: 0x100a4c000
|
||||
Identifier: igneumd
|
||||
Version: 0
|
||||
Code Type: ARM64
|
||||
Platform: macOS
|
||||
Parent Process: node [53092]
|
||||
Target Type: live task
|
||||
|
||||
Date/Time: 2026-10-05 02:37:18.131 <local>
|
||||
Launch Time: 2026-10-05 02:19:55.547 <local>
|
||||
OS Version: macOS 26.6.2 (25G83)
|
||||
Report Version: 7
|
||||
Analysis Tool: /usr/bin/vmmap
|
||||
|
||||
Physical footprint: 569.6M
|
||||
Physical footprint (peak): 569.6M
|
||||
Idle exit: untracked
|
||||
----
|
||||
|
||||
ReadOnly portion of Libraries: Total=898.4M resident=212.5M(24%) swapped_out_or_unallocated=685.8M(76%)
|
||||
Writable regions: Total=5.8G written=567.6M(10%) resident=567.6M(10%) swapped_out=0K(0%) unallocated=5.2G(90%)
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED VOLATILE NONVOL EMPTY REGION
|
||||
REGION TYPE SIZE SIZE SIZE SIZE SIZE SIZE SIZE COUNT (non-coalesced)
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
IOAccelerator 2.6G 548.1M 548.1M 0K 0K 0K 0K 87
|
||||
IOAccelerator (reserved) 2.9G 0K 0K 0K 0K 0K 0K 2 reserved VM address space (unallocated)
|
||||
Kernel Alloc Once 32K 16K 16K 0K 0K 0K 0K 1
|
||||
MALLOC guard page 3632K 0K 0K 0K 0K 0K 0K 4
|
||||
MALLOC metadata 880K 544K 544K 0K 0K 0K 0K 4
|
||||
MALLOC_SMALL 44.0M 14.7M 14.7M 0K 0K 0K 0K 11 see MALLOC ZONE table below
|
||||
MALLOC_SMALL (empty) 8192K 64K 64K 0K 0K 0K 0K 2 see MALLOC ZONE table below
|
||||
MALLOC_TINY 4096K 208K 208K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
Memory Tag 22 64.0M 16K 16K 0K 0K 0K 0K 1
|
||||
STACK GUARD 1424K 0K 0K 0K 0K 0K 0K 89
|
||||
Stack 154.2M 2128K 2128K 0K 0K 0K 0K 90
|
||||
Stack Guard 56.0M 0K 0K 0K 0K 0K 0K 1
|
||||
VM_ALLOCATE 1088K 0K 0K 0K 0K 0K 0K 68
|
||||
VM_ALLOCATE (reserved) 8576K 0K 0K 0K 0K 0K 0K 67 reserved VM address space (unallocated)
|
||||
__AUTH 1321K 918K 0K 0K 0K 0K 0K 149
|
||||
__AUTH_CONST 17.7M 11.5M 0K 0K 0K 0K 0K 340
|
||||
__CTF 824 824 0K 0K 0K 0K 0K 1
|
||||
__DATA 4453K 2379K 346K 0K 0K 0K 0K 297
|
||||
__DATA_CONST 17.6M 12.3M 1344K 0K 0K 0K 0K 339
|
||||
__DATA_DIRTY 1320K 1040K 351K 0K 0K 0K 0K 284
|
||||
__FONT_DATA 2352 2352 0K 0K 0K 0K 0K 1
|
||||
__LINKEDIT 573.7M 32.9M 0K 0K 0K 0K 0K 2
|
||||
__OBJC_RO 79.2M 59.9M 0K 0K 0K 0K 0K 1
|
||||
__OBJC_RW 2599K 2375K 39K 0K 0K 0K 0K 1
|
||||
__TEXT 324.7M 179.6M 0K 0K 0K 0K 0K 348
|
||||
__TPRO_CONST 128K 96K 48K 0K 0K 0K 0K 2
|
||||
page table in kernel 1779K 1779K 1779K 0K 0K 0K 0K 1
|
||||
shared memory 48K 48K 48K 0K 0K 0K 0K 2
|
||||
unused but dirty shlib __DATA 79K 79K 79K 0K 0K 0K 0K 39
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
TOTAL 6.8G 870.4M 569.6M 0K 0K 0K 0K 2235
|
||||
TOTAL, minus reserved VM space 4.0G 870.4M 569.6M 0K 0K 0K 0K 2235
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED ALLOCATION BYTES DIRTY+SWAP REGION
|
||||
MALLOC ZONE SIZE SIZE SIZE SIZE COUNT ALLOCATED FRAG SIZE % FRAG COUNT
|
||||
=========== ======= ========= ========= ========= ========= ========= ========= ====== ======
|
||||
DefaultMallocZone_0x10345c000 56.8M 15.5M 15.5M 0K 5469 14.9M 523K 4% 16
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
Process: igneumd [55063]
|
||||
Path: /Users/USER/*/igneumd
|
||||
Load Address: 0x100a4c000
|
||||
Identifier: igneumd
|
||||
Version: 0
|
||||
Code Type: ARM64
|
||||
Platform: macOS
|
||||
Parent Process: node [53092]
|
||||
Target Type: live task
|
||||
|
||||
Date/Time: 2026-10-05 02:45:21.762 <local>
|
||||
Launch Time: 2026-10-05 02:19:55.547 <local>
|
||||
OS Version: macOS 26.6.2 (25G83)
|
||||
Report Version: 7
|
||||
Analysis Tool: /usr/bin/vmmap
|
||||
|
||||
Physical footprint: 584.0M
|
||||
Physical footprint (peak): 584.0M
|
||||
Idle exit: untracked
|
||||
----
|
||||
|
||||
ReadOnly portion of Libraries: Total=898.4M resident=212.6M(24%) swapped_out_or_unallocated=685.8M(76%)
|
||||
Writable regions: Total=5.8G written=582.0M(10%) resident=582.0M(10%) swapped_out=0K(0%) unallocated=5.2G(90%)
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED VOLATILE NONVOL EMPTY REGION
|
||||
REGION TYPE SIZE SIZE SIZE SIZE SIZE SIZE SIZE COUNT (non-coalesced)
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
IOAccelerator 2.6G 555.7M 555.7M 0K 0K 0K 0K 87
|
||||
IOAccelerator (reserved) 2.9G 0K 0K 0K 0K 0K 0K 2 reserved VM address space (unallocated)
|
||||
Kernel Alloc Once 32K 16K 16K 0K 0K 0K 0K 1
|
||||
MALLOC guard page 3632K 0K 0K 0K 0K 0K 0K 4
|
||||
MALLOC metadata 880K 592K 592K 0K 0K 0K 0K 4
|
||||
MALLOC_SMALL 56.0M 21.5M 21.5M 0K 0K 0K 0K 14 see MALLOC ZONE table below
|
||||
MALLOC_SMALL (empty) 8192K 48K 48K 0K 0K 0K 0K 2 see MALLOC ZONE table below
|
||||
MALLOC_TINY 4096K 208K 208K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
Memory Tag 22 64.0M 16K 16K 0K 0K 0K 0K 1
|
||||
STACK GUARD 1424K 0K 0K 0K 0K 0K 0K 89
|
||||
Stack 154.2M 2128K 2128K 0K 0K 0K 0K 90
|
||||
Stack Guard 56.0M 0K 0K 0K 0K 0K 0K 1
|
||||
VM_ALLOCATE 1088K 0K 0K 0K 0K 0K 0K 68
|
||||
VM_ALLOCATE (reserved) 8576K 0K 0K 0K 0K 0K 0K 67 reserved VM address space (unallocated)
|
||||
__AUTH 1321K 925K 0K 0K 0K 0K 0K 149
|
||||
__AUTH_CONST 17.7M 11.5M 0K 0K 0K 0K 0K 340
|
||||
__CTF 824 824 0K 0K 0K 0K 0K 1
|
||||
__DATA 4453K 2379K 346K 0K 0K 0K 0K 297
|
||||
__DATA_CONST 17.6M 12.3M 1344K 0K 0K 0K 0K 339
|
||||
__DATA_DIRTY 1320K 1056K 351K 0K 0K 0K 0K 284
|
||||
__FONT_DATA 2352 2352 0K 0K 0K 0K 0K 1
|
||||
__LINKEDIT 573.7M 32.9M 0K 0K 0K 0K 0K 2
|
||||
__OBJC_RO 79.2M 59.9M 0K 0K 0K 0K 0K 1
|
||||
__OBJC_RW 2599K 2375K 39K 0K 0K 0K 0K 1
|
||||
__TEXT 324.7M 179.6M 0K 0K 0K 0K 0K 348
|
||||
__TPRO_CONST 128K 112K 48K 0K 0K 0K 0K 2
|
||||
page table in kernel 1779K 1779K 1779K 0K 0K 0K 0K 1
|
||||
shared memory 48K 48K 48K 0K 0K 0K 0K 2
|
||||
unused but dirty shlib __DATA 79K 79K 79K 0K 0K 0K 0K 39
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
TOTAL 6.9G 884.9M 584.0M 0K 0K 0K 0K 2238
|
||||
TOTAL, minus reserved VM space 4.0G 884.9M 584.0M 0K 0K 0K 0K 2238
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED ALLOCATION BYTES DIRTY+SWAP REGION
|
||||
MALLOC ZONE SIZE SIZE SIZE SIZE COUNT ALLOCATED FRAG SIZE % FRAG COUNT
|
||||
=========== ======= ========= ========= ========= ========= ========= ========= ====== ======
|
||||
DefaultMallocZone_0x10345c000 68.8M 22.3M 22.3M 0K 5530 22.0M 269K 2% 19
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
Process: igneumd [55063]
|
||||
Path: /Users/USER/*/igneumd
|
||||
Load Address: 0x100a4c000
|
||||
Identifier: igneumd
|
||||
Version: 0
|
||||
Code Type: ARM64
|
||||
Platform: macOS
|
||||
Parent Process: node [53092]
|
||||
Target Type: live task
|
||||
|
||||
Date/Time: 2026-10-05 02:28:14.170 <local>
|
||||
Launch Time: 2026-10-05 02:19:55.547 <local>
|
||||
OS Version: macOS 26.6.2 (25G83)
|
||||
Report Version: 7
|
||||
Analysis Tool: /usr/bin/vmmap
|
||||
|
||||
Physical footprint: 299.2M
|
||||
Physical footprint (peak): 299.2M
|
||||
Idle exit: untracked
|
||||
----
|
||||
|
||||
ReadOnly portion of Libraries: Total=898.4M resident=212.3M(24%) swapped_out_or_unallocated=686.1M(76%)
|
||||
Writable regions: Total=4.5G written=297.2M(6%) resident=297.2M(6%) swapped_out=0K(0%) unallocated=4.2G(94%)
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED VOLATILE NONVOL EMPTY REGION
|
||||
REGION TYPE SIZE SIZE SIZE SIZE SIZE SIZE SIZE COUNT (non-coalesced)
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
IOAccelerator 2.4G 284.5M 284.5M 0K 0K 0K 0K 84
|
||||
IOAccelerator (reserved) 1.9G 0K 0K 0K 0K 0K 0K 1 reserved VM address space (unallocated)
|
||||
Kernel Alloc Once 32K 16K 16K 0K 0K 0K 0K 1
|
||||
MALLOC guard page 3632K 0K 0K 0K 0K 0K 0K 4
|
||||
MALLOC metadata 880K 496K 496K 0K 0K 0K 0K 4
|
||||
MALLOC_SMALL 36.0M 8320K 8320K 0K 0K 0K 0K 9 see MALLOC ZONE table below
|
||||
MALLOC_SMALL (empty) 4096K 32K 32K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
MALLOC_TINY 4096K 208K 208K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
Memory Tag 22 64.0M 16K 16K 0K 0K 0K 0K 1
|
||||
STACK GUARD 1424K 0K 0K 0K 0K 0K 0K 89
|
||||
Stack 154.2M 2128K 2128K 0K 0K 0K 0K 90
|
||||
Stack Guard 56.0M 0K 0K 0K 0K 0K 0K 1
|
||||
VM_ALLOCATE 1088K 0K 0K 0K 0K 0K 0K 68
|
||||
VM_ALLOCATE (reserved) 8576K 0K 0K 0K 0K 0K 0K 67 reserved VM address space (unallocated)
|
||||
__AUTH 1321K 925K 0K 0K 0K 0K 0K 149
|
||||
__AUTH_CONST 17.7M 11.5M 0K 0K 0K 0K 0K 340
|
||||
__CTF 824 824 0K 0K 0K 0K 0K 1
|
||||
__DATA 4453K 2379K 346K 0K 0K 0K 0K 297
|
||||
__DATA_CONST 17.6M 12.3M 1344K 0K 0K 0K 0K 339
|
||||
__DATA_DIRTY 1320K 1072K 351K 0K 0K 0K 0K 284
|
||||
__FONT_DATA 2352 2352 0K 0K 0K 0K 0K 1
|
||||
__LINKEDIT 573.7M 32.9M 0K 0K 0K 0K 0K 2
|
||||
__OBJC_RO 79.2M 59.8M 0K 0K 0K 0K 0K 1
|
||||
__OBJC_RW 2599K 2375K 39K 0K 0K 0K 0K 1
|
||||
__TEXT 324.7M 179.4M 0K 0K 0K 0K 0K 348
|
||||
__TPRO_CONST 128K 112K 48K 0K 0K 0K 0K 2
|
||||
page table in kernel 1634K 1634K 1634K 0K 0K 0K 0K 1
|
||||
shared memory 48K 48K 48K 0K 0K 0K 0K 2
|
||||
unused but dirty shlib __DATA 79K 79K 79K 0K 0K 0K 0K 39
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
TOTAL 5.6G 599.7M 299.2M 0K 0K 0K 0K 2228
|
||||
TOTAL, minus reserved VM space 3.7G 599.7M 299.2M 0K 0K 0K 0K 2228
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED ALLOCATION BYTES DIRTY+SWAP REGION
|
||||
MALLOC ZONE SIZE SIZE SIZE SIZE COUNT ALLOCATED FRAG SIZE % FRAG COUNT
|
||||
=========== ======= ========= ========= ========= ========= ========= ========= ====== ======
|
||||
DefaultMallocZone_0x10345c000 44.8M 9024K 9024K 0K 5463 9157K 0K 0% 13
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
Process: igneumd [55718]
|
||||
Path: /Users/USER/*/igneumd
|
||||
Load Address: 0x102804000
|
||||
Identifier: igneumd
|
||||
Version: 0
|
||||
Code Type: ARM64
|
||||
Platform: macOS
|
||||
Parent Process: node [53051]
|
||||
Target Type: live task
|
||||
|
||||
Date/Time: 2026-10-05 02:22:42.382 <local>
|
||||
Launch Time: 2026-10-05 02:22:34.566 <local>
|
||||
OS Version: macOS 26.6.2 (25G83)
|
||||
Report Version: 7
|
||||
Analysis Tool: /usr/bin/vmmap
|
||||
|
||||
Physical footprint: 22.5M
|
||||
Physical footprint (peak): 22.5M
|
||||
Idle exit: untracked
|
||||
----
|
||||
|
||||
ReadOnly portion of Libraries: Total=898.4M resident=210.9M(23%) swapped_out_or_unallocated=687.4M(77%)
|
||||
Writable regions: Total=4.3G written=20.5M(0%) resident=20.5M(0%) swapped_out=0K(0%) unallocated=4.2G(100%)
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED VOLATILE NONVOL EMPTY REGION
|
||||
REGION TYPE SIZE SIZE SIZE SIZE SIZE SIZE SIZE COUNT (non-coalesced)
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
IOAccelerator 2.3G 14.0M 14.0M 0K 0K 0K 0K 71
|
||||
IOAccelerator (reserved) 1.8G 0K 0K 0K 0K 0K 0K 2 reserved VM address space (unallocated)
|
||||
Kernel Alloc Once 32K 16K 16K 0K 0K 0K 0K 1
|
||||
MALLOC guard page 3984K 0K 0K 0K 0K 0K 0K 4
|
||||
MALLOC metadata 880K 480K 480K 0K 0K 0K 0K 4
|
||||
MALLOC_SMALL 32.0M 2560K 2560K 0K 0K 0K 0K 8 see MALLOC ZONE table below
|
||||
MALLOC_SMALL (empty) 4096K 32K 32K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
MALLOC_TINY 4096K 192K 192K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
Memory Tag 22 64.0M 16K 16K 0K 0K 0K 0K 1
|
||||
STACK GUARD 1424K 0K 0K 0K 0K 0K 0K 89
|
||||
Stack 154.2M 1728K 1728K 0K 0K 0K 0K 90
|
||||
Stack Guard 56.0M 0K 0K 0K 0K 0K 0K 1
|
||||
VM_ALLOCATE 1088K 0K 0K 0K 0K 0K 0K 68
|
||||
VM_ALLOCATE (reserved) 8576K 0K 0K 0K 0K 0K 0K 67 reserved VM address space (unallocated)
|
||||
__AUTH 1321K 925K 0K 0K 0K 0K 0K 149
|
||||
__AUTH_CONST 17.7M 11.5M 0K 0K 0K 0K 0K 340
|
||||
__CTF 824 824 0K 0K 0K 0K 0K 1
|
||||
__DATA 4453K 2379K 346K 0K 0K 0K 0K 297
|
||||
__DATA_CONST 17.6M 12.3M 1344K 0K 0K 0K 0K 339
|
||||
__DATA_DIRTY 1320K 1072K 351K 0K 0K 0K 0K 284
|
||||
__FONT_DATA 2352 2352 0K 0K 0K 0K 0K 1
|
||||
__LINKEDIT 573.7M 32.9M 0K 0K 0K 0K 0K 2
|
||||
__OBJC_RO 79.2M 59.8M 0K 0K 0K 0K 0K 1
|
||||
__OBJC_RW 2599K 2375K 39K 0K 0K 0K 0K 1
|
||||
__TEXT 324.7M 178.0M 0K 0K 0K 0K 0K 348
|
||||
__TPRO_CONST 128K 112K 48K 0K 0K 0K 0K 2
|
||||
page table in kernel 1474K 1474K 1474K 0K 0K 0K 0K 1
|
||||
shared memory 48K 48K 48K 0K 0K 0K 0K 2
|
||||
unused but dirty shlib __DATA 79K 79K 79K 0K 0K 0K 0K 39
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
TOTAL 5.3G 321.7M 22.6M 0K 0K 0K 0K 2215
|
||||
TOTAL, minus reserved VM space 3.6G 321.7M 22.6M 0K 0K 0K 0K 2215
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED ALLOCATION BYTES DIRTY+SWAP REGION
|
||||
MALLOC ZONE SIZE SIZE SIZE SIZE COUNT ALLOCATED FRAG SIZE % FRAG COUNT
|
||||
=========== ======= ========= ========= ========= ========= ========= ========= ====== ======
|
||||
DefaultMallocZone_0x1053e0000 40.8M 3232K 3232K 0K 5445 3009K 223K 7% 12
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
Process: igneumd [55718]
|
||||
Path: /Users/USER/*/igneumd
|
||||
Load Address: 0x102804000
|
||||
Identifier: igneumd
|
||||
Version: 0
|
||||
Code Type: ARM64
|
||||
Platform: macOS
|
||||
Parent Process: node [53051]
|
||||
Target Type: live task
|
||||
|
||||
Date/Time: 2026-10-05 02:38:53.727 <local>
|
||||
Launch Time: 2026-10-05 02:22:34.566 <local>
|
||||
OS Version: macOS 26.6.2 (25G83)
|
||||
Report Version: 7
|
||||
Analysis Tool: /usr/bin/vmmap
|
||||
|
||||
Physical footprint: 1.3G
|
||||
Physical footprint (peak): 1.3G
|
||||
Idle exit: untracked
|
||||
----
|
||||
|
||||
ReadOnly portion of Libraries: Total=898.4M resident=212.3M(24%) swapped_out_or_unallocated=686.0M(76%)
|
||||
Writable regions: Total=10.3G written=1.3G(13%) resident=1.3G(13%) swapped_out=0K(0%) unallocated=9.0G(87%)
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED VOLATILE NONVOL EMPTY REGION
|
||||
REGION TYPE SIZE SIZE SIZE SIZE SIZE SIZE SIZE COUNT (non-coalesced)
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
IOAccelerator 3.8G 1.3G 1.3G 0K 0K 0K 0K 83
|
||||
IOAccelerator (reserved) 6.2G 0K 0K 0K 0K 0K 0K 6 reserved VM address space (unallocated)
|
||||
Kernel Alloc Once 32K 16K 16K 0K 0K 0K 0K 1
|
||||
MALLOC guard page 3984K 0K 0K 0K 0K 0K 0K 4
|
||||
MALLOC metadata 880K 560K 560K 0K 0K 0K 0K 4
|
||||
MALLOC_SMALL 44.0M 14.3M 14.3M 0K 0K 0K 0K 11 see MALLOC ZONE table below
|
||||
MALLOC_SMALL (empty) 12.0M 96K 96K 0K 0K 0K 0K 3 see MALLOC ZONE table below
|
||||
MALLOC_TINY 4096K 192K 192K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
Memory Tag 22 64.0M 16K 16K 0K 0K 0K 0K 1
|
||||
STACK GUARD 1424K 0K 0K 0K 0K 0K 0K 89
|
||||
Stack 154.2M 2080K 2080K 0K 0K 0K 0K 90
|
||||
Stack Guard 56.0M 0K 0K 0K 0K 0K 0K 1
|
||||
VM_ALLOCATE 1088K 0K 0K 0K 0K 0K 0K 68
|
||||
VM_ALLOCATE (reserved) 8576K 0K 0K 0K 0K 0K 0K 67 reserved VM address space (unallocated)
|
||||
__AUTH 1321K 925K 0K 0K 0K 0K 0K 149
|
||||
__AUTH_CONST 17.7M 11.5M 0K 0K 0K 0K 0K 340
|
||||
__CTF 824 824 0K 0K 0K 0K 0K 1
|
||||
__DATA 4453K 2379K 346K 0K 0K 0K 0K 297
|
||||
__DATA_CONST 17.6M 12.3M 1344K 0K 0K 0K 0K 339
|
||||
__DATA_DIRTY 1320K 1056K 351K 0K 0K 0K 0K 284
|
||||
__FONT_DATA 2352 2352 0K 0K 0K 0K 0K 1
|
||||
__LINKEDIT 573.7M 32.9M 0K 0K 0K 0K 0K 2
|
||||
__OBJC_RO 79.2M 59.9M 0K 0K 0K 0K 0K 1
|
||||
__OBJC_RW 2599K 2375K 39K 0K 0K 0K 0K 1
|
||||
__TEXT 324.7M 179.4M 0K 0K 0K 0K 0K 348
|
||||
__TPRO_CONST 128K 112K 48K 0K 0K 0K 0K 2
|
||||
page table in kernel 2211K 2211K 2211K 0K 0K 0K 0K 1
|
||||
shared memory 48K 48K 48K 0K 0K 0K 0K 2
|
||||
unused but dirty shlib __DATA 79K 79K 79K 0K 0K 0K 0K 39
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
TOTAL 11.3G 1.6G 1.3G 0K 0K 0K 0K 2236
|
||||
TOTAL, minus reserved VM space 5.1G 1.6G 1.3G 0K 0K 0K 0K 2236
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED ALLOCATION BYTES DIRTY+SWAP REGION
|
||||
MALLOC ZONE SIZE SIZE SIZE SIZE COUNT ALLOCATED FRAG SIZE % FRAG COUNT
|
||||
=========== ======= ========= ========= ========= ========= ========= ========= ====== ======
|
||||
DefaultMallocZone_0x1053e0000 60.8M 15.1M 15.1M 0K 5467 14.9M 203K 2% 17
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
Process: igneumd [55718]
|
||||
Path: /Users/USER/*/igneumd
|
||||
Load Address: 0x102804000
|
||||
Identifier: igneumd
|
||||
Version: 0
|
||||
Code Type: ARM64
|
||||
Platform: macOS
|
||||
Parent Process: node [53051]
|
||||
Target Type: live task
|
||||
|
||||
Date/Time: 2026-10-05 02:47:59.295 <local>
|
||||
Launch Time: 2026-10-05 02:22:34.566 <local>
|
||||
OS Version: macOS 26.6.2 (25G83)
|
||||
Report Version: 7
|
||||
Analysis Tool: /usr/bin/vmmap
|
||||
|
||||
Physical footprint: 1.3G
|
||||
Physical footprint (peak): 1.3G
|
||||
Idle exit: untracked
|
||||
----
|
||||
|
||||
ReadOnly portion of Libraries: Total=898.4M resident=212.3M(24%) swapped_out_or_unallocated=686.0M(76%)
|
||||
Writable regions: Total=10.3G written=1.3G(13%) resident=1.3G(13%) swapped_out=0K(0%) unallocated=9.0G(87%)
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED VOLATILE NONVOL EMPTY REGION
|
||||
REGION TYPE SIZE SIZE SIZE SIZE SIZE SIZE SIZE COUNT (non-coalesced)
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
IOAccelerator 3.8G 1.3G 1.3G 0K 0K 0K 0K 83
|
||||
IOAccelerator (reserved) 6.2G 0K 0K 0K 0K 0K 0K 6 reserved VM address space (unallocated)
|
||||
Kernel Alloc Once 32K 16K 16K 0K 0K 0K 0K 1
|
||||
MALLOC guard page 3984K 0K 0K 0K 0K 0K 0K 4
|
||||
MALLOC metadata 880K 592K 592K 0K 0K 0K 0K 4
|
||||
MALLOC_SMALL 52.0M 21.5M 21.5M 0K 0K 0K 0K 13 see MALLOC ZONE table below
|
||||
MALLOC_SMALL (empty) 12.0M 96K 96K 0K 0K 0K 0K 3 see MALLOC ZONE table below
|
||||
MALLOC_TINY 4096K 192K 192K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
Memory Tag 22 64.0M 16K 16K 0K 0K 0K 0K 1
|
||||
STACK GUARD 1424K 0K 0K 0K 0K 0K 0K 89
|
||||
Stack 154.2M 2144K 2144K 0K 0K 0K 0K 90
|
||||
Stack Guard 56.0M 0K 0K 0K 0K 0K 0K 1
|
||||
VM_ALLOCATE 1088K 0K 0K 0K 0K 0K 0K 68
|
||||
VM_ALLOCATE (reserved) 8576K 0K 0K 0K 0K 0K 0K 67 reserved VM address space (unallocated)
|
||||
__AUTH 1321K 925K 0K 0K 0K 0K 0K 149
|
||||
__AUTH_CONST 17.7M 11.5M 0K 0K 0K 0K 0K 340
|
||||
__CTF 824 824 0K 0K 0K 0K 0K 1
|
||||
__DATA 4453K 2379K 346K 0K 0K 0K 0K 297
|
||||
__DATA_CONST 17.6M 12.3M 1344K 0K 0K 0K 0K 339
|
||||
__DATA_DIRTY 1320K 1056K 351K 0K 0K 0K 0K 284
|
||||
__FONT_DATA 2352 2352 0K 0K 0K 0K 0K 1
|
||||
__LINKEDIT 573.7M 32.9M 0K 0K 0K 0K 0K 2
|
||||
__OBJC_RO 79.2M 59.9M 0K 0K 0K 0K 0K 1
|
||||
__OBJC_RW 2599K 2375K 39K 0K 0K 0K 0K 1
|
||||
__TEXT 324.7M 179.4M 0K 0K 0K 0K 0K 348
|
||||
__TPRO_CONST 128K 112K 48K 0K 0K 0K 0K 2
|
||||
page table in kernel 2211K 2211K 2211K 0K 0K 0K 0K 1
|
||||
shared memory 48K 48K 48K 0K 0K 0K 0K 2
|
||||
unused but dirty shlib __DATA 79K 79K 79K 0K 0K 0K 0K 39
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
TOTAL 11.4G 1.6G 1.3G 0K 0K 0K 0K 2238
|
||||
TOTAL, minus reserved VM space 5.1G 1.6G 1.3G 0K 0K 0K 0K 2238
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED ALLOCATION BYTES DIRTY+SWAP REGION
|
||||
MALLOC ZONE SIZE SIZE SIZE SIZE COUNT ALLOCATED FRAG SIZE % FRAG COUNT
|
||||
=========== ======= ========= ========= ========= ========= ========= ========= ====== ======
|
||||
DefaultMallocZone_0x1053e0000 68.8M 22.4M 22.4M 0K 5528 22.0M 333K 2% 19
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
Process: igneumd [55718]
|
||||
Path: /Users/USER/*/igneumd
|
||||
Load Address: 0x102804000
|
||||
Identifier: igneumd
|
||||
Version: 0
|
||||
Code Type: ARM64
|
||||
Platform: macOS
|
||||
Parent Process: node [53051]
|
||||
Target Type: live task
|
||||
|
||||
Date/Time: 2026-10-05 02:30:49.931 <local>
|
||||
Launch Time: 2026-10-05 02:22:34.566 <local>
|
||||
OS Version: macOS 26.6.2 (25G83)
|
||||
Report Version: 7
|
||||
Analysis Tool: /usr/bin/vmmap
|
||||
|
||||
Physical footprint: 1.3G
|
||||
Physical footprint (peak): 1.3G
|
||||
Idle exit: untracked
|
||||
----
|
||||
|
||||
ReadOnly portion of Libraries: Total=898.4M resident=212.1M(24%) swapped_out_or_unallocated=686.3M(76%)
|
||||
Writable regions: Total=10.3G written=1.3G(13%) resident=1.3G(13%) swapped_out=0K(0%) unallocated=9.0G(87%)
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED VOLATILE NONVOL EMPTY REGION
|
||||
REGION TYPE SIZE SIZE SIZE SIZE SIZE SIZE SIZE COUNT (non-coalesced)
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
IOAccelerator 3.8G 1.3G 1.3G 0K 0K 0K 0K 83
|
||||
IOAccelerator (reserved) 6.2G 0K 0K 0K 0K 0K 0K 6 reserved VM address space (unallocated)
|
||||
Kernel Alloc Once 32K 16K 16K 0K 0K 0K 0K 1
|
||||
MALLOC guard page 3984K 0K 0K 0K 0K 0K 0K 4
|
||||
MALLOC metadata 880K 528K 528K 0K 0K 0K 0K 4
|
||||
MALLOC_SMALL 36.0M 8304K 8304K 0K 0K 0K 0K 9 see MALLOC ZONE table below
|
||||
MALLOC_SMALL (empty) 12.0M 80K 80K 0K 0K 0K 0K 3 see MALLOC ZONE table below
|
||||
MALLOC_TINY 4096K 192K 192K 0K 0K 0K 0K 1 see MALLOC ZONE table below
|
||||
Memory Tag 22 64.0M 16K 16K 0K 0K 0K 0K 1
|
||||
STACK GUARD 1424K 0K 0K 0K 0K 0K 0K 89
|
||||
Stack 154.2M 2048K 2048K 0K 0K 0K 0K 90
|
||||
Stack Guard 56.0M 0K 0K 0K 0K 0K 0K 1
|
||||
VM_ALLOCATE 1088K 0K 0K 0K 0K 0K 0K 68
|
||||
VM_ALLOCATE (reserved) 8576K 0K 0K 0K 0K 0K 0K 67 reserved VM address space (unallocated)
|
||||
__AUTH 1321K 925K 0K 0K 0K 0K 0K 149
|
||||
__AUTH_CONST 17.7M 11.5M 0K 0K 0K 0K 0K 340
|
||||
__CTF 824 824 0K 0K 0K 0K 0K 1
|
||||
__DATA 4453K 2379K 346K 0K 0K 0K 0K 297
|
||||
__DATA_CONST 17.6M 12.3M 1344K 0K 0K 0K 0K 339
|
||||
__DATA_DIRTY 1320K 1072K 351K 0K 0K 0K 0K 284
|
||||
__FONT_DATA 2352 2352 0K 0K 0K 0K 0K 1
|
||||
__LINKEDIT 573.7M 32.9M 0K 0K 0K 0K 0K 2
|
||||
__OBJC_RO 79.2M 59.8M 0K 0K 0K 0K 0K 1
|
||||
__OBJC_RW 2599K 2375K 39K 0K 0K 0K 0K 1
|
||||
__TEXT 324.7M 179.2M 0K 0K 0K 0K 0K 348
|
||||
__TPRO_CONST 128K 112K 48K 0K 0K 0K 0K 2
|
||||
page table in kernel 2211K 2211K 2211K 0K 0K 0K 0K 1
|
||||
shared memory 48K 48K 48K 0K 0K 0K 0K 2
|
||||
unused but dirty shlib __DATA 79K 79K 79K 0K 0K 0K 0K 39
|
||||
=========== ======= ======== ===== ======= ======== ====== ===== =======
|
||||
TOTAL 11.3G 1.6G 1.3G 0K 0K 0K 0K 2234
|
||||
TOTAL, minus reserved VM space 5.1G 1.6G 1.3G 0K 0K 0K 0K 2234
|
||||
|
||||
VIRTUAL RESIDENT DIRTY SWAPPED ALLOCATION BYTES DIRTY+SWAP REGION
|
||||
MALLOC ZONE SIZE SIZE SIZE SIZE COUNT ALLOCATED FRAG SIZE % FRAG COUNT
|
||||
=========== ======= ========= ========= ========= ========= ========= ========= ====== ======
|
||||
DefaultMallocZone_0x1053e0000 52.8M 9072K 9072K 0K 5461 9157K 0K 0% 15
|
||||
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
{
|
||||
"scenario": "F23 (equivocation ban node-local; honest nodes refuse each other's certs)",
|
||||
"expected": "ban expiry identical across honest nodes; 0 voter-count refusals; 0 CONFLICTING; 0 disagreeing locked indices",
|
||||
"observed": "equiv detections 14/7/7; stripped-until per node - | - | - (distinct values 0); voter-count-mismatch refusals 0/0/0; CONFLICTING 0/0/0; disagreeing locked indices 0; maxLocked 61/61/61",
|
||||
"pass": true
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
{
|
||||
"scenario": "F24b (deep reorg under merge depth; determination never revisited)",
|
||||
"expected": "after a reorg deeper than checkpoint_depth the losing node re-determines the moved indices and accepts the network certificates; 0 false CONFLICTING, 0 stuck indices, 0 disagreeing locks",
|
||||
"observed": "n1 determined 32..33 during the 24s cut; after heal: cert-for-other-block refusals 0/26, CONFLICTING 7/3, equivocation 0/0, PoW-rejected 1668/2025, sinks equal false, disagreeing locked indices 9, n1 indices stuck unlocked that n0 locked [33], maxLocked 54/43",
|
||||
"pass": false
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
{
|
||||
"scenario": "F24c (3/3 split, 16 s cut under merge depth; determination never revisited)",
|
||||
"expected": "after a reorg deeper than checkpoint_depth the losing node re-determines the moved indices and accepts the network certificates; 0 false CONFLICTING, 0 stuck indices, 0 disagreeing locks",
|
||||
"observed": "n1 determined 31..32 during the 16s cut; after heal: cert-for-other-block refusals 0/0, CONFLICTING 0/0, equivocation 0/0, PoW-rejected 1935/1930, sinks equal false, disagreeing locked indices 0, n1 indices stuck unlocked that n0 locked [], maxLocked 61/61",
|
||||
"pass": true
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
|
||||
### digest-old: n1 on the shared fast-time override, n0 with finality.weight_window 121 (one DAA second more), n2 on the shared override
|
||||
|
||||
| measure | n0 (mismatched) | n1 (listener) | n2 (matching) |
|
||||
|---|---|---|---|
|
||||
| params digest printed at start | none | none | none |
|
||||
| "consensus params digest mismatch" lines | 0 | 0 | 0 |
|
||||
| peers after 25 s (n0, n1) and after n2 dialled (n1) | 1 | 1 then 2 | connected after 1 s |
|
||||
|
||||
### ban-old: 480 s, 1 blocks/s in all, 6 voters, delay 100 ms, a0 equivocates once at index 9; P2 cut at 259 s, healed at 304 s; n0 detected the equivocation at 291 s
|
||||
|
||||
| measure | n0 (saw it over RPC) | n1 (from the block at once) | n2 (from the block, after the heal) |
|
||||
|---|---|---|---|
|
||||
| EQUIVOCATION lines (of which "carried by block") | 2 (0) | 1 (0) | 1 (0) |
|
||||
| certificates refused "names N voters, this node counts M" | 2 | 0 | 0 |
|
||||
| CONFLICTING certificate lines | 0 | 0 | 0 |
|
||||
| indices whose certificates name 5 voters (a0 stripped) | 10..13 (4) | 10..13 (4) | 10..13 (4) |
|
||||
| max locked index at the end | 14 | 14 | 14 |
|
||||
|
||||
indices with certificate lines on at least two nodes: voter counts agree at 9, differ at 0; locked indices disagreeing across the three nodes: 0
|
||||
|
||||
### reorg-old: warm 230 s, split 180 s (n0 alone with 30% of the weight), heal window 150 s, 1 blocks/s in all, delay 100 ms
|
||||
|
||||
| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) |
|
||||
|---|---|---|---|
|
||||
| max locked index at the cut | 7 | 7 | 7 |
|
||||
| max locked index at the heal | 7 | 12 | 12 |
|
||||
| max locked index at the end | 17 | 17 | 17 |
|
||||
| "re-determined" lines | 0 | 0 | 0 |
|
||||
| certificates kept pending | 0 | 0 | 0 |
|
||||
| CONFLICTING certificate lines | 0 | 0 | 0 |
|
||||
|
||||
n0 determined 2 checkpoint(s) on its own chain during the split (indices 8, 9); after the heal n0 holds the same locked block as n1 at 1 of them; locked indices disagreeing across the three nodes: 0; n0 reconnected 10 s after the gate reopened
|
||||
|
||||
[FAIL] digest-old
|
||||
[FAIL] ban-old
|
||||
[FAIL] reorg-old
|
||||
44
docs/benchmarks/round4-consensus-2026-10-04/results-final.md
Normal file
44
docs/benchmarks/round4-consensus-2026-10-04/results-final.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
|
||||
### digest-final: n1 on the shared fast-time override, n0 with finality.weight_window 121 (one DAA second more), n2 on the shared override
|
||||
|
||||
| measure | n0 (mismatched) | n1 (listener) | n2 (matching) |
|
||||
|---|---|---|---|
|
||||
| params digest printed at start | 4bf763ba5b78c6ac88463932f522679186cb641f631671eb25fc17b01071aea9 | 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852 | 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852 |
|
||||
| "consensus params digest mismatch" lines | 2 | 2 | 0 |
|
||||
| peers after 25 s (n0, n1) and after n2 dialled (n1) | 0 | 0 then 1 | connected after 1 s |
|
||||
|
||||
n0's line: `WARN ] P2P, got reject message: consensus params digest mismatch - local: 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852, remote: 4bf763ba5b78c6ac88463932f522679186cb641f631671eb25fc17b01071aea9: the peer's override file, environment or build differs from peer: 127.0.0.1:29411`
|
||||
|
||||
### ban-final: 480 s, 1 blocks/s in all, 6 voters, delay 100 ms, a0 equivocates once at index 9; P2 cut at 252 s, healed at 297 s; n0 detected the equivocation at 288 s
|
||||
|
||||
| measure | n0 (saw it over RPC) | n1 (from the block at once) | n2 (from the block, after the heal) |
|
||||
|---|---|---|---|
|
||||
| EQUIVOCATION lines (of which "carried by block") | 2 (1) | 1 (1) | 1 (1) |
|
||||
| certificates refused "names N voters, this node counts M" | 0 | 0 | 0 |
|
||||
| CONFLICTING certificate lines | 0 | 0 | 0 |
|
||||
| indices whose certificates name 5 voters (a0 stripped) | 10..12 (3) | 10..12 (3) | 10..12 (3) |
|
||||
| max locked index at the end | 15 | 15 | 15 |
|
||||
|
||||
indices with certificate lines on at least two nodes: voter counts agree at 10, differ at 0; locked indices disagreeing across the three nodes: 0
|
||||
|
||||
### reorg-final: warm 230 s, split 180 s (n0 alone with 30% of the weight), heal window 150 s, 1 blocks/s in all, delay 100 ms
|
||||
|
||||
| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) |
|
||||
|---|---|---|---|
|
||||
| max locked index at the cut | 7 | 7 | 7 |
|
||||
| max locked index at the heal | 7 | 11 | 11 |
|
||||
| max locked index at the end | 16 | 16 | 16 |
|
||||
| "re-determined" lines | 2 | 0 | 0 |
|
||||
| certificates kept pending | 2 | 0 | 0 |
|
||||
| CONFLICTING certificate lines | 0 | 0 | 0 |
|
||||
| certificates refused over another block, pre-F24 wording | 0 | 0 | 0 |
|
||||
| pending certificates verified at determination (did not verify) | 2 (0) | 0 (0) | 0 (0) |
|
||||
| indices n1 locked that this node did not lock | none | | |
|
||||
|
||||
n0 determined 2 checkpoint(s) on its own chain during the split (indices 8, 9); after the heal n0 holds the same locked block as n1 at 0 of them; locked indices disagreeing across the three nodes: 0; n0 reconnected 10 s after the gate reopened
|
||||
Finality: checkpoint 8 re-determined: block a806eb0744d0e12c09c1de08bd6afc2445cd4c27edff2f78d50ed296c33fbfb7 (blue score 240, daa 239), was 46cfb6b02eb5728cba01cd729d87463fb3bd4603abbbf13921b4067b3f1b5895: the selected chain moved past it
|
||||
Finality: checkpoint 9 re-determined: block 8c1b51dd691441543fcb76809c67058d1c161b432ad091d949e1879270225174 (blue score 263, daa 262), was 88138fd98a72ac2ec4a5778f97c3f0dd913a2dfc70335ff6fb487649e8ffc422: the selected chain moved past it
|
||||
|
||||
[PASS] digest-final
|
||||
[PASS] ban-final
|
||||
[FAIL] reorg-final
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
|
||||
### reorg-final2: warm 230 s, split 180 s (n0 alone with 30% of the weight), heal window 150 s, 1 blocks/s in all, delay 100 ms
|
||||
|
||||
| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) |
|
||||
|---|---|---|---|
|
||||
| max locked index at the cut | 8 | 8 | 8 |
|
||||
| max locked index at the heal | 8 | 8 | 8 |
|
||||
| max locked index at the end | 16 | 16 | 16 |
|
||||
| "re-determined" lines | 1 | 0 | 0 |
|
||||
| certificates kept pending | 1 | 0 | 0 |
|
||||
| CONFLICTING certificate lines | 0 | 0 | 0 |
|
||||
| certificates refused over another block, pre-F24 wording | 0 | 0 | 0 |
|
||||
| pending certificates verified at determination (did not verify) | 1 (0) | 0 (0) | 0 (0) |
|
||||
| indices n1 locked that this node did not lock | none | | |
|
||||
| records whose block is below the index's target blue score | none | | |
|
||||
|
||||
n0 determined 1 checkpoint(s) on its own chain during the split (indices 9); after the heal n0 holds the same locked block as n1 at 0 of them; locked indices disagreeing across the three nodes: 0; n0 reconnected 10 s after the gate reopened
|
||||
Finality: checkpoint 9 re-determined: block dd8f57d7a5bc1a80eab4d87704310396c614d7cdb5ea21595694b9453b21d1bf (blue score 270, daa 269), was c3379892186e5e96e679bdfb3ec6991a9cb41ebe953e84baf3f1cf0ba07388d6: the selected chain moved past it
|
||||
|
||||
[PASS] reorg-final2
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
|
||||
### digest-fud: n1 on the shared fast-time override, n0 with finality.weight_window 121 (one DAA second more), n2 on the shared override
|
||||
|
||||
| measure | n0 (mismatched) | n1 (listener) | n2 (matching) |
|
||||
|---|---|---|---|
|
||||
| params digest printed at start | 4bf763ba5b78c6ac88463932f522679186cb641f631671eb25fc17b01071aea9 | 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852 | 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852 |
|
||||
| "consensus params digest mismatch" lines | 1 | 2 | 0 |
|
||||
| peers after 25 s (n0, n1) and after n2 dialled (n1) | 0 | 0 then 1 | connected after 1 s |
|
||||
|
||||
n0's line: `WARN ] Refusing peer 127.0.0.1:29411: consensus params digest mismatch, local 4bf763ba5b78c6ac88463932f522679186cb641f631671eb25fc17b01071aea9 remote 7a40cc3b90c7726b9813113448510bd0597856baf9f0e6f245e3cf5cac494852 (the peer's override file, environment or build differs)`
|
||||
|
||||
[PASS] digest-fud
|
||||
|
||||
### ban-fud: 480 s, 1 blocks/s in all, 6 voters, delay 100 ms, a0 equivocates once at index 9; P2 cut at 259 s, healed at 304 s; n0 detected the equivocation at 301 s
|
||||
|
||||
| measure | n0 (saw it over RPC) | n1 (from the block at once) | n2 (from the block, after the heal) |
|
||||
|---|---|---|---|
|
||||
| EQUIVOCATION lines (of which "carried by block") | 2 (1) | 1 (1) | 1 (1) |
|
||||
| certificates refused "names N voters, this node counts M" | 0 | 0 | 0 |
|
||||
| CONFLICTING certificate lines | 0 | 0 | 0 |
|
||||
| indices whose certificates name 5 voters (a0 stripped) | 10..13 (4) | 10..13 (4) | 10..13 (4) |
|
||||
| max locked index at the end | 14 | 14 | 14 |
|
||||
|
||||
indices with certificate lines on at least two nodes: voter counts agree at 11, differ at 0; locked indices disagreeing across the three nodes: 0
|
||||
|
||||
### reorg-fud: warm 230 s, split 180 s (n0 alone with 30% of the weight), heal window 150 s, 1 blocks/s in all, delay 100 ms
|
||||
|
||||
| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) |
|
||||
|---|---|---|---|
|
||||
| max locked index at the cut | 7 | 7 | 7 |
|
||||
| max locked index at the heal | 7 | 11 | 11 |
|
||||
| max locked index at the end | 15 | 15 | 15 |
|
||||
| "re-determined" lines | 2 | 0 | 0 |
|
||||
| certificates kept pending | 4 | 0 | 0 |
|
||||
| CONFLICTING certificate lines | 0 | 0 | 0 |
|
||||
|
||||
n0 determined 1 checkpoint(s) on its own chain during the split (indices 8); after the heal n0 holds the same locked block as n1 at 0 of them; locked indices disagreeing across the three nodes: 0; n0 reconnected 40 s after the gate reopened
|
||||
Finality: checkpoint 8 re-determined: block ce42c8457e85d754f842851e685aa141f3f46fe6de4e5f707c8239cdef7f72e5 (blue score 240, daa 239), was c3cc4e6bbf573e2b24a763728f76783ca2cd41c62194a00a028f3b28cfb1ee12: the selected chain moved past it
|
||||
Finality: checkpoint 9 re-determined: block eed5a7f19d11600695f5027327fd053ecf911453955c97d906bb24601601b4fb (blue score 261, daa 260), was e2d7874fd1e059996eb2ee36aca1bbda09dcbb168ec8030315ca24df1a8601b0: the selected chain moved past it
|
||||
|
||||
[PASS] ban-fud
|
||||
[PASS] reorg-fud
|
||||
18
docs/benchmarks/round4-consensus-2026-10-04/results-old2.md
Normal file
18
docs/benchmarks/round4-consensus-2026-10-04/results-old2.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
|
||||
### reorg-old2: warm 230 s, split 180 s (n0 alone with 30% of the weight), heal window 150 s, 1 blocks/s in all, delay 100 ms
|
||||
|
||||
| measure | n0 (cut off, 30%) | n1 (70% side) | n2 (70% side) |
|
||||
|---|---|---|---|
|
||||
| max locked index at the cut | 7 | 7 | 7 |
|
||||
| max locked index at the heal | 7 | 11 | 11 |
|
||||
| max locked index at the end | 15 | 15 | 15 |
|
||||
| "re-determined" lines | 0 | 0 | 0 |
|
||||
| certificates kept pending | 0 | 0 | 0 |
|
||||
| CONFLICTING certificate lines | 0 | 0 | 0 |
|
||||
| certificates refused over another block, pre-F24 wording | 3 | 0 | 0 |
|
||||
| pending certificates verified at determination (did not verify) | 0 (0) | 0 (0) | 0 (0) |
|
||||
| indices n1 locked that this node did not lock | 8 9 | | |
|
||||
|
||||
n0 determined 1 checkpoint(s) on its own chain during the split (indices 8); after the heal n0 holds the same locked block as n1 at 0 of them; locked indices disagreeing across the three nodes: 0; n0 reconnected 40 s after the gate reopened
|
||||
|
||||
[FAIL] reorg-old2
|
||||
22
docs/bugs.md
22
docs/bugs.md
|
|
@ -7,16 +7,34 @@ shard run reported as exit 0, 7a7e873).
|
|||
| Date | Symptom | Cause | Fix | Proven by |
|
||||
|---|---|---|---|---|
|
||||
| 4 Oct 2026 | Every `ci` run on master red since 67bf226 (eleven pushes), unnoticed | `sim/difficulty/records/testnet-v2-2026-10-04.schedule.log` carried a home path; `.log` was outside the identity scrub's extension list in `tools/ci/identity-check.sh` (and in the mirror's `tools/sync.sh`) | 2996cca: `.log` scrubbed like the other text files; the record rewritten with `~`; the same list in igneum-public `tools/sync.sh` (local commit e18256d, not pushed) | `bash tools/ci/identity-check.sh` 0 hits locally; run 37226816xxx on master green |
|
||||
| 5 Oct 2026 | PC 1 (Windows 11 Pro 26200, default terminal Windows Terminal 1.24): "Windows Command Processor" windows whenever a remote job runs (the project lead) | measured, not guessed: `tools/windows/console-watch.ps1` (job run-20261005-182528) started every candidate child from the app's job runner, whose console is headless (`conhost.exe 0x4`, hwnd 0), with a user32 EnumWindows sampler every 30 ms: powershell, cmd, query, curl, nvidia-smi, wsl --status, a distro, interop cmd and powershell, `powershell -WindowStyle Hidden`, `Start-Process -WindowStyle Hidden`: 0 windows each; `Start-Process cmd` in a new console: a Terminal window and a cmd PseudoConsoleWindow (the known-failed case fires). The 25-minute background watcher (console-watch-bg.ps1, run-20261005-184330, 18:44 to 19:09 UTC, every 200 ms) across an app restart, a build job, two run jobs, two collect jobs and the sweep helper's elevated launch at 19:04:43: 0 console or Terminal windows, 69 conhost starts (every one `conhost.exe 0x4`, headless, under curl, wsl, wslhost, powershell), 1 cmd.exe (under wslhost, WSL interop, no window). The one road that creates a console of its own is the elevated launch (`Start-Process -Verb RunAs`, the AppInfo service: the power cap, the sweep helper, the clock sync, an elevated job); it carried `-WindowStyle Hidden` in four copies, and "Windows Command Processor" is also the name on the UAC prompt the engine raises for cmd.exe (the sweep helper prompted at 17:00, 17:30 and 18:12 UTC, the power cap at every start; the elevated watcher's own prompt, run-20261005-184610, timed out unanswered at 122 s) | `platform::elevated_ps_line` + `elevated_command`: one builder for every elevated launch, hidden by construction, exit 251 when the prompt is refused; the elevated job wrapper reports its own console (`elevated console: hwnd N visible False`) on every elevated job; `tools/ci/windows-spawn-check.mjs` fails CI on a Command::new without the quiet flag, a creation_flags other than CREATE_NO_WINDOW, a Start-Process without -WindowStyle Hidden/-NoNewWindow, or a host.cpp spawn without CREATE_NO_WINDOW / SW_HIDE | the watcher's known-failed case (2 windows) and known-finished case (0); the CI check's self-test (9 cases) and the tree (0 hits); the igneum-app test suite on PC 1 |
|
||||
| 4 Oct 2026 | `collect-pc1-board3` printed PowerShell parse errors (`.Name`, `.AdapterRAM`) | the publishing shell expanded `$_` inside double quotes to nothing before the command reached the jobs file; nothing to do with Format-List or Out-String (board2 and board4 printed their values) | publish-jobs.sh refuses a collect command that pipes into a script block without `$_` or `$PSItem` | the eaten form refused with the reason, the single-quoted form published to a test folder |
|
||||
| 4 Oct 2026 | the same job reported `done (exit 0)` over `command exit Some(1)` | `run_collect` in `app/igneum-app/src/jobrun.rs` builds `Done` from the upload count only; the command's exit code is logged and dropped | branch `bugfix-collect-exit`, 3c36ee4 (app engine; merge by the main session) | `cargo test --bin igneum-app collect_outcome`: the board3 shape (`Some(1)`) is failed exit 1, `Some(0)` done, cap timeout, failed uploads still fail |
|
||||
| 4 Oct 2026 | the same job reported `done (exit 0)` over `command exit Some(1)` | `run_collect` in `app/igneum-app/src/jobrun.rs` builds `Done` from the upload count only; the command's exit code is logged and dropped | branch `bugfix-collect-exit`, 35ccdc8 rebased on c257444 (app engine; merge by the main session) | `cargo test --bin igneum-app`: all 28 tests pass on the rebased branch; the new one covers the board3 shape (`Some(1)` is failed exit 1), `Some(0)` done, the cap as timeout, failed uploads still failing |
|
||||
| 4 Oct 2026 | `publish-jobs.sh --deploy` said "not reachable, differs from the local one, or does not verify yet" after a deploy that had succeeded | one check the instant the CLI returned, while the edge still served the previous file; the deploy's own exit status was hidden by `\|\| true` | `verify_live`: up to `--tries` (12) checks 5 s apart, each failure names its condition; `publish-jobs.sh verify` re-checks on its own; a failed deploy stops before the check | finished: `verify --tries 2` against the live file (try 1 of 2); failed: a local server with an older file ("differs", both publish stamps named) and a closed port ("is not reachable") |
|
||||
| 4 Oct 2026 | console Machines: PC 37ba0461 showed 0.0 MH/s and 0 accepted while its log held an accepted block at 1 MH/s | `parseLabel` in the console API knew nvidia, amd, mac, metal and opencl; the OpenCL fallback on an iGPU is labelled `other-<id8>-n` and the card was dropped | parsers moved to `relay/lib/parse.mjs`, vendors `other` and `intel` added, `node --test relay/test/parse.test.mjs` in CI | the test; the live console after the deploy shows the card |
|
||||
| 4 Oct 2026 | console Machines: a card said "117.2 MH/s now" while its "status" column said 4 m ago (PC 2 during shard run 3: the prover held the GPU and the worker's STATUS line stopped) | the card's hash came from the last STATUS line in the tail with no age check; the machine total summed it | `markStale`: a card whose STATUS line is older than 120 s is `stale`, shown as "last N MH/s" with a red "stale" mark, and left out of the machine total (API, page and `tools/console.mjs`) | the test (58 s fresh, 240 s stale, none stale); the live console after the deploy |
|
||||
| 4 Oct 2026 | `vercel env add` from `site/` fails with "Could not retrieve Project Settings" | `site/.vercel/project.json` links the [other-business] team's `igneum` project; the live site (igneum.network, igneum.com, the GitHub integration) is the `igneum` team's project of the same name, which the igneum login reads and the [other-business] link does not | documented in `packaging/README-ship.md` (link, env ls, env add, deploy); no env set | `env ls` from a scratch link to the igneum-team project lists the two names; a branch push produced `igneum-git-<branch>` |
|
||||
| 4 Oct 2026 | `tools/jobs.mjs` and `publish-jobs.sh` print the downloads-folder token inside URLs on every run (X24 pattern) | the tokened base URL is echoed as is | the token masked as `<token>` in every printed URL | by eye, this log's own transcript |
|
||||
| 4 Oct 2026 | round 4 X28 and X24, the parts under an hour: `===` on secrets, no HSTS on the relay, the relay token printed by `tools/relay.mjs list` and `watch` | as the review said | c1f59fb: `sameSecret` (timingSafeEqual, `relay/lib/auth.mjs`, test in CI), `Strict-Transport-Security` in `relay/vercel.json`, `/r/<token>` printed (only `url` prints the real one) | relay deployed: key auth 200, wrong key and token 401, token path 200, HSTS header present |
|
||||
| 4 Oct 2026 | the live feed showed two "checkpoint N locked" events 30 ms apart (1122, 1172, 1230, 1258, 1259 in 400 observer lines), and 708 of 764 locked checkpoints in `live_checkpoints` had `votes_seen` 0 | `finalityTick` read the state, awaited two SQL writes, then set the map; the `finalityLockNotification` handler checked the same map synchronously in between and recorded the lock too; a lock claimed by the notification was never upserted again, so the poll's `votes_seen` never landed | 7de1bdb: the poll claims the state before its first await; a `checkpointDetailed` set makes the poll fill `votes_seen` once | before: 5 duplicates in 400 lines; after the 19:50:33 UTC restart: 21 locks (1297 to 1317), 0 duplicates, 0 write failures; 1300 was notification-first and the poll filled it to 17 votes. The zeros that remain are the node's own count (`finality.rs:770`, its vote map for that hash, empty when the lock came by certificate), not the observer's. Index 1296 appears twice on the feed: it locked inside the restart window, one write per process, a restart-boundary one-off At the 20:15:58 restart index 1319 (locked 14 min before) was recorded again: open, the seed should have held it locked; f22870a logs the seeded states and the earlier state on such a record. The 20:24:46 restart seeded 'proposed 500, locked 864' and re-recorded nothing (11 locks, 0 duplicates) |
|
||||
| 4 Oct 2026 | the observer kept running old code after its fix was on master and HEAD had moved past it | `autosync.sh` restarted the observer only when its own fast-forward moved HEAD; a pull by hand (19:35 UTC, HEAD to 8a77b85) bypassed it | autosync compares the checked-out `tools/observer` tree id with a marker written at each restart and restarts on any difference; `autosync.sh check` says what it would do | `check` with no marker: "restart due", exit 3; with the marker equal to the tree: "not due", exit 0; the shared checkout at that moment: due |
|
||||
| 4 Oct 2026 | the Mac card read 0.0 MH/s for a minute while its blocks were still accepted (after the stale mark shipped) | STALE_S 120 s left no margin: one missed 60 s upload (a 120 s gap at 19:45:59 UTC) plus a 30 s STATUS age plus the 10 s cache | STALE_S 180 s (one missed upload is not stale; PC 2's real case was a 1,860 s gap) | test: 150 s is fresh, 240 s stale |
|
||||
| 4 Oct 2026 | a machine stopped on purpose (Sam's Mac, 14:47 UTC) read "silent 4h" on the console, the same as a crash or a lost network | the app logs `quit: stopping the miners, then the node` and `stopped` and uploads once more before it exits (so it does report), but the console never read those lines | 4d208c9: `parseAppTail` (now in `relay/lib/parse.mjs`) sets `stopped {at, reason quit\|update}` when a quit or OTA-install line has no status line after it; the card says "stopped (quit) N ago" with a grey stripe, `tools/console.mjs` says STOPPED | unit test: quit, update, running, and a quit followed by a later status line; live: five running machines show no false stop; 20:45:01 UTC Sam's Mac quit for the 0.3.4 install and the card read "STOPPED (update) 3m ago" (the quit line and the `[info] installing` line in its last upload), while PC 2's earlier canary quit had not been caught because the detector shipped after it; 00:04 UTC on 5 Oct Sam's Mac quit for the night and the card read "STOPPED (quit)" |
|
||||
| 4 Oct 2026 | `publish-manifest.sh --deploy` called the live manifest "verified" after one signature check: any validly signed manifest, including the previous version still at the edge, passed; a failed deploy was hidden by `\|\| true` | one-shot check, signature only, no byte compare | `verify_live_manifest`: up to `--tries` checks 5 s apart, byte-identical to the folder's file, then the signature, each failure named; `--verify-only` runs just the check; a failed deploy stops first (the ship tool's own verify step already compared bytes and version, so a cut through `tools/ship-app.mjs` was covered; a hand publish was not) | finished: `--verify-only --tries 2` against the live 0.3.3 (try 1); failed: a local server with an altered copy ("differs", both stamps named) and a closed port ("is not reachable") |
|
||||
| 4 Oct 2026 | PC 2 came back on 0.3.4 (the canary) and its card's OTA state read "none" | the OTA state parsed only `OTA: …` lines, and the install lines sit in the previous run's log file; the new run only says `update check: 0.3.4 is current (manifest 0.3.4)` | 797a844 then 2nd commit: every update line the app logs (`is available: downloading`, `downloaded and verified`, `is ready; it installs at the next safe moment`, `installing`, `update to X complete`, `is marked failed`, `has no build yet`, `is current`) is parsed and the newest decides the state; the Mac's card had read "0.3.3 is current" from a 70-minute-old check while 0.3.4 was downloaded, verified and staged | test: staged beats the older check, a finished run reads current, a newer OTA line wins, failed and updated; live cards after the deploy |
|
||||
| 4 Oct 2026 | the live 0.3.3 Mac bundle's `Info.plist` said 0.3.2 while the engine reported 0.3.3 (Finder and Get Info showed the wrong version) | the 0.3.3 cut was by hand and the plist was not bumped | nothing to do: 0.3.4 was cut by `tools/ship-app.mjs`, which bumps the six version files, and its plist says 0.3.4 | `PlistBuddy` on the staged 0.3.4 bundle |
|
||||
| 4 Oct 2026 | 0.3.4 reports `igneumd/2.1.0-73fc2fd` on Windows and `-1b65132` on the Macs; neither is a fork commit (both are igneum-repo commits), so the suffix says nothing about the node source | the fork's `build-info/build.rs` looks for a `.git` directory; a worktree's `.git` is a file, so the walk climbs into the vendoring igneum repository and embeds its HEAD; then it reads `.git/HEAD` as a path, which a worktree lacks | fork branch `bugfix-build-info-worktree` (vendor/igneum-node-bughunt, commits 92e1b030 and its follow-up): the root is any `.git`, the HEAD file comes from `git rev-parse --absolute-git-dir`, the ref from `--git-common-dir`; for the main session to merge into devnet-v4 and finality-fixes (the fork has no remote) | `cargo build -p kaspa-build-info` in the fixed worktree embeds the fork's `92e1b030` and watches the worktree's HEAD; the unfixed devnet-v4 worktree embeds the outer repo's `ebce665` and watches the outer repo's files |
|
||||
| 4 Oct 2026 | PC 2's card went from "updated 0.3.4 @20:41" to "none" by 21:12 while nothing had changed on PC 2 | the console parsed the last 60 KB of the app upload and a PC logs every block, so an update line left that window in about 30 min; the app itself uploads at most the last 256 KiB (262,144 chars per upload on PC 1), the hard cap | 699d0d8: `console_ota_memo` keeps the last OTA state per machine and app run, written when a parse finds one and read back for the same run when the tail has none; a relaunch starts clean (0855c44 widened the SQL window first, which cannot help against the upload cap) | after the deploy all five machines carry memo rows and the cards read installing (PC 1), staged (PC 37ba0461), updated (the rest) |
|
||||
|
||||
## Open
|
||||
|
||||
- Sam's Mac (3a9bf309): CLOSED 19:15 UTC, the app came back on 0.3.3 and mines (11.4 MH/s): it was stopped by its user for 4.5 h. What stays: the last node upload (14:47:02 UTC) ends with `SIGTERM - shutting down` and `igneumd has stopped`, the miner upload stops at 14:46:56, nothing after: a clean app-driven stop (quit or Stop), not a crash and not a network loss (the stop lines reached the intake). Its app predates the app-log upload (`app ?` on the console), so there is no app stream to say which. The app posts nothing on a clean quit, so the console shows "silent 4h" for a machine that was stopped on purpose. Proposed: one `[ok] app quit by the user` line uploaded before the engine stops, and the console card saying "stopped (quit) at 14:47" instead of "silent".
|
||||
- INCIDENT 20:45 UTC, CLOSED 21:00: Sam's Mac (3a9bf309) quit for the 0.3.4 install at 20:45:03 and its run began at 21:00:05 (15 min; this Mac took the same bundle in 2 s). Its `ota-apply.log` (collect job `collect-sam-ota-034`) shows the helper verified, swapped and saw "0.3.4 is running" within seconds, so the gap is between a process the helper matched and the engine's first log line: a first-launch prompt on that Mac or a first launch that died before logging; the app logs nothing until the engine is up. Open for the main session: the host should log the moment it starts and the helper the pid it matched (app code). Console #309 and #312.
|
||||
- Nothing ties a shipped node binary to a fork commit: `payload-inputs.json`'s `node_source_commit` is the fork HEAD at push time, `igneumd --version` prints no hash. With the build-info fix the log header carries the fork commit; the ship tool could then compare both platforms' headers with `--node-commit` (main session).
|
||||
- The observer's proving endpoint is the Mac app's node (`IGNEUM_EVM_RPC` default 127.0.0.1:26800, by design): every app restart or quit blips the proving feed (11 `fetch failed` lines at 20:57:04 during this Mac's OTA); the observer's own node has no `--evm-rpclisten` (devnet node owners).
|
||||
- Windows OTA wording (0.3.4, PC 1 at 21:31:52 UTC): after 15 minutes without word from a per-user installer that never ran (0d123b3, DETACHED_PROCESS), the app logs "administrator approval not given … the update waits for the next time someone is at this PC" and the card reads "waiting for approval"; there was no prompt to answer. For 0.3.5: say "the installer never reported" (app code).
|
||||
- `ota-apply.sh` (embedded in `app/igneum-app/src/ota.rs`) logs `chdir: error retrieving current directory: getcwd` because it runs with the old bundle's directory as cwd and moves it aside; harmless; a `cd /` at the top removes it (app code, for the main session).
|
||||
|
||||
- Sam's Mac (3a9bf309): CLOSED 19:15 UTC; the console side (stopped versus silent) shipped in 4d208c9. Earlier note: CLOSED 19:15 UTC, the app came back on 0.3.3 and mines (11.4 MH/s): it was stopped by its user for 4.5 h. What stays: the last node upload (14:47:02 UTC) ends with `SIGTERM - shutting down` and `igneumd has stopped`, the miner upload stops at 14:46:56, nothing after: a clean app-driven stop (quit or Stop), not a crash and not a network loss (the stop lines reached the intake). Its app predates the app-log upload (`app ?` on the console), so there is no app stream to say which. The app posts nothing on a clean quit, so the console shows "silent 4h" for a machine that was stopped on purpose. Proposed: one `[ok] app quit by the user` line uploaded before the engine stops, and the console card saying "stopped (quit) at 14:47" instead of "silent".
|
||||
- Sam's Mac went silent again at 20:20:12 UTC: the app stream ends on a normal status line (8 MH/s, mining), no `quit:` line, the node log ends mid-stream; so a sleep or a lost network, not a quit, and the console's SILENT (not "stopped") is the right reading. The live "stopped" case came at 20:45:01 with its 0.3.4 install quit: labelled stopped (update).
|
||||
- `collect` jobs: the Format-List / Out-String loss did not reproduce (board2 printed `Sum`, board4 printed `Capacity` and `Speed`); only the `$_` case failed. Closed unless it shows again.
|
||||
| 4 Oct 2026 22:25 BST | Windows update over the air sat on "the installer is starting" (PC 2, 0.3.3 to 0.3.4); no ota-apply.log, no result | the engine spawned powershell.exe with CREATE_NO_WINDOW and DETACHED_PROCESS; with no console PowerShell exits before the script's first line; the same helper launched by a remote job logged, checked the hash and refused as designed | ota.rs spawn_detached: CREATE_NO_WINDOW only (0.3.5) | dry run run-ota-helper-dryrun-pc2 (helper exit 1 with a wrong hash, log and result written); the real proof is the 0.3.5 to 0.3.6 update on a PC |
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ Worked example. Sender S has nonce 5. Miner A's block carries S:5, S:6. Miner B'
|
|||
|
||||
Consequence for users. A transaction can be skipped in one block and execute in a later one without being re-broadcast, as long as a miner includes it again; the node's mempool re-queues a skipped transaction once (then drops it). `eth_getTransactionReceipt` returns null until the executing copy lands, as on Ethereum for a pending transaction.
|
||||
|
||||
Relay (implemented 5 October 2026, fork `tx-gossip`, protocol version 14). A node's mempool is no longer only what its own RPC received. Every admitted hash is announced to every relay-aware peer within 250 ms (`IgneumEvmTxInvMessage`, the inventory pattern of Kaspa's `InvTransactions`); a peer requests the hashes it does not know (`IgneumRequestEvmTxsMessage`) and the holder answers one `IgneumEvmTxsMessage` with the raw bytes it still has; the receiver runs the same admission as `eth_sendRawTransaction` (signature, chain id, nonce window of 16, fee cap at or above the execution base fee, funds, 64 queued per sender, the pgas estimate) and a transaction the node already executed is refused without re-admission, so the pools converge and the chain's own blocks carry a transaction once. Dedup is by hash: the pool answers "known" for what it holds, executed or refused as invalid in the last 65,536 hashes, and one request per hash is outstanding across all peers. Per peer, 2,000 hashes a second with a burst of 8,192 are accepted in and served out; 4,096 hashes per message and 4 MiB per answer, over which the peer is dropped. A state-free fault (malformed, bad signature, wrong chain id, a refused type) disconnects the relaying peer, since every node refuses it the same way; a state-dependent refusal (nonce beyond the window, fee cap under the base fee, funds, queue depth, the 50,000-transaction pool cap) is dropped quietly, because the peer's tip may differ. Relay is off while the node is out of sync. Code: `protocol/flows/src/v10/evmrelay.rs`, the pump in `protocol/flows/src/service.rs`, the sink in `igneum/exec/src/service.rs` (`EvmTxRelaySink`).
|
||||
|
||||
Alternative. Per-block nonces or sequence-independent nonces (Sui-style objects). Rejected: every wallet assumes Ethereum nonces.
|
||||
|
||||
### 1.5 Invalid transactions are skipped by rule
|
||||
|
|
@ -460,7 +462,7 @@ Rule change, 4 October 2026 (findings F-exec-A and F-exec-B of the attack suite,
|
|||
| Units | 1 sompi = 1e10 wei; 1 IGN = 1e18 wei | Subsidies come from `igneum::block_subsidy` in 8-decimal sompi; the EVM is 18-decimal. The open "8 or 18 decimals" decision is unchanged; this is the fixed scaling at the bridge named there |
|
||||
| Rewards | 80% of every blue block's subsidy to its miner, 20% to the proving pool escrow `0x...0220`, both credited in the segment that merges the block; reds unpaid | Design 4.4, with the pool held in a keyless account until proof records exist |
|
||||
| Simnet | Devnet block rate and depths (1 BPS, k 18, mergeset 180, merge depth 3,600) with proof of work skipped; chain id 4463 shared with the devnet | A CPU test network of the devnet DAG shape |
|
||||
| Mempool hand-out | A transaction handed to a template is not offered again for 4 s unless a chain block skipped it; a transaction skipped twice is dropped | Kaspa removes a block's transactions on block-added; the cooldown is the stand-in until the executor listens to block-added |
|
||||
| Mempool hold | A transaction stays in every template until a block carrying it is added to the DAG (any block, this node's or a peer's: the executor subscribes to consensus `BlockAdded`); then it is held for 30 s or until the executor removes it (executed) or offers it again (a chain block skipped it); a transaction skipped twice is dropped | Kaspa's own rule (`mining/src/manager.rs`, `handle_new_block_transactions`). Replaced the 4-second hand-out cooldown on 5 October 2026 (fork `tx-gossip`, `pool.rs IN_BLOCK_HOLD`, `service.rs listen_block_added`): the cooldown made a sender mineable 1 s in 5 and inclusion came in 50-s bursts (bench-log, 5 October 2026 afternoon); with the hold a transaction is offered to every template until a block has it |
|
||||
| Reorgs | Post-segment states for the last 64 chain blocks; deeper reorgs replay from genesis | Observed depth on the test network: 1 to 3 with Poisson-paced miners. A fixed per-template hold had made the three stub miners mine in lockstep rounds, and with equal work per block the GHOSTDAG hash tie-break then kept two equal-work chains alive from genesis (flips 48 deep every few seconds); `igneum-miner --hold-ms` is exponential now |
|
||||
| Block tags | `pending`, `safe` and `finalized` all resolve to the executed tip | The virtual's segment is not executed eagerly and no certified checkpoint exists on this branch; the RPC does not pretend otherwise |
|
||||
|
||||
|
|
@ -474,7 +476,7 @@ Rule change, 4 October 2026 (findings F-exec-A and F-exec-B of the attack suite,
|
|||
6. The virtual's segment is not executed eagerly (design 1.2 "about one second after inclusion"); the executor runs about one chain block behind the sink. `pending` tags resolve to the executed tip.
|
||||
7. `eth_subscribe`, `debug_traceTransaction`, `trace_block`, `eth_getProof`, `eth_getUncle*`, `IgneumInfo`: not implemented.
|
||||
8. The chain follower polls `get_virtual_chain_from_block` every 100 ms instead of subscribing to virtual-chain-changed notifications.
|
||||
9. Mempool: no p2p relay of EVM transactions between nodes (each node's pool is what its RPC received), no eviction by age, no fee-based replacement beyond the 10% rule.
|
||||
9. Mempool: p2p relay of EVM transactions implemented 5 October 2026 (section 1.4 "Relay", protocol version 14; measured on a 3-node fast-time chain A - B - C in the bench-log of that day: every transaction sent to A was included by B's and C's blocks). Still missing: eviction by age and fee-based replacement beyond the 10% rule.
|
||||
10. Differential rows 1 (ethereum/tests), 3 (independent linearizer), 4 (Python oracle), 5 and 6 are not built; the harness here is the balance and receipt comparison of the acceptance criteria.
|
||||
|
||||
### 10.4 Merge plan with the finality branch
|
||||
|
|
|
|||
72
docs/design/miner-dev-fee.md
Normal file
72
docs/design/miner-dev-fee.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# The miner software's dev fee
|
||||
|
||||
Decision (the project lead, 4 October 2026, evening): the Igneum miner software takes a visible, switchable 1% dev fee, the norm
|
||||
for GPU miners (lolMiner, T-Rex). The protocol stays fee-free: no dev fund, no fee to any team, foundation or fund
|
||||
(CLAUDE.md, litepaper). This fee is the software's, like every third-party miner's, and it is documented as such.
|
||||
|
||||
## Mechanism
|
||||
|
||||
Solo mining, no pool: the miner asks its node for block templates with a payout address. One template in 100 is
|
||||
requested with the dev payout address instead of the user's. The choice is a template counter, never a random draw,
|
||||
so it is exactly 1 in 100 and anyone can audit it from the source: template `n` (counting from 0) is a fee template
|
||||
when `floor((n + 1) p / 100) > floor(n p / 100)`, which at `p = 1` is the templates 99, 199, 299, ... Every template
|
||||
is mined for the same time, so fee blocks are `p` in 100 on average.
|
||||
|
||||
What changes on a fee template, and what does not:
|
||||
|
||||
| Field | User template | Fee template |
|
||||
|---|---|---|
|
||||
| Coinbase extra data `IGNA` address (the execution-layer payout the chain pays) | the user's `--evm-address` | the dev address |
|
||||
| Vote key hash (finality weight) | the user's | the user's, unchanged |
|
||||
| UTXO-side coinbase script (`--address`, a label address with no key in the app) | the user's | the user's, unchanged |
|
||||
|
||||
So a fee block still adds to the user's finality weight, and the only thing that moves is who the execution layer
|
||||
pays for that block.
|
||||
|
||||
Source: `vendor/igneum-node-v4` branch `dev-fee`, `igneum/miner/src/main.rs`, section "Software dev fee"
|
||||
(`DevFee`, `fee_slot`, `Identity::with_dev_fee_address`, `template(.., fee)`, `submit(.., fee)`, `payouts`).
|
||||
|
||||
## The flag, the lines, the counter
|
||||
|
||||
| Where | What |
|
||||
|---|---|
|
||||
| `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 (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)` |
|
||||
|
||||
The app (Igneum Miner): Settings shows the same line next to the rewards address with a switch, stored in
|
||||
`settings.json` as `dev_fee` (default on); off passes `--dev-fee 0`. The dashboard's "your blocks" row shows the
|
||||
lifetime dev-fee block count; each fee block is also an event. The HiveOS package: `DEV_FEE=0` in the Flight Sheet's
|
||||
extra config.
|
||||
|
||||
## The addresses
|
||||
|
||||
| Constant (`igneum/miner/src/main.rs`) | Value | Network |
|
||||
|---|---|---|
|
||||
| `DEV_FEE_ADDRESS` | `0x7F45d7d7272e57639BeBb739A60B05bB2CD4C126`: the project lead's payout address from the Igneum Wallet, given 5 October 2026, EIP-55 checksum verified, on file at `~/.config/igneum/dev-fee-release.json`; set on the fork's `release-0.3.6` (commit cf369022). Were it ever not 40 hex the fee would be off outside the devnet and the miner would say so at start | mainnet, testnet |
|
||||
| `DEV_FEE_ADDRESS_DEVNET` | `0xdfaea67368f3e3753397d878f97efe6aa8020c2e` | devnet and simnet only |
|
||||
|
||||
The devnet address was generated on 4 October 2026 with the app's own key derivation (secp256k1, keccak of the
|
||||
uncompressed public key, `app/igneum-app/src/keys.rs`), checked against the known vector for key 0x01. The private key
|
||||
is held outside the repository (the maintainers' local secret store, mode 0600) and never in any commit. Devnet coins
|
||||
have no value and the devnet may be reset; this address is never a release address.
|
||||
|
||||
## Tests and measurement
|
||||
|
||||
- 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 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".
|
||||
|
||||
## Public text
|
||||
|
||||
- Litepaper, "For miners", "What a miner's hour looks like": the fee paragraph.
|
||||
- Homepage, the miner section's download note.
|
||||
- `docs/fud-ledger.md`: entry E18 (software-level, switchable, the audit).
|
||||
156
docs/design/miner-tuning.md
Normal file
156
docs/design/miner-tuning.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Miner tuning: variant racing and fleet learning
|
||||
|
||||
4 October 2026, evening. the project lead: "we need to make our miner better than anything else can be". Two levers, both
|
||||
measured: a race between kernel variants at every hourly swap, and a fleet that remembers which variant each card
|
||||
model likes. Numbers live in `docs/bench-log.md` ("miner performance: variant racing"); the PC job is in
|
||||
`docs/plans/miner-perf.md`. Nothing here changes the hash: every variant is the same instruction text in a
|
||||
different shape for the compiler, and a variant that is not bit-exact is discarded before it is timed.
|
||||
|
||||
## 1. Why a race
|
||||
|
||||
The lottery program changes every hour. The generator draws 64 instructions with 16 loads; the compiler sees a
|
||||
different straight-line body each time, and what suits one body (full unrolling, a read-only load path, more
|
||||
threads per block) does not suit the next. A fixed compile is a guess. The compile-ahead pipeline already builds
|
||||
the next hour's kernel one lead (600 DAA, about 600 s) before the boundary, so there is time to build several
|
||||
and let the card pick.
|
||||
|
||||
## 2. The variants
|
||||
|
||||
| Knob | NVIDIA (NVRTC worker, `proto-cuda/nvrtc/worker.cpp`) | Apple (Metal worker, `proto-metal/main.swift`) |
|
||||
|---|---|---|
|
||||
| Unroll | `#pragma unroll 2` or `8` before the iteration loop (`u2`, `u8`) | the same pragma (`u2`, `u8`) |
|
||||
| Load path | `ds[i]` as shipped; `__ldg` read-only path (`ldg`); `__ldcg` L2 only (`ldcg`); `__ldcs` streaming (`ldcs`) | none (one address space on Apple silicon) |
|
||||
| Register budget | `--maxrregcount=32` or `64` (`r32`, `r64`); `__launch_bounds__(128, 4)` (`lb4-w4`), `(64, 8)` (`lb8-w2`) | `[[max_total_threads_per_threadgroup(N)]]` 256, 512, 1024 (`mt256` ...) |
|
||||
| Threads per block | 2, 4, 8 warps (`w2`, `w4`, `w8`) | 64, 128, 256 threads per threadgroup (`g64`, `g128`, `g256`) |
|
||||
| Compiler | | `optimizationLevel = .size` (`osize`) |
|
||||
| Combinations | `u2-ldg`, `u2-w4`, `ldg-w4`, `ldcg-w4` | `u2-g128`, `u8-g128`, `mt256-g128`, `mt512-g256` |
|
||||
|
||||
17 names on NVIDIA, 14 on Metal. `base` is always the pack's text as shipped with the worker's default block:
|
||||
the kernel every machine ran before this change. Names are stable; the tuning file and the fleet records use them.
|
||||
|
||||
NVIDIA rewrites are textual, on the pack's own `kernel_bound.cu`, with exact anchors from `igneum-pow`'s emitter
|
||||
(`"\n for (uint32_t it = 0u; it < "`, `" ^ ds["`, `"__global__ void igneum_hash_bound("`); a text without the
|
||||
anchor refuses the variant instead of guessing. The pack format, the miner and `igneum-pow` are untouched, so a
|
||||
new worker races old packs. OpenCL (AMD, Intel) is not raced yet: `proto-opencl/host.c` builds through
|
||||
`clBuildProgram` with `-D IGNEUM_GROUP` already, so the same catalogue (unroll pragma, group size, `-cl-` options)
|
||||
is the next step; see "open".
|
||||
|
||||
## 3. The race inside the prepare
|
||||
|
||||
```
|
||||
prepare <epoch> <day> <pack> (the miner, one lead before the boundary)
|
||||
compile kernel.cu, build cache + dataset, self-test base (as before)
|
||||
compile the variants (NVRTC: 4 threads; Metal: in turn) budget: --race-budget-s, default 120
|
||||
for each round (1 in --serve):
|
||||
for each variant: lock the card, self-test, time ~2 s, unlock
|
||||
winner = fastest; base keeps its place unless beaten by 0.5%
|
||||
one "race ..." line, then "prepared ..." as before
|
||||
job on the new pair at the boundary (swaps as before; the winner serves the hour)
|
||||
```
|
||||
|
||||
Rules that keep the swap safe:
|
||||
|
||||
| Rule | Where |
|
||||
|---|---|
|
||||
| Base is the first entry and is never discarded; a race that runs out of budget keeps the best so far | `racePair`, `raceProgram` |
|
||||
| Every variant must reproduce the pack's vector warps (NVIDIA) or the base kernel's output over 2^16 nonces (Metal), bit for bit, or it is out | `raceTime`, `raceProgram` |
|
||||
| The card is exclusive while a variant is timed: one mutex, held per chunk by the job loop and per window by the race. Mining pauses about 2 s per variant and resumes between variants | `gpuMutex`, `gpuLock` |
|
||||
| `--race-budget-s` is capped at 540 (the lead is 600 DAA); default 120 | option parsing |
|
||||
| A pair compiled inline (nobody prepared it) races after its first job, in the background | Metal `raceDue`; NVIDIA self-heal path |
|
||||
| `--race off` restores the old behaviour; `--race a,b,c` limits the catalogue | both workers |
|
||||
| Under `IGNEUM_EMU` (the Mac's emulation test) the race is off: the stand-in checks that the handed-over text is the pack's | `racePair` |
|
||||
|
||||
Cost per hour: on the 5090 about 17 variants x (2 s window + a self-test) of paused mining, under 1% of the hour,
|
||||
plus the compiles on the CPU. The expected gain is what the race measures; nothing is claimed for it.
|
||||
|
||||
The line, one per race (the miner logs it as `worker: race ...`):
|
||||
|
||||
```
|
||||
race <epoch16> device <name> driver <d> arch <a> loads <n> wide <n> variants <k> base=<MH/s>/<regs>r/<warps>w u2=... ldg=-
|
||||
winner <name> <MH/s> base <MH/s> gain <+pct>% compile <ms> bench <ms> total <ms> ms [pinned by tuning|tuned order]
|
||||
[| <variant>: <why it is out>]
|
||||
```
|
||||
|
||||
`--race --pack <dir>` (NVIDIA) and `--race-test --seed <s> --day <d>` (Metal) run the race alone, three rounds,
|
||||
and print a table; that is what the bench log and the PC job use.
|
||||
|
||||
## 4. Fleet learning
|
||||
|
||||
### 4.1 The record
|
||||
|
||||
The app's engine reads the race line (`engine.rs` `race_line`) and writes one `TUNING {json}` line to the app
|
||||
log, which the existing intake receives with every upload (Neon `miner_logs`, the same table `tools/logs.mjs`
|
||||
reads). Fields:
|
||||
|
||||
| Field | From |
|
||||
|---|---|
|
||||
| `ts`, `machine` (id8), `app` (version) | the engine |
|
||||
| `card` (the worker's device name, spaces as underscores: the key everything else uses), `vendor`, `worker` (CUDA, Metal, OpenCL) | the race line, the card state |
|
||||
| `driver`, `arch` (sm_120, metal) | the race line |
|
||||
| `epoch` (16 hex), `loads`, `wide` (the program class features the generator fixes: loads per hash and wide loads per hash) | the race line |
|
||||
| `variants` {name: MH/s or null} | the race line |
|
||||
| `winner`, `mhs`, `base_mhs`, `gain_pct`, `total_ms`, `pinned`, `tuned`, `notes` | the race line |
|
||||
| `power_limit_w`, `power_w`, `power_pct`, `mh_per_w` (= mhs / power_w, 0 when the card reports no draw) | the card state (nvidia-smi telemetry; Apple reports none) |
|
||||
|
||||
The card state also carries `variant`, `race_mhs`, `race_gain_pct`, `race_variants` for the dashboard, and one
|
||||
event per race ("RTX 5090 kernel race: u2-ldg at 118.3 MH/s (+2.1% over base, 17 variants, 41 s)").
|
||||
|
||||
### 4.2 The aggregation
|
||||
|
||||
`tools/tuning.mjs` on the Mac (or a small job): every app-log upload of the window (default 7 days) with a
|
||||
TUNING line, de-duplicated on (machine, card, epoch) because the log is re-sent every minute, then per card
|
||||
model and variant the sample count, the median MH/s and the median MH per watt. The winner is the best median
|
||||
with at least `--min-samples` (3) samples; `--by mhw` ranks by MH per watt instead. `--write tuning.json` writes:
|
||||
|
||||
```json
|
||||
{"updated": "2026-10-04T21:00:00Z", "window_days": 7,
|
||||
"cards": {"NVIDIA_GeForce_RTX_5090": {"variant": "u2-ldg", "race": true, "candidates": ["u2-ldg", "ldg", "base"],
|
||||
"samples": 41, "races": 41, "machines": 2, "mhs": 118.3, "base_mhs": 115.9,
|
||||
"gain_pct": 2.07, "mh_per_w": 0.254, "worker": "CUDA", "by": "mhs"}}}
|
||||
```
|
||||
|
||||
A program class split (by `loads`, `wide`) is in the record and not yet in the aggregation: the generator fixes
|
||||
16 loads per instruction block, so every program has 128 loads per hash today; the split starts to matter when the
|
||||
era draw changes the mix.
|
||||
|
||||
### 4.3 The way back: the manifest
|
||||
|
||||
`packaging/ota/publish-manifest.sh --tuning tuning.json` puts the object under `tuning` in the signed
|
||||
`igneum-app-latest.json` (carried over from the current manifest when not given; `--no-tuning` drops it; the
|
||||
same script now also takes `--override '{json}'` for `consensus.override` and carries that over too).
|
||||
`manifest.rs` parses `tuning` (an object with a `cards` object, else the manifest is refused). The updater writes
|
||||
it as is to `<app data>/app/tuning.json` (`ota.rs` `write_tuning`, next to `override.json`), logs one event, and
|
||||
the engine starts every miner with `IGNEUM_TUNING_FILE=<that path>` (`procs::spawn` gained an environment
|
||||
parameter); the miner's child, the worker, reads it at every prepare. No restart for a change: a worker that has
|
||||
the path reads the file again at its next prepare; a miner started before the file existed is restarted by the
|
||||
usual hourly path.
|
||||
|
||||
What a worker does with its entry (`readTuning`, `readMetalTuning`):
|
||||
|
||||
| Entry | Behaviour |
|
||||
|---|---|
|
||||
| none for this card model | the full race |
|
||||
| `race: true` with `candidates` | the candidates are raced first, then the rest as the budget allows (the fleet keeps learning; the card starts from the known best) |
|
||||
| `race: false` with `variant` | the variant is compiled and self-tested, no timing (about 1 s); a failed self-test falls back to the full race |
|
||||
| `--variant <name>` on the worker | the same as a pinned entry, for tests |
|
||||
|
||||
### 4.4 What is implemented and what is not
|
||||
|
||||
| Piece | State |
|
||||
|---|---|
|
||||
| NVRTC worker race, `--race` mode, tuning file | code, syntax-checked for mingw and the emulation build; emulation suite; **not yet run on a GPU** (the PC job does that) |
|
||||
| Metal worker race, `--race-test`, deferred race, tuning file | code and the Mac measurement (bench log) |
|
||||
| OpenCL worker race | not implemented (open) |
|
||||
| Engine: race line to TUNING record, card state, event, `IGNEUM_TUNING_FILE` | code, unit tests of the manifest parse |
|
||||
| `publish-manifest.sh --tuning`, `--override`, carry-over | code (dry run against a scratch folder in the plan) |
|
||||
| `tools/tuning.mjs` | code; needs records, so no table yet |
|
||||
| Aggregation as a job on a PC or the observer | not needed yet: the Mac script reads the intake |
|
||||
| Dashboard field for the variant | state only; the UI does not show it yet |
|
||||
|
||||
## 5. Open
|
||||
|
||||
- OpenCL: the same catalogue through `clBuildProgram` options and the pragma; `--group-warps` exists already.
|
||||
- The program class split in the aggregation once eras change the instruction mix.
|
||||
- A per-card cap on how long a race may pause mining (today the 2 s windows plus the budget); and whether to race
|
||||
only every N hours once a card's winner is stable (the pinned entry does that by hand).
|
||||
- Power: the record has MH per watt, the race does not touch the power cap; a race across caps is a later lever.
|
||||
101
docs/design/site-polish-2026-10-04.md
Normal file
101
docs/design/site-polish-2026-10-04.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Site polish, 4 October 2026
|
||||
|
||||
Branch `site-polish`. The brief: one header on every page, the litepaper's tabs fixed, every element of every page checked at 390, 768 and 1440 px in light and dark, performance measured before and after, copy law on every sentence, the build green. This file records what was broken, what changed, the numbers, and what was left alone.
|
||||
|
||||
## 1. What was broken
|
||||
|
||||
| # | Where | Fault | Root cause |
|
||||
|---|---|---|---|
|
||||
| 1 | Litepaper, whole-paper mode | Every contents click, pager click and back press landed on the section for a moment and then slid back to the Abstract | `show()` always scrolled to the top of `<article>`, never to the section. In one-section mode the section is the top of the article, so the fault was invisible; in whole-paper mode the browser's own fragment jump landed first and the smooth scroll then dragged the reader to the top |
|
||||
| 2 | Litepaper, one-section mode | A deep link (`/litepaper#limits`) opened on the cover, not the section | The browser's fragment scroll ran while the section was still `display:none`; the router then showed the section with `scroll=false` |
|
||||
| 3 | Litepaper, phone | The sticky contents row covered the heading after a jump | No `scroll-margin-top`; the hand-tuned 70 px offset did not match the row's real height (99 px) |
|
||||
| 4 | Litepaper, keyboard and screen readers | After choosing a section, focus stayed in the contents list | Focus was never moved |
|
||||
| 5 | Litepaper | 29 subsection headings had no ids, so only the 17 sections were addressable by URL | Never generated |
|
||||
| 6 | Header, every page | Five different headers: 11 links on the homepage, 10 on the live page (section anchors into the homepage), 6 plain links on the evidence and log pages, no header at all on the litepaper (a cover with two arrow links) | Each page carried its own copy |
|
||||
| 7 | Header, mobile | The menu on the live page closed on tap-outside but not on Escape and not on a link tap; the evidence, log and litepaper pages had no menu, their links wrapped | Three different scripts, two pages without one |
|
||||
| 8 | Header, every page | The mark was the bare flame, not the master mark in its black square | Pre-dates the brand master of 4 October |
|
||||
| 9 | Live page, phone | The page scrolled sideways to 420 px at 390 px | Three legend captions were `white-space:nowrap` spans ("one lane per miner, colour from its id; hover or tap a block") |
|
||||
| 10 | Live page, phone | The hash-rate cell clipped to "264.1 M…" | 20 px Unbounded in a 125 px cell with `text-overflow:ellipsis` |
|
||||
| 11 | Homepage, 390 px and 1440 px | The headline wrapped to four lines with "fire." alone on the last | `clamp(40px, 7vw, 76px)` never fitted "Mined by GPUs." in the column at either end |
|
||||
| 12 | Homepage | The journey block arrived after a fetch of `journey.json`, after first paint, and pushed the page down | Rendered from a fetch |
|
||||
| 13 | Every page | A render-blocking stylesheet from fonts.googleapis.com, then the woff2 files from a second origin; no font fallback metrics, so the swap moved every line | Google Fonts link in each `<head>` |
|
||||
| 14 | 404 | Vercel's plain text "NOT_FOUND" | No `404.html` |
|
||||
| 15 | Sitemap | `/evidence` missing; every lastmod 3 October | Not updated |
|
||||
| 16 | Log page share card | `og.png?v=2` while every other page was on `?v=3` | The build template was not bumped with the pages |
|
||||
| 17 | Evidence table | Column sort only by mouse (the `<th>` was the click target, not focusable); filter chips had no pressed state | Markup |
|
||||
|
||||
Items 9, 10, 11 and 13 were found by the checks in this round, not by eye.
|
||||
|
||||
## 2. What changed
|
||||
|
||||
### Shared chrome (one source, injected by the build)
|
||||
|
||||
`site/partials/head.html`, `nav.html`, `footer.html`. `site/build.mjs` injects them into every page between `<!-- head:start -->`, `<!-- nav:start -->` and `<!-- footer:start -->` markers, marks the active link with `aria-current="page"`, and inlines `journey.json` into the homepage between `<!-- journey:start -->` markers. The committed pages carry the injected copy, so they are right with or without a build; CI runs the build and the link check on every push.
|
||||
|
||||
- Header: the master mark (black square, no ring) at 36 px, the wordmark, five links in one order on every page (Litepaper, Live devnet, Engineering log, Evidence, GitHub) and the Get the miner button. Sticky, 68 px, blurred backdrop. Under 901 px a menu: opens on the button, closes on any link, a tap outside, Escape (focus returns to the button) or a resize past 900 px; `aria-expanded` and `aria-controls` set. A skip link to `#main` on every page.
|
||||
- Footer: one footer, six Read links and four Follow links, the same everywhere. Page-specific lines (the evidence "as of" date, the log's "generated at build time") moved into the page body above it.
|
||||
- Litepaper: the chrome follows the paper's scheme (light by default, dark with the system) through `--ui-*` tokens; every other page is obsidian.
|
||||
- Fonts: self-hosted latin subsets in `site/fonts/` (Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500; OFL), 139 KB for all eight, 118 KB for a typical page, `font-display:swap`, the two first-paint faces preloaded. Fallback faces (`Unbounded Fallback` on Arial Black, `Plex Sans Fallback` on Arial, `Plex Mono Fallback` on Courier New) carry `size-adjust`, `ascent-override` and `descent-override` computed from the font tables with fonttools, so the swap does not move the layout (a deep link into the litepaper lands within 3 px before and after the fonts arrive, and is re-aimed on `document.fonts.ready`).
|
||||
- Site-wide: `text-wrap:balance` on h1 to h3, `text-wrap:pretty` on paragraphs and list items; one `:focus-visible` ring; one `prefers-reduced-motion` rule that stops every animation and transition.
|
||||
|
||||
### Litepaper
|
||||
|
||||
New section router. The hash names a section or any heading inside one, in both reading modes. In-page links are routed by a click delegate (`pushState` + `go()`), never by the browser's fragment jump; back and forward replay through `popstate`; every scroll clears the sticky bar and, on a phone, the contents row (`--lp-off`, measured from the real heights; `[id]{scroll-margin-top:var(--lp-off)}` so a native jump lands in the same place); focus moves to the heading with `preventScroll`; every h3 gets an id from its text (29 added, so `/litepaper#what-is-not-here` works). The mode buttons carry `aria-pressed` and stack vertically in the desktop sidebar (they wrapped to two lines before). The cover lost its duplicate brand block and arrow links.
|
||||
|
||||
### Homepage
|
||||
|
||||
Header reduced to the five links; the in-page sections are still there for scrolling. The headline is capped at `9cqw` of its column so it is two lines at every width. Journey data is inlined by the build (no fetch, no shift); dates read "4 Oct 2026" with the day shown once per group; the toggle carries `aria-expanded`; the NOW label lists the active phases with commas and balances its lines. A preconnect to cdn.jsdelivr.net for the light client's libraries.
|
||||
|
||||
### Live page
|
||||
|
||||
Legend captions wrap. The hash-rate value shows its unit in small mono beside the number. Idle, the scene is drawn 30 times a second instead of 60 (a pointer or a pinned block gets every frame); the DAG canvas backing store is capped at 1.5x. The proving strip's "not yet activated" line breaks at its semicolon when wider than the strip on a phone. `?perf=1` exposes `window.__igneumPerf` (frames, draw milliseconds) for the next measurement.
|
||||
|
||||
### Evidence
|
||||
|
||||
Column headings are real buttons (keyboard sort, `aria-sort` kept); filter chips carry `aria-pressed`; a "scroll sideways" hint above the 1,100 px table under 1,140 px; the "as of" line moved into the page.
|
||||
|
||||
### Engineering log (build template)
|
||||
|
||||
Contents in a labelled box: sticky and scrollable beside the article on desktop, a 40 vh scroll box on a phone (57 entries). Headings land under the bar (`scroll-margin-top`). `og.png?v=3`.
|
||||
|
||||
### 404
|
||||
|
||||
`site/404.html`: the four pages as cards. Vercel serves it for any unknown path (checked locally with the same clean-URL rules; `noindex`).
|
||||
|
||||
### Headers and sitemap
|
||||
|
||||
`vercel.json`: fonts `immutable` for a year, images and the manifest a day with a week of stale-while-revalidate; HTML stays `must-revalidate`. `sitemap.xml`: `/evidence` added, lastmod 4 October.
|
||||
|
||||
## 3. Checks
|
||||
|
||||
`check.mjs` (scratchpad, Chrome for Testing 154, never the owner's Chrome) over `/`, `/litepaper`, `/live`, `/bench`, `/evidence` and a 404 path at 390, 768 and 1440 px: no horizontal overflow, no console errors, the five links in order and the right one active, the square mark present, the menu opens, closes on tap-outside and on Escape with focus back on the button; on the litepaper in both modes at 390 and 1440: a contents click, a pager click, back, a deep link to a section and to an h3 on reload, and Enter on a focused pill each land within 2 px of the measured offset with focus on the heading. `node site/build.mjs` and `tools/ci/link-check.mjs` (245 internal links, 0 broken) pass.
|
||||
|
||||
## 4. Numbers
|
||||
|
||||
Static, from the files (before = the committed site at 8fd492c, after = this branch):
|
||||
|
||||
| Page | HTML before (gzip) | HTML after (gzip) | Third-party requests before | After |
|
||||
|---|---|---|---|---|
|
||||
| / | 57,261 B (17,265) | 74,065 B (21,810) | 3 (fonts CSS, 2 preconnects) + the woff2 files | 1 (jsdelivr, light client only) |
|
||||
| /litepaper | 63,628 B (20,556) | 79,208 B (24,957) | 1 + woff2 | 0 |
|
||||
| /live | 47,680 B (15,640) | 56,357 B (17,777) | 3 + woff2 | 0 |
|
||||
| /evidence | 56,729 B (18,899) | 69,890 B (22,493) | 1 + woff2 | 0 |
|
||||
| /bench | 267,259 B (94,723) | 280,179 B (98,289) | 1 + woff2 | 0 |
|
||||
|
||||
The HTML grew by the inlined font-face block, the shared chrome and, on the homepage, the inlined journey (about 4 KB gzipped a page). In exchange every page lost a render-blocking stylesheet from a third origin and two DNS lookups.
|
||||
|
||||
Live page, idle draw cost (Chrome for Testing, 2x display, canvas in view, 10 s, `?perf=1`; taken while cargo builds and a reliability run held the Mac, so an upper bound): 1440 px, 30.0 draws/s, 69 ms of draw in 10 s = 0.7% of one core; 390 px, 30.0 draws/s, 60 ms = 0.6%. Before the change the loop drew at the display rate (60/s) with a 2x backing store, so the same work was about 2.6x this, approximate. Off screen or in a hidden tab the loop stops, as before.
|
||||
|
||||
Lighthouse (mobile simulation, performance only, before on a frozen copy of the committed site at port 4174, after on port 4173, Chrome for Testing 154 downloaded into the scratchpad): queued at 19:45Z under `tools/lock/with-lock.sh measure` and not run by the time this was written. The measure lock was held by reliability runs from 19:16Z onwards (pid 66963, then pid 5534 from 20:18Z) with three cargo builds beside them and a load average of 258, and a paint time taken under that is not a number. The runner is `scratchpad/lh.sh` (before and after, five pages, score, FCP, LCP, TBT, CLS, speed index, bytes, requests, render-blocking resources); run it under the measure lock on a quiet machine and paste the table here. What can be stated without a timer: every page lost its only render-blocking third-party stylesheet and the two origins behind it; the fonts a page needs are one origin, preloaded, cached a year; the homepage lost the post-paint journey fetch; the fallback metrics hold the layout within 3 px across the swap.
|
||||
|
||||
Deep-link landing: within 2 px of the measured offset in every case but one. In one-section mode at 1440 px the Finality section is short, "What is not here" is its last heading, and the page cannot scroll far enough to put it under the bar; it lands at the page's maximum scroll, 3 px short. A padding trick would add a blank screen under every section, so it is left as it is.
|
||||
|
||||
## 5. Left alone, and why
|
||||
|
||||
- `site/api/live.mjs`, `checkpoint.mjs`, `log.mjs`: the observer agent's. The homepage light-client card currently reads "could not verify: voter 0 key does not hash to its vote_key_hash" on production as well as locally; that is the checkpoint data, not the page, and is reported, not fixed here.
|
||||
- `site/verify/`: the light client, unchanged.
|
||||
- The GitHub link (`github.com/igneum-network/spec`, 200 on 4 October) stays; `docs/fud-fixes.md` X1 asked for it to go only while private.
|
||||
- Download buttons: the homepage's Windows, macOS, Linux and HiveOS buttons link to `#journey` as before (no public build yet); no `dl.` URL exists on the site, the scrub replaces the host name on the log page.
|
||||
- The litepaper's light scheme by default: the paper is the one light surface on purpose.
|
||||
- Three glowing active phase cards on the homepage: kept, stopped under reduced motion.
|
||||
- The evidence table's 1,100 px minimum width: it is a 7-column reference table; it scrolls inside its box and says so on narrow screens.
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
Four rules for reading the table:
|
||||
|
||||
1. Nothing on this chain has been reproduced externally or reviewed independently. Every row's last column says "none yet". The repository is private until January 2027 (`site/journey.json`), so the first three labels are the ceiling today.
|
||||
1. Nothing on this chain has been reproduced externally or reviewed independently. Every row's last column says "none yet". The repository is private until the public testnet (decision of 5 October 2026), so the first three labels are the ceiling today.
|
||||
2. A status applies to the exact version in the row. An audit of one version never covers a newer one; when the version changes, the status falls back to "tested by the team" until the new version is reproduced or reviewed again.
|
||||
3. "Tested by the team" on one machine is one machine. The rows say which. Discrete AMD, Intel and a 2019-class CPU core have not run anything.
|
||||
4. The 12-node cloud network of 4 October 2026 (`infra/cloud-devnet`, Hetzner VMs in five locations) is the project's own. Rows that cite it are tested by the team, not reproduced externally.
|
||||
|
|
@ -39,13 +39,13 @@ Versions in the table: `igneum-pow` is the Rust crate at `igneum-pow/Cargo.toml`
|
|||
| 12 | The difficulty rule recovers from a hashrate step within minutes, where Kaspa's sampled rule never settles. A step inside an epoch set the rule oscillating on the live devnet on 4 October 2026; rule v2 removes it in the simulator and on a test network and is built but not yet rolled out | Spec 2.3; litepaper Speed (implied); bench page | tested by the team | repo `e9328c6`, `abb5a5d` (attacks), `67bf226` (rule v2); fork `difficulty` branch (timestamp fix) and `devnet-v4` `a21ff239` (`difficulty_v2_activation_daa`, `REF_WINDOW_V2 = 600`); `sim/difficulty/sim.py --live` | The live record `sim/difficulty/records/live-2026-10-04.csv` (8,090 headers, `pull_live.py`) and the hash-rate record beside it; `sim/difficulty/sim.py` on the synthetic set and the DAG replay; `sim/difficulty/attacks/attacks.py`; `sim/difficulty/testnet_v2.py` (3 nodes, activation at DAA 900); `cargo test --release -p kaspa-consensus --lib difficulty` (15 pass); bench-log "difficulty controller", "difficulty rule under attack", "timestamp attack fixed", "difficulty rule v2" | Live devnet v4, 4 October 2026 (UTC): a second RTX 5090 joining 7 minutes into an epoch (about 152 to 280 MH/s) hardened the difficulty 70M to 144M in 90 s and then swung by about a third for 40 minutes around the true level of 139M while the epoch-long reference lane carried the join; that card leaving for 4 minutes eased 116M to 67M and back to 106M; the epoch boundary with both PCs restarting took 152M to 77M in 3 minutes, after which the rule held within 1.3% per minute with no flips. Cause: the reference lane covered the whole epoch, so a mid-epoch step polluted it for the hour and the 25% trigger flipped on the short lane's noise. The DAG replay reproduces the record (std of log difficulty 0.115 against 0.134, 4.3 peaks against 4). Rule v2 (reference window 600 DAA) on the replay: std 0.026, 0 flips, mean 142.6M against 139M true; on a 3-node test network the v2 nodes eased a leave with no peak and held a rejoin within 3% after 60 s, and a node without the activation height forked off at it as designed. Rule v2 rolled onto the 12-node cloud network on 4 October (all nodes crossed the height on one chain; a hash-rate step then settled in 160 to 270 s with no swing) and activates on the devnet at DAA 33,000 the same evening. Timestamp forging (ledger M23) fixed the same day: a 50% forger drifts the rate under 1.1% where the 3 October rule gave it a 9.9x difficulty. Simulator, settled seconds: x50 step 62 to 66 (Kaspa 1,542), /50 step 657 to 753 (Kaspa 12,296). Apple M5 Max under load 7 to 442; the DAG model is fitted on one scale; the pool hopper's 0.7-point excess over Kaspa's rule stays open | none yet |
|
||||
| 13 | Every node executes the ordered transactions natively and reaches the same state root | Litepaper Proving ("Every node executes ... natively"), Building ("runs on Igneum unchanged") | tested by the team | repo `f5f8c80`, `8dae48b`; fork `devnet-v4` `dc749905`; revm 43.0.3 | `node tools/evm-smoke/smoke.mjs` against a 3-node `igneumd`; `igneum-exec-diff seq.json`; bench-log "execution layer devnet v3" and "devnet-v4 integration" | Simnet, 3 October 2026: 87 of 87 viem checks, state roots identical on 3 nodes at four heights, 57 executed and 19 skipped transactions agree with plain revm, 0 mismatches. Merged node on real proof of work, 4 October 2026: 84 of 85 checks (the miss needs parallel blocks the network did not produce in 36 s), 59 transfers in 10 chain blocks, state roots identical on 3 nodes, `igneum-exec-diff` 0 mismatches over 59 transactions; the live devnet v4 runs this execution layer. Apple M5 Max. The prover is a stub; state is rebuilt from genesis at start; no EVM transaction relay between nodes | none yet |
|
||||
| 14 | Ethereum bytecode runs unchanged, with the documented differences of spec 7.1 | Homepage Build card; litepaper Building | tested by the team | as row 13; fixes `F-exec-A`, `F-exec-B` (spec 7.5) | `tools/evm-smoke/smoke.mjs`: deploy via viem, `increment`, `hashLoop`, `eth_estimateGas`, `eth_getLogs`; `tools/exec-attacks` scenarios 1 and 3; bench-log "execution layer attack fixes" | Deployment, calls, reverts, logs and gas estimates behave as viem expects; chain id 4463; the prototype pgas table gives 0.0095 to 0.028 pgas per gas, below the design's band before calibration, 3 October 2026. 4 October 2026: a transaction that would cross the block's proving budget is refused by the mempool and, if forced in, aborted and charged with its nonce advanced (25 of 25 checks; 30 of 30 malformed cases). Apple M5 Max. The `Prover` precompile, proof records and the shard planner are not in the node | none yet |
|
||||
| 15 | Every block is proven, with the proof landing within about a minute at launch | Homepage stats ("~60 s to a proof"); litepaper Proving; roadmap phase 3 gate | implemented | repo `d7e1f89` (GPU proof), `e01a3cc`, `292e800`, `eedd136` (`proving/igneum-prove`: shard cutter, MPT witnesses, shard and aggregator guests); SP1 6.8.1; spec 7.2, 7.6 | `proving/windows-wsl2` (SETUP-PROVER, PROVE-BLOCK) on the RTX 5090; `igneum-prove-host --mode block` on `proving/fixtures/`; bench-log "proving v0 on the RTX 5090" and "proving: devnet v4 shards" | First GPU proof of an Igneum block, 4 October 2026, RTX 5090 (WSL2, SP1 cuda, mining paused): fixture `block-78-increment` (2 transactions), core proof 1.4 s (7.3 MB, verify 0.221 s), compressed proof 2.7 s (1.27 MB, verify 0.038 s), post-state and receipts roots identical to the node's; 15.7x and 20.6x faster than a loaded M5 Max CPU. The same day on that CPU (load 38 to 47): a three-shard block proved shard by shard and aggregated by recursion, 19 min (1,139 s) end to end, 245 to 337 s per compressed shard proof, every proof verified. What is not there: no proof is produced, carried or checked on the chain (the devnet prover is a stub that signs claims), the proving pool pays nobody (row 21), the block proven is far below one shard, and the 60-second figure remains a design target; the pass mark is the standard in `docs/benchmarks/proving-e2e.md`. Second RTX 5090 run, 4 October 2026 evening (job run-20261004-173115): a full shard at the provisional S_p (6.75 M pgas, 60.8 M cycles) executed in 1.63 s, core proof 8.3 s (18.1 MB), compressed proof 10.9 s (1.27 MB, verify 0.040 s); a two-shard block (13.5 M pgas) proved shard by shard (11.7 s and 10.0 s) and aggregated in 2.2 s, 24 s of GPU stages end to end, every proof verified, six tampered witnesses rejected. The two host defects (an abort after the upload, an idle wait that turned out to be an unbuffered 18 MB proof save through the WSL2 file bridge, 24 minutes) are fixed (ledger P20) | none yet |
|
||||
| 16 | A 12 GB card proves one shard in about 20 s | Litepaper Proving ("The proving budget"); roadmap gate 2 | designed | spec 5.1 (Target), 7.6 (`S_p` provisional, 7,500,000 pgas = `B_p` / 4) | `PROVE-SHARD.bat` on the RTX 5090 (pending); the end-to-end standard in `docs/benchmarks/proving-e2e.md`; bench-log "proving: devnet v4 shards" | Measured on a 32 GB card, not yet on a 12 GB card. A shard at the provisional `S_p` is 60.8 M SP1 cycles on the prototype pgas table (9 cycles per pgas, 44 per EVM gas; the modexp entry about 100x its SP1 cost); on an RTX 5090 (4 October 2026 evening, job run-20261004-173115) it executed in 1.63 s and its compressed proof took 10.9 s, verified in 0.040 s, so the 32 GB card is inside the 20 s target with margin. Whether a 12 GB card proves it at all, and in what time, is the next measurement (an RTX 3060 and an RTX 5060 Ti 16 GB are on order). A per-shard time can be met by shrinking the shard, so the project does not use it as a pass mark | none yet |
|
||||
| 17 | The chip resistance target: a chip gains under 2x over a GPU | Litepaper Mining, "What Igneum does not claim"; homepage "no chip can be built for it" | designed | spec 0.2 (Target); O-1.17 | Public benchmark with a leaderboard by card model and a standing bounty, January 2027 (O-1.17); the on-die-SRAM test on the RTX 5090 (R3.5) | A target, not a measurement. Review round 3 priced a recompute chip with the 256 MiB cache on die at about 2.4x, approximate, before the usual chip-versus-GPU integer gain; the design answer (cache larger than any die) is open (spec 1.16) | none yet |
|
||||
| 18 | The chip resistance measurements: the program is random-access bound, not bandwidth bound, and sits beyond a card's on-chip cache | Litepaper Mining ("bound by memory bandwidth", to be corrected), vs RandomX "Measured so far" | tested by the team | repo `aba248d`, `f2a1a64`, `4b95c5e` | RTX 5090 dataset sweep 4 MiB to 1 GiB with `proto-cuda/host.cu`; bench-log "RTX 5090 first run" and "dataset sweep" | At 1 GiB: 228.1 Mhash/s, 23.7 G random loads/s, 94.9 GB/s useful against a 1,638 GB/s dataset fill; inside the 96 MiB L2 (4 and 64 MiB) 1,340 to 1,353 Mhash/s, about 5.8x faster; 104 against 128 loads per hash gives 228 against 185 Mhash/s, proportional. 3 October 2026, RTX 5090, Windows, CUDA 12.8, version 1 programs. Prototype dataset 1 GiB against 2 GB at genesis; a pure random-read microbenchmark (R3 chip designer, attack 2) has not run; the sweep has not been repeated on version 2 | none yet |
|
||||
| 19 | The lottery hash is sound as a hash: uniform output, deterministic, no out-of-bounds read, fuzzed | Litepaper vs RandomX ("Every number above is measured and logged") | tested by the team | repo `c52307e`, `58a5a63`, `b27da39`; `proto-metal/TESTS.md` | `proto-metal/igneum-bench --fuzz --edge --stats --determinism --memcheck`; `--fuzz 2000` on the version 2 generator; `igneum-census`; bench-log "hardening tests", the re-run on the memory-hard dataset, "generator version 2 adopted" | Version 1: 10,200 random programs, 1,305,600 hashes, 0 mismatches; 14 of 14 edge cases; bit frequency within 2.90 sigma, avalanche mean 31.99 to 32.04 of 32; deterministic fingerprint across 5 runs; every dataset read masked, 3 October 2026. Version 2, 4 October 2026: 2,000 random programs through the Metal cross-check, 8,000 warps, 0 mismatches, 128 loads per hash on every program; 20,000-program census, 5.2% rejected (4.1% static, 1.1% dynamic). Apple M5 Max. Statistics are not a security proof; the edge, stats and memcheck sections were not re-run on version 2 (they do not depend on the generator); the seed derivation review (O-1.4) is open; the fuzz set has run on Metal and the CPU only | none yet |
|
||||
| 15 | Every block is proven, with the proof landing within about a minute at launch | Homepage stats ("~60 s to a proof"); litepaper Proving; roadmap phase 3 gate | implemented | repo `d7e1f89` (GPU proof), `e01a3cc`, `292e800`, `eedd136` (`proving/igneum-prove`: shard cutter, MPT witnesses, shard and aggregator guests); SP1 6.8.1; spec 7.2, 7.6 | `proving/windows-wsl2` (SETUP-PROVER, PROVE-BLOCK) on the RTX 5090; `igneum-prove-host --mode block` on `proving/fixtures/`; bench-log "proving v0 on the RTX 5090" and "proving: devnet v4 shards" | First GPU proof of an Igneum block, 4 October 2026, RTX 5090 (WSL2, SP1 cuda, mining paused): fixture `block-78-increment` (2 transactions), core proof 1.4 s (7.3 MB, verify 0.221 s), compressed proof 2.7 s (1.27 MB, verify 0.038 s), post-state and receipts roots identical to the node's; 15.7x and 20.6x faster than a loaded M5 Max CPU. The same day on that CPU (load 38 to 47): a three-shard block proved shard by shard and aggregated by recursion, 19 min (1,139 s) end to end, 245 to 337 s per compressed shard proof, every proof verified. What is not there: no proof is produced, carried or checked on the chain (the devnet prover is a stub that signs claims), the proving pool pays nobody (row 21), the block proven is far below one shard, and the 60-second figure remains a design target; the pass mark is the standard in `docs/benchmarks/proving-e2e.md`. Second RTX 5090 run, 4 October 2026 evening (job run-20261004-173115): a full shard at the provisional S_p (6.75 M pgas, 60.8 M cycles) executed in 1.63 s, core proof 8.3 s (18.1 MB), compressed proof 10.9 s (1.27 MB, verify 0.040 s); a two-shard block (13.5 M pgas) proved shard by shard (11.7 s and 10.0 s) and aggregated in 2.2 s, 24 s of GPU stages end to end, every proof verified, six tampered witnesses rejected. The two host defects (an abort after the upload, an idle wait that turned out to be an unbuffered 18 MB proof save through the WSL2 file bridge, 24 minutes) are fixed (ledger P20) 5 October 2026, live devnet with real transactions (bench-log "real transactions, the first non-empty shard proven and paid"): block 72704 shard 0, 29 transfers, 5,800 pgas, proven on PC 2 in 34 s, verified on the Mac in 0.297 s and paid 1.7623 IGN, 53 s after the chain block executed; of about 1,400 blocks in the 20-minute window 36 were proven (the one prover takes the newest shard assigned to it), so "every block" is not yet true; a second content shard (72803, all copies skipped) failed the native-execution veto on the exporter's block structure, fixed with fixtures the same day, the node side pending the 0.3.9 rollout 5 October 2026, evening (bench-log "proving v1"): the aggregated segment record, the chain rule and the unproven rule are implemented behind `proving_v1_activation_daa` (branch proving-v1, not on the devnet before 0.3.11); on the RTX 5090 a chain of 8 consecutive live blocks proved and aggregated by recursion in 135.6 s with the miner on the card (17 s a block, one proof of 1,272,909 bytes attesting all 8, verified in 0.04 s); the 3-node fast-time harness paid a segment record 1.0 s after submission and refused a late one after its deadline (21 checks); the devnet itself, with one prover, carried proofs for 2.4% of blocks over 30 minutes at a block-to-record latency p50 44 s, p99 52 s. The "within about a minute" holds per proven block; "every block" needs 18 mining 5090s or 6 proving-only cards at empty blocks on the measured rates, and the mandatory rule stays off until the share is one | none yet |
|
||||
| 16 | A 12 GB card proves one shard in about 20 s (WITHDRAWN 5 October 2026: a 24 GB card proves a full shard at the adopted size in 4.3 s; 32 GB mines and proves) | Litepaper Proving ("The proving budget"); roadmap gate 2 | designed | spec 5.1 (Target), 7.6 (`S_p` provisional, 7,500,000 pgas = `B_p` / 4) | `PROVE-SHARD.bat` on the RTX 5090 (pending); the end-to-end standard in `docs/benchmarks/proving-e2e.md`; bench-log "proving: devnet v4 shards" | Measured on a 32 GB card, not yet on a 12 GB card. A shard at the provisional `S_p` is 60.8 M SP1 cycles on the prototype pgas table (9 cycles per pgas, 44 per EVM gas; the modexp entry about 100x its SP1 cost); on an RTX 5090 (4 October 2026 evening, job run-20261004-173115) it executed in 1.63 s and its compressed proof took 10.9 s, verified in 0.040 s, so the 32 GB card is inside the 20 s target with margin. Whether a 12 GB card proves it at all, and in what time, is the next measurement (an RTX 3060 and an RTX 5060 Ti 16 GB are on order). A per-shard time can be met by shrinking the shard, so the project does not use it as a pass mark 5 October 2026, evening (bench-log "proving v1", the S_p curve): measured on the RTX 5090 with SP1 6.8.1's GPU prover, the card to itself, 1-s nvidia-smi samples: an empty shard 13,874 MiB and 2.2 s; a full shard at the ADOPTED v1 budget (30,000 pgas, 4.7 M cycles) 20,434 MiB and 4.3 s; the full prototype shard (6.75 M pgas, 60 M cycles) 28,307 MiB and 10.8 s; beside the miner 15,670 and 30,039 MiB. No environment knob of SP1 moves the 13.9 GB floor and the GPU server has no options of its own, so on this build a 12 GB card proves nothing, a 16 GB card only empty shards, a 24 GB card the adopted full shard alone and beside the miner (22,210 MiB and 13.2 s, measured on the 32 GB card: the 5090's allocation pattern, not yet a run on a 24 GB card) and a 32 GB card the prototype shard beside the miner with 2.5 GB spare. The litepaper line now says so; the 12 GB gate returns when a prover build with a smaller floor is measured on a 12 GB card | none yet |
|
||||
| 17 | The chip resistance target: a chip gains under 2x over a GPU | Homepage hero and litepaper abstract ("a custom chip gains under 2x, and the model and the bounty are public"), litepaper "What Igneum does not claim" | tested by the team (the model), designed (the target) | program class v3 (Counter ASIC 2.0, 5 October 2026): branches ca2-v3 d233fa1 and after, ca2-mixer 1ab8b21, ca2-era 78c0ee4; `docs/analysis/chip-model-v3.md`, `docs/analysis/sram-mirror.md`, `docs/analysis/scratch-soundness.md` | The m16 recompute model re-run on the measured v3 rates and verifier times; the on-die-cache chip row | The on-die-cache recompute chip against the RTX 5090's measured 136.1 MH/s: class v2 2.4x; class v3 (mixer x8) 0.31x bare, 0.92x with a 3x fixed-function allowance (approximate), 0.76x at equal silicon; margin 8% on the allowance, 9% on the budget. 5 October 2026, M5 Max, RTX 5090, RX 9070 XT. The 2x target is a target: no chip has been built; the bounty stands (O-1.17) | none yet |
|
||||
| 18 | The chip resistance measurements: the program is latency-bound (random reads), not bandwidth-bound, on every card we own, and sits beyond a card's on-chip cache | Litepaper Mining ("waits on memory latency, not on maths or bandwidth"), vs RandomX; the numbers page | tested by the team | readwidth e752fc7 (`docs/plans/read-width.md`), ca2-era 78c0ee4, ca2-cache 2de19e5 (`docs/plans/hot-table.md`) | The dependent-read probes at 32 to 1,024 MiB and the hash rate per class on the three cards; the latency-bound share = rate over the probe ceiling per load | Latency-bound share at the 1 GiB dataset: RTX 5090 0.96 (v2) and 1.01 (v3), RX 9070 XT 0.87 and 0.95, M5 Max 1.01 and 1.06; wider reads do not close the AMD gap (the 9070 XT does 2.4 G dependent reads per second at every width; the 5090 goes bandwidth-bound at 64 B, share 0.58); a 32 to 96 MiB hot table is not kept resident by any card while the dataset streams (g 0.80 to 0.87 in the added form). 5 October 2026 | none yet |
|
||||
| 19 | The lottery hash is sound as a hash: uniform output, deterministic, no out-of-bounds read, fuzzed; class v3 bit-exact on the three vendors | Litepaper vs RandomX ("Every number above is measured and logged"), the numbers page | tested by the team | ca2-mixer 1ab8b21 (`tests/mixer.rs`, `tests/scratch.rs`), ca2-era 78c0ee4, ca2-soundness a465881 (`docs/analysis/scratch-soundness.md`), `igneum-pow/tests/packs.rs` | The crate suite (53 + 4 + 19 + 7), the Metal fuzz, edge, stats and determinism runs on the v3 construction, the pack vectors and 2^24 fingerprints on Metal, Apple OpenCL, the RTX 5090 and the RX 9070 XT, the 1,024-hash CPU re-check per card | Class v3 (mixer x8 + era): 200-program fuzz 200 of 200 on Metal, every tenth on Apple OpenCL; the pinned v3 packs 3/3 + 3/3 and 96 of 96 lanes on Metal and Apple OpenCL; the six era packs' fingerprints equal on the three vendors (PC 1 job run-ca2-era-pc1-20261005, 5 October 2026); the v2 exports byte-identical on the v3 crate; the final-class PC rows and the G2 re-check: job run-ca2-era-pc1b-20261005 (pending at the time of writing) | none yet |
|
||||
| 20 | No premine, no pre-sale, no allocation: every coin is minted by the schedule and every coin goes to the block producer (80%) and the proving pool (20%) | Homepage stats and Economics tiles; litepaper Supply, Economics | implemented | repo `6ac80a3`; fork "igneum-node devnet v0"; `consensus/core/src/igneum.rs`, `coinbase.rs` | `cargo test -p kaspa-consensus-core igneum` (8 pass: subsidy table, ramp, split, cap) and `cargo test -p kaspa-consensus coinbase` (8 pass); `igneum-miner inspect 40`; bench-log "igneum-node devnet v0" | Coinbases on the devnet: 80/20 exact on 39 of 39 single-payee blocks, the 20% to the `igneum-proving-pool-v0` output; the per-second schedule sums to under the 4,000,000,000 cap by less than 100 coins; 3,168,808,781 units per DAA second in years 0 to 2, halving at 63,115,200 DAA s. 3 October 2026, Apple M5 Max. The devnet genesis carries no allocation; the mainnet genesis does not exist yet, so the claim is about the code and the stated rule, not a launch that has happened | none yet |
|
||||
| 21 | The proving pool's 20% reaches shard provers and aggregators | Litepaper Economics; homepage "20% provers" | designed | spec 5.3; `proving/igneum-prove` carries the prover's payout address in every shard proof (ledger P12) | None. The pool output exists (row 20); the payout from it against proof records is unwritten | The escrow accumulated on the simnet (92.55 IGN at the end of the v3 run) and nothing can draw it. Rule decided: per block, divided among shards by consensus proving cost, sortition to 8 provers for 10 s then open (spec 7.2). The economy model of 4 October 2026 (`sim/economy`, 1,000 operators, 30 days) kept every block proven within 60 s under six stress scenarios; a model, not hardware | none yet |
|
||||
| 21 | The proving pool's 20% reaches shard provers and aggregators | Litepaper Economics; homepage "20% provers" | tested by the team | spec 5.3; `proving/igneum-prove` carries the prover's payout address in every shard proof (ledger P12) | None. The pool output exists (row 20); the payout from it against proof records is unwritten. Since 5 October 2026: the payout rule is live on the devnet (`proving.rs shard_payouts`, the carrying segment pays the first valid record per shard its part of the segment's pool credit) | The escrow accumulated on the simnet (92.55 IGN at the end of the v3 run) and nothing can draw it. Rule decided: per block, divided among shards by consensus proving cost, sortition to 8 provers for 10 s then open (spec 7.2). The economy model of 4 October 2026 (`sim/economy`, 1,000 operators, 30 days) kept every block proven within 60 s under six stress scenarios; a model, not hardware Live devnet, 5 October 2026: 388 shards paid by 16:02 UTC, 446.13 IGN from the pool to PC 2's payout address, 0.8813 IGN per mergeset block of the proven segment (bench-log entries of 5 October: "the first shards proven, verified and paid" and "real transactions, the first non-empty shard proven and paid") | none yet |
|
||||
| 22 | The base fee is burned in full and the priority fee splits 80% to the miner and provers, 20% to the apps whose code ran | Homepage Economics caption and Build card; litepaper "Where fees go" | tested by the team | repo `f5f8c80`; fork worktree `vendor/igneum-node-exec` | `tools/evm-smoke/smoke.mjs` receipt checks; bench-log "execution layer devnet v3" | Transfer receipt: `burnedProvingFee` 200 gwei, `minerTip` 16,800 gwei (80%), unregistered developer share 4,200 gwei burned; contract call: 80% to the miner, 20% credited to the payee the constructor registered, balance delta equal. 3 October 2026, Apple M5 Max simnet. The provers' part of the 80% is not split out (no provers exist); the base fee stayed at the 1 gwei floor throughout | none yet |
|
||||
| 23 | No fee to any team, foundation or fund; 0 admin keys in consensus | Homepage Economics tiles and caption; litepaper "No fund, no foundation" and Governance | designed | spec 5.5, 5.6 (decided 3 October 2026); spec 08 | Reading: no coinbase output, fee route or consensus key in the fork names any party (`coinbase.rs`, `docs/fork-divergence.md`) | The emission code has two outputs (row 20) and the fee code has three routes (row 22), none to a team. The 1% fee of the official client is a client setting, not a protocol rule, and is not implemented. The release key of spec 08 signs client updates (the Igneum Miner app's over-the-air manifest since 4 October 2026, Ed25519) and holds no consensus power; its custody policy is open (O-8.1) | none yet |
|
||||
| 24 | External proving jobs pay 90% to the provers who delivered and burn 10%, once settled in IGN | Homepage "IGN burned from jobs, phase two"; litepaper Proving and Economics | designed | spec 5.4 | None. Needs the proof bridge (spec 7.3, phase two) and the settlement switch (O-5.2) | At launch jobs are paid on the customer's chain in the customer's currency and nothing is burned (ledger P10). No job market code exists | none yet |
|
||||
|
|
@ -60,9 +60,9 @@ Versions in the table: `igneum-pow` is the Rust crate at `igneum-pow/Cargo.toml`
|
|||
|
||||
| Status | Rows |
|
||||
|---|---|
|
||||
| designed | 6 (rows 16, 17, 21, 23, 24, 26) |
|
||||
| designed | 5 (rows 16, 17, 23, 24, 26) |
|
||||
| implemented | 3 (rows 2, 15, 20) |
|
||||
| tested by the team | 21 (rows 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 18, 19, 22, 25, 27, 28, 29, 30) |
|
||||
| tested by the team | 22 (rows 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 18, 19, 21, 22, 25, 27, 28, 29, 30) |
|
||||
| reproduced externally | 0 |
|
||||
| reviewed independently | 0 |
|
||||
|
||||
|
|
@ -81,12 +81,20 @@ Versions in the table: `igneum-pow` is the Rust crate at `igneum-pow/Cargo.toml`
|
|||
| 26 | test-network certificate | live devnet certificate | the first live lock |
|
||||
| 29, 30 | new | tested by the team | the cloud network's propagation run; the one-click app on PC 2 |
|
||||
|
||||
## What moved on 5 October 2026
|
||||
|
||||
| Row | Before | After | Why |
|
||||
|---|---|---|---|
|
||||
| 15 | implemented | implemented, with a live result | the first non-empty shard (block 72704, 29 transfers) proven, verified and paid on the devnet; not every block is proven yet |
|
||||
| 21 | designed | tested by the team | 388 shards paid from the pool on the live devnet, the rule in `proving.rs`, the numbers in the bench log |
|
||||
| 22 | Card lifetime: a 4 GB card mines about four years and an 8 GB card about twelve, under the dataset's step schedule (2 GB at genesis, doubling at years 4, 12, 28, 60) with the cache freed after the daily build | Litepaper Hardware and vs RandomX ("Dataset" row); homepage Mine card and "Memory" row | designed | `docs/analysis/card-lifetime-2026-10-05.md` (branch card-lifetime 1fecfe2); spec 1.13.3 option (b) recommended to the project lead 5 October 2026 (`docs/plans/counter-asic-2-rollout.md` 6c) | The per-tier working-set arithmetic of that document (GTX 1650, RTX 3050, RTX 3060, RTX 4090 tiers) against the step schedule | A design claim: under the continuous mapping (a) a 4 GB card is out within 1 to 1.5 years and an 8 GB card at 6 to 7.5 years, so the sentence is true only under the step schedule (b), which the spec has not yet fixed (O-1.13) | none yet |
|
||||
|
||||
## What would move a row
|
||||
|
||||
| From | To | What it takes |
|
||||
|---|---|---|
|
||||
| designed | implemented | Code in this repository with test vectors that pass |
|
||||
| implemented | tested by the team | A bench-log entry with the machine, the date, the command and the number |
|
||||
| tested by the team | reproduced externally | The repository public (January 2027), the command published, and a third party's run with the same result, linked from the row |
|
||||
| tested by the team | reproduced externally | The repository public (at the public testnet), the command published, and a third party's run with the same result, linked from the row |
|
||||
| reproduced externally | reviewed independently | A named reviewer's published finding on that version. Funding for review is `docs/plans/funding.md` |
|
||||
| any | the row's status falls back | A new version of the code or rule the row names |
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Internal working list. Written 3 October 2026 against `docs/fud-ledger.md` version 0.1, `docs/spec/06-open-items.md`, `docs/bench-log.md`, and the public text in `site/index.html` (HP), `site/litepaper.html` (LP) and `site/journey.json` (J) as checked this evening. The ledger itself is not changed by this file. Entries are referenced by ledger id; overclaims by item number.
|
||||
|
||||
Decisions of 3 October 2026 applied throughout: (a) no development fund, priority fee 80% to miners and provers and 20% to the called app, external jobs 90% to provers and 10% burned; (b) the ledger is internal; (c) the project is established offshore and the founder's location is never mentioned; (d) deSEC nameservers now, a non-US registrar in December 2026; (e) the dedicated identities exist (GitHub organisation igneum-network with one anonymous owner, Vercel team igneum, Google Workspace on igneum.network).
|
||||
Decisions of 3 October 2026 applied throughout: (a) no development fund, priority fee 80% to miners and provers and 20% to the called app, external jobs 90% to provers and 10% burned; (b) the ledger is internal (superseded 5 October 2026: the ledger is published with the repository at the public testnet); (c) the project is established offshore and the founder's location is never mentioned; (d) deSEC nameservers now, a non-US registrar in December 2026; (e) the dedicated identities exist (GitHub organisation igneum-network with one anonymous owner, Vercel team igneum, Google Workspace on igneum.network).
|
||||
|
||||
## 1. Summary
|
||||
|
||||
|
|
@ -12,14 +12,14 @@ Decisions of 3 October 2026 applied throughout: (a) no development fund, priorit
|
|||
4. Answered: 16 entries. All 16 still carry a wording fix, an experiment or a counsel check.
|
||||
5. Overclaims: 78 items. 10 already fixed (5, 6, 9, 22, 23, 27, 38, 40, 47, 71), 3 partly fixed (11, 31, 75), 65 still present or still missing. Item 23 is fixed but stale. Item 73 must not be applied as written.
|
||||
6. The 3 October decisions close E4 outright, narrow G4, G8 and L3, and change E3, E5, G3 and G5 (section 4). The dev fund removal forces a rewrite of 11 places in the site, spec and design files (row 7).
|
||||
7. EXPOSES: six items. CLAUDE.md (full name, other companies, registrar, database region, personal GitHub handle), the cryptographer agent file, the commit handle igneum-labs, the +0100 timezone on every commit, the earlier ledger text "The founder is in the UK" still in git history, and the ledger itself in the tree. All are fixed in section 5.
|
||||
7. EXPOSES: six items. CLAUDE.md (full name, other companies, registrar, database region, personal GitHub handle), the cryptographer agent file, the commit handle igneum-labs, the local-time offset on every commit, the earlier ledger text "The founder is in the UK" still in git history, and the ledger itself in the tree. All are fixed in section 5.
|
||||
8. Measured since the ledger was written: the 256 MB cache dataset is built and bit-exact on Apple, NVIDIA (CUDA and OpenCL) and an AMD integrated chip; the shortcut is 4.8x slower than honest on Apple; CPU verify is 0.41 to 1.2 ms per warp with the cache; the VDF grinding simulation ran. Items 16, 20, 21, 23, 56 and 78 need these facts, not the ledger's replacements.
|
||||
9. By timing: 44 rows now (site text, decisions, accounts; two of them continue into December and mainnet), 12 before the repository goes public (citations, counsel, gate 1 measurements, the phase 2 benchmark), 16 before public testnet (gate 2 and 3 work, policies), 1 before mainnet, 1 with nothing further (F6).
|
||||
10. Five most urgent: section 5 steps 1 to 4 (the tree and history must be clean before any public push); rows 1 to 5 (dead links, the miner button, no contact route, listings, Day one); row 7 (dev fund text everywhere); row 9 (the founder is pseudonymous now, the text and the ledger disagree); rows 43 to 46 (trademark and counsel).
|
||||
|
||||
## 2. Every open or conceded entry
|
||||
|
||||
Who: the project lead (decision or account), Claude (text, spec, code, simulation), counsel, measurement (an experiment that produces a number). When: now, before public repo (January 2027 with the benchmark), before testnet (August 2027), before mainnet (November 2027).
|
||||
Who: the project lead (decision or account), Claude (text, spec, code, simulation), counsel, measurement (an experiment that produces a number). When: now, before public repo (opens at the public testnet, August 2027), before testnet (August 2027), before mainnet (November 2027).
|
||||
|
||||
| # | Ledger | Issue | Fix | Who | When | EXPOSES |
|
||||
|---|---|---|---|---|---|---|
|
||||
|
|
@ -31,7 +31,7 @@ Who: the project lead (decision or account), Claude (text, spec, code, simulatio
|
|||
| 6 | L2 (with E2) | Inducement wording: "so the people who show up early get the most" (LP 183), chart caption "Half of the 4 billion cap is mined in the first two years" (LP 184), HP "Half of all IGN is mined in the first two years" | Items 54 and 76: schedule facts, no "so" clause. Counsel opinion is row 46 | Claude | now | no |
|
||||
| 7 | E4, E5, G5, G8, O-5.4 | Dev fund text everywhere after decision (a): LP Economics (65/15/15/5 paragraph, "Development, paid by outsiders" section), LP Governance (fund bullet, second-client bullet), LP firsts row ("a development fund paid by outsiders"), HP Economics ("Development is paid from fees..."), spec 05 sections 5.2, 5.4, 5.5 and the self-dealing arithmetic, spec README row 5, spec 00 row 5, spec 06 items O-5.4 and O-5.7, docs/design/execution-layer.md lines 143, 210 and 217, the design document (CLAUDE.md already carries the new split) | Rewrite to: base fee burned in full; priority fee 80% miner and provers, 20% called app; external jobs 90% prover, 10% burn; no fund. the project lead confirms the 15% priority-fee burn is gone on purpose (section 4) | Claude, the project lead confirms | now | no |
|
||||
| 8 | E5 | HP "Not one coin to a founder, a fund or a stake" while the official client carries a 1% dev fee to the founder's company; LP spreads the dev fee, the pool and the proving business over three sections | Item 62 on HP. One LP heading that holds the 1% dev fee, the pool, the proving business, and that these now fund development since there is no fund | Claude | now | EXPOSES: "the founder's company" must be the offshore entity once it exists, never a company the project lead already owns |
|
||||
| 9 | G3 | The ledger's answer says "The founder's name is on every commit". Decisions (c) and (e) make the founder pseudonymous | Do not apply item 73 as written. LP "Who are you?" gets: "The founder is pseudonymous until the team page at public testnet. No cryptographer is hired yet." the project lead re-confirms whether the team page at public testnet names anyone | the project lead decides, Claude writes | now | EXPOSES: the ledger answer and CLAUDE.md name the founder; see section 5 |
|
||||
| 9 | G3 | The ledger's answer says "The founder's name is on every commit". Decisions (c) and (e) make the founder pseudonymous | Do not apply item 73 as written. LP "Who are you?" gets: "The founder is pseudonymous until the team page at public testnet. No cryptographer is hired yet." Decided 5 October 2026: no team page for now; the litepaper says the team is pseudonymous and names no team page | the project lead decides, Claude writes | now | EXPOSES: the ledger answer and CLAUDE.md name the founder; see section 5 |
|
||||
| 10 | F5 | "whole network's hashrate" misread | Item 32 | Claude | now | no |
|
||||
| 11 | F10, F4, G8 | Pool concentration unstated; LP heading "Speed and finality, powered by miners alone" | Items 33 and 43. Heading becomes "Speed and finality, a miner-weighted overlay on proof of work". Name pool concentration as the governance risk | Claude | now | no |
|
||||
| 12 | C3, M3, C8, C11 | Kaspa misstated; firsts table; LP 68 "taken over by chips, as Kaspa was" still reads as capture; Conflux missing from the problem section | Apply items 4, 7, 8, 10, 11 and 29. Items 5, 6, 9 and 47 are done | Claude | now | no |
|
||||
|
|
@ -163,6 +163,32 @@ Added after `docs/review/external-2026-10-03.md`. Rows continue the numbering of
|
|||
| 102 | X17 | Client shows gross earnings only; no mining and proving split, no failed jobs, no isolation design | O-8.2 display rules (client and phone-app 4.1, written 3 October 2026 night); O-8.3 isolation design before the first external job; update control already spec 8.2 item 4 | Claude (design); miner client (code) | before testnet | no |
|
||||
| 103 | M22 | Bounty has no metric, judge, eligible hardware or fund; 2x is not the economic line | Scoring rules (per-program distribution of hash/s and hash/J, capital per unit of hash rate, longevity, shortcut classes) published with the benchmark; funder, judge and reward are the project lead's; public claim limited to "competitive against the best independently proposed design across tested workloads" | Claude (rules), the project lead (fund) | before public repo | EXPOSES: the payer is the entity (row 50) |
|
||||
|
||||
### 2.6 Sweep (5 October 2026, evening), round 6: the experiment and status items
|
||||
|
||||
Appended by the consensus engineer and cryptographer agent (worktree `igneum-wt-fud-a`); the wording items of the same evening are the other agent's section. Rows below name the earlier row they touch; nothing above is rewritten. Evidence in `docs/bench-log.md`, "5 October 2026 (evening), FUD ledger sweep round 6".
|
||||
|
||||
| Row touched | Ledger | What changed (5 October 2026, evening) | Who next | When |
|
||||
|---|---|---|---|---|
|
||||
| 106 | G12, X18 | Done and rolled out (0.3.5, 07:33 BST; every node prints the digest, the digest refused the early-restarted hand node for 20 min at 08:15 BST). Left: the digest-less allowance on devnet and simnet, `rollout-v2.sh` two-field write | consensus engineer | before testnet |
|
||||
| 107 | F23, F24 | Done and rolled out (0.3.5): 0 "names N voters" refusals and 0 CONFLICTING on five machines in 10 h; checkpoints 2970 and 2971 re-determined on three machines at 10:56 BST with no hole | none | done |
|
||||
| 108 | M26, M27, X21 | Done and rolled out (0.3.5): the 0.3.4 prepare storm (4,299 refusals in 2 h on PC 1) ended at the 0.3.5 start, 0 `prepare-failed` since; cards read `mismatched=0 faults=0`. Left: the edited-kernel red-card test on a PC | miner lead | when convenient |
|
||||
| 117 | M20 | Done and rolled out (0.3.5, `m20-pruning`, 4 tests). Left: the live test, a fresh node syncing once the pruning point leaves genesis (still genesis at DAA 113,289 at 16:00 UTC) | consensus engineer | tonight or tomorrow, when the point moves |
|
||||
| 2.5 (M30, "the flood memory growth") | M30 | Done and rolled out (0.3.5): cache builds equal node restarts (5 / 5 / 8 in 10 h), none at the epoch rolls | none | done |
|
||||
| (F25) | F25 | Done and rolled out (0.3.5 merge 7abce72); both harness libraries ran tonight | none | done |
|
||||
| (F21, F22) | F21, F22 | Shipped in every node since 0.3.4; the switch `finality_v3_activation_daa` is absent from the live override file. Rollout = N3 of `docs/plans/finality-v3-rollout-devnet.md` | the project lead (N3), then the operator steps | now |
|
||||
| 50 | M1 | Program space counted (about 2^1550 shapes under a 2^256 seed; the per-program spread is 1.10x under version 2). The claim stays a target; the experiment stays the bounty and benchmark | the project lead (M22 terms), Claude (benchmark) | before public repo |
|
||||
| 60 | M11 | Measured on five machines, four compilers, three vendors over 10 to 12 boundaries each: NVRTC 0.6 to 1.1 s, OpenCL 7 to 12 s (iGPU 55 to 124 s beside builds), Metal under 0.5 s plus the race; 5090 race +0.00%, base twice. Left: multi-card rig, ROCm (hardware) | measurement (O-1.16) | before testnet |
|
||||
| 55 | M16 | Cost model written (`docs/analysis/m16-recompute-attacker-2026-10-05.md`): 1.5x to 2.4x at equal integer budget, 3x to 6x with a fixed-function factor, the mixer-cost lever. Left: the 64 MiB-cache inline kernel on the 5090 (PC job), O-1.6 | measurement; the project lead at gate 1 | before public repo |
|
||||
| 121 | M21 | Block sizes measured (p50 723 B, max 6.9 KB); k 5 at the measured p99, 6 with 500 KB bodies, 18 at 5 s. Left: O-2.2 with proof-bearing bodies | consensus engineer | before testnet |
|
||||
| 57 | P3 | The certificate half measured in a phone-sized tab (139 to 155 ms cold, 58 to 68 ms warm, on the laptop's CPU; no phone); the wrapper is unbuilt | measurement (phase 2) | before public repo |
|
||||
| 69 | P9 | Parameter table written from the live numbers (8 assignees, window 10 DAA s today, 25 s proposed, no shard bond, 120-s job claim timeout, `S_p` 30,000 at v1). Left: O-5.1 and O-5.6 values on the phase 4 devnet | the project lead (values), measurement | before testnet |
|
||||
| (P14) | P14 | Fixed in spec 05 section 5.1: one controller, the EIP-1559 step of `next_base_fee`. Left: design 4.1's "smoothed" phrase; R1 band, R8 | execution engineer | when convenient |
|
||||
| (F16) | F16 | Two options priced, B recommended (never withdraw, as 3.11.4). Left: replace the 3.5 paragraph, `finality_conflict` in the node, the forced double-certificate test | the project lead (gate 3), consensus engineer | before testnet |
|
||||
| 73 | X5 | Measurement defined: N_ind = distinct (ASN, machine fingerprint, pool attestation) classes among keys above dust; today N_ind by fingerprint is 5 for 21 keys. Left: the observer's ASN and fingerprint columns, the pool statement format | Claude (observer), the project lead (definition) | before testnet |
|
||||
| 65 | C4 | The overlay measured against bare GHOSTDAG on the same binary (`tools/finality-attacks/c4.mjs`): see the ledger entry and the bench-log table | cryptographer | before testnet |
|
||||
| 43, 44, 46, 58 | L1 to L5 | Untouched; decision owner line added (the project lead, with counsel) | the project lead, counsel | as rowed |
|
||||
| 9 | G3 | Untouched; decision owner line added (the project lead) | the project lead | now |
|
||||
|
||||
## 3. Overclaims still in public text today
|
||||
|
||||
Checked against the files this evening. "present" means the quoted text is still live. "missing" means the item is an addition that has not been made. "fixed" means the replacement is in. Line numbers are from the stripped page text, not the HTML.
|
||||
|
|
@ -264,10 +290,28 @@ Added after `docs/review/round-4-2026-10-04.md`. Rows continue the numbering of
|
|||
| 111 | M29 | LP 424 describes earnings in currency, a hardware wallet and proving the app does not have | Rewrite to 0.3.3; earnings, currency and the hardware wallet become a roadmap sentence | Claude | now | no |
|
||||
| 112 | F21 | LP 511 says a third of the blocks is needed to split finality in a partition | The round-4 section 1 (b) sentence | Claude | now | no |
|
||||
| 113 | X24, X25, X26, X27 | Token in the URL path and printed; agent self-installs at every start; permanent feed with the dl token in bodies; free-text `from` and no clean rotation | Header token in every client, HSTS, masked prints; arm only on `reboot_continue`; retention and a cap; sender binding; documented rotation (3 h) | relay owner | now | no |
|
||||
| 114 | G14 | The intake key (8 commits), the dl token (1), the review files, 51 files with the first name, `+0100` stamps | Section 5 step 4's rewrite list extended; `TZ=UTC` now | the project lead, Claude | before public repo | EXPOSES: yes, the whole row |
|
||||
| 114 | G14 | The intake key (8 commits), the dl token (1), the review files, 51 files with the first name, local-time stamps | Section 5 step 4's rewrite list extended; `TZ=UTC` now | the project lead, Claude | before public repo | EXPOSES: yes, the whole row |
|
||||
| 115 | X19, X20, M25, M28, X22, X28, X29, E17 | The minors of round 4 (node knobs and silences, cold-sync cost, miner day length, kernel commitment, restart paths, relay hygiene, host and file modes, unlogged economics inputs) | As each ledger entry says; the stray token-named file and the 0644 modes today | consensus engineer, miner-community-lead, relay owner, Claude | when convenient | no |
|
||||
| 116 | X30 | Bench page private strings; /api/live addresses, key hashes, payout addresses; the mobile menu | Fixed 4 October 2026: `ac89a37`, `6b644a6`, `2d8f09c`, `621f5cc` | Claude | done | no |
|
||||
|
||||
### 2.5 Ledger sweep (night of 4 to 5 October 2026)
|
||||
|
||||
Added by `docs/review/ledger-sweep-2026-10-05.md`, which holds what ran, what did not and why. Rows continue the numbering. Effort in hours. Items owned by the fud-consensus branch (F23, F24, G12, X18, the flood memory growth) and the testnet-prep branch (the base-fee floor, testnet parameters, G13, G14, public text) are not repeated here.
|
||||
|
||||
| # | Ledger | Issue | Fix | Who | When | EXPOSES |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 117 | M20 | Pruning-proof headers are still checked with the kHeavyHash stub on `devnet-v4` (`pruning_proof/validate.rs:192`, `apply.rs:74, 200`, `mod.rs:207`), and `validate_trusted_header` skips `pre_pow_validation` (round 4) | Derive the epoch and day of a proof header from the proof's own headers (fork map a4, O-2.5), replace the stub call, run the bits and DAA checks on trusted headers; test: a fresh node syncs a fast-time simnet past its pruning depth (6 h) | consensus engineer | before testnet | no |
|
||||
| 118 | X20 | Cold sync walks the selected chain once per checkpoint index from index 1 (`processes/finality.rs:204`) | Start from the last certified index carried in headers and walk once; test on a 10^5-block fast-time simnet, time to first resolution (2 h) | consensus engineer | before testnet | no |
|
||||
| 119 | P15 | The RPC block's `gasLimit` is one block's `B_e` beside a segment's summed `gasUsed` (`proving` branch, `igneum/exec/src/rpc.rs:355-356`) | Report `k x B_e` for a k-block segment, or document the invariant break for Blockscout (R10) (1 h) | execution engineer | before a public RPC | no |
|
||||
| 120 | M28 | Nothing commits the seed to the kernel text the worker compiles; no kernel hash exists on any branch | A hash of the emitted kernel in `program.json`, derived from the seed by the miner and checked by both workers; one sentence in the docs that the chain commits to the seed, not the text (3 h) | miner lead | before testnet | no |
|
||||
| 121 | M21 | k = 18 is Kaspa's table value; `calculate_ghostdag_k` gives k 5 at the cloud devnet's measured p99 of 0.67 s and k 55 at a 20-s bound, and proof-bearing bodies are unmeasured | Run O-2.2 with bodies of the size design 5.4 implies, take the p99 propagation, re-derive k with the fork's function (the table is in the ledger entry) (3 h) | consensus engineer | before testnet | no |
|
||||
| 122 | X29 | The live node's gRPC listens on every interface (`igneumd` on `*:26610`, read-only check 5 October); the file modes are fixed | `--rpclisten=127.0.0.1:26610`; PC 2 through a tunnel or its own node (0.5 h) | app owner, the project lead | now | no |
|
||||
| 123 | M14, F14 | O-3.14 (the finality simulation with the DAA in the loop under both forms of W2) has no DAA model inside `finality_v2.py`; the chain-model result (no amplification under either controller) stands in for it | Add a lagging retarget to `finality_v2.py` (Kaspa's sampled window and rule v2), run the 50x pulse under both W2 forms, log the day the renter crosses a third (4 h) | cryptographer (sim) | before testnet | no |
|
||||
| 124 | F1, O-3.1 | Spec 06 row O-3.1 still said "not yet in `sim/`" and the ledger's launch-month arithmetic was in prose only | Done in the sweep (5 October 2026): rows O-3.1, O-3.14, O-5.9 and O-5.11 of spec 06 carry tonight's evidence (O-3.15 was already marked decided) | Claude (spec) | done | no |
|
||||
| 125 | X14 | Only hashing concentration can be computed from what the observer keeps; signing, proving and aggregation need certificate and proof-record extracts | The observer stores signer bitmaps per certificate and prover keys per proof record; a nightly top-1/3/10 table on the live page (3 h) | app owner (observer) | before testnet | no |
|
||||
| 126 | E12 | The devnet half of O-5.9 (profit-only prover clients, `f_p` and `B_p` paths) | Phase 4 devnet run as the ledger entry states (4 h) | execution engineer | before testnet | no |
|
||||
| 127 | M25 | Confirmed 5 October 2026 (sweep batch 3): a miner started with a different `IGNEUM_POW_DAY_MS` builds its cache for another day and every block is rejected as `BlockInvalid` / `block has invalid proof-of-work`, with no line naming the day (0 of 4 accepted against 7 of 7 for the control) | The day length (or the day index) in the template beside `pow_epoch`, the miner takes it from there and ignores the environment; the node's PoW rejection names the engine's day and the header's day (1.5 h) | miner lead, consensus engineer | before testnet | no |
|
||||
|
||||
## 4. What the 3 October decisions close or change
|
||||
|
||||
Closed:
|
||||
|
|
@ -296,23 +340,25 @@ Changed, not closed:
|
|||
- G5 (second client): "funded from the development fund as its first priority" has no funder. Row 47.
|
||||
- G3 (who are you): the anonymous founder is now the design, not a flaw to rebut. The ledger's "The founder's name is on every commit" is void. Row 9. The team-page-at-testnet decision needs re-confirming.
|
||||
- L1 and L2 (counsel): "offshore, parked" and "jurisdiction not yet named" become "offshore, being established", which is a jurisdiction counsel can be briefed in. Not closed until the opinion exists.
|
||||
- The ledger header ("Published alongside the litepaper") and its submission paragraph are now wrong under (b). The ledger is not changed by this file; the project lead edits those two lines when he next touches it.
|
||||
- The ledger header ("Published alongside the litepaper") and its submission paragraph are now wrong under (b). Both lines were rewritten on 5 October 2026 under the later decision: published with the repository at the public testnet, submissions to hello@igneum.network or the repository issues.
|
||||
- Decision (e) makes CLAUDE.md stale: it still says the Vercel project lives in the [other-business] team (moved per commit 61aa946) and lists two organisation owners. Section 5 step 2.
|
||||
|
||||
Text the decisions force, beyond the overclaims list: row 7 lists every file. The design document is the eleventh place and the only one outside the repository.
|
||||
|
||||
## 5. Before the repository goes public, in order
|
||||
|
||||
Nothing below is optional. The history, not just the working tree, carries the names: CLAUDE.md with the full name is in every commit, the ledger's earlier L2 text ("The founder is in the UK") is in the commits before 14ef6b3, and site/ledger.html (636 lines, the full ledger rendered) is in the commits before the same one. The commit author and committer on every commit is igneum-labs, and every commit carries a +0100 offset, which is UK or Irish summer time in early October. A file edit fixes none of that.
|
||||
The repository goes public at the public testnet (decision of 5 October 2026). The ledger and this file are published with it.
|
||||
|
||||
1. Move the ledger out of the tree. `docs/fud-ledger.md` is internal (decision b) and it maps the providers (GoDaddy, Vercel), names `.claude/agents/`, and says the founder's name is on every commit. Move it to a private location outside the repository (or a private repo) and add `docs/fud-ledger.md` to `.gitignore`. Keep this file (`docs/fud-fixes.md`) with it: it names the same things. The spec and the open-items file cite ledger ids (M7, F1, P8 and so on); those ids survive without the file and need no change.
|
||||
Nothing below is optional. The history, not just the working tree, carries the names: CLAUDE.md with the full name is in every commit, the ledger's earlier L2 text ("The founder is in the UK") is in the commits before 14ef6b3, and site/ledger.html (636 lines, the full ledger rendered) is in the commits before the same one. The commit author and committer on every commit is igneum-labs, and every commit carries a local-time offset one hour ahead of UTC, which is UK or Irish summer time in early October. A file edit fixes none of that.
|
||||
|
||||
1. Keep the ledger in the tree and publish it with the repository (decision of 5 October 2026, which replaces decision b). `docs/fud-ledger.md` and this file (`docs/fud-fixes.md`) are on the public export list of `tools/ci/identity-check.sh`; every hit the check finds in them is reworded before the first public push, and no entry is removed. The ledger still maps the providers (GoDaddy, Vercel) and names `.claude/agents/`; the private export rules cover the founder's name, and the rest is reworded in place. The spec and the open-items file cite ledger ids (M7, F1, P8 and so on); those ids do not change.
|
||||
2. Rewrite CLAUDE.md as a public file. Remove: line 4 ("the project lead's project"); every "the project lead" (lines 22, 34, 39, 41, 46, 48, 52; "the project lead's copy law" becomes "the copy law"); line 42 (the second owner [second-owner-login], "the project lead, the igneum.network Google login", the sentence about 40 commits carrying his name); line 43 ([other-business] team; it is the igneum team now); line 44 (Neon id and London region); lines 46 to 49 (registrar, "Full Protection", the purchase quotes; after December the registrar changes anyway); lines 51 to 53 (the browser-profile section names every other business: [other-business], QUANTUM, [other-business], [other-business], [other-business], [other-business]); the claude.ai artifact and doc links in "Source of truth" (they identify the tooling account). Move the operational notes (accounts, registrar, database id, browser profile, deploy scope) to a file outside the repository, for example `~/.config/igneum/NOTES.md`.
|
||||
3. Scrub `.claude/agents/cryptographer.md` line 32 ("unless the project lead overrides" becomes "unless the project lead overrides"). The other three agent files are clean. Decide whether `.claude/agents/` stays public at all; if it does, it is the G2 disclosure and consistent with row 29.
|
||||
4. Rewrite the history once, before the first public push. Nothing is public and nobody has cloned, so the cheapest safe route is to squash to one root commit ("Igneum: public tree") authored by a neutral handle at a UTC timestamp. If the history is kept instead, run git filter-repo to: drop `docs/fud-ledger.md`, `docs/fud-fixes.md` and `site/ledger.html` from every commit; replace CLAUDE.md and the agent file in every commit with the scrubbed versions; rewrite every author and committer to the neutral handle; rewrite every date to +0000. Either way: rename the GitHub account igneum-labs to a handle without a name (the noreply address keeps its numeric id, so the rename is one setting), confirm [second-owner-login] is no longer an organisation owner (decision e says one anonymous owner), and set `TZ=UTC` in whatever script commits from now on so no new +0100 appears.
|
||||
4. Rewrite the history once, before the first public push. Nothing is public and nobody has cloned, so the cheapest safe route is to squash to one root commit ("Igneum: public tree") authored by a neutral handle at a UTC timestamp. If the history is kept instead, run git filter-repo to: drop `site/ledger.html` from every commit; replace CLAUDE.md, the agent file, `docs/fud-ledger.md` and `docs/fud-fixes.md` in every commit with the scrubbed versions; rewrite every author and committer to the neutral handle; rewrite every date to +0000. Either way: rename the GitHub account igneum-labs to a handle without a name (the noreply address keeps its numeric id, so the rename is one setting), confirm [second-owner-login] is no longer an organisation owner (decision e says one anonymous owner), and set `TZ=UTC` in whatever script commits from now on so no local-time offset appears again.
|
||||
5. Check the organisation and the account profiles: no location, no personal avatar, no personal email, 2FA on, the organisation's public members list empty. The Vercel team igneum and the Workspace are not visible from the repository; nothing to do there beyond step 2.
|
||||
6. Add a LICENSE and a root README. Neither exists (`git ls-files` shows no root README or LICENSE). A public repository without a licence invites the first issue to be about the licence. the project lead picks the licence.
|
||||
7. Re-run the sweep from this evening on the final tree: `git ls-files | xargs grep -nIE "[user]|[removed]|london|[other-business]|[other-business]|[other-business]|quantum|godaddy|soft-voice|/Users/"` must return nothing, and `git log --format='%an %ae %cn %ce %ad'` must show one neutral identity and +0000 only. Secrets: the history grep for connection strings and tokens returned zero hits today; repeat it after the rewrite.
|
||||
7. Re-run the sweep from this evening on the final tree: `git ls-files | xargs grep -nIE -f igneum-public/tools/identity.local` (the private identity list, case-insensitive) must return nothing, and `git log --format='%an %ae %cn %ce %ad'` must show one neutral identity and +0000 only. Secrets: the history grep for connection strings and tokens returned zero hits today; repeat it after the rewrite.
|
||||
8. Fix the public text first (rows 1 to 41). The ledger's X1 answer is that the honest route is to open the repository with the bench logs, the simulator and the test report. Opening it with "no chip can ever" still on the homepage hands the first critic the ledger's own list.
|
||||
9. Put the GitHub links back on HP and the /bench page (row 1) on the day the repository opens, with the January 2027 benchmark.
|
||||
9. Put the GitHub links back on HP and the /bench page (row 1) on the day the repository opens, at the public testnet.
|
||||
|
||||
What is already clean: `docs/bench-log.md` and the /bench page name machines, not people (commit 1769eda); the live site returns 404 for everything under `docs/` and 307 for `/ledger`; `site/.env.local`, `site/.vercel/` and `vendor/` are ignored; no tracked file carries a home-directory path; no connection string or token appears anywhere in the history.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
149
docs/plans/build-job.md
Normal file
149
docs/plans/build-job.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# The `build` job: a PC builds the node and the app, nobody at the keyboard
|
||||
|
||||
4 October 2026, evening. the project lead's ask ("efficiency"): every Windows node and app build went through a GitHub runner at
|
||||
15 to 25 minutes a round, and every Linux binary was cross-compiled on this Mac under the build lock. The two
|
||||
RTX 5090 PCs (PC 1 `ae432dc7`, PC 2 `1ccfe586`) run Igneum Miner 0.3.3 with the signed job channel and each has a
|
||||
WSL2 Ubuntu 24.04 owned by the app (root, cargo and the SP1 toolchain under /root from the shard jobs). So the build
|
||||
goes to them: a `build` job, mining untouched.
|
||||
|
||||
## What is built (branch `build-job`)
|
||||
|
||||
| Piece | Where | State |
|
||||
|---|---|---|
|
||||
| Job kind `build`: params, validation, the 40-minute default budget, `parse_lenient` (an unknown kind is skipped by the app instead of rejecting the file; the signer stays strict) | `app/igneum-app/src/jobs.rs` | unit tests on the Mac |
|
||||
| The plan: parameters, the inputs manifest (what to build, what to test; shell-unsafe names refused), the bash stage scripts (setup, extract, linux, windows, test, pack), per-stage caps, parsers for free space, the outputs file and the relay replies | `app/igneum-app/src/jobbuild.rs` (new) | unit tests on the Mac |
|
||||
| The runner: free-space check on the Windows drive and inside the distro, fetch with sha256, the manifest out of the zip, one `wsl.exe` call per stage under its cap (kill of the Linux side on a cap), zstd outputs uploaded to the relay (fn=upload, Blob PUT, fn=drop), RESULT and STAGE lines with UTC times, closing SUMMARY with `outputs[]` | `app/igneum-app/src/jobrun.rs` (`run_build`, `build_stage`, `relay_upload`) | compiles for macOS and `x86_64-pc-windows-gnu`; not run on a PC |
|
||||
| Packer: the fork worktree, `app/igneum-app`, `brand/icons`, `proto-cuda` (no redist) into `build-inputs.zip` with `.sha256` and `.json` (branch, commit, dirty, date, builds, tests) on the downloads host | `packaging/windows/push-build-inputs.sh` (new) | run against a scratch folder: 7.9 MB, 1937 files, no target dirs |
|
||||
| Publisher: `publish-jobs.sh add --kind build` (platform windows, requires `wsl`, `--budget-minutes`, `--stage-minutes`, `--targets`, `--no-tests`, `--min-free-gb`, `--relay-url`, `--nice`, `--cargo-jobs`) | `packaging/ota/publish-jobs.sh` | see "Tested" |
|
||||
| Mac tool: pack + publish + watch + fetch; sha256 of the zst and of the unpacked file against the PC's RESULT lines; PE header check of every exe (MZ, PE, x86-64, PE32+, .text, over 1 MB); `verify-exe.py --version` on igneum-app.exe; placement where `push-inputs.sh`, `make-payload.sh` and the cloud-devnet scripts look | `tools/build-job.mjs` (new) | fetch path run against the live relay: a real exe round-tripped, a wrong sha256 refused |
|
||||
| `relay.mjs drop <file> --body` carries the body (the fetch fallback reads the sha256 from it) | `tools/relay.mjs` | run live |
|
||||
| Dashboard label, README row | `app/igneum-app/ui/app.js`, `packaging/ota/README.md` | |
|
||||
|
||||
### What the job does on the PC
|
||||
|
||||
| Stage | What | Cap |
|
||||
|---|---|---|
|
||||
| check | free GB on the drive that holds the app data (PowerShell `DriveInfo`) and under `/root/igneum-build` inside the distro (`df`); both must be at or over `min_free_gb` (20) | 2 min |
|
||||
| fetch | `build-inputs.zip` by https, sha256 and size as signed in the job; `manifest.json` read out of it with `tar` | curl, 60 min |
|
||||
| setup | `apt-get install` of what `dpkg -s` says is missing (build-essential, clang and libclang for bindgen, protobuf-compiler, zstd, unzip, `gcc-mingw-w64-x86-64`, `g++-mingw-w64-x86-64`, `binutils-mingw-w64-x86-64`, `mingw-w64-x86-64-dev`), rustup when cargo is missing, `rustup target add x86_64-pc-windows-gnu` | stage cap |
|
||||
| extract | `/root/igneum-build/src` replaced by the zip; `/root/igneum-build/target` (CARGO_TARGET_DIR) persists, so the ~500 dependency crates compile once | stage cap |
|
||||
| linux | per manifest unit: `nice -n 19 cargo build --release -p kaspad -p igneum-miner --features kaspad/igneum-pow`, then the app crate (optional on Linux: a failure is a RESULT line, not a job failure) | stage cap |
|
||||
| windows | the same with `--target x86_64-pc-windows-gnu`, `CC/CXX = x86_64-w64-mingw32-gcc-posix/g++-posix`, `LIBCLANG_PATH` and `BINDGEN_EXTRA_CLANG_ARGS` for librocksdb-sys, `-C link-arg=-static -C link-arg=-static-libgcc`, `IGNEUM_WINDRES` for the coin icon | stage cap |
|
||||
| test | `cargo test --release` per manifest test unit (default `igneum-app`, `igneum-miner`); a failure marks the job failed but the binaries still ship | stage cap |
|
||||
| pack | `zstd -T0 -12` per binary (`igneumd.linux.zst`, `igneumd.exe.zst`, ...), sha256 of both forms, `build-outputs.json`, copied to the job folder on the Windows side | stage cap |
|
||||
| upload | each `.zst` under 50 MB to the relay: `fn=upload` with the intake key (allowed for upload and drop; round 4 X23 keeps the key away from run and task posts), PUT to Vercel Blob, `fn=drop` to `mac` titled `build-job <id> <file>` with the sha256 lines in the body | stage cap |
|
||||
|
||||
Every stage prints `STAGE <name> start <utc> cap N min` and `STAGE <name> end|timeout <utc> N s exit M`; every
|
||||
binary a `RESULT <target> <name> <bytes> bytes sha256 <hex>` line and later `RESULT output ...` with the zst sha256
|
||||
and `RESULT upload ... relay item <id>`. The console's Jobs tab shows these as they arrive; the job is final only on
|
||||
the app's closing SUMMARY, whose `outputs[]` carries the relay item ids and both sha256 per file.
|
||||
|
||||
Guard rails, as asked:
|
||||
- The whole job runs under `budget_minutes` (default 40; `MAX_RUN_TIMEOUT_MIN` 600). Each stage takes
|
||||
`stage_minutes.<stage>` when given, never more than what is left of the budget. A cap ends `wsl.exe` and then
|
||||
`cargo`, `rustc`, `cc1plus` and `zstd` inside the distro.
|
||||
- 20 GB free on both sides before anything is fetched.
|
||||
- The app's runner is serial: `Jobs.tick` starts a queued job only when `self.active.is_none()`
|
||||
(`src/jobrun.rs`), so a build never overlaps a shard benchmark; the second job waits in the queue.
|
||||
- Nothing stops the miners: `needs_miners_stopped` is true only for `shard-benchmark` and a `run` with
|
||||
`stop_miners_first`. The report says so twice ("the miners keep mining", "the miners were never stopped").
|
||||
|
||||
## The order of events (the main session does this)
|
||||
|
||||
The app on both PCs is 0.3.3, whose parser rejects the WHOLE jobs file when any job has a kind it does not know
|
||||
("one bad job rejects the file"). So the `build` kind must reach the PCs before the first build job is published:
|
||||
|
||||
1. Merge `build-job`, bump the app to 0.3.4 (`app/igneum-app/Cargo.toml`, `resources/igneum-app.rc` x2, `app/windows/version.h`, as the 0.3.3 commit did) and cut it. This last round still goes through the GitHub runner (`fetch-ci-artifacts.sh`, `publish-manifest.sh`). From 0.3.4 on, an unknown kind is skipped, so the next new kind needs no such dance.
|
||||
2. Rebuild the signer in the checkout that publishes: `cd app/igneum-app && cargo build --release --bin igneum-ota-sign` (the old binary refuses `kind 'build' is unknown`).
|
||||
3. Confirm 0.3.4 on PC 1 in the console (Machines tab, app version) or `node tools/jobs.mjs status` after an `update-now` job.
|
||||
4. Publish the first job (below) and watch.
|
||||
|
||||
## The first job: PC 1, which is idle on jobs
|
||||
|
||||
packaging/windows/push-build-inputs.sh --node vendor/igneum-node-v4
|
||||
packaging/ota/publish-jobs.sh add --kind build --target ae432dc7 \
|
||||
--title "First PC build: node devnet-v4 and app, Linux and Windows" \
|
||||
--budget-minutes 150 --stage-minutes '{"setup":20,"linux":50,"windows":50,"test":20,"upload":15}' --deploy
|
||||
|
||||
or in one go, watching and fetching included:
|
||||
|
||||
node tools/build-job.mjs run --node vendor/igneum-node-v4 --target ae432dc7 --budget-minutes 150 \
|
||||
--stage-minutes '{"setup":20,"linux":50,"windows":50,"test":20,"upload":15}'
|
||||
|
||||
The first job is cold: the distro has no mingw, no clang, no `/root/igneum-build/target`, so the node's ~500
|
||||
crates compile for both targets. The Mac's cross-builds of the same tree take 8 to 15 minutes per target with 4 to 6
|
||||
jobs at nice 19 (`infra/cross/build-linux.sh`, `proto-cuda/windows-node/cross-build.sh`, 4 October); the PC's CPU is
|
||||
unknown to me (approximate: comparable), and the ext4 vhdx is slower than the Mac's SSD. Hence 150 minutes for the
|
||||
first job, the 40-minute default for the warm ones after it.
|
||||
|
||||
What success looks like, in order, on the console's Jobs tab (or `node tools/build-job.mjs watch <id>`):
|
||||
|
||||
| Line | Means |
|
||||
|---|---|
|
||||
| `RESULT check <utc> drive C: N GB free, Ubuntu-24.04 /root: M GB free, floor 20 GB` | both sides over 20 GB |
|
||||
| `RESULT fetch <utc> 79xxxxx bytes sha256 ok, node devnet-v4 3bfe346f, app 0.3.4, 2 build units, 2 test units` | the zip is the signed one |
|
||||
| `RESULT setup ok cargo 1.x | rustc 1.x | mingw x86_64-w64-mingw32-gcc-posix (GCC) 13.x` | toolchain in place (first run installs; later runs say "apt packages present") |
|
||||
| `RESULT linux node build exit 0 NNN s`, `RESULT linux igneumd NNN bytes sha256 ...`, `RESULT linux igneum-miner ...` | the Linux node |
|
||||
| `RESULT linux app/igneum-app build exit 0` or `... optional on linux: not fatal` | the engine on Linux, best effort |
|
||||
| `RESULT windows node build exit 0 NNN s`, `RESULT windows igneumd.exe ...`, `igneum-miner.exe`, `igneum-app.exe` | the Windows binaries |
|
||||
| `RESULT test app/igneum-app [igneum-app] exit 0`, `RESULT test node [igneum-miner] exit 0` | tests |
|
||||
| `RESULT output ...` x5, `RESULT upload <utc> igneumd.exe.zst relay item N ...` x5 | on the relay |
|
||||
| `RESULT build <utc> done node devnet-v4 3bfe346f app 0.3.4: every stage ok; 5 files uploaded (NN MB); NN min of 150` | final |
|
||||
| SUMMARY: `status done, exit 0` | the job is closed; `uploaded_files` 5 in the extras |
|
||||
|
||||
Then on the Mac: `node tools/build-job.mjs fetch <id>` prints one line per file ("matches the PC PE ok ...",
|
||||
"coin icon and version block ok" for igneum-app.exe) and places them:
|
||||
|
||||
| File | Lands in |
|
||||
|---|---|
|
||||
| `igneumd.exe`, `igneum-miner.exe` | `vendor/igneum-node/target-integration/x86_64-pc-windows-gnu/release/` (what `push-inputs.sh` and `make-payload.sh` read) |
|
||||
| `igneum-app.exe` | `app/igneum-app/target/x86_64-pc-windows-gnu/release/` (`make-payload.sh`'s default) |
|
||||
| `igneumd`, `igneum-miner`, `igneum-app` (Linux) | `infra/cross/out/` with `version.txt` (where `build-linux.sh` leaves them for the cloud devnet) |
|
||||
|
||||
Also check: the Machines tab still shows PC 1 mining through the whole job (hash rate, accepted blocks), and the
|
||||
Relay tab shows five `build-job <id> ...` file items from `DESKTOP-KMCV30N-ae432dc7` to `mac`.
|
||||
|
||||
Failure signatures to expect on a first run, and what they mean:
|
||||
|
||||
| Line | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `check`: distro free space unknown, "does not answer" | WSL not reachable from the app's account (PC 2's 4 Oct `getpwnam` class) | the job's `wsl_user`/`distro` params; the engine log's `probe wsl` lines |
|
||||
| `RESULT setup apt failed for: gcc-mingw-w64-x86-64 ...` | apt mirror or package names on 24.04 | `run` job with `apt-cache policy gcc-mingw-w64-x86-64` |
|
||||
| windows node build: `undefined reference to pthread_...` or libstdc++ errors | the mingw thread model (win32 vs posix); the script names the `-posix` compilers on purpose | `run` job: `x86_64-w64-mingw32-g++-posix --version`, `ls /usr/x86_64-w64-mingw32/lib/libwinpthread.a` |
|
||||
| windows node build: `bindgen` cannot find `stddef.h` | `BINDGEN_EXTRA_CLANG_ARGS` sysroot | the sysroot path on the PC (`/usr/x86_64-w64-mingw32`) |
|
||||
| app windows build: `no windres found` | `binutils-mingw-w64-x86-64` missing | setup stage output |
|
||||
| `RESULT upload ... over the relay's 50 MB cap` | `igneumd.exe` zst over 50 MB (today's Mac cross-build: 50.5 MB exe, zst approximate 16 MB; fine) | split or a bigger cap in `relay/lib/relay.mjs` |
|
||||
| `STAGE windows timeout` | the cold build under the cap | re-add with a bigger `stage_minutes.windows`; the target dir keeps what compiled |
|
||||
|
||||
A failed job still uploads whatever target built (the pack and upload stages run when any target succeeded), and
|
||||
the Mac tool fetches those; the job status says `failed` with the stage in `failures[]`.
|
||||
|
||||
## What was tested on the Mac, and what was not
|
||||
|
||||
Tested:
|
||||
- `cargo test` of the app crate: 33 tests pass (the ones that existed plus 6 new: build params refused and accepted,
|
||||
unknown kinds refused by the signer and skipped by the runner with the signature still checked, the plan from the
|
||||
manifest with shell-unsafe names refused, the stage scripts carrying the plan, the parsers); the signer binary's
|
||||
21 pass.
|
||||
- `cargo check --target x86_64-pc-windows-gnu` of the app crate (the Windows runner code compiles).
|
||||
- The packer against a scratch folder; the zip inspected (manifest, Cargo files, icon present; no target dirs).
|
||||
- The publisher: `add --kind build` against a scratch `--dest` with the rebuilt signer (signs, verifies, lists;
|
||||
the job carries budget 150, the five stage caps, the zip's sha256 and size, target `ae432dc7`, platform windows,
|
||||
requires `wsl`). The signer built before this branch refuses the same file with "kind 'build' is unknown", which
|
||||
is exactly what a 0.3.3 app would do: hence the rollout order above. A build job with a bad sha256 is refused at
|
||||
signing.
|
||||
- The Mac tool's fetch path against the live relay: a real `igneum-app.exe` zstd-compressed, posted as a build
|
||||
output with the sha256 lines, fetched, decompressed, both sha256 matched, PE header and `verify-exe.py` passed; a
|
||||
second post with a wrong sha256 refused (exit 1). Both test items deleted from the relay afterwards.
|
||||
- The PE check on today's real `igneumd.exe` and `igneum-app.exe` (ok) and on a Linux binary (refused).
|
||||
|
||||
Not tested (only a PC can):
|
||||
- The job itself: wsl.exe output through the sink per stage, the free-space probes, the apt install on 24.04, the
|
||||
mingw posix toolchain with rocksdb, bindgen against `/usr/x86_64-w64-mingw32`, the static link, build times
|
||||
against the caps, `taskkill` plus the in-distro `pkill` on a cap, the relay upload from Windows curl (the PUT with
|
||||
`--data-binary @file`), the 50 MB limit against the real zst sizes.
|
||||
- `cargo test` of `igneum-miner` inside the distro (what tests it has, how long).
|
||||
- The dashboard strip with a `build` job running.
|
||||
- Whether the engine builds on Linux at all (optional on purpose).
|
||||
|
||||
The main session publishes the first job and cuts 0.3.4; this branch stops here.
|
||||
75
docs/plans/consequences-2026-10-05.md
Normal file
75
docs/plans/consequences-2026-10-05.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Consequences ledger, 5 to 6 October 2026 (night)
|
||||
|
||||
The standing consequences reviewer (CLAUDE.md, "Every number carries its consequences"). One row per number whose consequence for a user tier, a chip builder or a public claim nobody had stated or acted on. Tiers: a home miner with one 8, 12, 16, 24 or 32 GB card; a rig; a pool user; Windows, Linux, macOS; NVIDIA, AMD, Apple. Times UTC. State: open, sent (the owner has the message), in work, closed, decision (in `consequences-decisions.md`).
|
||||
|
||||
Rows already handled before this ledger opened, for the shape: 15.6 GB mine-and-prove peak (12 and 16 GB profiles, the sweep `memsweep-pc2-pv1`); 9070 XT 18 MH/s (the read-width experiment); the cache never grows (layer 6 option C); aggregation 9.7 s a block (the aggregation-cost agent).
|
||||
|
||||
## Round 1 (21:30 to 22:30)
|
||||
|
||||
| # | Number | Source | Tier affected | Consequence | Action | Owner | State |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| C1 | Fee switch H = 210,000, reached about 18:45Z on 6 October (DAA 137,041 at 22:32:54Z, 1.002 DAA/s averaged since the 15:40Z read; the plans first said 19:50 from 0.965 blocks/s) | `docs/plans/fee-switch-devnet.md` sections 3 and 7; `vendor/igneum-node-0310/igneum/exec/src/rpc.rs` 849 (no `daaScore`, no `feesV1ActivationDaa` in `igneum_exportSegments`); the app's exporter call without `--fees-v1-activation-daa` (`app/igneum-app/src/prover.rs` 516 to 519) | every prover on the devnet (PC 2, the Mac, any 0.3.10 machine) | From the first chain block at or above H the 0.3.10 node cuts 30,000-pgas shards and meters with the v1 table, but the app's exporter sees a dump without the switch and cuts at 7.5 M with the prototype table: the host refuses the fixture (wrong `S_p`) or the node vetoes the statement. Proving on the devnet goes dark at H and nothing is paid until every prover runs a node whose export carries the switch (the proving-v1 fork does, `vendor/igneum-node-pv1` rpc.rs 961 and 982). `pc2-chain.ps1` fixtures from the live node fail the same way after H | Either 0.3.11 (proving-v1 fork) on every prover before H, or the switch republished at a later H before 19:50Z tomorrow (a digest flip, every node). Recommendation in `consequences-decisions.md` D1 | proving v1 acd4f36bc2c07a4e2, shipper ae892a8b0f78fe31c, coordinator ada8afb62d752b1e2 | in work (proving agent: fork eb32c645 on 21d4c73c carries daaScore and feesV1ActivationDaa, the exporter needs no flag; 0.3.11 on every prover before 16:00Z on 6 October or H moves to tip + 86,400; the line is in the proving plan, the rollout plan and release-0.3.10.md) |
|
||||
| C2 | 16,751 MiB GPU peak during the chain of 8 with the miner resident (`chain-pc2-pv1c`) | bench-log "proving v1", step 2 chain row | 16 GB cards (RTX 5080, 5060 Ti 16 GB, 4060 Ti 16 GB) | The plan's "a 16 GB card sits 0.4 GB under tonight's peak" is the empty-shard row (15,590 MiB). The chained aggregation adds 1.2 GB and lands at 16.4 GB, over a 16 GB card. So a 16 GB card cannot mine and aggregate on this build; it can at most mine and prove shards, with 0.4 GB spare and no full shard measured | The 16 GB chained-aggregation row goes into the sweep; the aggregator step in `prover.rs aggregate_once` gates on card memory (24 GB with the miner running, else pause the miner on that card for the aggregation); the Proving tile says which role the card runs | proving v1 | closed as measured (proving agent, spcurve-miner-pc2-pv1 219517f: the adopted shard beside the miner 22,210 MiB and 13.2 s, so a 24 GB card has 2.3 GB spare from the fee switch, the number approximate for the card itself because it is the 5090's allocation pattern; the prototype shard 30.1 GB, the 32 GB card alone; aggregation_card gates on the same memory rule, 23,552 MB either role). Open: the first real 24 GB card measurement, and the public line says "24 GB" from a 32 GB card's pattern (D2 wording) |
|
||||
| C3 | 13,816 MiB GPU peak, prover alone, empty shards (`prover-cost-pc2-pv1`) | bench-log "proving v1", step 1 | 12 GB cards (RTX 3060 12 GB, 4070, 5070), the default-on rule (`provedefault.rs` `MIN_VRAM_MB` 11,776) | On the only measurement the prover by itself exceeds a 12 GB card by 1.5 GB, so the 12 GB default gate switches proving on for cards that cannot run it on this build unless the SP1 knobs bring the peak down. The litepaper's "12 GB or more proves full shards" (`site/litepaper.html` 560) and evidence row 16 rest on the sweep | 0.3.11 does not ship the default-on until the sweep has a row under 11.5 GB for a full v1 shard; if none, the gate moves to 24 GB and the public claim is qualified (D2) | proving v1 (sweep in work); public claim: decision | closed as measured (proving agent: the sweep moves no floor, 13.9 GB for an empty shard, 28.3 GB for a full prototype shard; the default is 20 GB mining / 16 GB prove-only; the 12 GB sentence is false on this build and goes to the project lead as D2 with the curve); the v1-shard row is C15 |
|
||||
| C4 | Host RAM 25,550 MB used of 63,132 on PC 2 with the prover on; the WSL2 VM working set 7,915 MB | bench-log "proving v1", step 1 host RAM row | Windows home miners with 16 GB RAM (the common gaming PC); Macs with 16 GB switching the CPU prover on | The prover default has no RAM floor. On Windows the WSL2 VM alone holds 7.9 GB beside the app, the node and the game-class desktop; a 16 GB machine with proving on by default swaps or kills the node. The app's own RSS (engine, node, verifier) is not separated in the measurement, so no requirement can be stated yet | The sweep job records the app's and the node's working sets beside the VM's; `provedefault` reads total RAM and stays off under 32 GB on Windows until measured; the Proving tile and the miner page state the RAM requirement | proving v1; miner UI adbf58b058186a18b (the tile line) | closed in code (proving v1 c2544be: MIN_RAM_MB_WINDOWS 31,000 in provedefault.rs, the tile line names the 7.9 GB VM on a 63 GB PC; unknown RAM is not a gate; the miner UI help line next cut) |
|
||||
| C5 | The app quit for the 0.3.10 update at 20:01:09Z and aborted the chain job at block 3 ("aborted (the app is quitting)"); `prover.rs` kills the child on quit | bench-log "proving v1" chain run 1; `app/igneum-app/src/prover.rs` 266 to 272 | every prover on every update; with proving v1 the aggregator | Tonight's `update-now` to every app aborts whichever shard each prover has in flight (up to 37 s of work each, no payout, re-assigned to nobody until the window passes). Under proving v1 a restart mid-segment loses the aggregator's chain state: the segment goes unproven after T (600 DAA), the aggregator share of 8 blocks is forfeited to the escrow, and the next record must be fresh-chain. With one aggregator on the devnet every app update costs 10 minutes of unproven segments | The update's safe moment waits for the prover's current proof (as it does for the node); the aggregator persists the last segment proof and resumes the chain after a restart; the Updates section says "waits for the proof in flight". For 0.3.10 tonight the aborted shards are an accepted cost, recorded | proving v1; shipper (tonight's rollout note); miner UI (Updates wording) | refused for tonight by the proving agent (persisting the segment proof and holding the update for a proof in flight are 0.3.12; the cost is in the plan as the rule working as written); the shipper carries the aborted-shard count in release-0.3.10.md section 8; the 20:01:09Z abort was NOT 0.3.10 (shipper: nothing published), see C16. The count, read from the intake at 22:3xZ: PC 2 quit for the 0.3.10 restart at 21:49:29Z (run win-1ccfe586-20261005-200114) with no proof in flight, because its prover had been dark on the root socket since 21:25Z (last exporter line 21:44:02Z, C17); the Mac proves nothing by default; so the restart aborted 0 shards tonight and the first instance of this class will be the 0.3.11 rollout |
|
||||
| C6 | The prover costs a mining 5090 4.0% (124.7 to 119.7 MH/s); the software dev fee is 1 template in 100 | bench-log "proving v1" step 1; `packaging/hive/README.md` "The dev fee" | pool users; the pool's ledger | A member who proves sends 4% fewer shares, so the pool's vardiff and `stats.hashrate` read a 4% loss while the proving income (90% of the shard credit plus a tenth to an aggregator) is paid to the member's own key and never appears in the pool's ledger: the pool dashboard understates a proving member's earnings. The software dev fee has no mechanism in pool mode (the pool issues the templates), so a pooled miner pays no dev fee today and the pool design must say whether it takes one (1 share in 100 to the dev address) or none | The pool-v0 design states both: the member `stats` carry a `proving` flag and the pool page shows proving income beside shares; the dev fee rule in pool mode is written down before the first pool ships | pool a4781ba117326091f | closed on pool-v0 (pool agent: the member stats line carries a proving flag, /api/miners/<address> and the pool page show it with the 4% note and that proving income never passes through the pool; the software dev fee is NONE in pool mode, the pool's own fee (default 1%) is the only fee, carried in the welcome message's share_scheme, shown on the Connect card, written in docs/plans/pool.md and packaging/hive/README.md). Merge note: packaging/hive/README.md is now edited on three branches (hive-words, ota-k2, pool-v0). Public wording: the miner page's "a visible 1% software fee you can switch off" is a solo-mining sentence once a pool exists, added to D8 |
|
||||
| C7 | The HiveOS package holds `igneumd`, `igneum-miner` and the two workers; no `igneum-prove-host`, no SP1 GPU server, no per-card rule | `packaging/hive/make-hive-package.sh` 26 to 30; README "What the hooks do" | rigs (HiveOS, Linux), the largest hashrate tier | A rig cannot prove at all, so the 20% proving share is reachable only from the app. The miner page says "the card mines and proves" (`site/miner.html` 7, `site/index.html` 484) and the HiveOS README does not say rigs mine only. A rig that could prove needs the 251 MB SP1 GPU server, CUDA 12.8 and the per-card profile of C2 and C3, and a rig's RAM (4 to 8 GB on most Hive images, approximate) is below C4's floor | The rig installer either carries the prover with the per-card rule and a RAM check, or its README and the miners page say rigs mine only and provers are app machines; `IDENTITIES=8 for a big card, 2 for a small one` gets a threshold in GB | rig installer a3e7b2b03222f5cff | closed (rig installer 88f31d9: gates 23,552 MB for both roles from provedefault.rs 440fd59, the README table per tier; HiveOS hive-words 2d056e8: the same table; open only the miners page sentence, D8) |
|
||||
| C8 | Dataset 2 GiB at genesis plus 0.5 GiB a year; the working-set rule "under 6 GB on an 8 GB card"; the cache 256 MiB doubling at years 4 and 12; scratch up to 128 KiB per resident warp | spec 01 section 1.13.3; coordinator's budget rule; layer 6 option C; `docs/plans/hot-table.md` section 3 | 4 GB and 8 GB cards; the litepaper's claim | `site/index.html` 461 says "Any 4 GB card" and the litepaper (560) says a 4 GB card mines for about four years and an 8 GB card for more than a decade. At 75% of the card the 4 GB card's dataset room is about 2.4 GiB: under one year. The 8 GB card's room is about 5 GiB after cache, hot table, scratch and buffers: about six years, five and a half with the year-4 cache step. Neither public sentence holds under the schedule | A card-lifetime table per tier (sub-agent, `docs/analysis/card-lifetime-2026-10-05.md`); the public wording is a claim for the project lead (D3) | sub-agent (table); decision (wording) | table landed (sub-agent, docs/analysis/card-lifetime-2026-10-05.md, 1fecfe2, merged into ca2-coord at 22:50): 4 GB 1.0 to 1.5 years under (a) and year 4 under (b); 8 GB 6.3 to 7.5 or 12; 12 GB 12 to 13.5 or 12 to 28 depending on whether the cache stays resident; Apple 8 GB 2.6 to 3.4 or 4. The coordinator decided the cache is freed after the daily build and recommends (b) to the project lead; the public sentences hold only under (b): D3 and D4 revised |
|
||||
| C9 | Index mapping option (a) multiply-shift (fades cards) against (b) power-of-two steps 2, 4, 8 GiB | spec 01 section 1.13.3, gate 1 | 8 GB and 12 GB cards | Option (b)'s 8 GiB step (about year 12) ends 8 GB and 12 GB cards on the same day; option (a) fades them one year at a time. The choice is a genesis parameter and a tier consequence nobody has put beside the options | Decision request D4 with the lifetime table of C8 | decision | decision (D4, with the card-lifetime table; the public copy now reads the step schedule as the gate 1 proposal, C31) |
|
||||
| C10 | The on-die-cache recompute chip's gain is 2.4x at every scratch share under the 6 GB cap; the mixer multiplier x2 brings it to 1.2x, x4 to 0.6x, inside the 10 ms verify gate | `docs/analysis/scratch-soundness.md` finding 2 and section 10; M16 analysis table | chip builders; the site's "under 2x" claim; pool share verification | Layer 3 does not deliver the headline and the rollout plan's own rule (6a) says the public claim is qualified when no share gets the chip under 2x. The lever that does is M16's mixer multiplier, which doubles the CPU verify per warp (0.441 ms to about 0.9 ms at x2): a pool core verifies about 11,000 members' shares instead of 22,000, and node block verification doubles. The corrected SRAM cost ($19 to $37 of silicon per mirror die) means the mirror never stops a funded chip; the cache schedule keeps it above GPU L2 only | The coordinator either carries the mixer x2 into class v3 for the devnet (it is a lottery-hash change like the others; the verify cost per warp is measured with it) or marks the site's "under 2x" as qualified in the public copy level 3 and D5 asks the project lead which | coordinator ada8afb62d752b1e2 | closed as a consequence (coordinator: the public claim was qualified at 21:50; at 22:00 the M16 mixer x4 was decided into class v3 behind the same activation; the pool-core and node verification consequences are C14) |
|
||||
| C11 | RX 9070 XT 17.73 MH/s at 198.9 W (0.089 MH/W) against the RTX 5090 122.30 MH/s at 307.6 W (0.398 MH/W); every read width costs the 9070 XT the same 2.4 G line fetches a second | bench-log AMD telemetry entry; readwidth probe ceilings (status 20:35) | AMD home miners; the "three vendors" copy | Under the "widest latency-bound read" rule w16 moves bytes per hash, not loads per hash, so the AMD card keeps about a seventh of the 5090's hash rate and pays 4.5x the electricity per hash; at a UK tariff of 25 p/kWh (approximate) the 9070 XT spends 4.5x the 5090's pence per IGN. The readwidth decision table carries MH/s only; no MH/W or MH per pound per card per class, and the public copy implies vendor parity | The readwidth table adds MH/W (and MH per pound at list prices, approximate) per card per class; the v3 decision names the AMD consequence; whether the class should favour fewer loads per hash for AMD's 64-byte lines is a consensus choice for the project lead (D6) | read-width a451c9935bfb1bc19; coordinator; decision | closed (read-width e752fc7 section 4.1: MH/W and MH per pound per class per card; the AMD gap is the card's random-access rate, no width closes it; the coordinator's 22:30 entry says "near parity per pound" where the table says the 5090 is 2.2x per pound: C18) |
|
||||
| C12 | The v3 working set on Apple: 1,568 to 1,760 MiB today, 2,592 to 2,784 MiB at the 2 GiB genesis dataset, in unified memory shared with macOS; 2,048 launched warps x 128 KiB scratch | `docs/plans/hot-table.md` section 3 M5 Max row; era-layout section 3 | Apple silicon with 8 GB and 16 GB (the base Mac mini, MacBook Air) | An 8 GB Mac holds the miner's 2.6 GB beside macOS's 3 to 4 GB: it mines today and swaps at the first dataset growth step. The app has no gate on Metal's `recommendedMaxWorkingSetSize`; the site's "Any 4 GB card" has no Apple line. The CPU prover's RAM on a 16 GB Mac is unmeasured (the Settings switch lets it on) | The app reads `recommendedMaxWorkingSetSize` and refuses to mine (with the reason on the Mine tile) when the working set exceeds it; the site says "Mac: 8 GB or more"; the Apple scratch row at 128 KiB is measured in the readwidth table, not launched at 2,048 by assumption | miner UI adbf58b058186a18b; read-width (the Apple row) | in work (read-width 30ff674: the Apple scratch footprint by arithmetic is 1.4 GiB at 2,048 x 32 KiB and 1.6 GiB at 2,048 x 128 KiB, 2.4 GiB at 4,096 x 128 KiB; the Metal harness now prints currentAllocatedSize and recommendedMaxWorkingSetSize in its RESULT line, the measured row waits for the Mac measure lock, held by a prover measurement since 20:31Z; the miner UI gates on the arithmetic plus its output buffer until then, mining.gate_reason next cut) |
|
||||
| C13 | `/api/supply` `max_supply_ign` 4,000,000,000 against `minted_at_end_ign` about 3,963,000,000 (the ramp withholds about 37 M, the floors the rest); the live tables lack `number`, `tx_count`, `detail` until the observer restarts | `site/api/supply.mjs` 31 to 48; `docs/plans/explorer.md` "Open" | everyone who reads the explorer beside the homepage tile "4B IGN hard cap, ever" (`site/index.html` 386) | The tile says 4 billion and the explorer page says "of 4,000,000,000 by the rule" while the API's own end figure is 3.963 billion: a reader who adds the two columns finds 37 million missing. The explorer page shows "Circulating 0 IGN" on the devnet deployment until the observer restarts on the new code | The tile, the litepaper's supply line and the explorer carry "cap 4,000,000,000; about 3.96 billion ever minted" (the litepaper already says "approached and never reached"); the explorer does not go live before the observer restart lands the columns | explorer a76f60b415859b7b5; wording: the project lead (D7, with D3) | closed (explorer 3e01212: the tile reads "cap 4,000,000,000, 3.96 billion ever minted, x% so far" with the withheld figure on hover; the homepage tile and litepaper wording stay with the project lead, D3 and D7; the zero-circulating premise was sharper than stated, a 42703 failure not a null, now a 503 with the reason, and moot: the live observer restarted on the explorer code at 19:42:50Z, so the live tables carry the columns) |
|
||||
| C14 | The M16 mixer x4 decided into class v3 at 22:00 (coordinator): verifier 1.6 to 4.8 ms per warp against 0.441 ms today (0.87 cold), measurement running on branch ca2-mixer | coordinator's reply 21:5x; `docs/analysis/m16-recompute-attacker-2026-10-05.md` table; spec 09 section 9.8 item 5 | pool operators; every node (the Hetzner seeds, the observer, a 2019-class laptop); the 10 ms verify gate | At x4 one pool core verifies 200 to 600 shares a second instead of 2,270, so one core covers 2,000 to 6,000 members at one share per 10 s instead of 22,000; a block's CPU re-check and the miner's own `cpu re-check` of every found hash cost 4 to 11x; the seeds' small VMs verify every header at that cost; the gate's margin falls from 23x to 2 to 6x, which bounds Counter ASIC 3.0's room | The coordinator is adding the numbers to the ca2-mixer document and the status (said in reply); the reviewer checks at the next sweep that the pool-members-per-core and the seed-VM header-verify rows are there, and that the spec 09 figure 2,270 shares a second per core is re-cut with v3 | coordinator ada8afb62d752b1e2 | closed with C19 (the same measurement: x4 is 1.45x and x8 2.1x the verifier, not 4 to 11x; a pool core verifies 1,140 shares a second at x4 and 790 at x8 quiet) |
|
||||
| C15 | A full prototype shard peaks at 28.3 GB on sp1-gpu-server 6.8.1 (the sweep, proving agent 22:0x); an empty one 13.9 GB; no knob moves either floor | proving agent's reply; sweep job `memsweep-pc2-pv1` | 24 GB cards (4090, 7900-class if it had a path); the 5090 that mines and proves; the fleet table | A 24 GB card cannot prove a prototype full shard at all, mining or not; a 5090 mining (3.4 GB resident) plus a full shard is 31.7 GB against 31.8 GB, the edge. The 20 GB / 16 GB default rests on the empty-shard number. From H tomorrow the fleet proves v1 shards of 30,000 pgas (about 7 M cycles, a ninth of the prototype shard) whose peak is unmeasured; `proving/fixtures/fees-v1-shards2` and `-shards3` are that shape. The fleet table's "proving-only" rows are 32 GB-card rows until then; the litepaper's "12 GB" (D2) and the evidence row 16 fall with it | The sweep's last row is a v1-budget shard with and without the miner, and the provedefault gates are set from it before 0.3.11 ships default-on; the fleet table labels its rows by the card that fits | proving v1 | closed as measured (proving agent, the S_p curve, app default 440fd59): 32 GB mines and proves today (28.3 GB alone, 30.0 beside the miner); 24 GB proves the adopted 30,000-pgas shard (20.4 GB alone, about 22 GB beside the miner) from DAA 210,000 and nothing before it; 16 GB proves only empty shards alone (13.9 GB), nothing beside the miner (15.7 GB); 12 GB proves nothing on SP1 6.8.1; the default is on at 24 GB or more. Relayed to the rig installer and the HiveOS words sub-agent for their tables; until H tomorrow every prover on the devnet is a 32 GB card |
|
||||
| C16 | PC 2's app quit at 20:01:09Z ("aborted (the app is quitting)"), logged by the proving agent as "the app quit for the 0.3.10 update"; the shipper says nothing of 0.3.10 was published and no update-now of its exists (the live manifest is 0.3.9 from 17:59:14Z) | bench-log "proving v1" chain run 1; the shipper's reply 22:0x | every measurement on PC 2 that straddles 20:01Z (the prover-cost phase B ended 19:51Z, the chain re-run began 20:05Z, the readwidth 5090 job queued) | An app that quits for an unknown reason voids any number taken across it (CLAUDE.md: a number taken while another build or simulation ran is not a number; the same for a restart). The bench-log line names a cause that did not happen | The proving agent reads PC 2's app log for the quit reason at 20:01Z and corrects the bench-log line; if the cause is another agent's job or the auto-update, that agent's measurements across it are marked | proving v1 (the log read); the agent the cause names | closed in part (proving agent: PC 2 logged "quit: stopping the miners, then the node" at 20:01:09Z, a plain quit command 20 s after the efficiency sweep's administrator prompt was cancelled at the keyboard and 13 s after the live prover failed on a root-owned /tmp/sp1-cuda-0.sock left by the chain job; the bench-log line corrected; the quit's origin is not in the log, see C17) |
|
||||
| C17 | The chain job ran igneum-prove-host as root inside WSL2 and left a root-owned `/tmp/sp1-cuda-0.sock`; the live prover (the app's user) then failed with `CudaClientError: Connect(PermissionDenied)` at 20:00:56Z; the app quit at 20:01:09Z on a command whose source the log does not name | proving agent's reply 22:1x; PC 2's app log | every PC 2 measurement that shares the card with the live prover; every operator whose machine takes remote jobs | Two classes, not one bug. (1) Any job script that runs the SP1 server or the host as root in WSL2 breaks the live prover for every later shard until a reboot or a manual unlink; the proving agent fixed its own scripts (kill the server, remove the socket at the end), the class check (CLAUDE.md, 5 October: grep every script with the same shape, add a check that fails when the shape comes back) is not yet written. (2) A quit the log cannot attribute (job, UI, signal, update) voids the measurements around it and nobody can say who stopped a miner; the app logs "quit:" without a source | (1) `tools/ci/` gets a check that fails on any `.ps1` or `.sh` playbook that invokes `igneum-prove-host`, `sp1-gpu-server` or `wsl -u root` without the socket cleanup line, and the proving agent greps tonight's four PC 2 scripts; (2) the engine's quit log line carries its source (job id and playbook name, the UI, a signal, the updater) in the next app cut | proving v1 (1); coordinator for the next-cut list (2) | closed in part (proving v1 c2544be: every pv1 playbook kills the server and unlinks the socket at start and end, tools/ci/prover-socket-check.sh in CI; the publish-time gate is C27 on bash-body-check 6805125; the unattributed quit, the engine logging its source, is on the coordinator's next-cut list) |
|
||||
|
||||
## Round 2 (22:30 to 23:30)
|
||||
|
||||
| # | Number | Source | Tier affected | Consequence | Action | Owner | State |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| C18 | 5090 0.072 against 9070 XT 0.032 MH/s per pound at list prices (2.2x), 4.9x per watt | read-width.md 4.1 | AMD home miners; the public level 3 copy | The coordinator's 22:30 status entry says "near parity per pound"; the table it cites says 2.2x. The level 3 page is written from the status | The status and level 3 carry the table's figure (2.2x per pound, 4.9x per watt, 7.5x in rate); "near parity" is struck | coordinator | closed (coordinator 6b07776: the status, the rollout plan and the public copy carry 2.2x per pound, 4.9x per watt, 7.5x in rate) |
|
||||
| C19 | Mixer x4 into class v3: CPU verify 1.6 to 4.8 ms per warp against 0.604 today; pruning depth 108,000 DAA s (spec 02) | status 22:00; M16 table; spec 02 line 17 | every node (the three testnet seeds on small Hetzner VMs, the observer, a laptop node); IBD; pools | A new node verifies every header in the pruning window on one core: 108,000 x 4.8 ms = 8.6 min at x4 against 1.1 min today (a 30-s block-time budget at 1 block/s is unaffected: 4.8 ms per block is 0.5% of a core). Every miner's own `cpu re-check` of a found hash and every pool share verification cost the same 8x; the 10 ms gate (ledger M9) keeps 2x of margin at the slow end, which is what Counter ASIC 3.0 has left to spend. On the 2019-class laptop core the evidence table names (rule 3) the figure is unmeasured and may pass 10 ms | The ca2-mixer document carries: ms per warp on the M5 Max core AND a scaled 2019-class figure (marked approximate), the pruning-window IBD minutes per tier, pool shares per core per second, and the gate margin left for 3.0; the seeds' header-verify load is checked in the testnet go checklist | ca2-mixer af345b1e2c541ffbb; coordinator | closed as measured (ca2-mixer 54bbfcc, mixer-x4.md 6.5): one M5 Max core under a load of 5.6, ms per warp v2 1.33, x4 1.94 (1.45x), x8 2.79 (2.1x), worst cold 1.58 / 2.04 / 2.94; quiet-core scaled 0.60 / 0.88 / 1.26 (approximate); 2019-class laptop 1.5 / 2.2 / 3.2 (approximate, unmeasured); shares per core per second quiet 1,660 / 1,140 / 790 (spec 09's 2,270 re-cut to 1,660), a 22,000-member pool needs 1.3 / 1.9 / 2.8 quiet cores; IBD over 108,000 headers quiet 1.1 / 1.6 / 2.3 min (laptop 2.7 / 4.0 / 5.8); gate margin for 3.0 on the loaded core 8.4 / 8.0 / 7.1 ms. The mixer multiplies the ALU part only, so x8 is 2.1x the verifier. Owed before the level 3 page quotes an absolute: one quiet-core run (the ratios are the measurement tonight). Superseded at 22:07 by the fixed crate: v3 (x8) 2.1 ms per warp near-quiet, 3.4x v2 (see C29) |
|
||||
| C20 | Layer 9: the epoch length as an era parameter, 600 to 7,200 DAA s (10 min to 2 h), base 3,600 | status 22:30 and 23:00 | rigs (HiveOS and the rig installer), Macs, pools, the seed path | Both rig miners run `--exit-on-seed-change` and re-export the pack on exit 42 (h-run.sh 56 to 61, igneum-miner.sh 67 to 78): at a 10-minute epoch every card's miner restarts six times an hour with a pack export each time, and the restart gap is lost hashing; the app's prepare-ahead path does not restart. The Mac fleet's prepare pause (35 s an hour at one epoch an hour, bench-log M11) becomes 3.5 min an hour, 6%. The 10-minute seed VDF (spec 04) equals the shortest epoch, so the seed for epoch n+1 is known only as epoch n starts, which is the compile-ahead window the agent must measure per card (the 5090 compiled in 1,285 ms, the 9070 XT unmeasured). A pool's `job` cadence and the dev-fee counter are unaffected | The epoch-length document carries a per-tier row: rig restart cost per epoch length (and the fix: prepare-ahead in the rig scripts, no exit 42 path), the Mac pause share, the compile-ahead margin per card at 600 DAA s against the VDF; the rig installer removes `--exit-on-seed-change` in favour of the prepare path before layer 9 can draw a short epoch | ca2-epoch a32a3ece66c02417a; rig installer | closed with a correction (ca2-epoch 4300608, epoch-length.md sections 6.3, 7, 9): the rig scripts pass --prepare-packs as well, so a worker with prepare support swaps in place and exit 42 is the fallback on a prepare MISS (the loaded iGPU missed 2 of 10, M11), not a restart every epoch; the risk at 600 s is one restart plus export plus inline compile per missed boundary; the Mac race pause is 6.3% of a 600-s epoch (race default off); the program is known a full epoch ahead at every length (lead and T_epoch fixed, option A); the 9070 XT compile is OWED (no prepared line from gfx1201 in any upload); the rig installer (88f31d9) confirms the rig already takes the prepare path: exit 42 fires only when a worker's ready line lacks "prepare 1", and both shipped workers answer it; documented in its README, no code change |
|
||||
| C21 | OTA K2: apps embed `OTA_PUBLIC_KEYS = [K1, K2]` and honour a signed `revoked_keys` list; the rig installer verifies the manifest with ONE key (`OTA_PUBLIC_KEY_HEX`, install-rig.sh 168, igneum-update.sh 2) and knows no revocation; the HiveOS package verifies nothing (no manifest, the override reaches it only by republish) | ota-k2 c722579; packaging/linux; packaging/hive | rigs (both packages), the seeds (if they take the manifest) | The day K1 is lost or revoked and the manifest is signed with K2, every rig on the installer refuses the manifest, stops taking overrides, and is isolated at the next height switch; a leaked K1 keeps signing for rigs, because they carry no revocation list. HiveOS rigs get neither keys nor revocation: a republished package is their only path, and nothing checks who published it | The rig installer carries both public keys and the `revoked_keys` rule in the same form as the app (keys.md section 4, step 3), installed and read from the manifest; the HiveOS README states that the package is unsigned and names the sha256 the Flight Sheet URL should be checked against; keys.md lists the rig and HiveOS paths in its table of what trusts K1 | OTA key af2bb75a5436324d0 (keys.md, the shared verifier form); rig installer a3e7b2b03222f5cff | closed for the rig and the docs (rig installer 88f31d9: OTA_PUBLIC_KEYS [K1, K2 slot] embedded, manifest_check mirrors the app's manifest::check with the revoked_keys record at /var/lib/igneum/updates/revoked.json, tested on three throwaway keys; OTA agent 00fcbb5: keys.md table of every path that trusts K1, the HiveOS README unsigned-archive note); open: the wallet (wallet-v1) still trusts K1 alone, listed in keys.md for its owner; merge note: packaging/hive/README.md is edited on both ota-k2 and hive-words |
|
||||
|
||||
Sweep 3 (20:46 Mac clock) notes, no new row: the 9070 XT dropped off PC 1's bus at about 20:40 UTC (the second eGPU fault of the day); the coordinator stated the consequences (G1 on the gfx1036 stand-in, the 9070 XT v3 hash-rate and power rows owed, every earlier 9070 XT row stands, the AMD sweep queue item blocked, nobody woken) in its 21:05 and 21:10 entries and the rollout plan 7b. The epoch-length plan (ca2-epoch 4300608) carries its own per-tier table (section 7), including the node tier (one core 100% busy on the VDF at the 600-s floor) and the chain (24% of blocks in difficulty settle at the floor). The proving agent's 440fd59 rewrote the litepaper's two proving-gate sentences and evidence rows 15 and 16 on its branch (D2: the project lead approves the draft); the litepaper's card-lifetime sentence is untouched (D3, D4).
|
||||
| C22 | The SP1 CPU prover peaks at 29.5 to 30.5 GB RSS whatever the shard size and costs 282 s a shard (PC 1, `cpu-prove-pc1-small2`); no zkVM proves on AMD; the analysis concludes "no CPU tier" | `docs/analysis/amd-proving.md` sections 2, 3, 4a (amd-prove f1d7a7d, merged into ca2-coord) | Macs with 16 or 24 GB (the Settings switch turns the CPU prover on); AMD-only Windows and Linux machines (Settings can switch it on); every tier's expectation of the 20% share | provedefault.rs has a RAM gate for Windows (31,000 MB) and none for macOS or Linux, so a 16 GB Mac that flips the switch runs a 30 GB prover into swap and takes the node down with it (the Mac went down at 1% battery on 4 October; this is the same class of outage from memory). The analysis says the tile must say "about five minutes, paid only when no card proves first" but not that the switch is refused under 32 GB. The public tiers: AMD and Apple miners never see the 20% share on this build (now on the site, 1c8439f) | The CPU-prover switch is refused with the reason on every OS under 32 GB of RAM (the Windows constant generalised: macOS reads hw.memsize, Linux /proc/meminfo), and the tile line carries the 5-minute and 30 GB figures | proving v1 acd4f36bc2c07a4e2 | taken in full (proving agent: the prover loop refuses the CPU path on every OS under 32 GB, Settings cannot bypass it, with the line naming the machine's RAM; the tile line on CPU machines carries the 5-minute, 30 GB, paid-only-if-no-card figures; lands in the next app commit after the gate-test build; the "measured on a 32 GB card, not yet on a 24 GB card" marker is in evidence row 16, both litepaper sentences and the plan) |
|
||||
|
||||
Sweep 5 (21:08 Mac clock) notes: the coordinator applied the public proving line on ca2-coord (1c8439f: index, litepaper, miner page: "an NVIDIA card with 24 GB or more proves; AMD and Apple cards mine; a prover for them lands when a zkVM ships one") and the proving agent rewrote the litepaper's two gate sentences on proving-v1 (440fd59): two drafts of overlapping public sentences on two unpushed branches, both for the project lead (D2, D8); the integrator takes one. The measure-lock convoy (a0c3d13: a dead holder, two waiters, cargo tests re-acquiring build slots) is stated by the coordinator with a next-cut task. The S_p CPU shard job was dropped on the 312-s small-shard number (stated). GitHub Actions outage: the 0.3.10 installer builds on PC 1 (stated, 21:01).
|
||||
|
||||
## Round 3 (21:30 to 22:00 Mac clock)
|
||||
|
||||
| # | Number | Source | Tier affected | Consequence | Action | Owner | State |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| C23 | Mixer x4 or x8: the 5090's daily dataset build 13.4 ms at x1 (54 ms at x4, about 107 ms at x8); the integrated gfx1036 builds the dataset per PREPARE, 7 to 12 s at x1 and 55 to 124 s under CPU load (epoch-length.md section 7); the x8 rule: "the daily build under 1 s on every card we own" | status 21:25 and 21:27; epoch-length.md section 7 | integrated GPUs (the iGPU tier), 8 GB cards (about a tenth of a 5090's rate), rigs with one weak card | The x8 rule names "every card we own": the gfx1036 is one, and at x8 its per-prepare build is 56 to 96 s per epoch (7 to 16 min under load), so it misses every epoch boundary and falls to the exit-42 path; at x4 it is 28 to 48 s per hour (0.8 to 1.3%) or 4 to 8 min under load. An 8 GB discrete card scales at about a tenth of the 5090: about 1 s at x8, on the rule's edge. The per-day dataset reuse in the worker (owed in epoch-length.md section 9) is what makes the mixer cheap for the iGPU tier; without it the mixer multiplies a per-epoch cost | The mixer decision names the gfx1036 and an 8 GB-class scaled row beside the 5090, M5 Max and 9070 XT in the "under 1 s" check, and the per-day dataset reuse in the worker lands before (or with) class v3, or the iGPU tier is stated as "mines v3 with a restart per epoch" on the level 3 page | ca2-mixer af345b1e2c541ffbb; coordinator | taken (coordinator and ca2-mixer, mixer-x4.md build-time table: the x8 table carries the gfx1036 row and a scaled 8 GB-class row; the under-1-s rule applies to the discrete cards' daily build; for the integrated tier either the per-day dataset reuse in the three workers lands with class v3, asked of the mixer and node agents as a bounded change tonight, or the level 3 page says the iGPU tier mines v3 with a restart per epoch; recorded with the x4/x8 choice) |
|
||||
| C24 | Two PowerShell job scripts lost a quote inside an inline bash body tonight: amd-prove's awk program (cpu-prove-pc1-small, exit 0 with nothing proved) and the 0.3.10 installer's `bash -c` string (fb-installer-pc1-3, exit 2 in 4 s); amd-prove added `tools/amd-prove/check-job-bash.sh` (bash -n on its own scripts' bash bodies) | amd-proving.md section 2; status 21:28 | every PC job; the morning's rollouts | The class (CLAUDE.md, 5 October: fix the class the same day, add a check that fails when the shape comes back) is "a bash body inside a PowerShell job string"; the check exists for one agent's scripts and did not cover the shipper's, which failed the same way an hour later | A repo-wide CI check: every `.ps1` under relay/playbooks and tools that carries a bash body (`wsl ... bash -c`, `bash -lc`, here-strings fed to bash) has that body extracted and passed through `bash -n`; the shipper's rule (the WSL part as a file run with `bash <file>`) written in packaging/README-ship.md as the convention | sub-agent (bounded, no owner); coordinator told | closed on branch bash-body-check 7adb1ca (tools/ci/bash-body-check.sh with fixtures and self-test, ci.yml, the convention in packaging/README-ship.md; the flagged existing playbooks are in the sub-agent's report for their owners) |
|
||||
| C25 | Ember Tune: the signed manifest carries per-card-model tuning priors (power limit and core clock) that a new card applies and confirms in two steps; K1 signs it | ember-tune.md sections 4 and 5; bench-log Ember Tune entry | every NVIDIA and AMD card on the app; the keys | The manifest now sets clocks and power limits on every user's card, so the signing key's blast radius grew: a signed prior can underclock the fleet or push a card model to its power ceiling. The plan's clamps (inside power.min_limit / max_limit and clocks.max.gr, the confirm step, a faulted step reverted) are the bound; keys.md's "what K1 signs" table (ota-k2 00fcbb5) predates the priors and does not list them | ember-tune.md section 5 states the bound in one line (a prior can never set a value outside the card's own reported limits, and never a memory clock), with the test that proves it; keys.md's table gains the tuning priors under K1 with that bound | Ember Tune a855dcc4bd05e0615; OTA key agent (the table row) | closed (Ember Tune 5d7ced9: the rule as a row in section 5 with the two tests named, a prior of 9,000 MHz at 30% clamps to 3,090 MHz at 50%, a bad prior costs one confirm step per card; the OTA agent has the keys.md note: tuning priors and the kill switch under K1 with that bound) |
|
||||
|
||||
Sweep 7 (21:49 UTC) notes, no new row: the hot table is measured and NOT adopted (g 0.93 to 0.96 on the Mac against the 0.97 rule, bdab8df); the era draw passes the 5% rule on the Mac (spread 0.8%, c570da3) and its chip line says the union of a program's 16 windows covered the whole dataset in 300 of 300 programs, so a chip mirrors the whole dataset or nothing; the mixer x8 passes the verifier half of its rule (C19) and the PC build rows decide the other half about 22:10; PC 2's miners have been off since a job's /api/resume at 21:25 answered ok without restarting them (the devnet short PC 2's rate, the aggregation-cost mining phases void, a next-cut defect: a resume re-checks the miner processes), all stated by the coordinator at 21:45; PC 1 restarted on 0.3.10 at 21:40:41Z with no measurement straddling it.
|
||||
|
||||
## Round 4 (22:00 to 22:30 UTC)
|
||||
|
||||
| # | Number | Source | Tier affected | Consequence | Action | Owner | State |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| C26 | The 13.9 GB floor's cause: the shipped sp1-gpu-server 6.8.1 panics on any card under 24 GB (builder.rs 35 to 39) and allocates its core, recursion, shrink and wrap provers at Setup; the prover-floor agent rebuilds it from source on PC 2 with those sizes cut, CUDA_ARCHS=120 | proving-v1.md 4c82e56; status 22:01 | 12 and 16 GB NVIDIA cards (RTX 3060, 4070, 5070, 5080, 4060 Ti 16 GB): the tier the project lead asked for; packaging and signing | A server built for CUDA_ARCHS=120 runs on the 5090 only; the 12 GB tier is sm_86 (3060) and sm_89 (4070), the 16 GB tier sm_89 and sm_120, so a cut-size server measured on the 5090 proves nothing about a 3060 until the on-order 3060 runs it, and the build must list sm_86, sm_89, sm_120 (sm_100 is datacentre) to serve the tier at all. Shipping our own 250 MB CUDA server means the project signs and distributes a build of someone else's prover: it enters the DMG and the WSL2 package, the K1-signed inputs, the SBOM-style notes in evidence.md, and every SP1 upgrade is re-done by hand. Cut buffer sizes do not change the verifying key (prover-side chunking), so no guest re-pin, but the recipe must say so with a verify-segment run on a proof from the rebuilt server | The prover-floor measurement states its arch list and the card it ran on; the 12 GB claim waits for the 3060; the rebuilt server's packaging path (who builds, who signs, where it lands) is a row in the proving plan before 0.3.12, and the public line keeps "24 GB" until the 3060 proves on it | prover-floor agent (through the coordinator); proving v1 | taken (the proving plan carries "A self-built CUDA server (the 12 GB path), before 0.3.12": arch list sm_86 / sm_89 / sm_120 with one measured row per family, the build on PC 1 from a pinned SP1 tag, the Mac signs, placement as wsl2/bin/sp1-gpu-server with its sha256 in payload-inputs.json and the DMG, the evidence.md note, the rebuild at each SP1 upgrade, the gate that verify-segment and verify show the pinned keys unchanged; the 12 GB claim waits for the 3060; the prover-floor agent abefda4c3872f866f has the measurement side) |
|
||||
| C27 | The root-socket fault recurred at 21:25Z from another agent's job (agg-cost-pc2-1) after the class fix and CI check landed; PC 2's prover was dark 37 minutes; a resume at 21:25 answered ok without restarting the miners | bench-log 1d78979; status 21:45, 22:03 | every PC job; the devnet's proving and hash rate tonight | The class check lives in CI, but PC jobs are published from worktrees by `publish-jobs.sh` and never pass through CI before they run, so a job written on a branch without the check runs the old shape. The check must run where the job is published, not only where the repo is tested | `packaging/ota/publish-jobs.sh add` runs `tools/ci/prover-socket-check.sh` and the bash-body check on the script it publishes and refuses on a failure; the sub-agent on the bash-body check wires both; the resume defect is on the next-cut list (stated) | sub-agent bash-body-check (the wiring); coordinator (the rule) | closed on branch bash-body-check 6805125 (publish-jobs.sh add --kind run runs the bash-body check and prover-socket-check.sh before signing, refuses with the output, never skips; test-publish-jobs.sh 32 passed with four new refusals). Merge notes for the integrator: prover-socket-check.sh exists on both proving-v1 c2544be and this branch (add/add, take the superset here); the socket grep flags tools/amd-prove/pc1-cpu-prove.ps1 (CPU-only, -u root, no GPU server) so that job needs the cleanup lines or an allow-list entry before its next publish, told to the coordinator |
|
||||
|
||||
Sweep 8 (22:09 UTC) notes: mixer x8 DECIDED into v3 on the PC rows (the daily 1 GiB build latency-bound on every card: 5090 23 to 25 ms, 9070 XT 72 to 77 ms at every multiplier; the chip row 0.92x with the 3x factor), so the public claim holds with margin (D5 re-cut); the verifier regression (2.2x) bisected to inlining in the mixer's fetch loop and fixed, so the quiet-core figures of C19 return to about 0.6 / 0.9 / 1.3 ms; G6 job 3 failed on a stale fork test (era inside the class), job 4 on the final tree; the integration merge into master has three known conflicts (bench-log append-only, packfile.h and host.c take the ca2-v3 side).
|
||||
| C28 | One sp1-gpu-server per card on rigs pins about 6 GB of host RAM per server today (under 2 GB with route A's buffers, approximate) | `docs/analysis/proving-methods.md` section 5, rig row (proving-methods e7e0db7) | rigs with several 24 GB or 32 GB cards on the rig installer | A six-card rig that proves on every card pins about 36 GB of host RAM under the shipped server; the rig installer's preflight says 16 GB to prove (a warning, per card not per rig) and its prover unit runs one server, so the moment it moves to one server per card (the analysis's route C) the RAM check is wrong by the card count | The rig preflight scales its RAM warning by the number of proving cards (6 GB each today, the route A figure when measured) and the README's per-card table gains a host RAM column; the one-server-per-card unit lands only with that check | rig installer a3e7b2b03222f5cff | closed (rig installer 086008a: the preflight checks host RAM against 8 GB plus about 6 GB per proving card at the 23,552 MB gate, PROVER_RAM_GB_PER_CARD default 6 marked approximate, the README host RAM row per tier; the one-server-per-card unit lands only with that check) |
|
||||
|
||||
Sweep 8a (22:2x UTC) note: `docs/analysis/proving-methods.md` (branch proving-methods e7e0db7, 411 lines) carries its own per-tier table for today, route A, route D and route 3, and a public paragraph; it is the third draft of the proving public line (with proving-v1 440fd59 and ca2-coord 1c8439f), noted under D2 and D8; the route choice is D10.
|
||||
| C29 | The litepaper's verifier line now reads "Measured 2.1 ms on one loaded Apple M5 Max core for class v3" and the status says "4.8x inside the gate"; the measurement (ca2-mixer 54bbfcc) is 2.79 ms per warp for x8 on the loaded core (2.1 was the RATIO to v2), worst cold unit 2.94 ms, quiet-core about 1.3 ms by scaling | site/litepaper.html on ca2-coord 8e65696; status 22:20 | the public page; every node operator who reads the gate margin | A ratio printed as milliseconds understates the verifier cost by a third and overstates the gate margin (10 / 2.79 = 3.6x, 3.4x on the worst cold unit, not 4.8x). The number is the one a reviewer will re-run first | The line reads "about 2.8 ms per warp on a loaded M5 Max core (about 1.3 ms quiet, approximate), 2.1x the v2 verifier; worst cold unit 2.9 ms; the 10 ms gate leaves 3.4x" until the quiet-core run lands | coordinator ada8afb62d752b1e2 | closed, the reviewer's reading WITHDRAWN in part (coordinator 7e6f77c, status 22:25): the fixed crate's session at 22:07 measured 2.1 ms per warp for class v3 as a MEASUREMENT (worst cold 2.15) on a core at load 5.5, and that binary's v2 figure matched readwidth's quiet 0.61 ms within 1%, so the numbers are near-quiet and the litepaper's 2.1 ms was right; the 2.79 ms I cited was the slow binary's 21:40 session (the inlining regression, since fixed); the gate leaves 4.8x (4.6x on the worst cold unit), 3.4x the v2 verifier. The "about 1.3 ms quiet" scaling is struck everywhere. C19's quiet-core figures are superseded by this session |
|
||||
| C30 | The 5090 mines in the app at 115.4 MH/s (the power sweep, 22:09 to 22:15Z, hash from the app's API) against 136 to 137 MH/s at device time in every bench row tonight, with the cap not binding (draw 316 W under a 431 W cap, SM at 3,051 MHz) | bench-log e304458; read-width and mixer PC rows | every 5090 owner on the app (and every big card: the gap is the app's job loop, not the kernel) | About 15% of a 5090's hash is lost between the kernel and the app, and the sweep entry explains it away as API sampling. M11 measured the 9070 XT at the app's 2^21 job size equal to its 2^24 rate, but no 5090 row exists at 2^21; the 5090 finishes a 2^21 job in about 15 ms, so per-job launch, read-back and template work can cost that much. The STATUS line prints "wall" and "inside jobs" rates and would show it | One measurement on PC 1: `igneum-worker-cuda --bench` on the live pack at --batch-log2 21 and 24 on the 5090, and the 5090's STATUS wall-against-inside gap over 10 minutes; if the job size is the cause, the app's job size for cards over 100 MH/s rises (2^22 or 2^23) in the next cut: a 15% gain for every 5090 owner | repro-bench agent a0b9f574775ef1693 (its PC 1 slot); coordinator | taken (repro-bench agent: --bench at 2^21 and 2^24 on the 5090 on the genesis pack and the live pack in its PC 1 slot, then PC 2; the STATUS wall-against-inside gap from the 10 minutes before and after its window; the consequence written either way) |
|
||||
|
||||
Sweep 9 (22:2x UTC) notes: the devnet at 22:24:31Z reads DAA 136,578, 0.909 blocks/s measured over the stats window and 1.005 DAA/s averaged since the fee-switch plan's 15:40Z read (112,227), so H = 210,000 lands between about 18:50 and 19:35 UTC on 6 October, up to an hour EARLIER than the 19:50Z written in fee-switch-devnet.md, the rollout plan 7a and release-0.3.10.md; the 16:00Z check (D1) keeps about 2.8 hours of margin and stands; the plans' ETA is re-cut in D1 and sent to the coordinator. Gates tonight: G3, G4, G6 green on the final class (x8 + era); G4b added (the Mac app passes --prepare-packs only to non-Metal workers, so every Mac would stop at the first v3 epoch: found by the node agent, the fix with a unit test and a real Metal gate run before the ship); G1 and G2 on the PC 1 job since 22:16.
|
||||
|
||||
Merge note for the integrator (22:3x UTC): branch `consequences` is docs/plans/consequences-2026-10-05.md and consequences-decisions.md only (base ca8d9f3); a merge-tree against master 1f0d62c shows 0 conflicts. Branch `bash-body-check` (7adb1ca, 6805125, e3bd761) carries tools/ci/bash-body-check.sh, kit-path-check.sh, the copied prover-socket-check.sh (add/add with proving-v1's: take bash-body-check's), ci.yml steps, the publish-jobs.sh gate and packaging/README-ship.md; it is on the coordinator's ship order after ca2-coord.
|
||||
| C31 | The copy the ship takes (ca2-coord at 22:3x): litepaper line 562 "12 GB or more proves full shards" beside line 452 "Proving needs an NVIDIA card with 24 GB or more"; evidence row 16 still "A 12 GB card proves one shard in about 20 s, designed" while proving-v1 440fd59 withdrew it; line 562 states the dataset "doubles on a step schedule fixed at genesis (years 4, 12 and 28)" | ca2-coord site/litepaper.html 452 and 562, docs/evidence.md 43; proving-v1 440fd59; D4 | every reader of the litepaper; the integrator; the project lead's D4 | One page says 12 GB and 24 GB for the same thing; the evidence table on the ship branch contradicts the measurement, and the two branches will conflict on evidence.md and litepaper.html at the merge (ship order: ca2-coord before proving-v1), so the stale row can win by accident; and the step schedule is written as a genesis fact while the coordinator's own 22:50 entry calls mapping (b) a recommendation for the project lead (gate 1, D4): a public page should not decide a genesis parameter before he does | On ca2-coord: strike "12 GB or more proves full shards" from line 562 (line 452 is the sentence); take proving-v1's evidence row 16 (WITHDRAWN, 24 GB measured) at the merge and say so in the merge plan; write the growth sentence as the recommendation it is ("the plan is a step schedule ... decided at gate 1") until D4 is taken | coordinator ada8afb62d752b1e2 | closed on ca2-coord a7be43f and 0d9b23d (the 12 GB clause struck; the merge rule "take proving-v1's row 16 and its proving sentences" in the rollout plan; one wording in all five places, "the proposed schedule, fixed at the testnet genesis: 2 GB, doubling at years 4, 12 and 28", the lifetime sentences kept as consequences of the proposal and marked approximate) |
|
||||
| C32 | The 0.3.10 install at 21:49Z cleared PC 1's app jobs folder and with it the AMD kit fetched at 21:23:59Z; the amd-card-test playbook now says its fetch must be republished after any app update | bench-log 39f02ff; status 21:05 ("the jobs folder is cleared by fetch jobs" was the earlier, wrong reading) | every PC job tonight and tomorrow; the 0.3.11 rollout | A class, not one playbook: every fetch-then-run pair (era, hot table, mixer, repro, Ember, the AMD sweep, the prover-floor build) loses its kit when an update lands between the fetch and the run, and the run fails in seconds or, worse, runs against a stale copy. The 0.3.11 update-now reaches PC 2 while the prover-floor agent's 60 to 90 minute server build runs there (go at 22:17, to about 23:50): if that build's working directory is under the app's jobs folder, the update wipes it mid-build and the 12 GB rows slip past the morning | (1) The 0.3.11 update-now is sequenced after the prover-floor build closes, or the build's directory is confirmed outside the jobs folder before the ship; (2) every run playbook begins with a presence check of its kit and fails with "kit missing: republish the fetch after the app update" (the class check: the bash-body sub-agent's CI check gains a rule that a run job naming a kit path tests it first, or the coordinator's queue re-fetches after every update as a rule) | coordinator ada8afb62d752b1e2 (the queue and the ship order) | taken (coordinator: the prover-floor build lives under /opt/igneum-floor in WSL2, outside the jobs folder, but it is the app's job process and an app restart ends it, so the 0.3.11 update-now goes to PC 2 only after floor-build-3 closes, the ship's earlier steps not waiting; the re-fetch rule and the presence-check rule are in the rollout plan beside the one-job rule, the playbook owners carry it at their next publish; the CI side is with the bash-body sub-agent as a kit-path check). CI side closed on bash-body-check e3bd761: tools/ci/kit-path-check.sh in ci.yml and in publish-jobs.sh add --kind run (34 tests pass); every existing kit-using playbook (13 across master, ca2-v3, ca2-analysis, rdna4-telemetry) already checks before use, so the gate guards the shape without a backlog |
|
||||
| C33 | `/api/live` at 22:33Z: 13 of 482 blocks fully proven in 10 minutes (2.7%), 1 prover, median proof lag 46 s, the live node's verifier "Off" with 42 pool entries pending and 0 verified; `/api/stats` (the documented public API) carries no proving field at all | live and stats handlers (`site/api/stats.mjs` FIELDS; the explorer branch 3e01212); the homepage "~60 s to a proof"; evidence row 15 | everyone who reads the public API or the homepage tile; the testnet's first external reader | The public stats API hides the one number that qualifies the tile and row 15: coverage is 2.7% with one prover, and the proof lag is 46 s. A reader can find it only on /api/live. The live node's verifier "Off" (42 pending, 0 verified) is the Mac app node in trust mode or without a host, so the page says "verifier Off" while the chain pays provers: a public-page oddity the morning reader will ask about | `/api/stats` gains a `proving` object from the same live_state (`blocks_10m`, `blocks_fully_proven_10m`, `shards_paid_10m`, `provers_10m`, `median_proof_lag_s`, `active`), documented in docs/api/public-stats.md and in its contract test; the live page's verifier line names which node it reads and why it is off; the homepage tile's "~60 s" caption cites the measured 46 s median and the 2.7% coverage ("the target; today one prover covers 2.7% of blocks at a 46 s median") | explorer a76f60b415859b7b5 (the API); the tile caption: D2 wording for the project lead | closed on explorer d7e797c (/api/stats carries the proving object with coverage_10m, 0.0273 at 22:33Z, in the contract test, the live check and docs/api/public-stats.md with the sentence that coverage is what a third party reads before "every block is proven"; the live page's proving legend and the API note say the observer's node runs its own verifier off and reads paid shards from the chain). The homepage tile caption stays with the project lead (D2) |
|
||||
|
||||
Sweep 11 (22:38 UTC) notes, no new row: gate G4b GREEN (a real Metal miner across a v3 boundary; a second Metal-only fault found and fixed first, 00c55aa: serveDataset keyed the day dataset by day alone and would have hashed v3 over an x1 dataset; the coordinator's next-cut rule: every worker path mines across a boundary in the gate network before a class change ships). The reviewer checked the PCs' side of that class: the CUDA worker and the OpenCL host build each resident pair (program, cache, dataset) from the pack's own memhard.h and key it by (epoch, day, class, era) through pairIsClass (proto-cuda/nvrtc/worker.cpp 451, proto-opencl/host.c 1106 on ca2-v3 fa3c932), so the fault does not reach the PCs at N4. The patched sp1-gpu-server built green on PC 2 at 22:32:29Z with sm_86, sm_89, sm_120 (C26's arch list), the floor sweep (9 points) running; the integration merges (readwidth 30ff674, origin/master 1f0d62c) on ca2-v3 49c7e78 with every check green. All gates but G5 (the ship's build) are green; the ship waits on the merged tip.
|
||||
| C34 | The rollout plan's packaged override line (counter-asic-2-rollout.md 26) carries five switches: difficulty_v2 33000, proving_v0 84100, fees_v1 210000, finality_v3 135200, program_class_v3 N4; section 8a says 0.3.11 publishes ONE object with both activations set | counter-asic-2-rollout.md 26 and 8a; proving-v1.md (the four v1 fields enter the digest only once proving_v1_activation_daa is set) | every node and every prover on the devnet at the 0.3.11 publish | If the publisher copies line 26, proving v1 ships in the binary and never activates: proving_v1_activation_daa stays at never on every node, the digest is the five-field one, the segment records are never carried, and C1's fix (the export fields) still works but the aggregator, the chain rule and the unproven rule stay off while the plan and the public copy say they are live. The four fields (activation_daa H1 = tip + 14,400 at publish, segment_blocks 8, unproven_daa 600, aggregator_share_bps 1000) must be in the packaged line, the manifest object, the hand nodes' and the seed's files, verbatim, and the expected digest read on a scratch node with all nine fields | Line 26 and the ship step name the nine-field object with N4 and H1 both set at publish and the scratch-node digest read over that object (the 22:xx "expected 0.3.11 digest with the two new fields at never" is the rolling-upgrade digest, not the activation one; both are recorded) | coordinator ada8afb62d752b1e2 (the ship runbook) | sent |
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue