diff --git a/proving/igneum-prove/Cargo.lock b/proving/igneum-prove/Cargo.lock index 9978a77c4..83b39d497 100644 --- a/proving/igneum-prove/Cargo.lock +++ b/proving/igneum-prove/Cargo.lock @@ -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" diff --git a/proving/igneum-prove/Cargo.toml b/proving/igneum-prove/Cargo.toml index 71b460efa..117902fd6 100644 --- a/proving/igneum-prove/Cargo.toml +++ b/proving/igneum-prove/Cargo.toml @@ -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" } diff --git a/proving/igneum-prove/core/Cargo.toml b/proving/igneum-prove/core/Cargo.toml index 3b1ddf4fb..9ecf761c2 100644 --- a/proving/igneum-prove/core/Cargo.toml +++ b/proving/igneum-prove/core/Cargo.toml @@ -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 diff --git a/proving/igneum-prove/core/src/agg.rs b/proving/igneum-prove/core/src/agg.rs index 8f8277880..4770ed1de 100644 --- a/proving/igneum-prove/core/src/agg.rs +++ b/proving/igneum-prove/core/src/agg.rs @@ -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, } +/// 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, + 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>, pub parent_hash: B256, pub prev: Option, + /// 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, } #[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, } 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 { 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 { - 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 = 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, } } diff --git a/proving/igneum-prove/fin/Cargo.toml b/proving/igneum-prove/fin/Cargo.toml new file mode 100644 index 000000000..6c319678f --- /dev/null +++ b/proving/igneum-prove/fin/Cargo.toml @@ -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"] diff --git a/proving/igneum-prove/fin/src/bls.rs b/proving/igneum-prove/fin/src/bls.rs new file mode 100644 index 000000000..3c2716789 --- /dev/null +++ b/proving/igneum-prove/fin/src/bls.rs @@ -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 = >>::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() + } +} diff --git a/proving/igneum-prove/fin/src/cert.rs b/proving/igneum-prove/fin/src/cert.rs new file mode 100644 index 000000000..3f41ca6e2 --- /dev/null +++ b/proving/igneum-prove/fin/src/cert.rs @@ -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, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CertificateWitness { + pub index: u64, + pub checkpoint: H, + pub voter_count: u32, + pub bitmap: Vec, + #[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, +} + +pub fn signer_positions(bitmap: &[u8], voter_count: u32) -> Vec { + 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 = 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(¶ms.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(()) +} diff --git a/proving/igneum-prove/fin/src/fold.rs b/proving/igneum-prove/fin/src/fold.rs new file mode 100644 index 000000000..dd55c3b7f --- /dev/null +++ b/proving/igneum-prove/fin/src/fold.rs @@ -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, +} + +/// 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, + pub evidence: Vec, + pub leaves: Vec, + pub certificates: Vec, +} + +/// 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, 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 { + 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, Vec) = 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(¶ms.chain_id, e.index, &e.hash_a), crate::DST_VOTE, &e.sig_a); + let ok_b = bls.verify_one(&e.pubkey, &vote_message(¶ms.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(¶ms.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 +} diff --git a/proving/igneum-prove/fin/src/lib.rs b/proving/igneum-prove/fin/src/lib.rs new file mode 100644 index 000000000..b786a6419 --- /dev/null +++ b/proving/igneum-prove/fin/src/lib.rs @@ -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 { + 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 { + 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) { + 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, 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 { + 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, + /// 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 { + 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 { + 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(a: &[u8; N], s: S) -> Result { + 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 = Vec::deserialize(d)?; + v.try_into().map_err(|v: Vec| serde::de::Error::custom(format!("expected {N} bytes, got {}", v.len()))) + } +} diff --git a/proving/igneum-prove/fin/src/mmr.rs b/proving/igneum-prove/fin/src/mmr.rs new file mode 100644 index 000000000..7789747b9 --- /dev/null +++ b/proving/igneum-prove/fin/src/mmr.rs @@ -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 = 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, + 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 { + 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 = 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 }) + } +} diff --git a/proving/igneum-prove/fin/src/ring.rs b/proving/igneum-prove/fin/src/ring.rs new file mode 100644 index 000000000..166727b4e --- /dev/null +++ b/proving/igneum-prove/fin/src/ring.rs @@ -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) { + 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 { + 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, + pub siblings: Vec, +} + +/// 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, Vec), String>; + /// Writes `entries` to `slot` and returns the new root (the siblings are those `open` returned). + fn write(&mut self, slot: u32, entries: Vec, siblings: &[H]) -> H; +} + +/// The guest's ring: a queue of witnesses. +pub struct WitnessRing { + pub witnesses: std::collections::VecDeque, +} + +impl WitnessRing { + pub fn new(witnesses: Vec) -> Self { + Self { witnesses: witnesses.into() } + } +} + +impl RingAccess for WitnessRing { + fn open(&mut self, slot: u32, root: &H) -> Result<(Vec, Vec), 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, siblings: &[H]) -> H { + root_from_path(slot, leaf_hash(&entries), siblings) + } +} diff --git a/proving/igneum-prove/fin/src/tracker.rs b/proving/igneum-prove/fin/src/tracker.rs new file mode 100644 index 000000000..d3889c546 --- /dev/null +++ b/proving/igneum-prove/fin/src/tracker.rs @@ -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, + /// (level, index) -> hash, for nodes that differ from the level's default + nodes: HashMap<(u8, u64), H>, + leaves: HashMap>, + /// Every leaf opened, in order: the witnesses a guest needs to replay the same fold. + pub recorded: Vec, +} + +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 { + 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 { + self.leaves.get(&slot).cloned().unwrap_or_default() + } + + pub fn set(&mut self, slot: u32, entries: Vec) { + 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 { + 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 { + let mut v: Vec = self.leaves.values().flatten().cloned().collect(); + v.sort(); + v + } +} + +impl RingAccess for SparseRing { + fn open(&mut self, slot: u32, root: &H) -> Result<(Vec, Vec), 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, _siblings: &[H]) -> H { + self.set(slot, entries); + self.root() + } +} diff --git a/proving/igneum-prove/fin/tests/harness.rs b/proving/igneum-prove/fin/tests/harness.rs new file mode 100644 index 000000000..9a320fe9d --- /dev/null +++ b/proving/igneum-prove/fin/tests/harness.rs @@ -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> = 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, + /// (number, hash, daa, blues) per chain block + blocks: Vec, + /// the full table after each block (number -> keys) and the history leaves, from the tracker's fold + tables: HashMap>, + mmr: Mmr, + leaves: HashMap, + tracker: SparseRing, + state: FinState, + bls: BlstBls, +} + +impl Sim { + fn new(params: FinParams, labels: &[&str]) -> Self { + let keys: Vec = labels.iter().map(|l| key(l)).collect(); + let state = FinState::empty(¶ms, 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 { + 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 = 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::(), "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 = 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)); +} diff --git a/proving/igneum-prove/host/src/fin.rs b/proving/igneum-prove/host/src/fin.rs new file mode 100644 index 000000000..f42f264b3 --- /dev/null +++ b/proving/igneum-prove/host/src/fin.rs @@ -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 `), 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, + #[serde(default)] + pub witness: FoldWitness, +} + +/// `--fin `: 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, +} + +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> { + 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 `: 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 { + 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 { + 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()) +} diff --git a/proving/igneum-prove/host/src/main.rs b/proving/igneum-prove/host/src/main.rs index 8b261007b..56c9ca1bc 100644 --- a/proving/igneum-prove/host/src/main.rs +++ b/proving/igneum-prove/host/src/main.rs @@ -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 ")?, &arg("--statement").context("--statement 0x")?); } + 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 ")?, 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 : 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 (the segment's shard proofs in shard order)")?, &arg("--parent").context("--parent 0x")?, arg("--prev").as_deref(), out_path.as_deref()); + return run_aggregate(&pinned, &arg("--proofs").context("--proofs (the segment's shard proofs in shard order)")?, &arg("--parent").context("--parent 0x")?, 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 [--mode native|execute|shard|compressed|block|all] [--shard N] [--budget ] [--prover 0x..] [--out results.json]; --mode chain --chain [--prover 0x..] [--out results.json] [--save-shards] [--prev prev.bin]; --mode aggregate --proofs --parent 0x.. [--prev prev.bin] [--out results.json]; --mode verify --proof --statement 0x..; --mode verify-segment --proof --statement 0x..; --mode id")?; let shard_index: usize = arg("--shard").map(|s| s.parse()).transpose()?.unwrap_or(0); @@ -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::(), file.blocks.iter().map(|b| b.witness.certificates.len()).sum::(), 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::(), 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 [--block ] [--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::().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:?}"); diff --git a/proving/igneum-prove/host/src/proof_system.rs b/proving/igneum-prove/host/src/proof_system.rs index bfc60e2ea..18559e65c 100644 --- a/proving/igneum-prove/host/src/proof_system.rs +++ b/proving/igneum-prove/host/src/proof_system.rs @@ -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 { - 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 { 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) -> Result { + 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 }) } }