diff --git a/proving/igneum-prove/fin/src/fold.rs b/proving/igneum-prove/fin/src/fold.rs index d690b3446..5e1f3517a 100644 --- a/proving/igneum-prove/fin/src/fold.rs +++ b/proving/igneum-prove/fin/src/fold.rs @@ -4,6 +4,7 @@ use crate::bls::Bls; use crate::cert::{verify_certificate, CertificateWitness}; +use crate::header::{outranks, HeaderWitness}; 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}; @@ -24,6 +25,10 @@ pub struct ChainBlockWitness { pub block_hash: H, pub daa: u64, pub blues: Vec, + /// Level 1 (design 5.2): the chain block's own header first, then every mergeset block's (blue and red). + /// Empty: level 0, the blues are the prover's word (vetoed on the chain by every full node). + #[serde(default)] + pub headers: Vec, } /// W1: a key reveal with its proof of possession. @@ -107,6 +112,9 @@ pub fn fold_block(state: &mut FinState, params: &FinParams, block: &ChainBlockWi let mut report = FoldReport::default(); let window_start = block.daa.saturating_sub(params.weight_window); + // 0. level 1: the headers pin the blues (design 5.2); reds ride into the ring uncounted + let reds = if block.headers.is_empty() { Vec::new() } else { check_headers(state, block)? }; + // 1. the block's blue blocks into the ring and their keys for b in &block.blues { if b.daa > block.daa { @@ -118,7 +126,7 @@ pub fn fold_block(state: &mut FinState, params: &FinParams, block: &ChainBlockWi } 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 }; + let e = RingEntry { daa: b.daa, block_hash: b.block_hash, key_hash: b.key_hash, blue: true }; if entries.iter().any(|x| x.block_hash == b.block_hash) { return Err(format!("blue block {} counted twice", hex32(&b.block_hash))); } @@ -129,6 +137,20 @@ pub fn fold_block(state: &mut FinState, params: &FinParams, block: &ChainBlockWi state.keys[i].blocks += 1; report.counted += 1; } + for r in &reds { + if r.daa <= window_start { + continue; + } + let slot = slot_of(r.daa); + let (mut entries, siblings) = ring.open(slot, &state.ring_root)?; + if entries.iter().any(|x| x.block_hash == r.block_hash) { + return Err(format!("red block {} was in an earlier mergeset", hex32(&r.block_hash))); + } + let e = RingEntry { daa: r.daa, block_hash: r.block_hash, key_hash: r.key_hash, blue: false }; + let pos = entries.binary_search(&e).unwrap_err(); + entries.insert(pos, e); + state.ring_root = ring.write(slot, entries, &siblings); + } // 2. blocks that left the window: DAA scores in (old_start, window_start] let old_start = state.end_daa.saturating_sub(params.weight_window); @@ -140,7 +162,7 @@ pub fn fold_block(state: &mut FinState, params: &FinParams, block: &ChainBlockWi let (entries, siblings) = ring.open(slot, &state.ring_root)?; let (gone, kept): (Vec, Vec) = entries.into_iter().partition(|e| e.daa <= window_start); if !gone.is_empty() { - for g in &gone { + for g in gone.iter().filter(|g| g.blue) { 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; @@ -213,6 +235,11 @@ pub fn fold_block(state: &mut FinState, params: &FinParams, block: &ChainBlockWi // 6. the block's own entry in the history, with the table after it state.end_daa = block.daa; state.end_number = block.number; + state.end_hash = block.block_hash; + if let Some(h) = block.headers.first() { + state.end_blue_score = h.blue_score; + state.end_blue_work = h.blue_work.clone(); + } 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 }; @@ -227,3 +254,87 @@ pub fn hex32(h: &H) -> String { } s } + +/// Level 1: every header hashes to the hash it stands for; the chain block's header names this block, its DAA +/// score and the selected parent (the previous chain block, by blue work then hash among its direct parents); +/// every blue of the witness is a header's block with that header's key and DAA score; the blue count equals +/// the blue score step; every mergeset block is reached from the chain block by parent links through mergeset +/// blocks. Returns the reds (uncounted, for the ring). +fn check_headers(state: &FinState, block: &ChainBlockWitness) -> Result, String> { + let own = &block.headers[0]; + let own_hash = own.hash(); + if own_hash != block.block_hash { + return Err(format!("the chain block's header hashes to {}, not {}", hex32(&own_hash), hex32(&block.block_hash))); + } + if own.daa_score != block.daa { + return Err("the chain block's header carries another DAA score".into()); + } + if !own.blue { + return Err("the chain block is blue in its own mergeset".into()); + } + // the mergeset headers by hash + let mut by_hash: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for h in &block.headers[1..] { + let hh = h.hash(); + if hh == block.block_hash || by_hash.insert(hh, h).is_some() { + return Err(format!("mergeset header {} listed twice", hex32(&hh))); + } + } + // the selected parent: the previous chain block, and the greatest direct parent by (blue work, hash) + if state.leaves > 0 { + let parents = own.direct_parents(); + if !parents.contains(&state.end_hash) { + return Err("the previous chain block is not a direct parent of this one".into()); + } + for p in parents { + if *p == state.end_hash { + continue; + } + let Some(ph) = by_hash.get(p) else { return Err(format!("direct parent {} is neither the selected parent nor in the mergeset", hex32(p))) }; + if outranks(&ph.blue_work, p, &state.end_blue_work, &state.end_hash) { + return Err(format!("direct parent {} outranks the selected parent", hex32(p))); + } + } + if own.blue_score != state.end_blue_score + block.blues.len() as u64 { + return Err(format!("blue score {} is not the selected parent's {} plus the {} blues counted", own.blue_score, state.end_blue_score, block.blues.len())); + } + } + // every blue is a header's block with that header's key and DAA score (the chain block itself first) + let mut seen_blue = std::collections::BTreeSet::new(); + for b in &block.blues { + let h = if b.block_hash == block.block_hash { own } else { by_hash.get(&b.block_hash).copied().ok_or_else(|| format!("blue block {} has no header in the witness", hex32(&b.block_hash)))? }; + if !h.blue || h.vote_key_hash != b.key_hash || h.daa_score != b.daa { + return Err(format!("blue block {} differs from its header (colour, key or DAA score)", hex32(&b.block_hash))); + } + if !seen_blue.insert(b.block_hash) { + return Err(format!("blue block {} listed twice", hex32(&b.block_hash))); + } + } + let mut reds = Vec::new(); + for (hh, h) in &by_hash { + if h.blue { + if !seen_blue.contains(hh) { + return Err(format!("mergeset block {} is blue in its header and not counted", hex32(hh))); + } + } else { + reds.push(BlueBlock { block_hash: *hh, key_hash: h.vote_key_hash, daa: h.daa_score }); + } + } + // reachability: from the chain block's direct parents through mergeset blocks' direct parents + let mut reached: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut queue: Vec = own.direct_parents().to_vec(); + while let Some(p) = queue.pop() { + if p == state.end_hash || !reached.insert(p) { + continue; + } + if let Some(h) = by_hash.get(&p) { + queue.extend(h.direct_parents().iter().copied()); + } + } + for hh in by_hash.keys() { + if !reached.contains(hh) { + return Err(format!("mergeset block {} is not reached from the chain block by parent links", hex32(hh))); + } + } + Ok(reds) +} diff --git a/proving/igneum-prove/fin/src/header.rs b/proving/igneum-prove/fin/src/header.rs new file mode 100644 index 000000000..98853fe98 --- /dev/null +++ b/proving/igneum-prove/fin/src/header.rs @@ -0,0 +1,75 @@ +//! Level 1 of the blue colouring (docs/design/finality-in-proof.md 5.2): the fold takes every mergeset block's +//! header, recomputes the chain's block hash (BLAKE2b-256 keyed `BlockHash` over the fields in the order of the +//! fork's `consensus/core/src/hashing/header.rs`), and checks what a header pins: the block's vote key hash, its +//! DAA score, the chain block's blue score against the previous one (the blue count), the selected parent (the +//! direct parent of greatest blue work, hash as the tie-break), and that every mergeset block is reached from the +//! chain block by parent links through mergeset blocks. What a lie can still do: swap the colours of two mergeset +//! blocks of one chain block (the blue count holds, the ring refuses any block seen before). + +use crate::H; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeaderWitness { + pub version: u16, + /// Parents by level, level 0 first (the direct parents). + pub parents_by_level: Vec>, + pub hash_merkle_root: H, + pub accepted_id_merkle_root: H, + pub utxo_commitment: H, + pub timestamp: u64, + pub bits: u32, + pub nonce: u64, + pub daa_score: u64, + /// Big-endian bytes of the 192-bit blue work with leading zeros stripped (what the hash writes). + pub blue_work: Vec, + pub blue_score: u64, + pub pruning_point: H, + pub vote_key_hash: H, + /// Blue in its chain block's mergeset (counted) or red (kept in the ring uncounted). + pub blue: bool, +} + +impl HeaderWitness { + /// The fork's `hash_override_nonce_time` with the header's own nonce and timestamp. + pub fn hash(&self) -> H { + let mut st = blake2b_simd::Params::new().hash_length(32).key(b"BlockHash").to_state(); + st.update(&self.version.to_le_bytes()); + st.update(&(self.parents_by_level.len() as u64).to_le_bytes()); + for level in &self.parents_by_level { + st.update(&(level.len() as u64).to_le_bytes()); + for p in level { + st.update(p); + } + } + st.update(&self.hash_merkle_root); + st.update(&self.accepted_id_merkle_root); + st.update(&self.utxo_commitment); + st.update(&self.timestamp.to_le_bytes()); + st.update(&self.bits.to_le_bytes()); + st.update(&self.nonce.to_le_bytes()); + st.update(&self.daa_score.to_le_bytes()); + st.update(&self.blue_score.to_le_bytes()); + st.update(&(self.blue_work.len() as u64).to_le_bytes()); + st.update(&self.blue_work); + st.update(&self.pruning_point); + st.update(&self.vote_key_hash); + let mut h = [0u8; 32]; + h.copy_from_slice(st.finalize().as_bytes()); + h + } + + pub fn direct_parents(&self) -> &[H] { + self.parents_by_level.first().map(|v| v.as_slice()).unwrap_or(&[]) + } +} + +/// Greater blue work wins; equal work, the greater hash (GHOSTDAG's `find_selected_parent` order on +/// `SortableBlock`: blue work then hash). +pub fn outranks(work_a: &[u8], hash_a: &H, work_b: &[u8], hash_b: &H) -> bool { + match work_a.len().cmp(&work_b.len()).then_with(|| work_a.cmp(work_b)) { + std::cmp::Ordering::Greater => true, + std::cmp::Ordering::Less => false, + std::cmp::Ordering::Equal => hash_a > hash_b, + } +} diff --git a/proving/igneum-prove/fin/src/lib.rs b/proving/igneum-prove/fin/src/lib.rs index b786a6419..59948d753 100644 --- a/proving/igneum-prove/fin/src/lib.rs +++ b/proving/igneum-prove/fin/src/lib.rs @@ -12,6 +12,7 @@ pub mod bls; pub mod cert; pub mod fold; +pub mod header; pub mod mmr; pub mod ring; pub mod tracker; @@ -212,6 +213,14 @@ pub struct FinState { /// DAA score and chain number of the last chain block folded in. pub end_daa: u64, pub end_number: u64, + /// Level 1 (design 5.2): the last chain block's hash, blue score and blue work, so the next fold can check + /// its selected parent and its blue count against the headers. Zero before level 1 witnesses. + #[serde(default)] + pub end_hash: H, + #[serde(default)] + pub end_blue_score: u64, + #[serde(default)] + pub end_blue_work: Vec, /// The first chain block this attestation counted from (section 1 of the design: `history_first`). pub history_first: u64, /// Sorted by key hash. @@ -297,6 +306,9 @@ impl FinState { params_hash: params.hash(), end_daa: 0, end_number: first_number.saturating_sub(1), + end_hash: ZERO, + end_blue_score: 0, + end_blue_work: Vec::new(), history_first: first_number, keys: Vec::new(), ring_root: ring::empty_root(), @@ -313,7 +325,7 @@ impl FinState { /// `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]) + sha256(&[b"igneum-fin-state-v1", &self.params_hash, &self.end_daa.to_le_bytes(), &self.end_number.to_le_bytes(), &self.end_hash, &self.end_blue_score.to_le_bytes(), &(self.end_blue_work.len() as u64).to_le_bytes(), &self.end_blue_work, &self.keys_hash(), &self.ring_root]) } pub fn history_root(&self) -> H { diff --git a/proving/igneum-prove/fin/src/ring.rs b/proving/igneum-prove/fin/src/ring.rs index 166727b4e..e2bf1cba0 100644 --- a/proving/igneum-prove/fin/src/ring.rs +++ b/proving/igneum-prove/fin/src/ring.rs @@ -20,6 +20,14 @@ pub struct RingEntry { pub daa: u64, pub block_hash: H, pub key_hash: H, + /// Level 1 (docs/design/finality-in-proof.md 5.2): a red mergeset block is kept in the ring uncounted, so a + /// block seen in one chain block's mergeset is refused in any later one, blue or red. + #[serde(default = "default_blue")] + pub blue: bool, +} + +fn default_blue() -> bool { + true } impl RingEntry { @@ -27,6 +35,7 @@ impl RingEntry { out.extend_from_slice(&self.daa.to_le_bytes()); out.extend_from_slice(&self.block_hash); out.extend_from_slice(&self.key_hash); + out.push(u8::from(self.blue)); } } @@ -39,7 +48,7 @@ pub fn leaf_hash(entries: &[RingEntry]) -> H { if entries.is_empty() { return ZERO; } - let mut buf = Vec::with_capacity(1 + entries.len() * 72); + let mut buf = Vec::with_capacity(1 + entries.len() * 73); buf.push(0u8); for e in entries { e.write(&mut buf); diff --git a/proving/igneum-prove/fin/tests/harness.rs b/proving/igneum-prove/fin/tests/harness.rs index 9369c77a6..f5ec8dbb1 100644 --- a/proving/igneum-prove/fin/tests/harness.rs +++ b/proving/igneum-prove/fin/tests/harness.rs @@ -94,7 +94,7 @@ impl Sim { 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 block = ChainBlockWitness { number: n, block_hash: hash_of("chain", n), daa: n, blues, headers: Vec::new() }; // a key's reveal rides with its first block in the table (the node's W1 rule; the tracker re-supplies it // whenever a key whose entry aged out mines again) let mut witness = witness; @@ -198,7 +198,7 @@ fn the_carried_table_equals_a_brute_force_count_of_the_window_at_every_block() { 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; + let ring_count: u64 = sim.tracker.all_entries().iter().filter(|e| e.blue).count() as u64; assert_eq!(ring_count, brute.values().sum::(), "the ring holds exactly the window at block {n}"); } } @@ -212,7 +212,7 @@ fn a_block_counted_twice_is_refused_and_ageing_is_exact_at_the_edge() { // 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 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], headers: Vec::new() }; 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}"); @@ -486,3 +486,60 @@ fn the_guest_curve_and_blst_agree_on_real_certificates_and_disagree_with_nothing assert!(ZkBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop)); assert!(BlstBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop)); } + +/// Level 1: a chain of headers hashed the chain's way, with a side block blue and one red; a swapped key, a +/// wrong blue count, an unreached block and a replayed red are each refused. +#[test] +fn level_one_pins_the_blues_to_headers_and_parent_links() { + use igneum_fin_core::header::HeaderWitness; + let p = params(); + let mut state = FinState::empty(&p, 0); + let mut ring = SparseRing::new(); + let keys: Vec = ["a", "b", "c"].iter().map(|l| key(l)).collect(); + let hdr = |parents: Vec, daa: u64, blue_score: u64, work: u8, key: &Key, blue: bool| HeaderWitness { version: 2, parents_by_level: vec![parents], hash_merkle_root: hash_of("m", daa), accepted_id_merkle_root: ZERO_H, utxo_commitment: ZERO_H, timestamp: 1_000 + daa, bits: 0x1e00ffff, nonce: daa * 7, daa_score: daa, blue_work: vec![work], blue_score, pruning_point: ZERO_H, vote_key_hash: key.hash, blue }; + // block 0 (genesis-like, no parents), by a + let h0 = hdr(vec![], 0, 0, 1, &keys[0], true); + let b0 = ChainBlockWitness { number: 0, block_hash: h0.hash(), daa: 0, blues: vec![BlueBlock { block_hash: h0.hash(), key_hash: keys[0].hash, daa: 0 }], headers: vec![h0.clone()] }; + let mut w = FoldWitness::default(); + w.reveals = keys.iter().map(reveal).collect(); + fold_block(&mut state, &p, &b0, &mut ring, &w, &BlstBls).unwrap(); + ring.take_witnesses(); + // a side block s by b (parent: block 0) and a red r by c (parent: block 0); block 1 by a with parents [0, s, r] + let hs = hdr(vec![h0.hash()], 1, 1, 2, &keys[1], true); + let hr = hdr(vec![h0.hash()], 1, 1, 1, &keys[2], false); + let h1 = hdr(vec![h0.hash(), hs.hash(), hr.hash()], 1, 2, 9, &keys[0], true); + let good = ChainBlockWitness { number: 1, block_hash: h1.hash(), daa: 1, blues: vec![BlueBlock { block_hash: h1.hash(), key_hash: keys[0].hash, daa: 1 }, BlueBlock { block_hash: hs.hash(), key_hash: keys[1].hash, daa: 1 }], headers: vec![h1.clone(), hs.clone(), hr.clone()] }; + // known-failed cases first + let mut swapped = good.clone(); + swapped.blues[1].key_hash = keys[2].hash; + let e = fold_block(&mut state.clone(), &p, &swapped, &mut ring.clone(), &FoldWitness::default(), &BlstBls).unwrap_err(); + assert!(e.contains("differs from its header"), "{e}"); + let mut miscount = good.clone(); + miscount.blues.push(BlueBlock { block_hash: hr.hash(), key_hash: keys[2].hash, daa: 1 }); + let e = fold_block(&mut state.clone(), &p, &miscount, &mut ring.clone(), &FoldWitness::default(), &BlstBls).unwrap_err(); + assert!(e.contains("blue score") || e.contains("differs from its header"), "{e}"); + let mut unreached = good.clone(); + let hx = hdr(vec![hash_of("nowhere", 9)], 1, 1, 1, &keys[1], false); + unreached.headers.push(hx); + let e = fold_block(&mut state.clone(), &p, &unreached, &mut ring.clone(), &FoldWitness::default(), &BlstBls).unwrap_err(); + assert!(e.contains("not reached"), "{e}"); + let mut wrong_sp = good.clone(); + wrong_sp.headers[0].parents_by_level = vec![vec![hs.hash(), hr.hash()]]; + wrong_sp.block_hash = wrong_sp.headers[0].hash(); + wrong_sp.blues[0].block_hash = wrong_sp.block_hash; + let e = fold_block(&mut state.clone(), &p, &wrong_sp, &mut ring.clone(), &FoldWitness::default(), &BlstBls).unwrap_err(); + assert!(e.contains("not a direct parent"), "{e}"); + // the good block folds: b credited for s, c not credited for r, r in the ring uncounted + fold_block(&mut state, &p, &good, &mut ring, &FoldWitness::default(), &BlstBls).unwrap(); + ring.take_witnesses(); + let w_of = |k: &Key| igneum_fin_core::find_key(&state.keys, &k.hash).map(|i| state.keys[i].blocks).unwrap_or(0); + assert_eq!((w_of(&keys[0]), w_of(&keys[1]), w_of(&keys[2])), (2, 1, 0)); + assert!(ring.all_entries().iter().any(|e| e.block_hash == hr.hash() && !e.blue)); + // block 2 replays r as a blue: refused by the ring + let h2 = hdr(vec![h1.hash(), hr.hash()], 2, 4, 20, &keys[0], true); + let replay = ChainBlockWitness { number: 2, block_hash: h2.hash(), daa: 2, blues: vec![BlueBlock { block_hash: h2.hash(), key_hash: keys[0].hash, daa: 2 }, BlueBlock { block_hash: hr.hash(), key_hash: keys[2].hash, daa: 1 }], headers: vec![h2.clone(), { let mut x = hr.clone(); x.blue = true; x }] }; + let e = fold_block(&mut state.clone(), &p, &replay, &mut ring.clone(), &FoldWitness::default(), &BlstBls).unwrap_err(); + assert!(e.contains("counted twice") || e.contains("not a direct parent") || e.contains("listed twice"), "{e}"); +} + +const ZERO_H: H = [0u8; 32];