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 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 14:06:26 +00:00
parent b49d3ee358
commit d6d6153c9f
11 changed files with 798 additions and 17 deletions

View file

@ -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<Work> {
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<Work> {
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, `<label_base>-<card n>`, and `-1..N` per identity when a card runs more than one.
pub fn labels(shared: &Shared) -> Vec<String> {
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<PathBuf>,
cuda: bool,
}
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
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<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): 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::<Vec<_>>().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<F: FnOnce(&mut crate::state::ProvingState)>(shared: &Shared, f: F) {
let mut st = shared.state.lock().unwrap();
f(&mut st.proving);
}
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.
pub fn start(shared: Arc<Shared>, 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<Shared>, bin_dir: PathBuf) {
let mut attempted: HashSet<(String, u32)> = HashSet::new();
let mut tools: Option<Tools> = None;
let mut last_probe = Instant::now() - Duration::from_secs(600);
let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new();
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::<Vec<_>>(), 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::<String>());
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<Value, String> {
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");
}
}

View file

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

View file

@ -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<ExecutedReceipt> = 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,

View file

@ -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<IncludingBlock>,
pub pre_state: Vec<AccountFixture>,
}

View file

@ -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<ShardSpec> {
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<ShardSpec> {
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");
}
}

View file

@ -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<ShardTx>,
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,

View file

@ -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::<Result<Vec<_>>>()?;
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::<Result<Vec<_>>>()).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,
})

View file

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

View file

@ -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<String> = std::env::args().collect();
let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host <fixture.json> [--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 <file>")?, &arg("--statement").context("--statement 0x<keccak of the public values>")?);
}
let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host <fixture.json> [--mode native|execute|shard|compressed|block|all] [--shard N] [--prover 0x..] [--out results.json]; igneum-prove-host --mode verify --proof <file> --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 `<out dir>/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<String, serde_json::Value>) -> 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 <file> --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<String, serde_json::Value>) -> Result<()> {
let block_t = Instant::now();
let mut proofs: Vec<Sp1ShardProof> = Vec::with_capacity(shards.len());

View file

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

219
tools/proving-v0/run.mjs Normal file
View file

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