prover: segment-aligned work (proving v1): a free prover claims a whole segment, proves every shard from one export in one chain run, submits the shard records and the segment record; the host's chain mode takes --prev and writes per-shard records with --save-shards

One prover at 2.2% coverage never had 8 consecutive proven blocks (C47, 6 October 2026); claiming whole segments makes it complete about 1 segment in 75 instead of none. The choice is deterministic per key (FNV of first block and key hash) over the untouched whole segments inside their deadline by a margin (240 DAA or 1.5x the last segment's time); the per-block path stays as the fallback. Unit tests for the grid, the grouping, the margin, the attempted set and the per-key order; 120 app tests, 8 core and 9 host tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 07:12:23 +00:00
parent 7dade79bcb
commit 3671918fb4
7 changed files with 489 additions and 8 deletions

View file

@ -30,6 +30,7 @@ mod jobrun;
mod jobbuild;
mod prover;
mod provedefault;
mod segments;
mod verifier;
mod wslhost;
mod sweep;

View file

@ -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<Work> {
in_pool: w["pool"].as_array().map(|p| !p.is_empty()).unwrap_or(false),
shard_wei: hexu(&w["shardWei"]),
key_hash: w["assignedKeys"].as_array().and_then(|k| k.first()).and_then(|k| k.as_str()).unwrap_or("").to_string(),
daa: hexu(&w["daaScore"]) as u64,
})
.collect()
})
@ -344,6 +347,9 @@ fn loop_forever(shared: Arc<Shared>, 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<f64> = 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<Shared>, bin_dir: PathBuf) {
});
continue;
}
let work = match evm_rpc(&shared, "igneum_getAssignedShards", json!([keys.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(), 60]), Duration::from_secs(10)) {
let work = match evm_rpc(&shared, "igneum_getAssignedShards", json!([keys.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(), crate::segments::WORK_LOOKBACK]), Duration::from_secs(10)) {
Ok(v) => parse_work(&v),
Err(e) => {
set(&shared, |p| {
@ -477,6 +483,21 @@ fn loop_forever(shared: Arc<Shared>, 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<Shared>, 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<Shared>, 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<u64>, last_secs: Option<f64>) -> Option<(crate::segments::SegmentWork, Option<String>, String)> {
let hexu = |x: &Value| x.as_str().and_then(|s| u64::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0);
let st = evm_rpc(shared, "igneum_getProvingStatus", json!([]), Duration::from_secs(10)).ok()?;
let v1 = &st["v1"];
if !v1["active"].as_bool().unwrap_or(false) || v1["start"].is_null() {
return None;
}
let (start, n, unproven, tip_daa) = (hexu(&v1["start"]), hexu(&v1["segmentBlocks"]).max(1), hexu(&v1["unprovenDaa"]), hexu(&st["tipDaa"]));
let segs = crate::segments::whole_segments(start, n, unproven, work);
let need = crate::segments::need_daa(last_secs);
let cands = crate::segments::candidates(&segs, tip_daa, need, key_hash, attempted);
if cands.is_empty() {
return None;
}
let dir = shared.runtime.app_dir.join("proving");
let _ = std::fs::create_dir_all(&dir);
let wsl = cfg!(windows);
let as_host_path = |p: &Path| if wsl { wsl_path(p) } else { p.display().to_string() };
for seg in cands.into_iter().take(3) {
let Ok(stmt) = evm_rpc(shared, "igneum_getSegmentStatement", json!([format!("{:#x}", seg.first)]), Duration::from_secs(10)) else { continue };
if !stmt["executed"].as_bool().unwrap_or(false) || stmt["status"]["status"].as_str() != Some("pending") {
attempted.insert(seg.first);
continue;
}
let prev = &stmt["previous"];
if prev.is_null() {
// fresh only when no record of the previous segment is waiting to be carried (the chain rule would
// refuse a fresh record once that one pays)
if seg.first >= start + n {
let p = evm_rpc(shared, "igneum_getSegmentRecords", json!([format!("{:#x}", seg.first - n)]), Duration::from_secs(10)).unwrap_or(Value::Null);
let waiting = p["pool"].as_array().map(|a| a.iter().any(|e| e["verified"] == json!(true) && e["includedIn"].is_null())).unwrap_or(false);
if waiting || !p["paid"].is_null() {
continue;
}
}
return Some((seg, None, stmt["publicValuesFresh"].as_str().unwrap_or("").to_string()));
}
if prev["proofInPool"] != json!(true) {
continue;
}
let Ok(got) = evm_rpc(shared, "igneum_getSegmentProofBytes", json!([prev["first"], prev["keyHash"]]), Duration::from_secs(60)) else { continue };
let hex = got["proof"].as_str().unwrap_or("").trim_start_matches("0x").to_string();
if hex.is_empty() {
continue;
}
let bytes: Vec<u8> = (0..hex.len() / 2).map(|k| u8::from_str_radix(&hex[2 * k..2 * k + 2], 16).unwrap_or(0)).collect();
let f = dir.join(format!("prev-{}.bin", seg.first));
if std::fs::write(&f, bytes).is_err() {
continue;
}
return Some((seg, Some(as_host_path(&f)), stmt["publicValuesContinuing"].as_str().unwrap_or("").to_string()));
}
None
}
/// Proving v1 segment path, the work: one export of the chain to the segment's last block, one fixture per block,
/// one host run (`--mode chain --save-shards`, `--prev` when the chain continues) that proves every shard and
/// aggregates the segment, then every shard record signed and submitted (the shard payouts) and the segment
/// record signed and submitted (the aggregator share). Returns the segment's aggregator wei.
fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork, label: &str, payout: &str, prev_file: Option<&str>, expected_pv: &str, submitted: &mut Vec<(u64, String, u32, u128)>) -> Result<u128, String> {
let (first, last) = (seg.first, seg.last);
let dir = shared.runtime.app_dir.join("proving").join(format!("seg-{first}"));
let _ = std::fs::create_dir_all(&dir);
let as_host_path = |p: &Path| if t.wsl { wsl_path(p) } else { p.display().to_string() };
set(shared, |p| {
p.status = "proving".into();
p.current = format!("segment {first}..{last} ({} shards)", seg.shards.len());
p.started_at = crate::platform::unix_now_f();
p.message = "exporting the chain and cutting the segment's blocks".into();
});
shared.log(&format!("prover: segment {first}..{last} claimed ({} shards{}): export, cut, chain ({}), sign, submit", seg.shards.len(), if prev_file.is_some() { ", continuing the previous segment's proof" } else { ", fresh" }, if t.cuda { "CUDA" } else { "CPU" }));
// 1. export once, cut every block
let seq = dir.join("seq.json");
let export = evm_rpc(shared, "igneum_exportSegments", json!(["0x0", format!("{last:#x}")]), Duration::from_secs(300))?;
std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?;
let mut fixtures: Vec<String> = Vec::new();
for b in first..=last {
let fixture = dir.join(format!("block-{b}.json"));
let (ok, out) = run_tool(shared, t, &t.export, &[as_host_path(&seq), b.to_string(), as_host_path(&fixture)], &[], Duration::from_secs(600), &dir.join(format!("export-{b}.log")));
if !ok || !fixture.exists() {
return Err(format!("exporter, block {b}: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
}
fixtures.push(as_host_path(&fixture));
}
let _ = std::fs::remove_file(&seq);
// 2. the chain: every shard proven, every block aggregated with the previous, in one process
set(shared, |p| p.message = format!("proving {} shards and aggregating segment {first}..{last} ({})", seg.shards.len(), if t.cuda { "GPU" } else { "CPU, slow" }));
let results = dir.join("chain-results.json");
let mut args: Vec<String> = vec!["--mode".into(), "chain".into(), "--chain".into(), fixtures.join(","), "--prover".into(), payout.to_string(), "--save-shards".into(), "--out".into(), as_host_path(&results)];
if let Some(pf) = prev_file {
args.push("--prev".into());
args.push(pf.to_string());
}
let (ok, out) = run_tool(shared, t, &t.host, &args, &[("SP1_PROVER", if t.cuda { "cuda" } else { "cpu" }), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600), &dir.join("chain.log"));
if !ok || !results.exists() {
let last_line = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
let hint = if last_line.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user: the root-socket class)" } else { "" };
return Err(format!("chain: {last_line}{hint}"));
}
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let to_win = |f: &str| if t.wsl { PathBuf::from(f.replace("/mnt/c/", "C:/")) } else { PathBuf::from(f) };
// 3. the shard records
let chain = chain_name(shared);
let mut shard_ok = 0usize;
for b in res["blocks"].as_array().cloned().unwrap_or_default() {
for r in b["shard_records"].as_array().cloned().unwrap_or_default() {
let (number, hash, shard) = (r["number"].as_u64().unwrap_or(0), r["block_hash"].as_str().unwrap_or("").to_string(), r["shard"].as_u64().unwrap_or(0) as u32);
let statement = r["statement"].as_str().unwrap_or("").to_string();
let proof_sha = r["proof_sha256"].as_str().unwrap_or("").to_string();
let proof_file = r["proof_file"].as_str().unwrap_or("").to_string();
let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-record", label, &chain, &hash, &number.to_string(), &shard.to_string(), payout, &statement, &proof_sha]), None, Duration::from_secs(20)).ok_or("sign-record did not run")?;
let signed: Value = serde_json::from_str(sg.lines().last().unwrap_or("")).map_err(|_| format!("sign-record: {}", sg.trim()))?;
let record = signed["record"].as_str().ok_or("sign-record gave no record")?.to_string();
let proof = std::fs::read(to_win(&proof_file)).map_err(|e| format!("proof file {proof_file}: {e}"))?;
let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::<String>());
let out = evm_rpc(shared, "igneum_submitProofRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?;
if out["accepted"].as_bool().unwrap_or(false) {
shard_ok += 1;
let wei = seg.shards.iter().position(|(n, _, s)| *n == number && *s == shard).map(|_| seg.shard_wei / seg.shards.len().max(1) as u128).unwrap_or(0);
submitted.push((number, hash.clone(), shard, wei));
} else {
shared.log(&format!("prover: segment {first}..{last}: block {number} shard {shard} record refused: {}", out["reason"].as_str().unwrap_or("?")));
}
}
}
if shard_ok != seg.shards.len() {
return Err(format!("{shard_ok} of {} shard records accepted; the segment record is not submitted", seg.shards.len()));
}
set(shared, |p| {
p.proved += shard_ok as u32;
p.submitted += shard_ok as u32;
});
// 4. the segment record: the aggregated statement against the node's native one (every field but provers)
let pv = res["segment_public_values"].as_str().ok_or("no public values in the chain results")?.to_string();
let proof_sha = res["segment_proof_sha256"].as_str().ok_or("no segment proof hash in the chain results")?.to_string();
let proof_file = res["segment_proof_file"].as_str().ok_or("no segment proof file in the chain results")?.to_string();
let strip = |h: &str| { let h = h.trim_start_matches("0x"); if h.len() == 680 { format!("{}{}", &h[..472], &h[536..]) } else { h.to_string() } };
if strip(&pv) != strip(expected_pv) {
return Err(format!("the aggregated statement differs from the node's native statement (it would be vetoed); ours {} node {}", &pv[..66.min(pv.len())], &expected_pv[..66.min(expected_pv.len())]));
}
let last_hash = seg.shards.iter().rev().find(|(n, _, _)| *n == last).map(|(_, h, _)| h.clone()).ok_or("no last block hash")?;
let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-segment-record", label, &chain, &first.to_string(), &last.to_string(), &last_hash, payout, &pv, &proof_sha]), None, Duration::from_secs(20)).ok_or("sign-segment-record did not run")?;
let signed: Value = serde_json::from_str(sg.lines().last().unwrap_or("")).map_err(|_| format!("sign-segment-record: {}", sg.trim()))?;
let record = signed["record"].as_str().ok_or("sign-segment-record gave no record")?.to_string();
let proof = std::fs::read(to_win(&proof_file)).map_err(|e| format!("segment proof file {proof_file}: {e}"))?;
let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::<String>());
let r = evm_rpc(shared, "igneum_submitSegmentRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?;
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

View file

@ -0,0 +1,214 @@
//! Proving v1 (spec 7.8): segment-aligned work for the prover loop (6 October 2026).
//!
//! The shipped loop took the newest open shard each pass, so one prover scattered one block in about 45 across
//! the segment grid and no segment ever had all its blocks proven (node 1, 04:16Z: pending 55, proven 0). Here a
//! free prover claims a whole segment (`proving_v1_segment_blocks` consecutive chain blocks), proves every shard
//! of it in order from one export in one host run (`--mode chain --save-shards`), submits the shard records and the
//! aggregated segment record, then takes the next. One card completes whole segments at its own rate instead of
//! completing none.
//!
//! The choice is deterministic per prover: among the untouched whole segments still inside their deadline by a
//! margin, the lowest FNV-1a of (first block, this prover's key hash) wins, so several provers spread over the
//! candidates without a coordinator; the per-block fallback (`prover::choose`) stays for the passes where no whole
//! segment qualifies.
use std::collections::{BTreeMap, HashSet};
use crate::prover::Work;
/// The least time a claimed segment is given before its deadline (DAA units, about one a second on devnet): the
/// chain of 8 empty blocks took 135.6 s cold beside the miner (bench-log, 5 October 2026), so 240 leaves the
/// submission and the carrying block inside the window.
pub const SEGMENT_MARGIN_MIN_DAA: u64 = 240;
/// The margin grows with what the last segment actually took, times this.
pub const SEGMENT_MARGIN_FACTOR: f64 = 1.5;
/// How far back the work list reaches (chain blocks): the record window, so every open segment inside the
/// deadline is visible.
pub const WORK_LOOKBACK: u64 = 600;
#[derive(Clone, Debug, PartialEq)]
pub struct SegmentWork {
pub first: u64,
pub last: u64,
/// the last block's DAA score; the deadline is it plus `proving_v1_unproven_daa`
pub last_daa: u64,
pub deadline_daa: u64,
/// (chain block number, block hash, shard index) in chain order
pub shards: Vec<(u64, String, u32)>,
/// the shard payouts summed (what the shards earn when carried)
pub shard_wei: u128,
}
/// The segment holding chain block `number` on the grid that starts at `start`.
pub fn segment_of(start: u64, n: u64, number: u64) -> (u64, u64) {
let n = n.max(1);
let k = number.saturating_sub(start) / n;
(start + k * n, start + k * n + n - 1)
}
/// The DAA margin a segment must have before its deadline: the floor, or 1.5 times the last segment's wall time.
pub fn need_daa(last_segment_secs: Option<f64>) -> u64 {
let from_last = last_segment_secs.map(|s| (s * SEGMENT_MARGIN_FACTOR).ceil() as u64).unwrap_or(0);
from_last.max(SEGMENT_MARGIN_MIN_DAA)
}
/// Groups the node's work list into whole, untouched segments: every block of the segment is in the list, every
/// listed shard is open (past its exclusive window), unpaid and not in this node's pool from us. A segment with a
/// block missing (inside the exclusive window, or outside the lookback) or a shard already paid is not a candidate.
pub fn whole_segments(start: u64, n: u64, unproven_daa: u64, work: &[Work]) -> Vec<SegmentWork> {
let n = n.max(1);
let mut by_block: BTreeMap<u64, Vec<&Work>> = BTreeMap::new();
for w in work.iter().filter(|w| w.number >= start) {
by_block.entry(w.number).or_default().push(w);
}
let mut out = Vec::new();
let mut seen = HashSet::new();
for number in by_block.keys() {
let (first, last) = segment_of(start, n, *number);
if !seen.insert(first) {
continue;
}
let mut shards = Vec::new();
let mut wei: u128 = 0;
let mut last_daa = 0;
let mut whole = true;
for b in first..=last {
let Some(entries) = by_block.get(&b) else {
whole = false;
break;
};
let mut e: Vec<&&Work> = entries.iter().collect();
e.sort_by_key(|w| w.shard);
e.dedup_by_key(|w| w.shard);
if e.iter().any(|w| !w.open || w.paid || w.in_pool) {
whole = false;
break;
}
for w in e {
shards.push((w.number, w.hash.clone(), w.shard));
wei = wei.saturating_add(w.shard_wei);
if b == last {
last_daa = w.daa;
}
}
}
if whole && !shards.is_empty() {
out.push(SegmentWork { first, last, last_daa, deadline_daa: last_daa.saturating_add(unproven_daa), shards, shard_wei: wei });
}
}
out
}
/// FNV-1a 64 of the segment's first block and this prover's key hash: the per-prover rank.
pub fn rank(first: u64, key_hash: &str) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in first.to_be_bytes().iter().chain(key_hash.as_bytes()) {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
/// The segments to try, best first: inside the deadline by `need` DAA at `tip_daa`, not attempted, ranked by
/// `rank(first, key)` (ties by the older first block).
pub fn candidates(segs: &[SegmentWork], tip_daa: u64, need: u64, key_hash: &str, attempted: &HashSet<u64>) -> Vec<SegmentWork> {
let mut c: Vec<SegmentWork> = segs.iter().filter(|s| !attempted.contains(&s.first) && s.deadline_daa >= tip_daa.saturating_add(1).saturating_add(need)).cloned().collect();
c.sort_by(|a, b| rank(a.first, key_hash).cmp(&rank(b.first, key_hash)).then(a.first.cmp(&b.first)));
c
}
#[cfg(test)]
mod tests {
use super::*;
fn w(number: u64, shard: u32, daa: u64, open: bool, paid: bool, in_pool: bool) -> Work {
Work { number, hash: format!("0x{number:064x}"), shard, pgas: 0, tx_count: 0, assigned: false, open, paid, in_pool, shard_wei: 10, key_hash: String::new(), daa }
}
fn grid(start: u64, n: u64, segments: u64, daa0: u64) -> Vec<Work> {
(0..segments * n).map(|i| w(start + i, 0, daa0 + i, true, false, false)).collect()
}
#[test]
fn the_grid_is_counted_from_the_first_v1_block() {
assert_eq!(segment_of(100, 8, 100), (100, 107));
assert_eq!(segment_of(100, 8, 107), (100, 107));
assert_eq!(segment_of(100, 8, 108), (108, 115));
assert_eq!(segment_of(100, 8, 123), (116, 123));
}
#[test]
fn only_whole_open_unpaid_untouched_segments_qualify() {
let mut work = grid(100, 4, 3, 1000); // 100..111, three segments
work.retain(|x| x.number != 105); // 104..107 has a block missing (inside its exclusive window, say)
work.iter_mut().find(|x| x.number == 110).unwrap().paid = true; // 108..111 has a paid shard
let segs = whole_segments(100, 4, 600, &work);
assert_eq!(segs.len(), 1);
assert_eq!((segs[0].first, segs[0].last), (100, 103));
assert_eq!(segs[0].shards.len(), 4);
assert_eq!(segs[0].last_daa, 1003);
assert_eq!(segs[0].deadline_daa, 1603);
assert_eq!(segs[0].shard_wei, 40);
// a shard of ours already in the pool, or one still exclusive, also disqualifies
let mut work = grid(100, 4, 1, 1000);
work[1].in_pool = true;
assert!(whole_segments(100, 4, 600, &work).is_empty());
let mut work = grid(100, 4, 1, 1000);
work[3].open = false;
assert!(whole_segments(100, 4, 600, &work).is_empty());
}
#[test]
fn a_block_with_several_shards_lists_them_in_order() {
let mut work = grid(100, 2, 1, 1000);
work.push(w(101, 1, 1001, true, false, false));
work.push(w(100, 1, 1000, true, false, false));
let segs = whole_segments(100, 2, 600, &work);
assert_eq!(segs[0].shards.iter().map(|(n, _, s)| (*n, *s)).collect::<Vec<_>>(), vec![(100, 0), (100, 1), (101, 0), (101, 1)]);
}
#[test]
fn the_deadline_margin_and_the_attempted_set_filter_the_candidates() {
let work = grid(100, 8, 4, 1000); // 100..131, deadlines 1607, 1615, 1623, 1631
let segs = whole_segments(100, 8, 600, &work);
assert_eq!(segs.len(), 4);
// at tip DAA 1380 with a 240 margin only the segments with a deadline at or past 1621 remain
let c = candidates(&segs, 1380, 240, "0xkey", &HashSet::new());
let firsts: Vec<u64> = c.iter().map(|s| s.first).collect();
assert_eq!(firsts.len(), 2);
assert!(firsts.contains(&116) && firsts.contains(&124));
let mut attempted = HashSet::new();
attempted.insert(firsts[0]);
let c2 = candidates(&segs, 1380, 240, "0xkey", &attempted);
assert_eq!(c2.len(), 1);
assert_eq!(c2[0].first, firsts[1]);
// past every deadline: nothing
assert!(candidates(&segs, 1700, 240, "0xkey", &HashSet::new()).is_empty());
}
#[test]
fn the_order_is_deterministic_per_key_and_differs_between_keys() {
let work = grid(100, 8, 6, 1000);
let segs = whole_segments(100, 8, 600, &work);
let a = candidates(&segs, 1000, 240, "0xaaaa", &HashSet::new());
let a2 = candidates(&segs, 1000, 240, "0xaaaa", &HashSet::new());
assert_eq!(a, a2);
assert_eq!(a.len(), 6);
// two provers rank the six candidates differently (the spread); the sets are the same
let b = candidates(&segs, 1000, 240, "0xbbbb", &HashSet::new());
let (fa, fb): (Vec<u64>, Vec<u64>) = (a.iter().map(|s| s.first).collect(), b.iter().map(|s| s.first).collect());
let mut sa = fa.clone();
let mut sb = fb.clone();
sa.sort();
sb.sort();
assert_eq!(sa, sb);
assert_ne!(fa, fb, "two keys should not rank six segments identically");
}
#[test]
fn the_margin_follows_the_last_segment_time() {
assert_eq!(need_daa(None), 240);
assert_eq!(need_daa(Some(100.0)), 240);
assert_eq!(need_daa(Some(190.0)), 285);
}
}

View file

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

View file

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

View file

@ -219,6 +219,7 @@
<div class="cell"><div class="k">proven</div><div class="v" id="pv-submitted">0</div><div class="s">proofs sent to the node</div></div>
<div class="cell"><div class="k">paid</div><div class="v" id="pv-paid">0</div><div class="s" id="pv-paid-sub">shards paid out</div></div>
</div>
<p class="note" id="pv-seg-note" hidden></p>
<div class="grid2">
<div class="card">
<div class="card-head"><h3>Verifier</h3><div class="eyebrow">the node's check</div></div>

View file

@ -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 <file>: 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 <fixture.json> [--mode native|execute|shard|compressed|block|all] [--shard N] [--budget <test pgas>] [--prover 0x..] [--out results.json]; --mode chain --chain <f1,f2,...> [--prover 0x..] [--out results.json] [--save-shards]; --mode aggregate --proofs <a.bin,...> --parent 0x.. [--prev prev.bin] [--out results.json]; --mode verify --proof <file> --statement 0x..; --mode verify-segment --proof <file> --statement 0x..; --mode id")?;
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] [--budget <test pgas>] [--prover 0x..] [--out results.json]; --mode chain --chain <f1,f2,...> [--prover 0x..] [--out results.json] [--save-shards] [--prev prev.bin]; --mode aggregate --proofs <a.bin,...> --parent 0x.. [--prev prev.bin] [--out results.json]; --mode verify --proof <file> --statement 0x..; --mode verify-segment --proof <file> --statement 0x..; --mode id")?;
let shard_index: usize = arg("--shard").map(|s| s.parse()).transpose()?.unwrap_or(0);
// `--budget <pgas>`: 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<proof_system::Sp1SegmentProof> = 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<proof_system::Sp1SegmentProof> = 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<Sp1ShardProof> = 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<serde_json::Value> = 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 {}",