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