From d6d6153c9fbafbac21f67b554804c39e6ed35f42 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:06:26 +0000 Subject: [PATCH] Proving v0: payouts in the shard statement (fixture, ShardInput, executor), the empty-segment plan fix, host modes compressed and verify, exporter reads payouts; the app's prover service (src/prover.rs); the 3-node test network script (tools/proving-v0/run.mjs); proving_v0_activation_daa in the fast-time profile Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/prover.rs | 458 ++++++++++++++++++ infra/fast-time/override-60x.json | 3 +- proving/igneum-prove/core/src/executor.rs | 11 +- proving/igneum-prove/core/src/fixture.rs | 4 + proving/igneum-prove/core/src/plan.rs | 22 +- proving/igneum-prove/core/src/shard.rs | 10 +- proving/igneum-prove/export/src/main.rs | 3 + proving/igneum-prove/host/Cargo.toml | 1 + proving/igneum-prove/host/src/main.rs | 79 ++- proving/igneum-prove/host/src/proof_system.rs | 5 + tools/proving-v0/run.mjs | 219 +++++++++ 11 files changed, 798 insertions(+), 17 deletions(-) create mode 100644 app/igneum-app/src/prover.rs create mode 100644 tools/proving-v0/run.mjs diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs new file mode 100644 index 00000000..2cd2e67b --- /dev/null +++ b/app/igneum-app/src/prover.rs @@ -0,0 +1,458 @@ +//! The prover service (proving v0, docs/spec/07-execution.md 7.7, docs/plans/proving-v0.md): when `prove` is on +//! in the settings, this machine proves the shards the chain assigns to its vote keys and submits the proof +//! records to its own node, which relays them and carries them in blocks; the carrying segment pays the shard's +//! part of the proving pool to the rewards address. +//! +//! One thread, its own loop, no job mechanism of its own: every 10 s while enabled it asks the node for the +//! shards assigned to this machine's keys (`igneum_getAssignedShards`), takes the newest one not yet paid or +//! attempted, exports the chain (`igneum_exportSegments`) and cuts the fixture (`igneum-prove-export`), proves +//! the shard (`igneum-prove-host --mode compressed`), signs the record with the identity's vote key +//! (`igneum-miner sign-record`, the same label the miner derives the key from) and submits it +//! (`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. + +use crate::engine::Shared; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// One shard the node lists for this machine's keys (`igneum_getAssignedShards`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Work { + pub number: u64, + pub hash: String, + pub shard: u32, + pub pgas: u64, + pub tx_count: u64, + pub assigned: bool, + pub paid: bool, + pub in_pool: bool, + pub shard_wei: u128, + /// The first of this machine's keys that is assigned (the label that signs). + pub key_hash: String, +} + +/// Parses the node's work list. Newest first, as the node returns it. +pub fn parse_work(v: &Value) -> Vec { + let hexu = |x: &Value| x.as_str().and_then(|s| u128::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0); + v.as_array() + .map(|a| { + a.iter() + .map(|w| Work { + number: hexu(&w["number"]) as u64, + hash: w["hash"].as_str().unwrap_or("").to_string(), + shard: w["shard"].as_u64().unwrap_or(0) as u32, + pgas: hexu(&w["pgas"]) as u64, + tx_count: w["txCount"].as_u64().unwrap_or(0), + assigned: w["assigned"].as_bool().unwrap_or(false), + paid: !w["paid"].is_null(), + 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(), + }) + .collect() + }) + .unwrap_or_default() +} + +/// The shard to prove next: assigned to one of our keys, unpaid, not already in the pool from us, not attempted +/// in this session; the newest first (its exclusive window is the one still open), the smallest among equals. +pub fn choose(work: &[Work], attempted: &HashSet<(String, u32)>) -> Option { + let mut c: Vec<&Work> = work.iter().filter(|w| w.assigned && !w.paid && !w.in_pool && !attempted.contains(&(w.hash.clone(), w.shard))).collect(); + c.sort_by(|a, b| b.number.cmp(&a.number).then(a.pgas.cmp(&b.pgas))); + c.first().map(|w| (*w).clone()) +} + +/// 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 + } +} + +/// The identity labels this machine mines with (the vote keys the node assigns shards to): one per enabled +/// card, `-`, and `-1..N` per identity when a card runs more than one. +pub fn labels(shared: &Shared) -> Vec { + let base = format!("{}-{}", if cfg!(target_os = "macos") { "mac" } else { "win" }, shared.runtime.id8()); + let st = shared.state.lock().unwrap(); + let mut out = Vec::new(); + for c in st.mining.cards.iter().filter(|c| c.enabled) { + let label = format!("{base}-{}", c.index + 1); + if c.identities > 1 { + for i in 1..=c.identities { + out.push(format!("{label}-{i}")); + } + } else { + out.push(label); + } + } + if out.is_empty() { + out.push(format!("{base}-1")); + } + out +} + +/// The network name the record signature is domain-separated with (`Params::network_name()`). +pub fn chain_name(shared: &Shared) -> String { + let r = &shared.runtime; + match r.devnet_suffix { + Some(s) if r.network == "devnet" => format!("igneum-devnet-{s}"), + _ => format!("igneum-{}", r.network), + } +} + +struct Tools { + host: PathBuf, + export: PathBuf, + miner: PathBuf, + /// Windows: the host and the exporter run inside WSL2 (paths are WSL paths then). + wsl: bool, + setup_script: Option, + cuda: bool, +} + +fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); + let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method)); + std::fs::write(&tmp, body).map_err(|e| e.to_string())?; + let url = format!("http://127.0.0.1:{}", shared.runtime.evm_port()); + let out = crate::detect::run_timeout( + Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "--data-binary", &format!("@{}", tmp.display())]), + None, + timeout + Duration::from_secs(2), + ); + let _ = std::fs::remove_file(&tmp); + let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?; + let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: {e}"))?; + if let Some(err) = v.get("error") { + return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error"))); + } + Ok(v.get("result").cloned().unwrap_or(Value::Null)) +} + +fn find_tools(bin_dir: &Path) -> Result { + 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): built there by setup-wsl.sh, or shipped as a Linux binary + let probe = Command::new("wsl").args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", "for f in ~/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"]).output(); + 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(); + 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)" })), + } + } else { + let host = bin_dir.join("igneum-prove-host"); + let export = bin_dir.join("igneum-prove-export"); + if !host.exists() || !export.exists() { + return Err(format!("igneum-prove-host and igneum-prove-export are not next to the engine ({})", bin_dir.display())); + } + Ok(Tools { host, export, miner, wsl: false, setup_script: None, cuda: false }) + } +} + +/// Runs the host or the exporter: directly, or through WSL on Windows. Returns (exit ok, output). +fn run_tool(t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration) -> (bool, String) { + let mut cmd = if t.wsl { + let mut c = Command::new("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::>().join(" ")); + c.args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", &line]); + c + } else { + let mut c = Command::new(exe); + c.args(args); + for (k, v) in env { + c.env(k, v); + } + c + }; + crate::platform::quiet(&mut cmd); + let started = Instant::now(); + match crate::detect::run_timeout(&mut cmd, None, limit) { + Some(out) => { + let ok = out.lines().any(|l| l.starts_with("results written to") || l.contains("fixture written") || l.starts_with("wrote ")) || !out.contains("Error") && started.elapsed() < limit; + (ok, out) + } + None => (false, format!("{} did not finish in {} s", exe.display(), limit.as_secs())), + } +} + +fn set(shared: &Shared, f: F) { + let mut st = shared.state.lock().unwrap(); + f(&mut st.proving); +} + +static BIN_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Starts the prover thread. It idles while the setting is off or the node is not synced. +pub fn start(shared: Arc, bin_dir: PathBuf) { + let _ = BIN_DIR.set(bin_dir.clone()); + std::thread::Builder::new() + .name("igneum-prover".into()) + .spawn(move || loop_forever(shared, bin_dir)) + .expect("prover thread"); +} + +fn loop_forever(shared: Arc, bin_dir: PathBuf) { + let mut attempted: HashSet<(String, u32)> = HashSet::new(); + let mut tools: Option = None; + let mut last_probe = Instant::now() - Duration::from_secs(600); + let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new(); + loop { + std::thread::sleep(Duration::from_secs(10)); + let enabled = shared.settings.lock().unwrap().prove; + let (synced, quitting) = { + let st = shared.state.lock().unwrap(); + (st.node.synced, st.quitting) + }; + if quitting { + return; + } + if !enabled { + set(&shared, |p| { + p.enabled = false; + p.status = "off".into(); + }); + continue; + } + if tools.is_none() && last_probe.elapsed() >= Duration::from_secs(60) { + last_probe = Instant::now(); + match find_tools(&bin_dir) { + Ok(t) => { + shared.log(&format!("prover: host {}{}{}", t.host.display(), if t.wsl { " (WSL2)" } else { "" }, if t.cuda { ", CUDA" } else { ", CPU (slow)" })); + set(&shared, |p| { + p.available = true; + p.setup_hint = String::new(); + p.backend = if t.cuda { "cuda".into() } else { "cpu".into() }; + }); + tools = Some(t); + } + Err(e) => { + set(&shared, |p| { + p.enabled = true; + p.available = false; + p.setup_hint = e.clone(); + p.status = "setup".into(); + p.message = e; + }); + continue; + } + } + } + let Some(t) = tools.as_ref() else { continue }; + set(&shared, |p| { + p.enabled = true; + p.available = true; + }); + if !synced { + set(&shared, |p| { + p.status = "waiting".into(); + p.message = "waiting for the node to sync".into(); + }); + continue; + } + // 1. the keys and the work list + let labels = labels(&shared); + let mut keys: Vec<(String, String)> = Vec::new(); + for l in &labels { + if let Some(out) = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["key-hash", l]), None, Duration::from_secs(10)) { + if let Some(h) = out.lines().last().map(|s| s.trim().to_string()).filter(|s| s.len() == 64 || s.len() == 66) { + keys.push((l.clone(), if h.starts_with("0x") { h } else { format!("0x{h}") })); + } + } + } + if keys.is_empty() { + set(&shared, |p| { + p.status = "waiting".into(); + p.message = "no vote key (is igneum-miner next to the engine?)".into(); + }); + continue; + } + let work = match evm_rpc(&shared, "igneum_getAssignedShards", json!([keys.iter().map(|(_, h)| h.clone()).collect::>(), 60]), Duration::from_secs(10)) { + Ok(v) => parse_work(&v), + Err(e) => { + set(&shared, |p| { + p.status = "waiting".into(); + p.message = e; + }); + continue; + } + }; + // paid shards among what we submitted + for (n, h, s, wei) in submitted.clone() { + if work.iter().any(|w| w.hash == h && w.shard == s && w.paid) { + submitted.retain(|x| !(x.1 == h && x.2 == s)); + shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18)); + set(&shared, |p| { + p.paid += 1; + p.paid_wei += wei; + p.last_paid = format!("block {n} shard {s}"); + }); + } + } + let assigned = work.iter().filter(|w| w.assigned).count() as u32; + set(&shared, |p| { + p.assigned = assigned; + p.keys = keys.len() as u32; + }); + 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 in the last 60 blocks".into() } else { "every assigned shard is proven or paid".into() }; + }); + continue; + }; + attempted.insert((w.hash.clone(), w.shard)); + let label = keys.iter().find(|(_, h)| *h == w.key_hash).map(|(l, _)| l.clone()).unwrap_or_else(|| keys[0].0.clone()); + let payout = shared.settings.lock().unwrap().address.clone(); + if payout.len() != 42 { + set(&shared, |p| { + p.status = "waiting".into(); + p.message = "no rewards address".into(); + }); + continue; + } + let started = Instant::now(); + set(&shared, |p| { + p.status = "proving".into(); + p.current = format!("block {} shard {} ({} txs, {} pgas)", w.number, w.shard, w.tx_count, w.pgas); + p.started_at = crate::platform::unix_now_f(); + p.message = "exporting the chain and cutting the shard".into(); + }); + shared.log(&format!("prover: block {} shard {} assigned to {label}: export, cut, prove ({}), sign, submit", w.number, w.shard, if t.cuda { "CUDA" } else { "CPU" })); + // 2. export and cut + let dir = shared.runtime.app_dir.join("proving"); + let _ = std::fs::create_dir_all(&dir); + let seq = dir.join("seq.json"); + let fixture = dir.join(format!("block-{}.json", w.number)); + let results = dir.join(format!("results-{}-{}.json", w.number, w.shard)); + let outcome: Result<(), String> = (|| { + let export = evm_rpc(&shared, "igneum_exportSegments", json!(["0x0", format!("{:#x}", w.number)]), Duration::from_secs(120))?; + std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?; + let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) }; + let (ok, out) = run_tool(t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600)); + 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() }); + let prover_env = if t.cuda { "cuda" } else { "cpu" }; + let (ok, out) = run_tool(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)); + let _ = std::fs::write(dir.join(format!("prove-{}-{}.log", w.number, w.shard)), &out); + if !ok || !results.exists() { + return Err(format!("prover: {}", 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 statement = res["statement"].as_str().ok_or("no statement in the results")?.to_string(); + let proof_sha = res["proof_sha256"].as_str().ok_or("no proof hash in the results")?.to_string(); + let proof_file = res["proof_file"].as_str().ok_or("no proof file in the results")?.to_string(); + let proof_path = if t.wsl { PathBuf::from(proof_file.replace("/mnt/c/", "C:/")) } else { PathBuf::from(proof_file) }; + let secs = res["compressed_prove_seconds"].as_f64().unwrap_or(0.0); + set(&shared, |p| { + p.message = format!("proved in {secs:.0} s, signing and submitting"); + p.last_prove_s = secs; + }); + // 3. sign and submit + let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-record", &label, &chain_name(&shared), &w.hash, &w.number.to_string(), &w.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(&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::()); + let r = evm_rpc(&shared, "igneum_submitProofRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?; + if !r["accepted"].as_bool().unwrap_or(false) { + return Err(format!("record refused: {}", r["reason"].as_str().unwrap_or("?"))); + } + Ok(()) + })(); + match outcome { + Ok(()) => { + shared.event("proving", &format!("block {} shard {} proven and submitted in {:.0} s", w.number, w.shard, started.elapsed().as_secs_f64())); + submitted.push((w.number, w.hash.clone(), w.shard, w.shard_wei)); + set(&shared, |p| { + p.proved += 1; + p.submitted += 1; + p.status = "submitted".into(); + p.message = format!("block {} shard {} submitted; paid when a block carries it", w.number, w.shard); + p.current = String::new(); + }); + } + Err(e) => { + shared.log(&format!("prover: block {} shard {}: {e}", w.number, w.shard)); + set(&shared, |p| { + p.failed += 1; + p.status = "idle".into(); + p.message = e; + p.current = String::new(); + }); + } + } + } +} + +/// 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 { + let bin_dir = BIN_DIR.get().cloned().unwrap_or_default(); + let script = bin_dir.join("wsl2").join("setup-wsl.sh"); + if !cfg!(windows) { + return Err("nothing to set up on this platform: the prover runs next to the engine".into()); + } + if !script.exists() { + return Err(format!("setup script missing: {}", script.display())); + } + let line = format!("bash {}", wsl_path(&script)); + let mut c = Command::new("cmd"); + c.args(["/c", "start", "", "wsl", "-d", "Ubuntu-24.04", "--", "bash", "-lc", &line]); + c.spawn().map_err(|e| e.to_string())?; + shared.event("proving", "WSL2 prover setup started in its own window"); + Ok(json!({ "ok": true })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chooses_the_newest_unpaid_assigned_shard_not_yet_attempted() { + let v: Value = serde_json::from_str(r#"[ + {"number":"0x20","hash":"0xa","shard":0,"pgas":"0x640","txCount":1,"assigned":true,"assignedKeys":["0xk1"],"paid":null,"pool":[],"shardWei":"0x10"}, + {"number":"0x1f","hash":"0xb","shard":1,"pgas":"0x10","txCount":0,"assigned":true,"assignedKeys":["0xk1"],"paid":null,"pool":[{"keyHash":"0xk1"}],"shardWei":"0x10"}, + {"number":"0x1e","hash":"0xc","shard":0,"pgas":"0x10","txCount":0,"assigned":true,"assignedKeys":["0xk2"],"paid":{"wei":"0x1"},"pool":[],"shardWei":"0x10"}, + {"number":"0x1d","hash":"0xd","shard":0,"pgas":"0x10","txCount":0,"assigned":false,"assignedKeys":[],"paid":null,"pool":[],"shardWei":"0x10"}, + {"number":"0x1c","hash":"0xe","shard":0,"pgas":"0x10","txCount":0,"assigned":true,"assignedKeys":["0xk2"],"paid":null,"pool":[],"shardWei":"0x10"} + ]"#).unwrap(); + let work = parse_work(&v); + assert_eq!(work.len(), 5); + assert_eq!(work[0].shard_wei, 16); + let mut attempted = HashSet::new(); + let w = choose(&work, &attempted).unwrap(); + assert_eq!((w.number, w.shard, w.key_hash.as_str()), (32, 0, "0xk1"), "the newest assigned, unpaid, not in the pool"); + attempted.insert((w.hash.clone(), w.shard)); + let w = choose(&work, &attempted).unwrap(); + assert_eq!((w.number, w.shard), (28, 0), "block 31 is already in the pool, 30 is paid, 29 is not ours"); + attempted.insert((w.hash.clone(), w.shard)); + assert!(choose(&work, &attempted).is_none()); + } + + #[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"); + } +} diff --git a/infra/fast-time/override-60x.json b/infra/fast-time/override-60x.json index 96acba58..a5e2beb9 100644 --- a/infra/fast-time/override-60x.json +++ b/infra/fast-time/override-60x.json @@ -50,5 +50,6 @@ "pow_epoch_blocks": 60, "pow_epoch_lead": 10, "pow_day_ms": 1440000, - "difficulty_v2_activation_daa": 18446744073709551615 + "difficulty_v2_activation_daa": 18446744073709551615, + "proving_v0_activation_daa": 18446744073709551615 } diff --git a/proving/igneum-prove/core/src/executor.rs b/proving/igneum-prove/core/src/executor.rs index fa85334d..2daa5788 100644 --- a/proving/igneum-prove/core/src/executor.rs +++ b/proving/igneum-prove/core/src/executor.rs @@ -189,15 +189,20 @@ pub struct RangeOutcome { /// Executes a contiguous range of a segment's transactions on `db` from `carry_in` (design 1.2 and 5.1): the /// rewards first when given (the segment's first shard), then every transaction in order. `db` is the state /// before the range on entry and after it on return. With `roots`, the state root is computed at every boundary. -pub fn execute_range(db: &mut IgneumDb, chain_id: u64, env: &FixtureEnv, rewards: Option<(&[(Address, U256)], U256)>, txs: &[ShardTx], carry_in: Carry, roots: bool) -> RangeOutcome { +pub fn execute_range(db: &mut IgneumDb, chain_id: u64, env: &FixtureEnv, rewards: Option<(&[(Address, U256)], U256, &[(Address, U256)])>, txs: &[ShardTx], carry_in: Carry, roots: bool) -> RangeOutcome { let base_fee_exec = env.base_fee_exec as u128; let base_fee_proving = env.base_fee_proving as u128; - if let Some((rewards, pool)) = rewards { + if let Some((rewards, pool, payouts)) = rewards { for (miner, wei) in rewards { db.add_balance(*miner, *wei); } db.add_balance(PROVING_POOL_ADDRESS, pool); + // proving v0 payouts (spec 7.7), as the node applies them: from the escrow, saturating + for (to, wei) in payouts { + db.sub_balance(PROVING_POOL_ADDRESS, *wei); + db.add_balance(*to, *wei); + } } let mut executed: Vec = Vec::new(); @@ -381,7 +386,7 @@ pub struct BlockOutcome { /// recorded after every transaction. pub fn execute_block(db: &mut IgneumDb, f: &crate::fixture::BlockFixture) -> BlockOutcome { let txs = f.flatten(); - let out = execute_range(db, f.chain_id, &f.env, Some((&f.rewards, f.proving_pool_credit)), &txs, Carry::default(), true); + let out = execute_range(db, f.chain_id, &f.env, Some((&f.rewards, f.proving_pool_credit, &f.payouts)), &txs, Carry::default(), true); let state_root = db.state_root(); BlockOutcome { executed: out.executed, diff --git a/proving/igneum-prove/core/src/fixture.rs b/proving/igneum-prove/core/src/fixture.rs index 9666c1f9..36555d36 100644 --- a/proving/igneum-prove/core/src/fixture.rs +++ b/proving/igneum-prove/core/src/fixture.rs @@ -50,6 +50,10 @@ pub struct BlockFixture { pub rewards: Vec<(Address, U256)>, /// The 20% proving-pool share credited to the pool escrow for this segment, in wei. pub proving_pool_credit: U256, + /// Proving v0 (spec 7.7): the prover payouts the segment applies, pool escrow to payout address, in wei. + /// Consensus data (from the proof records the segment's blocks carry), applied by shard 0 after the rewards. + #[serde(default)] + pub payouts: Vec<(Address, U256)>, pub blocks: Vec, pub pre_state: Vec, } diff --git a/proving/igneum-prove/core/src/plan.rs b/proving/igneum-prove/core/src/plan.rs index b910b537..4e3df63d 100644 --- a/proving/igneum-prove/core/src/plan.rs +++ b/proving/igneum-prove/core/src/plan.rs @@ -32,22 +32,24 @@ pub fn shard_id(segment: &B256, index: u32) -> B256 { /// Cuts the trace greedily: a shard grows while the next transaction keeps it at or under `budget`; a /// transaction that alone exceeds the budget becomes its own shard. A segment with no transactions is one -/// empty shard (it still carries the rewards). `pre_root` and `pre_carry` describe the state before the first -/// transaction; every boundary must carry its state root. -pub fn plan(pre_root: B256, pre_carry: Carry, boundaries: &[Boundary], budget: u64) -> Vec { - let root_at = |i: usize| -> B256 { +/// empty shard (it still carries the rewards and payouts, so its post-root is `post_root`, the segment's root +/// after them, not `pre_root`). `pre_root` and `pre_carry` describe the state before the rewards; every +/// boundary must carry its state root. The same cut as the node's `igneum_exec::proving::cut`. +pub fn plan(pre_root: B256, post_root: B256, pre_carry: Carry, boundaries: &[Boundary], budget: u64) -> Vec { + let n = boundaries.len(); + let pre_root_at = |i: usize| -> B256 { if i == 0 { pre_root } else { boundaries[i - 1].state_root.expect("the planner needs the state root at every boundary") } }; + let post_root_at = |i: usize| -> B256 { if i == n { post_root } else { pre_root_at(i) } }; let carry_at = |i: usize| -> Carry { if i == 0 { pre_carry } else { boundaries[i - 1].carry } }; let pgas_at = |i: usize| -> u64 { if i == 0 { 0 } else { boundaries[i - 1].pgas } }; let gas_at = |i: usize| -> u64 { if i == 0 { 0 } else { boundaries[i - 1].gas } }; let mut shards = Vec::new(); - let n = boundaries.len(); let mut start = 0usize; let mut index = 0u32; loop { @@ -73,8 +75,8 @@ pub fn plan(pre_root: B256, pre_carry: Carry, boundaries: &[Boundary], budget: u over_budget: over, carry_in: carry_at(start), carry_out: carry_at(end), - pre_root: root_at(start), - post_root: root_at(end), + pre_root: pre_root_at(start), + post_root: post_root_at(end), }); index += 1; if end >= n { @@ -103,7 +105,7 @@ mod tests { #[test] fn cuts_at_the_budget_and_isolates_an_oversized_transaction() { let b = boundaries(&[300, 300, 500, 1200, 100, 100]); - let s = plan(B256::ZERO, Carry::default(), &b, 1000); + let s = plan(B256::ZERO, B256::with_last_byte(6), Carry::default(), &b, 1000); let ranges: Vec<(u32, u32, bool)> = s.iter().map(|x| (x.tx_start, x.tx_end, x.over_budget)).collect(); assert_eq!(ranges, vec![(0, 2, false), (2, 3, false), (3, 4, true), (4, 6, false)]); assert_eq!(s[0].pre_root, B256::ZERO); @@ -114,8 +116,10 @@ mod tests { #[test] fn an_empty_segment_is_one_shard() { - let s = plan(B256::ZERO, Carry::default(), &[], 1000); + let s = plan(B256::ZERO, B256::with_last_byte(9), Carry::default(), &[], 1000); assert_eq!(s.len(), 1); assert_eq!((s[0].tx_start, s[0].tx_end), (0, 0)); + assert_eq!(s[0].pre_root, B256::ZERO); + assert_eq!(s[0].post_root, B256::with_last_byte(9), "the empty shard ends at the root after the rewards"); } } diff --git a/proving/igneum-prove/core/src/shard.rs b/proving/igneum-prove/core/src/shard.rs index dfd942b0..4547c77c 100644 --- a/proving/igneum-prove/core/src/shard.rs +++ b/proving/igneum-prove/core/src/shard.rs @@ -17,6 +17,9 @@ pub struct ShardInput { /// Applied by shard 0 only (the segment's rewards precede its first transaction). pub rewards: Vec<(Address, U256)>, pub proving_pool_credit: U256, + /// Applied by shard 0 only, after the rewards (proving v0 payouts, spec 7.7). + #[serde(default)] + pub payouts: Vec<(Address, U256)>, pub shard_index: u32, pub txs: Vec, pub carry_in: Carry, @@ -104,7 +107,7 @@ impl ShardOutput { /// The statement, as the guest runs it and as the host checks it natively first. pub fn shard_statement(input: &ShardInput) -> ShardOutput { let (mut db, pre_root) = witness::load(&input.witness, &input.block_hashes); - let rewards = (input.shard_index == 0).then_some((&input.rewards[..], input.proving_pool_credit)); + let rewards = (input.shard_index == 0).then_some((&input.rewards[..], input.proving_pool_credit, &input.payouts[..])); let out = execute_range(&mut db, input.chain_id, &input.env, rewards, &input.txs, input.carry_in, false); let post_root = witness::post_root(&db, &input.witness); ShardOutput { @@ -146,13 +149,13 @@ pub fn build_shards(block: &crate::fixture::BlockFixture, budget: u64, prover: A let pre_root = db.state_root(); let txs = block.flatten(); let outcome = execute_block(&mut db, block); - let specs = crate::plan::plan(pre_root, Carry::default(), &outcome.boundaries, budget); + let specs = crate::plan::plan(pre_root, outcome.state_root, Carry::default(), &outcome.boundaries, budget); let mut state = load_pre_state(block); let mut shards = Vec::with_capacity(specs.len()); for spec in specs { let range = &txs[spec.tx_start as usize..spec.tx_end as usize]; - let rewards = (spec.index == 0).then_some((&block.rewards[..], block.proving_pool_credit)); + let rewards = (spec.index == 0).then_some((&block.rewards[..], block.proving_pool_credit, &block.payouts[..])); let pre = state.clone(); state.start_log(); let _ = execute_range(&mut state, block.chain_id, &block.env, rewards, range, spec.carry_in, false); @@ -164,6 +167,7 @@ pub fn build_shards(block: &crate::fixture::BlockFixture, budget: u64, prover: A block_hashes: block.block_hashes.clone(), rewards: if spec.index == 0 { block.rewards.clone() } else { Vec::new() }, proving_pool_credit: if spec.index == 0 { block.proving_pool_credit } else { U256::ZERO }, + payouts: if spec.index == 0 { block.payouts.clone() } else { Vec::new() }, shard_index: spec.index, txs: range.to_vec(), carry_in: spec.carry_in, diff --git a/proving/igneum-prove/export/src/main.rs b/proving/igneum-prove/export/src/main.rs index 7c032436..f7f902be 100644 --- a/proving/igneum-prove/export/src/main.rs +++ b/proving/igneum-prove/export/src/main.rs @@ -68,6 +68,8 @@ fn fixture_of(export: &Value, segments: &[Value], n: usize, hashes: &[(u64, B256 }; let rewards = seg["rewards"].as_array().context("rewards")?.iter().map(|r| Ok((addr(&r["address"])?, u256(&r["wei"])?))).collect::>>()?; let proving_pool_credit: U256 = seg["provingPoolCredit"].as_str().context("provingPoolCredit")?.parse()?; + // proving v0 payouts (spec 7.7); an export from a node before proving v0 has none + let payouts = seg["payouts"].as_array().map(|a| a.iter().map(|r| Ok((addr(&r["address"])?, u256(&r["wei"])?))).collect::>>()).transpose()?.unwrap_or_default(); let pre_state = db .addresses() .into_iter() @@ -80,6 +82,7 @@ fn fixture_of(export: &Value, segments: &[Value], n: usize, hashes: &[(u64, B256 block_hashes: hashes.iter().rev().take(256).rev().copied().collect(), rewards, proving_pool_credit, + payouts, blocks: blocks_of(seg)?, pre_state, }) diff --git a/proving/igneum-prove/host/Cargo.toml b/proving/igneum-prove/host/Cargo.toml index d13ab051..024feda5 100644 --- a/proving/igneum-prove/host/Cargo.toml +++ b/proving/igneum-prove/host/Cargo.toml @@ -16,6 +16,7 @@ bincode.workspace = true serde_json.workspace = true anyhow.workspace = true hex.workspace = true +sha2 = "0.10" tokio = { version = "1", features = ["rt-multi-thread", "time"] } [build-dependencies] diff --git a/proving/igneum-prove/host/src/main.rs b/proving/igneum-prove/host/src/main.rs index 9749c042..3c4aa0f4 100644 --- a/proving/igneum-prove/host/src/main.rs +++ b/proving/igneum-prove/host/src/main.rs @@ -22,6 +22,7 @@ use igneum_prove_core::agg::{self, AggInput, BlockOutput}; use igneum_prove_core::shard::{build_shards, shard_statement, BuiltShard, ShardInput, ShardOutput}; use igneum_prove_core::Fixture; use proof_system::{ProofSystem, SegmentClaim, ShardWitness, Sp1ProofSystem, Sp1ShardProof, StubProofSystem}; +use sha2::Digest; use sp1_sdk::{include_elf, Elf}; use std::time::{Duration, Instant}; @@ -63,9 +64,13 @@ fn main() -> Result<()> { fn run() -> Result<()> { let args: Vec = std::env::args().collect(); - let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host [--mode native|execute|shard|block|all] [--shard N] [--prover 0x..] [--out results.json]")?; let arg = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); let mode = arg("--mode").unwrap_or_else(|| "all".into()); + if mode == "verify" { + // proving v0 (spec 7.7): the node's proof pool verifies a submitted shard proof off the consensus path + return run_verify(&arg("--proof").context("--proof ")?, &arg("--statement").context("--statement 0x")?); + } + let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host [--mode native|execute|shard|compressed|block|all] [--shard N] [--prover 0x..] [--out results.json]; igneum-prove-host --mode verify --proof --statement 0x..")?; let shard_index: usize = arg("--shard").map(|s| s.parse()).transpose()?.unwrap_or(0); let prover: Address = arg("--prover").map(|s| s.parse()).transpose()?.unwrap_or_else(|| Address::from_slice(&[0x19; 20])); let out_path = arg("--out"); @@ -199,6 +204,7 @@ fn run() -> Result<()> { let r = match mode.as_str() { "execute" => run_execute(&sp1, &shards, block.env.parent_hash, &mut results), "shard" => run_shard(&sp1, &shards, shard_index, out_path.as_deref(), &mut results), + "compressed" => run_compressed(&sp1, &shards, shard_index, out_path.as_deref(), &mut results), "block" => run_block(&sp1, &shards, &claim, out_path.as_deref(), &mut results), "all" => run_shard(&sp1, &shards, shard_index, out_path.as_deref(), &mut results).and_then(|_| run_block(&sp1, &shards, &claim, out_path.as_deref(), &mut results)), other => Err(anyhow!("unknown mode {other}")), @@ -342,6 +348,77 @@ fn run_shard(sp1: &Sp1ProofSystem, shards: &[BuiltShard], index: usize, out_dir: Ok(()) } +/// Proving v0 (spec 7.7): the prover's mode. Execute (the cycle count), then the compressed proof of one shard, +/// verified; writes `/block-N-shard-i-compressed.bin` and records the statement (keccak of the public +/// values, what the proof record carries) and the proof's SHA-256 (the record's `proof_hash`). +fn run_compressed(sp1: &Sp1ProofSystem, shards: &[BuiltShard], index: usize, out_dir: Option<&str>, results: &mut serde_json::Map) -> Result<()> { + let s = shards.get(index).ok_or_else(|| anyhow!("shard {index} is not in the plan ({} shards)", shards.len()))?; + let i = s.output.shard_index; + results.insert("shard_index".into(), i.into()); + stage(&format!("execute shard {i}")); + let (out, report, dt) = sp1.execute_shard(&s.input)?; + check_shard_output(&out, &s.output)?; + let cycles = report.total_instruction_count(); + println!("RESULT execute shard {i}: {} cycles, {:.2} s at {}", cycles, dt.as_secs_f64(), now()); + results.insert("cycles".into(), cycles.into()); + results.insert("execute_seconds".into(), dt.as_secs_f64().into()); + + stage(&format!("compressed shard {i}")); + let proof = sp1.prove_shard(&ShardWitness { input: s.input.clone() })?; + let dt = sp1.last_timing("compressed").unwrap_or_default(); + let (ok, vdt) = sp1.verify_shard(&proof.proof, &s.output); + let bytes = bincode::serialize(&proof.proof)?; + let statement = alloy_primitives::keccak256(s.output.to_bytes()); + let proof_hash: [u8; 32] = sha2::Sha256::digest(&bytes).into(); + println!("RESULT compressed shard {i}: prove {:.1} s, proof {} bytes, verify {:.3} s, {}; statement {} proof sha256 0x{} prover {} at {}", dt.as_secs_f64(), bytes.len(), vdt.as_secs_f64(), if ok { "VERIFIED" } else { "VERIFY FAILED" }, statement, hex::encode(proof_hash), proof.output.prover, now()); + if !ok { + bail!("compressed proof of shard {i} did not verify"); + } + results.insert("compressed_prove_seconds".into(), dt.as_secs_f64().into()); + results.insert("compressed_verify_seconds".into(), vdt.as_secs_f64().into()); + results.insert("compressed_proof_bytes".into(), bytes.len().into()); + results.insert("statement".into(), statement.to_string().into()); + results.insert("proof_sha256".into(), format!("0x{}", hex::encode(proof_hash)).into()); + results.insert("block_hash".into(), s.input.env.hash.to_string().into()); + results.insert("number".into(), s.input.env.number.into()); + results.insert("prover".into(), proof.output.prover.to_string().into()); + let dir = out_dir.and_then(|p| std::path::Path::new(p).parent().map(|d| d.to_path_buf())).unwrap_or_else(|| std::path::PathBuf::from(".")); + let file = dir.join(format!("block-{}-shard-{i}-compressed.bin", s.input.env.number)); + std::fs::write(&file, &bytes)?; + results.insert("proof_file".into(), file.display().to_string().into()); + println!("proof written to {}", file.display()); + Ok(()) +} + +/// `--mode verify --proof --statement 0x..`: loads the SP1 shard verifying key, verifies the compressed +/// proof against it and checks that the keccak of its public values is the statement. Exit 0 = verified, +/// 3 = not verified (what the node's proof pool reads). +fn run_verify(proof_path: &str, statement: &str) -> Result<()> { + let bytes = std::fs::read(proof_path).with_context(|| format!("read {proof_path}"))?; + let want: B256 = statement.parse().context("statement is not 32 bytes of hex")?; + stage("setup"); + let t = Instant::now(); + let sp1 = Sp1ProofSystem::from_env(SHARD_ELF, AGG_ELF)?; + println!("RESULT setup: {:.2} s, shard program id {} at {}", t.elapsed().as_secs_f64(), sp1.program_id(), now()); + stage("verify"); + let t = Instant::now(); + let proof: sp1_sdk::SP1ProofWithPublicValues = bincode::deserialize(&bytes).context("the file is not a bincode SP1 proof")?; + let got = alloy_primitives::keccak256(proof.public_values.as_slice()); + let output = ShardOutput::from_bytes(proof.public_values.as_slice()); + let ok = sp1.verify_shard_bytes(&proof) && got == want && output.is_some(); + let dt = t.elapsed().as_secs_f64(); + match &output { + Some(o) => println!("RESULT verify: {} in {dt:.3} s; block {} shard {} prover {} statement {got} (want {want}) proof {} bytes at {}", if ok { "VERIFIED" } else { "NOT VERIFIED" }, o.number, o.shard_index, o.prover, bytes.len(), now()), + None => println!("RESULT verify: NOT VERIFIED in {dt:.3} s; public values are not a shard statement at {}", now()), + } + drop(sp1); + if ok { + Ok(()) + } else { + std::process::exit(3) + } +} + fn run_block(sp1: &Sp1ProofSystem, shards: &[BuiltShard], claim: &SegmentClaim, out_dir: Option<&str>, results: &mut serde_json::Map) -> Result<()> { let block_t = Instant::now(); let mut proofs: Vec = Vec::with_capacity(shards.len()); diff --git a/proving/igneum-prove/host/src/proof_system.rs b/proving/igneum-prove/host/src/proof_system.rs index b636dbbb..840fae0f 100644 --- a/proving/igneum-prove/host/src/proof_system.rs +++ b/proving/igneum-prove/host/src/proof_system.rs @@ -243,6 +243,11 @@ impl Sp1ProofSystem { Ok((proof, dt)) } + /// The cryptographic check alone (the public values are the caller's to compare). + pub fn verify_shard_bytes(&self, proof: &SP1ProofWithPublicValues) -> bool { + self.client.verify(proof, &self.shard_vk, None).is_ok() + } + pub fn verify_shard(&self, proof: &SP1ProofWithPublicValues, expected: &ShardOutput) -> (bool, Duration) { let t = Instant::now(); let ok = self.client.verify(proof, &self.shard_vk, None).is_ok(); diff --git a/tools/proving-v0/run.mjs b/tools/proving-v0/run.mjs new file mode 100644 index 00000000..c4151c0f --- /dev/null +++ b/tools/proving-v0/run.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +// Proving v0 end to end on a private 3-node test network (spec 7.7, docs/plans/proving-v0.md): ports 29800 and up, +// network igneum-devnet-955, data under /tmp/igneum-proving-v0, infra/fast-time's 60x profile with +// skip_proof_of_work and proving_v0_activation_daa set. Three voting vmine producers (the proving build's +// igneum-miner) share 1 block/s; v0 names a funded EVM address. Node 0 verifies SP1 proofs with the real host +// (IGNEUM_PROOF_VERIFIER); nodes 1 and 2 run in trust mode, so the relay and the verifier are both exercised. +// +// Steps, each timed: wait for the activation DAA, send one transfer, export the chain and cut the fixture with +// igneum-prove-export, check the exporter's plan against the node's igneum_getShardPlan, prove the shard on the +// CPU with igneum-prove-host --mode compressed, sign the record with igneum-miner sign-record (v0's vote key), +// submit it to node 1, watch node 0 verify it, watch a block carry it, and check the payout address's balance. +// Never touches the live devnet (26610/26611, 26640/28640) or the fast-time simnet (29500+). +// +// node tools/proving-v0/run.mjs [--secs 1500] [--activation 60] [--empty] (--empty proves the newest empty segment instead) + +import { spawn, spawnSync } from 'node:child_process'; +import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs'; +import { connectRpc } from '../finality-attacks/lib/rpc.mjs'; +import { privateKeyToAccount } from '../prove-fixtures/node_modules/viem/_esm/accounts/index.js'; + +const ROOT = new URL('../../', import.meta.url).pathname; +const FILE = `${ROOT}infra/fast-time/override-60x.json`; +const REL = process.env.IGNEUM_PROVING_BIN || `${ROOT}vendor/igneum-node-proving/target/release`; +const IGNEUMD = `${REL}/igneumd`; +const MINER = `${REL}/igneum-miner`; +const PROVE = process.env.IGNEUM_PROVE_BIN || `${ROOT}proving/igneum-prove/target/release`; +const HOST = `${PROVE}/igneum-prove-host`; +const EXPORT = `${PROVE}/igneum-prove-export`; +const TMP = '/tmp/igneum-proving-v0'; +const BASE = 29800, SUFFIX = 955, CHAIN_NAME = `igneum-devnet-${SUFFIX}`, CHAIN_ID = 4463; +const args = process.argv.slice(2); +const SECS = +(args[args.indexOf('--secs') + 1] || 1500); +const ACTIVATION = +(args[args.indexOf('--activation') + 1] || 60); +const EMPTY = args.includes('--empty'); +const started = []; +const t0 = Date.now(); +const since = () => ((Date.now() - t0) / 1000).toFixed(1); +const log = (...a) => console.log(new Date().toISOString().slice(11, 23), `t=${since()}s`, ...a); +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); +for (const b of [IGNEUMD, MINER, HOST, EXPORT]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); } + +// the funded account: v0's payout address (a throwaway key from tools/prove-fixtures/gen.mjs) +const funded = privateKeyToAccount('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); +const PAYOUT = '0x4242424242424242424242424242424242424242'; + +rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); +const override = `${TMP}/override.json`; +writeFileSync(override, JSON.stringify({ ...JSON.parse(readFileSync(FILE, 'utf8')), skip_proof_of_work: true, proving_v0_activation_daa: ACTIVATION })); + +class Node { + constructor(i, connect = [], env = {}) { + this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3; + this.connect = connect; this.env = env; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; + } + get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; } + get evm() { return `http://127.0.0.1:${this.evmPort}`; } + async start() { + mkdirSync(this.dir, { recursive: true }); + const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc', + `--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`, + `--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes']; + if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0'); + const out = openSync(this.logFile, 'a'); + this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, ...this.env } }); + started.push(this.proc); + await sleep(800); + this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`); + log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} evm ${this.evmPort} p2p ${this.p2pPort} env ${JSON.stringify(this.env)}`); + return this; + } + async eth(method, params = []) { + const r = await fetch(this.evm, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }); + const j = await r.json(); + if (j.error) throw new Error(`${method}: ${j.error.message}`); + return j.result; + } + grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } } +} +function miner(argv, name) { + const out = openSync(`${TMP}/${name}.log`, 'a'); + const p = spawn(MINER, argv, { stdio: ['ignore', out, out] }); + started.push(p); + return p; +} +async function stopAll() { + for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } + await sleep(1500); + for (const p of started) { try { p.kill('SIGKILL'); } catch { } } +} +process.on('SIGINT', async () => { await stopAll(); process.exit(130); }); +const report = { activation: ACTIVATION, steps: {} }; +const step = (k, v) => { report.steps[k] = v; log(`STEP ${k}: ${JSON.stringify(v)}`); }; +function run(cmd, argv, env = {}) { + const t = Date.now(); + const r = spawnSync(cmd, argv, { encoding: 'utf8', maxBuffer: 1 << 28, env: { ...process.env, ...env } }); + return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), secs: (Date.now() - t) / 1000 }; +} + +try { + const n0 = await new Node(0, [], { IGNEUM_PROOF_VERIFIER: HOST, SP1_PROVER: 'cpu' }).start(); + const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`], { IGNEUM_PROOF_VERIFY: 'trust' }).start(); + const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`, `127.0.0.1:${n1.p2pPort}`], { IGNEUM_PROOF_VERIFY: 'trust' }).start(); + const nodes = [n0, n1, n2]; + log(`node 0 says: ${n0.grepLog(/Proving v0|proving v0/).join(' | ') || '(no proving line)'}`); + + // three voters sharing 1 block/s; v0 pays the funded account + nodes.forEach((n, i) => miner(['vmine', n.grpc, String(SECS), '--label', `v${i}`, '--share', String(1 / 3), '--bps', '1', ...(i === 0 ? ['--evm-address', funded.address] : [])], `vmine-v${i}`)); + const keyHashes = []; + for (let i = 0; i < 3; i++) { + for (let k = 0; k < 50 && !keyHashes[i]; k++) { const m = (() => { try { return readFileSync(`${TMP}/vmine-v${i}.log`, 'utf8').match(/key=([0-9a-f]{64})/); } catch { return null; } })(); if (m) keyHashes[i] = '0x' + m[1]; else await sleep(200); } + } + log(`vote keys: ${keyHashes.join(' ')}`); + const status0 = await n0.eth('igneum_getProvingStatus'); + log(`proving status n0: ${JSON.stringify(status0)}`); + step('status', { activationDaa: status0.activationDaa, verifier: status0.verifier }); + + // 1. wait for the activation DAA (plus a margin: the sortition window needs dust blocks per key) + let daa = 0; + while (daa < ACTIVATION + 5) { + await sleep(2000); + const s = await n0.eth('igneum_getProvingStatus'); + daa = parseInt(s.tipDaa, 16); + if (Math.round(+since()) % 10 === 0) log(`daa ${daa} active=${s.active} pool=${JSON.stringify(s.pool)}`); + } + step('activation', { daa, secs: +since() }); + + // 2. a transfer from the funded account, so a segment has one transaction + const balance = BigInt(await n0.eth('eth_getBalance', [funded.address, 'latest'])); + log(`funded ${funded.address} balance ${balance} wei`); + if (balance === 0n) throw new Error('the funded account has no rewards yet'); + const nonce = parseInt(await n0.eth('eth_getTransactionCount', [funded.address, 'latest']), 16); + const raw = await funded.signTransaction({ type: 'eip1559', chainId: CHAIN_ID, nonce, to: '0x1111111111111111111111111111111111111111', value: 1_000_000_000_000_000n, gas: 21000n, maxFeePerGas: 20_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n }); + const txHash = await n0.eth('eth_sendRawTransaction', [raw]); + let receipt = null; + for (let k = 0; k < 120 && !receipt; k++) { await sleep(1000); receipt = await n0.eth('eth_getTransactionReceipt', [txHash]); } + if (!receipt) throw new Error('the transfer was not executed in 120 s'); + const txNumber = parseInt(receipt.blockNumber, 16); + step('transfer', { txHash, number: txNumber, secs: +since() }); + + // 3. the work list of v0's key, and the shard to prove + await sleep(1500); + const work = await n0.eth('igneum_getAssignedShards', [[keyHashes[0]], 100]); + const mine = work.filter(w => w.assigned); + log(`assigned shards for v0 in the last 100 blocks: ${mine.length} of ${work.length} listed (${mine.slice(0, 5).map(w => `#${parseInt(w.number, 16)}/${w.shard} txs ${w.txCount}`).join(', ')} ...)`); + let target = EMPTY ? mine.find(w => w.txCount === 0) : mine.find(w => parseInt(w.number, 16) === txNumber); + if (!target) { log(`v0 is not assigned to block ${txNumber} (or no empty shard); taking the newest assigned shard`); target = mine[0]; } + if (!target) throw new Error('v0 has no assigned shard'); + const number = parseInt(target.number, 16); + const plan = await n0.eth('igneum_getShardPlan', [target.number]); + const plan1 = await n1.eth('igneum_getShardPlan', [target.number]); + if (JSON.stringify(plan.shards) !== JSON.stringify(plan1.shards)) throw new Error('nodes 0 and 1 disagree on the shard plan'); + step('plan', { number, hash: plan.hash, shards: plan.shards.length, eligibleKeys: plan.eligibleKeys, windowBlocks: plan.windowBlocks, assignees: plan.shards[target.shard].assignees.length, pgas: parseInt(plan.shards[target.shard].pgas, 16), shardWei: target.shardWei, sameOnNode1: true }); + + // 4. export and cut the fixture; the exporter's plan must be the node's + const seq = await n0.eth('igneum_exportSegments', ['0x0', target.number]); + writeFileSync(`${TMP}/seq.json`, JSON.stringify(seq)); + const fixture = `${TMP}/block-${number}.json`; + const ex = run(EXPORT, [`${TMP}/seq.json`, String(number), fixture, '--source', `proving-v0 test network block ${number}`]); + writeFileSync(`${TMP}/export.log`, ex.out); + if (ex.code !== 0) throw new Error(`exporter failed (${ex.code}): ${ex.out.split('\n').slice(-5).join(' | ')}`); + const fx = JSON.parse(readFileSync(fixture, 'utf8')); + const fs0 = fx.plan.shards[target.shard], ns0 = plan.shards[target.shard]; + const same = fx.plan.shards.length === plan.shards.length && fs0.pre_root === ns0.preRoot && fs0.post_root === ns0.postRoot && fs0.link_in === ns0.linkIn && fs0.link_out === ns0.linkOut && fs0.receipts_root === ns0.receiptsRoot && fs0.pgas_used === parseInt(ns0.pgas, 16); + step('export', { secs: ex.secs, exporterShards: fx.plan.shards.length, matchesNode: same, preRoot: fs0.pre_root, postRoot: fs0.post_root }); + if (!same) throw new Error(`the exporter's plan differs from the node's: ${JSON.stringify({ fs0, ns0 })}`); + + // 5. prove the shard on the CPU (execute, then compressed) + log(`proving block ${number} shard ${target.shard} on the CPU (SP1_PROVER=cpu); this takes minutes on a loaded Mac`); + const results = `${TMP}/results-${number}-${target.shard}.json`; + const pr = run(HOST, [fixture, '--mode', 'compressed', '--shard', String(target.shard), '--prover', PAYOUT, '--out', results], { SP1_PROVER: 'cpu', RUST_LOG: 'off' }); + writeFileSync(`${TMP}/prove.log`, pr.out); + if (pr.code !== 0) throw new Error(`prover failed (${pr.code}): ${pr.out.split('\n').filter(l => /RESULT|error|Error/.test(l)).slice(-6).join(' | ')}`); + const res = JSON.parse(readFileSync(results, 'utf8')); + step('prove', { secs: pr.secs, cycles: res.cycles, executeSeconds: res.execute_seconds, compressedSeconds: res.compressed_prove_seconds, verifySeconds: res.compressed_verify_seconds, proofBytes: res.compressed_proof_bytes, statement: res.statement, proofSha256: res.proof_sha256 }); + + // 6. sign the record with v0's vote key and submit it to node 1 (trust mode), with the proof bytes + const sg = run(MINER, ['sign-record', 'v0', CHAIN_NAME, plan.hash, String(number), String(target.shard), PAYOUT, res.statement, res.proof_sha256]); + if (sg.code !== 0) throw new Error(`sign-record failed: ${sg.out}`); + const signed = JSON.parse(sg.out.trim().split('\n').pop()); + if (signed.keyHash !== keyHashes[0].slice(2)) throw new Error(`sign-record key ${signed.keyHash} is not v0's ${keyHashes[0]}`); + const proofHex = '0x' + readFileSync(res.proof_file).toString('hex'); + const sub = await n1.eth('igneum_submitProofRecord', [{ record: signed.record, proof: proofHex }]); + step('submit', { to: 'n1', ...sub, secs: +since() }); + if (!sub.accepted) throw new Error(`record refused: ${sub.reason}`); + + // 7. node 0 receives it over p2p and verifies the SP1 proof; then a block carries it and the segment pays + let verified = null, paid = null, carriedBy = null, relayedAt = null, verifiedAt = null, paidAt = null; + const deadline = Date.now() + 600_000; + while (Date.now() < deadline && !(paid && verified)) { + await sleep(1000); + const r0 = await n0.eth('igneum_getProofRecords', [target.number]); + const e0 = r0.pool.find(e => e.shard === target.shard); + if (e0 && relayedAt == null) { relayedAt = +since(); log(`n0 holds the record over p2p (verified=${e0.verified})`); } + if (e0 && e0.verified != null && verified == null) { verified = e0.verified; verifiedAt = +since(); log(`n0 verifier says ${e0.verified}: ${e0.note}`); } + const paidRow = r0.paid && r0.paid[target.shard]; + if (paidRow && paid == null) { paid = paidRow; paidAt = +since(); carriedBy = (r0.carried.find(c => c.valid) || {}).carrier; log(`PAID on n0: ${JSON.stringify(paidRow)} carried by ${carriedBy}`); } + if (Math.round(+since()) % 15 === 0) log(`waiting: pool ${JSON.stringify((e0 || {}).verified)} included ${(e0 || {}).includedIn || 'no'} paid ${paid ? 'yes' : 'no'}`); + } + step('relay_verify_pay', { relayedAt, verified, verifiedAt, paidAt, carriedBy, paid }); + if (!verified) throw new Error('node 0 did not verify the proof'); + if (!paid) throw new Error('no block carried the record within 10 minutes'); + // every node agrees on the payment, and the payout address holds the shard's part + await sleep(3000); + const agree = []; + for (const n of nodes) { const r = await n.eth('igneum_getProofRecords', [target.number]); agree.push(r.paid && r.paid[target.shard] ? r.paid[target.shard].wei : null); } + const bal = BigInt(await n0.eth('eth_getBalance', [PAYOUT, 'latest'])); + const pool = await n0.eth('igneum_getProvingStatus'); + step('payout', { paidWeiPerNode: agree, payoutBalanceWei: bal.toString(), shardWei: BigInt(target.shardWei).toString(), balanceEqualsShardWei: bal === BigInt(target.shardWei), poolBalanceWei: BigInt(pool.poolBalanceWei).toString(), paidShards: pool.paidShards }); + report.ok = bal === BigInt(target.shardWei) && agree.every(a => a === agree[0] && a != null); + log(`RESULT proving v0 end to end: ${report.ok ? 'PASSED' : 'FAILED'} in ${since()} s`); +} catch (e) { + report.error = e.message; + log(`FAILED: ${e.message}`); +} finally { + writeFileSync(`${TMP}/report.json`, JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); + await stopAll(); + process.exit(report.ok ? 0 : 1); +}