fin-proof: igneum-fin-core (the carried table, ring, history, certificate step), the fold in the aggregator, host --fin and final-at
The W2 table folded one chain block at a time with the ring witnessed and the key table inline; the certificate verified against the table the proof committed at its own checkpoint; the frozen table never expiring in the proof; the harness tests with real BLS keys, the known-failed case first. The evm-types path dependency now names vendor/igneum-node (this lane's clone of release-0.3.18-node). Work in progress: not yet built. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b0c4d5d222
commit
a83fdba796
16 changed files with 1995 additions and 39 deletions
50
proving/igneum-prove/Cargo.lock
generated
50
proving/igneum-prove/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
use crate::executor::Carry;
|
||||
use crate::shard::ShardOutput;
|
||||
use alloy_primitives::{keccak256, B256};
|
||||
use igneum_fin_core::fold::{fold_block, ChainBlockWitness, FoldWitness};
|
||||
use igneum_fin_core::ring::{RingLeafWitness, WitnessRing};
|
||||
use igneum_fin_core::{FinExt, FinParams, FinState};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A verifying key hash as the guest receives it (SP1's `hash_u32`) and as the public values carry it.
|
||||
|
|
@ -22,6 +25,18 @@ pub struct PrevLink {
|
|||
pub public_values: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Finality in the proof (docs/design/finality-in-proof.md): what the fold of this chain block takes. The previous
|
||||
/// state is checked against the previous proof's extension when that proof carries one; else it is the root the
|
||||
/// attestation starts from (taken as given, `history_first` = this block).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FinInput {
|
||||
pub params: FinParams,
|
||||
pub prev_state: FinState,
|
||||
pub block: ChainBlockWitness,
|
||||
pub ring: Vec<RingLeafWitness>,
|
||||
pub witness: FoldWitness,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AggInput {
|
||||
pub shard_vk: [u32; 8],
|
||||
|
|
@ -29,6 +44,10 @@ pub struct AggInput {
|
|||
pub shards: Vec<Vec<u8>>,
|
||||
pub parent_hash: B256,
|
||||
pub prev: Option<PrevLink>,
|
||||
/// Absent: the 340-byte statement of proving v1, byte for byte. Present: the statement gains the finality
|
||||
/// extension (`FinExt`, 164 bytes).
|
||||
#[serde(default)]
|
||||
pub fin: Option<FinInput>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -54,10 +73,14 @@ pub struct BlockOutput {
|
|||
pub agg_vk: B256,
|
||||
/// Blocks attested by this proof: 1, or the previous proof's count plus one.
|
||||
pub chain_len: u64,
|
||||
/// The finality extension (docs/design/finality-in-proof.md section 1), from `finality_in_proof_activation_daa`.
|
||||
pub fin: Option<FinExt>,
|
||||
}
|
||||
|
||||
impl BlockOutput {
|
||||
pub const LEN: usize = 8 + 8 + 32 + 32 + 4 + 32 * 4 + 8 + 8 + 4 + 4 + 32 * 3 + 8;
|
||||
/// The length with the finality extension.
|
||||
pub const LEN2: usize = Self::LEN + FinExt::LEN;
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(Self::LEN);
|
||||
|
|
@ -78,13 +101,18 @@ impl BlockOutput {
|
|||
}
|
||||
v.extend_from_slice(&self.chain_len.to_be_bytes());
|
||||
debug_assert_eq!(v.len(), Self::LEN);
|
||||
if let Some(f) = &self.fin {
|
||||
v.extend_from_slice(&f.to_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn from_bytes(b: &[u8]) -> Option<Self> {
|
||||
if b.len() != Self::LEN {
|
||||
return None;
|
||||
}
|
||||
let fin = match b.len() {
|
||||
Self::LEN => None,
|
||||
Self::LEN2 => Some(FinExt::from_bytes(&b[Self::LEN..])?),
|
||||
_ => return None,
|
||||
};
|
||||
let u64_at = |i: usize| u64::from_be_bytes(b[i..i + 8].try_into().unwrap());
|
||||
let u32_at = |i: usize| u32::from_be_bytes(b[i..i + 4].try_into().unwrap());
|
||||
let b256_at = |i: usize| B256::from_slice(&b[i..i + 32]);
|
||||
|
|
@ -106,6 +134,7 @@ impl BlockOutput {
|
|||
shard_vk: b256_at(268),
|
||||
agg_vk: b256_at(300),
|
||||
chain_len: u64_at(332),
|
||||
fin,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -154,11 +183,13 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
|
|||
let first = first.unwrap();
|
||||
let last = last.unwrap();
|
||||
let shard_vk = vk_bytes(&input.shard_vk);
|
||||
let mut prev_fin: Option<FinExt> = None;
|
||||
let (agg_vk, chain_len) = match &input.prev {
|
||||
None => (B256::ZERO, 1u64),
|
||||
Some(prev) => {
|
||||
verify(&prev.agg_vk, &prev.public_values);
|
||||
let p = BlockOutput::from_bytes(&prev.public_values).expect("previous block public values decode");
|
||||
prev_fin = p.fin.clone();
|
||||
let agg_vk = vk_bytes(&prev.agg_vk);
|
||||
assert_eq!(p.chain_id, first.chain_id);
|
||||
assert_eq!(p.number + 1, first.number, "the previous proof is of the parent segment");
|
||||
|
|
@ -169,6 +200,25 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
|
|||
(agg_vk, p.chain_len + 1)
|
||||
}
|
||||
};
|
||||
// finality in the proof: the fold of this chain block, rooted at the previous proof's state or at the witness
|
||||
let fin = input.fin.as_ref().map(|f| {
|
||||
let mut state = f.prev_state.clone();
|
||||
match &prev_fin {
|
||||
Some(ext) => assert_eq!(&state.extension(), ext, "the finality state is not the one the previous proof committed"),
|
||||
None => {
|
||||
// the root: a state the proof did not verify (the node's native compare does, a light client bridges
|
||||
// to it from the proof it holds); the attestation of this proof chain starts at this block
|
||||
assert_eq!(state.end_number + 1, first.number, "the root state ends at the parent of this block");
|
||||
state.history_first = first.number;
|
||||
}
|
||||
}
|
||||
assert_eq!(f.block.number, first.number, "the finality witness is of this chain block");
|
||||
assert_eq!(f.block.block_hash.as_slice(), first.block_hash.as_slice(), "the finality witness names this chain block");
|
||||
let mut ring = WitnessRing::new(f.ring.clone());
|
||||
fold_block(&mut state, &f.params, &f.block, &mut ring, &f.witness, &igneum_fin_core::bls::ZkBls).expect("the finality fold");
|
||||
assert!(ring.witnesses.is_empty(), "every ring witness consumed");
|
||||
state.extension()
|
||||
});
|
||||
BlockOutput {
|
||||
chain_id: first.chain_id,
|
||||
number: first.number,
|
||||
|
|
@ -187,5 +237,6 @@ pub fn aggregate(input: &AggInput, verify: &mut dyn FnMut(&[u32; 8], &[u8])) ->
|
|||
shard_vk,
|
||||
agg_vk,
|
||||
chain_len,
|
||||
fin,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
proving/igneum-prove/fin/Cargo.toml
Normal file
24
proving/igneum-prove/fin/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "igneum-fin-core"
|
||||
description = "Finality carried inside the segment proof (docs/design/finality-in-proof.md): the W2 weight table as a commitment, its per-block update, the certificate check against the carried table; one code path for the guest, the host and the node's native tracker"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
sha2 = "0.10"
|
||||
blake2b_simd = { version = "1.0", default-features = false }
|
||||
# The BLS12-381 verifier of the guest (zkcrypto's crate, SP1's patch puts the field operations on the precompiles).
|
||||
# Natively it is the slow but exact reference the tests cross-check against blst.
|
||||
bls12_381 = { version = "0.8", default-features = false, features = ["groups", "pairings", "alloc", "experimental"], optional = true }
|
||||
group = { version = "0.13", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
blst = "0.3"
|
||||
bincode.workspace = true
|
||||
hex.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["zk-bls"]
|
||||
zk-bls = ["dep:bls12_381", "dep:group"]
|
||||
54
proving/igneum-prove/fin/src/bls.rs
Normal file
54
proving/igneum-prove/fin/src/bls.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! BLS12-381 in the min-pubkey setting the node uses (blst `min_pk`: public keys in G1, signatures in G2,
|
||||
//! hash-to-curve `BLS12381G2_XMD:SHA-256_SSWU_RO_`). The guest verifies through zkcrypto's `bls12_381` under
|
||||
//! SP1's patch; the node passes its own blst-backed verifier. Both answer the same question:
|
||||
//! `e(sum of pubkeys, H(msg)) == e(g1, sig)`, every key in the group and none the identity.
|
||||
|
||||
use crate::{PUBKEY_LEN, SIG_LEN};
|
||||
|
||||
pub trait Bls {
|
||||
/// `fast_aggregate_verify`: the aggregate of `pubkeys` signed `msg` under `dst` with `sig`.
|
||||
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool;
|
||||
|
||||
fn verify_one(&self, pubkey: &[u8; PUBKEY_LEN], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
|
||||
self.verify_aggregate(std::slice::from_ref(pubkey), msg, dst, sig)
|
||||
}
|
||||
}
|
||||
|
||||
/// zkcrypto's curve: the guest's verifier and the native reference.
|
||||
#[cfg(feature = "zk-bls")]
|
||||
pub struct ZkBls;
|
||||
|
||||
#[cfg(feature = "zk-bls")]
|
||||
impl Bls for ZkBls {
|
||||
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
|
||||
use bls12_381::hash_to_curve::{ExpandMsgXmd, HashToCurve};
|
||||
use bls12_381::{multi_miller_loop, G1Affine, G1Projective, G2Affine, G2Prepared, G2Projective, Gt};
|
||||
use group::prime::PrimeCurveAffine;
|
||||
use group::{Curve, Group};
|
||||
if pubkeys.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut agg = G1Projective::identity();
|
||||
for pk in pubkeys {
|
||||
let p = G1Affine::from_compressed(pk);
|
||||
if p.is_none().into() {
|
||||
return false;
|
||||
}
|
||||
let p = p.unwrap();
|
||||
if p.is_identity().into() {
|
||||
return false;
|
||||
}
|
||||
agg += G1Projective::from(p);
|
||||
}
|
||||
let s = G2Affine::from_compressed(sig);
|
||||
if s.is_none().into() {
|
||||
return false;
|
||||
}
|
||||
let s = s.unwrap();
|
||||
let hm: G2Projective = <G2Projective as HashToCurve<ExpandMsgXmd<sha2::Sha256>>>::hash_to_curve(msg, dst);
|
||||
let agg_affine = agg.to_affine();
|
||||
let neg_g1 = -G1Affine::generator();
|
||||
let r = multi_miller_loop(&[(&agg_affine, &G2Prepared::from(hm.to_affine())), (&neg_g1, &G2Prepared::from(s))]).final_exponentiation();
|
||||
r == Gt::identity()
|
||||
}
|
||||
}
|
||||
145
proving/igneum-prove/fin/src/cert.rs
Normal file
145
proving/igneum-prove/fin/src/cert.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! The certificate inside the recursion (design section 3): the checkpoint is a chain block in the history, the
|
||||
//! table at that block is the one the proof committed there, the bitmap's signers are revealed voters of that
|
||||
//! table, the aggregate signature verifies, two thirds of the table signed (Q3) and two thirds of the table at the
|
||||
//! previous lock (Q5, less the keys that left), which never expires inside the proof (section 4.4).
|
||||
|
||||
use crate::bls::Bls;
|
||||
use crate::mmr::{HistoryLeaf, MmrProof};
|
||||
use crate::{keys_hash, vote_message, voters_at, FinParams, FinState, KeyEntry, Lock, H, SIG_LEN};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A table at a chain block: the leaf that commits it and the entries.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TableAt {
|
||||
pub leaf: HistoryLeaf,
|
||||
pub proof: MmrProof,
|
||||
pub keys: Vec<KeyEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CertificateWitness {
|
||||
pub index: u64,
|
||||
pub checkpoint: H,
|
||||
pub voter_count: u32,
|
||||
pub bitmap: Vec<u8>,
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub signature: [u8; SIG_LEN],
|
||||
/// The table at the checkpoint block.
|
||||
pub at: TableAt,
|
||||
/// The table at the previous lock's block, when a lock exists.
|
||||
pub frozen: Option<TableAt>,
|
||||
}
|
||||
|
||||
pub fn signer_positions(bitmap: &[u8], voter_count: u32) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
for (byte_index, byte) in bitmap.iter().enumerate() {
|
||||
for bit in 0..8 {
|
||||
if byte & (1 << bit) != 0 {
|
||||
let pos = byte_index * 8 + bit;
|
||||
if pos < voter_count as usize {
|
||||
out.push(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check_table(state: &FinState, t: &TableAt, what: &str) -> Result<(), String> {
|
||||
if !t.proof.verify(&t.leaf.hash(), &state.history_root(), state.leaves) {
|
||||
return Err(format!("the {what} block is not in the proof's history"));
|
||||
}
|
||||
if keys_hash(&t.keys) != t.leaf.keys_hash {
|
||||
return Err(format!("the {what} table is not the one the proof committed at that block"));
|
||||
}
|
||||
if !t.keys.windows(2).all(|p| p[0].key_hash < p[1].key_hash) {
|
||||
return Err(format!("the {what} table is not sorted"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_certificate(state: &mut FinState, params: &FinParams, c: &CertificateWitness, bls: &dyn Bls) -> Result<(), String> {
|
||||
if state.stale {
|
||||
return Err("the proof chain is stale: no lock for a full window, no certificate is accepted".into());
|
||||
}
|
||||
if c.index <= state.lock.index {
|
||||
return Err(format!("certificate index {} is not above the last lock {}", c.index, state.lock.index));
|
||||
}
|
||||
check_table(state, &c.at, "checkpoint")?;
|
||||
let leaf = &c.at.leaf;
|
||||
if leaf.block_hash != c.checkpoint {
|
||||
return Err("the certificate's checkpoint is not the block the history leaf names".into());
|
||||
}
|
||||
if leaf.number < state.history_first || (state.lock.index > 0 && leaf.number <= state.lock.number) {
|
||||
return Err(format!("checkpoint block {} is not above the last lock's block {}", leaf.number, state.lock.number));
|
||||
}
|
||||
if state.lock.index > 0 && leaf.daa <= state.lock.daa {
|
||||
return Err("checkpoint DAA score is not above the last lock's".into());
|
||||
}
|
||||
if leaf.daa < params.min_daa {
|
||||
return Err(format!("checkpoint DAA score {} is below min_daa {} (C5)", leaf.daa, params.min_daa));
|
||||
}
|
||||
// the canonical voter list at the checkpoint and the signers
|
||||
let (voters, total) = voters_at(&c.at.keys, leaf.daa, params.dust);
|
||||
if voters.len() != c.voter_count as usize {
|
||||
return Err(format!("the certificate names {} voters, the table at the checkpoint has {}", c.voter_count, voters.len()));
|
||||
}
|
||||
if total != leaf.total {
|
||||
return Err("the table's total differs from the leaf's".into());
|
||||
}
|
||||
let positions = signer_positions(&c.bitmap, c.voter_count);
|
||||
if positions.is_empty() {
|
||||
return Err("the certificate has no signer".into());
|
||||
}
|
||||
let mut signed = 0u64;
|
||||
let mut pubkeys = Vec::with_capacity(positions.len());
|
||||
let mut signer_hashes: Vec<H> = Vec::with_capacity(positions.len());
|
||||
for p in &positions {
|
||||
let k = &c.at.keys[voters[*p]];
|
||||
if !k.revealed() {
|
||||
return Err("a signer's key is not revealed".into());
|
||||
}
|
||||
signed += k.blocks;
|
||||
pubkeys.push(k.pubkey);
|
||||
signer_hashes.push(k.key_hash);
|
||||
}
|
||||
if !bls.verify_aggregate(&pubkeys, &vote_message(¶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(())
|
||||
}
|
||||
228
proving/igneum-prove/fin/src/fold.rs
Normal file
228
proving/igneum-prove/fin/src/fold.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//! One fold: one chain block into the carried state (design section 2), then the certificates that landed
|
||||
//! (section 3, `cert`). The same function runs in the guest (witnessed ring), on the host and in the node's
|
||||
//! tracker (sparse ring, witnesses recorded).
|
||||
|
||||
use crate::bls::Bls;
|
||||
use crate::cert::{verify_certificate, CertificateWitness};
|
||||
use crate::mmr::{self, HistoryLeaf};
|
||||
use crate::ring::{slot_of, RingAccess, RingEntry, RING_LEAF_DAA};
|
||||
use crate::{find_key, vote_key_hash, vote_message, voters_at, FinParams, FinState, KeyEntry, H, PUBKEY_LEN, SIG_LEN};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One blue block of the chain block's past counted for its key: the chain block itself and its mergeset blues
|
||||
/// (`ChainBlockRecord.mergeset` with `is_blue` on the node).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BlueBlock {
|
||||
pub block_hash: H,
|
||||
pub key_hash: H,
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChainBlockWitness {
|
||||
pub number: u64,
|
||||
pub block_hash: H,
|
||||
pub daa: u64,
|
||||
pub blues: Vec<BlueBlock>,
|
||||
}
|
||||
|
||||
/// W1: a key reveal with its proof of possession.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Reveal {
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub pubkey: [u8; PUBKEY_LEN],
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub pop: [u8; SIG_LEN],
|
||||
}
|
||||
|
||||
/// 3.6: two votes by one key at one index for different blocks, dated by their carrier.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EvidenceWitness {
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub pubkey: [u8; PUBKEY_LEN],
|
||||
pub index: u64,
|
||||
pub hash_a: H,
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub sig_a: [u8; SIG_LEN],
|
||||
pub hash_b: H,
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub sig_b: [u8; SIG_LEN],
|
||||
/// DAA score of the lowest carrier in the chain block's past (the node's `bans_at`).
|
||||
pub carrier_daa: u64,
|
||||
}
|
||||
|
||||
/// W7: a departure announcement dated by its carrier.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeaveWitness {
|
||||
pub daa: u64,
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub pubkey: [u8; PUBKEY_LEN],
|
||||
#[serde(with = "crate::serde_arrays")]
|
||||
pub signature: [u8; SIG_LEN],
|
||||
pub carrier_daa: u64,
|
||||
}
|
||||
|
||||
/// What one fold takes beside the ring witnesses.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FoldWitness {
|
||||
pub reveals: Vec<Reveal>,
|
||||
pub evidence: Vec<EvidenceWitness>,
|
||||
pub leaves: Vec<LeaveWitness>,
|
||||
pub certificates: Vec<CertificateWitness>,
|
||||
}
|
||||
|
||||
/// What a fold reports beside the new state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FoldReport {
|
||||
pub counted: u64,
|
||||
pub skipped_old: u64,
|
||||
pub expired: u64,
|
||||
pub locks: u64,
|
||||
}
|
||||
|
||||
fn touch(keys: &mut Vec<KeyEntry>, key_hash: &H) -> usize {
|
||||
match find_key(keys, key_hash) {
|
||||
Ok(i) => i,
|
||||
Err(i) => {
|
||||
keys.insert(i, KeyEntry::new(*key_hash));
|
||||
i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Folds chain block `block` into `state`. Errors make the proof impossible (the guest panics on them).
|
||||
pub fn fold_block(state: &mut FinState, params: &FinParams, block: &ChainBlockWitness, ring: &mut dyn RingAccess, witness: &FoldWitness, bls: &dyn Bls) -> Result<FoldReport, String> {
|
||||
if state.params_hash != params.hash() {
|
||||
return Err("the state was computed under other finality parameters".into());
|
||||
}
|
||||
if state.leaves > 0 && block.number != state.end_number + 1 {
|
||||
return Err(format!("chain block {} does not follow {}", block.number, state.end_number));
|
||||
}
|
||||
if state.leaves == 0 && block.number != state.history_first {
|
||||
return Err(format!("the attestation starts at {} and the first block folded is {}", state.history_first, block.number));
|
||||
}
|
||||
if block.daa < state.end_daa {
|
||||
return Err(format!("chain block {} has DAA score {} below the previous block's {}", block.number, block.daa, state.end_daa));
|
||||
}
|
||||
let mut report = FoldReport::default();
|
||||
let window_start = block.daa.saturating_sub(params.weight_window);
|
||||
|
||||
// 1. the block's blue blocks into the ring and their keys
|
||||
for b in &block.blues {
|
||||
if b.daa > block.daa {
|
||||
return Err(format!("blue block {} has DAA score {} above its chain block's {}", hex32(&b.block_hash), b.daa, block.daa));
|
||||
}
|
||||
if b.daa <= window_start {
|
||||
report.skipped_old += 1;
|
||||
continue;
|
||||
}
|
||||
let slot = slot_of(b.daa);
|
||||
let (mut entries, siblings) = ring.open(slot, &state.ring_root)?;
|
||||
let e = RingEntry { daa: b.daa, block_hash: b.block_hash, key_hash: b.key_hash };
|
||||
if entries.iter().any(|x| x.block_hash == b.block_hash) {
|
||||
return Err(format!("blue block {} counted twice", hex32(&b.block_hash)));
|
||||
}
|
||||
let pos = entries.binary_search(&e).unwrap_err();
|
||||
entries.insert(pos, e);
|
||||
state.ring_root = ring.write(slot, entries, &siblings);
|
||||
let i = touch(&mut state.keys, &b.key_hash);
|
||||
state.keys[i].blocks += 1;
|
||||
report.counted += 1;
|
||||
}
|
||||
|
||||
// 2. blocks that left the window: DAA scores in (old_start, window_start]
|
||||
let old_start = state.end_daa.saturating_sub(params.weight_window);
|
||||
if state.leaves > 0 && window_start > old_start {
|
||||
let first_slot_daa = (old_start + 1) / RING_LEAF_DAA * RING_LEAF_DAA;
|
||||
let mut d = first_slot_daa;
|
||||
while d <= window_start {
|
||||
let slot = slot_of(d);
|
||||
let (entries, siblings) = ring.open(slot, &state.ring_root)?;
|
||||
let (gone, kept): (Vec<RingEntry>, Vec<RingEntry>) = entries.into_iter().partition(|e| e.daa <= window_start);
|
||||
if !gone.is_empty() {
|
||||
for g in &gone {
|
||||
let i = find_key(&state.keys, &g.key_hash).map_err(|_| format!("expired block {} names a key not in the table", hex32(&g.block_hash)))?;
|
||||
state.keys[i].blocks = state.keys[i].blocks.checked_sub(1).ok_or("a key's block count went below zero")?;
|
||||
report.expired += 1;
|
||||
}
|
||||
state.ring_root = ring.write(slot, kept, &siblings);
|
||||
}
|
||||
d += RING_LEAF_DAA;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. reveals, evidence, leaves (W1, 3.6, W7), each signature verified here
|
||||
for r in &witness.reveals {
|
||||
if !bls.verify_one(&r.pubkey, &r.pubkey, crate::DST_POP, &r.pop) {
|
||||
return Err("a key reveal's proof of possession does not verify".into());
|
||||
}
|
||||
let kh = vote_key_hash(&r.pubkey);
|
||||
let i = touch(&mut state.keys, &kh);
|
||||
if state.keys[i].revealed() && state.keys[i].pubkey != r.pubkey {
|
||||
return Err("a key reveal names another key for a revealed hash".into());
|
||||
}
|
||||
state.keys[i].pubkey = r.pubkey;
|
||||
}
|
||||
for e in &witness.evidence {
|
||||
if e.hash_a == e.hash_b {
|
||||
return Err("evidence names one block twice".into());
|
||||
}
|
||||
if e.carrier_daa > block.daa {
|
||||
return Err("evidence carried after this chain block".into());
|
||||
}
|
||||
let ok_a = bls.verify_one(&e.pubkey, &vote_message(¶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
|
||||
}
|
||||
347
proving/igneum-prove/fin/src/lib.rs
Normal file
347
proving/igneum-prove/fin/src/lib.rs
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
//! Finality carried inside the segment proof (`docs/design/finality-in-proof.md`).
|
||||
//!
|
||||
//! The weight table of spec 03 W2, as the node computes it (`compute_weights` in the fork's
|
||||
//! `consensus/src/processes/finality.rs`), kept incrementally by the aggregator guest: one chain block per fold,
|
||||
//! the block's blue blocks added to their keys, the blocks that left the 30-day window taken off, the table
|
||||
//! committed by hash, every chain block appended to a history MMR with its table commitment, and a lock
|
||||
//! certificate verified against the table at its own checkpoint block. The same functions run in the guest, on
|
||||
//! the host natively, and in the node's tracker (which feeds the witnesses and compares the result: the veto).
|
||||
//!
|
||||
//! Nothing here depends on kaspa types: bytes in, bytes out, so the crate compiles for the zkVM target.
|
||||
|
||||
pub mod bls;
|
||||
pub mod cert;
|
||||
pub mod fold;
|
||||
pub mod mmr;
|
||||
pub mod ring;
|
||||
pub mod tracker;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub type H = [u8; 32];
|
||||
pub const PUBKEY_LEN: usize = 48;
|
||||
pub const SIG_LEN: usize = 96;
|
||||
pub const ZERO: H = [0u8; 32];
|
||||
|
||||
/// The vote tag of spec 3.10 C2 (the fork's `DST_VOTE`).
|
||||
pub const DST_VOTE: &[u8] = b"IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
|
||||
/// The proof-of-possession tag (W1).
|
||||
pub const DST_POP: &[u8] = b"IGNEUM_POP_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_";
|
||||
/// The leave tag (W7).
|
||||
pub const DST_LEAVE: &[u8] = b"IGNEUM_LEAVE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
|
||||
|
||||
pub fn sha256(parts: &[&[u8]]) -> H {
|
||||
let mut h = Sha256::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
/// W1: `vote_key_hash` = BLAKE2b-256 keyed `IgneumVoteKeyHash` over the 48-byte compressed G1 key (the fork's
|
||||
/// `kaspa_hashes::VoteKeyHash`).
|
||||
pub fn vote_key_hash(pubkey: &[u8; PUBKEY_LEN]) -> H {
|
||||
let out = blake2b_simd::Params::new().hash_length(32).key(b"IgneumVoteKeyHash").hash(pubkey);
|
||||
let mut h = [0u8; 32];
|
||||
h.copy_from_slice(out.as_bytes());
|
||||
h
|
||||
}
|
||||
|
||||
/// C2: `"igneum-vote-v1/" || chain_id || 0 || index_le || checkpoint`.
|
||||
pub fn vote_message(chain_id: &str, index: u64, checkpoint: &H) -> Vec<u8> {
|
||||
let mut m = Vec::with_capacity(16 + chain_id.len() + 8 + 32);
|
||||
m.extend_from_slice(b"igneum-vote-v1/");
|
||||
m.extend_from_slice(chain_id.as_bytes());
|
||||
m.push(0);
|
||||
m.extend_from_slice(&index.to_le_bytes());
|
||||
m.extend_from_slice(checkpoint);
|
||||
m
|
||||
}
|
||||
|
||||
/// W7: `"igneum-leave-v1/" || chain_id || 0 || daa_le`.
|
||||
pub fn leave_message(chain_id: &str, daa: u64) -> Vec<u8> {
|
||||
let mut m = Vec::with_capacity(17 + chain_id.len() + 8);
|
||||
m.extend_from_slice(b"igneum-leave-v1/");
|
||||
m.extend_from_slice(chain_id.as_bytes());
|
||||
m.push(0);
|
||||
m.extend_from_slice(&daa.to_le_bytes());
|
||||
m
|
||||
}
|
||||
|
||||
/// The finality parameters the table is computed under (spec 03, `FinalityParams` on the node). Committed into
|
||||
/// every table root, so a proof made under other parameters commits to another table.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FinParams {
|
||||
/// The network name the votes are domain-separated with (`igneum-devnet`, `igneum-devnet-7`).
|
||||
pub chain_id: String,
|
||||
/// W2: the trailing weight window in DAA seconds (2,592,000 mainnet, 7,200 devnet).
|
||||
pub weight_window: u64,
|
||||
/// W3: a key under this many blue blocks in the window is no voter.
|
||||
pub dust: u64,
|
||||
/// C5: no certificate below this DAA score.
|
||||
pub min_daa: u64,
|
||||
/// W7: a leave takes effect this many DAA seconds after its carrier.
|
||||
pub leave_delay: u64,
|
||||
/// 3.6: an equivocating key is stripped for this many DAA seconds after the evidence's carrier.
|
||||
pub equivocation_ban: u64,
|
||||
}
|
||||
|
||||
impl FinParams {
|
||||
pub fn hash(&self) -> H {
|
||||
sha256(&[
|
||||
b"igneum-fin-params-v1",
|
||||
&(self.chain_id.len() as u64).to_le_bytes(),
|
||||
self.chain_id.as_bytes(),
|
||||
&self.weight_window.to_le_bytes(),
|
||||
&self.dust.to_le_bytes(),
|
||||
&self.min_daa.to_le_bytes(),
|
||||
&self.leave_delay.to_le_bytes(),
|
||||
&self.equivocation_ban.to_le_bytes(),
|
||||
])
|
||||
}
|
||||
|
||||
/// Q3's floor, inclusive: `3 x signed >= 2 x total` (the node's `FinalityParams::floor_met`).
|
||||
pub fn floor_met(signed: u64, total: u64) -> bool {
|
||||
(signed as u128) * 3 >= 2 * (total as u128)
|
||||
}
|
||||
|
||||
pub fn devnet(chain_id: &str) -> Self {
|
||||
Self { chain_id: chain_id.into(), weight_window: 7_200, dust: 5, min_daa: 7_200, leave_delay: 3_600, equivocation_ban: 7_200 }
|
||||
}
|
||||
|
||||
pub fn mainnet(chain_id: &str) -> Self {
|
||||
Self { chain_id: chain_id.into(), weight_window: 2_592_000, dust: 100, min_daa: 2_592_000, leave_delay: 3_600, equivocation_ban: 2_592_000 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One key of the table: its blue blocks in the window, its revealed public key (zero until revealed), the end
|
||||
/// of its ban (3.6) and the span of its leave (W7). 112 bytes serialised.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KeyEntry {
|
||||
pub key_hash: H,
|
||||
#[serde(with = "serde_arrays")]
|
||||
pub pubkey: [u8; PUBKEY_LEN],
|
||||
pub blocks: u64,
|
||||
pub ban_until: u64,
|
||||
pub leave_from: u64,
|
||||
pub leave_until: u64,
|
||||
}
|
||||
|
||||
impl KeyEntry {
|
||||
pub const LEN: usize = 32 + PUBKEY_LEN + 8 + 8 + 8 + 8;
|
||||
|
||||
pub fn new(key_hash: H) -> Self {
|
||||
Self { key_hash, pubkey: [0u8; PUBKEY_LEN], blocks: 0, ban_until: 0, leave_from: 0, leave_until: 0 }
|
||||
}
|
||||
|
||||
pub fn write(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.key_hash);
|
||||
out.extend_from_slice(&self.pubkey);
|
||||
out.extend_from_slice(&self.blocks.to_le_bytes());
|
||||
out.extend_from_slice(&self.ban_until.to_le_bytes());
|
||||
out.extend_from_slice(&self.leave_from.to_le_bytes());
|
||||
out.extend_from_slice(&self.leave_until.to_le_bytes());
|
||||
}
|
||||
|
||||
pub fn revealed(&self) -> bool {
|
||||
self.pubkey != [0u8; PUBKEY_LEN]
|
||||
}
|
||||
|
||||
/// A voter at DAA score `daa`: above dust, not stripped, not gone (W3, 3.6, W7).
|
||||
pub fn is_voter(&self, daa: u64, dust: u64) -> bool {
|
||||
self.blocks >= dust && daa >= self.ban_until && !self.is_gone(daa)
|
||||
}
|
||||
|
||||
/// W7: the key has left at `daa`.
|
||||
pub fn is_gone(&self, daa: u64) -> bool {
|
||||
self.leave_until > 0 && self.leave_from <= daa && daa < self.leave_until
|
||||
}
|
||||
|
||||
/// An entry that holds nothing any more is dropped from the table at the end of a fold.
|
||||
pub fn is_empty_at(&self, daa: u64) -> bool {
|
||||
self.blocks == 0 && daa >= self.ban_until && daa >= self.leave_until
|
||||
}
|
||||
}
|
||||
|
||||
/// The key table: sorted by key hash, which is the canonical voter order of spec 3.10 C3.
|
||||
pub fn keys_hash(keys: &[KeyEntry]) -> H {
|
||||
let mut buf = Vec::with_capacity(8 + keys.len() * KeyEntry::LEN);
|
||||
buf.extend_from_slice(&(keys.len() as u64).to_le_bytes());
|
||||
for k in keys {
|
||||
k.write(&mut buf);
|
||||
}
|
||||
sha256(&[b"igneum-fin-keys-v1", &buf])
|
||||
}
|
||||
|
||||
/// The canonical voter list at `daa` (positions index a certificate's bitmap) and the total weight.
|
||||
pub fn voters_at(keys: &[KeyEntry], daa: u64, dust: u64) -> (Vec<usize>, u64) {
|
||||
let mut positions = Vec::new();
|
||||
let mut total = 0u64;
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if k.is_voter(daa, dust) {
|
||||
positions.push(i);
|
||||
total += k.blocks;
|
||||
}
|
||||
}
|
||||
(positions, total)
|
||||
}
|
||||
|
||||
/// Table lookup by key hash (the table is sorted).
|
||||
pub fn find_key(keys: &[KeyEntry], key_hash: &H) -> Result<usize, usize> {
|
||||
keys.binary_search_by(|k| k.key_hash.cmp(key_hash))
|
||||
}
|
||||
|
||||
/// The latest lock the proof chain has verified (zero before the first).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Lock {
|
||||
pub index: u64,
|
||||
pub hash: H,
|
||||
pub number: u64,
|
||||
pub signed: u64,
|
||||
pub total: u64,
|
||||
pub frozen_signed: u64,
|
||||
pub frozen_total: u64,
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
/// The carried state: what one fold takes in and gives out. Its commitment is `extension()`, the public values.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FinState {
|
||||
pub params_hash: H,
|
||||
/// DAA score and chain number of the last chain block folded in.
|
||||
pub end_daa: u64,
|
||||
pub end_number: u64,
|
||||
/// The first chain block this attestation counted from (section 1 of the design: `history_first`).
|
||||
pub history_first: u64,
|
||||
/// Sorted by key hash.
|
||||
pub keys: Vec<KeyEntry>,
|
||||
/// Root of the block ring (`ring`).
|
||||
pub ring_root: H,
|
||||
/// The history MMR's peaks, left to right, and its leaf count (`mmr`).
|
||||
pub peaks: Vec<(u8, H)>,
|
||||
pub leaves: u64,
|
||||
pub lock: Lock,
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// The fixed extension of the block statement (design section 1).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FinExt {
|
||||
pub fin_version: u16,
|
||||
pub table_root: H,
|
||||
pub history_root: H,
|
||||
pub history_first: u64,
|
||||
pub lock: Lock,
|
||||
pub flags: u16,
|
||||
}
|
||||
|
||||
impl FinExt {
|
||||
pub const VERSION: u16 = 1;
|
||||
pub const LEN: usize = 2 + 32 + 32 + 8 + (8 + 32 + 8 + 8 + 8 + 8 + 8 + 8) + 2;
|
||||
pub const FLAG_STALE: u16 = 1;
|
||||
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(Self::LEN);
|
||||
v.extend_from_slice(&self.fin_version.to_be_bytes());
|
||||
v.extend_from_slice(&self.table_root);
|
||||
v.extend_from_slice(&self.history_root);
|
||||
v.extend_from_slice(&self.history_first.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.index.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.hash);
|
||||
v.extend_from_slice(&self.lock.number.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.signed.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.total.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.frozen_signed.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.frozen_total.to_be_bytes());
|
||||
v.extend_from_slice(&self.lock.daa.to_be_bytes());
|
||||
v.extend_from_slice(&self.flags.to_be_bytes());
|
||||
debug_assert_eq!(v.len(), Self::LEN);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn from_bytes(b: &[u8]) -> Option<Self> {
|
||||
if b.len() != Self::LEN {
|
||||
return None;
|
||||
}
|
||||
let u64_at = |i: usize| u64::from_be_bytes(b[i..i + 8].try_into().unwrap());
|
||||
let h_at = |i: usize| -> H { b[i..i + 32].try_into().unwrap() };
|
||||
Some(Self {
|
||||
fin_version: u16::from_be_bytes([b[0], b[1]]),
|
||||
table_root: h_at(2),
|
||||
history_root: h_at(34),
|
||||
history_first: u64_at(66),
|
||||
lock: Lock {
|
||||
index: u64_at(74),
|
||||
hash: h_at(82),
|
||||
number: u64_at(114),
|
||||
signed: u64_at(122),
|
||||
total: u64_at(130),
|
||||
frozen_signed: u64_at(138),
|
||||
frozen_total: u64_at(146),
|
||||
daa: u64_at(154),
|
||||
},
|
||||
flags: u16::from_be_bytes([b[162], b[163]]),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stale(&self) -> bool {
|
||||
self.flags & Self::FLAG_STALE != 0
|
||||
}
|
||||
}
|
||||
|
||||
impl FinState {
|
||||
/// The empty state rooted at chain block `first_number` (the attestation counts from that block on).
|
||||
pub fn empty(params: &FinParams, first_number: u64) -> Self {
|
||||
Self {
|
||||
params_hash: params.hash(),
|
||||
end_daa: 0,
|
||||
end_number: first_number.saturating_sub(1),
|
||||
history_first: first_number,
|
||||
keys: Vec::new(),
|
||||
ring_root: ring::empty_root(),
|
||||
peaks: Vec::new(),
|
||||
leaves: 0,
|
||||
lock: Lock::default(),
|
||||
stale: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keys_hash(&self) -> H {
|
||||
keys_hash(&self.keys)
|
||||
}
|
||||
|
||||
/// `sha256("igneum-fin-state-v1" || params_hash || end_daa || end_number || keys_hash || ring_root)`.
|
||||
pub fn table_root(&self) -> H {
|
||||
sha256(&[b"igneum-fin-state-v1", &self.params_hash, &self.end_daa.to_le_bytes(), &self.end_number.to_le_bytes(), &self.keys_hash(), &self.ring_root])
|
||||
}
|
||||
|
||||
pub fn history_root(&self) -> H {
|
||||
mmr::bag_peaks(&self.peaks)
|
||||
}
|
||||
|
||||
pub fn extension(&self) -> FinExt {
|
||||
FinExt {
|
||||
fin_version: FinExt::VERSION,
|
||||
table_root: self.table_root(),
|
||||
history_root: self.history_root(),
|
||||
history_first: self.history_first,
|
||||
lock: self.lock.clone(),
|
||||
flags: if self.stale { FinExt::FLAG_STALE } else { 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `serde` for fixed arrays over 32 bytes.
|
||||
pub mod serde_arrays {
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer, const N: usize>(a: &[u8; N], s: S) -> Result<S::Ok, S::Error> {
|
||||
a.as_slice().serialize(s)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>, const N: usize>(d: D) -> Result<[u8; N], D::Error> {
|
||||
let v: Vec<u8> = Vec::deserialize(d)?;
|
||||
v.try_into().map_err(|v: Vec<u8>| serde::de::Error::custom(format!("expected {N} bytes, got {}", v.len())))
|
||||
}
|
||||
}
|
||||
164
proving/igneum-prove/fin/src/mmr.rs
Normal file
164
proving/igneum-prove/fin/src/mmr.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! The history: a Merkle mountain range with one leaf per chain block the attestation covers. A leaf commits the
|
||||
//! block's number, hash, DAA score, the table root after it and its total weight, so a certificate's checkpoint
|
||||
//! is looked up by its leaf and a verifier answers "is block n final" from a leaf and a path.
|
||||
|
||||
use crate::{sha256, H};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HistoryLeaf {
|
||||
pub number: u64,
|
||||
pub block_hash: H,
|
||||
pub daa: u64,
|
||||
/// The table root (keys and ring) after this block was folded.
|
||||
pub table_root: H,
|
||||
/// The keys hash alone, so a certificate's table witness can be checked without the ring.
|
||||
pub keys_hash: H,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
impl HistoryLeaf {
|
||||
pub fn hash(&self) -> H {
|
||||
sha256(&[&[4u8], &self.number.to_le_bytes(), &self.block_hash, &self.daa.to_le_bytes(), &self.table_root, &self.keys_hash, &self.total.to_le_bytes()])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge(left: &H, right: &H) -> H {
|
||||
sha256(&[&[2u8], left, right])
|
||||
}
|
||||
|
||||
/// Bags the peaks right to left into one root; zero for an empty range.
|
||||
pub fn bag_peaks(peaks: &[(u8, H)]) -> H {
|
||||
let Some((_, last)) = peaks.last() else { return [0u8; 32] };
|
||||
let mut root = *last;
|
||||
for (_, p) in peaks[..peaks.len() - 1].iter().rev() {
|
||||
root = sha256(&[&[3u8], p, &root]);
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// Appends a leaf hash: the new peak of height 0 merges with equal-height peaks to its left.
|
||||
pub fn append(peaks: &mut Vec<(u8, H)>, leaves: &mut u64, leaf: H) {
|
||||
let mut h = 0u8;
|
||||
let mut node = leaf;
|
||||
while let Some(&(ph, p)) = peaks.last() {
|
||||
if ph != h {
|
||||
break;
|
||||
}
|
||||
peaks.pop();
|
||||
node = merge(&p, &node);
|
||||
h += 1;
|
||||
}
|
||||
peaks.push((h, node));
|
||||
*leaves += 1;
|
||||
}
|
||||
|
||||
/// A membership proof: the leaf's position, its siblings inside its mountain (with the side each sits on), and
|
||||
/// the peaks of the range at the size the proof is made for, the leaf's mountain's peak among them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MmrProof {
|
||||
pub position: u64,
|
||||
/// (sibling, sibling_is_left)
|
||||
pub siblings: Vec<(H, bool)>,
|
||||
pub peaks: Vec<(u8, H)>,
|
||||
pub peak_index: usize,
|
||||
}
|
||||
|
||||
impl MmrProof {
|
||||
/// Checks that `leaf` sits at `position` in a range whose peaks bag to `root` with `leaves` leaves.
|
||||
pub fn verify(&self, leaf: &H, root: &H, leaves: u64) -> bool {
|
||||
if self.position >= leaves || self.peak_index >= self.peaks.len() {
|
||||
return false;
|
||||
}
|
||||
// the peaks' heights must describe exactly `leaves` leaves, strictly decreasing left to right
|
||||
let mut count = 0u64;
|
||||
let mut last: Option<u8> = None;
|
||||
for &(h, _) in &self.peaks {
|
||||
if let Some(l) = last
|
||||
&& l <= h
|
||||
{
|
||||
return false;
|
||||
}
|
||||
last = Some(h);
|
||||
count += 1u64 << h;
|
||||
}
|
||||
if count != leaves {
|
||||
return false;
|
||||
}
|
||||
// the leaf's mountain: positions before it are the earlier peaks' leaves
|
||||
let before: u64 = self.peaks[..self.peak_index].iter().map(|&(h, _)| 1u64 << h).sum();
|
||||
let height = self.peaks[self.peak_index].0 as usize;
|
||||
if self.position < before || self.position - before >= (1u64 << height) || self.siblings.len() != height {
|
||||
return false;
|
||||
}
|
||||
let mut idx = self.position - before;
|
||||
let mut node = *leaf;
|
||||
for (s, left) in &self.siblings {
|
||||
let expect_left = idx & 1 == 1;
|
||||
if *left != expect_left {
|
||||
return false;
|
||||
}
|
||||
node = if *left { merge(s, &node) } else { merge(&node, s) };
|
||||
idx >>= 1;
|
||||
}
|
||||
node == self.peaks[self.peak_index].1 && bag_peaks(&self.peaks) == *root
|
||||
}
|
||||
}
|
||||
|
||||
/// A full in-memory MMR (the tracker and the tests): every node kept, proofs for any leaf at any later size.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Mmr {
|
||||
/// All leaf hashes in order.
|
||||
pub leaf_hashes: Vec<H>,
|
||||
pub peaks: Vec<(u8, H)>,
|
||||
}
|
||||
|
||||
impl Mmr {
|
||||
pub fn append(&mut self, leaf: H) {
|
||||
let mut n = self.leaf_hashes.len() as u64;
|
||||
append(&mut self.peaks, &mut n, leaf);
|
||||
self.leaf_hashes.push(leaf);
|
||||
}
|
||||
|
||||
pub fn leaves(&self) -> u64 {
|
||||
self.leaf_hashes.len() as u64
|
||||
}
|
||||
|
||||
pub fn root(&self) -> H {
|
||||
bag_peaks(&self.peaks)
|
||||
}
|
||||
|
||||
/// The proof of leaf `position` at the current size (recomputed from the leaves: the tracker makes few).
|
||||
pub fn proof(&self, position: u64) -> Option<MmrProof> {
|
||||
let leaves = self.leaves();
|
||||
if position >= leaves {
|
||||
return None;
|
||||
}
|
||||
let mut before = 0u64;
|
||||
let mut peak_index = 0usize;
|
||||
for (i, &(h, _)) in self.peaks.iter().enumerate() {
|
||||
let size = 1u64 << h;
|
||||
if position < before + size {
|
||||
peak_index = i;
|
||||
break;
|
||||
}
|
||||
before += size;
|
||||
}
|
||||
let height = self.peaks[peak_index].0 as usize;
|
||||
// build the mountain's levels from its leaves
|
||||
let mut level: Vec<H> = self.leaf_hashes[before as usize..(before + (1u64 << height)) as usize].to_vec();
|
||||
let mut idx = (position - before) as usize;
|
||||
let mut siblings = Vec::with_capacity(height);
|
||||
for _ in 0..height {
|
||||
let sib = idx ^ 1;
|
||||
siblings.push((level[sib], sib < idx));
|
||||
let mut next = Vec::with_capacity(level.len() / 2);
|
||||
for pair in level.chunks(2) {
|
||||
next.push(merge(&pair[0], &pair[1]));
|
||||
}
|
||||
level = next;
|
||||
idx >>= 1;
|
||||
}
|
||||
Some(MmrProof { position, siblings, peaks: self.peaks.clone(), peak_index })
|
||||
}
|
||||
}
|
||||
127
proving/igneum-prove/fin/src/ring.rs
Normal file
127
proving/igneum-prove/fin/src/ring.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//! The block ring: a Merkle tree of 2^RING_DEPTH leaves, one per RING_LEAF_DAA DAA seconds, each leaf the
|
||||
//! sorted list of the blue blocks (DAA score, block hash, key hash) that fell in its span. The window is
|
||||
//! 2,592,000 DAA seconds on mainnet; the ring spans 2^18 x 16 = 4,194,304, so a slot is reused only after its
|
||||
//! blocks have aged out. The ring is what lets the fold age blocks out exactly (each expired block's key is
|
||||
//! decremented, as the node's window would drop it) and what refuses a block hash counted twice (level 1 of the
|
||||
//! design's section 5.2).
|
||||
//!
|
||||
//! The guest never holds the ring; it opens a leaf through a witness (the entries and the siblings) and writes it
|
||||
//! back by recomputing the root. The node's tracker holds the ring sparsely and produces the witnesses.
|
||||
|
||||
use crate::{sha256, H, ZERO};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const RING_LEAF_DAA: u64 = 16;
|
||||
pub const RING_DEPTH: usize = 18;
|
||||
pub const RING_SLOTS: u64 = 1 << RING_DEPTH;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct RingEntry {
|
||||
pub daa: u64,
|
||||
pub block_hash: H,
|
||||
pub key_hash: H,
|
||||
}
|
||||
|
||||
impl RingEntry {
|
||||
pub fn write(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.daa.to_le_bytes());
|
||||
out.extend_from_slice(&self.block_hash);
|
||||
out.extend_from_slice(&self.key_hash);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slot_of(daa: u64) -> u32 {
|
||||
((daa / RING_LEAF_DAA) % RING_SLOTS) as u32
|
||||
}
|
||||
|
||||
/// A leaf's hash: zero when empty, else `sha256(0x00 || entries)` over the sorted entries.
|
||||
pub fn leaf_hash(entries: &[RingEntry]) -> H {
|
||||
if entries.is_empty() {
|
||||
return ZERO;
|
||||
}
|
||||
let mut buf = Vec::with_capacity(1 + entries.len() * 72);
|
||||
buf.push(0u8);
|
||||
for e in entries {
|
||||
e.write(&mut buf);
|
||||
}
|
||||
sha256(&[&buf])
|
||||
}
|
||||
|
||||
pub fn node_hash(left: &H, right: &H) -> H {
|
||||
sha256(&[&[1u8], left, right])
|
||||
}
|
||||
|
||||
/// The hash of an all-empty subtree at each level (level 0 = a leaf).
|
||||
pub fn defaults() -> Vec<H> {
|
||||
let mut d = Vec::with_capacity(RING_DEPTH + 1);
|
||||
d.push(ZERO);
|
||||
for l in 1..=RING_DEPTH {
|
||||
let below = d[l - 1];
|
||||
d.push(node_hash(&below, &below));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
pub fn empty_root() -> H {
|
||||
defaults()[RING_DEPTH]
|
||||
}
|
||||
|
||||
/// The root from a leaf hash and its siblings, bottom up.
|
||||
pub fn root_from_path(slot: u32, leaf: H, siblings: &[H]) -> H {
|
||||
assert_eq!(siblings.len(), RING_DEPTH, "a ring path has {RING_DEPTH} siblings");
|
||||
let mut h = leaf;
|
||||
let mut idx = slot as u64;
|
||||
for s in siblings {
|
||||
h = if idx & 1 == 0 { node_hash(&h, s) } else { node_hash(s, &h) };
|
||||
idx >>= 1;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// One leaf opened for the guest: its entries and siblings as they stand at that moment of the fold.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RingLeafWitness {
|
||||
pub slot: u32,
|
||||
pub entries: Vec<RingEntry>,
|
||||
pub siblings: Vec<H>,
|
||||
}
|
||||
|
||||
/// How a fold reaches the ring: the guest consumes witnesses in order, the tracker reads its own tree and
|
||||
/// records what it read as the witnesses the guest will need.
|
||||
pub trait RingAccess {
|
||||
/// Opens `slot`: the leaf's entries and siblings, checked against `root`.
|
||||
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String>;
|
||||
/// Writes `entries` to `slot` and returns the new root (the siblings are those `open` returned).
|
||||
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, siblings: &[H]) -> H;
|
||||
}
|
||||
|
||||
/// The guest's ring: a queue of witnesses.
|
||||
pub struct WitnessRing {
|
||||
pub witnesses: std::collections::VecDeque<RingLeafWitness>,
|
||||
}
|
||||
|
||||
impl WitnessRing {
|
||||
pub fn new(witnesses: Vec<RingLeafWitness>) -> Self {
|
||||
Self { witnesses: witnesses.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RingAccess for WitnessRing {
|
||||
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String> {
|
||||
let w = self.witnesses.pop_front().ok_or_else(|| format!("ring witness missing for slot {slot}"))?;
|
||||
if w.slot != slot {
|
||||
return Err(format!("ring witness names slot {} where the fold opens slot {slot}", w.slot));
|
||||
}
|
||||
if !w.entries.windows(2).all(|p| p[0] < p[1]) {
|
||||
return Err(format!("ring leaf {slot} is not sorted"));
|
||||
}
|
||||
if root_from_path(slot, leaf_hash(&w.entries), &w.siblings) != *root {
|
||||
return Err(format!("ring witness for slot {slot} does not open the ring root"));
|
||||
}
|
||||
Ok((w.entries, w.siblings))
|
||||
}
|
||||
|
||||
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, siblings: &[H]) -> H {
|
||||
root_from_path(slot, leaf_hash(&entries), siblings)
|
||||
}
|
||||
}
|
||||
103
proving/igneum-prove/fin/src/tracker.rs
Normal file
103
proving/igneum-prove/fin/src/tracker.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
//! The native side: a sparse ring the node's tracker (and the tests) hold in full, which records what it reads so
|
||||
//! the guest's witnesses fall out of a native fold. Memory: one node per distinct hash on a path from a non-empty
|
||||
//! leaf, at most about 2 x 2^18 entries.
|
||||
|
||||
use crate::ring::{defaults, leaf_hash, node_hash, RingAccess, RingEntry, RingLeafWitness, RING_DEPTH};
|
||||
use crate::H;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SparseRing {
|
||||
defaults: Vec<H>,
|
||||
/// (level, index) -> hash, for nodes that differ from the level's default
|
||||
nodes: HashMap<(u8, u64), H>,
|
||||
leaves: HashMap<u32, Vec<RingEntry>>,
|
||||
/// Every leaf opened, in order: the witnesses a guest needs to replay the same fold.
|
||||
pub recorded: Vec<RingLeafWitness>,
|
||||
}
|
||||
|
||||
impl Default for SparseRing {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SparseRing {
|
||||
pub fn new() -> Self {
|
||||
Self { defaults: defaults(), nodes: HashMap::new(), leaves: HashMap::new(), recorded: Vec::new() }
|
||||
}
|
||||
|
||||
fn node(&self, level: u8, index: u64) -> H {
|
||||
self.nodes.get(&(level, index)).copied().unwrap_or(self.defaults[level as usize])
|
||||
}
|
||||
|
||||
pub fn root(&self) -> H {
|
||||
self.node(RING_DEPTH as u8, 0)
|
||||
}
|
||||
|
||||
pub fn siblings(&self, slot: u32) -> Vec<H> {
|
||||
let mut idx = slot as u64;
|
||||
let mut out = Vec::with_capacity(RING_DEPTH);
|
||||
for level in 0..RING_DEPTH as u8 {
|
||||
out.push(self.node(level, idx ^ 1));
|
||||
idx >>= 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn entries(&self, slot: u32) -> Vec<RingEntry> {
|
||||
self.leaves.get(&slot).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set(&mut self, slot: u32, entries: Vec<RingEntry>) {
|
||||
let mut h = leaf_hash(&entries);
|
||||
if entries.is_empty() {
|
||||
self.leaves.remove(&slot);
|
||||
} else {
|
||||
self.leaves.insert(slot, entries);
|
||||
}
|
||||
let mut idx = slot as u64;
|
||||
for level in 0..=RING_DEPTH as u8 {
|
||||
if h == self.defaults[level as usize] {
|
||||
self.nodes.remove(&(level, idx));
|
||||
} else {
|
||||
self.nodes.insert((level, idx), h);
|
||||
}
|
||||
if level == RING_DEPTH as u8 {
|
||||
break;
|
||||
}
|
||||
let sib = self.node(level, idx ^ 1);
|
||||
h = if idx & 1 == 0 { node_hash(&h, &sib) } else { node_hash(&sib, &h) };
|
||||
idx >>= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the witnesses recorded so far (the guest's input for the fold just run).
|
||||
pub fn take_witnesses(&mut self) -> Vec<RingLeafWitness> {
|
||||
std::mem::take(&mut self.recorded)
|
||||
}
|
||||
|
||||
/// Every non-empty leaf's entries (the differential test counts the window from them).
|
||||
pub fn all_entries(&self) -> Vec<RingEntry> {
|
||||
let mut v: Vec<RingEntry> = self.leaves.values().flatten().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl RingAccess for SparseRing {
|
||||
fn open(&mut self, slot: u32, root: &H) -> Result<(Vec<RingEntry>, Vec<H>), String> {
|
||||
if self.root() != *root {
|
||||
return Err("the tracker's ring root differs from the state's".into());
|
||||
}
|
||||
let entries = self.entries(slot);
|
||||
let siblings = self.siblings(slot);
|
||||
self.recorded.push(RingLeafWitness { slot, entries: entries.clone(), siblings: siblings.clone() });
|
||||
Ok((entries, siblings))
|
||||
}
|
||||
|
||||
fn write(&mut self, slot: u32, entries: Vec<RingEntry>, _siblings: &[H]) -> H {
|
||||
self.set(slot, entries);
|
||||
self.root()
|
||||
}
|
||||
}
|
||||
443
proving/igneum-prove/fin/tests/harness.rs
Normal file
443
proving/igneum-prove/fin/tests/harness.rs
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
//! The harness of design section 7: a simulated chain of chain blocks with real BLS keys (blst, the node's
|
||||
//! library), folded natively through the tracker (witnesses recorded) and replayed through the witnessed ring (the
|
||||
//! guest's path); the table checked against a brute-force count of the window at every block; certificates built
|
||||
//! from real votes; the known-failed case first (today's light client accepts a certificate over a voter list the
|
||||
//! node of its choosing hands it); then the proof-carried table refusing the same certificate; then a light client
|
||||
//! answering "final at checkpoint N" from the extension and a history proof alone.
|
||||
|
||||
use blst::min_pk::{AggregateSignature, SecretKey, Signature};
|
||||
use igneum_fin_core::bls::{Bls, ZkBls};
|
||||
use igneum_fin_core::cert::{signer_positions, CertificateWitness, TableAt};
|
||||
use igneum_fin_core::fold::{fold_block, BlueBlock, ChainBlockWitness, FoldWitness, LeaveWitness, Reveal};
|
||||
use igneum_fin_core::mmr::{HistoryLeaf, Mmr};
|
||||
use igneum_fin_core::ring::WitnessRing;
|
||||
use igneum_fin_core::tracker::SparseRing;
|
||||
use igneum_fin_core::{keys_hash, vote_key_hash, vote_message, voters_at, FinExt, FinParams, FinState, KeyEntry, DST_LEAVE, DST_POP, DST_VOTE, H, PUBKEY_LEN, SIG_LEN};
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct Key {
|
||||
sk: SecretKey,
|
||||
pk: [u8; PUBKEY_LEN],
|
||||
hash: H,
|
||||
}
|
||||
|
||||
fn key(label: &str) -> Key {
|
||||
let mut ikm = [7u8; 32];
|
||||
for (i, b) in label.bytes().enumerate() {
|
||||
ikm[i % 32] ^= b;
|
||||
}
|
||||
let sk = SecretKey::key_gen(&ikm, b"igneum").unwrap();
|
||||
let pk = sk.sk_to_pk().compress();
|
||||
Key { sk, pk, hash: vote_key_hash(&pk) }
|
||||
}
|
||||
|
||||
fn reveal(k: &Key) -> Reveal {
|
||||
Reveal { pubkey: k.pk, pop: k.sk.sign(&k.pk, DST_POP, &[]).compress() }
|
||||
}
|
||||
|
||||
fn hash_of(tag: &str, n: u64) -> H {
|
||||
igneum_fin_core::sha256(&[tag.as_bytes(), &n.to_le_bytes()])
|
||||
}
|
||||
|
||||
/// A native BLS verifier through blst, the node's own library (the cross-check for the guest's curve).
|
||||
struct BlstBls;
|
||||
impl Bls for BlstBls {
|
||||
fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool {
|
||||
let pks: Option<Vec<blst::min_pk::PublicKey>> = pubkeys.iter().map(|p| blst::min_pk::PublicKey::from_bytes(p).ok()).collect();
|
||||
let Some(pks) = pks else { return false };
|
||||
let Some(sig) = Signature::from_bytes(sig).ok() else { return false };
|
||||
let refs: Vec<&blst::min_pk::PublicKey> = pks.iter().collect();
|
||||
sig.fast_aggregate_verify(true, msg, dst, &refs) == blst::BLST_ERROR::BLST_SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/// The simulated chain: one chain block per DAA second, every block blue, keys mine in proportion to `share`.
|
||||
struct Sim {
|
||||
params: FinParams,
|
||||
keys: Vec<Key>,
|
||||
/// (number, hash, daa, blues) per chain block
|
||||
blocks: Vec<ChainBlockWitness>,
|
||||
/// the full table after each block (number -> keys) and the history leaves, from the tracker's fold
|
||||
tables: HashMap<u64, Vec<KeyEntry>>,
|
||||
mmr: Mmr,
|
||||
leaves: HashMap<u64, HistoryLeaf>,
|
||||
tracker: SparseRing,
|
||||
state: FinState,
|
||||
bls: BlstBls,
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
fn new(params: FinParams, labels: &[&str]) -> Self {
|
||||
let keys: Vec<Key> = labels.iter().map(|l| key(l)).collect();
|
||||
let state = FinState::empty(¶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<H, u64> {
|
||||
let mut m = HashMap::new();
|
||||
for b in &self.blocks {
|
||||
for bl in &b.blues {
|
||||
if bl.daa > daa.saturating_sub(self.params.weight_window) && bl.daa <= daa {
|
||||
*m.entry(bl.key_hash).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn table_at(&self, number: u64) -> TableAt {
|
||||
TableAt { leaf: self.leaves[&number].clone(), proof: self.mmr.proof(number).unwrap(), keys: self.tables[&number].clone() }
|
||||
}
|
||||
|
||||
/// A certificate for index `index` over chain block `number`, signed by `signers`.
|
||||
fn certificate(&self, index: u64, number: u64, signers: &[usize]) -> CertificateWitness {
|
||||
let checkpoint = self.blocks[number as usize].block_hash;
|
||||
let table = &self.tables[&number];
|
||||
let (voters, _) = voters_at(table, number, self.params.dust);
|
||||
let msg = vote_message(&self.params.chain_id, index, &checkpoint);
|
||||
let mut sigs = Vec::new();
|
||||
let mut positions = Vec::new();
|
||||
for &s in signers {
|
||||
let k = &self.keys[s];
|
||||
let pos = voters.iter().position(|&v| table[v].key_hash == k.hash).expect("a signer is a voter");
|
||||
positions.push(pos);
|
||||
sigs.push(k.sk.sign(&msg, DST_VOTE, &[]));
|
||||
}
|
||||
let refs: Vec<&Signature> = sigs.iter().collect();
|
||||
let agg = AggregateSignature::aggregate(&refs, true).unwrap().to_signature().compress();
|
||||
let mut bitmap = vec![0u8; voters.len().div_ceil(8)];
|
||||
for p in positions {
|
||||
bitmap[p / 8] |= 1 << (p % 8);
|
||||
}
|
||||
let frozen = (self.state.lock.index > 0).then(|| self.table_at(self.state.lock.number));
|
||||
CertificateWitness { index, checkpoint, voter_count: voters.len() as u32, bitmap, signature: agg, at: self.table_at(number), frozen }
|
||||
}
|
||||
}
|
||||
|
||||
fn params() -> FinParams {
|
||||
FinParams { chain_id: "igneum-fintest".into(), weight_window: 200, dust: 5, min_daa: 200, leave_delay: 20, equivocation_ban: 200 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_carried_table_equals_a_brute_force_count_of_the_window_at_every_block() {
|
||||
let mut sim = Sim::new(params(), &["a", "b", "c", "d"]);
|
||||
// keys mine 4:3:2:1 with side blocks; the window (200) rolls over twice in 500 blocks
|
||||
for n in 0..500u64 {
|
||||
let miner = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3][(n % 10) as usize];
|
||||
let extra: Vec<usize> = if n % 7 == 0 { vec![1, 2] } else if n % 11 == 0 { vec![3] } else { vec![] };
|
||||
sim.mine(miner, &extra, FoldWitness::default()).unwrap();
|
||||
let brute = sim.brute(n);
|
||||
for k in &sim.state.keys {
|
||||
assert_eq!(k.blocks, brute.get(&k.key_hash).copied().unwrap_or(0), "key blocks at block {n}");
|
||||
}
|
||||
for (kh, b) in &brute {
|
||||
let i = igneum_fin_core::find_key(&sim.state.keys, kh).expect("every key with blocks is in the table");
|
||||
assert_eq!(sim.state.keys[i].blocks, *b);
|
||||
}
|
||||
let ring_count: u64 = sim.tracker.all_entries().len() as u64;
|
||||
assert_eq!(ring_count, brute.values().sum::<u64>(), "the ring holds exactly the window at block {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_counted_twice_is_refused_and_ageing_is_exact_at_the_edge() {
|
||||
let mut sim = Sim::new(params(), &["a", "b"]);
|
||||
for _ in 0..3 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
// the same side block hash twice in one chain block
|
||||
let n = sim.blocks.len() as u64;
|
||||
let dup = BlueBlock { block_hash: hash_of("dup", 1), key_hash: sim.keys[1].hash, daa: n };
|
||||
let block = ChainBlockWitness { number: n, block_hash: hash_of("chain", n), daa: n, blues: vec![BlueBlock { block_hash: hash_of("chain", n), key_hash: sim.keys[0].hash, daa: n }, dup.clone(), dup] };
|
||||
let mut s = sim.state.clone();
|
||||
let err = fold_block(&mut s, &sim.params, &block, &mut sim.tracker, &FoldWitness::default(), &sim.bls).unwrap_err();
|
||||
assert!(err.contains("counted twice"), "{err}");
|
||||
sim.tracker.take_witnesses();
|
||||
// ageing: with W = 200, the block at DAA d leaves exactly when the chain reaches d + 200
|
||||
let mut sim = Sim::new(params(), &["a", "b"]);
|
||||
for _ in 0..201u64 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
// blocks 0..=200 mined; at DAA 200 the window is (0, 200]: block 0 is out, 200 blocks counted
|
||||
assert_eq!(sim.state.keys[0].blocks, 200);
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
assert_eq!(sim.state.keys[0].blocks, 200, "one in, one out");
|
||||
}
|
||||
|
||||
/// Builds a chain past the first-month gate with four keys of weight 40, 30, 20 and 10 percent, every key revealed.
|
||||
fn chain_past_the_gate(labels: &[&str]) -> Sim {
|
||||
let mut sim = Sim::new(params(), labels);
|
||||
let mut w = FoldWitness::default();
|
||||
w.reveals = (0..labels.len()).map(|i| reveal(&sim.keys[i])).collect();
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
for n in 1..260u64 {
|
||||
let miner = [0, 0, 0, 0, 1, 1, 1, 2, 2, 3][(n % 10) as usize];
|
||||
sim.mine(miner, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
sim
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_failed_first_todays_light_client_takes_the_voter_list_from_a_node_and_the_proof_does_not() {
|
||||
// keys a, b, c, d hold 40, 30, 20, 10 percent of the real 200-block window; e is the attacker with nothing
|
||||
let mut sim = chain_past_the_gate(&["a", "b", "c", "d", "e"]);
|
||||
let cp = 250u64;
|
||||
let real_table = sim.tables[&cp].clone();
|
||||
let (real_voters, real_total) = voters_at(&real_table, cp, sim.params.dust);
|
||||
assert_eq!(real_voters.len(), 4, "e mined nothing and is no voter");
|
||||
|
||||
// The attacker's node hands a light client a voter list of its own making: e with 70 percent of the weight. The
|
||||
// certificate over the real checkpoint is signed by e alone. Today's client (site/verify/core.js, 10.4 items 2
|
||||
// and 3) sums the weights of the list it was given, verifies the aggregate signature and locks: the rule it runs
|
||||
// is right, the list is the node's word.
|
||||
let e = &sim.keys[4];
|
||||
let mut forged: Vec<KeyEntry> = real_table.clone();
|
||||
let mut e_entry = KeyEntry::new(e.hash);
|
||||
e_entry.pubkey = e.pk;
|
||||
e_entry.blocks = real_total * 7 / 3 + 1;
|
||||
let pos = forged.binary_search_by(|k| k.key_hash.cmp(&e.hash)).unwrap_err();
|
||||
forged.insert(pos, e_entry);
|
||||
let (forged_voters, forged_total) = voters_at(&forged, cp, sim.params.dust);
|
||||
let checkpoint = sim.blocks[cp as usize].block_hash;
|
||||
let msg = vote_message(&sim.params.chain_id, 9, &checkpoint);
|
||||
let sig = e.sk.sign(&msg, DST_VOTE, &[]).compress();
|
||||
let e_pos = forged_voters.iter().position(|&v| forged[v].key_hash == e.hash).unwrap();
|
||||
let mut bitmap = vec![0u8; forged_voters.len().div_ceil(8)];
|
||||
bitmap[e_pos / 8] |= 1 << (e_pos % 8);
|
||||
// today's client, the JS logic as Rust
|
||||
let positions = signer_positions(&bitmap, forged_voters.len() as u32);
|
||||
let signed: u64 = positions.iter().map(|&p| forged[forged_voters[p]].blocks).sum();
|
||||
let pks: Vec<[u8; PUBKEY_LEN]> = positions.iter().map(|&p| forged[forged_voters[p]].pubkey).collect();
|
||||
let sig_ok = BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &sig);
|
||||
let todays_client_locks = sig_ok && FinParams::floor_met(signed, forged_total);
|
||||
assert!(todays_client_locks, "KNOWN FAILED: the client of spec 10.4 locks on a forged voter list ({signed} of {forged_total} signed)");
|
||||
|
||||
// The proof-carried table: the same certificate presented to the fold with the forged table as the witness
|
||||
// is refused (the table at the checkpoint is the one the proof committed), and with the real table it is
|
||||
// refused too (e holds nothing there and is no voter: the bitmap names a position outside the list, or e's
|
||||
// weight is zero).
|
||||
let forged_at = TableAt { leaf: sim.leaves[&cp].clone(), proof: sim.mmr.proof(cp).unwrap(), keys: forged.clone() };
|
||||
let cert = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap: bitmap.clone(), signature: sig, at: forged_at, frozen: None };
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
let err = sim.mine(1, &[], w).unwrap_err();
|
||||
assert!(err.contains("not the one the proof committed"), "the forged table is refused: {err}");
|
||||
let cert_real = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap, signature: sig, at: sim.table_at(cp), frozen: None };
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert_real);
|
||||
let err = sim.mine(1, &[], w).unwrap_err();
|
||||
assert!(err.contains("names 5 voters"), "the real table has four voters: {err}");
|
||||
assert_eq!(sim.state.lock.index, 0, "no lock from the attacker");
|
||||
|
||||
// and the honest certificate (a, b, c: 90 percent) locks, with the extension saying so
|
||||
let cert = sim.certificate(9, cp, &[0, 1, 2]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
sim.mine(1, &[], w).unwrap();
|
||||
let ext = sim.state.extension();
|
||||
assert_eq!(ext.lock.index, 9);
|
||||
assert_eq!(ext.lock.hash, checkpoint);
|
||||
assert_eq!(ext.lock.number, cp);
|
||||
assert!(FinParams::floor_met(ext.lock.signed, ext.lock.total));
|
||||
assert_eq!(ext.lock.total, real_total);
|
||||
assert!(!ext.stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_thirds_is_inclusive_and_under_it_is_refused() {
|
||||
// six equal keys: 4 of 6 locks, 3 of 6 does not (spec 3.10 Q3's unit test, here inside the proof)
|
||||
let mut sim = Sim::new(params(), &["a", "b", "c", "d", "e", "f"]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.reveals = (0..6).map(|i| reveal(&sim.keys[i])).collect();
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
for n in 1..250u64 {
|
||||
sim.mine((n % 6) as usize, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let cp = 240u64;
|
||||
let three = sim.certificate(8, cp, &[0, 1, 2]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(three);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("under two thirds (Q3)"), "{err}");
|
||||
let four = sim.certificate(8, cp, &[0, 1, 2, 3]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(four);
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
assert_eq!(sim.state.lock.index, 8);
|
||||
// a second certificate at a locked index is refused, as is one below it
|
||||
let again = sim.certificate(8, cp, &[0, 1, 2, 3, 4]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(again);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("not above the last lock"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frozen_table_holds_a_side_without_its_partner_and_a_leave_releases_it_after_the_delay() {
|
||||
// a (60 percent) and b (40 percent) lock together; b stops; a alone cannot lock against the frozen table however
|
||||
// long it mines; b's leave shrinks the frozen denominator after leave_delay and a locks; after a window with no
|
||||
// lock the proof chain is stale and refuses everything
|
||||
let mut sim = Sim::new(params(), &["a", "b"]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.reveals = vec![reveal(&sim.keys[0]), reveal(&sim.keys[1])];
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
for n in 1..230u64 {
|
||||
sim.mine(if n % 5 < 3 { 0 } else { 1 }, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let cert = sim.certificate(7, 220, &[0, 1]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
assert_eq!(sim.state.lock.index, 7);
|
||||
let lock_daa = sim.state.lock.daa;
|
||||
// b stops; a mines alone for 150 blocks: under v2 a holds two thirds of its own sliding table by then
|
||||
for _ in 0..150 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let n = sim.blocks.len() as u64 - 1;
|
||||
let (_, total) = voters_at(&sim.state.keys, n, sim.params.dust);
|
||||
let a_blocks = sim.state.keys.iter().find(|k| k.key_hash == sim.keys[0].hash).unwrap().blocks;
|
||||
assert!(FinParams::floor_met(a_blocks, total), "a holds two thirds of the sliding table by now ({a_blocks} of {total})");
|
||||
let cert = sim.certificate(12, n, &[0]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("(Q5)"), "the frozen table holds a: {err}");
|
||||
// b's leave, carried now; it takes effect leave_delay later
|
||||
let leave_daa = sim.blocks.len() as u64;
|
||||
let leave = LeaveWitness { daa: leave_daa, pubkey: sim.keys[1].pk, signature: sim.keys[1].sk.sign(&igneum_fin_core::leave_message(&sim.params.chain_id, leave_daa), DST_LEAVE, &[]).compress(), carrier_daa: leave_daa };
|
||||
let mut w = FoldWitness::default();
|
||||
w.leaves.push(leave);
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
for _ in 0..5 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let n = sim.blocks.len() as u64 - 1;
|
||||
let cert = sim.certificate(13, n, &[0]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("(Q5)"), "before the delay the leave removes nothing: {err}");
|
||||
for _ in 0..sim.params.leave_delay {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
let n = sim.blocks.len() as u64 - 1;
|
||||
assert!(n < lock_daa + sim.params.weight_window, "still inside the window after the last lock");
|
||||
let cert = sim.certificate(14, n, &[0]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
sim.mine(0, &[], w).unwrap();
|
||||
assert_eq!(sim.state.lock.index, 14, "after the delay b is out of the frozen denominator and a locks alone");
|
||||
assert!(sim.state.lock.frozen_total < sim.state.lock.total + sim.state.lock.frozen_total, "the frozen total shrank");
|
||||
|
||||
// stale: a window with no lock
|
||||
let since = sim.state.lock.daa;
|
||||
while (sim.blocks.len() as u64) < since + sim.params.weight_window + 2 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
assert!(sim.state.stale);
|
||||
let n = sim.blocks.len() as u64 - 1;
|
||||
let cert = sim.certificate(20, n, &[0]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
let err = sim.mine(0, &[], w).unwrap_err();
|
||||
assert!(err.contains("stale"), "{err}");
|
||||
assert!(sim.state.extension().stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_light_client_answers_final_at_checkpoint_from_the_extension_and_a_history_proof_alone() {
|
||||
let mut sim = chain_past_the_gate(&["a", "b", "c", "d"]);
|
||||
let cert = sim.certificate(9, 250, &[0, 1, 2]);
|
||||
let mut w = FoldWitness::default();
|
||||
w.certificates.push(cert);
|
||||
sim.mine(1, &[], w).unwrap();
|
||||
for _ in 0..10 {
|
||||
sim.mine(0, &[], FoldWitness::default()).unwrap();
|
||||
}
|
||||
// what a verifier holds: the extension bytes of the latest proof (the proof itself verified elsewhere)
|
||||
let bytes = sim.state.extension().to_bytes();
|
||||
assert_eq!(bytes.len(), FinExt::LEN);
|
||||
let ext = FinExt::from_bytes(&bytes).unwrap();
|
||||
let leaves = sim.state.leaves;
|
||||
// block 240 is at or below the lock's block 250: final; block 255 is not; a tampered leaf is not in the history
|
||||
let answer = |number: u64| -> &'static str {
|
||||
let leaf = sim.leaves[&number].clone();
|
||||
let proof = sim.mmr.proof(number).unwrap();
|
||||
if !proof.verify(&leaf.hash(), &ext.history_root, leaves) {
|
||||
return "not in this chain";
|
||||
}
|
||||
if ext.stale() {
|
||||
return "stale";
|
||||
}
|
||||
if leaf.number <= ext.lock.number {
|
||||
"final"
|
||||
} else {
|
||||
"not final"
|
||||
}
|
||||
};
|
||||
assert_eq!(answer(240), "final");
|
||||
assert_eq!(answer(250), "final");
|
||||
assert_eq!(answer(255), "not final");
|
||||
let mut bad = sim.leaves[&240].clone();
|
||||
bad.block_hash = hash_of("other", 240);
|
||||
assert!(!sim.mmr.proof(240).unwrap().verify(&bad.hash(), &ext.history_root, leaves));
|
||||
assert_eq!(keys_hash(&sim.tables[&250]), sim.leaves[&250].keys_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guest_curve_and_blst_agree_on_real_certificates_and_disagree_with_nothing() {
|
||||
let sim = chain_past_the_gate(&["a", "b", "c"]);
|
||||
let cert = sim.certificate(9, 250, &[0, 1]);
|
||||
let table = &sim.tables[&250];
|
||||
let (voters, _) = voters_at(table, 250, sim.params.dust);
|
||||
let pks: Vec<[u8; PUBKEY_LEN]> = signer_positions(&cert.bitmap, cert.voter_count).iter().map(|&p| table[voters[p]].pubkey).collect();
|
||||
let msg = vote_message(&sim.params.chain_id, 9, &cert.checkpoint);
|
||||
assert!(BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &cert.signature));
|
||||
assert!(ZkBls.verify_aggregate(&pks, &msg, DST_VOTE, &cert.signature));
|
||||
let mut bad = cert.signature;
|
||||
bad[10] ^= 1;
|
||||
assert!(!BlstBls.verify_aggregate(&pks, &msg, DST_VOTE, &bad));
|
||||
assert!(!ZkBls.verify_aggregate(&pks, &msg, DST_VOTE, &bad));
|
||||
let other = vote_message(&sim.params.chain_id, 10, &cert.checkpoint);
|
||||
assert!(!ZkBls.verify_aggregate(&pks, &other, DST_VOTE, &cert.signature));
|
||||
// a proof of possession
|
||||
let r = reveal(&sim.keys[0]);
|
||||
assert!(ZkBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop));
|
||||
assert!(BlstBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop));
|
||||
}
|
||||
130
proving/igneum-prove/host/src/fin.rs
Normal file
130
proving/igneum-prove/host/src/fin.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//! Finality in the proof, the host's half (docs/design/finality-in-proof.md): the witness file an aggregation
|
||||
//! takes (`--fin <file>`), folded natively first so every block's input state is known, and the light client's
|
||||
//! question (`--mode final-at`) answered from a proof's extension and a history proof alone.
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use igneum_fin_core::cert::TableAt;
|
||||
use igneum_fin_core::fold::{fold_block, ChainBlockWitness, FoldWitness};
|
||||
use igneum_fin_core::mmr::{HistoryLeaf, MmrProof};
|
||||
use igneum_fin_core::ring::{RingLeafWitness, WitnessRing};
|
||||
use igneum_fin_core::{FinExt, FinParams, FinState};
|
||||
use igneum_prove_core::agg::{BlockOutput, FinInput};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One chain block's finality witness as the node's RPC hands it to the aggregator.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BlockFin {
|
||||
pub block: ChainBlockWitness,
|
||||
pub ring: Vec<RingLeafWitness>,
|
||||
#[serde(default)]
|
||||
pub witness: FoldWitness,
|
||||
}
|
||||
|
||||
/// `--fin <file>`: the parameters, the state before the first block, and every block's witness in order.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FinWitnessFile {
|
||||
pub format: String,
|
||||
pub params: FinParams,
|
||||
pub root_state: FinState,
|
||||
pub blocks: Vec<BlockFin>,
|
||||
}
|
||||
|
||||
pub const FORMAT: &str = "igneum-fin-witness-v1";
|
||||
|
||||
/// Folds every block natively (the known-finished case before any proof) and returns one `FinInput` per block,
|
||||
/// each with the state its fold starts from.
|
||||
pub fn prepare(file: &FinWitnessFile, first_number: u64) -> Result<Vec<FinInput>> {
|
||||
if file.format != FORMAT {
|
||||
bail!("finality witness format {} (want {FORMAT})", file.format);
|
||||
}
|
||||
let mut state = file.root_state.clone();
|
||||
if state.end_number + 1 != first_number && !(state.leaves == 0 && state.history_first == first_number) {
|
||||
bail!("the finality root state ends at chain block {} and the first block to aggregate is {first_number}", state.end_number);
|
||||
}
|
||||
let mut out = Vec::with_capacity(file.blocks.len());
|
||||
for (i, b) in file.blocks.iter().enumerate() {
|
||||
if b.block.number != first_number + i as u64 {
|
||||
bail!("finality witness {i} is of chain block {}, expected {}", b.block.number, first_number + i as u64);
|
||||
}
|
||||
let input = FinInput { params: file.params.clone(), prev_state: state.clone(), block: b.block.clone(), ring: b.ring.clone(), witness: b.witness.clone() };
|
||||
let mut ring = WitnessRing::new(b.ring.clone());
|
||||
fold_block(&mut state, &file.params, &b.block, &mut ring, &b.witness, &igneum_fin_core::bls::ZkBls).map_err(|e| anyhow!("finality fold of chain block {} natively: {e}", b.block.number))?;
|
||||
if !ring.witnesses.is_empty() {
|
||||
bail!("finality witness of chain block {} carries {} ring leaves the fold did not open", b.block.number, ring.witnesses.len());
|
||||
}
|
||||
out.push(input);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `--block <file>`: a chain block's history leaf and its proof at the size of the proof's history.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct BlockQuery {
|
||||
pub leaf: HistoryLeaf,
|
||||
pub proof: MmrProof,
|
||||
}
|
||||
|
||||
/// The light client's answer, from the extension alone (the proof itself verified by the caller).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Answer {
|
||||
/// Final at checkpoint `index` (locked at chain block `lock_number`).
|
||||
Final { index: u64, lock_number: u64 },
|
||||
/// On this chain but above the latest lock.
|
||||
NotFinal { lock_index: u64, lock_number: u64 },
|
||||
/// The proof chain has had no lock for a full window: the client needs a fresh root.
|
||||
Stale { lock_index: u64 },
|
||||
/// The block is not in the proof's history.
|
||||
NotInChain,
|
||||
/// The proof carries no finality claim (made before the activation).
|
||||
NoClaim,
|
||||
}
|
||||
|
||||
pub fn answer(output: &BlockOutput, history_leaves: u64, query: Option<&BlockQuery>) -> Answer {
|
||||
let Some(ext) = &output.fin else { return Answer::NoClaim };
|
||||
let (number, in_chain) = match query {
|
||||
None => (output.number, true),
|
||||
Some(q) => (q.leaf.number, q.proof.verify(&q.leaf.hash(), &ext.history_root, history_leaves)),
|
||||
};
|
||||
if !in_chain {
|
||||
return Answer::NotInChain;
|
||||
}
|
||||
if ext.stale() {
|
||||
return Answer::Stale { lock_index: ext.lock.index };
|
||||
}
|
||||
if ext.lock.index > 0 && number <= ext.lock.number {
|
||||
Answer::Final { index: ext.lock.index, lock_number: ext.lock.number }
|
||||
} else {
|
||||
Answer::NotFinal { lock_index: ext.lock.index, lock_number: ext.lock.number }
|
||||
}
|
||||
}
|
||||
|
||||
/// The history's leaf count from the extension: `number - history_first + 1` when the attestation rooted at an
|
||||
/// empty state; a witness root carries more, so the query names the count it was made at.
|
||||
pub fn leaves_hint(ext: &FinExt, output: &BlockOutput) -> u64 {
|
||||
output.number.saturating_sub(ext.history_first) + 1
|
||||
}
|
||||
|
||||
pub fn describe(a: &Answer) -> String {
|
||||
match a {
|
||||
Answer::Final { index, lock_number } => format!("final at checkpoint {index} (locked at chain block {lock_number})"),
|
||||
Answer::NotFinal { lock_index, lock_number } => format!("not final (latest lock: checkpoint {lock_index} at chain block {lock_number})"),
|
||||
Answer::Stale { lock_index } => format!("stale: no lock for a full window after checkpoint {lock_index}; this client needs a fresh root"),
|
||||
Answer::NotInChain => "not in this chain".into(),
|
||||
Answer::NoClaim => "no finality claim in this proof".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_witness(path: &str) -> Result<FinWitnessFile> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("{path} is not a finality witness file"))
|
||||
}
|
||||
|
||||
pub fn load_query(path: &str) -> Result<BlockQuery> {
|
||||
let text = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("{path} is not a block query"))
|
||||
}
|
||||
|
||||
/// Certificate witnesses carry tables; this is what one weighs on the wire.
|
||||
pub fn table_bytes(t: &TableAt) -> usize {
|
||||
t.keys.len() * igneum_fin_core::KeyEntry::LEN + 32 * (t.proof.siblings.len() + t.proof.peaks.len())
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
//! ids with no setup. `--mode verify` uses SP1's light verifier and the pinned verifying key: no prover client,
|
||||
//! no key generation (the 114 s to 138 s the Mac's node spent per proof on 5 October).
|
||||
|
||||
mod fin;
|
||||
mod pinned;
|
||||
mod proof_system;
|
||||
|
||||
|
|
@ -82,11 +83,18 @@ fn run() -> Result<()> {
|
|||
// proving v1 (spec 7.8): the node's pool verifies an aggregated segment proof against the pinned aggregator key
|
||||
return run_verify_segment(&pinned, &arg("--proof").context("--proof <file>")?, &arg("--statement").context("--statement 0x<keccak of the block public values>")?);
|
||||
}
|
||||
if mode == "final-at" {
|
||||
// finality in the proof (docs/design/finality-in-proof.md section 4): the light client's question answered
|
||||
// from one verified proof's extension and, for an earlier block, a history proof; no node asked
|
||||
return run_final_at(&pinned, &arg("--proof").context("--proof <file>")?, arg("--block").as_deref(), arg("--leaves").as_deref());
|
||||
}
|
||||
let prover: Address = arg("--prover").map(|s| s.parse()).transpose()?.unwrap_or_else(|| Address::from_slice(&[0x19; 20]));
|
||||
let out_path = arg("--out");
|
||||
// --fin <file>: the finality witness of every block aggregated (the statement gains the extension)
|
||||
let fin_path = arg("--fin");
|
||||
if mode == "aggregate" {
|
||||
// proving v1: the live aggregator, from shard proof files (the node's pool) and the previous segment proof
|
||||
return run_aggregate(&pinned, &arg("--proofs").context("--proofs <a.bin,b.bin,...> (the segment's shard proofs in shard order)")?, &arg("--parent").context("--parent 0x<parent chain block hash>")?, arg("--prev").as_deref(), out_path.as_deref());
|
||||
return run_aggregate(&pinned, &arg("--proofs").context("--proofs <a.bin,b.bin,...> (the segment's shard proofs in shard order)")?, &arg("--parent").context("--parent 0x<parent chain block hash>")?, arg("--prev").as_deref(), out_path.as_deref(), fin_path.as_deref());
|
||||
}
|
||||
if mode == "chain" {
|
||||
// proving v1: N consecutive fixtures proven shard by shard, each block aggregated with the previous block's
|
||||
|
|
@ -100,7 +108,7 @@ fn run() -> Result<()> {
|
|||
// the segment length, the chain rule of spec 7.8) instead of starting fresh. The app's segment path (6 October
|
||||
// 2026) passes it when the node reports the previous segment paid and its proof in the pool.
|
||||
let prev = arg("--prev");
|
||||
return run_chain(&pinned, &fixtures, prover, out_path.as_deref(), save_shards, prev.as_deref());
|
||||
return run_chain(&pinned, &fixtures, prover, out_path.as_deref(), save_shards, prev.as_deref(), fin_path.as_deref());
|
||||
}
|
||||
let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host <fixture.json> [--mode native|execute|shard|compressed|block|all] [--shard N] [--budget <test pgas>] [--prover 0x..] [--out results.json]; --mode chain --chain <f1,f2,...> [--prover 0x..] [--out results.json] [--save-shards] [--prev prev.bin]; --mode aggregate --proofs <a.bin,...> --parent 0x.. [--prev prev.bin] [--out results.json]; --mode verify --proof <file> --statement 0x..; --mode verify-segment --proof <file> --statement 0x..; --mode id")?;
|
||||
let shard_index: usize = arg("--shard").map(|s| s.parse()).transpose()?.unwrap_or(0);
|
||||
|
|
@ -625,7 +633,7 @@ fn out_dir_of(out_path: Option<&str>) -> std::path::PathBuf {
|
|||
/// `--mode chain`: every fixture in order, consecutive on the chain (number and parent hash), each block's shards
|
||||
/// proven compressed and aggregated with the previous block's aggregated proof (`AggInput.prev`, the chain rule),
|
||||
/// every proof verified. One RESULT line per shard, per block (with the running totals) and for the chain.
|
||||
fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_path: Option<&str>, save_shards: bool, prev_path: Option<&str>) -> Result<()> {
|
||||
fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_path: Option<&str>, save_shards: bool, prev_path: Option<&str>, fin_path: Option<&str>) -> Result<()> {
|
||||
if fixtures.is_empty() {
|
||||
bail!("--chain needs at least one fixture");
|
||||
}
|
||||
|
|
@ -678,6 +686,20 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
|
|||
}
|
||||
};
|
||||
let base_len = prev.as_ref().map(|p| p.output.chain_len).unwrap_or(0);
|
||||
// the finality witnesses, folded natively first (the known-finished case before any proof)
|
||||
let mut fin_inputs = match fin_path {
|
||||
None => Vec::new(),
|
||||
Some(p) => {
|
||||
let file = fin::load_witness(p)?;
|
||||
let inputs = fin::prepare(&file, first)?;
|
||||
if inputs.len() != built.len() {
|
||||
bail!("{p} carries {} finality witnesses for {} blocks", inputs.len(), built.len());
|
||||
}
|
||||
println!("RESULT chain fin: {} finality witnesses folded natively, root state ends at chain block {} with {} keys, {} ring leaves opened, {} certificates at {}", inputs.len(), file.root_state.end_number, file.root_state.keys.len(), file.blocks.iter().map(|b| b.ring.len()).sum::<usize>(), file.blocks.iter().map(|b| b.witness.certificates.len()).sum::<usize>(), now());
|
||||
inputs
|
||||
}
|
||||
};
|
||||
fin_inputs.reverse();
|
||||
let sp1 = setup_sp1(pinned, &mut results)?;
|
||||
let chain_t = Instant::now();
|
||||
let mut blocks_json = Vec::with_capacity(built.len());
|
||||
|
|
@ -718,7 +740,10 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
|
|||
}
|
||||
shards_total += proofs.len();
|
||||
stage(&format!("chain block {number} aggregate {} shards{}", proofs.len(), if prev.is_some() { " with the previous block proof" } else { "" }));
|
||||
let seg = sp1.aggregate(prev.as_ref(), &proofs)?;
|
||||
let seg = sp1.aggregate_with(prev.as_ref(), &proofs, fin_inputs.pop())?;
|
||||
if let Some(f) = &seg.output.fin {
|
||||
println!("RESULT chain block {number} fin: table root {} history root {} lock index {} at chain block {} ({} of {} signed, frozen {} of {}){} at {}", B256::from(f.table_root), B256::from(f.history_root), f.lock.index, f.lock.number, f.lock.signed, f.lock.total, f.lock.frozen_signed, f.lock.frozen_total, if f.stale() { ", STALE" } else { "" }, now());
|
||||
}
|
||||
let adt = sp1.last_timing("aggregate").unwrap_or_default().as_secs_f64();
|
||||
let sdt = sp1.last_timing("aggregate-stdin").unwrap_or_default().as_secs_f64();
|
||||
agg_total += adt;
|
||||
|
|
@ -785,7 +810,7 @@ fn run_chain(pinned: &pinned::Pinned, fixtures: &[String], prover: Address, out_
|
|||
/// previous segment's aggregated proof when the chain continues. Each block is aggregated with the previous
|
||||
/// block's proof in one process (one key setup); the output is the last block's aggregated proof, its public
|
||||
/// values, the statement and the proof hash the segment record carries.
|
||||
fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path: Option<&str>, out_path: Option<&str>) -> Result<()> {
|
||||
fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path: Option<&str>, out_path: Option<&str>, fin_path: Option<&str>) -> Result<()> {
|
||||
let first_parent: B256 = parent.parse().context("--parent is not 32 bytes of hex")?;
|
||||
let mut results = serde_json::Map::new();
|
||||
results.insert("mode".into(), "aggregate".into());
|
||||
|
|
@ -831,6 +856,19 @@ fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path:
|
|||
let first = blocks[0][0].output.number;
|
||||
let last = blocks[blocks.len() - 1][0].output.number;
|
||||
println!("igneum-prove-host sources {}: aggregate blocks {first}..={last} ({} shard proofs){}; {}", env!("IGNEUM_PROVE_SOURCES"), blocks.iter().map(|b| b.len()).sum::<usize>(), prev.as_ref().map(|p| format!(", chaining to block {} (chain_len {})", p.output.number, p.output.chain_len)).unwrap_or_default(), now());
|
||||
let mut fin_inputs = match fin_path {
|
||||
None => Vec::new(),
|
||||
Some(p) => {
|
||||
let file = fin::load_witness(p)?;
|
||||
let inputs = fin::prepare(&file, first)?;
|
||||
if inputs.len() != blocks.len() {
|
||||
bail!("{p} carries {} finality witnesses for {} blocks", inputs.len(), blocks.len());
|
||||
}
|
||||
println!("RESULT aggregate fin: {} finality witnesses folded natively at {}", inputs.len(), now());
|
||||
inputs
|
||||
}
|
||||
};
|
||||
fin_inputs.reverse();
|
||||
let sp1 = setup_sp1(pinned, &mut results)?;
|
||||
let t_all = Instant::now();
|
||||
let mut per_block = Vec::new();
|
||||
|
|
@ -838,7 +876,10 @@ fn run_aggregate(pinned: &pinned::Pinned, proofs: &str, parent: &str, prev_path:
|
|||
let number = shards[0].output.number;
|
||||
stage(&format!("aggregate block {number}, {} shards", shards.len()));
|
||||
let deferred = shards.len() + usize::from(prev.is_some());
|
||||
let seg = sp1.aggregate(prev.as_ref(), shards)?;
|
||||
let seg = sp1.aggregate_with(prev.as_ref(), shards, fin_inputs.pop())?;
|
||||
if let Some(f) = &seg.output.fin {
|
||||
println!("RESULT aggregate block {number} fin: lock index {} at chain block {} ({} of {} signed){} at {}", f.lock.index, f.lock.number, f.lock.signed, f.lock.total, if f.stale() { ", STALE" } else { "" }, now());
|
||||
}
|
||||
let adt = sp1.last_timing("aggregate").unwrap_or_default().as_secs_f64();
|
||||
let sdt = sp1.last_timing("aggregate-stdin").unwrap_or_default().as_secs_f64();
|
||||
let claim = SegmentClaim::from_block(&seg.output);
|
||||
|
|
@ -911,6 +952,42 @@ fn run_verify_segment(pinned: &pinned::Pinned, proof_path: &str, statement: &str
|
|||
}
|
||||
}
|
||||
|
||||
/// `--mode final-at --proof <file> [--block <query.json>] [--leaves N]`: the light client of design section 4.
|
||||
/// Verifies the segment proof with the light verifier against the pinned aggregator key, then answers from the
|
||||
/// extension: the proof's own last block, or the block a history leaf and proof name. Exit 0 = final, 4 = not
|
||||
/// final, 5 = stale or no claim, 3 = the proof did not verify.
|
||||
fn run_final_at(pinned: &pinned::Pinned, proof_path: &str, block_path: Option<&str>, leaves: Option<&str>) -> Result<()> {
|
||||
use sp1_sdk::blocking::{LightProver, Prover};
|
||||
let bytes = std::fs::read(proof_path).with_context(|| format!("read {proof_path}"))?;
|
||||
let t = Instant::now();
|
||||
let verifier = LightProver::new();
|
||||
let proof: sp1_sdk::SP1ProofWithPublicValues = bincode::deserialize(&bytes).context("the file is not a bincode SP1 proof")?;
|
||||
let same_program = pinned::claimed_program_id(&proof) == Some(pinned.agg_id);
|
||||
let output = BlockOutput::from_bytes(proof.public_values.as_slice()).context("public values are not a block statement")?;
|
||||
let ids_ok = output.shard_vk == pinned.shard_id && (output.agg_vk == pinned.agg_id || (output.chain_len == 1 && output.agg_vk == B256::ZERO));
|
||||
let ok = same_program && ids_ok && verifier.verify(&proof, &pinned.agg_vk, None).is_ok();
|
||||
let vdt = t.elapsed().as_secs_f64();
|
||||
if !ok {
|
||||
println!("RESULT final-at: the proof did not verify ({:.3} s) at {}", vdt, now());
|
||||
std::process::exit(3)
|
||||
}
|
||||
let t = Instant::now();
|
||||
let query = block_path.map(fin::load_query).transpose()?;
|
||||
let history_leaves = match (leaves, &output.fin) {
|
||||
(Some(n), _) => n.parse::<u64>().context("--leaves N")?,
|
||||
(None, Some(ext)) => fin::leaves_hint(ext, &output),
|
||||
(None, None) => 0,
|
||||
};
|
||||
let a = fin::answer(&output, history_leaves, query.as_ref());
|
||||
let adt = t.elapsed().as_secs_f64();
|
||||
println!("RESULT final-at: {}; proof of chain block {} ({}) chain_len {} verified in {vdt:.3} s, answered in {adt:.6} s from {} bytes of public values; {} at {}", fin::describe(&a), output.number, output.block_hash, output.chain_len, proof.public_values.as_slice().len(), output.fin.as_ref().map(|f| format!("history root {} first {} lock {} at block {} signed {} of {}", B256::from(f.history_root), f.history_first, f.lock.index, f.lock.number, f.lock.signed, f.lock.total)).unwrap_or_else(|| "no extension".into()), now());
|
||||
match a {
|
||||
fin::Answer::Final { .. } => Ok(()),
|
||||
fin::Answer::NotFinal { .. } | fin::Answer::NotInChain => std::process::exit(4),
|
||||
fin::Answer::Stale { .. } | fin::Answer::NoClaim => std::process::exit(5),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_shard_output(out: &ShardOutput, native: &ShardOutput) -> Result<()> {
|
||||
if out != native {
|
||||
bail!("the guest's public values differ from the native run:\n guest {out:?}\n native {native:?}");
|
||||
|
|
|
|||
|
|
@ -295,34 +295,12 @@ impl ProofSystem for Sp1ProofSystem {
|
|||
|
||||
/// The aggregator guest over the shard proofs (and the previous segment's proof when given), by recursion.
|
||||
fn aggregate(&self, prev: Option<&Sp1SegmentProof>, shards: &[Sp1ShardProof]) -> Result<Sp1SegmentProof> {
|
||||
let first = shards.first().ok_or_else(|| anyhow!("no shards"))?;
|
||||
// 5 October 2026 (aggregation cost): the stdin build (the proof clones into the request) is timed apart
|
||||
// from the prove call, so the host's own share of an aggregation is visible next to the GPU's.
|
||||
let t_stdin = Instant::now();
|
||||
let mut stdin = SP1Stdin::new();
|
||||
let input = AggInput {
|
||||
shard_vk: self.shard_vk_hash(),
|
||||
shards: shards.iter().map(|s| s.output.to_bytes()).collect(),
|
||||
parent_hash: first.parent_hash,
|
||||
prev: prev.map(|p| PrevLink { agg_vk: self.agg_vk_hash(), public_values: p.proof.public_values.to_vec() }),
|
||||
};
|
||||
stdin.write_vec(bincode::serialize(&input)?);
|
||||
for s in shards {
|
||||
let SP1Proof::Compressed(proof) = s.proof.proof.clone() else { return Err(anyhow!("shard {} is not a compressed proof", s.output.shard_index)) };
|
||||
stdin.write_proof(*proof, self.shard_vk.vk.clone());
|
||||
}
|
||||
if let Some(p) = prev {
|
||||
let SP1Proof::Compressed(proof) = p.proof.proof.clone() else { return Err(anyhow!("the previous block proof is not a compressed proof")) };
|
||||
stdin.write_proof(*proof, self.agg_vk.vk.clone());
|
||||
}
|
||||
self.record("aggregate-stdin", t_stdin.elapsed());
|
||||
let t = Instant::now();
|
||||
let proof = self.client.prove(&self.agg_pk, stdin).compressed().run()?;
|
||||
self.record("aggregate", t.elapsed());
|
||||
let output = BlockOutput::from_bytes(proof.public_values.as_slice()).ok_or_else(|| anyhow!("block public values have the wrong length"))?;
|
||||
Ok(Sp1SegmentProof { proof, output })
|
||||
self.aggregate_with(prev, shards, None)
|
||||
}
|
||||
|
||||
fn pgas_table(&self) -> &PgasTable {
|
||||
&self.table
|
||||
}
|
||||
fn wrap(&self, _p: &Sp1SegmentProof) -> Result<Sp1WrappedProof> {
|
||||
Err(anyhow!("wrap (Groth16 or Plonk over bn254) is not run: it needs SP1's circuit artifacts and is the ledger P3 measurement"))
|
||||
}
|
||||
|
|
@ -340,8 +318,38 @@ impl ProofSystem for Sp1ProofSystem {
|
|||
let Some(out) = BlockOutput::from_bytes(p.0.public_values.as_slice()) else { return false };
|
||||
self.client.verify(&p.0, &self.agg_vk, None).is_ok() && &SegmentClaim::from_block(&out) == claim
|
||||
}
|
||||
}
|
||||
|
||||
fn pgas_table(&self) -> &PgasTable {
|
||||
&self.table
|
||||
impl Sp1ProofSystem {
|
||||
/// The aggregation with the finality fold (docs/design/finality-in-proof.md): `fin` is this chain block's
|
||||
/// finality input; the guest folds it and the statement gains the extension.
|
||||
pub fn aggregate_with(&self, prev: Option<&Sp1SegmentProof>, shards: &[Sp1ShardProof], fin: Option<igneum_prove_core::agg::FinInput>) -> Result<Sp1SegmentProof> {
|
||||
let first = shards.first().ok_or_else(|| anyhow!("no shards"))?;
|
||||
// 5 October 2026 (aggregation cost): the stdin build (the proof clones into the request) is timed apart
|
||||
// from the prove call, so the host's own share of an aggregation is visible next to the GPU's.
|
||||
let t_stdin = Instant::now();
|
||||
let mut stdin = SP1Stdin::new();
|
||||
let input = AggInput {
|
||||
shard_vk: self.shard_vk_hash(),
|
||||
shards: shards.iter().map(|s| s.output.to_bytes()).collect(),
|
||||
parent_hash: first.parent_hash,
|
||||
prev: prev.map(|p| PrevLink { agg_vk: self.agg_vk_hash(), public_values: p.proof.public_values.to_vec() }),
|
||||
fin,
|
||||
};
|
||||
stdin.write_vec(bincode::serialize(&input)?);
|
||||
for s in shards {
|
||||
let SP1Proof::Compressed(proof) = s.proof.proof.clone() else { return Err(anyhow!("shard {} is not a compressed proof", s.output.shard_index)) };
|
||||
stdin.write_proof(*proof, self.shard_vk.vk.clone());
|
||||
}
|
||||
if let Some(p) = prev {
|
||||
let SP1Proof::Compressed(proof) = p.proof.proof.clone() else { return Err(anyhow!("the previous block proof is not a compressed proof")) };
|
||||
stdin.write_proof(*proof, self.agg_vk.vk.clone());
|
||||
}
|
||||
self.record("aggregate-stdin", t_stdin.elapsed());
|
||||
let t = Instant::now();
|
||||
let proof = self.client.prove(&self.agg_pk, stdin).compressed().run()?;
|
||||
self.record("aggregate", t.elapsed());
|
||||
let output = BlockOutput::from_bytes(proof.public_values.as_slice()).ok_or_else(|| anyhow!("block public values have the wrong length"))?;
|
||||
Ok(Sp1SegmentProof { proof, output })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue