diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 2e716439..08ba7c66 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -30,6 +30,7 @@ mod jobrun; mod jobbuild; mod prover; mod provedefault; +mod segments; mod verifier; mod wslhost; mod sweep; diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index 8f3e548e..9d1ed0bd 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -47,6 +47,8 @@ pub struct Work { pub shard_wei: u128, /// The first of this machine's keys that is assigned (the label that signs). pub key_hash: String, + /// The chain block's DAA score (the deadline clock of spec 7.8). + pub daa: u64, } /// Parses the node's work list. Newest first, as the node returns it. @@ -67,6 +69,7 @@ pub fn parse_work(v: &Value) -> Vec { in_pool: w["pool"].as_array().map(|p| !p.is_empty()).unwrap_or(false), shard_wei: hexu(&w["shardWei"]), key_hash: w["assignedKeys"].as_array().and_then(|k| k.first()).and_then(|k| k.as_str()).unwrap_or("").to_string(), + daa: hexu(&w["daaScore"]) as u64, }) .collect() }) @@ -344,6 +347,9 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { let ram_mb = crate::detect::total_ram_mb(); let mut last_probe = Instant::now() - Duration::from_secs(600); let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new(); + // proving v1 segment path: (first, last, aggregator wei) of the segment records this machine submitted + let mut submitted_segments: Vec<(u64, u64, u128)> = Vec::new(); + let mut last_segment_secs: Option = None; let mut last_verifier_read = Instant::now() - Duration::from_secs(600); let mut asked_restart = false; // macOS and Linux: the host sits next to the engine, so its pinned ids are read at once, proving on or off @@ -450,7 +456,7 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { }); continue; } - let work = match evm_rpc(&shared, "igneum_getAssignedShards", json!([keys.iter().map(|(_, h)| h.clone()).collect::>(), 60]), Duration::from_secs(10)) { + let work = match evm_rpc(&shared, "igneum_getAssignedShards", json!([keys.iter().map(|(_, h)| h.clone()).collect::>(), crate::segments::WORK_LOOKBACK]), Duration::from_secs(10)) { Ok(v) => parse_work(&v), Err(e) => { set(&shared, |p| { @@ -477,6 +483,21 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { }); } } + // paid segments among what we submitted + for (first, last, wei) in submitted_segments.clone() { + let paid = evm_rpc(&shared, "igneum_getSegmentRecords", json!([format!("{first:#x}")]), Duration::from_secs(10)) + .ok() + .map(|r| !r["paid"].is_null() && r["paid"]["payout"].as_str().map(|a| a.eq_ignore_ascii_case(&payout_address(&shared))).unwrap_or(false)) + .unwrap_or(false); + if paid { + submitted_segments.retain(|x| x.0 != first); + shared.event("proving", &format!("segment {first}..{last} paid {} IGN to the aggregator", wei as f64 / 1e18)); + set(&shared, |p| { + p.segments_paid += 1; + p.segment_paid_wei += wei; + }); + } + } let assigned = work.iter().filter(|w| w.assigned).count() as u32; set(&shared, |p| { p.assigned = assigned; @@ -496,10 +517,46 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { } } } + // proving v1 segment path (src/segments.rs, 6 October 2026): a whole segment first, the newest shard only + // when no whole segment qualifies + let payout = shared.settings.lock().unwrap().address.clone(); + if payout.len() == 42 { + if let Some((seg, prev_file, expected_pv)) = pick_segment(&shared, &work, &keys[0].1, &mut attempted_segments, last_segment_secs) { + attempted_segments.insert(seg.first); + let started = Instant::now(); + match prove_segment(&shared, t, &seg, &keys[0].0, &payout, prev_file.as_deref(), &expected_pv, &mut submitted) { + Ok(agg_wei) => { + let secs = started.elapsed().as_secs_f64(); + last_segment_secs = Some(secs); + submitted_segments.push((seg.first, seg.last, agg_wei)); + shared.event("proving", &format!("segment {}..{}: {} shards proven, aggregated and submitted in {secs:.0} s", seg.first, seg.last, seg.shards.len())); + set(&shared, |p| { + p.segments_submitted += 1; + p.segment_last_s = secs; + p.status = "submitted".into(); + p.message = format!("segment {}..{} submitted; paid when a block carries it", seg.first, seg.last); + p.segment_note = format!("segment {}..{} proven whole in {secs:.0} s", seg.first, seg.last); + p.current = String::new(); + }); + } + Err(e) => { + shared.log(&format!("prover: segment {}..{}: {e}", seg.first, seg.last)); + set(&shared, |p| { + p.failed += 1; + p.status = "idle".into(); + p.message = e.clone(); + p.segment_note = e; + p.current = String::new(); + }); + } + } + continue; + } + } let Some(w) = choose(&work, &attempted) else { set(&shared, |p| { p.status = if submitted.is_empty() { "idle".into() } else { "submitted".into() }; - p.message = if assigned == 0 { "no shard assigned to this machine and none open in the last 60 blocks".into() } else { "every assigned and open shard is proven or paid".into() }; + p.message = if assigned == 0 { format!("no shard assigned to this machine and none open in the last {} blocks", crate::segments::WORK_LOOKBACK) } else { "every assigned and open shard is proven or paid".into() }; }); continue; }; @@ -592,6 +649,173 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { } } +/// Proving v1 segment path, the choice: the node's v1 status (active, the grid start, the segment length, the +/// deadline clock), the work list grouped into whole untouched segments (`segments::whole_segments`), the +/// candidates inside the deadline ranked for this key, then for the best three the node's segment statement: +/// executed and pending; the previous segment either paid with its proof in this node's pool (the chain continues, +/// `--prev`) or not paid and with no verified record of it waiting in the pool (fresh). Returns the segment, the +/// previous proof's host path when the chain continues, and the public values the node expects. +fn pick_segment(shared: &Shared, work: &[Work], key_hash: &str, attempted: &mut HashSet, last_secs: Option) -> Option<(crate::segments::SegmentWork, Option, String)> { + let hexu = |x: &Value| x.as_str().and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0); + let st = evm_rpc(shared, "igneum_getProvingStatus", json!([]), Duration::from_secs(10)).ok()?; + let v1 = &st["v1"]; + if !v1["active"].as_bool().unwrap_or(false) || v1["start"].is_null() { + return None; + } + let (start, n, unproven, tip_daa) = (hexu(&v1["start"]), hexu(&v1["segmentBlocks"]).max(1), hexu(&v1["unprovenDaa"]), hexu(&st["tipDaa"])); + let segs = crate::segments::whole_segments(start, n, unproven, work); + let need = crate::segments::need_daa(last_secs); + let cands = crate::segments::candidates(&segs, tip_daa, need, key_hash, attempted); + if cands.is_empty() { + return None; + } + let dir = shared.runtime.app_dir.join("proving"); + let _ = std::fs::create_dir_all(&dir); + let wsl = cfg!(windows); + let as_host_path = |p: &Path| if wsl { wsl_path(p) } else { p.display().to_string() }; + for seg in cands.into_iter().take(3) { + let Ok(stmt) = evm_rpc(shared, "igneum_getSegmentStatement", json!([format!("{:#x}", seg.first)]), Duration::from_secs(10)) else { continue }; + if !stmt["executed"].as_bool().unwrap_or(false) || stmt["status"]["status"].as_str() != Some("pending") { + attempted.insert(seg.first); + continue; + } + let prev = &stmt["previous"]; + if prev.is_null() { + // fresh only when no record of the previous segment is waiting to be carried (the chain rule would + // refuse a fresh record once that one pays) + if seg.first >= start + n { + let p = evm_rpc(shared, "igneum_getSegmentRecords", json!([format!("{:#x}", seg.first - n)]), Duration::from_secs(10)).unwrap_or(Value::Null); + let waiting = p["pool"].as_array().map(|a| a.iter().any(|e| e["verified"] == json!(true) && e["includedIn"].is_null())).unwrap_or(false); + if waiting || !p["paid"].is_null() { + continue; + } + } + return Some((seg, None, stmt["publicValuesFresh"].as_str().unwrap_or("").to_string())); + } + if prev["proofInPool"] != json!(true) { + continue; + } + let Ok(got) = evm_rpc(shared, "igneum_getSegmentProofBytes", json!([prev["first"], prev["keyHash"]]), Duration::from_secs(60)) else { continue }; + let hex = got["proof"].as_str().unwrap_or("").trim_start_matches("0x").to_string(); + if hex.is_empty() { + continue; + } + let bytes: Vec = (0..hex.len() / 2).map(|k| u8::from_str_radix(&hex[2 * k..2 * k + 2], 16).unwrap_or(0)).collect(); + let f = dir.join(format!("prev-{}.bin", seg.first)); + if std::fs::write(&f, bytes).is_err() { + continue; + } + return Some((seg, Some(as_host_path(&f)), stmt["publicValuesContinuing"].as_str().unwrap_or("").to_string())); + } + None +} + +/// Proving v1 segment path, the work: one export of the chain to the segment's last block, one fixture per block, +/// one host run (`--mode chain --save-shards`, `--prev` when the chain continues) that proves every shard and +/// aggregates the segment, then every shard record signed and submitted (the shard payouts) and the segment +/// record signed and submitted (the aggregator share). Returns the segment's aggregator wei. +fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork, label: &str, payout: &str, prev_file: Option<&str>, expected_pv: &str, submitted: &mut Vec<(u64, String, u32, u128)>) -> Result { + let (first, last) = (seg.first, seg.last); + let dir = shared.runtime.app_dir.join("proving").join(format!("seg-{first}")); + let _ = std::fs::create_dir_all(&dir); + let as_host_path = |p: &Path| if t.wsl { wsl_path(p) } else { p.display().to_string() }; + set(shared, |p| { + p.status = "proving".into(); + p.current = format!("segment {first}..{last} ({} shards)", seg.shards.len()); + p.started_at = crate::platform::unix_now_f(); + p.message = "exporting the chain and cutting the segment's blocks".into(); + }); + shared.log(&format!("prover: segment {first}..{last} claimed ({} shards{}): export, cut, chain ({}), sign, submit", seg.shards.len(), if prev_file.is_some() { ", continuing the previous segment's proof" } else { ", fresh" }, if t.cuda { "CUDA" } else { "CPU" })); + // 1. export once, cut every block + let seq = dir.join("seq.json"); + let export = evm_rpc(shared, "igneum_exportSegments", json!(["0x0", format!("{last:#x}")]), Duration::from_secs(300))?; + std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?; + let mut fixtures: Vec = Vec::new(); + for b in first..=last { + let fixture = dir.join(format!("block-{b}.json")); + let (ok, out) = run_tool(shared, t, &t.export, &[as_host_path(&seq), b.to_string(), as_host_path(&fixture)], &[], Duration::from_secs(600), &dir.join(format!("export-{b}.log"))); + if !ok || !fixture.exists() { + return Err(format!("exporter, block {b}: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed"))); + } + fixtures.push(as_host_path(&fixture)); + } + let _ = std::fs::remove_file(&seq); + // 2. the chain: every shard proven, every block aggregated with the previous, in one process + set(shared, |p| p.message = format!("proving {} shards and aggregating segment {first}..{last} ({})", seg.shards.len(), if t.cuda { "GPU" } else { "CPU, slow" })); + let results = dir.join("chain-results.json"); + let mut args: Vec = vec!["--mode".into(), "chain".into(), "--chain".into(), fixtures.join(","), "--prover".into(), payout.to_string(), "--save-shards".into(), "--out".into(), as_host_path(&results)]; + if let Some(pf) = prev_file { + args.push("--prev".into()); + args.push(pf.to_string()); + } + let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join("chain.log")); + if !ok || !results.exists() { + let last_line = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string(); + let hint = if last_line.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user: the root-socket class)" } else { "" }; + return Err(format!("chain: {last_line}{hint}")); + } + let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; + let to_win = |f: &str| if t.wsl { PathBuf::from(f.replace("/mnt/c/", "C:/")) } else { PathBuf::from(f) }; + // 3. the shard records + let chain = chain_name(shared); + let mut shard_ok = 0usize; + for b in res["blocks"].as_array().cloned().unwrap_or_default() { + for r in b["shard_records"].as_array().cloned().unwrap_or_default() { + let (number, hash, shard) = (r["number"].as_u64().unwrap_or(0), r["block_hash"].as_str().unwrap_or("").to_string(), r["shard"].as_u64().unwrap_or(0) as u32); + let statement = r["statement"].as_str().unwrap_or("").to_string(); + let proof_sha = r["proof_sha256"].as_str().unwrap_or("").to_string(); + let proof_file = r["proof_file"].as_str().unwrap_or("").to_string(); + let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-record", label, &chain, &hash, &number.to_string(), &shard.to_string(), payout, &statement, &proof_sha]), None, Duration::from_secs(20)).ok_or("sign-record did not run")?; + let signed: Value = serde_json::from_str(sg.lines().last().unwrap_or("")).map_err(|_| format!("sign-record: {}", sg.trim()))?; + let record = signed["record"].as_str().ok_or("sign-record gave no record")?.to_string(); + let proof = std::fs::read(to_win(&proof_file)).map_err(|e| format!("proof file {proof_file}: {e}"))?; + let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::()); + let out = evm_rpc(shared, "igneum_submitProofRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?; + if out["accepted"].as_bool().unwrap_or(false) { + shard_ok += 1; + let wei = seg.shards.iter().position(|(n, _, s)| *n == number && *s == shard).map(|_| seg.shard_wei / seg.shards.len().max(1) as u128).unwrap_or(0); + submitted.push((number, hash.clone(), shard, wei)); + } else { + shared.log(&format!("prover: segment {first}..{last}: block {number} shard {shard} record refused: {}", out["reason"].as_str().unwrap_or("?"))); + } + } + } + if shard_ok != seg.shards.len() { + return Err(format!("{shard_ok} of {} shard records accepted; the segment record is not submitted", seg.shards.len())); + } + set(shared, |p| { + p.proved += shard_ok as u32; + p.submitted += shard_ok as u32; + }); + // 4. the segment record: the aggregated statement against the node's native one (every field but provers) + let pv = res["segment_public_values"].as_str().ok_or("no public values in the chain results")?.to_string(); + let proof_sha = res["segment_proof_sha256"].as_str().ok_or("no segment proof hash in the chain results")?.to_string(); + let proof_file = res["segment_proof_file"].as_str().ok_or("no segment proof file in the chain results")?.to_string(); + let strip = |h: &str| { let h = h.trim_start_matches("0x"); if h.len() == 680 { format!("{}{}", &h[..472], &h[536..]) } else { h.to_string() } }; + if strip(&pv) != strip(expected_pv) { + return Err(format!("the aggregated statement differs from the node's native statement (it would be vetoed); ours {} node {}", &pv[..66.min(pv.len())], &expected_pv[..66.min(expected_pv.len())])); + } + let last_hash = seg.shards.iter().rev().find(|(n, _, _)| *n == last).map(|(_, h, _)| h.clone()).ok_or("no last block hash")?; + let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-segment-record", label, &chain, &first.to_string(), &last.to_string(), &last_hash, payout, &pv, &proof_sha]), None, Duration::from_secs(20)).ok_or("sign-segment-record did not run")?; + let signed: Value = serde_json::from_str(sg.lines().last().unwrap_or("")).map_err(|_| format!("sign-segment-record: {}", sg.trim()))?; + let record = signed["record"].as_str().ok_or("sign-segment-record gave no record")?.to_string(); + let proof = std::fs::read(to_win(&proof_file)).map_err(|e| format!("segment proof file {proof_file}: {e}"))?; + let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::()); + let r = evm_rpc(shared, "igneum_submitSegmentRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?; + if !r["accepted"].as_bool().unwrap_or(false) { + return Err(format!("segment record refused: {}", r["reason"].as_str().unwrap_or("?"))); + } + set(shared, |p| p.aggregated += 1); + let hexu = |x: &Value| x.as_str().and_then(|s| u128::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0); + let agg_wei = evm_rpc(shared, "igneum_getSegmentStatement", json!([format!("{first:#x}")]), Duration::from_secs(10)).map(|s| hexu(&s["aggregatorWei"])).unwrap_or(0); + // the shard proofs stay for the aggregator of the next segment (the chain link is the segment proof, read back + // from the pool); the fixtures and the export go + for b in first..=last { + let _ = std::fs::remove_file(dir.join(format!("block-{b}.json"))); + } + Ok(agg_wei) +} + /// Proving v1 (spec 7.8): one aggregation attempt. When the node reports v1 active, takes the newest executed /// segment that is still pending and not yet attempted here, needs one shard proof per shard of every block in /// this node's pool (`igneum_getProofBytes`, a verified one when there is one) and, when the previous segment is diff --git a/app/igneum-app/src/segments.rs b/app/igneum-app/src/segments.rs new file mode 100644 index 00000000..dc5b255c --- /dev/null +++ b/app/igneum-app/src/segments.rs @@ -0,0 +1,214 @@ +//! Proving v1 (spec 7.8): segment-aligned work for the prover loop (6 October 2026). +//! +//! The shipped loop took the newest open shard each pass, so one prover scattered one block in about 45 across +//! the segment grid and no segment ever had all its blocks proven (node 1, 04:16Z: pending 55, proven 0). Here a +//! free prover claims a whole segment (`proving_v1_segment_blocks` consecutive chain blocks), proves every shard +//! of it in order from one export in one host run (`--mode chain --save-shards`), submits the shard records and the +//! aggregated segment record, then takes the next. One card completes whole segments at its own rate instead of +//! completing none. +//! +//! The choice is deterministic per prover: among the untouched whole segments still inside their deadline by a +//! margin, the lowest FNV-1a of (first block, this prover's key hash) wins, so several provers spread over the +//! candidates without a coordinator; the per-block fallback (`prover::choose`) stays for the passes where no whole +//! segment qualifies. + +use std::collections::{BTreeMap, HashSet}; + +use crate::prover::Work; + +/// The least time a claimed segment is given before its deadline (DAA units, about one a second on devnet): the +/// chain of 8 empty blocks took 135.6 s cold beside the miner (bench-log, 5 October 2026), so 240 leaves the +/// submission and the carrying block inside the window. +pub const SEGMENT_MARGIN_MIN_DAA: u64 = 240; +/// The margin grows with what the last segment actually took, times this. +pub const SEGMENT_MARGIN_FACTOR: f64 = 1.5; +/// How far back the work list reaches (chain blocks): the record window, so every open segment inside the +/// deadline is visible. +pub const WORK_LOOKBACK: u64 = 600; + +#[derive(Clone, Debug, PartialEq)] +pub struct SegmentWork { + pub first: u64, + pub last: u64, + /// the last block's DAA score; the deadline is it plus `proving_v1_unproven_daa` + pub last_daa: u64, + pub deadline_daa: u64, + /// (chain block number, block hash, shard index) in chain order + pub shards: Vec<(u64, String, u32)>, + /// the shard payouts summed (what the shards earn when carried) + pub shard_wei: u128, +} + +/// The segment holding chain block `number` on the grid that starts at `start`. +pub fn segment_of(start: u64, n: u64, number: u64) -> (u64, u64) { + let n = n.max(1); + let k = number.saturating_sub(start) / n; + (start + k * n, start + k * n + n - 1) +} + +/// The DAA margin a segment must have before its deadline: the floor, or 1.5 times the last segment's wall time. +pub fn need_daa(last_segment_secs: Option) -> u64 { + let from_last = last_segment_secs.map(|s| (s * SEGMENT_MARGIN_FACTOR).ceil() as u64).unwrap_or(0); + from_last.max(SEGMENT_MARGIN_MIN_DAA) +} + +/// Groups the node's work list into whole, untouched segments: every block of the segment is in the list, every +/// listed shard is open (past its exclusive window), unpaid and not in this node's pool from us. A segment with a +/// block missing (inside the exclusive window, or outside the lookback) or a shard already paid is not a candidate. +pub fn whole_segments(start: u64, n: u64, unproven_daa: u64, work: &[Work]) -> Vec { + let n = n.max(1); + let mut by_block: BTreeMap> = BTreeMap::new(); + for w in work.iter().filter(|w| w.number >= start) { + by_block.entry(w.number).or_default().push(w); + } + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for number in by_block.keys() { + let (first, last) = segment_of(start, n, *number); + if !seen.insert(first) { + continue; + } + let mut shards = Vec::new(); + let mut wei: u128 = 0; + let mut last_daa = 0; + let mut whole = true; + for b in first..=last { + let Some(entries) = by_block.get(&b) else { + whole = false; + break; + }; + let mut e: Vec<&&Work> = entries.iter().collect(); + e.sort_by_key(|w| w.shard); + e.dedup_by_key(|w| w.shard); + if e.iter().any(|w| !w.open || w.paid || w.in_pool) { + whole = false; + break; + } + for w in e { + shards.push((w.number, w.hash.clone(), w.shard)); + wei = wei.saturating_add(w.shard_wei); + if b == last { + last_daa = w.daa; + } + } + } + if whole && !shards.is_empty() { + out.push(SegmentWork { first, last, last_daa, deadline_daa: last_daa.saturating_add(unproven_daa), shards, shard_wei: wei }); + } + } + out +} + +/// FNV-1a 64 of the segment's first block and this prover's key hash: the per-prover rank. +pub fn rank(first: u64, key_hash: &str) -> u64 { + let mut h: u64 = 0xcbf29ce484222325; + for b in first.to_be_bytes().iter().chain(key_hash.as_bytes()) { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +/// The segments to try, best first: inside the deadline by `need` DAA at `tip_daa`, not attempted, ranked by +/// `rank(first, key)` (ties by the older first block). +pub fn candidates(segs: &[SegmentWork], tip_daa: u64, need: u64, key_hash: &str, attempted: &HashSet) -> Vec { + let mut c: Vec = segs.iter().filter(|s| !attempted.contains(&s.first) && s.deadline_daa >= tip_daa.saturating_add(1).saturating_add(need)).cloned().collect(); + c.sort_by(|a, b| rank(a.first, key_hash).cmp(&rank(b.first, key_hash)).then(a.first.cmp(&b.first))); + c +} + +#[cfg(test)] +mod tests { + use super::*; + + fn w(number: u64, shard: u32, daa: u64, open: bool, paid: bool, in_pool: bool) -> Work { + Work { number, hash: format!("0x{number:064x}"), shard, pgas: 0, tx_count: 0, assigned: false, open, paid, in_pool, shard_wei: 10, key_hash: String::new(), daa } + } + + fn grid(start: u64, n: u64, segments: u64, daa0: u64) -> Vec { + (0..segments * n).map(|i| w(start + i, 0, daa0 + i, true, false, false)).collect() + } + + #[test] + fn the_grid_is_counted_from_the_first_v1_block() { + assert_eq!(segment_of(100, 8, 100), (100, 107)); + assert_eq!(segment_of(100, 8, 107), (100, 107)); + assert_eq!(segment_of(100, 8, 108), (108, 115)); + assert_eq!(segment_of(100, 8, 123), (116, 123)); + } + + #[test] + fn only_whole_open_unpaid_untouched_segments_qualify() { + let mut work = grid(100, 4, 3, 1000); // 100..111, three segments + work.retain(|x| x.number != 105); // 104..107 has a block missing (inside its exclusive window, say) + work.iter_mut().find(|x| x.number == 110).unwrap().paid = true; // 108..111 has a paid shard + let segs = whole_segments(100, 4, 600, &work); + assert_eq!(segs.len(), 1); + assert_eq!((segs[0].first, segs[0].last), (100, 103)); + assert_eq!(segs[0].shards.len(), 4); + assert_eq!(segs[0].last_daa, 1003); + assert_eq!(segs[0].deadline_daa, 1603); + assert_eq!(segs[0].shard_wei, 40); + // a shard of ours already in the pool, or one still exclusive, also disqualifies + let mut work = grid(100, 4, 1, 1000); + work[1].in_pool = true; + assert!(whole_segments(100, 4, 600, &work).is_empty()); + let mut work = grid(100, 4, 1, 1000); + work[3].open = false; + assert!(whole_segments(100, 4, 600, &work).is_empty()); + } + + #[test] + fn a_block_with_several_shards_lists_them_in_order() { + let mut work = grid(100, 2, 1, 1000); + work.push(w(101, 1, 1001, true, false, false)); + work.push(w(100, 1, 1000, true, false, false)); + let segs = whole_segments(100, 2, 600, &work); + assert_eq!(segs[0].shards.iter().map(|(n, _, s)| (*n, *s)).collect::>(), vec![(100, 0), (100, 1), (101, 0), (101, 1)]); + } + + #[test] + fn the_deadline_margin_and_the_attempted_set_filter_the_candidates() { + let work = grid(100, 8, 4, 1000); // 100..131, deadlines 1607, 1615, 1623, 1631 + let segs = whole_segments(100, 8, 600, &work); + assert_eq!(segs.len(), 4); + // at tip DAA 1380 with a 240 margin only the segments with a deadline at or past 1621 remain + let c = candidates(&segs, 1380, 240, "0xkey", &HashSet::new()); + let firsts: Vec = c.iter().map(|s| s.first).collect(); + assert_eq!(firsts.len(), 2); + assert!(firsts.contains(&116) && firsts.contains(&124)); + let mut attempted = HashSet::new(); + attempted.insert(firsts[0]); + let c2 = candidates(&segs, 1380, 240, "0xkey", &attempted); + assert_eq!(c2.len(), 1); + assert_eq!(c2[0].first, firsts[1]); + // past every deadline: nothing + assert!(candidates(&segs, 1700, 240, "0xkey", &HashSet::new()).is_empty()); + } + + #[test] + fn the_order_is_deterministic_per_key_and_differs_between_keys() { + let work = grid(100, 8, 6, 1000); + let segs = whole_segments(100, 8, 600, &work); + let a = candidates(&segs, 1000, 240, "0xaaaa", &HashSet::new()); + let a2 = candidates(&segs, 1000, 240, "0xaaaa", &HashSet::new()); + assert_eq!(a, a2); + assert_eq!(a.len(), 6); + // two provers rank the six candidates differently (the spread); the sets are the same + let b = candidates(&segs, 1000, 240, "0xbbbb", &HashSet::new()); + let (fa, fb): (Vec, Vec) = (a.iter().map(|s| s.first).collect(), b.iter().map(|s| s.first).collect()); + let mut sa = fa.clone(); + let mut sb = fb.clone(); + sa.sort(); + sb.sort(); + assert_eq!(sa, sb); + assert_ne!(fa, fb, "two keys should not rank six segments identically"); + } + + #[test] + fn the_margin_follows_the_last_segment_time() { + assert_eq!(need_daa(None), 240); + assert_eq!(need_daa(Some(100.0)), 240); + assert_eq!(need_daa(Some(190.0)), 285); + } +} diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index da328308..2f94de27 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -204,6 +204,13 @@ pub struct ProvingState { /// proving v1: segment records this machine aggregated and submitted, and the aggregator's last line pub aggregated: u32, pub segment_note: String, + /// proving v1 segment path (6 October 2026): whole segments this machine proved and submitted, paid, and what + /// they paid (wei as a decimal string, see `paid_wei`); the last segment's wall time + pub segments_submitted: u32, + pub segments_paid: u32, + #[serde(serialize_with = "u128_string")] + pub segment_paid_wei: u128, + pub segment_last_s: f64, } #[derive(Clone, Serialize, Default)] diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index af1bb188..feeb5dc8 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -1011,6 +1011,11 @@ if (typeof document !== 'undefined') (function () { setText('pv-submitted', String(pv.submitted || 0)); setText('pv-paid', String(pv.paid || 0)); setText('pv-paid-sub', pv.paid_wei ? (Number(pv.paid_wei) / 1e18).toFixed(4) + ' IGN earned' : 'shards paid out'); + // proving v1 segments (6 October 2026): whole segments this machine proved, and the segment path's last line + var segLine = ''; + if (pv.segments_submitted) segLine = 'Segments: ' + pv.segments_submitted + ' proven whole, ' + (pv.segments_paid || 0) + ' paid' + (pv.segment_paid_wei && Number(pv.segment_paid_wei) ? ' (' + (Number(pv.segment_paid_wei) / 1e18).toFixed(4) + ' IGN to the aggregator)' : '') + (pv.segment_last_s ? ', the last in ' + Math.round(pv.segment_last_s) + ' s' : '') + '.'; + if (pv.segment_note && enabled) segLine += (segLine ? ' ' : '') + pv.segment_note + '.'; + $('pv-seg-note').hidden = !segLine; setText('pv-seg-note', segLine); var v = View.verifierWords(pv); setText('pv-verifier', v.word); $('pv-verifier').className = 'big-word ' + v.tone; $('pv-verifier-note').hidden = !pv.verifier_note; setText('pv-verifier-note', pv.verifier_note || ''); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index f878ae00..165762cf 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -219,6 +219,7 @@
proven
0
proofs sent to the node
paid
0
shards paid out
+

Verifier

the node's check
diff --git a/proving/igneum-prove/host/src/main.rs b/proving/igneum-prove/host/src/main.rs index 005182c2..8b261007 100644 --- a/proving/igneum-prove/host/src/main.rs +++ b/proving/igneum-prove/host/src/main.rs @@ -96,9 +96,13 @@ fn run() -> Result<()> { // --save-shards writes every shard's compressed proof next to the results (block-N-shard-i-compressed.bin), // so `--mode aggregate` can re-run the aggregation of the same proofs under other settings let save_shards = args.iter().any(|a| a == "--save-shards"); - return run_chain(&pinned, &fixtures, prover, out_path.as_deref(), save_shards); + // --prev : the previous segment's aggregated proof; the chain continues from it (chain_len grows past + // the segment length, the chain rule of spec 7.8) instead of starting fresh. The app's segment path (6 October + // 2026) passes it when the node reports the previous segment paid and its proof in the pool. + let prev = arg("--prev"); + return run_chain(&pinned, &fixtures, prover, out_path.as_deref(), save_shards, prev.as_deref()); } - let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host [--mode native|execute|shard|compressed|block|all] [--shard N] [--budget ] [--prover 0x..] [--out results.json]; --mode chain --chain [--prover 0x..] [--out results.json] [--save-shards]; --mode aggregate --proofs --parent 0x.. [--prev prev.bin] [--out results.json]; --mode verify --proof --statement 0x..; --mode verify-segment --proof --statement 0x..; --mode id")?; + let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host [--mode native|execute|shard|compressed|block|all] [--shard N] [--budget ] [--prover 0x..] [--out results.json]; --mode chain --chain [--prover 0x..] [--out results.json] [--save-shards] [--prev prev.bin]; --mode aggregate --proofs --parent 0x.. [--prev prev.bin] [--out results.json]; --mode verify --proof --statement 0x..; --mode verify-segment --proof --statement 0x..; --mode id")?; let shard_index: usize = arg("--shard").map(|s| s.parse()).transpose()?.unwrap_or(0); // `--budget `: re-plan the fixture's block at a TEST budget (the S_p curve of 5 October 2026); the fixture's // own per-shard plan is then not compared (the chain and the sums still are), and `--out` records the cut @@ -621,7 +625,7 @@ fn out_dir_of(out_path: Option<&str>) -> std::path::PathBuf { /// `--mode chain`: every fixture in order, consecutive on the chain (number and parent hash), each block's shards /// proven compressed and aggregated with the previous block's aggregated proof (`AggInput.prev`, the chain rule), /// every proof verified. One RESULT line per shard, per block (with the running totals) and for the chain. -fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_path: Option<&str>, save_shards: bool) -> Result<()> { +fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_path: Option<&str>, save_shards: bool, prev_path: Option<&str>) -> Result<()> { if fixtures.is_empty() { bail!("--chain needs at least one fixture"); } @@ -659,9 +663,23 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_ println!("RESULT chain native block {}: {} shard(s), pgas {}, gas {}, pre {} post {}", f.block.env.number, shards.len(), outcome.pgas_used, outcome.gas_used, pre_root, outcome.state_root); built.push((f.block.env.number, f.block.env.hash, shards, pre_root)); } + // the previous segment's proof: the chain continues from it (its block must be the parent of the first fixture) + let mut prev: Option = match prev_path { + None => None, + Some(p) => { + let bytes = std::fs::read(p).with_context(|| format!("read {p}"))?; + let proof: sp1_sdk::SP1ProofWithPublicValues = bincode::deserialize(&bytes).with_context(|| format!("{p} is not a bincode SP1 proof"))?; + let output = BlockOutput::from_bytes(proof.public_values.as_slice()).with_context(|| format!("{p}: public values are not a block statement"))?; + if output.number + 1 != first || output.block_hash != loaded[0].1.block.env.parent_hash { + bail!("--prev attests block {} ({}), the chain starts at block {first} with parent {}: the previous proof must be the parent block's", output.number, output.block_hash, loaded[0].1.block.env.parent_hash); + } + println!("RESULT chain prev: block {} chain_len {} (the chain continues from it)", output.number, output.chain_len); + Some(proof_system::Sp1SegmentProof { proof, output }) + } + }; + let base_len = prev.as_ref().map(|p| p.output.chain_len).unwrap_or(0); let sp1 = setup_sp1(pinned, &mut results)?; let chain_t = Instant::now(); - let mut prev: Option = None; let mut blocks_json = Vec::with_capacity(built.len()); let (mut shard_total, mut agg_total, mut shards_total) = (0.0f64, 0.0f64, 0usize); let out_dir = out_dir_of(out_path); @@ -670,6 +688,9 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_ let block_t = Instant::now(); let mut proofs: Vec = Vec::with_capacity(shards.len()); let mut shard_secs = Vec::new(); + // with --save-shards: what a shard's proof record carries (the statement, the proof's sha256, the file), so + // the app's segment path signs and submits every shard of the chain from one run + let mut shard_records: Vec = Vec::new(); for s in shards { let i = s.output.shard_index; stage(&format!("chain block {number} compressed shard {i}")); @@ -684,7 +705,13 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_ shard_total += dt; if save_shards { let file = out_dir.join(format!("block-{number}-shard-{i}-compressed.bin")); - save_proof(&p.proof, file.clone()); + let bytes = bincode::serialize(&p.proof)?; + std::fs::write(&file, &bytes).with_context(|| format!("write {}", file.display()))?; + let proof_hash: [u8; 32] = sha2::Sha256::digest(&bytes).into(); + shard_records.push(serde_json::json!({ + "number": number, "block_hash": s.input.env.hash.to_string(), "shard": i, "statement": alloy_primitives::keccak256(s.output.to_bytes()).to_string(), + "proof_sha256": format!("0x{}", hex::encode(proof_hash)), "proof_bytes": bytes.len(), "proof_file": file.display().to_string(), "prove_seconds": dt, + })); shard_files.push(file.display().to_string()); } proofs.push(p); @@ -715,7 +742,7 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_ if !ok { bail!("the aggregated proof of block {number} did not verify"); } - let expected_len = blocks_json.len() as u64 + 1; + let expected_len = base_len + blocks_json.len() as u64 + 1; if seg.output.chain_len != expected_len { bail!("block {number}: chain_len {} is not {expected_len}", seg.output.chain_len); } @@ -723,11 +750,13 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_ "number": number, "shards": seg.output.shard_count, "shard_prove_seconds": shard_secs, "aggregate_prove_seconds": adt, "aggregate_stdin_seconds": sdt, "aggregate_verify_seconds": vdt, "proof_bytes": bytes, "chain_len": seg.output.chain_len, "block_seconds": block_s, "cumulative_seconds": cumulative, "post_root": seg.output.post_root.to_string(), "statement": alloy_primitives::keccak256(seg.output.to_bytes()).to_string(), + "shard_records": shard_records, })); prev = Some(seg); } let seg = prev.unwrap(); let total = chain_t.elapsed().as_secs_f64(); + results.insert("base_chain_len".into(), base_len.into()); let (statement, bytes) = segment_results(&seg, &out_dir, &mut results)?; println!( "RESULT chain: {} blocks {first}..={last}, {shards_total} shards, shard proofs {shard_total:.1} s, aggregation {agg_total:.1} s, end to end {total:.1} s; final proof {bytes} bytes attests chain_len {} (statement {statement}), pre {} post {} provers {} at {}",