Proving: aggregator guest, host modes shard and block with per-stage timestamps, exporter with shard plans

Two SP1 programs: the shard guest and the aggregator guest (deferred proof verification of the shard vk). Host modes native (cut, witnesses, chain and sums, three tamper checks, stub), execute (cycles per shard and for the aggregator), shard (execute, core, compressed, each verified), block (compressed proof per shard, aggregation, verified against the shard program id and the claim). Every stage prints a STAGE line and a RESULT line with a UTC timestamp so a silent gap is visible, and the host holds a Tokio runtime for the whole run and drops the proof system inside it, for the sp1-cuda Drop panic (ledger P20). The exporter writes v1 fixtures with the plan and every shard's expected roots, links and witness size; --budget makes a test cut.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 10:05:05 +00:00
parent 9bea7c718b
commit f71af23dcb
10 changed files with 604 additions and 190 deletions

View file

@ -0,0 +1,12 @@
[package]
name = "igneum-prove-aggregator"
description = "SP1 guest: verifies the shard proofs of one Igneum chain block in order and commits the block statement (design 5.3)"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
sp1-zkvm = { workspace = true, features = ["verify"] }
igneum-prove-core.workspace = true
bincode.workspace = true
sha2 = "0.10"

View file

@ -0,0 +1,20 @@
//! The aggregator guest. Input: bincode of `AggInput` (the shard program's verifying key, the shard proofs'
//! public values in order, the parent hash, optionally the previous block proof's key and public values); the
//! shard proofs themselves come in as SP1 deferred proofs. Output: `BlockOutput` in its fixed byte layout.
//! Every check is `igneum_prove_core::agg::aggregate`, the same code the host runs natively first.
#![no_main]
sp1_zkvm::entrypoint!(main);
use igneum_prove_core::agg::{aggregate, AggInput};
use sha2::{Digest, Sha256};
pub fn main() {
let input = sp1_zkvm::io::read_vec();
let input: AggInput = bincode::deserialize(&input).expect("AggInput decodes");
let out = aggregate(&input, &mut |vk, pv| {
let digest: [u8; 32] = Sha256::digest(pv).into();
sp1_zkvm::lib::verify::verify_sp1_proof(vk, &digest);
});
sp1_zkvm::io::commit_slice(&out.to_bytes());
}

View file

@ -11,3 +11,4 @@ alloy-primitives.workspace = true
serde_json.workspace = true
hex.workspace = true
anyhow.workspace = true
bincode.workspace = true

View file

@ -1,15 +1,17 @@
//! Usage: igneum-prove-export <seq.json> <block number> <out.json> [--source "text"]
//! Usage: igneum-prove-export <seq.json> <block number> <out.json> [--source "text"] [--budget <pgas>]
//!
//! `seq.json` is the output of `igneum_exportSegments` (tools/evm-smoke writes it). The exporter rebuilds the
//! genesis state (the DeveloperRegistry at its fixed address, nothing else), replays every segment through
//! `igneum_prove_core::execute_block` and compares the state root after each with the node's `stateRoot`.
//! Any mismatch is fatal: the port would not be the node's executor. At the requested block it snapshots the
//! pre-state, runs the block, and writes the fixture with the expected roots.
//! `seq.json` is the output of `igneum_exportSegments` (tools/evm-smoke and tools/prove-fixtures write it). The
//! exporter rebuilds the genesis state (the DeveloperRegistry at its fixed address, nothing else), replays every
//! segment through `igneum_prove_core::execute_block` and compares the state root after each with the node's
//! `stateRoot`. Any mismatch is fatal: the port would not be the node's executor. At the requested block it
//! snapshots the pre-state, runs the block, cuts the shard plan at `S_p` (or `--budget`, a test cut), builds
//! and checks every shard's witness, and writes the fixture with the expected values per shard and per block.
use alloy_primitives::{keccak256, Address, Bytes, B256, U256};
use anyhow::{bail, Context, Result};
use igneum_prove_core::config::DEVELOPER_REGISTRY_ADDRESS;
use igneum_prove_core::{execute_block, AccountFixture, BlockFixture, Expected, Fixture, FixtureEnv, IgneumDb, IncludingBlock};
use igneum_prove_core::config::{DEVELOPER_REGISTRY_ADDRESS, SHARD_PROVING_GAS_BUDGET};
use igneum_prove_core::shard::build_shards;
use igneum_prove_core::{execute_block, AccountFixture, BlockFixture, Expected, Fixture, FixtureEnv, IgneumDb, IncludingBlock, Plan, ShardExpected};
use serde_json::Value;
fn addr(v: &Value) -> Result<Address> {
@ -86,12 +88,13 @@ fn fixture_of(export: &Value, segments: &[Value], n: usize, hashes: &[(u64, B256
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 4 {
bail!("usage: igneum-prove-export <seq.json> <block number> <out.json> [--source text]");
bail!("usage: igneum-prove-export <seq.json> <block number> <out.json> [--source text] [--budget pgas]");
}
let export: Value = serde_json::from_str(&std::fs::read_to_string(&args[1])?)?;
let want: u64 = args[2].parse()?;
let out_path = &args[3];
let source = args.iter().position(|a| a == "--source").and_then(|i| args.get(i + 1)).cloned().unwrap_or_else(|| format!("{} (igneum_exportSegments), block {}", args[1], want));
let budget: u64 = args.iter().position(|a| a == "--budget").and_then(|i| args.get(i + 1)).map(|b| b.parse()).transpose()?.unwrap_or(SHARD_PROVING_GAS_BUDGET);
let segments = export["segments"].as_array().context("segments")?.clone();
let registry_code = bytes(&export["registryCode"])?;
@ -120,6 +123,7 @@ fn main() -> Result<()> {
pre_state_root: pre_root,
post_state_root: out.state_root,
receipts_root: out.receipts_root,
tx_commitment: out.tx_commitment,
gas_used: out.gas_used,
pgas_used: out.pgas_used,
executed: out.executed.len() as u32,
@ -140,10 +144,40 @@ fn main() -> Result<()> {
println!(" skipped {h}: {why:?}");
}
for r in &out.executed {
println!(" executed {}: status {} gas {} pgas {} logs {}", r.tx_hash, r.status, r.gas_used, r.pgas_used, r.logs.len());
println!(" executed {}: status {} gas {} pgas {} logs {}{}", r.tx_hash, r.status, r.gas_used, r.pgas_used, r.logs.len(), if r.pgas_aborted { " pgas-aborted" } else { "" });
}
println!(" pre-state root {pre_root}\n post-state root {} (node: {node_root})\n receipts root {}", out.state_root, out.receipts_root);
fixture = Some(Fixture { format: igneum_prove_core::fixture::FORMAT.into(), source: source.clone(), block: f, expected });
println!(" pre-state root {pre_root}\n post-state root {} (node: {node_root})\n receipts root {}\n tx commitment {}", out.state_root, out.receipts_root, out.tx_commitment);
// The shard plan and every shard's witness, checked natively (design 5.1).
let (_, _, shards) = build_shards(&f, budget, Address::ZERO);
println!(" plan: {} shard(s) at S_p = {budget} pgas{}", shards.len(), if budget == SHARD_PROVING_GAS_BUDGET { "" } else { " (TEST CUT below the consensus budget)" });
let mut plan_shards = Vec::with_capacity(shards.len());
for s in &shards {
let (accounts, slots, leaves, hashes) = s.input.witness.stats();
let bytes = bincode::serialize(&s.input)?.len() as u64;
println!(
" shard {}: txs {}..{}, gas {}, pgas {}{}, executed {}, skipped {}, witness {accounts} accounts {slots} slots {leaves} leaves {hashes} hashes, input {bytes} bytes\n pre {} post {}",
s.spec.index, s.spec.tx_start, s.spec.tx_end, s.spec.gas, s.spec.pgas, if s.spec.over_budget { " (ONE TRANSACTION ABOVE THE BUDGET)" } else { "" }, s.output.executed, s.output.skipped, s.spec.pre_root, s.spec.post_root
);
plan_shards.push(ShardExpected {
index: s.spec.index,
tx_start: s.spec.tx_start,
tx_end: s.spec.tx_end,
over_budget: s.spec.over_budget,
pre_root: s.spec.pre_root,
post_root: s.spec.post_root,
receipts_root: s.output.receipts_root,
link_in: s.spec.carry_in.link(),
link_out: s.spec.carry_out.link(),
gas_used: s.spec.gas,
pgas_used: s.spec.pgas,
executed: s.output.executed,
skipped: s.output.skipped,
witness: (accounts as u32, slots as u32, leaves as u32, hashes as u32, bytes),
});
}
let plan = Plan { shard_budget: budget, consensus: budget == SHARD_PROVING_GAS_BUDGET, shards: plan_shards };
fixture = Some(Fixture { format: igneum_prove_core::fixture::FORMAT.into(), source: source.clone(), block: f, plan, expected });
}
}
let final_root = b256(&export["stateRoot"])?;

View file

@ -1,18 +1,22 @@
[package]
name = "igneum-prove-host"
description = "SP1 host: loads a block fixture, runs the guest in execute, core and compressed modes, prints cycles, times and sizes, verifies the proofs; holds the versioned ProofSystem trait"
description = "SP1 host: loads a block fixture, cuts and witnesses its shards, proves one shard (execute, core, compressed) or the whole block (shard proofs plus aggregation), prints cycles, times and sizes with timestamps, verifies every proof; holds the versioned ProofSystem trait"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
igneum-prove-core.workspace = true
igneum-evm-types.workspace = true
sp1-sdk = { workspace = true, features = ["blocking"] }
alloy-primitives.workspace = true
alloy-trie.workspace = true
alloy-rlp.workspace = true
bincode.workspace = true
serde_json.workspace = true
anyhow.workspace = true
hex.workspace = true
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
[build-dependencies]
sp1-build.workspace = true

View file

@ -1,3 +1,4 @@
fn main() {
sp1_build::build_program("../program");
sp1_build::build_program("../aggregator");
}

View file

@ -1,139 +1,408 @@
//! igneum-prove-host: proves one Igneum chain block fixture with SP1.
//! igneum-prove-host: proves one Igneum chain block fixture with SP1, shard by shard (design 5.1 and 5.3).
//!
//! Usage: igneum-prove-host <fixture.json> [--mode native|execute|core|compressed|all] [--out <results.json>]
//! Usage: igneum-prove-host <fixture.json> [--mode native|execute|shard|block|all] [--shard N]
//! [--prover 0x<payout address>] [--out <results.json>]
//!
//! Modes build on each other: `native` runs the statement on the host and checks the fixture's expected roots;
//! `execute` runs the guest in SP1's executor for the cycle count (no proof); `core` makes and verifies a core
//! (STARK, many shards) proof; `compressed` makes and verifies the recursion-compressed proof; `all` runs them
//! all. The prover comes from `SP1_PROVER` (cpu, the default; cuda, Linux x86_64 with the `cuda` feature; mock).
//! Every run prints one `RESULT` line per mode (cycles, seconds, bytes) that PROVE-BLOCK and the bench log read.
//! Modes build on each other. `native` cuts the block into shards at `S_p`, builds every shard's witness, runs
//! every shard statement natively, checks that the shards chain (roots, links) and sum (gas, pgas) to the whole
//! block, aggregates them natively, and shows that a tampered witness fails. `execute` runs every shard guest
//! and the aggregator guest in SP1's executor for the cycle counts (no proof). `shard` proves one shard (the
//! `--shard` index, default 0) in all three SP1 stages: execute, core, compressed, each verified. `block` makes
//! the compressed proof of every shard and the aggregated block proof, verified. `all` is shard then block.
//! The prover comes from `SP1_PROVER` (cpu, the default; cuda, Linux x86_64 with the `cuda` feature; mock).
//! Every stage prints a `STAGE ... start` line and one `RESULT` line with a UTC timestamp, so a silent gap
//! between stages is visible (ledger P20). The host holds a Tokio runtime for the whole run and drops the
//! proof system inside it, so the CUDA client's destructor finds a runtime (ledger P20).
mod proof_system;
use alloy_primitives::{Address, B256};
use anyhow::{anyhow, bail, Context, Result};
use igneum_prove_core::executor::prove_statement;
use igneum_prove_core::{Fixture, ProveOutput};
use proof_system::{ProofSystem, SegmentClaim, ShardWitness, Sp1ProofSystem, StubProofSystem};
use igneum_prove_core::agg::{self, AggInput, BlockOutput};
use igneum_prove_core::config::SHARD_PROVING_GAS_BUDGET;
use igneum_prove_core::shard::{build_shards, shard_statement, BuiltShard, ShardInput, ShardOutput};
use igneum_prove_core::Fixture;
use proof_system::{ProofSystem, SegmentClaim, ShardWitness, Sp1ProofSystem, Sp1ShardProof, StubProofSystem};
use sp1_sdk::{include_elf, Elf};
use std::time::Instant;
use std::time::{Duration, Instant};
const ELF: Elf = include_elf!("igneum-prove-program");
const SHARD_ELF: Elf = include_elf!("igneum-prove-program");
const AGG_ELF: Elf = include_elf!("igneum-prove-aggregator");
fn now() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let (h, m, s) = ((secs / 3600) % 24, (secs / 60) % 60, secs % 60);
let days = secs / 86400;
// Civil date from days since the epoch (Howard Hinnant's algorithm).
let z = days as i64 + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
fn stage(name: &str) {
println!("STAGE {name} start {}", now());
}
fn main() -> Result<()> {
// Ledger P20: the SP1 CUDA client spawns its shutdown on the current Tokio runtime from `Drop`; hold one for
// the whole run so the drop at the end finds it, and give the spawned tasks time to finish.
let runtime = tokio::runtime::Runtime::new()?;
let guard = runtime.enter();
let result = run();
drop(guard);
runtime.shutdown_timeout(Duration::from_secs(10));
result
}
fn run() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host <fixture.json> [--mode native|execute|core|compressed|all] [--out results.json]")?;
let mode = args.iter().position(|a| a == "--mode").and_then(|i| args.get(i + 1)).map(String::as_str).unwrap_or("all");
let out_path = args.iter().position(|a| a == "--out").and_then(|i| args.get(i + 1)).cloned();
let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host <fixture.json> [--mode native|execute|shard|block|all] [--shard N] [--prover 0x..] [--out results.json]")?;
let arg = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
let mode = arg("--mode").unwrap_or_else(|| "all".into());
let shard_index: usize = arg("--shard").map(|s| s.parse()).transpose()?.unwrap_or(0);
let prover: Address = arg("--prover").map(|s| s.parse()).transpose()?.unwrap_or_else(|| Address::from_slice(&[0x19; 20]));
let out_path = arg("--out");
let fixture: Fixture = serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("read {path}"))?)?;
if fixture.format != igneum_prove_core::fixture::FORMAT {
bail!("fixture format {} is not {}", fixture.format, igneum_prove_core::fixture::FORMAT);
bail!("fixture format {} is not {} (regenerate with igneum-prove-export)", fixture.format, igneum_prove_core::fixture::FORMAT);
}
let block = &fixture.block;
let txs: usize = block.blocks.iter().map(|b| b.txs.len()).sum();
let prover = std::env::var("SP1_PROVER").unwrap_or_else(|_| "cpu".into());
println!("fixture {path}: chain {} block {} ({}), {txs} transactions in {} including blocks, {} accounts in the pre-state; SP1_PROVER={prover}", block.chain_id, block.env.number, block.env.hash, block.blocks.len(), block.pre_state.len());
let prover_kind = std::env::var("SP1_PROVER").unwrap_or_else(|_| "cpu".into());
println!(
"fixture {path}: chain {} block {} ({}), {txs} transactions in {} including blocks, {} accounts in the pre-state, plan {} shard(s) at S_p = {} pgas{}; SP1_PROVER={prover_kind}; prover payout {prover}; {}",
block.chain_id,
block.env.number,
block.env.hash,
block.blocks.len(),
block.pre_state.len(),
fixture.plan.shards.len(),
fixture.plan.shard_budget,
if fixture.plan.consensus { "" } else { " (TEST CUT below the consensus budget)" },
now()
);
let mut results = serde_json::Map::new();
results.insert("fixture".into(), path.clone().into());
results.insert("block".into(), block.env.number.into());
results.insert("prover".into(), prover.clone().into());
results.insert("prover".into(), prover_kind.clone().into());
results.insert("shard_budget".into(), fixture.plan.shard_budget.into());
results.insert("consensus_budget".into(), fixture.plan.consensus.into());
results.insert("sp1_crate_version".into(), "6.8.1".into());
results.insert("sp1_circuit_version".into(), sp1_sdk::SP1_CIRCUIT_VERSION.into());
// 1. Native: the statement on the host, against the fixture's expected values.
// 1. Native: the cut, the witnesses, every shard statement, the chain and the sums, against the fixture.
stage("native");
let t = Instant::now();
let native = prove_statement(block);
let (outcome, pre_root, shards) = build_shards(block, fixture.plan.shard_budget, prover);
let native_s = t.elapsed().as_secs_f64();
let e = &fixture.expected;
let same = native.pre_state_root == e.pre_state_root && native.post_state_root == e.post_state_root && native.receipts_root == e.receipts_root && native.gas_used == e.gas_used && native.pgas_used == e.pgas_used;
println!("RESULT native: {:.4} s, pre {} post {} receipts {} gas {} pgas {} executed {} skipped {}: {}", native_s, native.pre_state_root, native.post_state_root, native.receipts_root, native.gas_used, native.pgas_used, native.executed, native.skipped, if same { "MATCHES the fixture's expected values" } else { "DIFFERS from the fixture's expected values" });
let same = pre_root == e.pre_state_root && outcome.state_root == e.post_state_root && outcome.receipts_root == e.receipts_root && outcome.tx_commitment == e.tx_commitment && outcome.gas_used == e.gas_used && outcome.pgas_used == e.pgas_used;
println!(
"RESULT native: {:.4} s, pre {} post {} receipts {} gas {} pgas {} executed {} skipped {}: {} at {}",
native_s,
pre_root,
outcome.state_root,
outcome.receipts_root,
outcome.gas_used,
outcome.pgas_used,
outcome.executed.len(),
outcome.skipped.len(),
if same { "MATCHES the fixture's expected values" } else { "DIFFERS from the fixture's expected values" },
now()
);
if !same {
bail!("native execution differs from the fixture; regenerate the fixture with igneum-prove-export");
}
if e.node_state_root != e.post_state_root {
bail!("the fixture's node state root differs from its expected post-state root; the exporter must not have produced this file");
}
if shards.len() != fixture.plan.shards.len() {
bail!("the plan has {} shards here and {} in the fixture", shards.len(), fixture.plan.shards.len());
}
let (mut sum_gas, mut sum_pgas) = (0u64, 0u64);
let mut prev_root = pre_root;
let mut prev_link = igneum_prove_core::Carry::default().link();
for (s, x) in shards.iter().zip(&fixture.plan.shards) {
let o = &s.output;
let (accounts, slots, leaves, hashes) = s.input.witness.stats();
let bytes = bincode::serialize(&s.input)?.len();
if o.pre_root != x.pre_root || o.post_root != x.post_root || o.receipts_root != x.receipts_root || o.link_in != x.link_in || o.link_out != x.link_out || o.gas_used != x.gas_used || o.pgas_used != x.pgas_used {
bail!("shard {}: the native statement differs from the fixture's plan", o.shard_index);
}
if o.pre_root != prev_root || o.link_in != prev_link {
bail!("shard {}: does not continue the previous shard", o.shard_index);
}
prev_root = o.post_root;
prev_link = o.link_out;
sum_gas += o.gas_used;
sum_pgas += o.pgas_used;
println!(
"RESULT shard {} native: txs {}..{} ({} executed, {} skipped), gas {}, pgas {}{}, witness {accounts} accounts {slots} slots {leaves} leaves {hashes} hashes, input {bytes} bytes, pre {} post {}",
o.shard_index,
s.spec.tx_start,
s.spec.tx_end,
o.executed,
o.skipped,
o.gas_used,
o.pgas_used,
if s.spec.over_budget { " (ONE TRANSACTION ABOVE S_p)" } else { "" },
o.pre_root,
o.post_root
);
}
if prev_root != outcome.state_root || sum_gas != outcome.gas_used || sum_pgas != outcome.pgas_used {
bail!("the shards do not chain to the block's post-root or do not sum to its gas and pgas");
}
let native_block = agg::aggregate(&AggInput { shard_vk: [0; 8], shards: shards.iter().map(|s| s.output.to_bytes()).collect(), parent_hash: block.env.parent_hash, prev: None }, &mut |_, _| {});
if native_block.post_root != outcome.state_root || native_block.tx_commitment != outcome.tx_commitment || native_block.gas_used != outcome.gas_used {
bail!("the native aggregation does not reproduce the block");
}
println!("RESULT plan: {} shard(s) chain from {} to {} and sum to gas {} pgas {}: the cut equals the block; native aggregation receipts {} provers {}", shards.len(), pre_root, outcome.state_root, sum_gas, sum_pgas, native_block.receipts, native_block.provers);
results.insert("native_seconds".into(), native_s.into());
let claim = SegmentClaim::from_output(&native);
results.insert("shards".into(), (shards.len() as u64).into());
results.insert("shard_pgas".into(), shards.iter().map(|s| serde_json::Value::from(s.output.pgas_used)).collect::<Vec<_>>().into());
results.insert("witness_bytes".into(), shards.iter().map(|s| serde_json::Value::from(bincode::serialize(&s.input).map(|b| b.len() as u64).unwrap_or(0))).collect::<Vec<_>>().into());
// 2. The devnet stub behind the same trait (design 5.7), so the pipeline shape is exercised too.
// 2. Tampered witnesses must fail: a changed balance moves the pre-root away from the node's; a changed
// storage value breaks the storage root check; a dropped account makes the execution unprovable.
tamper_checks(&shards[0])?;
// 3. The devnet stub behind the same trait (design 5.7), so the pipeline shape is exercised too.
let stub = StubProofSystem::new([7u8; 32]);
let witness = ShardWitness { block: block.clone(), shard_index: 0, prover: alloy_primitives::B256::ZERO };
let stub_proof = stub.prove_shard(&witness)?;
let stub_seg = stub.aggregate(None, &[stub_proof])?;
let stub_shards: Vec<_> = shards.iter().map(|s| stub.prove_shard(&ShardWitness { input: s.input.clone() })).collect::<Result<_>>()?;
let stub_seg = stub.aggregate(None, &stub_shards)?;
let claim = SegmentClaim { segment: block.env.hash, pre_root, post_root: outcome.state_root, receipts: native_block.receipts };
println!("RESULT stub: ProofSystem v{} program {} verify_segment {}", StubProofSystem::VERSION, stub.program_id(), stub.verify_segment(&stub_seg, &claim));
if mode == "native" {
return finish(results, out_path);
}
// 3. SP1, version 1 behind the trait.
// 4. SP1, version 1 behind the trait: two programs.
stage("setup");
let t = Instant::now();
let sp1 = Sp1ProofSystem::from_env(ELF)?;
println!("RESULT setup: {:.2} s, ProofSystem v{} program id (vk hash) {}", t.elapsed().as_secs_f64(), Sp1ProofSystem::VERSION, sp1.program_id());
results.insert("setup_seconds".into(), t.elapsed().as_secs_f64().into());
results.insert("program_id".into(), sp1.program_id().to_string().into());
let sp1 = Sp1ProofSystem::from_env(SHARD_ELF, AGG_ELF)?;
let setup_s = t.elapsed().as_secs_f64();
println!("RESULT setup: {:.2} s (shard {:.2} s, aggregator {:.2} s), ProofSystem v{} shard program id {} aggregator id {} at {}", setup_s, sp1.last_timing("setup-shard").map(|d| d.as_secs_f64()).unwrap_or(0.0), sp1.last_timing("setup-aggregator").map(|d| d.as_secs_f64()).unwrap_or(0.0), Sp1ProofSystem::VERSION, sp1.program_id(), sp1.aggregator_id(), now());
results.insert("setup_seconds".into(), setup_s.into());
results.insert("shard_program_id".into(), sp1.program_id().to_string().into());
results.insert("aggregator_id".into(), sp1.aggregator_id().to_string().into());
if matches!(mode, "execute" | "all" | "core" | "compressed") {
let (out, report, dt) = sp1.execute(block)?;
check_output(&out, &native)?;
let cycles = report.total_instruction_count();
let gas = report.gas().unwrap_or(0);
println!("RESULT execute: {} cycles, prover gas {}, {:.2} s, {:.0} cycles per EVM gas, syscalls {}", cycles, gas, dt.as_secs_f64(), cycles as f64 / native.gas_used.max(1) as f64, report.syscall_counts.values().sum::<u64>());
results.insert("cycles".into(), cycles.into());
results.insert("prover_gas".into(), gas.into());
results.insert("execute_seconds".into(), dt.as_secs_f64().into());
}
if mode == "execute" {
return finish(results, out_path);
}
if matches!(mode, "core" | "all") {
let shard = sp1.prove_shard(&witness)?;
let core_proof = shard.proof.clone().expect("prove_shard returns a core proof");
let (prove_s, verify_s, bytes) = report_proof(&sp1, "core", &core_proof, |p| sp1.verify_shard(p, &claim), &shard)?;
results.insert("core_prove_seconds".into(), prove_s.into());
results.insert("core_verify_seconds".into(), verify_s.into());
results.insert("core_proof_bytes".into(), bytes.into());
if let Some(dir) = out_path.as_deref().and_then(|p| std::path::Path::new(p).parent()) {
let _ = core_proof.save(dir.join(format!("block-{}-core.bin", block.env.number)));
}
}
if matches!(mode, "compressed" | "all") {
// v0: the segment proof is the compressed proof of the single shard; the witness is re-proven in
// compressed mode (SP1 compresses by recursion over the core shards it generates itself).
let shard = proof_system::Sp1ShardProof { proof: None, witness_input: igneum_prove_core::ProveInput { block: block.clone() } };
let seg = sp1.aggregate(None, &[shard])?;
let (prove_s, verify_s, bytes) = report_proof(&sp1, "compressed", &seg.proof, |_| sp1.verify_segment(&seg, &claim), &seg)?;
results.insert("compressed_prove_seconds".into(), prove_s.into());
results.insert("compressed_verify_seconds".into(), verify_s.into());
results.insert("compressed_proof_bytes".into(), bytes.into());
if let Some(dir) = out_path.as_deref().and_then(|p| std::path::Path::new(p).parent()) {
let _ = seg.proof.save(dir.join(format!("block-{}-compressed.bin", block.env.number)));
}
}
let r = match mode.as_str() {
"execute" => run_execute(&sp1, &shards, block.env.parent_hash, &mut results),
"shard" => run_shard(&sp1, &shards, shard_index, out_path.as_deref(), &mut results),
"block" => run_block(&sp1, &shards, &claim, out_path.as_deref(), &mut results),
"all" => run_shard(&sp1, &shards, shard_index, out_path.as_deref(), &mut results).and_then(|_| run_block(&sp1, &shards, &claim, out_path.as_deref(), &mut results)),
other => Err(anyhow!("unknown mode {other}")),
};
// The proof system (and with it the CUDA client) is dropped here, inside the runtime guard of `main`.
drop(sp1);
r?;
finish(results, out_path)
}
fn check_output(out: &ProveOutput, native: &ProveOutput) -> Result<()> {
if out != native {
bail!("the guest's public values differ from the native run:\n guest {out:?}\n native {native:?}");
fn tamper_checks(shard: &BuiltShard) -> Result<()> {
let quiet = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcome = |name: &str, input: ShardInput, expect_root: B256| -> Result<()> {
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| shard_statement(&input)));
let verdict = match &r {
Err(_) => "REJECTED (the statement cannot be proven)".to_string(),
Ok(o) if o.pre_root != expect_root => format!("REJECTED (pre-root {} is not the node's {})", o.pre_root, expect_root),
Ok(_) => "ACCEPTED".to_string(),
};
println!("RESULT tamper {name}: {verdict}");
if verdict.starts_with("ACCEPTED") {
bail!("a tampered witness ({name}) was accepted");
}
Ok(())
};
let expect = shard.output.pre_root;
// (a) A balance in an account leaf, re-encoded so the leaf still decodes: the witness is consistent with
// itself and the pre-root it yields is simply not the node's.
let mut a = shard.input.clone();
let addresses: Vec<B256> = a.witness.accounts.iter().map(|acc| alloy_primitives::keccak256(acc.address)).collect();
if let Some((_, v)) = a.witness.trie.leaves.iter_mut().find(|(k, _)| addresses.contains(k)) {
let mut acc = <alloy_trie::TrieAccount as alloy_rlp::Decodable>::decode(&mut v.as_ref()).expect("leaf decodes");
acc.balance += alloy_primitives::U256::from(1);
*v = alloy_rlp::encode(acc).into();
}
outcome("account balance", a, expect)?;
// (b) A storage value, where a shard touches storage; else a code byte.
let mut b = shard.input.clone();
let mut touched = false;
for acc in b.witness.accounts.iter_mut() {
if let Some((_, v)) = acc.storage.leaves.first_mut() {
let mut bytes = v.to_vec();
let last = bytes.len() - 1;
bytes[last] ^= 0x01;
*v = bytes.into();
touched = true;
break;
}
}
if !touched {
for acc in b.witness.accounts.iter_mut() {
if !acc.code.is_empty() {
let mut bytes = acc.code.to_vec();
bytes[0] ^= 0x01;
acc.code = bytes.into();
touched = true;
break;
}
}
}
if touched {
outcome("storage or code", b, expect)?;
}
// (c) An account dropped from the witness: its leaf stays in the trie (the pre-root still matches) but the
// execution reads it and the strict database refuses.
let mut c = shard.input.clone();
let victim = c.txs.first().and_then(|t| igneum_evm_types::decode_and_check(&t.raw, c.chain_id).ok()).map(|tx| tx.sender);
if let Some(sender) = victim {
c.witness.accounts.retain(|acc| acc.address != sender);
outcome("dropped account", c, expect)?;
}
std::panic::set_hook(quiet);
Ok(())
}
fn run_execute(sp1: &Sp1ProofSystem, shards: &[BuiltShard], parent_hash: B256, results: &mut serde_json::Map<String, serde_json::Value>) -> Result<()> {
let mut cycles_all = Vec::new();
let mut outputs = Vec::new();
for s in shards {
stage(&format!("execute shard {}", s.output.shard_index));
let (out, report, dt) = sp1.execute_shard(&s.input)?;
check_shard_output(&out, &s.output)?;
let cycles = report.total_instruction_count();
println!("RESULT execute shard {}: {} cycles, prover gas {}, {:.2} s, {:.0} cycles per EVM gas, {:.0} cycles per pgas, syscalls {} at {}", out.shard_index, cycles, report.gas().unwrap_or(0), dt.as_secs_f64(), cycles as f64 / out.gas_used.max(1) as f64, cycles as f64 / out.pgas_used.max(1) as f64, report.syscall_counts.values().sum::<u64>(), now());
cycles_all.push(serde_json::Value::from(cycles));
outputs.push(out);
}
stage("execute aggregator");
let (out, report, dt) = sp1.execute_aggregator(&outputs, parent_hash)?;
let cycles = report.total_instruction_count();
println!("RESULT execute aggregator: {} cycles over {} shards (deferred proof verification off), {:.2} s, post-root {} at {}", cycles, out.shard_count, dt.as_secs_f64(), out.post_root, now());
results.insert("shard_cycles".into(), cycles_all.into());
results.insert("aggregator_cycles".into(), cycles.into());
Ok(())
}
fn run_shard(sp1: &Sp1ProofSystem, shards: &[BuiltShard], index: usize, out_dir: Option<&str>, results: &mut serde_json::Map<String, serde_json::Value>) -> Result<()> {
let s = shards.get(index).ok_or_else(|| anyhow!("shard {index} is not in the plan ({} shards)", shards.len()))?;
let i = s.output.shard_index;
results.insert("shard_index".into(), i.into());
stage(&format!("execute shard {i}"));
let (out, report, dt) = sp1.execute_shard(&s.input)?;
check_shard_output(&out, &s.output)?;
let cycles = report.total_instruction_count();
println!("RESULT execute shard {i}: {} cycles, prover gas {}, {:.2} s, {:.0} cycles per EVM gas, {:.0} cycles per pgas, syscalls {} at {}", cycles, report.gas().unwrap_or(0), dt.as_secs_f64(), cycles as f64 / out.gas_used.max(1) as f64, cycles as f64 / out.pgas_used.max(1) as f64, report.syscall_counts.values().sum::<u64>(), now());
results.insert("cycles".into(), cycles.into());
results.insert("prover_gas".into(), report.gas().unwrap_or(0).into());
results.insert("execute_seconds".into(), dt.as_secs_f64().into());
stage(&format!("core shard {i}"));
let (core, dt) = sp1.prove_shard_core(&s.input)?;
let (ok, vdt) = sp1.verify_shard(&core, &s.output);
let bytes = bincode::serialize(&core)?.len();
println!("RESULT core shard {i}: prove {:.1} s, proof {} bytes, verify {:.3} s, {}; post-root {} at {}", dt.as_secs_f64(), bytes, vdt.as_secs_f64(), if ok { "VERIFIED" } else { "VERIFY FAILED" }, s.output.post_root, now());
if !ok {
bail!("core proof of shard {i} did not verify");
}
results.insert("core_prove_seconds".into(), dt.as_secs_f64().into());
results.insert("core_verify_seconds".into(), vdt.as_secs_f64().into());
results.insert("core_proof_bytes".into(), bytes.into());
if let Some(dir) = out_dir.and_then(|p| std::path::Path::new(p).parent()) {
let _ = core.save(dir.join(format!("block-{}-shard-{i}-core.bin", s.input.env.number)));
}
stage(&format!("compressed shard {i}"));
let proof = sp1.prove_shard(&ShardWitness { input: s.input.clone() })?;
let dt = sp1.last_timing("compressed").unwrap_or_default();
let (ok, vdt) = sp1.verify_shard(&proof.proof, &s.output);
let bytes = bincode::serialize(&proof.proof)?.len();
println!("RESULT compressed shard {i}: prove {:.1} s, proof {} bytes, verify {:.3} s, {}; post-root {} prover {} at {}", dt.as_secs_f64(), bytes, vdt.as_secs_f64(), if ok { "VERIFIED" } else { "VERIFY FAILED" }, proof.output.post_root, proof.output.prover, now());
if !ok {
bail!("compressed proof of shard {i} did not verify");
}
results.insert("compressed_prove_seconds".into(), dt.as_secs_f64().into());
results.insert("compressed_verify_seconds".into(), vdt.as_secs_f64().into());
results.insert("compressed_proof_bytes".into(), bytes.into());
if let Some(dir) = out_dir.and_then(|p| std::path::Path::new(p).parent()) {
let _ = proof.proof.save(dir.join(format!("block-{}-shard-{i}-compressed.bin", s.input.env.number)));
}
Ok(())
}
/// Prints one RESULT line for a proof: prove time (from the system's timing log), proof size (bincode, as SP1
/// saves it) and verify time. Returns (prove seconds, verify seconds, bytes).
fn report_proof<T>(sp1: &Sp1ProofSystem, what: &str, proof: &sp1_sdk::SP1ProofWithPublicValues, verify: impl Fn(&T) -> bool, p: &T) -> Result<(f64, f64, usize)> {
let prove_s = sp1.timings.lock().unwrap().iter().rev().find(|(k, _)| k == what).map(|(_, d)| d.as_secs_f64()).ok_or_else(|| anyhow!("no timing for {what}"))?;
let bytes = bincode::serialize(proof)?.len();
let t = Instant::now();
let ok = verify(p);
let verify_s = t.elapsed().as_secs_f64();
let out = ProveOutput::from_bytes(proof.public_values.as_slice()).ok_or_else(|| anyhow!("public values of the {what} proof have the wrong length"))?;
println!("RESULT {what}: prove {:.1} s, proof {} bytes, verify {:.3} s, {}; post-state root {} receipts root {}", prove_s, bytes, verify_s, if ok { "VERIFIED" } else { "VERIFY FAILED" }, out.post_state_root, out.receipts_root);
if !ok {
bail!("{what} proof did not verify");
fn run_block(sp1: &Sp1ProofSystem, shards: &[BuiltShard], claim: &SegmentClaim, out_dir: Option<&str>, results: &mut serde_json::Map<String, serde_json::Value>) -> Result<()> {
let block_t = Instant::now();
let mut proofs: Vec<Sp1ShardProof> = Vec::with_capacity(shards.len());
let mut shard_seconds = Vec::new();
for s in shards {
let i = s.output.shard_index;
stage(&format!("compressed shard {i} (block mode)"));
let p = sp1.prove_shard(&ShardWitness { input: s.input.clone() })?;
let dt = sp1.last_timing("compressed").unwrap_or_default();
let (ok, vdt) = sp1.verify_shard(&p.proof, &s.output);
println!("RESULT block shard {i}: compressed prove {:.1} s, proof {} bytes, verify {:.3} s, {}, pgas {} at {}", dt.as_secs_f64(), bincode::serialize(&p.proof)?.len(), vdt.as_secs_f64(), if ok { "VERIFIED" } else { "VERIFY FAILED" }, p.output.pgas_used, now());
if !ok {
bail!("compressed proof of shard {i} did not verify");
}
shard_seconds.push(serde_json::Value::from(dt.as_secs_f64()));
proofs.push(p);
}
Ok((prove_s, verify_s, bytes))
stage(&format!("aggregate {} shards", proofs.len()));
let seg = sp1.aggregate(None, &proofs)?;
let dt = sp1.last_timing("aggregate").unwrap_or_default();
let ok = sp1.verify_segment(&seg, claim);
let vdt = sp1.last_timing("verify-block").unwrap_or_default();
let bytes = bincode::serialize(&seg.proof)?.len();
let total = block_t.elapsed().as_secs_f64();
println!(
"RESULT block: {} shards, aggregate prove {:.1} s, proof {} bytes, verify {:.3} s, {}; block {} pre {} post {} receipts {} tx commitment {} gas {} pgas {} provers {}; shard proofs plus aggregation {:.1} s at {}",
seg.output.shard_count,
dt.as_secs_f64(),
bytes,
vdt.as_secs_f64(),
if ok { "VERIFIED (shard program id and claim checked)" } else { "VERIFY FAILED" },
seg.output.number,
seg.output.pre_root,
seg.output.post_root,
seg.output.receipts,
seg.output.tx_commitment,
seg.output.gas_used,
seg.output.pgas_used,
seg.output.provers,
total,
now()
);
if !ok {
bail!("the block proof did not verify");
}
results.insert("block_shard_prove_seconds".into(), shard_seconds.into());
results.insert("aggregate_prove_seconds".into(), dt.as_secs_f64().into());
results.insert("aggregate_verify_seconds".into(), vdt.as_secs_f64().into());
results.insert("block_proof_bytes".into(), bytes.into());
results.insert("block_total_seconds".into(), total.into());
if let Some(dir) = out_dir.and_then(|p| std::path::Path::new(p).parent()) {
let _ = seg.proof.save(dir.join(format!("block-{}-aggregated.bin", seg.output.number)));
}
let _ = BlockOutput::LEN;
Ok(())
}
fn check_shard_output(out: &ShardOutput, native: &ShardOutput) -> Result<()> {
if out != native {
bail!("the guest's public values differ from the native run:\n guest {out:?}\n native {native:?}");
}
Ok(())
}
fn finish(results: serde_json::Map<String, serde_json::Value>, out_path: Option<String>) -> Result<()> {

View file

@ -1,19 +1,20 @@
//! The versioned prover trait of `docs/design/execution-layer.md` section 5.6, with the two implementations the
//! design names: the devnet stub (5.7, a signed claim) and version 1, SP1 (Hypercube class, hash-based).
//!
//! v0 scope. A shard is one whole chain block (the shard planner of 5.1 is not here yet); `aggregate` over one
//! shard is SP1's compress stage on that shard's witness (recursion over several shard proofs is the next step);
//! `wrap` (Groth16 or Plonk over bn254 for the bridge verifier and light clients, ledger P3) is not run in v0
//! and returns an error. Ledger P12 (the prover's payout key inside the shard statement) is also not in v0:
//! `ShardWitness.prover` is carried but not committed by the guest yet.
//! Devnet v4 scope. A shard is a planned range of a segment with its witness (design 5.1); `prove_shard` makes
//! the compressed shard proof the aggregator can verify (the core proof is made separately for the
//! measurement); `aggregate` proves the aggregator guest over the shard proofs by recursion (design 5.3), with
//! the previous segment's proof when given (the chain rule); `wrap` (Groth16 or Plonk over bn254, ledger P3)
//! is not run and returns an error. The prover's payout address is in every shard statement (ledger P12).
#![allow(dead_code)] // wrap, verify_wrapped, pgas_table, shard_index and prover are the trait surface of design 5.6, unused in v0
#![allow(dead_code)] // wrap, verify_wrapped and pgas_table are the trait surface of design 5.6, unused here
use alloy_primitives::{keccak256, B256};
use anyhow::{anyhow, Result};
use igneum_prove_core::{BlockFixture, ProveInput, ProveOutput};
use igneum_prove_core::agg::{self, AggInput, BlockOutput, PrevLink};
use igneum_prove_core::shard::{shard_statement, ShardInput, ShardOutput};
use sp1_sdk::blocking::{Prover, ProveRequest, ProverClient, SP1Stdin};
use sp1_sdk::{Elf, HashableKey, ProvingKey, SP1ProofWithPublicValues, SP1VerifyingKey};
use sp1_sdk::{Elf, HashableKey, ProvingKey, SP1Proof, SP1ProofWithPublicValues, SP1VerifyingKey};
use std::time::{Duration, Instant};
/// The prototype pgas table identity (design 4.2, the table itself lives in `igneum_prove_core::pgas`).
@ -23,17 +24,13 @@ pub struct PgasTable {
pub version: u16,
}
/// What a shard proof is about: the segment's block environment and ordered transactions plus the pre-state
/// the shard touches (v0: the whole in-memory state). `prover` is the payout key of design 5.4 (not yet in the
/// statement, ledger P12).
/// What a shard proof is about (design 5.1): the shard's input, witness and prover included.
#[derive(Clone, Debug)]
pub struct ShardWitness {
pub block: BlockFixture,
pub shard_index: u32,
pub prover: B256,
pub input: ShardInput,
}
/// The claim a proof record makes (design 5.4): the segment, its pre- and post-roots and receipts root.
/// The claim a proof record makes (design 5.4): the segment, its pre- and post-roots and receipts commitment.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SegmentClaim {
pub segment: B256,
@ -43,8 +40,8 @@ pub struct SegmentClaim {
}
impl SegmentClaim {
pub fn from_output(o: &ProveOutput) -> Self {
Self { segment: o.block_hash, pre_root: o.pre_state_root, post_root: o.post_state_root, receipts: o.receipts_root }
pub fn from_block(o: &BlockOutput) -> Self {
Self { segment: o.block_hash, pre_root: o.pre_root, post_root: o.post_root, receipts: o.receipts }
}
pub fn digest(&self) -> B256 {
let mut buf = Vec::with_capacity(128);
@ -61,7 +58,7 @@ pub trait ProofSystem: Send + Sync {
type ShardProof;
type SegmentProof;
type WrappedProof;
/// Hash of the executor guest (SP1: the verifying key hash; stub: a fixed tag).
/// Hash of the executor guest (SP1: the shard program's verifying key hash; stub: a fixed tag).
fn program_id(&self) -> B256;
fn prove_shard(&self, w: &ShardWitness) -> Result<Self::ShardProof>;
fn aggregate(&self, prev: Option<&Self::SegmentProof>, shards: &[Self::ShardProof]) -> Result<Self::SegmentProof>;
@ -72,14 +69,21 @@ pub trait ProofSystem: Send + Sync {
}
// ---------------------------------------------------------------------------------------------------------------
// Version 0: the devnet stub of design 5.7. `prove_shard` executes natively and signs the claim with a devnet
// key (keccak MAC); `verify_segment` checks the MAC. Never a mainnet version.
// Version 0: the devnet stub of design 5.7. `prove_shard` executes natively and signs the shard output with a
// devnet key (keccak MAC); `aggregate` chains the shard outputs natively; `verify_segment` checks the MAC.
// Never a mainnet version.
pub struct StubProofSystem {
pub key: [u8; 32],
table: PgasTable,
}
#[derive(Clone, Debug)]
pub struct StubShardProof {
pub output: ShardOutput,
pub mac: B256,
}
#[derive(Clone, Debug)]
pub struct StubProof {
pub claim: SegmentClaim,
@ -88,38 +92,46 @@ pub struct StubProof {
impl StubProofSystem {
pub fn new(key: [u8; 32]) -> Self {
Self { key, table: PgasTable { name: "prototype-fb33069", version: 0 } }
Self { key, table: PgasTable { name: "prototype-b7fca5a0", version: 0 } }
}
fn mac(&self, claim: &SegmentClaim) -> B256 {
fn mac(&self, digest: B256) -> B256 {
let mut buf = Vec::with_capacity(64);
buf.extend_from_slice(&self.key);
buf.extend_from_slice(claim.digest().as_slice());
buf.extend_from_slice(digest.as_slice());
keccak256(buf)
}
}
impl ProofSystem for StubProofSystem {
const VERSION: u16 = 0;
type ShardProof = StubProof;
type ShardProof = StubShardProof;
type SegmentProof = StubProof;
type WrappedProof = StubProof;
fn program_id(&self) -> B256 {
keccak256(b"igneum-proof-system-stub-v0")
}
fn prove_shard(&self, w: &ShardWitness) -> Result<StubProof> {
let out = igneum_prove_core::executor::prove_statement(&w.block);
let claim = SegmentClaim::from_output(&out);
Ok(StubProof { mac: self.mac(&claim), claim })
fn prove_shard(&self, w: &ShardWitness) -> Result<StubShardProof> {
let output = shard_statement(&w.input);
Ok(StubShardProof { mac: self.mac(keccak256(output.to_bytes())), output })
}
fn aggregate(&self, _prev: Option<&StubProof>, shards: &[StubProof]) -> Result<StubProof> {
shards.first().cloned().ok_or_else(|| anyhow!("no shards"))
fn aggregate(&self, _prev: Option<&StubProof>, shards: &[StubShardProof]) -> Result<StubProof> {
for s in shards {
if s.mac != self.mac(keccak256(s.output.to_bytes())) {
return Err(anyhow!("stub shard proof {} has a bad MAC", s.output.shard_index));
}
}
let parent_hash = B256::ZERO;
let input = AggInput { shard_vk: [0; 8], shards: shards.iter().map(|s| s.output.to_bytes()).collect(), parent_hash, prev: None };
let out = agg::aggregate(&input, &mut |_, _| {});
let claim = SegmentClaim::from_block(&out);
Ok(StubProof { mac: self.mac(claim.digest()), claim })
}
fn wrap(&self, p: &StubProof) -> Result<StubProof> {
Ok(p.clone())
}
fn verify_segment(&self, p: &StubProof, claim: &SegmentClaim) -> bool {
&p.claim == claim && p.mac == self.mac(claim)
&p.claim == claim && p.mac == self.mac(claim.digest())
}
fn verify_wrapped(&self, p: &StubProof, claim: &SegmentClaim) -> bool {
self.verify_segment(p, claim)
@ -130,72 +142,118 @@ impl ProofSystem for StubProofSystem {
}
// ---------------------------------------------------------------------------------------------------------------
// Version 1: SP1. Core proof per shard, compressed proof as the segment proof (v0: one shard, so the segment
// proof is the compressed proof of that shard's witness), wrap deferred.
// Version 1: SP1. Compressed proof per shard, the aggregator guest's compressed proof as the segment proof,
// wrap deferred.
pub struct Sp1ProofSystem {
client: sp1_sdk::blocking::EnvProver,
pk: sp1_sdk::blocking::EnvProvingKey,
vk: SP1VerifyingKey,
shard_pk: sp1_sdk::blocking::EnvProvingKey,
shard_vk: SP1VerifyingKey,
agg_pk: sp1_sdk::blocking::EnvProvingKey,
agg_vk: SP1VerifyingKey,
table: PgasTable,
pub timings: std::sync::Mutex<Vec<(String, Duration)>>,
}
pub struct Sp1ShardProof {
/// The core proof; `None` for a witness-only shard handed to `aggregate` when the core stage is skipped (v0).
pub proof: Option<SP1ProofWithPublicValues>,
pub witness_input: ProveInput,
/// The compressed (recursion) proof, what the aggregator verifies.
pub proof: SP1ProofWithPublicValues,
pub output: ShardOutput,
/// The segment's parent chain block (the chain rule binds the previous proof to it).
pub parent_hash: B256,
}
pub struct Sp1SegmentProof {
pub proof: SP1ProofWithPublicValues,
pub output: BlockOutput,
}
pub struct Sp1WrappedProof(pub SP1ProofWithPublicValues);
impl Sp1ProofSystem {
/// Builds the prover from the environment (`SP1_PROVER` = cpu, cuda or mock) and runs the key setup.
pub fn from_env(elf: Elf) -> Result<Self> {
/// Builds the prover from the environment (`SP1_PROVER` = cpu, cuda or mock) and runs both key setups.
pub fn from_env(shard_elf: Elf, agg_elf: Elf) -> Result<Self> {
let client = ProverClient::from_env();
let t = Instant::now();
let pk = client.setup(elf)?;
let vk = pk.verifying_key().clone();
let setup = t.elapsed();
Ok(Self { client, pk, vk, table: PgasTable { name: "prototype-fb33069", version: 1 }, timings: std::sync::Mutex::new(vec![("setup".into(), setup)]) })
let shard_pk = client.setup(shard_elf)?;
let shard_vk = shard_pk.verifying_key().clone();
let setup_shard = t.elapsed();
let t = Instant::now();
let agg_pk = client.setup(agg_elf)?;
let agg_vk = agg_pk.verifying_key().clone();
let setup_agg = t.elapsed();
Ok(Self {
client,
shard_pk,
shard_vk,
agg_pk,
agg_vk,
table: PgasTable { name: "prototype-b7fca5a0", version: 1 },
timings: std::sync::Mutex::new(vec![("setup-shard".into(), setup_shard), ("setup-aggregator".into(), setup_agg)]),
})
}
pub fn vk(&self) -> &SP1VerifyingKey {
&self.vk
pub fn shard_vk_hash(&self) -> [u32; 8] {
self.shard_vk.hash_u32()
}
pub fn agg_vk_hash(&self) -> [u32; 8] {
self.agg_vk.hash_u32()
}
pub fn aggregator_id(&self) -> B256 {
agg::vk_bytes(&self.agg_vk_hash())
}
pub fn stdin_for(block: &BlockFixture) -> Result<SP1Stdin> {
pub fn stdin_for(input: &ShardInput) -> Result<SP1Stdin> {
let mut stdin = SP1Stdin::new();
stdin.write_vec(bincode::serialize(&ProveInput { block: block.clone() })?);
stdin.write_vec(bincode::serialize(input)?);
Ok(stdin)
}
pub fn execute(&self, block: &BlockFixture) -> Result<(ProveOutput, sp1_sdk::ExecutionReport, Duration)> {
let stdin = Self::stdin_for(block)?;
pub fn execute_shard(&self, input: &ShardInput) -> Result<(ShardOutput, sp1_sdk::ExecutionReport, Duration)> {
let stdin = Self::stdin_for(input)?;
let t = Instant::now();
let (pv, report) = self.client.execute(self.pk.elf().clone(), stdin).calculate_gas(true).run().map_err(|e| anyhow!("{e}"))?;
let (pv, report) = self.client.execute(self.shard_pk.elf().clone(), stdin).calculate_gas(true).run().map_err(|e| anyhow!("{e}"))?;
let dt = t.elapsed();
let out = ProveOutput::from_bytes(pv.as_slice()).ok_or_else(|| anyhow!("public values are {} bytes, expected {}", pv.as_slice().len(), ProveOutput::LEN))?;
let out = ShardOutput::from_bytes(pv.as_slice()).ok_or_else(|| anyhow!("public values are {} bytes, expected {}", pv.as_slice().len(), ShardOutput::LEN))?;
Ok((out, report, dt))
}
/// Executes the aggregator over the shards' public values without proofs (deferred verification off):
/// the cycle count of the aggregation statement itself.
pub fn execute_aggregator(&self, shard_outputs: &[ShardOutput], parent_hash: B256) -> Result<(BlockOutput, sp1_sdk::ExecutionReport, Duration)> {
let input = AggInput { shard_vk: self.shard_vk_hash(), shards: shard_outputs.iter().map(|o| o.to_bytes()).collect(), parent_hash, prev: None };
let mut stdin = SP1Stdin::new();
stdin.write_vec(bincode::serialize(&input)?);
let t = Instant::now();
let (pv, report) = self.client.execute(self.agg_pk.elf().clone(), stdin).deferred_proof_verification(false).calculate_gas(true).run().map_err(|e| anyhow!("{e}"))?;
let dt = t.elapsed();
let out = BlockOutput::from_bytes(pv.as_slice()).ok_or_else(|| anyhow!("block public values are {} bytes, expected {}", pv.as_slice().len(), BlockOutput::LEN))?;
Ok((out, report, dt))
}
/// A core (STARK) proof of one shard, for the measurement; not what the aggregator consumes.
pub fn prove_shard_core(&self, input: &ShardInput) -> Result<(SP1ProofWithPublicValues, Duration)> {
let stdin = Self::stdin_for(input)?;
let t = Instant::now();
let proof = self.client.prove(&self.shard_pk, stdin).core().run()?;
let dt = t.elapsed();
self.record("core", dt);
Ok((proof, dt))
}
pub fn verify_shard(&self, proof: &SP1ProofWithPublicValues, expected: &ShardOutput) -> (bool, Duration) {
let t = Instant::now();
let ok = self.client.verify(proof, &self.shard_vk, None).is_ok();
let dt = t.elapsed();
(ok && ShardOutput::from_bytes(proof.public_values.as_slice()).as_ref() == Some(expected), dt)
}
fn record(&self, what: &str, dt: Duration) {
self.timings.lock().unwrap().push((what.to_string(), dt));
}
fn output_of(proof: &SP1ProofWithPublicValues) -> Option<ProveOutput> {
ProveOutput::from_bytes(proof.public_values.as_slice())
}
fn check(&self, proof: &SP1ProofWithPublicValues, claim: &SegmentClaim) -> bool {
let t = Instant::now();
let ok = self.client.verify(proof, &self.vk, None).is_ok();
self.record("verify", t.elapsed());
ok && Self::output_of(proof).map(|o| &SegmentClaim::from_output(&o) == claim).unwrap_or(false)
pub fn last_timing(&self, what: &str) -> Option<Duration> {
self.timings.lock().unwrap().iter().rev().find(|(k, _)| k == what).map(|(_, d)| *d)
}
}
@ -206,48 +264,63 @@ impl ProofSystem for Sp1ProofSystem {
type WrappedProof = Sp1WrappedProof;
fn program_id(&self) -> B256 {
B256::from_slice(&self.vk.bytes32_raw())
agg::vk_bytes(&self.shard_vk_hash())
}
fn prove_shard(&self, w: &ShardWitness) -> Result<Sp1ShardProof> {
let stdin = Self::stdin_for(&w.block)?;
let stdin = Self::stdin_for(&w.input)?;
let t = Instant::now();
let proof = self.client.prove(&self.pk, stdin).core().run()?;
self.record("core", t.elapsed());
Ok(Sp1ShardProof { proof: Some(proof), witness_input: ProveInput { block: w.block.clone() } })
let proof = self.client.prove(&self.shard_pk, stdin).compressed().run()?;
self.record("compressed", t.elapsed());
let output = ShardOutput::from_bytes(proof.public_values.as_slice()).ok_or_else(|| anyhow!("shard public values have the wrong length"))?;
Ok(Sp1ShardProof { proof, output, parent_hash: w.input.env.parent_hash })
}
/// The aggregator guest over the shard proofs (and the previous segment's proof when given), by recursion.
fn aggregate(&self, prev: Option<&Sp1SegmentProof>, shards: &[Sp1ShardProof]) -> Result<Sp1SegmentProof> {
if prev.is_some() {
return Err(anyhow!("v0 has no chain recursion (segment N-1 inside segment N); next step"));
let first = shards.first().ok_or_else(|| anyhow!("no shards"))?;
let mut stdin = SP1Stdin::new();
let input = AggInput {
shard_vk: self.shard_vk_hash(),
shards: shards.iter().map(|s| s.output.to_bytes()).collect(),
parent_hash: first.parent_hash,
prev: prev.map(|p| PrevLink { agg_vk: self.agg_vk_hash(), public_values: p.proof.public_values.to_vec() }),
};
stdin.write_vec(bincode::serialize(&input)?);
for s in shards {
let SP1Proof::Compressed(proof) = s.proof.proof.clone() else { return Err(anyhow!("shard {} is not a compressed proof", s.output.shard_index)) };
stdin.write_proof(*proof, self.shard_vk.vk.clone());
}
if let Some(p) = prev {
let SP1Proof::Compressed(proof) = p.proof.proof.clone() else { return Err(anyhow!("the previous block proof is not a compressed proof")) };
stdin.write_proof(*proof, self.agg_vk.vk.clone());
}
let [shard] = shards else { return Err(anyhow!("v0 aggregates exactly one shard (the whole block); got {}", shards.len())) };
let stdin = Self::stdin_for(&shard.witness_input.block)?;
let t = Instant::now();
let proof = self.client.prove(&self.pk, stdin).compressed().run()?;
self.record("compressed", t.elapsed());
Ok(Sp1SegmentProof { proof })
let proof = self.client.prove(&self.agg_pk, stdin).compressed().run()?;
self.record("aggregate", t.elapsed());
let output = BlockOutput::from_bytes(proof.public_values.as_slice()).ok_or_else(|| anyhow!("block public values have the wrong length"))?;
Ok(Sp1SegmentProof { proof, output })
}
fn wrap(&self, _p: &Sp1SegmentProof) -> Result<Sp1WrappedProof> {
Err(anyhow!("wrap (Groth16 or Plonk over bn254) is not run in v0: it needs SP1's circuit artifacts and is the ledger P3 measurement"))
Err(anyhow!("wrap (Groth16 or Plonk over bn254) is not run: it needs SP1's circuit artifacts and is the ledger P3 measurement"))
}
fn verify_segment(&self, p: &Sp1SegmentProof, claim: &SegmentClaim) -> bool {
self.check(&p.proof, claim)
let t = Instant::now();
let ok = self.client.verify(&p.proof, &self.agg_vk, None).is_ok();
self.record("verify-block", t.elapsed());
let Some(out) = BlockOutput::from_bytes(p.proof.public_values.as_slice()) else { return false };
// The aggregated statement is about the real shard program, and about this aggregator when it chains.
ok && out.shard_vk == self.program_id() && (out.chain_len == 1 || out.agg_vk == self.aggregator_id()) && &SegmentClaim::from_block(&out) == claim
}
fn verify_wrapped(&self, p: &Sp1WrappedProof, claim: &SegmentClaim) -> bool {
self.check(&p.0, claim)
let Some(out) = BlockOutput::from_bytes(p.0.public_values.as_slice()) else { return false };
self.client.verify(&p.0, &self.agg_vk, None).is_ok() && &SegmentClaim::from_block(&out) == claim
}
fn pgas_table(&self) -> &PgasTable {
&self.table
}
}
impl Sp1ProofSystem {
pub fn verify_shard(&self, p: &Sp1ShardProof, claim: &SegmentClaim) -> bool {
p.proof.as_ref().map(|pr| self.check(pr, claim)).unwrap_or(false)
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "igneum-prove-program"
description = "SP1 guest: re-executes one Igneum chain block and commits the post-state root and the receipts root"
description = "SP1 guest: proves one shard of an Igneum chain block (design 5.1): witness checked against the pre-root, the shard executed, the post-root and the prover committed"
version.workspace = true
edition.workspace = true
license.workspace = true

View file

@ -1,16 +1,16 @@
//! The guest. Input: bincode of `ProveInput` (the block fixture). Output (public values): `ProveOutput` in its
//! fixed byte layout. Everything between is `igneum_prove_core::executor::prove_statement`, the same code the
//! host runs natively first.
//! The shard guest. Input: bincode of `ShardInput` (environment, the shard's transactions, the carried-in
//! position, the prover's payout address, the witness). Output (public values): `ShardOutput` in its fixed byte
//! layout. Everything between is `igneum_prove_core::shard::shard_statement`, the same code the host runs
//! natively first.
#![no_main]
sp1_zkvm::entrypoint!(main);
use igneum_prove_core::executor::prove_statement;
use igneum_prove_core::ProveInput;
use igneum_prove_core::shard::{shard_statement, ShardInput};
pub fn main() {
let input = sp1_zkvm::io::read_vec();
let input: ProveInput = bincode::deserialize(&input).expect("ProveInput decodes");
let out = prove_statement(&input.block);
let input: ShardInput = bincode::deserialize(&input).expect("ShardInput decodes");
let out = shard_statement(&input);
sp1_zkvm::io::commit_slice(&out.to_bytes());
}