fin-proof: igneum-fin-core (the carried table, ring, history, certificate step), the fold in the aggregator, host --fin and final-at

The W2 table folded one chain block at a time with the ring witnessed and
the key table inline; the certificate verified against the table the proof
committed at its own checkpoint; the frozen table never expiring in the
proof; the harness tests with real BLS keys, the known-failed case first.
The evm-types path dependency now names vendor/igneum-node (this lane's
clone of release-0.3.18-node). Work in progress: not yet built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 07:45:49 +00:00
parent b0c4d5d222
commit a83fdba796
16 changed files with 1995 additions and 39 deletions

View file

@ -950,6 +950,16 @@ dependencies = [
"wyz",
]
[[package]]
name = "blake2b_simd"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff"
dependencies = [
"arrayvec",
"constant_time_eq",
]
[[package]]
name = "blake3"
version = "1.8.7"
@ -981,6 +991,22 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "bls12_381"
version = "0.8.0"
source = "git+https://github.com/sp1-patches/bls12_381?tag=patch-0.8.0-sp1-6.2.0#9e4e2ae4780d3d69cecbec000f5e814df2392468"
dependencies = [
"cfg-if",
"digest 0.10.7",
"ff",
"group",
"hex",
"pairing",
"rand_core 0.6.4",
"sp1-lib",
"subtle",
]
[[package]]
name = "blst"
version = "0.3.17"
@ -2762,6 +2788,20 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "igneum-fin-core"
version = "0.1.0"
dependencies = [
"bincode",
"blake2b_simd",
"bls12_381",
"blst",
"group",
"hex",
"serde",
"sha2 0.10.9",
]
[[package]]
name = "igneum-prove-aggregator"
version = "0.1.0"
@ -2782,6 +2822,7 @@ dependencies = [
"alloy-rlp",
"alloy-trie",
"igneum-evm-types",
"igneum-fin-core",
"revm",
"serde",
"serde_json",
@ -3778,6 +3819,15 @@ dependencies = [
"serde",
]
[[package]]
name = "pairing"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f"
dependencies = [
"group",
]
[[package]]
name = "parity-scale-codec"
version = "3.7.5"

View file

@ -3,7 +3,7 @@
# commit b7fca5a0, merged into devnet-v4) and SP1 v6.8.1 (24 Sep 2026) use. Change one, re-run the export and the native check.
[workspace]
resolver = "2"
members = ["core", "program", "aggregator", "host", "export"]
members = ["fin", "core", "program", "aggregator", "host", "export"]
[workspace.package]
version = "0.1.0"
@ -12,9 +12,10 @@ license = "ISC"
[workspace.dependencies]
igneum-prove-core = { path = "core" }
igneum-fin-core = { path = "fin" }
# The execution layer's own transaction decoder (alloy only, no kaspa dependency), so the guest and the node
# cannot disagree about what a well-formed transaction is.
igneum-evm-types = { path = "../../vendor/igneum-node-exec/igneum/evm-types" }
igneum-evm-types = { path = "../../vendor/igneum-node/igneum/evm-types" }
revm = { version = "=43.0.3", default-features = false, features = ["std", "serde", "optional_balance_check", "optional_no_base_fee", "optional_block_gas_limit", "optional_eip3607", "optional_priority_fee_check"] }
alloy-primitives = { version = "=1.7.3", default-features = false, features = ["std", "rlp", "serde", "k256"] }
@ -38,3 +39,6 @@ sp1-build = "=6.8.1"
[patch.crates-io]
sha3 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", package = "sha3", tag = "patch-sha3-0.11.0-sp1-6.0.0" }
k256 = { git = "https://github.com/sp1-patches/elliptic-curves", tag = "patch-k256-13.4-sp1-6.2.0" }
# Finality in the proof (7 October 2026): BLS12-381 field operations on the precompiles for the certificate check
# in the aggregator guest; natively the same crate's own arithmetic.
bls12_381 = { git = "https://github.com/sp1-patches/bls12_381", tag = "patch-0.8.0-sp1-6.2.0" }

View file

@ -7,6 +7,7 @@ license.workspace = true
[dependencies]
igneum-evm-types.workspace = true
igneum-fin-core.workspace = true
revm.workspace = true
alloy-primitives.workspace = true
alloy-consensus.workspace = true

View file

@ -5,6 +5,9 @@
use crate::executor::Carry;
use crate::shard::ShardOutput;
use alloy_primitives::{keccak256, B256};
use igneum_fin_core::fold::{fold_block, ChainBlockWitness, FoldWitness};
use igneum_fin_core::ring::{RingLeafWitness, WitnessRing};
use igneum_fin_core::{FinExt, FinParams, FinState};
use serde::{Deserialize, Serialize};
/// A verifying key hash as the guest receives it (SP1's `hash_u32`) and as the public values carry it.
@ -22,6 +25,18 @@ pub struct PrevLink {
pub public_values: Vec<u8>,
}
/// Finality in the proof (docs/design/finality-in-proof.md): what the fold of this chain block takes. The previous
/// state is checked against the previous proof's extension when that proof carries one; else it is the root the
/// attestation starts from (taken as given, `history_first` = this block).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FinInput {
pub params: FinParams,
pub prev_state: FinState,
pub block: ChainBlockWitness,
pub ring: Vec<RingLeafWitness>,
pub witness: FoldWitness,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AggInput {
pub shard_vk: [u32; 8],
@ -29,6 +44,10 @@ pub struct AggInput {
pub shards: Vec<Vec<u8>>,
pub parent_hash: B256,
pub prev: Option<PrevLink>,
/// Absent: the 340-byte statement of proving v1, byte for byte. Present: the statement gains the finality
/// extension (`FinExt`, 164 bytes).
#[serde(default)]
pub fin: Option<FinInput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -54,10 +73,14 @@ pub struct BlockOutput {
pub agg_vk: B256,
/// Blocks attested by this proof: 1, or the previous proof's count plus one.
pub chain_len: u64,
/// The finality extension (docs/design/finality-in-proof.md section 1), from `finality_in_proof_activation_daa`.
pub fin: Option<FinExt>,
}
impl BlockOutput {
pub const LEN: usize = 8 + 8 + 32 + 32 + 4 + 32 * 4 + 8 + 8 + 4 + 4 + 32 * 3 + 8;
/// The length with the finality extension.
pub const LEN2: usize = Self::LEN + FinExt::LEN;
pub fn to_bytes(&self) -> Vec<u8> {
let mut v = Vec::with_capacity(Self::LEN);
@ -78,13 +101,18 @@ impl BlockOutput {
}
v.extend_from_slice(&self.chain_len.to_be_bytes());
debug_assert_eq!(v.len(), Self::LEN);
if let Some(f) = &self.fin {
v.extend_from_slice(&f.to_bytes());
}
v
}
pub fn from_bytes(b: &[u8]) -> Option<Self> {
if b.len() != Self::LEN {
return None;
}
let fin = match b.len() {
Self::LEN => None,
Self::LEN2 => Some(FinExt::from_bytes(&b[Self::LEN..])?),
_ => return None,
};
let u64_at = |i: usize| u64::from_be_bytes(b[i..i + 8].try_into().unwrap());
let u32_at = |i: usize| u32::from_be_bytes(b[i..i + 4].try_into().unwrap());
let b256_at = |i: usize| B256::from_slice(&b[i..i + 32]);
@ -106,6 +134,7 @@ impl BlockOutput {
shard_vk: b256_at(268),
agg_vk: b256_at(300),
chain_len: u64_at(332),
fin,
})
}
}
@ -154,11 +183,13 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
let first = first.unwrap();
let last = last.unwrap();
let shard_vk = vk_bytes(&input.shard_vk);
let mut prev_fin: Option<FinExt> = None;
let (agg_vk, chain_len) = match &input.prev {
None => (B256::ZERO, 1u64),
Some(prev) => {
verify(&prev.agg_vk, &prev.public_values);
let p = BlockOutput::from_bytes(&prev.public_values).expect("previous block public values decode");
prev_fin = p.fin.clone();
let agg_vk = vk_bytes(&prev.agg_vk);
assert_eq!(p.chain_id, first.chain_id);
assert_eq!(p.number + 1, first.number, "the previous proof is of the parent segment");
@ -169,6 +200,25 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
(agg_vk, p.chain_len + 1)
}
};
// finality in the proof: the fold of this chain block, rooted at the previous proof's state or at the witness
let fin = input.fin.as_ref().map(|f| {
let mut state = f.prev_state.clone();
match &prev_fin {
Some(ext) => assert_eq!(&state.extension(), ext, "the finality state is not the one the previous proof committed"),
None => {
// the root: a state the proof did not verify (the node's native compare does, a light client bridges
// to it from the proof it holds); the attestation of this proof chain starts at this block
assert_eq!(state.end_number + 1, first.number, "the root state ends at the parent of this block");
state.history_first = first.number;
}
}
assert_eq!(f.block.number, first.number, "the finality witness is of this chain block");
assert_eq!(f.block.block_hash.as_slice(), first.block_hash.as_slice(), "the finality witness names this chain block");
let mut ring = WitnessRing::new(f.ring.clone());
fold_block(&mut state, &f.params, &f.block, &mut ring, &f.witness, &igneum_fin_core::bls::ZkBls).expect("the finality fold");
assert!(ring.witnesses.is_empty(), "every ring witness consumed");
state.extension()
});
BlockOutput {
chain_id: first.chain_id,
number: first.number,
@ -187,5 +237,6 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
shard_vk,
agg_vk,
chain_len,
fin,
}
}

View file

@ -0,0 +1,24 @@
[package]
name = "igneum-fin-core"
description = "Finality carried inside the segment proof (docs/design/finality-in-proof.md): the W2 weight table as a commitment, its per-block update, the certificate check against the carried table; one code path for the guest, the host and the node's native tracker"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
sha2 = "0.10"
blake2b_simd = { version = "1.0", default-features = false }
# The BLS12-381 verifier of the guest (zkcrypto's crate, SP1's patch puts the field operations on the precompiles).
# Natively it is the slow but exact reference the tests cross-check against blst.
bls12_381 = { version = "0.8", default-features = false, features = ["groups", "pairings", "alloc", "experimental"], optional = true }
group = { version = "0.13", default-features = false, optional = true }
[dev-dependencies]
blst = "0.3"
bincode.workspace = true
hex.workspace = true
[features]
default = ["zk-bls"]
zk-bls = ["dep:bls12_381", "dep:group"]

View file

@ -0,0 +1,54 @@
//! BLS12-381 in the min-pubkey setting the node uses (blst `min_pk`: public keys in G1, signatures in G2,
//! hash-to-curve `BLS12381G2_XMD:SHA-256_SSWU_RO_`). The guest verifies through zkcrypto's `bls12_381` under
//! SP1's patch; the node passes its own blst-backed verifier. Both answer the same question:
//! `e(sum of pubkeys, H(msg)) == e(g1, sig)`, every key in the group and none the identity.
use crate::{PUBKEY_LEN, SIG_LEN};
pub trait Bls {
/// `fast_aggregate_verify`: the aggregate of `pubkeys` signed `msg` under `dst` with `sig`.
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool;
fn verify_one(&self, pubkey: &[u8; PUBKEY_LEN], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
self.verify_aggregate(std::slice::from_ref(pubkey), msg, dst, sig)
}
}
/// zkcrypto's curve: the guest's verifier and the native reference.
#[cfg(feature = "zk-bls")]
pub struct ZkBls;
#[cfg(feature = "zk-bls")]
impl Bls for ZkBls {
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
use bls12_381::hash_to_curve::{ExpandMsgXmd, HashToCurve};
use bls12_381::{multi_miller_loop, G1Affine, G1Projective, G2Affine, G2Prepared, G2Projective, Gt};
use group::prime::PrimeCurveAffine;
use group::{Curve, Group};
if pubkeys.is_empty() {
return false;
}
let mut agg = G1Projective::identity();
for pk in pubkeys {
let p = G1Affine::from_compressed(pk);
if p.is_none().into() {
return false;
}
let p = p.unwrap();
if p.is_identity().into() {
return false;
}
agg += G1Projective::from(p);
}
let s = G2Affine::from_compressed(sig);
if s.is_none().into() {
return false;
}
let s = s.unwrap();
let hm: G2Projective = <G2Projective as HashToCurve<ExpandMsgXmd<sha2::Sha256>>>::hash_to_curve(msg, dst);
let agg_affine = agg.to_affine();
let neg_g1 = -G1Affine::generator();
let r = multi_miller_loop(&[(&agg_affine, &G2Prepared::from(hm.to_affine())), (&neg_g1, &G2Prepared::from(s))]).final_exponentiation();
r == Gt::identity()
}
}

View file

@ -0,0 +1,145 @@
//! The certificate inside the recursion (design section 3): the checkpoint is a chain block in the history, the
//! table at that block is the one the proof committed there, the bitmap's signers are revealed voters of that
//! table, the aggregate signature verifies, two thirds of the table signed (Q3) and two thirds of the table at the
//! previous lock (Q5, less the keys that left), which never expires inside the proof (section 4.4).
use crate::bls::Bls;
use crate::mmr::{HistoryLeaf, MmrProof};
use crate::{keys_hash, vote_message, voters_at, FinParams, FinState, KeyEntry, Lock, H, SIG_LEN};
use serde::{Deserialize, Serialize};
/// A table at a chain block: the leaf that commits it and the entries.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableAt {
pub leaf: HistoryLeaf,
pub proof: MmrProof,
pub keys: Vec<KeyEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CertificateWitness {
pub index: u64,
pub checkpoint: H,
pub voter_count: u32,
pub bitmap: Vec<u8>,
#[serde(with = "crate::serde_arrays")]
pub signature: [u8; SIG_LEN],
/// The table at the checkpoint block.
pub at: TableAt,
/// The table at the previous lock's block, when a lock exists.
pub frozen: Option<TableAt>,
}
pub fn signer_positions(bitmap: &[u8], voter_count: u32) -> Vec<usize> {
let mut out = Vec::new();
for (byte_index, byte) in bitmap.iter().enumerate() {
for bit in 0..8 {
if byte & (1 << bit) != 0 {
let pos = byte_index * 8 + bit;
if pos < voter_count as usize {
out.push(pos);
}
}
}
}
out
}
fn check_table(state: &FinState, t: &TableAt, what: &str) -> Result<(), String> {
if !t.proof.verify(&t.leaf.hash(), &state.history_root(), state.leaves) {
return Err(format!("the {what} block is not in the proof's history"));
}
if keys_hash(&t.keys) != t.leaf.keys_hash {
return Err(format!("the {what} table is not the one the proof committed at that block"));
}
if !t.keys.windows(2).all(|p| p[0].key_hash < p[1].key_hash) {
return Err(format!("the {what} table is not sorted"));
}
Ok(())
}
pub fn verify_certificate(state: &mut FinState, params: &FinParams, c: &CertificateWitness, bls: &dyn Bls) -> Result<(), String> {
if state.stale {
return Err("the proof chain is stale: no lock for a full window, no certificate is accepted".into());
}
if c.index <= state.lock.index {
return Err(format!("certificate index {} is not above the last lock {}", c.index, state.lock.index));
}
check_table(state, &c.at, "checkpoint")?;
let leaf = &c.at.leaf;
if leaf.block_hash != c.checkpoint {
return Err("the certificate's checkpoint is not the block the history leaf names".into());
}
if leaf.number < state.history_first || (state.lock.index > 0 && leaf.number <= state.lock.number) {
return Err(format!("checkpoint block {} is not above the last lock's block {}", leaf.number, state.lock.number));
}
if state.lock.index > 0 && leaf.daa <= state.lock.daa {
return Err("checkpoint DAA score is not above the last lock's".into());
}
if leaf.daa < params.min_daa {
return Err(format!("checkpoint DAA score {} is below min_daa {} (C5)", leaf.daa, params.min_daa));
}
// the canonical voter list at the checkpoint and the signers
let (voters, total) = voters_at(&c.at.keys, leaf.daa, params.dust);
if voters.len() != c.voter_count as usize {
return Err(format!("the certificate names {} voters, the table at the checkpoint has {}", c.voter_count, voters.len()));
}
if total != leaf.total {
return Err("the table's total differs from the leaf's".into());
}
let positions = signer_positions(&c.bitmap, c.voter_count);
if positions.is_empty() {
return Err("the certificate has no signer".into());
}
let mut signed = 0u64;
let mut pubkeys = Vec::with_capacity(positions.len());
let mut signer_hashes: Vec<H> = Vec::with_capacity(positions.len());
for p in &positions {
let k = &c.at.keys[voters[*p]];
if !k.revealed() {
return Err("a signer's key is not revealed".into());
}
signed += k.blocks;
pubkeys.push(k.pubkey);
signer_hashes.push(k.key_hash);
}
if !bls.verify_aggregate(&pubkeys, &vote_message(&params.chain_id, c.index, &c.checkpoint), crate::DST_VOTE, &c.signature) {
return Err("the certificate's aggregate signature does not verify".into());
}
if total == 0 || !FinParams::floor_met(signed, total) {
return Err(format!("signed {signed} of {total} is under two thirds (Q3)"));
}
// Q5, never expiring in the proof
let (frozen_signed, frozen_total) = if state.lock.index > 0 {
let f = c.frozen.as_ref().ok_or("a lock exists and the frozen table is missing")?;
check_table(state, f, "frozen")?;
if f.leaf.number != state.lock.number || f.leaf.block_hash != state.lock.hash {
return Err("the frozen table is not the table at the last lock's block".into());
}
let (fvoters, ftotal) = voters_at(&f.keys, f.leaf.daa, params.dust);
if ftotal != f.leaf.total {
return Err("the frozen table's total differs from its leaf's".into());
}
// keys gone at the checkpoint (their leaves carried after the lock) leave the frozen denominator
let gone = |kh: &H| c.at.keys.binary_search_by(|k| k.key_hash.cmp(kh)).ok().map(|i| c.at.keys[i].is_gone(leaf.daa)).unwrap_or(false);
let mut left = 0u64;
let mut fsigned = 0u64;
for &i in &fvoters {
let k = &f.keys[i];
if gone(&k.key_hash) {
left += k.blocks;
} else if signer_hashes.binary_search(&k.key_hash).is_ok() {
fsigned += k.blocks;
}
}
let ft = ftotal.saturating_sub(left);
if ft == 0 || !FinParams::floor_met(fsigned, ft) {
return Err(format!("signed {fsigned} of the frozen table's {ft} is under two thirds (Q5)"));
}
(fsigned, ft)
} else {
(0, 0)
};
state.lock = Lock { index: c.index, hash: c.checkpoint, number: leaf.number, signed, total, frozen_signed, frozen_total, daa: leaf.daa };
Ok(())
}

View file

@ -0,0 +1,228 @@
//! One fold: one chain block into the carried state (design section 2), then the certificates that landed
//! (section 3, `cert`). The same function runs in the guest (witnessed ring), on the host and in the node's
//! tracker (sparse ring, witnesses recorded).
use crate::bls::Bls;
use crate::cert::{verify_certificate, CertificateWitness};
use crate::mmr::{self, HistoryLeaf};
use crate::ring::{slot_of, RingAccess, RingEntry, RING_LEAF_DAA};
use crate::{find_key, vote_key_hash, vote_message, voters_at, FinParams, FinState, KeyEntry, H, PUBKEY_LEN, SIG_LEN};
use serde::{Deserialize, Serialize};
/// One blue block of the chain block's past counted for its key: the chain block itself and its mergeset blues
/// (`ChainBlockRecord.mergeset` with `is_blue` on the node).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlueBlock {
pub block_hash: H,
pub key_hash: H,
pub daa: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainBlockWitness {
pub number: u64,
pub block_hash: H,
pub daa: u64,
pub blues: Vec<BlueBlock>,
}
/// W1: a key reveal with its proof of possession.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Reveal {
#[serde(with = "crate::serde_arrays")]
pub pubkey: [u8; PUBKEY_LEN],
#[serde(with = "crate::serde_arrays")]
pub pop: [u8; SIG_LEN],
}
/// 3.6: two votes by one key at one index for different blocks, dated by their carrier.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceWitness {
#[serde(with = "crate::serde_arrays")]
pub pubkey: [u8; PUBKEY_LEN],
pub index: u64,
pub hash_a: H,
#[serde(with = "crate::serde_arrays")]
pub sig_a: [u8; SIG_LEN],
pub hash_b: H,
#[serde(with = "crate::serde_arrays")]
pub sig_b: [u8; SIG_LEN],
/// DAA score of the lowest carrier in the chain block's past (the node's `bans_at`).
pub carrier_daa: u64,
}
/// W7: a departure announcement dated by its carrier.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaveWitness {
pub daa: u64,
#[serde(with = "crate::serde_arrays")]
pub pubkey: [u8; PUBKEY_LEN],
#[serde(with = "crate::serde_arrays")]
pub signature: [u8; SIG_LEN],
pub carrier_daa: u64,
}
/// What one fold takes beside the ring witnesses.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FoldWitness {
pub reveals: Vec<Reveal>,
pub evidence: Vec<EvidenceWitness>,
pub leaves: Vec<LeaveWitness>,
pub certificates: Vec<CertificateWitness>,
}
/// What a fold reports beside the new state.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FoldReport {
pub counted: u64,
pub skipped_old: u64,
pub expired: u64,
pub locks: u64,
}
fn touch(keys: &mut Vec<KeyEntry>, key_hash: &H) -> usize {
match find_key(keys, key_hash) {
Ok(i) => i,
Err(i) => {
keys.insert(i, KeyEntry::new(*key_hash));
i
}
}
}
/// Folds chain block `block` into `state`. Errors make the proof impossible (the guest panics on them).
pub fn fold_block(state: &mut FinState, params: &FinParams, block: &ChainBlockWitness, ring: &mut dyn RingAccess, witness: &FoldWitness, bls: &dyn Bls) -> Result<FoldReport, String> {
if state.params_hash != params.hash() {
return Err("the state was computed under other finality parameters".into());
}
if state.leaves > 0 && block.number != state.end_number + 1 {
return Err(format!("chain block {} does not follow {}", block.number, state.end_number));
}
if state.leaves == 0 && block.number != state.history_first {
return Err(format!("the attestation starts at {} and the first block folded is {}", state.history_first, block.number));
}
if block.daa < state.end_daa {
return Err(format!("chain block {} has DAA score {} below the previous block's {}", block.number, block.daa, state.end_daa));
}
let mut report = FoldReport::default();
let window_start = block.daa.saturating_sub(params.weight_window);
// 1. the block's blue blocks into the ring and their keys
for b in &block.blues {
if b.daa > block.daa {
return Err(format!("blue block {} has DAA score {} above its chain block's {}", hex32(&b.block_hash), b.daa, block.daa));
}
if b.daa <= window_start {
report.skipped_old += 1;
continue;
}
let slot = slot_of(b.daa);
let (mut entries, siblings) = ring.open(slot, &state.ring_root)?;
let e = RingEntry { daa: b.daa, block_hash: b.block_hash, key_hash: b.key_hash };
if entries.iter().any(|x| x.block_hash == b.block_hash) {
return Err(format!("blue block {} counted twice", hex32(&b.block_hash)));
}
let pos = entries.binary_search(&e).unwrap_err();
entries.insert(pos, e);
state.ring_root = ring.write(slot, entries, &siblings);
let i = touch(&mut state.keys, &b.key_hash);
state.keys[i].blocks += 1;
report.counted += 1;
}
// 2. blocks that left the window: DAA scores in (old_start, window_start]
let old_start = state.end_daa.saturating_sub(params.weight_window);
if state.leaves > 0 && window_start > old_start {
let first_slot_daa = (old_start + 1) / RING_LEAF_DAA * RING_LEAF_DAA;
let mut d = first_slot_daa;
while d <= window_start {
let slot = slot_of(d);
let (entries, siblings) = ring.open(slot, &state.ring_root)?;
let (gone, kept): (Vec<RingEntry>, Vec<RingEntry>) = entries.into_iter().partition(|e| e.daa <= window_start);
if !gone.is_empty() {
for g in &gone {
let i = find_key(&state.keys, &g.key_hash).map_err(|_| format!("expired block {} names a key not in the table", hex32(&g.block_hash)))?;
state.keys[i].blocks = state.keys[i].blocks.checked_sub(1).ok_or("a key's block count went below zero")?;
report.expired += 1;
}
state.ring_root = ring.write(slot, kept, &siblings);
}
d += RING_LEAF_DAA;
}
}
// 3. reveals, evidence, leaves (W1, 3.6, W7), each signature verified here
for r in &witness.reveals {
if !bls.verify_one(&r.pubkey, &r.pubkey, crate::DST_POP, &r.pop) {
return Err("a key reveal's proof of possession does not verify".into());
}
let kh = vote_key_hash(&r.pubkey);
let i = touch(&mut state.keys, &kh);
if state.keys[i].revealed() && state.keys[i].pubkey != r.pubkey {
return Err("a key reveal names another key for a revealed hash".into());
}
state.keys[i].pubkey = r.pubkey;
}
for e in &witness.evidence {
if e.hash_a == e.hash_b {
return Err("evidence names one block twice".into());
}
if e.carrier_daa > block.daa {
return Err("evidence carried after this chain block".into());
}
let ok_a = bls.verify_one(&e.pubkey, &vote_message(&params.chain_id, e.index, &e.hash_a), crate::DST_VOTE, &e.sig_a);
let ok_b = bls.verify_one(&e.pubkey, &vote_message(&params.chain_id, e.index, &e.hash_b), crate::DST_VOTE, &e.sig_b);
if !(ok_a && ok_b) {
return Err("an equivocation vote does not verify".into());
}
let kh = vote_key_hash(&e.pubkey);
let i = touch(&mut state.keys, &kh);
let until = e.carrier_daa.saturating_add(params.equivocation_ban);
state.keys[i].ban_until = state.keys[i].ban_until.max(until);
if !state.keys[i].revealed() {
state.keys[i].pubkey = e.pubkey;
}
}
for l in &witness.leaves {
if l.carrier_daa > block.daa {
return Err("a leave carried after this chain block".into());
}
if !bls.verify_one(&l.pubkey, &crate::leave_message(&params.chain_id, l.daa), crate::DST_LEAVE, &l.signature) {
return Err("a leave does not verify".into());
}
let kh = vote_key_hash(&l.pubkey);
let i = touch(&mut state.keys, &kh);
if state.keys[i].leave_until == 0 {
state.keys[i].leave_from = l.carrier_daa.saturating_add(params.leave_delay);
state.keys[i].leave_until = l.carrier_daa.saturating_add(params.weight_window);
}
}
// 4. the block's own entry in the history, with the table after it
state.end_daa = block.daa;
state.end_number = block.number;
state.keys.retain(|k| !k.is_empty_at(block.daa));
let (_, total) = voters_at(&state.keys, block.daa, params.dust);
let leaf = HistoryLeaf { number: block.number, block_hash: block.block_hash, daa: block.daa, table_root: state.table_root(), keys_hash: state.keys_hash(), total };
mmr::append(&mut state.peaks, &mut state.leaves, leaf.hash());
// 5. staleness: a window without a lock (the frozen table never expires in the proof, design 4.4)
if state.lock.index > 0 && block.daa >= state.lock.daa.saturating_add(params.weight_window) {
state.stale = true;
}
// 6. the certificates that landed with this block
for c in &witness.certificates {
verify_certificate(state, params, c, bls)?;
report.locks += 1;
}
Ok(report)
}
pub fn hex32(h: &H) -> String {
let mut s = String::with_capacity(64);
for b in h {
s.push_str(&format!("{b:02x}"));
}
s
}

View file

@ -0,0 +1,347 @@
//! Finality carried inside the segment proof (`docs/design/finality-in-proof.md`).
//!
//! The weight table of spec 03 W2, as the node computes it (`compute_weights` in the fork's
//! `consensus/src/processes/finality.rs`), kept incrementally by the aggregator guest: one chain block per fold,
//! the block's blue blocks added to their keys, the blocks that left the 30-day window taken off, the table
//! committed by hash, every chain block appended to a history MMR with its table commitment, and a lock
//! certificate verified against the table at its own checkpoint block. The same functions run in the guest, on
//! the host natively, and in the node's tracker (which feeds the witnesses and compares the result: the veto).
//!
//! Nothing here depends on kaspa types: bytes in, bytes out, so the crate compiles for the zkVM target.
pub mod bls;
pub mod cert;
pub mod fold;
pub mod mmr;
pub mod ring;
pub mod tracker;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub type H = [u8; 32];
pub const PUBKEY_LEN: usize = 48;
pub const SIG_LEN: usize = 96;
pub const ZERO: H = [0u8; 32];
/// The vote tag of spec 3.10 C2 (the fork's `DST_VOTE`).
pub const DST_VOTE: &[u8] = b"IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
/// The proof-of-possession tag (W1).
pub const DST_POP: &[u8] = b"IGNEUM_POP_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_";
/// The leave tag (W7).
pub const DST_LEAVE: &[u8] = b"IGNEUM_LEAVE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
pub fn sha256(parts: &[&[u8]]) -> H {
let mut h = Sha256::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
/// W1: `vote_key_hash` = BLAKE2b-256 keyed `IgneumVoteKeyHash` over the 48-byte compressed G1 key (the fork's
/// `kaspa_hashes::VoteKeyHash`).
pub fn vote_key_hash(pubkey: &[u8; PUBKEY_LEN]) -> H {
let out = blake2b_simd::Params::new().hash_length(32).key(b"IgneumVoteKeyHash").hash(pubkey);
let mut h = [0u8; 32];
h.copy_from_slice(out.as_bytes());
h
}
/// C2: `"igneum-vote-v1/" || chain_id || 0 || index_le || checkpoint`.
pub fn vote_message(chain_id: &str, index: u64, checkpoint: &H) -> Vec<u8> {
let mut m = Vec::with_capacity(16 + chain_id.len() + 8 + 32);
m.extend_from_slice(b"igneum-vote-v1/");
m.extend_from_slice(chain_id.as_bytes());
m.push(0);
m.extend_from_slice(&index.to_le_bytes());
m.extend_from_slice(checkpoint);
m
}
/// W7: `"igneum-leave-v1/" || chain_id || 0 || daa_le`.
pub fn leave_message(chain_id: &str, daa: u64) -> Vec<u8> {
let mut m = Vec::with_capacity(17 + chain_id.len() + 8);
m.extend_from_slice(b"igneum-leave-v1/");
m.extend_from_slice(chain_id.as_bytes());
m.push(0);
m.extend_from_slice(&daa.to_le_bytes());
m
}
/// The finality parameters the table is computed under (spec 03, `FinalityParams` on the node). Committed into
/// every table root, so a proof made under other parameters commits to another table.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FinParams {
/// The network name the votes are domain-separated with (`igneum-devnet`, `igneum-devnet-7`).
pub chain_id: String,
/// W2: the trailing weight window in DAA seconds (2,592,000 mainnet, 7,200 devnet).
pub weight_window: u64,
/// W3: a key under this many blue blocks in the window is no voter.
pub dust: u64,
/// C5: no certificate below this DAA score.
pub min_daa: u64,
/// W7: a leave takes effect this many DAA seconds after its carrier.
pub leave_delay: u64,
/// 3.6: an equivocating key is stripped for this many DAA seconds after the evidence's carrier.
pub equivocation_ban: u64,
}
impl FinParams {
pub fn hash(&self) -> H {
sha256(&[
b"igneum-fin-params-v1",
&(self.chain_id.len() as u64).to_le_bytes(),
self.chain_id.as_bytes(),
&self.weight_window.to_le_bytes(),
&self.dust.to_le_bytes(),
&self.min_daa.to_le_bytes(),
&self.leave_delay.to_le_bytes(),
&self.equivocation_ban.to_le_bytes(),
])
}
/// Q3's floor, inclusive: `3 x signed >= 2 x total` (the node's `FinalityParams::floor_met`).
pub fn floor_met(signed: u64, total: u64) -> bool {
(signed as u128) * 3 >= 2 * (total as u128)
}
pub fn devnet(chain_id: &str) -> Self {
Self { chain_id: chain_id.into(), weight_window: 7_200, dust: 5, min_daa: 7_200, leave_delay: 3_600, equivocation_ban: 7_200 }
}
pub fn mainnet(chain_id: &str) -> Self {
Self { chain_id: chain_id.into(), weight_window: 2_592_000, dust: 100, min_daa: 2_592_000, leave_delay: 3_600, equivocation_ban: 2_592_000 }
}
}
/// One key of the table: its blue blocks in the window, its revealed public key (zero until revealed), the end
/// of its ban (3.6) and the span of its leave (W7). 112 bytes serialised.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyEntry {
pub key_hash: H,
#[serde(with = "serde_arrays")]
pub pubkey: [u8; PUBKEY_LEN],
pub blocks: u64,
pub ban_until: u64,
pub leave_from: u64,
pub leave_until: u64,
}
impl KeyEntry {
pub const LEN: usize = 32 + PUBKEY_LEN + 8 + 8 + 8 + 8;
pub fn new(key_hash: H) -> Self {
Self { key_hash, pubkey: [0u8; PUBKEY_LEN], blocks: 0, ban_until: 0, leave_from: 0, leave_until: 0 }
}
pub fn write(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.key_hash);
out.extend_from_slice(&self.pubkey);
out.extend_from_slice(&self.blocks.to_le_bytes());
out.extend_from_slice(&self.ban_until.to_le_bytes());
out.extend_from_slice(&self.leave_from.to_le_bytes());
out.extend_from_slice(&self.leave_until.to_le_bytes());
}
pub fn revealed(&self) -> bool {
self.pubkey != [0u8; PUBKEY_LEN]
}
/// A voter at DAA score `daa`: above dust, not stripped, not gone (W3, 3.6, W7).
pub fn is_voter(&self, daa: u64, dust: u64) -> bool {
self.blocks >= dust && daa >= self.ban_until && !self.is_gone(daa)
}
/// W7: the key has left at `daa`.
pub fn is_gone(&self, daa: u64) -> bool {
self.leave_until > 0 && self.leave_from <= daa && daa < self.leave_until
}
/// An entry that holds nothing any more is dropped from the table at the end of a fold.
pub fn is_empty_at(&self, daa: u64) -> bool {
self.blocks == 0 && daa >= self.ban_until && daa >= self.leave_until
}
}
/// The key table: sorted by key hash, which is the canonical voter order of spec 3.10 C3.
pub fn keys_hash(keys: &[KeyEntry]) -> H {
let mut buf = Vec::with_capacity(8 + keys.len() * KeyEntry::LEN);
buf.extend_from_slice(&(keys.len() as u64).to_le_bytes());
for k in keys {
k.write(&mut buf);
}
sha256(&[b"igneum-fin-keys-v1", &buf])
}
/// The canonical voter list at `daa` (positions index a certificate's bitmap) and the total weight.
pub fn voters_at(keys: &[KeyEntry], daa: u64, dust: u64) -> (Vec<usize>, u64) {
let mut positions = Vec::new();
let mut total = 0u64;
for (i, k) in keys.iter().enumerate() {
if k.is_voter(daa, dust) {
positions.push(i);
total += k.blocks;
}
}
(positions, total)
}
/// Table lookup by key hash (the table is sorted).
pub fn find_key(keys: &[KeyEntry], key_hash: &H) -> Result<usize, usize> {
keys.binary_search_by(|k| k.key_hash.cmp(key_hash))
}
/// The latest lock the proof chain has verified (zero before the first).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lock {
pub index: u64,
pub hash: H,
pub number: u64,
pub signed: u64,
pub total: u64,
pub frozen_signed: u64,
pub frozen_total: u64,
pub daa: u64,
}
/// The carried state: what one fold takes in and gives out. Its commitment is `extension()`, the public values.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FinState {
pub params_hash: H,
/// DAA score and chain number of the last chain block folded in.
pub end_daa: u64,
pub end_number: u64,
/// The first chain block this attestation counted from (section 1 of the design: `history_first`).
pub history_first: u64,
/// Sorted by key hash.
pub keys: Vec<KeyEntry>,
/// Root of the block ring (`ring`).
pub ring_root: H,
/// The history MMR's peaks, left to right, and its leaf count (`mmr`).
pub peaks: Vec<(u8, H)>,
pub leaves: u64,
pub lock: Lock,
pub stale: bool,
}
/// The fixed extension of the block statement (design section 1).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FinExt {
pub fin_version: u16,
pub table_root: H,
pub history_root: H,
pub history_first: u64,
pub lock: Lock,
pub flags: u16,
}
impl FinExt {
pub const VERSION: u16 = 1;
pub const LEN: usize = 2 + 32 + 32 + 8 + (8 + 32 + 8 + 8 + 8 + 8 + 8 + 8) + 2;
pub const FLAG_STALE: u16 = 1;
pub fn to_bytes(&self) -> Vec<u8> {
let mut v = Vec::with_capacity(Self::LEN);
v.extend_from_slice(&self.fin_version.to_be_bytes());
v.extend_from_slice(&self.table_root);
v.extend_from_slice(&self.history_root);
v.extend_from_slice(&self.history_first.to_be_bytes());
v.extend_from_slice(&self.lock.index.to_be_bytes());
v.extend_from_slice(&self.lock.hash);
v.extend_from_slice(&self.lock.number.to_be_bytes());
v.extend_from_slice(&self.lock.signed.to_be_bytes());
v.extend_from_slice(&self.lock.total.to_be_bytes());
v.extend_from_slice(&self.lock.frozen_signed.to_be_bytes());
v.extend_from_slice(&self.lock.frozen_total.to_be_bytes());
v.extend_from_slice(&self.lock.daa.to_be_bytes());
v.extend_from_slice(&self.flags.to_be_bytes());
debug_assert_eq!(v.len(), Self::LEN);
v
}
pub fn from_bytes(b: &[u8]) -> Option<Self> {
if b.len() != Self::LEN {
return None;
}
let u64_at = |i: usize| u64::from_be_bytes(b[i..i + 8].try_into().unwrap());
let h_at = |i: usize| -> H { b[i..i + 32].try_into().unwrap() };
Some(Self {
fin_version: u16::from_be_bytes([b[0], b[1]]),
table_root: h_at(2),
history_root: h_at(34),
history_first: u64_at(66),
lock: Lock {
index: u64_at(74),
hash: h_at(82),
number: u64_at(114),
signed: u64_at(122),
total: u64_at(130),
frozen_signed: u64_at(138),
frozen_total: u64_at(146),
daa: u64_at(154),
},
flags: u16::from_be_bytes([b[162], b[163]]),
})
}
pub fn stale(&self) -> bool {
self.flags & Self::FLAG_STALE != 0
}
}
impl FinState {
/// The empty state rooted at chain block `first_number` (the attestation counts from that block on).
pub fn empty(params: &FinParams, first_number: u64) -> Self {
Self {
params_hash: params.hash(),
end_daa: 0,
end_number: first_number.saturating_sub(1),
history_first: first_number,
keys: Vec::new(),
ring_root: ring::empty_root(),
peaks: Vec::new(),
leaves: 0,
lock: Lock::default(),
stale: false,
}
}
pub fn keys_hash(&self) -> H {
keys_hash(&self.keys)
}
/// `sha256("igneum-fin-state-v1" || params_hash || end_daa || end_number || keys_hash || ring_root)`.
pub fn table_root(&self) -> H {
sha256(&[b"igneum-fin-state-v1", &self.params_hash, &self.end_daa.to_le_bytes(), &self.end_number.to_le_bytes(), &self.keys_hash(), &self.ring_root])
}
pub fn history_root(&self) -> H {
mmr::bag_peaks(&self.peaks)
}
pub fn extension(&self) -> FinExt {
FinExt {
fin_version: FinExt::VERSION,
table_root: self.table_root(),
history_root: self.history_root(),
history_first: self.history_first,
lock: self.lock.clone(),
flags: if self.stale { FinExt::FLAG_STALE } else { 0 },
}
}
}
/// `serde` for fixed arrays over 32 bytes.
pub mod serde_arrays {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer, const N: usize>(a: &[u8; N], s: S) -> Result<S::Ok, S::Error> {
a.as_slice().serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>, const N: usize>(d: D) -> Result<[u8; N], D::Error> {
let v: Vec<u8> = Vec::deserialize(d)?;
v.try_into().map_err(|v: Vec<u8>| serde::de::Error::custom(format!("expected {N} bytes, got {}", v.len())))
}
}

View file

@ -0,0 +1,164 @@
//! The history: a Merkle mountain range with one leaf per chain block the attestation covers. A leaf commits the
//! block's number, hash, DAA score, the table root after it and its total weight, so a certificate's checkpoint
//! is looked up by its leaf and a verifier answers "is block n final" from a leaf and a path.
use crate::{sha256, H};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HistoryLeaf {
pub number: u64,
pub block_hash: H,
pub daa: u64,
/// The table root (keys and ring) after this block was folded.
pub table_root: H,
/// The keys hash alone, so a certificate's table witness can be checked without the ring.
pub keys_hash: H,
pub total: u64,
}
impl HistoryLeaf {
pub fn hash(&self) -> H {
sha256(&[&[4u8], &self.number.to_le_bytes(), &self.block_hash, &self.daa.to_le_bytes(), &self.table_root, &self.keys_hash, &self.total.to_le_bytes()])
}
}
pub fn merge(left: &H, right: &H) -> H {
sha256(&[&[2u8], left, right])
}
/// Bags the peaks right to left into one root; zero for an empty range.
pub fn bag_peaks(peaks: &[(u8, H)]) -> H {
let Some((_, last)) = peaks.last() else { return [0u8; 32] };
let mut root = *last;
for (_, p) in peaks[..peaks.len() - 1].iter().rev() {
root = sha256(&[&[3u8], p, &root]);
}
root
}
/// Appends a leaf hash: the new peak of height 0 merges with equal-height peaks to its left.
pub fn append(peaks: &mut Vec<(u8, H)>, leaves: &mut u64, leaf: H) {
let mut h = 0u8;
let mut node = leaf;
while let Some(&(ph, p)) = peaks.last() {
if ph != h {
break;
}
peaks.pop();
node = merge(&p, &node);
h += 1;
}
peaks.push((h, node));
*leaves += 1;
}
/// A membership proof: the leaf's position, its siblings inside its mountain (with the side each sits on), and
/// the peaks of the range at the size the proof is made for, the leaf's mountain's peak among them.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MmrProof {
pub position: u64,
/// (sibling, sibling_is_left)
pub siblings: Vec<(H, bool)>,
pub peaks: Vec<(u8, H)>,
pub peak_index: usize,
}
impl MmrProof {
/// Checks that `leaf` sits at `position` in a range whose peaks bag to `root` with `leaves` leaves.
pub fn verify(&self, leaf: &H, root: &H, leaves: u64) -> bool {
if self.position >= leaves || self.peak_index >= self.peaks.len() {
return false;
}
// the peaks' heights must describe exactly `leaves` leaves, strictly decreasing left to right
let mut count = 0u64;
let mut last: Option<u8> = None;
for &(h, _) in &self.peaks {
if let Some(l) = last
&& l <= h
{
return false;
}
last = Some(h);
count += 1u64 << h;
}
if count != leaves {
return false;
}
// the leaf's mountain: positions before it are the earlier peaks' leaves
let before: u64 = self.peaks[..self.peak_index].iter().map(|&(h, _)| 1u64 << h).sum();
let height = self.peaks[self.peak_index].0 as usize;
if self.position < before || self.position - before >= (1u64 << height) || self.siblings.len() != height {
return false;
}
let mut idx = self.position - before;
let mut node = *leaf;
for (s, left) in &self.siblings {
let expect_left = idx & 1 == 1;
if *left != expect_left {
return false;
}
node = if *left { merge(s, &node) } else { merge(&node, s) };
idx >>= 1;
}
node == self.peaks[self.peak_index].1 && bag_peaks(&self.peaks) == *root
}
}
/// A full in-memory MMR (the tracker and the tests): every node kept, proofs for any leaf at any later size.
#[derive(Clone, Debug, Default)]
pub struct Mmr {
/// All leaf hashes in order.
pub leaf_hashes: Vec<H>,
pub peaks: Vec<(u8, H)>,
}
impl Mmr {
pub fn append(&mut self, leaf: H) {
let mut n = self.leaf_hashes.len() as u64;
append(&mut self.peaks, &mut n, leaf);
self.leaf_hashes.push(leaf);
}
pub fn leaves(&self) -> u64 {
self.leaf_hashes.len() as u64
}
pub fn root(&self) -> H {
bag_peaks(&self.peaks)
}
/// The proof of leaf `position` at the current size (recomputed from the leaves: the tracker makes few).
pub fn proof(&self, position: u64) -> Option<MmrProof> {
let leaves = self.leaves();
if position >= leaves {
return None;
}
let mut before = 0u64;
let mut peak_index = 0usize;
for (i, &(h, _)) in self.peaks.iter().enumerate() {
let size = 1u64 << h;
if position < before + size {
peak_index = i;
break;
}
before += size;
}
let height = self.peaks[peak_index].0 as usize;
// build the mountain's levels from its leaves
let mut level: Vec<H> = self.leaf_hashes[before as usize..(before + (1u64 << height)) as usize].to_vec();
let mut idx = (position - before) as usize;
let mut siblings = Vec::with_capacity(height);
for _ in 0..height {
let sib = idx ^ 1;
siblings.push((level[sib], sib < idx));
let mut next = Vec::with_capacity(level.len() / 2);
for pair in level.chunks(2) {
next.push(merge(&pair[0], &pair[1]));
}
level = next;
idx >>= 1;
}
Some(MmrProof { position, siblings, peaks: self.peaks.clone(), peak_index })
}
}

View file

@ -0,0 +1,127 @@
//! The block ring: a Merkle tree of 2^RING_DEPTH leaves, one per RING_LEAF_DAA DAA seconds, each leaf the
//! sorted list of the blue blocks (DAA score, block hash, key hash) that fell in its span. The window is
//! 2,592,000 DAA seconds on mainnet; the ring spans 2^18 x 16 = 4,194,304, so a slot is reused only after its
//! blocks have aged out. The ring is what lets the fold age blocks out exactly (each expired block's key is
//! decremented, as the node's window would drop it) and what refuses a block hash counted twice (level 1 of the
//! design's section 5.2).
//!
//! The guest never holds the ring; it opens a leaf through a witness (the entries and the siblings) and writes it
//! back by recomputing the root. The node's tracker holds the ring sparsely and produces the witnesses.
use crate::{sha256, H, ZERO};
use serde::{Deserialize, Serialize};
pub const RING_LEAF_DAA: u64 = 16;
pub const RING_DEPTH: usize = 18;
pub const RING_SLOTS: u64 = 1 << RING_DEPTH;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct RingEntry {
pub daa: u64,
pub block_hash: H,
pub key_hash: H,
}
impl RingEntry {
pub fn write(&self, out: &mut Vec<u8>) {
out.extend_from_slice(&self.daa.to_le_bytes());
out.extend_from_slice(&self.block_hash);
out.extend_from_slice(&self.key_hash);
}
}
pub fn slot_of(daa: u64) -> u32 {
((daa / RING_LEAF_DAA) % RING_SLOTS) as u32
}
/// A leaf's hash: zero when empty, else `sha256(0x00 || entries)` over the sorted entries.
pub fn leaf_hash(entries: &[RingEntry]) -> H {
if entries.is_empty() {
return ZERO;
}
let mut buf = Vec::with_capacity(1 + entries.len() * 72);
buf.push(0u8);
for e in entries {
e.write(&mut buf);
}
sha256(&[&buf])
}
pub fn node_hash(left: &H, right: &H) -> H {
sha256(&[&[1u8], left, right])
}
/// The hash of an all-empty subtree at each level (level 0 = a leaf).
pub fn defaults() -> Vec<H> {
let mut d = Vec::with_capacity(RING_DEPTH + 1);
d.push(ZERO);
for l in 1..=RING_DEPTH {
let below = d[l - 1];
d.push(node_hash(&below, &below));
}
d
}
pub fn empty_root() -> H {
defaults()[RING_DEPTH]
}
/// The root from a leaf hash and its siblings, bottom up.
pub fn root_from_path(slot: u32, leaf: H, siblings: &[H]) -> H {
assert_eq!(siblings.len(), RING_DEPTH, "a ring path has {RING_DEPTH} siblings");
let mut h = leaf;
let mut idx = slot as u64;
for s in siblings {
h = if idx & 1 == 0 { node_hash(&h, s) } else { node_hash(s, &h) };
idx >>= 1;
}
h
}
/// One leaf opened for the guest: its entries and siblings as they stand at that moment of the fold.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RingLeafWitness {
pub slot: u32,
pub entries: Vec<RingEntry>,
pub siblings: Vec<H>,
}
/// How a fold reaches the ring: the guest consumes witnesses in order, the tracker reads its own tree and
/// records what it read as the witnesses the guest will need.
pub trait RingAccess {
/// Opens `slot`: the leaf's entries and siblings, checked against `root`.
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String>;
/// Writes `entries` to `slot` and returns the new root (the siblings are those `open` returned).
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, siblings: &[H]) -> H;
}
/// The guest's ring: a queue of witnesses.
pub struct WitnessRing {
pub witnesses: std::collections::VecDeque<RingLeafWitness>,
}
impl WitnessRing {
pub fn new(witnesses: Vec<RingLeafWitness>) -> Self {
Self { witnesses: witnesses.into() }
}
}
impl RingAccess for WitnessRing {
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String> {
let w = self.witnesses.pop_front().ok_or_else(|| format!("ring witness missing for slot {slot}"))?;
if w.slot != slot {
return Err(format!("ring witness names slot {} where the fold opens slot {slot}", w.slot));
}
if !w.entries.windows(2).all(|p| p[0] < p[1]) {
return Err(format!("ring leaf {slot} is not sorted"));
}
if root_from_path(slot, leaf_hash(&w.entries), &w.siblings) != *root {
return Err(format!("ring witness for slot {slot} does not open the ring root"));
}
Ok((w.entries, w.siblings))
}
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, siblings: &[H]) -> H {
root_from_path(slot, leaf_hash(&entries), siblings)
}
}

View file

@ -0,0 +1,103 @@
//! The native side: a sparse ring the node's tracker (and the tests) hold in full, which records what it reads so
//! the guest's witnesses fall out of a native fold. Memory: one node per distinct hash on a path from a non-empty
//! leaf, at most about 2 x 2^18 entries.
use crate::ring::{defaults, leaf_hash, node_hash, RingAccess, RingEntry, RingLeafWitness, RING_DEPTH};
use crate::H;
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct SparseRing {
defaults: Vec<H>,
/// (level, index) -> hash, for nodes that differ from the level's default
nodes: HashMap<(u8, u64), H>,
leaves: HashMap<u32, Vec<RingEntry>>,
/// Every leaf opened, in order: the witnesses a guest needs to replay the same fold.
pub recorded: Vec<RingLeafWitness>,
}
impl Default for SparseRing {
fn default() -> Self {
Self::new()
}
}
impl SparseRing {
pub fn new() -> Self {
Self { defaults: defaults(), nodes: HashMap::new(), leaves: HashMap::new(), recorded: Vec::new() }
}
fn node(&self, level: u8, index: u64) -> H {
self.nodes.get(&(level, index)).copied().unwrap_or(self.defaults[level as usize])
}
pub fn root(&self) -> H {
self.node(RING_DEPTH as u8, 0)
}
pub fn siblings(&self, slot: u32) -> Vec<H> {
let mut idx = slot as u64;
let mut out = Vec::with_capacity(RING_DEPTH);
for level in 0..RING_DEPTH as u8 {
out.push(self.node(level, idx ^ 1));
idx >>= 1;
}
out
}
pub fn entries(&self, slot: u32) -> Vec<RingEntry> {
self.leaves.get(&slot).cloned().unwrap_or_default()
}
pub fn set(&mut self, slot: u32, entries: Vec<RingEntry>) {
let mut h = leaf_hash(&entries);
if entries.is_empty() {
self.leaves.remove(&slot);
} else {
self.leaves.insert(slot, entries);
}
let mut idx = slot as u64;
for level in 0..=RING_DEPTH as u8 {
if h == self.defaults[level as usize] {
self.nodes.remove(&(level, idx));
} else {
self.nodes.insert((level, idx), h);
}
if level == RING_DEPTH as u8 {
break;
}
let sib = self.node(level, idx ^ 1);
h = if idx & 1 == 0 { node_hash(&h, &sib) } else { node_hash(&sib, &h) };
idx >>= 1;
}
}
/// Takes the witnesses recorded so far (the guest's input for the fold just run).
pub fn take_witnesses(&mut self) -> Vec<RingLeafWitness> {
std::mem::take(&mut self.recorded)
}
/// Every non-empty leaf's entries (the differential test counts the window from them).
pub fn all_entries(&self) -> Vec<RingEntry> {
let mut v: Vec<RingEntry> = self.leaves.values().flatten().cloned().collect();
v.sort();
v
}
}
impl RingAccess for SparseRing {
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String> {
if self.root() != *root {
return Err("the tracker's ring root differs from the state's".into());
}
let entries = self.entries(slot);
let siblings = self.siblings(slot);
self.recorded.push(RingLeafWitness { slot, entries: entries.clone(), siblings: siblings.clone() });
Ok((entries, siblings))
}
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, _siblings: &[H]) -> H {
self.set(slot, entries);
self.root()
}
}

View file

@ -0,0 +1,443 @@
//! The harness of design section 7: a simulated chain of chain blocks with real BLS keys (blst, the node's
//! library), folded natively through the tracker (witnesses recorded) and replayed through the witnessed ring (the
//! guest's path); the table checked against a brute-force count of the window at every block; certificates built
//! from real votes; the known-failed case first (today's light client accepts a certificate over a voter list the
//! node of its choosing hands it); then the proof-carried table refusing the same certificate; then a light client
//! answering "final at checkpoint N" from the extension and a history proof alone.
use blst::min_pk::{AggregateSignature, SecretKey, Signature};
use igneum_fin_core::bls::{Bls, ZkBls};
use igneum_fin_core::cert::{signer_positions, CertificateWitness, TableAt};
use igneum_fin_core::fold::{fold_block, BlueBlock, ChainBlockWitness, FoldWitness, LeaveWitness, Reveal};
use igneum_fin_core::mmr::{HistoryLeaf, Mmr};
use igneum_fin_core::ring::WitnessRing;
use igneum_fin_core::tracker::SparseRing;
use igneum_fin_core::{keys_hash, vote_key_hash, vote_message, voters_at, FinExt, FinParams, FinState, KeyEntry, DST_LEAVE, DST_POP, DST_VOTE, H, PUBKEY_LEN, SIG_LEN};
use std::collections::HashMap;
struct Key {
sk: SecretKey,
pk: [u8; PUBKEY_LEN],
hash: H,
}
fn key(label: &str) -> Key {
let mut ikm = [7u8; 32];
for (i, b) in label.bytes().enumerate() {
ikm[i % 32] ^= b;
}
let sk = SecretKey::key_gen(&ikm, b"igneum").unwrap();
let pk = sk.sk_to_pk().compress();
Key { sk, pk, hash: vote_key_hash(&pk) }
}
fn reveal(k: &Key) -> Reveal {
Reveal { pubkey: k.pk, pop: k.sk.sign(&k.pk, DST_POP, &[]).compress() }
}
fn hash_of(tag: &str, n: u64) -> H {
igneum_fin_core::sha256(&[tag.as_bytes(), &n.to_le_bytes()])
}
/// A native BLS verifier through blst, the node's own library (the cross-check for the guest's curve).
struct BlstBls;
impl Bls for BlstBls {
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
let pks: Option<Vec<blst::min_pk::PublicKey>> = pubkeys.iter().map(|p| blst::min_pk::PublicKey::from_bytes(p).ok()).collect();
let Some(pks) = pks else { return false };
let Some(sig) = Signature::from_bytes(sig).ok() else { return false };
let refs: Vec<&blst::min_pk::PublicKey> = pks.iter().collect();
sig.fast_aggregate_verify(true, msg, dst, &refs) == blst::BLST_ERROR::BLST_SUCCESS
}
}
/// The simulated chain: one chain block per DAA second, every block blue, keys mine in proportion to `share`.
struct Sim {
params: FinParams,
keys: Vec<Key>,
/// (number, hash, daa, blues) per chain block
blocks: Vec<ChainBlockWitness>,
/// the full table after each block (number -> keys) and the history leaves, from the tracker's fold
tables: HashMap<u64, Vec<KeyEntry>>,
mmr: Mmr,
leaves: HashMap<u64, HistoryLeaf>,
tracker: SparseRing,
state: FinState,
bls: BlstBls,
}
impl Sim {
fn new(params: FinParams, labels: &[&str]) -> Self {
let keys: Vec<Key> = labels.iter().map(|l| key(l)).collect();
let state = FinState::empty(&params, 0);
Self { params, keys, blocks: Vec::new(), tables: HashMap::new(), mmr: Mmr::default(), leaves: HashMap::new(), tracker: SparseRing::new(), state, bls: BlstBls }
}
/// Chain block `n` at DAA `n`, mined by key `miner`, with `extra` more blue blocks by the keys named; folds it.
fn mine(&mut self, miner: usize, extra: &[usize], witness: FoldWitness) -> Result<(), String> {
let n = self.blocks.len() as u64;
let mut blues = vec![BlueBlock { block_hash: hash_of("chain", n), key_hash: self.keys[miner].hash, daa: n }];
for (j, &k) in extra.iter().enumerate() {
blues.push(BlueBlock { block_hash: hash_of(&format!("side-{j}"), n), key_hash: self.keys[k].hash, daa: n });
}
let block = ChainBlockWitness { number: n, block_hash: hash_of("chain", n), daa: n, blues };
let before = self.state.clone();
let r = fold_block(&mut self.state, &self.params, &block, &mut self.tracker, &witness, &self.bls);
let witnesses = self.tracker.take_witnesses();
match r {
Ok(_) => {}
Err(e) => {
self.state = before;
return Err(e);
}
}
// the guest's path: the same fold through the witnesses, from the same previous state, must agree
let mut replay = before;
let mut ring = WitnessRing::new(witnesses);
fold_block(&mut replay, &self.params, &block, &mut ring, &witness, &ZkBls).expect("the witnessed replay folds");
assert_eq!(replay.extension(), self.state.extension(), "the guest's fold and the tracker's agree at block {n}");
assert!(ring.witnesses.is_empty(), "every witness consumed");
let (_, total) = voters_at(&self.state.keys, n, self.params.dust);
let leaf = HistoryLeaf { number: n, block_hash: block.block_hash, daa: n, table_root: self.state.table_root(), keys_hash: self.state.keys_hash(), total };
self.mmr.append(leaf.hash());
assert_eq!(self.mmr.root(), self.state.history_root());
self.leaves.insert(n, leaf);
self.tables.insert(n, self.state.keys.clone());
self.blocks.push(block);
Ok(())
}
/// The brute-force window count: blue blocks per key with DAA in (daa - W, daa].
fn brute(&self, daa: u64) -> HashMap<H, u64> {
let mut m = HashMap::new();
for b in &self.blocks {
for bl in &b.blues {
if bl.daa > daa.saturating_sub(self.params.weight_window) && bl.daa <= daa {
*m.entry(bl.key_hash).or_insert(0) += 1;
}
}
}
m
}
fn table_at(&self, number: u64) -> TableAt {
TableAt { leaf: self.leaves[&number].clone(), proof: self.mmr.proof(number).unwrap(), keys: self.tables[&number].clone() }
}
/// A certificate for index `index` over chain block `number`, signed by `signers`.
fn certificate(&self, index: u64, number: u64, signers: &[usize]) -> CertificateWitness {
let checkpoint = self.blocks[number as usize].block_hash;
let table = &self.tables[&number];
let (voters, _) = voters_at(table, number, self.params.dust);
let msg = vote_message(&self.params.chain_id, index, &checkpoint);
let mut sigs = Vec::new();
let mut positions = Vec::new();
for &s in signers {
let k = &self.keys[s];
let pos = voters.iter().position(|&v| table[v].key_hash == k.hash).expect("a signer is a voter");
positions.push(pos);
sigs.push(k.sk.sign(&msg, DST_VOTE, &[]));
}
let refs: Vec<&Signature> = sigs.iter().collect();
let agg = AggregateSignature::aggregate(&refs, true).unwrap().to_signature().compress();
let mut bitmap = vec![0u8; voters.len().div_ceil(8)];
for p in positions {
bitmap[p / 8] |= 1 << (p % 8);
}
let frozen = (self.state.lock.index > 0).then(|| self.table_at(self.state.lock.number));
CertificateWitness { index, checkpoint, voter_count: voters.len() as u32, bitmap, signature: agg, at: self.table_at(number), frozen }
}
}
fn params() -> FinParams {
FinParams { chain_id: "igneum-fintest".into(), weight_window: 200, dust: 5, min_daa: 200, leave_delay: 20, equivocation_ban: 200 }
}
#[test]
fn the_carried_table_equals_a_brute_force_count_of_the_window_at_every_block() {
let mut sim = Sim::new(params(), &["a", "b", "c", "d"]);
// keys mine 4:3:2:1 with side blocks; the window (200) rolls over twice in 500 blocks
for n in 0..500u64 {
let miner = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3][(n % 10) as usize];
let extra: Vec<usize> = if n % 7 == 0 { vec![1, 2] } else if n % 11 == 0 { vec![3] } else { vec![] };
sim.mine(miner, &extra, FoldWitness::default()).unwrap();
let brute = sim.brute(n);
for k in &sim.state.keys {
assert_eq!(k.blocks, brute.get(&k.key_hash).copied().unwrap_or(0), "key blocks at block {n}");
}
for (kh, b) in &brute {
let i = igneum_fin_core::find_key(&sim.state.keys, kh).expect("every key with blocks is in the table");
assert_eq!(sim.state.keys[i].blocks, *b);
}
let ring_count: u64 = sim.tracker.all_entries().len() as u64;
assert_eq!(ring_count, brute.values().sum::<u64>(), "the ring holds exactly the window at block {n}");
}
}
#[test]
fn a_block_counted_twice_is_refused_and_ageing_is_exact_at_the_edge() {
let mut sim = Sim::new(params(), &["a", "b"]);
for _ in 0..3 {
sim.mine(0, &[], FoldWitness::default()).unwrap();
}
// the same side block hash twice in one chain block
let n = sim.blocks.len() as u64;
let dup = BlueBlock { block_hash: hash_of("dup", 1), key_hash: sim.keys[1].hash, daa: n };
let block = ChainBlockWitness { number: n, block_hash: hash_of("chain", n), daa: n, blues: vec![BlueBlock { block_hash: hash_of("chain", n), key_hash: sim.keys[0].hash, daa: n }, dup.clone(), dup] };
let mut s = sim.state.clone();
let err = fold_block(&mut s, &sim.params, &block, &mut sim.tracker, &FoldWitness::default(), &sim.bls).unwrap_err();
assert!(err.contains("counted twice"), "{err}");
sim.tracker.take_witnesses();
// ageing: with W = 200, the block at DAA d leaves exactly when the chain reaches d + 200
let mut sim = Sim::new(params(), &["a", "b"]);
for _ in 0..201u64 {
sim.mine(0, &[], FoldWitness::default()).unwrap();
}
// blocks 0..=200 mined; at DAA 200 the window is (0, 200]: block 0 is out, 200 blocks counted
assert_eq!(sim.state.keys[0].blocks, 200);
sim.mine(0, &[], FoldWitness::default()).unwrap();
assert_eq!(sim.state.keys[0].blocks, 200, "one in, one out");
}
/// Builds a chain past the first-month gate with four keys of weight 40, 30, 20 and 10 percent, every key revealed.
fn chain_past_the_gate(labels: &[&str]) -> Sim {
let mut sim = Sim::new(params(), labels);
let mut w = FoldWitness::default();
w.reveals = (0..labels.len()).map(|i| reveal(&sim.keys[i])).collect();
sim.mine(0, &[], w).unwrap();
for n in 1..260u64 {
let miner = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3][(n % 10) as usize];
sim.mine(miner, &[], FoldWitness::default()).unwrap();
}
sim
}
#[test]
fn known_failed_first_todays_light_client_takes_the_voter_list_from_a_node_and_the_proof_does_not() {
// keys a, b, c, d hold 40, 30, 20, 10 percent of the real 200-block window; e is the attacker with nothing
let mut sim = chain_past_the_gate(&["a", "b", "c", "d", "e"]);
let cp = 250u64;
let real_table = sim.tables[&cp].clone();
let (real_voters, real_total) = voters_at(&real_table, cp, sim.params.dust);
assert_eq!(real_voters.len(), 4, "e mined nothing and is no voter");
// The attacker's node hands a light client a voter list of its own making: e with 70 percent of the weight. The
// certificate over the real checkpoint is signed by e alone. Today's client (site/verify/core.js, 10.4 items 2
// and 3) sums the weights of the list it was given, verifies the aggregate signature and locks: the rule it runs
// is right, the list is the node's word.
let e = &sim.keys[4];
let mut forged: Vec<KeyEntry> = real_table.clone();
let mut e_entry = KeyEntry::new(e.hash);
e_entry.pubkey = e.pk;
e_entry.blocks = real_total * 7 / 3 + 1;
let pos = forged.binary_search_by(|k| k.key_hash.cmp(&e.hash)).unwrap_err();
forged.insert(pos, e_entry);
let (forged_voters, forged_total) = voters_at(&forged, cp, sim.params.dust);
let checkpoint = sim.blocks[cp as usize].block_hash;
let msg = vote_message(&sim.params.chain_id, 9, &checkpoint);
let sig = e.sk.sign(&msg, DST_VOTE, &[]).compress();
let e_pos = forged_voters.iter().position(|&v| forged[v].key_hash == e.hash).unwrap();
let mut bitmap = vec![0u8; forged_voters.len().div_ceil(8)];
bitmap[e_pos / 8] |= 1 << (e_pos % 8);
// today's client, the JS logic as Rust
let positions = signer_positions(&bitmap, forged_voters.len() as u32);
let signed: u64 = positions.iter().map(|&p| forged[forged_voters[p]].blocks).sum();
let pks: Vec<[u8; PUBKEY_LEN]> = positions.iter().map(|&p| forged[forged_voters[p]].pubkey).collect();
let sig_ok = BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &sig);
let todays_client_locks = sig_ok && FinParams::floor_met(signed, forged_total);
assert!(todays_client_locks, "KNOWN FAILED: the client of spec 10.4 locks on a forged voter list ({signed} of {forged_total} signed)");
// The proof-carried table: the same certificate presented to the fold with the forged table as the witness
// is refused (the table at the checkpoint is the one the proof committed), and with the real table it is
// refused too (e holds nothing there and is no voter: the bitmap names a position outside the list, or e's
// weight is zero).
let forged_at = TableAt { leaf: sim.leaves[&cp].clone(), proof: sim.mmr.proof(cp).unwrap(), keys: forged.clone() };
let cert = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap: bitmap.clone(), signature: sig, at: forged_at, frozen: None };
let mut w = FoldWitness::default();
w.certificates.push(cert);
let err = sim.mine(1, &[], w).unwrap_err();
assert!(err.contains("not the one the proof committed"), "the forged table is refused: {err}");
let cert_real = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap, signature: sig, at: sim.table_at(cp), frozen: None };
let mut w = FoldWitness::default();
w.certificates.push(cert_real);
let err = sim.mine(1, &[], w).unwrap_err();
assert!(err.contains("names 5 voters"), "the real table has four voters: {err}");
assert_eq!(sim.state.lock.index, 0, "no lock from the attacker");
// and the honest certificate (a, b, c: 90 percent) locks, with the extension saying so
let cert = sim.certificate(9, cp, &[0, 1, 2]);
let mut w = FoldWitness::default();
w.certificates.push(cert);
sim.mine(1, &[], w).unwrap();
let ext = sim.state.extension();
assert_eq!(ext.lock.index, 9);
assert_eq!(ext.lock.hash, checkpoint);
assert_eq!(ext.lock.number, cp);
assert!(FinParams::floor_met(ext.lock.signed, ext.lock.total));
assert_eq!(ext.lock.total, real_total);
assert!(!ext.stale());
}
#[test]
fn two_thirds_is_inclusive_and_under_it_is_refused() {
// six equal keys: 4 of 6 locks, 3 of 6 does not (spec 3.10 Q3's unit test, here inside the proof)
let mut sim = Sim::new(params(), &["a", "b", "c", "d", "e", "f"]);
let mut w = FoldWitness::default();
w.reveals = (0..6).map(|i| reveal(&sim.keys[i])).collect();
sim.mine(0, &[], w).unwrap();
for n in 1..250u64 {
sim.mine((n % 6) as usize, &[], FoldWitness::default()).unwrap();
}
let cp = 240u64;
let three = sim.certificate(8, cp, &[0, 1, 2]);
let mut w = FoldWitness::default();
w.certificates.push(three);
let err = sim.mine(0, &[], w).unwrap_err();
assert!(err.contains("under two thirds (Q3)"), "{err}");
let four = sim.certificate(8, cp, &[0, 1, 2, 3]);
let mut w = FoldWitness::default();
w.certificates.push(four);
sim.mine(0, &[], w).unwrap();
assert_eq!(sim.state.lock.index, 8);
// a second certificate at a locked index is refused, as is one below it
let again = sim.certificate(8, cp, &[0, 1, 2, 3, 4]);
let mut w = FoldWitness::default();
w.certificates.push(again);
let err = sim.mine(0, &[], w).unwrap_err();
assert!(err.contains("not above the last lock"), "{err}");
}
#[test]
fn the_frozen_table_holds_a_side_without_its_partner_and_a_leave_releases_it_after_the_delay() {
// a (60 percent) and b (40 percent) lock together; b stops; a alone cannot lock against the frozen table however
// long it mines; b's leave shrinks the frozen denominator after leave_delay and a locks; after a window with no
// lock the proof chain is stale and refuses everything
let mut sim = Sim::new(params(), &["a", "b"]);
let mut w = FoldWitness::default();
w.reveals = vec![reveal(&sim.keys[0]), reveal(&sim.keys[1])];
sim.mine(0, &[], w).unwrap();
for n in 1..230u64 {
sim.mine(if n % 5 < 3 { 0 } else { 1 }, &[], FoldWitness::default()).unwrap();
}
let cert = sim.certificate(7, 220, &[0, 1]);
let mut w = FoldWitness::default();
w.certificates.push(cert);
sim.mine(0, &[], w).unwrap();
assert_eq!(sim.state.lock.index, 7);
let lock_daa = sim.state.lock.daa;
// b stops; a mines alone for 150 blocks: under v2 a holds two thirds of its own sliding table by then
for _ in 0..150 {
sim.mine(0, &[], FoldWitness::default()).unwrap();
}
let n = sim.blocks.len() as u64 - 1;
let (_, total) = voters_at(&sim.state.keys, n, sim.params.dust);
let a_blocks = sim.state.keys.iter().find(|k| k.key_hash == sim.keys[0].hash).unwrap().blocks;
assert!(FinParams::floor_met(a_blocks, total), "a holds two thirds of the sliding table by now ({a_blocks} of {total})");
let cert = sim.certificate(12, n, &[0]);
let mut w = FoldWitness::default();
w.certificates.push(cert);
let err = sim.mine(0, &[], w).unwrap_err();
assert!(err.contains("(Q5)"), "the frozen table holds a: {err}");
// b's leave, carried now; it takes effect leave_delay later
let leave_daa = sim.blocks.len() as u64;
let leave = LeaveWitness { daa: leave_daa, pubkey: sim.keys[1].pk, signature: sim.keys[1].sk.sign(&igneum_fin_core::leave_message(&sim.params.chain_id, leave_daa), DST_LEAVE, &[]).compress(), carrier_daa: leave_daa };
let mut w = FoldWitness::default();
w.leaves.push(leave);
sim.mine(0, &[], w).unwrap();
for _ in 0..5 {
sim.mine(0, &[], FoldWitness::default()).unwrap();
}
let n = sim.blocks.len() as u64 - 1;
let cert = sim.certificate(13, n, &[0]);
let mut w = FoldWitness::default();
w.certificates.push(cert);
let err = sim.mine(0, &[], w).unwrap_err();
assert!(err.contains("(Q5)"), "before the delay the leave removes nothing: {err}");
for _ in 0..sim.params.leave_delay {
sim.mine(0, &[], FoldWitness::default()).unwrap();
}
let n = sim.blocks.len() as u64 - 1;
assert!(n < lock_daa + sim.params.weight_window, "still inside the window after the last lock");
let cert = sim.certificate(14, n, &[0]);
let mut w = FoldWitness::default();
w.certificates.push(cert);
sim.mine(0, &[], w).unwrap();
assert_eq!(sim.state.lock.index, 14, "after the delay b is out of the frozen denominator and a locks alone");
assert!(sim.state.lock.frozen_total < sim.state.lock.total + sim.state.lock.frozen_total, "the frozen total shrank");
// stale: a window with no lock
let since = sim.state.lock.daa;
while (sim.blocks.len() as u64) < since + sim.params.weight_window + 2 {
sim.mine(0, &[], FoldWitness::default()).unwrap();
}
assert!(sim.state.stale);
let n = sim.blocks.len() as u64 - 1;
let cert = sim.certificate(20, n, &[0]);
let mut w = FoldWitness::default();
w.certificates.push(cert);
let err = sim.mine(0, &[], w).unwrap_err();
assert!(err.contains("stale"), "{err}");
assert!(sim.state.extension().stale());
}
#[test]
fn a_light_client_answers_final_at_checkpoint_from_the_extension_and_a_history_proof_alone() {
let mut sim = chain_past_the_gate(&["a", "b", "c", "d"]);
let cert = sim.certificate(9, 250, &[0, 1, 2]);
let mut w = FoldWitness::default();
w.certificates.push(cert);
sim.mine(1, &[], w).unwrap();
for _ in 0..10 {
sim.mine(0, &[], FoldWitness::default()).unwrap();
}
// what a verifier holds: the extension bytes of the latest proof (the proof itself verified elsewhere)
let bytes = sim.state.extension().to_bytes();
assert_eq!(bytes.len(), FinExt::LEN);
let ext = FinExt::from_bytes(&bytes).unwrap();
let leaves = sim.state.leaves;
// block 240 is at or below the lock's block 250: final; block 255 is not; a tampered leaf is not in the history
let answer = |number: u64| -> &'static str {
let leaf = sim.leaves[&number].clone();
let proof = sim.mmr.proof(number).unwrap();
if !proof.verify(&leaf.hash(), &ext.history_root, leaves) {
return "not in this chain";
}
if ext.stale() {
return "stale";
}
if leaf.number <= ext.lock.number {
"final"
} else {
"not final"
}
};
assert_eq!(answer(240), "final");
assert_eq!(answer(250), "final");
assert_eq!(answer(255), "not final");
let mut bad = sim.leaves[&240].clone();
bad.block_hash = hash_of("other", 240);
assert!(!sim.mmr.proof(240).unwrap().verify(&bad.hash(), &ext.history_root, leaves));
assert_eq!(keys_hash(&sim.tables[&250]), sim.leaves[&250].keys_hash);
}
#[test]
fn the_guest_curve_and_blst_agree_on_real_certificates_and_disagree_with_nothing() {
let sim = chain_past_the_gate(&["a", "b", "c"]);
let cert = sim.certificate(9, 250, &[0, 1]);
let table = &sim.tables[&250];
let (voters, _) = voters_at(table, 250, sim.params.dust);
let pks: Vec<[u8; PUBKEY_LEN]> = signer_positions(&cert.bitmap, cert.voter_count).iter().map(|&p| table[voters[p]].pubkey).collect();
let msg = vote_message(&sim.params.chain_id, 9, &cert.checkpoint);
assert!(BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &cert.signature));
assert!(ZkBls.verify_aggregate(&pks, &msg, DST_VOTE, &cert.signature));
let mut bad = cert.signature;
bad[10] ^= 1;
assert!(!BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &bad));
assert!(!ZkBls.verify_aggregate(&pks, &msg, DST_VOTE, &bad));
let other = vote_message(&sim.params.chain_id, 10, &cert.checkpoint);
assert!(!ZkBls.verify_aggregate(&pks, &other, DST_VOTE, &cert.signature));
// a proof of possession
let r = reveal(&sim.keys[0]);
assert!(ZkBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop));
assert!(BlstBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop));
}

View file

@ -0,0 +1,130 @@
//! Finality in the proof, the host's half (docs/design/finality-in-proof.md): the witness file an aggregation
//! takes (`--fin <file>`), folded natively first so every block's input state is known, and the light client's
//! question (`--mode final-at`) answered from a proof's extension and a history proof alone.
use anyhow::{anyhow, bail, Context, Result};
use igneum_fin_core::cert::TableAt;
use igneum_fin_core::fold::{fold_block, ChainBlockWitness, FoldWitness};
use igneum_fin_core::mmr::{HistoryLeaf, MmrProof};
use igneum_fin_core::ring::{RingLeafWitness, WitnessRing};
use igneum_fin_core::{FinExt, FinParams, FinState};
use igneum_prove_core::agg::{BlockOutput, FinInput};
use serde::{Deserialize, Serialize};
/// One chain block's finality witness as the node's RPC hands it to the aggregator.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BlockFin {
pub block: ChainBlockWitness,
pub ring: Vec<RingLeafWitness>,
#[serde(default)]
pub witness: FoldWitness,
}
/// `--fin <file>`: the parameters, the state before the first block, and every block's witness in order.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FinWitnessFile {
pub format: String,
pub params: FinParams,
pub root_state: FinState,
pub blocks: Vec<BlockFin>,
}
pub const FORMAT: &str = "igneum-fin-witness-v1";
/// Folds every block natively (the known-finished case before any proof) and returns one `FinInput` per block,
/// each with the state its fold starts from.
pub fn prepare(file: &FinWitnessFile, first_number: u64) -> Result<Vec<FinInput>> {
if file.format != FORMAT {
bail!("finality witness format {} (want {FORMAT})", file.format);
}
let mut state = file.root_state.clone();
if state.end_number + 1 != first_number && !(state.leaves == 0 && state.history_first == first_number) {
bail!("the finality root state ends at chain block {} and the first block to aggregate is {first_number}", state.end_number);
}
let mut out = Vec::with_capacity(file.blocks.len());
for (i, b) in file.blocks.iter().enumerate() {
if b.block.number != first_number + i as u64 {
bail!("finality witness {i} is of chain block {}, expected {}", b.block.number, first_number + i as u64);
}
let input = FinInput { params: file.params.clone(), prev_state: state.clone(), block: b.block.clone(), ring: b.ring.clone(), witness: b.witness.clone() };
let mut ring = WitnessRing::new(b.ring.clone());
fold_block(&mut state, &file.params, &b.block, &mut ring, &b.witness, &igneum_fin_core::bls::ZkBls).map_err(|e| anyhow!("finality fold of chain block {} natively: {e}", b.block.number))?;
if !ring.witnesses.is_empty() {
bail!("finality witness of chain block {} carries {} ring leaves the fold did not open", b.block.number, ring.witnesses.len());
}
out.push(input);
}
Ok(out)
}
/// `--block <file>`: a chain block's history leaf and its proof at the size of the proof's history.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BlockQuery {
pub leaf: HistoryLeaf,
pub proof: MmrProof,
}
/// The light client's answer, from the extension alone (the proof itself verified by the caller).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Answer {
/// Final at checkpoint `index` (locked at chain block `lock_number`).
Final { index: u64, lock_number: u64 },
/// On this chain but above the latest lock.
NotFinal { lock_index: u64, lock_number: u64 },
/// The proof chain has had no lock for a full window: the client needs a fresh root.
Stale { lock_index: u64 },
/// The block is not in the proof's history.
NotInChain,
/// The proof carries no finality claim (made before the activation).
NoClaim,
}
pub fn answer(output: &BlockOutput, history_leaves: u64, query: Option<&BlockQuery>) -> Answer {
let Some(ext) = &output.fin else { return Answer::NoClaim };
let (number, in_chain) = match query {
None => (output.number, true),
Some(q) => (q.leaf.number, q.proof.verify(&q.leaf.hash(), &ext.history_root, history_leaves)),
};
if !in_chain {
return Answer::NotInChain;
}
if ext.stale() {
return Answer::Stale { lock_index: ext.lock.index };
}
if ext.lock.index > 0 && number <= ext.lock.number {
Answer::Final { index: ext.lock.index, lock_number: ext.lock.number }
} else {
Answer::NotFinal { lock_index: ext.lock.index, lock_number: ext.lock.number }
}
}
/// The history's leaf count from the extension: `number - history_first + 1` when the attestation rooted at an
/// empty state; a witness root carries more, so the query names the count it was made at.
pub fn leaves_hint(ext: &FinExt, output: &BlockOutput) -> u64 {
output.number.saturating_sub(ext.history_first) + 1
}
pub fn describe(a: &Answer) -> String {
match a {
Answer::Final { index, lock_number } => format!("final at checkpoint {index} (locked at chain block {lock_number})"),
Answer::NotFinal { lock_index, lock_number } => format!("not final (latest lock: checkpoint {lock_index} at chain block {lock_number})"),
Answer::Stale { lock_index } => format!("stale: no lock for a full window after checkpoint {lock_index}; this client needs a fresh root"),
Answer::NotInChain => "not in this chain".into(),
Answer::NoClaim => "no finality claim in this proof".into(),
}
}
pub fn load_witness(path: &str) -> Result<FinWitnessFile> {
let text = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
serde_json::from_str(&text).with_context(|| format!("{path} is not a finality witness file"))
}
pub fn load_query(path: &str) -> Result<BlockQuery> {
let text = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
serde_json::from_str(&text).with_context(|| format!("{path} is not a block query"))
}
/// Certificate witnesses carry tables; this is what one weighs on the wire.
pub fn table_bytes(t: &TableAt) -> usize {
t.keys.len() * igneum_fin_core::KeyEntry::LEN + 32 * (t.proof.siblings.len() + t.proof.peaks.len())
}

View file

@ -20,6 +20,7 @@
//! ids with no setup. `--mode verify` uses SP1's light verifier and the pinned verifying key: no prover client,
//! no key generation (the 114 s to 138 s the Mac's node spent per proof on 5 October).
mod fin;
mod pinned;
mod proof_system;
@ -82,11 +83,18 @@ fn run() -> Result<()> {
// proving v1 (spec 7.8): the node's pool verifies an aggregated segment proof against the pinned aggregator key
return run_verify_segment(&pinned, &arg("--proof").context("--proof <file>")?, &arg("--statement").context("--statement 0x<keccak of the block public values>")?);
}
if mode == "final-at" {
// finality in the proof (docs/design/finality-in-proof.md section 4): the light client's question answered
// from one verified proof's extension and, for an earlier block, a history proof; no node asked
return run_final_at(&pinned, &arg("--proof").context("--proof <file>")?, arg("--block").as_deref(), arg("--leaves").as_deref());
}
let prover: Address = arg("--prover").map(|s| s.parse()).transpose()?.unwrap_or_else(|| Address::from_slice(&[0x19; 20]));
let out_path = arg("--out");
// --fin <file>: the finality witness of every block aggregated (the statement gains the extension)
let fin_path = arg("--fin");
if mode == "aggregate" {
// proving v1: the live aggregator, from shard proof files (the node's pool) and the previous segment proof
return run_aggregate(&pinned, &arg("--proofs").context("--proofs <a.bin,b.bin,...> (the segment's shard proofs in shard order)")?, &arg("--parent").context("--parent 0x<parent chain block hash>")?, arg("--prev").as_deref(), out_path.as_deref());
return run_aggregate(&pinned, &arg("--proofs").context("--proofs <a.bin,b.bin,...> (the segment's shard proofs in shard order)")?, &arg("--parent").context("--parent 0x<parent chain block hash>")?, arg("--prev").as_deref(), out_path.as_deref(), fin_path.as_deref());
}
if mode == "chain" {
// proving v1: N consecutive fixtures proven shard by shard, each block aggregated with the previous block's
@ -100,7 +108,7 @@ fn run() -> Result<()> {
// 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());
return run_chain(&pinned, &fixtures, prover, out_path.as_deref(), save_shards, prev.as_deref(), fin_path.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] [--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);
@ -625,7 +633,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, prev_path: Option<&str>) -> Result<()> {
fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_path: Option<&str>, save_shards: bool, prev_path: Option<&str>, fin_path: Option<&str>) -> Result<()> {
if fixtures.is_empty() {
bail!("--chain needs at least one fixture");
}
@ -678,6 +686,20 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
}
};
let base_len = prev.as_ref().map(|p| p.output.chain_len).unwrap_or(0);
// the finality witnesses, folded natively first (the known-finished case before any proof)
let mut fin_inputs = match fin_path {
None => Vec::new(),
Some(p) => {
let file = fin::load_witness(p)?;
let inputs = fin::prepare(&file, first)?;
if inputs.len() != built.len() {
bail!("{p} carries {} finality witnesses for {} blocks", inputs.len(), built.len());
}
println!("RESULT chain fin: {} finality witnesses folded natively, root state ends at chain block {} with {} keys, {} ring leaves opened, {} certificates at {}", inputs.len(), file.root_state.end_number, file.root_state.keys.len(), file.blocks.iter().map(|b| b.ring.len()).sum::<usize>(), file.blocks.iter().map(|b| b.witness.certificates.len()).sum::<usize>(), now());
inputs
}
};
fin_inputs.reverse();
let sp1 = setup_sp1(pinned, &mut results)?;
let chain_t = Instant::now();
let mut blocks_json = Vec::with_capacity(built.len());
@ -718,7 +740,10 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
}
shards_total += proofs.len();
stage(&format!("chain block {number} aggregate {} shards{}", proofs.len(), if prev.is_some() { " with the previous block proof" } else { "" }));
let seg = sp1.aggregate(prev.as_ref(), &proofs)?;
let seg = sp1.aggregate_with(prev.as_ref(), &proofs, fin_inputs.pop())?;
if let Some(f) = &seg.output.fin {
println!("RESULT chain block {number} fin: table root {} history root {} lock index {} at chain block {} ({} of {} signed, frozen {} of {}){} at {}", B256::from(f.table_root), B256::from(f.history_root), f.lock.index, f.lock.number, f.lock.signed, f.lock.total, f.lock.frozen_signed, f.lock.frozen_total, if f.stale() { ", STALE" } else { "" }, now());
}
let adt = sp1.last_timing("aggregate").unwrap_or_default().as_secs_f64();
let sdt = sp1.last_timing("aggregate-stdin").unwrap_or_default().as_secs_f64();
agg_total += adt;
@ -785,7 +810,7 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
/// previous segment's aggregated proof when the chain continues. Each block is aggregated with the previous
/// block's proof in one process (one key setup); the output is the last block's aggregated proof, its public
/// values, the statement and the proof hash the segment record carries.
fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path: Option<&str>, out_path: Option<&str>) -> Result<()> {
fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path: Option<&str>, out_path: Option<&str>, fin_path: Option<&str>) -> Result<()> {
let first_parent: B256 = parent.parse().context("--parent is not 32 bytes of hex")?;
let mut results = serde_json::Map::new();
results.insert("mode".into(), "aggregate".into());
@ -831,6 +856,19 @@ fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path:
let first = blocks[0][0].output.number;
let last = blocks[blocks.len() - 1][0].output.number;
println!("igneum-prove-host sources {}: aggregate blocks {first}..={last} ({} shard proofs){}; {}", env!("IGNEUM_PROVE_SOURCES"), blocks.iter().map(|b| b.len()).sum::<usize>(), prev.as_ref().map(|p| format!(", chaining to block {} (chain_len {})", p.output.number, p.output.chain_len)).unwrap_or_default(), now());
let mut fin_inputs = match fin_path {
None => Vec::new(),
Some(p) => {
let file = fin::load_witness(p)?;
let inputs = fin::prepare(&file, first)?;
if inputs.len() != blocks.len() {
bail!("{p} carries {} finality witnesses for {} blocks", inputs.len(), blocks.len());
}
println!("RESULT aggregate fin: {} finality witnesses folded natively at {}", inputs.len(), now());
inputs
}
};
fin_inputs.reverse();
let sp1 = setup_sp1(pinned, &mut results)?;
let t_all = Instant::now();
let mut per_block = Vec::new();
@ -838,7 +876,10 @@ fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path:
let number = shards[0].output.number;
stage(&format!("aggregate block {number}, {} shards", shards.len()));
let deferred = shards.len() + usize::from(prev.is_some());
let seg = sp1.aggregate(prev.as_ref(), shards)?;
let seg = sp1.aggregate_with(prev.as_ref(), shards, fin_inputs.pop())?;
if let Some(f) = &seg.output.fin {
println!("RESULT aggregate block {number} fin: lock index {} at chain block {} ({} of {} signed){} at {}", f.lock.index, f.lock.number, f.lock.signed, f.lock.total, if f.stale() { ", STALE" } else { "" }, now());
}
let adt = sp1.last_timing("aggregate").unwrap_or_default().as_secs_f64();
let sdt = sp1.last_timing("aggregate-stdin").unwrap_or_default().as_secs_f64();
let claim = SegmentClaim::from_block(&seg.output);
@ -911,6 +952,42 @@ fn run_verify_segment(pinned: &pinned::Pinned, proof_path: &str, statement: &str
}
}
/// `--mode final-at --proof <file> [--block <query.json>] [--leaves N]`: the light client of design section 4.
/// Verifies the segment proof with the light verifier against the pinned aggregator key, then answers from the
/// extension: the proof's own last block, or the block a history leaf and proof name. Exit 0 = final, 4 = not
/// final, 5 = stale or no claim, 3 = the proof did not verify.
fn run_final_at(pinned: &pinned::Pinned, proof_path: &str, block_path: Option<&str>, leaves: Option<&str>) -> Result<()> {
use sp1_sdk::blocking::{LightProver, Prover};
let bytes = std::fs::read(proof_path).with_context(|| format!("read {proof_path}"))?;
let t = Instant::now();
let verifier = LightProver::new();
let proof: sp1_sdk::SP1ProofWithPublicValues = bincode::deserialize(&bytes).context("the file is not a bincode SP1 proof")?;
let same_program = pinned::claimed_program_id(&proof) == Some(pinned.agg_id);
let output = BlockOutput::from_bytes(proof.public_values.as_slice()).context("public values are not a block statement")?;
let ids_ok = output.shard_vk == pinned.shard_id && (output.agg_vk == pinned.agg_id || (output.chain_len == 1 && output.agg_vk == B256::ZERO));
let ok = same_program && ids_ok && verifier.verify(&proof, &pinned.agg_vk, None).is_ok();
let vdt = t.elapsed().as_secs_f64();
if !ok {
println!("RESULT final-at: the proof did not verify ({:.3} s) at {}", vdt, now());
std::process::exit(3)
}
let t = Instant::now();
let query = block_path.map(fin::load_query).transpose()?;
let history_leaves = match (leaves, &output.fin) {
(Some(n), _) => n.parse::<u64>().context("--leaves N")?,
(None, Some(ext)) => fin::leaves_hint(ext, &output),
(None, None) => 0,
};
let a = fin::answer(&output, history_leaves, query.as_ref());
let adt = t.elapsed().as_secs_f64();
println!("RESULT final-at: {}; proof of chain block {} ({}) chain_len {} verified in {vdt:.3} s, answered in {adt:.6} s from {} bytes of public values; {} at {}", fin::describe(&a), output.number, output.block_hash, output.chain_len, proof.public_values.as_slice().len(), output.fin.as_ref().map(|f| format!("history root {} first {} lock {} at block {} signed {} of {}", B256::from(f.history_root), f.history_first, f.lock.index, f.lock.number, f.lock.signed, f.lock.total)).unwrap_or_else(|| "no extension".into()), now());
match a {
fin::Answer::Final { .. } => Ok(()),
fin::Answer::NotFinal { .. } | fin::Answer::NotInChain => std::process::exit(4),
fin::Answer::Stale { .. } | fin::Answer::NoClaim => std::process::exit(5),
}
}
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:?}");

View file

@ -295,34 +295,12 @@ impl ProofSystem for Sp1ProofSystem {
/// 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> {
let first = shards.first().ok_or_else(|| anyhow!("no shards"))?;
// 5 October 2026 (aggregation cost): the stdin build (the proof clones into the request) is timed apart
// from the prove call, so the host's own share of an aggregation is visible next to the GPU's.
let t_stdin = Instant::now();
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());
}
self.record("aggregate-stdin", t_stdin.elapsed());
let t = Instant::now();
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 })
self.aggregate_with(prev, shards, None)
}
fn pgas_table(&self) -> &PgasTable {
&self.table
}
fn wrap(&self, _p: &Sp1SegmentProof) -> Result<Sp1WrappedProof> {
Err(anyhow!("wrap (Groth16 or Plonk over bn254) is not run: it needs SP1's circuit artifacts and is the ledger P3 measurement"))
}
@ -340,8 +318,38 @@ impl ProofSystem for Sp1ProofSystem {
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 {
/// The aggregation with the finality fold (docs/design/finality-in-proof.md): `fin` is this chain block's
/// finality input; the guest folds it and the statement gains the extension.
pub fn aggregate_with(&self, prev: Option<&Sp1SegmentProof>, shards: &[Sp1ShardProof], fin: Option<igneum_prove_core::agg::FinInput>) -> Result<Sp1SegmentProof> {
let first = shards.first().ok_or_else(|| anyhow!("no shards"))?;
// 5 October 2026 (aggregation cost): the stdin build (the proof clones into the request) is timed apart
// from the prove call, so the host's own share of an aggregation is visible next to the GPU's.
let t_stdin = Instant::now();
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() }),
fin,
};
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());
}
self.record("aggregate-stdin", t_stdin.elapsed());
let t = Instant::now();
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 })
}
}