diff --git a/zebra-chain/src/history_tree.rs b/zebra-chain/src/history_tree.rs index da9edb6a5cd..65543f641f1 100644 --- a/zebra-chain/src/history_tree.rs +++ b/zebra-chain/src/history_tree.rs @@ -13,7 +13,7 @@ use std::{ use thiserror::Error; use crate::{ - block::{Block, ChainHistoryMmrRootHash, Height}, + block::{Block, ChainHistoryMmrRootHash, Header, Height}, fmt::SummaryDebug, ironwood, orchard, parameters::{Network, NetworkUpgrade}, @@ -237,6 +237,104 @@ impl NonEmptyHistoryTree { }) } + /// Create a new history tree with a single block, from that block's *parts* — its + /// header, height, per-pool note-commitment roots, and per-pool shielded tx-counts — + /// without the block body. Mirrors [`from_block`](Self::from_block) but builds the leaf + /// via [`Tree::new_from_parts`], for header-sync verification (design §6). + #[allow(clippy::unwrap_in_result, clippy::too_many_arguments)] + pub fn from_block_parts( + network: &Network, + header: &Header, + height: Height, + sapling_root: &sapling::tree::Root, + orchard_root: &orchard::tree::Root, + ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, + ) -> Result { + let network_upgrade = NetworkUpgrade::current(network, height); + let (tree, entry) = match network_upgrade { + NetworkUpgrade::Genesis + | NetworkUpgrade::BeforeOverwinter + | NetworkUpgrade::Overwinter + | NetworkUpgrade::Sapling + | NetworkUpgrade::Blossom => { + panic!("HistoryTree does not exist for pre-Heartwood upgrades") + } + NetworkUpgrade::Heartwood | NetworkUpgrade::Canopy => { + let (tree, entry) = Tree::::new_from_parts( + network, + header, + height, + sapling_root, + &Default::default(), + &Default::default(), + sapling_tx, + orchard_tx, + ironwood_tx, + )?; + (InnerHistoryTree::PreOrchard(tree), entry) + } + NetworkUpgrade::Nu5 + | NetworkUpgrade::Nu6 + | NetworkUpgrade::Nu6_1 + | NetworkUpgrade::Nu6_2 => { + let (tree, entry) = Tree::::new_from_parts( + network, + header, + height, + sapling_root, + orchard_root, + &Default::default(), + sapling_tx, + orchard_tx, + ironwood_tx, + )?; + (InnerHistoryTree::OrchardOnward(tree), entry) + } + NetworkUpgrade::Nu6_3 | NetworkUpgrade::Nu7 => { + let (tree, entry) = Tree::::new_from_parts( + network, + header, + height, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + )?; + (InnerHistoryTree::IronwoodOnward(tree), entry) + } + #[cfg(zcash_unstable = "zfuture")] + NetworkUpgrade::ZFuture => { + let (tree, entry) = Tree::::new_from_parts( + network, + header, + height, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + )?; + (InnerHistoryTree::IronwoodOnward(tree), entry) + } + }; + let mut peaks = BTreeMap::new(); + peaks.insert(0u32, entry); + Ok(NonEmptyHistoryTree { + network: network.clone(), + network_upgrade, + inner: tree, + size: 1, + peaks: peaks.into(), + current_height: height, + }) + } + /// Add block data to the tree. /// /// `sapling_root` is the root of the Sapling note commitment tree of the block. @@ -308,6 +406,94 @@ impl NonEmptyHistoryTree { Ok(()) } + /// Add a block to the tree from its *parts* (header + height + roots + shielded + /// tx-counts), without the block body. Mirrors [`push`](Self::push) but folds the leaf + /// via [`Tree::append_leaf_parts`], for header-sync verification (design §6). + #[allow(clippy::unwrap_in_result, clippy::too_many_arguments)] + pub fn push_from_parts( + &mut self, + header: &Header, + height: Height, + sapling_root: &sapling::tree::Root, + orchard_root: &orchard::tree::Root, + ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, + ) -> Result<(), HistoryTreeError> { + assert!( + Some(height) == self.current_height + 1, + "added block with height {:?} but it must be {:?}+1", + height, + self.current_height + ); + + let network_upgrade = NetworkUpgrade::current(&self.network, height); + if network_upgrade != self.network_upgrade { + // This is the activation block of a network upgrade. Create a new tree. + let new_tree = Self::from_block_parts( + &self.network, + header, + height, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + )?; + *self = new_tree; + assert_eq!(self.network_upgrade, network_upgrade); + return Ok(()); + } + + let new_entries = match &mut self.inner { + InnerHistoryTree::PreOrchard(tree) => tree + .append_leaf_parts( + header, + height, + sapling_root, + &Default::default(), + &Default::default(), + sapling_tx, + orchard_tx, + ironwood_tx, + ) + .map_err(|e| HistoryTreeError::InnerError { inner: e })?, + InnerHistoryTree::OrchardOnward(tree) => tree + .append_leaf_parts( + header, + height, + sapling_root, + orchard_root, + &Default::default(), + sapling_tx, + orchard_tx, + ironwood_tx, + ) + .map_err(|e| HistoryTreeError::InnerError { inner: e })?, + InnerHistoryTree::IronwoodOnward(tree) => tree + .append_leaf_parts( + header, + height, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + ) + .map_err(|e| HistoryTreeError::InnerError { inner: e })?, + }; + for entry in new_entries { + self.peaks.insert(self.size, entry); + self.size += 1; + } + self.prune()?; + self.current_height = height; + Ok(()) + } + /// Extend the history tree with the given blocks. pub fn try_extend< 'a, @@ -603,6 +789,70 @@ impl HistoryTree { Ok(()) } + /// Push a block to a maybe-existing `HistoryTree` from its *parts* (header + height + + /// roots + shielded tx-counts), without the block body. Mirrors [`push`](Self::push), + /// for header-sync verification (design §6). + #[allow(clippy::unwrap_in_result, clippy::too_many_arguments)] + pub fn push_from_parts( + &mut self, + network: &Network, + header: &Header, + height: Height, + sapling_root: &sapling::tree::Root, + orchard_root: &orchard::tree::Root, + ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, + ) -> Result<(), HistoryTreeError> { + let Some(heartwood_height) = NetworkUpgrade::Heartwood.activation_height(network) else { + assert!( + self.0.is_none(), + "history tree must not exist pre-Heartwood" + ); + return Ok(()); + }; + + match height.cmp(&heartwood_height) { + std::cmp::Ordering::Less => { + assert!( + self.0.is_none(), + "history tree must not exist pre-Heartwood" + ); + } + std::cmp::Ordering::Equal => { + let tree = Some(NonEmptyHistoryTree::from_block_parts( + network, + header, + height, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + )?); + *self = HistoryTree(tree); + } + std::cmp::Ordering::Greater => { + self.0 + .as_mut() + .expect("history tree must exist Heartwood-onward") + .push_from_parts( + header, + height, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + )?; + } + }; + Ok(()) + } + /// Return the hash of the tree root if the tree is not empty. pub fn hash(&self) -> Option { Some(self.0.as_ref()?.hash()) diff --git a/zebra-chain/src/history_tree/tests/vectors.rs b/zebra-chain/src/history_tree/tests/vectors.rs index e8f85019e44..eb76f43646b 100644 --- a/zebra-chain/src/history_tree/tests/vectors.rs +++ b/zebra-chain/src/history_tree/tests/vectors.rs @@ -109,6 +109,95 @@ fn push_and_prune_for_network_upgrade( Ok(()) } +/// The parts-based leaf path (`from_block_parts` / `push_from_parts`) must build a +/// byte-identical history tree to the block-based path — it's the *same* leaf, constructed +/// from the header + roots + shielded tx-counts instead of the block body. This is what lets +/// header-sync verification (design §6) fold supplied roots into the MMR and check them +/// against header commitments without downloading any block body. +#[test] +fn parts_path_matches_block_path() -> Result<()> { + for network in Network::iter() { + parts_path_matches_block_path_for_upgrade(network.clone(), NetworkUpgrade::Heartwood)?; + parts_path_matches_block_path_for_upgrade(network, NetworkUpgrade::Canopy)?; + } + Ok(()) +} + +fn parts_path_matches_block_path_for_upgrade( + network: Network, + network_upgrade: NetworkUpgrade, +) -> Result<()> { + let (blocks, sapling_roots) = network.block_sapling_roots_map(); + let height = network_upgrade.activation_height(&network).unwrap().0; + + let first_block = Arc::new( + blocks + .get(&height) + .expect("test vector exists") + .zcash_deserialize_into::()?, + ); + let second_block = Arc::new( + blocks + .get(&(height + 1)) + .expect("test vector exists") + .zcash_deserialize_into::()?, + ); + let first_sapling = + sapling::tree::Root::try_from(**sapling_roots.get(&height).expect("test vector exists"))?; + let second_sapling = sapling::tree::Root::try_from( + **sapling_roots + .get(&(height + 1)) + .expect("test vector exists"), + )?; + + // Block-based path. + let mut block_tree = NonEmptyHistoryTree::from_block( + &network, + first_block.clone(), + &first_sapling, + &Default::default(), + &Default::default(), + )?; + block_tree.push( + second_block.clone(), + &second_sapling, + &Default::default(), + &Default::default(), + )?; + + // Parts-based path: real per-pool tx-counts, header instead of body. + let mut parts_tree = NonEmptyHistoryTree::from_block_parts( + &network, + &first_block.header, + first_block.coinbase_height().unwrap(), + &first_sapling, + &Default::default(), + &Default::default(), + first_block.sapling_transactions_count(), + first_block.orchard_transactions_count(), + first_block.ironwood_transactions_count(), + )?; + parts_tree.push_from_parts( + &second_block.header, + second_block.coinbase_height().unwrap(), + &second_sapling, + &Default::default(), + &Default::default(), + second_block.sapling_transactions_count(), + second_block.orchard_transactions_count(), + second_block.ironwood_transactions_count(), + )?; + + assert_eq!( + block_tree.hash(), + parts_tree.hash(), + "parts-based history root must match the block-based root on {network} at {network_upgrade:?}" + ); + assert_eq!(block_tree.size(), parts_tree.size()); + + Ok(()) +} + /// Test the history tree works during a network upgrade using the block /// of a network upgrade and the previous block from the previous upgrade. #[test] diff --git a/zebra-chain/src/primitives/zcash_history.rs b/zebra-chain/src/primitives/zcash_history.rs index 6d2155d3524..fe5622ad383 100644 --- a/zebra-chain/src/primitives/zcash_history.rs +++ b/zebra-chain/src/primitives/zcash_history.rs @@ -12,7 +12,7 @@ use serde_big_array::BigArray; pub use zcash_history::{V1, V2, V3}; use crate::{ - block::{Block, ChainHistoryMmrRootHash}, + block::{Block, ChainHistoryMmrRootHash, Header, Height}, ironwood, orchard, parameters::{Network, NetworkUpgrade}, sapling, @@ -20,13 +20,53 @@ use crate::{ /// A trait to represent a version of `Tree`. pub trait Version: zcash_history::Version { - /// Convert a Block into the NodeData for this version. + /// Convert a block into the NodeData leaf for this version. + /// + /// Default: extract the header, height, and per-pool shielded transaction counts from + /// the block and delegate to [`Version::parts_to_history_node`], so the block-based and + /// parts-based leaves are built by identical code. fn block_to_history_node( block: Arc, network: &Network, sapling_root: &sapling::tree::Root, orchard_root: &orchard::tree::Root, ironwood_root: &ironwood::tree::Root, + ) -> Self::NodeData { + let height = block + .coinbase_height() + .expect("block must have coinbase height during contextual verification"); + Self::parts_to_history_node( + &block.header, + height, + network, + sapling_root, + orchard_root, + ironwood_root, + block.sapling_transactions_count(), + block.orchard_transactions_count(), + block.ironwood_transactions_count(), + ) + } + + /// Build the NodeData leaf for this version from its parts — the block header, height, + /// per-pool note-commitment roots, and per-pool shielded transaction counts — *without* + /// the block body. + /// + /// Every leaf field the body would provide is passed explicitly (the counts) or read + /// from the header (hash, time, difficulty/work); this is what lets a node rebuild the + /// ZIP-221 leaf and verify supplied roots against its own header commitments during + /// header sync, before any body is downloaded (design §6). + #[allow(clippy::too_many_arguments)] + fn parts_to_history_node( + header: &Header, + height: Height, + network: &Network, + sapling_root: &sapling::tree::Root, + orchard_root: &orchard::tree::Root, + ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, ) -> Self::NodeData; } @@ -85,6 +125,39 @@ impl Entry { ) -> Self { let node_data = V::block_to_history_node(block, network, sapling_root, orchard_root, ironwood_root); + Self::from_node_data::(node_data) + } + + /// Create a leaf Entry from parts (header + height + roots + shielded tx-counts), + /// without the block body — the header-sync verification path (design §6). + #[allow(clippy::too_many_arguments)] + fn new_leaf_parts( + header: &Header, + height: Height, + network: &Network, + sapling_root: &sapling::tree::Root, + orchard_root: &orchard::tree::Root, + ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, + ) -> Self { + let node_data = V::parts_to_history_node( + header, + height, + network, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + ); + Self::from_node_data::(node_data) + } + + /// Encode a version's `NodeData` leaf into an [`Entry`]. + fn from_node_data(node_data: V::NodeData) -> Self { let inner_entry = zcash_history::Entry::::new_leaf(node_data); let mut entry = Entry { inner: [0; zcash_history::MAX_ENTRY_SIZE], @@ -227,6 +300,99 @@ impl Tree { } Ok(new_nodes) } + + /// Create a single-node MMR tree from a block's parts (header + height + roots + + /// shielded tx-counts), without the block body — the header-sync verification path. + #[allow(clippy::unwrap_in_result, clippy::too_many_arguments)] + pub fn new_from_parts( + network: &Network, + header: &Header, + height: Height, + sapling_root: &sapling::tree::Root, + orchard_root: &orchard::tree::Root, + ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, + ) -> Result<(Self, Entry), io::Error> { + let network_upgrade = NetworkUpgrade::current(network, height); + let entry0 = Entry::new_leaf_parts::( + header, + height, + network, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + ); + let mut peaks = BTreeMap::new(); + peaks.insert(0u32, entry0); + Ok(( + Tree::new_from_cache(network, network_upgrade, 1, &peaks, &BTreeMap::new())?, + peaks + .remove(&0u32) + .expect("must work since it was just added"), + )) + } + + /// Append a new leaf built from a block's parts (header + height + roots + + /// shielded tx-counts), without the block body. + /// + /// # Panics + /// + /// Panics if the network upgrade of the given height differs from the tree's. + #[allow(clippy::unwrap_in_result, clippy::too_many_arguments)] + pub fn append_leaf_parts( + &mut self, + header: &Header, + height: Height, + sapling_root: &sapling::tree::Root, + orchard_root: &orchard::tree::Root, + ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, + ) -> Result, zcash_history::Error> { + let network_upgrade = NetworkUpgrade::current(&self.network, height); + + assert!( + network_upgrade == self.network_upgrade, + "added block from network upgrade {:?} but history tree is restricted to {:?}", + network_upgrade, + self.network_upgrade + ); + + let node_data = V::parts_to_history_node( + header, + height, + &self.network, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + ); + let appended = self.inner.append_leaf(node_data)?; + + let mut new_nodes = Vec::new(); + for entry_link in appended { + let mut entry = Entry { + inner: [0; zcash_history::MAX_ENTRY_SIZE], + }; + self.inner + .resolve_link(entry_link) + .expect("entry was just generated so it must be valid") + .node() + .write(&mut &mut entry.inner[..]) + .expect("buffer was created with enough capacity"); + new_nodes.push(entry); + } + Ok(new_nodes) + } + /// Return the root hash of the tree, i.e. `hashChainHistoryRoot`. pub fn hash(&self) -> ChainHistoryMmrRootHash { // Both append_leaf() and truncate_leaf() leave a root node, so it should @@ -245,42 +411,41 @@ impl std::fmt::Debug for Tree { } impl Version for zcash_history::V1 { - /// Convert a Block into a V1::NodeData used in the MMR tree. + /// Build a V1::NodeData leaf from parts. /// /// `sapling_root` is the root of the Sapling note commitment tree of the block. - /// `orchard_root` is ignored. - fn block_to_history_node( - block: Arc, + /// `orchard_root`, `ironwood_root`, `orchard_tx`, and `ironwood_tx` are ignored (V1). + fn parts_to_history_node( + header: &Header, + height: Height, network: &Network, sapling_root: &sapling::tree::Root, _orchard_root: &orchard::tree::Root, _ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + _orchard_tx: u64, + _ironwood_tx: u64, ) -> Self::NodeData { - let height = block - .coinbase_height() - .expect("block must have coinbase height during contextual verification"); let network_upgrade = NetworkUpgrade::current(network, height); let branch_id = network_upgrade .branch_id() .expect("must have branch ID for chain history network upgrades"); - let block_hash = block.hash().0; - let time: u32 = block - .header + let block_hash = header.hash().0; + let time: u32 = header .time .timestamp() .try_into() .expect("deserialized and generated timestamps are u32 values"); - let target = block.header.difficulty_threshold.0; + let target = header.difficulty_threshold.0; let sapling_root: [u8; 32] = sapling_root.into(); - let work = block - .header + let work = header .difficulty_threshold .to_work() .expect("work must be valid during contextual verification"); // There is no direct `std::primitive::u128` to `bigint::U256` conversion let work = primitive_types::U256::from_big_endian(&work.as_u128().to_be_bytes()); - let sapling_tx_count = block.sapling_transactions_count(); + let sapling_tx_count = sapling_tx; match network_upgrade { NetworkUpgrade::Genesis @@ -322,52 +487,79 @@ impl Version for zcash_history::V1 { } impl Version for V2 { - /// Convert a Block into a V1::NodeData used in the MMR tree. + /// Build a V2::NodeData leaf from parts. /// /// `sapling_root` is the root of the Sapling note commitment tree of the block. /// `orchard_root` is the root of the Orchard note commitment tree of the block. - fn block_to_history_node( - block: Arc, + /// `ironwood_root` and `ironwood_tx` are ignored (V2). + #[allow(clippy::too_many_arguments)] + fn parts_to_history_node( + header: &Header, + height: Height, network: &Network, sapling_root: &sapling::tree::Root, orchard_root: &orchard::tree::Root, - _ironwood_root: &ironwood::tree::Root, + ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, ) -> Self::NodeData { - let orchard_tx_count = block.orchard_transactions_count(); - let node_data_v1 = - V1::block_to_history_node(block, network, sapling_root, orchard_root, _ironwood_root); + let node_data_v1 = V1::parts_to_history_node( + header, + height, + network, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + ); let orchard_root: [u8; 32] = orchard_root.into(); Self::NodeData { v1: node_data_v1, start_orchard_root: orchard_root, end_orchard_root: orchard_root, - orchard_tx: orchard_tx_count, + orchard_tx, } } } impl Version for V3 { - /// Convert a Block into a V3::NodeData used in the MMR tree. + /// Build a V3::NodeData leaf from parts. /// /// `sapling_root` is the root of the Sapling note commitment tree of the block. /// `orchard_root` is the root of the Orchard note commitment tree of the block. /// `ironwood_root` is the root of the Ironwood note commitment tree of the block. - fn block_to_history_node( - block: Arc, + #[allow(clippy::too_many_arguments)] + fn parts_to_history_node( + header: &Header, + height: Height, network: &Network, sapling_root: &sapling::tree::Root, orchard_root: &orchard::tree::Root, ironwood_root: &ironwood::tree::Root, + sapling_tx: u64, + orchard_tx: u64, + ironwood_tx: u64, ) -> Self::NodeData { - let ironwood_tx_count = block.ironwood_transactions_count(); - let node_data_v2 = - V2::block_to_history_node(block, network, sapling_root, orchard_root, ironwood_root); + let node_data_v2 = V2::parts_to_history_node( + header, + height, + network, + sapling_root, + orchard_root, + ironwood_root, + sapling_tx, + orchard_tx, + ironwood_tx, + ); let ironwood_root: [u8; 32] = ironwood_root.into(); Self::NodeData { v2: node_data_v2, start_ironwood_root: ironwood_root, end_ironwood_root: ironwood_root, - ironwood_tx: ironwood_tx_count, + ironwood_tx, } } } diff --git a/zebra-replay-bench/src/apply.rs b/zebra-replay-bench/src/apply.rs index 2a202f34ba4..f69b3ea63a9 100644 --- a/zebra-replay-bench/src/apply.rs +++ b/zebra-replay-bench/src/apply.rs @@ -9,7 +9,7 @@ //! block is committed with its successor as `next_checkpoint`, the one-block-lag //! confirmation the peer-source fast path requires. -use std::{path::Path, sync::Arc, time::Instant}; +use std::{path::Path, time::Instant}; use color_eyre::eyre::{bail, eyre, Result}; use zebra_chain::{block::Height, parameters::Network}; @@ -126,20 +126,6 @@ pub fn run( Err(_) => None, }; - let next_checkpoint = if let Some(s) = &sidecar { - let (successor, auth) = match &next { - Some(n) => (n.block.clone(), n.auth), - None => { - let successor = Arc::new(s.successor.clone()); - let auth = successor.auth_data_root(); - (successor, auth) - } - }; - Some((successor, Some(auth))) - } else { - None - }; - let height = p.height; let commit_start = Instant::now(); let (_hash, trees) = state @@ -147,7 +133,6 @@ pub fn run( p.cv.into(), prev_trees.take(), None, - next_checkpoint, if sidecar.is_some() { "replay-bench-vct" } else { diff --git a/zebra-replay-bench/src/prefetch.rs b/zebra-replay-bench/src/prefetch.rs index 9f0504d582d..c44bd8a8c32 100644 --- a/zebra-replay-bench/src/prefetch.rs +++ b/zebra-replay-bench/src/prefetch.rs @@ -20,10 +20,7 @@ use std::{ }; use color_eyre::eyre::{eyre, Result}; -use zebra_chain::{ - block::{merkle::AuthDataRoot, Block}, - serialization::ZcashDeserialize, -}; +use zebra_chain::{block::Block, serialization::ZcashDeserialize}; use zebra_state::CheckpointVerifiedBlock; use crate::cache::CacheReader; @@ -45,13 +42,11 @@ pub fn capacity() -> usize { .unwrap_or(DEFAULT_PREFETCH_CAPACITY) } -/// One prepared block ready to commit: the verified block plus the per-block prep -/// the committer's `next_checkpoint` needs (the block handle and its auth-data -/// root), with its height and serialized byte length. +/// One prepared block ready to commit: the verified block and its handle, with its +/// height and serialized byte length. pub struct Prepared { pub cv: CheckpointVerifiedBlock, pub block: Arc, - pub auth: AuthDataRoot, pub len: usize, pub height: u32, } @@ -83,14 +78,12 @@ pub fn spawn(reader: CacheReader, capacity: usize) -> (JoinHandle<()>, Receiver< return; } }; - let auth = block.auth_data_root(); let cv = CheckpointVerifiedBlock::from(block.clone()); if tx .send(Ok(Prepared { cv, block, - auth, len, height, })) diff --git a/zebra-state/src/error.rs b/zebra-state/src/error.rs index 54adce5f644..19f9a9d05cf 100644 --- a/zebra-state/src/error.rs +++ b/zebra-state/src/error.rs @@ -254,6 +254,33 @@ pub enum CommitHeaderRangeError { anchor: block::Hash, }, + /// A supplied tree-aux root failed verification against the checkpoint-committed header + /// chain (design §6): the ZIP-221 MMR the supplied roots reconstruct is inconsistent with + /// this block's header commitment. This is a peer-data validation failure — the range is + /// rejected (not stored), the offending peer is scored, and the range is refetched. + #[error("header range commitment root at height {height:?} failed verification: {source}")] + InvalidCommitmentRoots { + /// The first offending height in the range. + height: block::Height, + /// The underlying commitment-verification error. + source: Box, + }, + + /// The running header-frontier history tree could not be positioned at the range anchor to + /// verify its supplied roots (design §6) — the stored roots are not contiguous up to the + /// anchor. This is a local state inconsistency, not a peer-data failure: the range is not + /// stored, and header sync retries. + #[error( + "header-frontier tree could not be positioned at anchor height {anchor_height:?} \ + (missing stored roots at {missing_height:?})" + )] + HeaderFrontierUnavailable { + /// The range anchor the frontier tree must be folded up to. + anchor_height: block::Height, + /// The first height whose stored roots or header were missing. + missing_height: block::Height, + }, + /// The inferred header height overflowed the valid block height range. #[error("header height overflow")] HeightOverflow, diff --git a/zebra-state/src/service/check.rs b/zebra-state/src/service/check.rs index e5281b3af12..e34be4d2d34 100644 --- a/zebra-state/src/service/check.rs +++ b/zebra-state/src/service/check.rs @@ -174,7 +174,38 @@ pub(crate) fn block_commitment_is_valid_for_chain_history( history_tree: &HistoryTree, precomputed_auth_data_root: Option, ) -> Result<(), ValidateContextError> { - match block.commitment(network)? { + let height = block + .coinbase_height() + .ok_or(CommitmentError::MissingBlockHeight { + block_hash: block.hash(), + })?; + // Use the auth data root precomputed by the verifier when available (byte-identical to + // recomputing it here), so the single-threaded committer does not repeat the work. + let auth_data_root = precomputed_auth_data_root.unwrap_or_else(|| block.auth_data_root()); + header_commitment_is_valid_for_chain_history( + &block.header, + height, + network, + history_tree, + auth_data_root, + ) +} + +/// Header-driven core of [`block_commitment_is_valid_for_chain_history`]: check a block's +/// header commitment against `history_tree` (the history tree as of its parent) plus the +/// block's own `auth_data_root`, without needing the block body. +/// +/// This is what lets header-sync verification (design §6) authenticate supplied roots against +/// the checkpoint-committed header chain before any body is downloaded. The block-based entry +/// point above delegates here after resolving the height and auth-data root from the block. +pub(crate) fn header_commitment_is_valid_for_chain_history( + header: &block::Header, + height: block::Height, + network: &Network, + history_tree: &HistoryTree, + auth_data_root: AuthDataRoot, +) -> Result<(), ValidateContextError> { + match header.commitment(network, height)? { block::Commitment::PreSaplingReserved(_) | block::Commitment::FinalSaplingRoot(_) | block::Commitment::ChainHistoryActivationReserved => { @@ -227,20 +258,13 @@ pub(crate) fn block_commitment_is_valid_for_chain_history( let history_tree_root = history_tree .hash() .or_else(|| { - (NetworkUpgrade::Heartwood.activation_height(network) - == block.coinbase_height()) - .then_some(block::CHAIN_HISTORY_ACTIVATION_RESERVED.into()) + (NetworkUpgrade::Heartwood.activation_height(network) == Some(height)) + .then_some(block::CHAIN_HISTORY_ACTIVATION_RESERVED.into()) }) .expect( "the history tree of the previous block must exist \ since the current block has a ChainHistoryBlockTxAuthCommitment", ); - // Use the auth data root precomputed by the verifier when available - // (it is byte-identical to recomputing it here), so the committer - // does not repeat the per-transaction auth-digest work on its - // single-threaded critical path. - let auth_data_root = - precomputed_auth_data_root.unwrap_or_else(|| block.auth_data_root()); let hash_block_commitments = ChainHistoryBlockTxAuthCommitmentHash::from_commitments( &history_tree_root, diff --git a/zebra-state/src/service/check/tests/nullifier.rs b/zebra-state/src/service/check/tests/nullifier.rs index 67b3769f7de..829a1f48884 100644 --- a/zebra-state/src/service/check/tests/nullifier.rs +++ b/zebra-state/src/service/check/tests/nullifier.rs @@ -91,7 +91,7 @@ proptest! { // randomly choose to commit the block to the finalized or non-finalized state if use_finalized_state { let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, "test"); // the block was committed prop_assert_eq!(Some((Height(1), block1.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); @@ -355,7 +355,7 @@ proptest! { // randomly choose to commit the next block to the finalized or non-finalized state if duplicate_in_finalized_state { let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, "test"); prop_assert_eq!(Some((Height(1), block1.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); prop_assert!(commit_result.is_ok()); @@ -454,7 +454,7 @@ proptest! { // randomly choose to commit the block to the finalized or non-finalized state if use_finalized_state { let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, "test"); prop_assert_eq!(Some((Height(1), block1.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); prop_assert!(commit_result.is_ok()); @@ -634,7 +634,7 @@ proptest! { // randomly choose to commit the next block to the finalized or non-finalized state if duplicate_in_finalized_state { let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, "test"); prop_assert_eq!(Some((Height(1), block1.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); prop_assert!(commit_result.is_ok()); @@ -731,7 +731,7 @@ proptest! { // randomly choose to commit the block to the finalized or non-finalized state if use_finalized_state { let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, "test"); prop_assert_eq!(Some((Height(1), block1.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); prop_assert!(commit_result.is_ok()); @@ -920,7 +920,7 @@ proptest! { // randomly choose to commit the next block to the finalized or non-finalized state if duplicate_in_finalized_state { let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, "test"); prop_assert_eq!(Some((Height(1), block1.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); prop_assert!(commit_result.is_ok()); @@ -1121,7 +1121,7 @@ proptest! { finalized_state.populate_with_anchors(&block2); let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.into(), None, None, "test"); prop_assert!(commit_result.is_ok()); let block2 = Arc::new(block2).prepare(); @@ -1175,7 +1175,7 @@ proptest! { finalized_state.populate_with_anchors(&block2); let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.into(), None, None, "test"); prop_assert!(commit_result.is_ok()); let block2 = Arc::new(block2).prepare(); @@ -1229,7 +1229,7 @@ proptest! { finalized_state.populate_with_anchors(&block2); let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.into(), None, None, "test"); prop_assert!(commit_result.is_ok()); let block2 = Arc::new(block2).prepare(); diff --git a/zebra-state/src/service/check/tests/utxo.rs b/zebra-state/src/service/check/tests/utxo.rs index 7c807963d13..69bfe446f69 100644 --- a/zebra-state/src/service/check/tests/utxo.rs +++ b/zebra-state/src/service/check/tests/utxo.rs @@ -185,7 +185,7 @@ proptest! { // randomly choose to commit the block to the finalized or non-finalized state if use_finalized_state { let block1 = CheckpointVerifiedBlock::from(Arc::new(block1)); - let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block1.clone().into(), None, None, "test"); // the block was committed prop_assert_eq!(Some((Height(1), block1.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); @@ -273,7 +273,7 @@ proptest! { if use_finalized_state_spend { let block2 = CheckpointVerifiedBlock::from(Arc::new(block2)); - let commit_result = finalized_state.commit_finalized_direct(block2.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block2.clone().into(), None, None, "test"); // the block was committed prop_assert_eq!(Some((Height(2), block2.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); @@ -609,7 +609,7 @@ proptest! { if use_finalized_state_spend { let block2 = CheckpointVerifiedBlock::from(block2.clone()); - let commit_result = finalized_state.commit_finalized_direct(block2.clone().into(), None, None, None, "test"); + let commit_result = finalized_state.commit_finalized_direct(block2.clone().into(), None, None, "test"); // the block was committed prop_assert_eq!(Some((Height(2), block2.hash)), read::best_tip(&non_finalized_state, &finalized_state.db)); @@ -877,13 +877,8 @@ fn new_state_with_mainnet_transparent_data( if use_finalized_state { let block1 = CheckpointVerifiedBlock::from(block1.clone()); - let commit_result = finalized_state.commit_finalized_direct( - block1.clone().into(), - None, - None, - None, - "test", - ); + let commit_result = + finalized_state.commit_finalized_direct(block1.clone().into(), None, None, "test"); // the block was committed assert_eq!( diff --git a/zebra-state/src/service/finalized_state.rs b/zebra-state/src/service/finalized_state.rs index dee5aa73c0d..4555309eac9 100644 --- a/zebra-state/src/service/finalized_state.rs +++ b/zebra-state/src/service/finalized_state.rs @@ -23,8 +23,7 @@ use std::{ }; use zebra_chain::{ - block::{self, merkle::AuthDataRoot, Block}, - ironwood, orchard, + block, ironwood, orchard, parallel::tree::{BlockNotePrecompute, NoteCommitmentTrees}, parameters::Network, sapling, @@ -178,6 +177,7 @@ pub const STATE_COLUMN_FAMILIES_IN_CODE: &[&str] = &[ "zakura_header_by_height", ZAKURA_HEADER_BODY_SIZE_BY_HEIGHT, ZAKURA_HEADER_COMMITMENT_ROOTS_BY_HEIGHT, + ZAKURA_HEADER_FRONTIER_TREE, // Transactions "tx_by_loc", "hash_by_tx_loc", @@ -286,6 +286,17 @@ pub const COMMITMENT_ROOTS_BY_HEIGHT: &str = "commitment_roots_by_height"; pub const ZAKURA_HEADER_COMMITMENT_ROOTS_BY_HEIGHT: &str = "zakura_header_commitment_roots_by_height"; +/// Column family: the running ZIP-221 history tree at the Zakura header-sync frontier. +/// +/// Keyed by the unit key `()` (a single value, atomically updated per header-range commit, +/// like the finalized `history_tree`). As each committed header range's supplied roots are +/// verified against the header commitments (design §6), they are folded into this tree, which +/// runs ahead of the finalized body tip. It is the authoritative verifier's running state: a +/// range that fails to fold consistently is rejected and its peer scored, so no unverified +/// root is ever persisted or served. Stored so verification resumes across restarts without an +/// O(frontier) rebuild. +pub const ZAKURA_HEADER_FRONTIER_TREE: &str = "zakura_header_frontier_tree"; + /// The finalized part of the chain state, stored in the db. /// /// `rocksdb` allows concurrent writes through a shared reference, @@ -789,7 +800,6 @@ impl FinalizedState { ordered_block: QueuedCheckpointVerified, prev_note_commitment_trees: Option, note_precompute: Option, - next_checkpoint: Option<(Arc, Option)>, ) -> Result< (CheckpointVerifiedBlock, NoteCommitmentTrees), (QueuedCheckpointVerified, CommitCheckpointVerifiedError), @@ -799,7 +809,6 @@ impl FinalizedState { checkpoint_verified.clone().into(), prev_note_commitment_trees, note_precompute, - next_checkpoint, "commit checkpoint-verified request", ); @@ -848,12 +857,6 @@ impl FinalizedState { finalizable_block: FinalizableBlock, prev_note_commitment_trees: Option, note_precompute: Option, - // The next checkpoint block (and its precomputed - // auth data root), used to verify this block's fixture roots before the fast - // path trusts them. `None` is only valid for fast blocks at the checkpoint - // handoff, where the embedded final frontiers independently authenticate - // this height's roots, or outside the checkpoint commit path. - next_checkpoint: Option<(Arc, Option)>, source: &str, ) -> Result<(block::Hash, NoteCommitmentTrees), CommitCheckpointVerifiedError> { let ( @@ -924,42 +927,25 @@ impl FinalizedState { .as_ref() .and_then(|v| v.final_frontiers_for_last_checkpoint(height)); - // This block's own commitment check is identical to the - // previous vct block's look-ahead. When that look-ahead - // already validated this exact header, skip the duplicate. - let block_hash = block.hash(); - let is_prevalidated = self.vct_prevalidated_next == Some((height, block_hash)); - if is_prevalidated { - if let Some(v) = &self.vct { - v.record_prevalidated(); - } - // Observability: the previous fast block's look-ahead already - // validated this header, so its commitment check was skipped (the - // dedup). A subset of `state.vct.fast.block.count`. - metrics::counter!("state.vct.prevalidated.block.count").increment(1); - } - - let mut verification_items = vec![ + // These supplied roots were already verified against the checkpoint-committed + // header chain at header-sync commit (design §6, folded into the + // `zakura_header_frontier_tree` MMR before they were persisted), so the + // committer trusts them. It re-checks this block's own header commitment + // against the running tree as a cheap consensus-equivalence invariant and + // folds the roots — but no longer waits for a buffered successor to confirm + // them, removing the successor dependency that deadlocked the write worker. + let verification_items = vec![ commitment_aux_verify::CommitmentRootVerification::with_roots( block.clone(), sapling_root, orchard_root, precomputed_auth_data_root, - is_prevalidated, + false, ), ]; - if let Some((next_block, next_auth)) = &next_checkpoint { - verification_items.push( - commitment_aux_verify::CommitmentRootVerification::header_only( - next_block.clone(), - *next_auth, - ), - ); - } - // Verifies this block's own header, folds its supplied roots into - // the candidate tree, and when buffered checks the successor header - // against that candidate (the one-block lag). + // Confirms this block's own header commitment against the running tree and + // folds its supplied roots into the candidate tree. let candidate = COMMIT_COMPUTE_POOL .install(|| { commitment_aux_verify::verify_commitment_roots( @@ -969,33 +955,9 @@ impl FinalizedState { ) }) .map_err(|(_fail_height, error)| { - self.vct_prevalidated_next = None; self.vct_reject_supplied_root(height, error) })?; - if let Some((next_block, _next_auth)) = &next_checkpoint { - self.vct_prevalidated_next = Some(( - (height + 1).expect("checkpoint block heights are valid"), - next_block.hash(), - )); - } else if self - .vct - .as_ref() - .is_some_and(|v| v.vct_root_needs_successor(height, &network)) - { - // Untrusted root at/above Heartwood, no successor to confirm it, - // not the last checkpoint: defer rather than persist it unverified. Leaves - // the database untouched; the block re-commits once the successor - // is buffered. - metrics::counter!("state.vct.root.await_successor.count").increment(1); - return Err(ValidateContextError::VctSuppliedRootAwaitingSuccessor { - height, - } - .into()); - } else { - self.vct_prevalidated_next = None; - } - history_tree = Arc::new(candidate); if let Some(v) = &self.vct { v.record_fast_block(); @@ -1293,19 +1255,6 @@ impl FinalizedState { self.vct_prevalidated_next = None; } - /// `true` when committing `height` on the fast path needs a buffered successor before - /// it can safely persist this block's supplied roots. - /// - /// Only untrusted peer-supplied roots at or above Heartwood require this. The - /// checkpoint handoff is exempt because its embedded final frontiers are verified - /// against this block's roots before the real tip treestate is written; trusted - /// local fixtures can commit their tip root on the in-arrears check. - pub(crate) fn vct_fast_needs_successor(&self, height: block::Height) -> bool { - self.vct - .as_ref() - .is_some_and(|v| v.vct_root_needs_successor(height, &self.network())) - } - /// Verify checkpoint handoff frontiers against this block's supplied roots. fn vct_verify_handoff_frontier_roots( &mut self, @@ -1355,19 +1304,13 @@ impl FinalizedState { /// Test-only: enable fast mode reading roots/frontiers from an arbitrary /// [`commitment_aux::CommitmentRootSource`] (e.g. a payload produced from a /// database via [`commitment_aux::produce_block_roots`]), so the producer→consumer - /// round-trip can be exercised in-process. `requires_verified_successor` marks - /// whether the installed source is untrusted and must defer tip roots until their - /// successor is buffered. + /// round-trip can be exercised in-process. #[cfg(test)] pub(in crate::service::finalized_state) fn enable_vct_fast_source( &mut self, source: Box, - requires_verified_successor: bool, ) { - self.vct = Some(VctState::test_with_source( - source, - requires_verified_successor, - )); + self.vct = Some(VctState::test_with_source(source)); } /// Test-only: the fast-sync handoff height recorded in the database marker, if any. @@ -1382,16 +1325,6 @@ impl FinalizedState { self.vct.as_ref().map(|v| v.fast_count()).unwrap_or(0) } - /// Test-only: number of fast blocks whose own commitment check was skipped by - /// the dedup (the previous block's look-ahead already validated them). - #[cfg(test)] - pub(crate) fn vct_prevalidated_count(&self) -> u64 { - self.vct - .as_ref() - .map(|v| v.prevalidated_count()) - .unwrap_or(0) - } - /// POC: log the consensus-equivalence digest (anchor sets + history root) and /// the fast-path block count at the stop height, so a legacy run and a fast run /// can be compared. Gated by `VCT_DIGEST` so normal runs pay nothing. diff --git a/zebra-state/src/service/finalized_state/commitment_aux_verify.rs b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs index 5f7be2f1fa2..9178586f005 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux_verify.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs @@ -15,9 +15,10 @@ use std::sync::Arc; use zebra_chain::{ - block::{merkle::AuthDataRoot, Block, Height}, + block::{merkle::AuthDataRoot, Block, Header, Height}, history_tree::HistoryTree, - orchard, + ironwood, orchard, + parallel::commitment_aux::BlockCommitmentRoots, parameters::{Network, NetworkUpgrade}, sapling, }; @@ -50,18 +51,6 @@ impl CommitmentRootVerification { skip_parent_check, } } - - pub(crate) fn header_only( - block: Arc, - precomputed_auth_data_root: Option, - ) -> Self { - CommitmentRootVerification { - block, - roots: None, - precomputed_auth_data_root, - skip_parent_check: false, - } - } } /// Verifies a supplied Sapling root for a *pre-Heartwood* block directly against the @@ -144,6 +133,136 @@ pub(crate) fn verify_supplied_orchard_root_below_nu5( Ok(()) } +/// Header-only variant of [`verify_supplied_sapling_root_below_heartwood`] (design §6.1): the +/// same direct pre-Heartwood Sapling check, driven by the header + height instead of the block +/// body, for the header-sync verification path. +pub(crate) fn verify_supplied_sapling_root_below_heartwood_from_header( + network: &Network, + header: &Header, + height: Height, + sapling_root: &sapling::tree::Root, +) -> Result<(), ValidateContextError> { + let expected = match header.commitment(network, height)? { + Commitment::FinalSaplingRoot(header_root) => header_root, + Commitment::PreSaplingReserved(_) => sapling::tree::NoteCommitmentTree::default().root(), + // Heartwood activation and later are authenticated by the MMR path. + _ => return Ok(()), + }; + + if sapling_root != &expected { + return Err(ValidateContextError::InvalidBlockCommitment( + CommitmentError::InvalidFinalSaplingRoot { + expected: <[u8; 32]>::from(expected), + actual: <[u8; 32]>::from(*sapling_root), + }, + )); + } + + Ok(()) +} + +/// Verifies a supplied Ironwood root for a *pre-Nu7* block (design §6.1, Ironwood analogue of +/// [`verify_supplied_orchard_root_below_nu5`]). +/// +/// The Ironwood tree does not activate until Nu7, and no leaf below Nu7 commits to an Ironwood +/// root (the V1/V2 history leaf ignores it). Below Nu7 the tree is provably the empty default, +/// so the supplied root is pinned to the empty-tree root — an untrusted source cannot inject a +/// non-empty Ironwood anchor there. At and above Nu7 the V3 MMR leaf authenticates it. +pub(crate) fn verify_supplied_ironwood_root_below_nu7( + network: &Network, + height: Height, + ironwood_root: &ironwood::tree::Root, +) -> Result<(), ValidateContextError> { + if let Some(nu7_height) = NetworkUpgrade::Nu7.activation_height(network) { + if height >= nu7_height { + return Ok(()); + } + } + + let expected = ironwood::tree::NoteCommitmentTree::default().root(); + if ironwood_root != &expected { + return Err(ValidateContextError::InvalidBlockCommitment( + CommitmentError::InvalidPreNu5OrchardRoot { + expected: <[u8; 32]>::from(expected), + actual: <[u8; 32]>::from(*ironwood_root), + }, + )); + } + + Ok(()) +} + +/// Verify supplied per-block roots against the checkpoint-committed header chain, folding them +/// into the ZIP-221 MMR **from parts** (no block bodies) — the header-sync verification path +/// (design §6). This is the authoritative check: a range that passes is safe to persist and +/// serve; a range that fails identifies the offending peer at ingestion. +/// +/// `items` are `(header, roots)` in ascending, contiguous height order, each one height above +/// `tree`'s current tip (`tree` is the running header-frontier history tree). Returns the +/// advanced tree, or `(height, error)` for the first block whose header commitment rejects the +/// roots folded so far. +/// +/// # Lag +/// +/// A block's commitment binds the history tree as of its *parent*, so the root supplied for +/// height `H` is confirmed when `H + 1` is processed. Over a contiguous range `[start..=end]` +/// this confirms `[start..=end - 1]`; the next range's first header confirms `end`. +pub(crate) fn verify_supplied_roots_from_parts<'a, I>( + network: &Network, + mut tree: HistoryTree, + items: I, +) -> Result +where + I: IntoIterator, +{ + for (header, roots) in items { + let height = roots.height; + + // Confirm this block's header commitment against the running tree (every root folded + // so far), driven by the header + this block's own auth-data root. + check::header_commitment_is_valid_for_chain_history( + header, + height, + network, + &tree, + roots.auth_data_root, + ) + .map_err(|error| (height, error))?; + + // Direct checks the MMR path can't vouch for below the pools' activations. + verify_supplied_sapling_root_below_heartwood_from_header( + network, + header, + height, + &roots.sapling_root, + ) + .map_err(|error| (height, error))?; + verify_supplied_orchard_root_below_nu5(network, height, &roots.orchard_root) + .map_err(|error| (height, error))?; + verify_supplied_ironwood_root_below_nu7(network, height, &roots.ironwood_root) + .map_err(|error| (height, error))?; + + // Fold this block's supplied roots into the running MMR, building the leaf from the + // header + carried tx-counts (no block body). + tree.push_from_parts( + network, + header, + height, + &roots.sapling_root, + &roots.orchard_root, + &roots.ironwood_root, + roots.sapling_tx, + roots.orchard_tx, + roots.ironwood_tx, + ) + .map_err(Arc::new) + .map_err(ValidateContextError::from) + .map_err(|error| (height, error))?; + } + + Ok(tree) +} + /// Verifies that `items` (blocks in ascending height order, with supplied /// Sapling/Orchard roots when they should be folded in) reconstruct a ZIP-221 /// history MMR consistent with the block header commitments, starting from `tree` diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshot.rs b/zebra-state/src/service/finalized_state/disk_format/tests/snapshot.rs index 780cb34a136..10e21d4acf2 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshot.rs +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshot.rs @@ -96,7 +96,7 @@ fn test_raw_rocksdb_column_families_with_network(network: Network) { .expect("test data deserializes"); state - .commit_finalized_direct(block.into(), None, None, None, "snapshot tests") + .commit_finalized_direct(block.into(), None, None, "snapshot tests") .expect("test block is valid"); let mut settings = insta::Settings::clone_current(); diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/column_family_names.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/column_family_names.snap index 3c5bc1df4af..5578c8d3785 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/column_family_names.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/column_family_names.snap @@ -40,6 +40,7 @@ expression: cf_names "zakura_header_body_size_by_height", "zakura_header_by_height", "zakura_header_commitment_roots_by_height", + "zakura_header_frontier_tree", "zakura_header_hash_by_height", "zakura_header_height_by_hash", ] diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_0.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_0.snap index e7ee4236fa7..77e3f859800 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_0.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_0.snap @@ -23,6 +23,7 @@ expression: empty_column_families "zakura_header_body_size_by_height: no entries", "zakura_header_by_height: no entries", "zakura_header_commitment_roots_by_height: no entries", + "zakura_header_frontier_tree: no entries", "zakura_header_hash_by_height: no entries", "zakura_header_height_by_hash: no entries", ] diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_1.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_1.snap index 8f6f6e5fe1d..f3fcda9a2ff 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_1.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_1.snap @@ -19,6 +19,7 @@ expression: empty_column_families "zakura_header_body_size_by_height: no entries", "zakura_header_by_height: no entries", "zakura_header_commitment_roots_by_height: no entries", + "zakura_header_frontier_tree: no entries", "zakura_header_hash_by_height: no entries", "zakura_header_height_by_hash: no entries", ] diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_2.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_2.snap index 8f6f6e5fe1d..f3fcda9a2ff 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_2.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@mainnet_2.snap @@ -19,6 +19,7 @@ expression: empty_column_families "zakura_header_body_size_by_height: no entries", "zakura_header_by_height: no entries", "zakura_header_commitment_roots_by_height: no entries", + "zakura_header_frontier_tree: no entries", "zakura_header_hash_by_height: no entries", "zakura_header_height_by_hash: no entries", ] diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@no_blocks.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@no_blocks.snap index 9a2a2e6bffb..f095412719c 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@no_blocks.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@no_blocks.snap @@ -39,6 +39,7 @@ expression: empty_column_families "zakura_header_body_size_by_height: no entries", "zakura_header_by_height: no entries", "zakura_header_commitment_roots_by_height: no entries", + "zakura_header_frontier_tree: no entries", "zakura_header_hash_by_height: no entries", "zakura_header_height_by_hash: no entries", ] diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_0.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_0.snap index e7ee4236fa7..77e3f859800 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_0.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_0.snap @@ -23,6 +23,7 @@ expression: empty_column_families "zakura_header_body_size_by_height: no entries", "zakura_header_by_height: no entries", "zakura_header_commitment_roots_by_height: no entries", + "zakura_header_frontier_tree: no entries", "zakura_header_hash_by_height: no entries", "zakura_header_height_by_hash: no entries", ] diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_1.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_1.snap index 8f6f6e5fe1d..f3fcda9a2ff 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_1.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_1.snap @@ -19,6 +19,7 @@ expression: empty_column_families "zakura_header_body_size_by_height: no entries", "zakura_header_by_height: no entries", "zakura_header_commitment_roots_by_height: no entries", + "zakura_header_frontier_tree: no entries", "zakura_header_hash_by_height: no entries", "zakura_header_height_by_hash: no entries", ] diff --git a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_2.snap b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_2.snap index 8f6f6e5fe1d..f3fcda9a2ff 100644 --- a/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_2.snap +++ b/zebra-state/src/service/finalized_state/disk_format/tests/snapshots/empty_column_families@testnet_2.snap @@ -19,6 +19,7 @@ expression: empty_column_families "zakura_header_body_size_by_height: no entries", "zakura_header_by_height: no entries", "zakura_header_commitment_roots_by_height: no entries", + "zakura_header_frontier_tree: no entries", "zakura_header_hash_by_height: no entries", "zakura_header_height_by_hash: no entries", ] diff --git a/zebra-state/src/service/finalized_state/tests/prop.rs b/zebra-state/src/service/finalized_state/tests/prop.rs index 261edd3db72..97e9fcb053c 100644 --- a/zebra-state/src/service/finalized_state/tests/prop.rs +++ b/zebra-state/src/service/finalized_state/tests/prop.rs @@ -38,10 +38,7 @@ type OrchardTree = Arc; type SproutTree = Arc; fn enable_vct_test_fixture_source(state: &mut FinalizedState, roots: TestRootMap) { - state.enable_vct_fast_source( - Box::new(commitment_aux::FixtureSource::new(roots, None)), - false, - ); + state.enable_vct_fast_source(Box::new(commitment_aux::FixtureSource::new(roots, None))); } fn enable_vct_test_fixture_source_with_handoff( @@ -52,18 +49,15 @@ fn enable_vct_test_fixture_source_with_handoff( orchard: OrchardTree, sprout: SproutTree, ) { - state.enable_vct_fast_source( - Box::new(commitment_aux::FixtureSource::new( - roots, - Some(commitment_aux::FinalFrontiers { - height: handoff_height, - sapling, - orchard, - sprout, - }), - )), - false, - ); + state.enable_vct_fast_source(Box::new(commitment_aux::FixtureSource::new( + roots, + Some(commitment_aux::FinalFrontiers { + height: handoff_height, + sapling, + orchard, + sprout, + }), + ))); } #[test] @@ -104,7 +98,7 @@ fn vct_generated_final_frontier_bytes_are_node_loader_compatible() -> Result<()> for block in blocks.iter().take(last + 1) { let cv = CheckpointVerifiedBlock::from(block.block.clone()); legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct frontier bytes legacy") + .commit_finalized_direct(cv.into(), None, None, "vct frontier bytes legacy") .unwrap(); } @@ -164,7 +158,7 @@ fn blocks_with_v5_transactions() -> Result<()> { checkpoint_verified.into(), None, None, - None, + "blocks_with_v5_transactions test" ).unwrap(); prop_assert_eq!(Some(height), state.finalized_tip_height()); @@ -241,7 +235,7 @@ fn all_upgrades_and_wrong_commitments_with_fake_activation_heights() -> Result<( checkpoint_verified.into(), None, None, - None, + "all_upgrades test" ).expect_err("Must fail commitment check"); failure_count += 1; @@ -253,7 +247,7 @@ fn all_upgrades_and_wrong_commitments_with_fake_activation_heights() -> Result<( checkpoint_verified.into(), None, None, - None, + "all_upgrades test" ).unwrap(); prop_assert_eq!(Some(height), state.finalized_tip_height()); @@ -330,7 +324,7 @@ fn vct_fast_path_matches_legacy_and_rejects_wrong_roots() -> Result<()> { for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct legacy") + .commit_finalized_direct(cv.into(), None, None, "vct legacy") .unwrap(); if i > seed { fixture.insert(i as u32, (trees.sapling.root(), trees.orchard.root())); @@ -347,33 +341,26 @@ fn vct_fast_path_matches_legacy_and_rejects_wrong_roots() -> Result<()> { enable_vct_test_fixture_source(&mut fast, fixture.clone()); for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct fast") + fast.commit_finalized_direct(cv.into(), None, None, "vct fast") .expect("verified fast commit succeeds"); } prop_assert_eq!(fast.db.vct_anchor_digest(), golden_anchors, "fast anchors must match legacy"); prop_assert_eq!(fast.db.history_tree().hash(), golden_history, "fast history must match legacy"); prop_assert_eq!(fast.vct_fast_count(), (last - seed) as u64, "every fast-eligible block took the fast path"); - // The dedup: each header commitment is checked once, not twice. Only the - // first fast block runs its own commitment check; every later fast block - // was already validated by its predecessor's look-ahead, so it is skipped. - prop_assert_eq!(fast.vct_prevalidated_count(), (last - seed - 1) as u64, "every fast block after the first skips its redundant own commitment check"); - // A trusted local fixture may commit its tip root without a successor: it is - // not adversarial and the root is checked in arrears when a successor arrives. + // A fast block commits its tip root without a buffered successor: the roots were + // already verified against the header chain at header-sync commit (design §6). let mut no_successor = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); enable_vct_test_fixture_source(&mut no_successor, fixture.clone()); for i in 0..last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); no_successor - .commit_finalized_direct(cv.into(), None, None, next, "vct no-successor seed") + .commit_finalized_direct(cv.into(), None, None, "vct no-successor seed") .expect("verified fast commit succeeds with successor"); } - prop_assert!(!no_successor.vct_fast_needs_successor(Height(last as u32)), "a trusted fixture tip can commit without a successor"); let cv = CheckpointVerifiedBlock::from(blocks[last].block.clone()); no_successor - .commit_finalized_direct(cv.into(), None, None, None, "vct trusted fixture no successor") + .commit_finalized_direct(cv.into(), None, None, "vct trusted fixture no successor") .expect("trusted fixture tip commits without a successor"); prop_assert_eq!( no_successor.db.finalized_tip_height(), @@ -387,6 +374,11 @@ fn vct_fast_path_matches_legacy_and_rejects_wrong_roots() -> Result<()> { // wrong root must be *rejected* by the next block's commitment (verify-before- // commit) — the commit at that height fails rather than persisting it. let bad_height = (nu5 + 1) as usize; + // A wrong Sapling MMR root is committed by the *next* block's header (the one-block + // lag), so the committer rejects it when that successor commits — one height later. + // The tip case (no successor) is caught by header-sync verification (design §6), not + // at the committer, so only the non-tip case is asserted here. + prop_assume!(bad_height < last); let mut bad_fixture = fixture.clone(); let bad_entry = bad_fixture.get_mut(&(bad_height as u32)).unwrap(); prop_assert_ne!(bad_entry.0, Default::default(), "a V2 block must have a non-empty Sapling root"); @@ -397,13 +389,12 @@ fn vct_fast_path_matches_legacy_and_rejects_wrong_roots() -> Result<()> { let mut error_height = None; for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - if bad.commit_finalized_direct(cv.into(), None, None, next, "vct bad").is_err() { + if bad.commit_finalized_direct(cv.into(), None, None, "vct bad").is_err() { error_height = Some(i); break; } } - prop_assert_eq!(error_height, Some(bad_height), "a wrong fixture root is rejected at its own commit"); + prop_assert_eq!(error_height, Some(bad_height + 1), "a wrong fixture Sapling root is rejected at its successor's commit (one-block lag)"); // Negative (Orchard, below NU5): no header commits to an Orchard root below // NU5 (V1 history leaves ignore it; no MMR below Heartwood), so the fast path @@ -428,8 +419,7 @@ fn vct_fast_path_matches_legacy_and_rejects_wrong_roots() -> Result<()> { let mut orchard_error_height = None; for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - if bad_orchard.commit_finalized_direct(cv.into(), None, None, next, "vct bad orchard").is_err() { + if bad_orchard.commit_finalized_direct(cv.into(), None, None, "vct bad orchard").is_err() { orchard_error_height = Some(i); break; } @@ -492,7 +482,7 @@ fn vct_frozen_frontier_hole_refuses_instead_of_recomputing() -> Result<()> { for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct hole legacy") + .commit_finalized_direct(cv.into(), None, None, "vct hole legacy") .unwrap(); if i > seed { fixture.insert(i as u32, (trees.sapling.root(), trees.orchard.root())); @@ -513,8 +503,7 @@ fn vct_frozen_frontier_hole_refuses_instead_of_recomputing() -> Result<()> { let mut error_height = None; for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = (i < last).then(|| (blocks[i + 1].block.clone(), None)); - match fast.commit_finalized_direct(cv.into(), None, None, next, "vct hole fast") { + match fast.commit_finalized_direct(cv.into(), None, None, "vct hole fast") { Ok(_) => {} Err(error) => { // The refusal is the typed, retryable error — not a generic @@ -588,7 +577,7 @@ fn vct_retryable_root_miss_keeps_checkpoint_response_pending() -> Result<()> { for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct response legacy") + .commit_finalized_direct(cv.into(), None, None, "vct response legacy") .unwrap(); if i > seed { fixture.insert(i as u32, (trees.sapling.root(), trees.orchard.root())); @@ -603,15 +592,13 @@ fn vct_retryable_root_miss_keeps_checkpoint_response_pending() -> Result<()> { for i in 0..hole { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct response fast") + fast.commit_finalized_direct(cv.into(), None, None, "vct response fast") .expect("pre-hole fast commits succeed"); } let cv = CheckpointVerifiedBlock::from(blocks[hole].block.clone()); let (rsp_tx, mut rsp_rx) = oneshot::channel(); - let next = Some((blocks[hole + 1].block.clone(), None)); - let result = fast.commit_finalized((cv, rsp_tx), None, None, next); + let result = fast.commit_finalized((cv, rsp_tx), None, None); let Err((returned_block, error)) = result else { panic!("missing frozen-frontier root should return the queued block for retry"); }; @@ -630,277 +617,6 @@ fn vct_retryable_root_miss_keeps_checkpoint_response_pending() -> Result<()> { Ok(()) } -/// An *untrusted* (peer) source must never commit a fast block whose own supplied root has -/// no buffered successor to confirm it against the header chain. A block's roots are only -/// committed by the next block's header (the one-block lag), so committing at the sync tip -/// would persist a root checked only one block later — irreversibly, once on disk. A wrong -/// tip root would then wedge the sync with no recovery (the failure surfaces at the next -/// block and is mis-attributed to *its* root). So the committer defers: it refuses the tip -/// block with the retryable `VctSuppliedRootAwaitingSuccessor`, leaves the database -/// untouched, and commits the same height once a successor is buffered. A trusted local -/// fixture is exempt (covered by `vct_fast_path_matches_legacy_and_rejects_wrong_roots`, -/// whose tip commits on the in-arrears check); this guards the peer path specifically. -#[test] -#[allow(clippy::needless_range_loop)] // the loop indexes blocks[i+1] and inserts roots by height -fn vct_peer_source_defers_unverifiable_tip_root_until_successor() -> Result<()> { - use crate::service::finalized_state::commitment_aux::PeerSource; - use zebra_chain::parallel::commitment_aux::BlockCommitmentRoots; - - let _init_guard = zebra_test::init(); - - let network = ParametersBuilder::default() - .with_activation_heights(ConfiguredActivationHeights { - before_overwinter: Some(1), - overwinter: Some(10), - sapling: Some(15), - blossom: Some(20), - heartwood: Some(25), - canopy: Some(30), - nu5: Some(35), - nu6: Some(40), - nu6_1: Some(45), - nu6_2: Some(47), - nu6_3: Some(48), - nu7: Some(50), - }) - .expect("failed to set activation heights") - .extend_funding_streams() - .to_network() - .expect("failed to build configured network"); - let ledger_strategy = - LedgerState::genesis_strategy(Some(network), None::, None, false); - - proptest!(ProptestConfig::with_cases(1), - |((chain, _count, network, _history_tree) in PreparedChain::default().with_ledger_strategy(ledger_strategy.clone()).with_valid_commitments().no_shrink())| { - - let blocks: Vec<_> = chain.iter().collect(); - let nu5 = NetworkUpgrade::Nu5.activation_height(&network).unwrap().0; - let heartwood = NetworkUpgrade::Heartwood.activation_height(&network).unwrap().0; - // The deferral target: a post-NU5 (real MMR root) height, so it sits above - // Heartwood where the root needs a successor to be confirmed. - let tip_target = (nu5 + 1) as usize; - prop_assert!(blocks.len() > tip_target + 1, "generated chain unexpectedly short"); - let seed = (heartwood - 1) as usize; - - // Legacy golden pass to source the correct per-block roots for the fast range. - let mut legacy = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - let mut peer_roots = Vec::new(); - for i in 0..=tip_target { - let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct defer legacy") - .unwrap(); - if i > seed { - peer_roots.push(BlockCommitmentRoots { - height: Height(i as u32), - sapling_root: trees.sapling.root(), - orchard_root: trees.orchard.root(), - ironwood_root: zebra_chain::ironwood::tree::NoteCommitmentTree::default().root(), - sapling_tx: 0, - orchard_tx: 0, - ironwood_tx: 0, - auth_data_root: blocks[i].block.auth_data_root(), - }); - } - } - - // An untrusted peer source pre-filled with the *correct* roots: the deferral is - // about the missing successor, not a bad root. - let (source, writer) = PeerSource::new(None); - writer.insert_roots(peer_roots); - - let mut fast = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - fast.enable_vct_fast_source(Box::new(source), true); - - // Commit up to (but not including) the tip target, each with its successor. - for i in 0..tip_target { - let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct defer pre-tip") - .expect("pre-tip fast commits succeed"); - } - prop_assert_eq!(fast.db.finalized_tip_height(), Some(Height((tip_target - 1) as u32))); - - // The tip target with no buffered successor must defer, not commit: its own - // (correct) root is not yet confirmed, and the peer source is untrusted. - prop_assert!( - fast.vct_fast_needs_successor(Height(tip_target as u32)), - "an untrusted peer tip root needs successor verification" - ); - let pre_deferral_prevalidated = fast.vct_prevalidated_count(); - let cv = CheckpointVerifiedBlock::from(blocks[tip_target].block.clone()); - let error = fast - .commit_finalized_direct(cv.into(), None, None, None, "vct defer tip no successor") - .expect_err("an untrusted tip root with no successor must defer, not commit"); - prop_assert!( - error.vct_supplied_root_unavailable_height().is_none(), - "deferral is not a refetch case (the root is present): {error:?}" - ); - prop_assert!( - format!("{error:?}").contains("VctSuppliedRootAwaitingSuccessor"), - "the tip defers with the await-successor error, got: {error:?}" - ); - prop_assert_eq!( - fast.db.finalized_tip_height(), - Some(Height((tip_target - 1) as u32)), - "the deferred block left the database untouched" - ); - let after_deferral_prevalidated = fast.vct_prevalidated_count(); - prop_assert_eq!( - after_deferral_prevalidated, - pre_deferral_prevalidated + 1, - "the deferred attempt uses the predecessor look-ahead" - ); - - // Once a successor is buffered, the very same height commits and the tip advances: - // the deferral was a wait, not a permanent stall. - let cv = CheckpointVerifiedBlock::from(blocks[tip_target].block.clone()); - let next = Some((blocks[tip_target + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct defer tip with successor") - .expect("the deferred height commits once its successor is buffered"); - prop_assert_eq!( - fast.vct_prevalidated_count(), - after_deferral_prevalidated + 1, - "the retry reuses the preserved predecessor look-ahead" - ); - prop_assert_eq!( - fast.db.finalized_tip_height(), - Some(Height(tip_target as u32)), - "the tip advances once the successor confirms the root" - ); - }); - - Ok(()) -} - -/// A wrong peer-supplied root must be recoverable at the same height: the committer rejects and -/// evicts the bad cached value, leaves the database parked below the height, then commits the -/// same block once the `tree_aux` driver refills that height with a verifiable root. -#[test] -#[allow(clippy::needless_range_loop)] // the loop indexes blocks[i+1] and inserts roots by height -fn vct_peer_source_bad_root_refill_commits_same_height() -> Result<()> { - use crate::service::finalized_state::commitment_aux::PeerSource; - use zebra_chain::parallel::commitment_aux::BlockCommitmentRoots; - - let _init_guard = zebra_test::init(); - - let network = ParametersBuilder::default() - .with_activation_heights(ConfiguredActivationHeights { - before_overwinter: Some(1), - overwinter: Some(10), - sapling: Some(15), - blossom: Some(20), - heartwood: Some(25), - canopy: Some(30), - nu5: Some(35), - nu6: Some(40), - nu6_1: Some(45), - nu6_2: Some(47), - nu6_3: Some(48), - nu7: Some(50), - }) - .expect("failed to set activation heights") - .extend_funding_streams() - .to_network() - .expect("failed to build configured network"); - let ledger_strategy = - LedgerState::genesis_strategy(Some(network), None::, None, false); - - proptest!(ProptestConfig::with_cases(1), - |((chain, _count, network, _history_tree) in PreparedChain::default().with_ledger_strategy(ledger_strategy.clone()).with_valid_commitments().no_shrink())| { - - let blocks: Vec<_> = chain.iter().collect(); - let nu5 = NetworkUpgrade::Nu5.activation_height(&network).unwrap().0; - let heartwood = NetworkUpgrade::Heartwood.activation_height(&network).unwrap().0; - let target = (nu5 + 1) as usize; - prop_assert!(blocks.len() > target + 1, "generated chain unexpectedly short"); - let seed = (heartwood - 1) as usize; - - // Source the true roots from a legacy pass, then poison the target height exactly - // as a malicious peer would. Earlier roots are correct so the frontier freezes - // before the bad root is encountered. - let mut legacy = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - let mut peer_roots = Vec::new(); - let mut correct_target_root = None; - for i in 0..=target { - let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct refill legacy") - .unwrap(); - if i > seed { - let root = BlockCommitmentRoots { - height: Height(i as u32), - sapling_root: trees.sapling.root(), - orchard_root: trees.orchard.root(), - ironwood_root: zebra_chain::ironwood::tree::NoteCommitmentTree::default().root(), - sapling_tx: 0, - orchard_tx: 0, - ironwood_tx: 0, - auth_data_root: blocks[i].block.auth_data_root(), - }; - if i == target { - correct_target_root = Some(root.clone()); - let mut poisoned = root; - prop_assert_ne!( - poisoned.sapling_root, - Default::default(), - "a V2 target block must have a non-empty Sapling root" - ); - poisoned.sapling_root = Default::default(); - peer_roots.push(poisoned); - } else { - peer_roots.push(root); - } - } - } - let correct_target_root = correct_target_root.expect("target root was produced"); - - let (source, writer) = PeerSource::new(None); - writer.insert_roots(peer_roots); - - let mut fast = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - fast.enable_vct_fast_source(Box::new(source), true); - - for i in 0..target { - let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct refill pre-target") - .expect("pre-target fast commits succeed"); - } - prop_assert_eq!(fast.db.finalized_tip_height(), Some(Height((target - 1) as u32))); - - let cv = CheckpointVerifiedBlock::from(blocks[target].block.clone()); - let next = Some((blocks[target + 1].block.clone(), None)); - let error = fast - .commit_finalized_direct(cv.into(), None, None, next.clone(), "vct poisoned target") - .expect_err("the poisoned peer root must be rejected before commit"); - prop_assert_eq!( - error.vct_supplied_root_unavailable_height(), - Some(Height(target as u32)), - "the bad root is exposed as a retryable refetch for its own height" - ); - prop_assert_eq!( - fast.db.finalized_tip_height(), - Some(Height((target - 1) as u32)), - "the rejected root left the database parked below the target" - ); - - // Simulate the `tree_aux` driver refilling the evicted height from another peer. - writer.insert_roots([correct_target_root]); - - let cv = CheckpointVerifiedBlock::from(blocks[target].block.clone()); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct refilled target") - .expect("the same height commits once the peer cache is refilled"); - prop_assert_eq!( - fast.db.finalized_tip_height(), - Some(Height(target as u32)), - "the refilled root unblocks the parked height" - ); - }); - - Ok(()) -} - /// The frozen-frontier guard must survive a restart. A fast sync interrupted before the /// checkpoint handoff leaves the stale frozen frontier persisted (fast commits never write /// per-height trees) with the tip still below the handoff, but the in-memory `frozen` flag @@ -965,7 +681,7 @@ fn vct_frozen_frontier_survives_reopen() -> Result<()> { for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct reopen legacy") + .commit_finalized_direct(cv.into(), None, None, "vct reopen legacy") .unwrap(); if i > seed { fixture.insert(i as u32, (trees.sapling.root(), trees.orchard.root())); @@ -1002,8 +718,7 @@ fn vct_frozen_frontier_survives_reopen() -> Result<()> { ); for i in 0..=stop { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct reopen fast") + fast.commit_finalized_direct(cv.into(), None, None, "vct reopen fast") .expect("verified fast commit succeeds"); } prop_assert_eq!(fast.vct_fast_synced_below(), Some(Height(handoff_height)), "the interrupted sync left the fast-sync marker"); @@ -1036,9 +751,8 @@ fn vct_frozen_frontier_survives_reopen() -> Result<()> { // from the durable marker. Before the fix it came back `false` and this would // legacy-recompute against the stale frontier; now it refuses. let cv = CheckpointVerifiedBlock::from(blocks[hole].block.clone()); - let next = Some((blocks[hole + 1].block.clone(), None)); let error = reopened - .commit_finalized_direct(cv.into(), None, None, next, "vct reopen hole") + .commit_finalized_direct(cv.into(), None, None, "vct reopen hole") .expect_err("a frozen-frontier hole must refuse after reopen, not recompute"); prop_assert!( format!("{error:?}").contains("VctSuppliedRootUnavailable"), @@ -1057,9 +771,8 @@ fn vct_frozen_frontier_survives_reopen() -> Result<()> { handoff_trees.sprout.clone(), ); let cv = CheckpointVerifiedBlock::from(blocks[hole].block.clone()); - let next = Some((blocks[hole + 1].block.clone(), None)); reopened - .commit_finalized_direct(cv.into(), None, None, next, "vct reopen refill") + .commit_finalized_direct(cv.into(), None, None, "vct reopen refill") .expect("the height commits once its root is fetched"); prop_assert_eq!(reopened.db.finalized_tip_height(), Some(Height(hole as u32)), "the tip advances past the former hole once the root arrives"); }); @@ -1127,7 +840,7 @@ fn vct_fast_sync_handoff_marks_database_and_resumes() -> Result<()> { for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct legacy") + .commit_finalized_direct(cv.into(), None, None, "vct legacy") .unwrap(); if i > seed { fixture.insert(i as u32, (trees.sapling.root(), trees.orchard.root())); @@ -1152,11 +865,9 @@ fn vct_fast_sync_handoff_marks_database_and_resumes() -> Result<()> { handoff_trees.orchard.clone(), handoff_trees.sprout.clone(), ); - prop_assert!(!fast.vct_fast_needs_successor(handoff), "the trusted handoff frontier authenticates the handoff root without a successor"); for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = (i < last).then(|| (blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct fast handoff") + fast.commit_finalized_direct(cv.into(), None, None, "vct fast handoff") .expect("verified fast commit succeeds"); } @@ -1232,8 +943,7 @@ fn vct_fast_sync_handoff_marks_database_and_resumes() -> Result<()> { let mut handoff_error = None; for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = (i < last).then(|| (blocks[i + 1].block.clone(), None)); - match bad_handoff.commit_finalized_direct(cv.into(), None, None, next, "vct bad handoff") { + match bad_handoff.commit_finalized_direct(cv.into(), None, None, "vct bad handoff") { Ok(_) => {} Err(error) => { error_height = Some(i); @@ -1309,7 +1019,7 @@ fn vct_mode_switches_continue_from_safe_boundaries() -> Result<()> { for i in 0..=post_handoff_tip { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct switch legacy") + .commit_finalized_direct(cv.into(), None, None, "vct switch legacy") .unwrap(); if i > seed && i <= handoff_index { fixture.insert(i as u32, (trees.sapling.root(), trees.orchard.root())); @@ -1347,8 +1057,7 @@ fn vct_mode_switches_continue_from_safe_boundaries() -> Result<()> { ); for i in 0..=handoff_index { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = (i < handoff_index).then(|| (blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct switch fast prefix") + fast.commit_finalized_direct(cv.into(), None, None, "vct switch fast prefix") .expect("verified fast prefix commits"); } prop_assert_eq!(fast.vct_fast_synced_below(), Some(handoff), "fast sync reached the handoff before the switch"); @@ -1362,7 +1071,7 @@ fn vct_mode_switches_continue_from_safe_boundaries() -> Result<()> { for i in (handoff_index + 1)..=post_handoff_tip { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); manual - .commit_finalized_direct(cv.into(), None, None, None, "vct switch manual suffix") + .commit_finalized_direct(cv.into(), None, None, "vct switch manual suffix") .expect("manual suffix commits after fast handoff"); } let manual_tip = manual.db.note_commitment_trees_for_tip(); @@ -1386,7 +1095,7 @@ fn vct_mode_switches_continue_from_safe_boundaries() -> Result<()> { for i in 0..=seed { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); manual_prefix - .commit_finalized_direct(cv.into(), None, None, None, "vct switch manual prefix") + .commit_finalized_direct(cv.into(), None, None, "vct switch manual prefix") .expect("manual prefix commits"); } } @@ -1418,9 +1127,8 @@ fn vct_mode_switches_continue_from_safe_boundaries() -> Result<()> { ); for i in (seed + 1)..=post_handoff_tip { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = (i < post_handoff_tip).then(|| (blocks[i + 1].block.clone(), None)); fast_suffix - .commit_finalized_direct(cv.into(), None, None, next, "vct switch fast suffix") + .commit_finalized_direct(cv.into(), None, None, "vct switch fast suffix") .expect("fast suffix commits after manual prefix"); } prop_assert_eq!( @@ -1439,229 +1147,6 @@ fn vct_mode_switches_continue_from_safe_boundaries() -> Result<()> { Ok(()) } -/// Standalone test isolating the verify-before-commit **dedup**: each header -/// commitment is checked once, not twice. -/// -/// - **Skip:** the first fast block runs its own commitment check; the next one -/// is skipped, because the first block's look-ahead already validated it. -/// - **Stale-cache guard:** a cache entry with the right height but the *wrong* -/// hash must not trigger a skip — the guard forces the own check to run, so a -/// stale or mismatched entry can never let an unverified block through. -/// - **Wrapper-hash guard:** a public `CheckpointVerifiedBlock::with_hash` caller -/// cannot replay a stale cached successor hash onto a different block. -#[test] -fn vct_dedup_skips_redundant_check_and_guards_stale_cache() -> Result<()> { - let _init_guard = zebra_test::init(); - - let network = ParametersBuilder::default() - .with_activation_heights(ConfiguredActivationHeights { - before_overwinter: Some(1), - overwinter: Some(10), - sapling: Some(15), - blossom: Some(20), - heartwood: Some(25), - canopy: Some(30), - nu5: Some(35), - nu6: Some(40), - nu6_1: Some(45), - nu6_2: Some(47), - nu6_3: Some(48), - nu7: Some(50), - }) - .expect("failed to set activation heights") - .extend_funding_streams() - .to_network() - .expect("failed to build configured network"); - let ledger_strategy = - LedgerState::genesis_strategy(Some(network), None::, None, false); - - proptest!(ProptestConfig::with_cases(1), - |((chain, _count, network, _history_tree) in PreparedChain::default().with_ledger_strategy(ledger_strategy.clone()).with_valid_commitments().no_shrink())| { - - let blocks: Vec<_> = chain.iter().collect(); - let nu5 = NetworkUpgrade::Nu5.activation_height(&network).unwrap().0 as usize; - - // Seed just before NU5, then operate on four consecutive fast blocks so - // the forged-wrapper regression exercises `hashBlockCommitments`. - let seed = nu5 - 2; - let last = seed + 4; - prop_assert!(blocks.len() > last + 1, "generated chain unexpectedly short"); - - // Legacy pass to record the correct per-block roots as the fixture. - let mut legacy = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - let mut fixture = std::collections::HashMap::new(); - for (i, prepared) in blocks.iter().take(last + 1).enumerate() { - let cv = CheckpointVerifiedBlock::from(prepared.block.clone()); - let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct dedup legacy") - .unwrap(); - if i > seed { - fixture.insert(i as u32, (trees.sapling.root(), trees.orchard.root())); - } - } - - let mut fast = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - enable_vct_test_fixture_source(&mut fast, fixture); - - // Commit block `i` with its real successor as the one-block look-ahead. - let commit = |fast: &mut FinalizedState, i: usize| { - let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct dedup fast") - .expect("verified fast commit succeeds"); - }; - - // genesis..=seed take the recompute path (no fixture entries), so the dedup - // never engages here. - for i in 0..=seed { - commit(&mut fast, i); - } - prop_assert_eq!(fast.vct_prevalidated_count(), 0, "no fast blocks committed yet"); - - // First fast block: no cached predecessor, so it runs its own check. - commit(&mut fast, seed + 1); - prop_assert_eq!(fast.vct_prevalidated_count(), 0, "the first fast block runs its own commitment check"); - - // Second fast block: its predecessor's look-ahead already validated it, - // so the own check is skipped — the dedup engages. - commit(&mut fast, seed + 2); - prop_assert_eq!(fast.vct_prevalidated_count(), 1, "the second fast block skips its redundant own commitment check"); - - // Stale-cache guard: overwrite the cache with the correct height but the - // hash of a *different* block. The next commit must NOT skip. - let stale_hash = blocks[seed + 1].hash; - prop_assert_ne!(stale_hash, blocks[seed + 3].hash, "stale hash must differ from the real block"); - fast.vct_prevalidated_next = Some((Height((seed + 3) as u32), stale_hash)); - commit(&mut fast, seed + 3); - prop_assert_eq!(fast.vct_prevalidated_count(), 1, "a stale cache entry (wrong hash) must not cause a false skip"); - - // Public wrapper-hash guard: the stale cache records a real look-ahead - // hash, but a caller-controlled checkpoint wrapper tries to replay that - // hash onto a different block whose own NU5 header commitment is invalid. - // The skip must compare the cache against the wrapped block's real hash, - // not the wrapper hash, so the bad commitment is checked and rejected. - let forged_wrapper_hash = blocks[seed + 2].hash; - let bad_block = blocks[seed + 4].block.clone().set_block_commitment([0x42; 32]); - let bad_block_hash = bad_block.hash(); - prop_assert_ne!( - forged_wrapper_hash, - bad_block_hash, - "the forged wrapper hash must differ from the bad block's real hash", - ); - fast.vct_prevalidated_next = - Some((Height((seed + 4) as u32), forged_wrapper_hash)); - let forged = CheckpointVerifiedBlock::with_hash(bad_block, forged_wrapper_hash); - let error = fast - .commit_finalized_direct(forged.into(), None, None, None, "vct forged wrapper hash") - .expect_err("a forged wrapper hash must not skip the bad block's own commitment check"); - prop_assert!( - format!("{error:?}").contains("VctSuppliedRootUnavailable"), - "the forged wrapper hash path must reject the bad commitment, got: {error:?}", - ); - prop_assert_eq!( - fast.vct_prevalidated_count(), - 1, - "the forged wrapper hash must not increment the prevalidated count", - ); - prop_assert_eq!( - fast.db.finalized_tip_height(), - Some(Height((seed + 3) as u32)), - "the rejected forged block must leave finalized state untouched", - ); - }); - - Ok(()) -} - -/// Clearing a cached VCT successor prevalidation must disarm exactly one possible -/// skip without disabling the normal dedup optimization for future contiguous fast -/// blocks. This covers the write-loop reset/drop behavior indirectly: those paths -/// call `clear_vct_prevalidated_next()` when buffered checkpoint state is discarded. -#[test] -fn vct_clear_prevalidation_cache_disarms_skip_then_dedup_resumes() -> Result<()> { - let _init_guard = zebra_test::init(); - - let network = ParametersBuilder::default() - .with_activation_heights(ConfiguredActivationHeights { - before_overwinter: Some(1), - overwinter: Some(10), - sapling: Some(15), - blossom: Some(20), - heartwood: Some(25), - canopy: Some(30), - nu5: Some(35), - nu6: Some(40), - nu6_1: Some(45), - nu6_2: Some(47), - nu6_3: Some(48), - nu7: Some(50), - }) - .expect("failed to set activation heights") - .extend_funding_streams() - .to_network() - .expect("failed to build configured network"); - let ledger_strategy = - LedgerState::genesis_strategy(Some(network), None::, None, false); - - proptest!(ProptestConfig::with_cases(1), - |((chain, _count, network, _history_tree) in PreparedChain::default().with_ledger_strategy(ledger_strategy.clone()).with_valid_commitments().no_shrink())| { - - let blocks: Vec<_> = chain.iter().collect(); - let nu5 = NetworkUpgrade::Nu5.activation_height(&network).unwrap().0 as usize; - let seed = nu5 - 2; - let last = seed + 5; - prop_assert!(blocks.len() > last + 1, "generated chain unexpectedly short"); - - let mut legacy = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - let mut fixture = std::collections::HashMap::new(); - for (i, prepared) in blocks.iter().take(last + 1).enumerate() { - let cv = CheckpointVerifiedBlock::from(prepared.block.clone()); - let (_h, trees) = legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct clear legacy") - .unwrap(); - if i > seed { - fixture.insert(i as u32, (trees.sapling.root(), trees.orchard.root())); - } - } - - let mut fast = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - enable_vct_test_fixture_source(&mut fast, fixture); - - let commit = |fast: &mut FinalizedState, i: usize| { - let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct clear fast") - .expect("verified fast commit succeeds"); - }; - - for i in 0..=seed { - commit(&mut fast, i); - } - commit(&mut fast, seed + 1); - prop_assert_eq!(fast.vct_prevalidated_count(), 0, "first fast block runs its own check"); - - commit(&mut fast, seed + 2); - prop_assert_eq!(fast.vct_prevalidated_count(), 1, "second fast block uses predecessor look-ahead"); - - fast.clear_vct_prevalidated_next(); - commit(&mut fast, seed + 3); - prop_assert_eq!( - fast.vct_prevalidated_count(), - 1, - "clearing the cache forces the next fast block to run its own check", - ); - - commit(&mut fast, seed + 4); - prop_assert_eq!( - fast.vct_prevalidated_count(), - 2, - "normal successor dedup resumes after the cleared block commits", - ); - }); - - Ok(()) -} - /// Increment-3 contract proof: a roots/frontier payload **produced from a database** /// (the serving read path) can replace the fixture and drives the fast path to /// byte-identical consensus state. @@ -1721,7 +1206,7 @@ fn vct_db_produced_payload_round_trips_to_byte_identical_state() -> Result<()> { for block in blocks.iter().take(last + 1) { let cv = CheckpointVerifiedBlock::from(block.block.clone()); legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct round-trip legacy") + .commit_finalized_direct(cv.into(), None, None, "vct round-trip legacy") .unwrap(); } let golden_anchors = legacy.db.vct_anchor_digest(); @@ -1751,13 +1236,11 @@ fn vct_db_produced_payload_round_trips_to_byte_identical_state() -> Result<()> { .map(|root| (root.height.0, (root.sapling_root, root.orchard_root))) .collect(); fast.enable_vct_fast_source( - Box::new(commitment_aux::FixtureSource::new(produced_roots, None)), - false, + Box::new(commitment_aux::FixtureSource::new(produced_roots, None)) ); for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct round-trip fast") + fast.commit_finalized_direct(cv.into(), None, None, "vct round-trip fast") .expect("verified fast commit from DB-produced roots succeeds"); } @@ -1855,7 +1338,7 @@ fn vct_peer_source_filled_incrementally_drives_byte_identical_state() -> Result< for block in blocks.iter().take(last + 1) { let cv = CheckpointVerifiedBlock::from(block.block.clone()); legacy - .commit_finalized_direct(cv.into(), None, None, None, "vct peer-source legacy") + .commit_finalized_direct(cv.into(), None, None, "vct peer-source legacy") .unwrap(); } let golden_anchors = legacy.db.vct_anchor_digest(); @@ -1879,11 +1362,10 @@ fn vct_peer_source_filled_incrementally_drives_byte_identical_state() -> Result< // untrusted source defers a tip commit with no successor (covered by // `vct_peer_source_defers_unverifiable_tip_root_until_successor`). let mut fast = FinalizedState::new(&Config::ephemeral(), &network, #[cfg(feature = "elasticsearch")] false); - fast.enable_vct_fast_source(Box::new(peer_source), true); + fast.enable_vct_fast_source(Box::new(peer_source)); for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); - fast.commit_finalized_direct(cv.into(), None, None, next, "vct peer-source fast") + fast.commit_finalized_direct(cv.into(), None, None, "vct peer-source fast") .expect("verified fast commit from peer-source roots succeeds"); } diff --git a/zebra-state/src/service/finalized_state/tests/rollback.rs b/zebra-state/src/service/finalized_state/tests/rollback.rs index 23f385b1379..637cae8593b 100644 --- a/zebra-state/src/service/finalized_state/tests/rollback.rs +++ b/zebra-state/src/service/finalized_state/tests/rollback.rs @@ -80,13 +80,7 @@ fn sync_to(config: &Config, network: &Network, blocks: &[SemanticallyVerifiedBlo for block in blocks { let checkpoint_verified = CheckpointVerifiedBlock::from(block.block.clone()); state - .commit_finalized_direct( - checkpoint_verified.into(), - None, - None, - None, - "rollback test", - ) + .commit_finalized_direct(checkpoint_verified.into(), None, None, "rollback test") .expect("committing a generated block to a fresh state succeeds"); } } diff --git a/zebra-state/src/service/finalized_state/vct.rs b/zebra-state/src/service/finalized_state/vct.rs index ba0d6bb5e88..851a39fc108 100644 --- a/zebra-state/src/service/finalized_state/vct.rs +++ b/zebra-state/src/service/finalized_state/vct.rs @@ -18,11 +18,7 @@ use std::sync::{ use thiserror::Error; #[cfg(test)] use zebra_chain::parallel::tree::NoteCommitmentTrees; -use zebra_chain::{ - block, orchard, - parameters::{Network, NetworkUpgrade}, - sapling, sprout, -}; +use zebra_chain::{block, orchard, parameters::Network, sapling, sprout}; use super::{ commitment_aux::{CommitmentRootSource, FinalFrontiers, PeerSource}, @@ -67,15 +63,8 @@ pub(crate) struct VctState { /// Where the verified per-block roots and handoff frontiers come from. The /// committer reads roots/handoff/frontiers through this seam only. source: Box, - /// Whether roots from this VCT state must be confirmed against a buffered successor - /// before they are committed. - requires_verified_successor: bool, /// Count of blocks that took the fast (skip-recompute) path, for the run summary. fast_count: AtomicU64, - /// Count of fast blocks whose own commitment check was skipped because the - /// previous block's look-ahead already validated it (the dedup). Lets tests - /// assert the dedup actually engages, so it can't be silently regressed. - prevalidated_count: AtomicU64, } /// Which commitment-root source the committer uses, resolved from the (already read) @@ -143,9 +132,7 @@ impl VctState { Some(Arc::new(VctState { fast: true, source: Box::new(source), - requires_verified_successor: true, fast_count: AtomicU64::new(0), - prevalidated_count: AtomicU64::new(0), })) } @@ -182,28 +169,6 @@ impl VctState { self.source.vct_root(height) } - /// `true` when committing `height` on the vct path needs a buffered successor before - /// it can safely persist this block's supplied roots. - /// - /// Only untrusted peer-supplied roots at or above Heartwood require this. The - /// checkpoint handoff is exempt because its embedded final frontiers are verified - /// against this block's roots before the real tip treestate is written; trusted - /// local fixtures can commit their tip root on the in-arrears check. - pub(super) fn vct_root_needs_successor( - &self, - height: block::Height, - network: &Network, - ) -> bool { - self.fast - && self.vct_roots_at_height(height).is_some() - && self.requires_verified_successor - && self - .source - .final_frontiers() - .is_none_or(|frontiers| frontiers.height != height) - && Some(height) >= NetworkUpgrade::Heartwood.activation_height(network) - } - /// Discard the supplied root for `height` after it failed verification, so a re-fetch /// can replace it. See /// [`CommitmentRootSource::invalidate`](super::commitment_aux::CommitmentRootSource::invalidate). @@ -244,36 +209,20 @@ impl VctState { self.fast_count.fetch_add(1, Ordering::Relaxed); } - /// Record a fast block whose own commitment check was skipped by the dedup. - pub(super) fn record_prevalidated(&self) { - self.prevalidated_count.fetch_add(1, Ordering::Relaxed); - } - /// Number of blocks that took the fast path so far. pub(super) fn fast_count(&self) -> u64 { self.fast_count.load(Ordering::Relaxed) } - /// Number of fast blocks whose own commitment check the dedup skipped. - #[cfg(test)] - pub(super) fn prevalidated_count(&self) -> u64 { - self.prevalidated_count.load(Ordering::Relaxed) - } - /// Test-only: build fast-mode state from an arbitrary commitment-root source /// (e.g. a payload produced from a database), so the producer→consumer round-trip /// can be exercised without networking. #[cfg(test)] - pub(super) fn test_with_source( - source: Box, - requires_verified_successor: bool, - ) -> Arc { + pub(super) fn test_with_source(source: Box) -> Arc { Arc::new(VctState { fast: true, source, - requires_verified_successor, fast_count: AtomicU64::new(0), - prevalidated_count: AtomicU64::new(0), }) } } @@ -399,83 +348,6 @@ mod tests { assert_eq!(select_source_mode(true, true, false), Legacy); } - #[test] - fn successor_policy_is_vct_state_data() { - let network = Network::Mainnet; - let height = NetworkUpgrade::Heartwood - .activation_height(&network) - .expect("mainnet has a Heartwood activation height"); - let root_map = - || std::iter::once((height.0, (Default::default(), Default::default()))).collect(); - - let trusted = VctState::test_with_source( - Box::new(super::super::commitment_aux::FixtureSource::new( - root_map(), - None, - )), - false, - ); - assert!( - !trusted.vct_root_needs_successor(height, &network), - "trusted fixture roots can commit without a buffered successor" - ); - - let untrusted = VctState::test_with_source( - Box::new(super::super::commitment_aux::FixtureSource::new( - root_map(), - None, - )), - true, - ); - assert!( - untrusted.vct_root_needs_successor(height, &network), - "untrusted roots defer until a buffered successor verifies them" - ); - } - - #[test] - fn vct_root_is_bounded_by_handoff_height() { - let handoff = block::Height(10); - let after_handoff = (handoff + 1).expect("test height is valid"); - let roots = std::collections::HashMap::from([ - (handoff.0, (Default::default(), Default::default())), - (after_handoff.0, (Default::default(), Default::default())), - ]); - let frontiers = FinalFrontiers { - height: handoff, - sapling: Arc::new(sapling::tree::NoteCommitmentTree::default()), - orchard: Arc::new(orchard::tree::NoteCommitmentTree::default()), - sprout: Arc::new(sprout::tree::NoteCommitmentTree::default()), - }; - - let bounded = VctState::test_with_source( - Box::new(super::super::commitment_aux::FixtureSource::new( - roots.clone(), - Some(frontiers), - )), - false, - ); - assert!( - bounded.vct_roots_at_height(handoff).is_some(), - "the handoff root remains fast-path eligible" - ); - assert!( - bounded.vct_roots_at_height(after_handoff).is_none(), - "roots above the handoff are ignored" - ); - - let unbounded = VctState::test_with_source( - Box::new(super::super::commitment_aux::FixtureSource::new( - roots, None, - )), - false, - ); - assert!( - unbounded.vct_roots_at_height(after_handoff).is_some(), - "sources without a handoff keep the existing fixture behavior" - ); - } - #[test] fn embedded_mainnet_final_frontiers_parse() { let frontiers = embedded_final_frontiers(&Network::Mainnet) diff --git a/zebra-state/src/service/finalized_state/zebra_db/block.rs b/zebra-state/src/service/finalized_state/zebra_db/block.rs index ec6926cedfe..691e761ad6b 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block.rs @@ -21,9 +21,10 @@ use itertools::Itertools; use zebra_chain::{ amount::NonNegative, block::{self, Block, Height}, + history_tree::HistoryTree, ironwood, orchard, parallel::{commitment_aux::BlockCommitmentRoots, tree::NoteCommitmentTrees}, - parameters::{Network, GENESIS_PREVIOUS_BLOCK_HASH}, + parameters::{Network, NetworkUpgrade, GENESIS_PREVIOUS_BLOCK_HASH}, sapling, serialization::{CompactSizeMessage, TrustedPreallocate, ZcashSerialize as _}, transaction::{self, Transaction}, @@ -43,6 +44,7 @@ use crate::{ disk_db::{DiskDb, DiskWriteBatch, ReadDisk, WriteDisk}, disk_format::{ block::TransactionLocation, + chain::HistoryTreeParts, shielded::CommitmentRootsByHeight, transparent::{AddressBalanceLocationUpdates, OutputLocation}, }, @@ -59,6 +61,25 @@ use crate::request::Spend; #[cfg(test)] mod tests; +/// Whether the running header-frontier history `tree` is folded exactly up to `anchor_height`. +/// +/// A non-empty tree matches when its tip equals the anchor. An empty tree matches exactly while +/// the anchor is below the first MMR height (Heartwood) — nothing has been folded yet and the +/// activation block folds into it next. +fn header_frontier_is_at_anchor( + tree: &HistoryTree, + anchor_height: block::Height, + network: &Network, +) -> bool { + match tree.as_ref().map(|tree| tree.current_height()) { + Some(height) => height == anchor_height, + None => match NetworkUpgrade::Heartwood.activation_height(network) { + Some(heartwood) => anchor_height < heartwood, + None => true, + }, + } +} + const ZAKURA_HEADER_HASH_BY_HEIGHT: &str = "zakura_header_hash_by_height"; const ZAKURA_HEADER_HEIGHT_BY_HASH: &str = "zakura_header_height_by_hash"; const ZAKURA_HEADER_BY_HEIGHT: &str = "zakura_header_by_height"; @@ -2109,6 +2130,38 @@ impl DiskWriteBatch { } } + // Authoritative header-sync verification (design §6): fold this range's supplied roots + // into the running header-frontier ZIP-221 MMR and confirm each against its + // checkpoint-committed header commitment. A range that fails is rejected here — nothing + // is stored — and its peer is scored via `InvalidCommitmentRoots`, catching a lying peer + // early and attributably. Only ranges carrying roots (the checkpoint window) are + // verified; above the last checkpoint no roots are requested. + if !tree_aux_roots.is_empty() { + let network = zebra_db.network(); + let frontier = self.position_header_frontier_tree(zebra_db, &network, anchor_height)?; + + let items = validated_headers + .iter() + .zip(tree_aux_roots.iter()) + .map(|((_height, _hash, header, _body_size), roots)| (header.as_ref(), roots)); + + let advanced = crate::service::finalized_state::commitment_aux_verify::verify_supplied_roots_from_parts( + &network, frontier, items, + ) + .map_err(|(height, source)| CommitHeaderRangeError::InvalidCommitmentRoots { + height, + source: Box::new(source), + })?; + + let frontier_cf = zebra_db + .db + .cf_handle(crate::service::finalized_state::ZAKURA_HEADER_FRONTIER_TREE) + .expect("ZAKURA_HEADER_FRONTIER_TREE column family exists"); + if let Some(tree) = advanced.as_ref() { + self.zs_insert(&frontier_cf, (), HistoryTreeParts::from(tree)); + } + } + for (index, (height, hash, header, body_size)) in validated_headers.into_iter().enumerate() { let same_header = zebra_db.zakura_header_hash(height) == Some(hash); @@ -2153,6 +2206,63 @@ impl DiskWriteBatch { )) } + /// Position the running header-frontier history tree at `anchor_height` for verification + /// (design §6). + /// + /// In the checkpoint window, headers are checkpoint-pinned and committed contiguously, so the + /// persisted frontier tree is normally already folded up to the anchor and returned directly. + /// Otherwise (a fresh adoption of the format, or a rare re-anchor) it folds the stored roots + /// forward from the frontier tip to the anchor, re-verifying them, and errors if the frontier + /// cannot be positioned exactly at the anchor. + fn position_header_frontier_tree( + &self, + zebra_db: &ZebraDb, + network: &Network, + anchor_height: block::Height, + ) -> Result { + let frontier = zebra_db.zakura_header_frontier_tree(); + if header_frontier_is_at_anchor(&frontier, anchor_height, network) { + return Ok(frontier); + } + + // Rebuild the frontier from an empty tree by folding the stored roots `[1..=anchor]`, + // re-verifying them. This handles both a lagging persisted frontier (first adoption of + // the format) and a re-anchor *below* the current frontier (a header re-org overwrote + // higher heights). Rare: in the checkpoint window headers are pinned and contiguous, so + // the fast path above normally returns immediately. + let stored_roots = zebra_db + .zakura_header_commitment_roots_by_height_range(block::Height(1)..=anchor_height); + let mut items = Vec::with_capacity(stored_roots.len()); + for roots in &stored_roots { + let header = zebra_db.zakura_header(roots.height).ok_or( + CommitHeaderRangeError::HeaderFrontierUnavailable { + anchor_height, + missing_height: roots.height, + }, + )?; + items.push((header, roots)); + } + let rebuilt = crate::service::finalized_state::commitment_aux_verify::verify_supplied_roots_from_parts( + network, + HistoryTree::default(), + items.iter().map(|(header, roots)| (header.as_ref(), *roots)), + ) + .map_err(|(height, source)| CommitHeaderRangeError::InvalidCommitmentRoots { + height, + source: Box::new(source), + })?; + + // The fold must reach the anchor, or the range would verify against the wrong tree. + if !header_frontier_is_at_anchor(&rebuilt, anchor_height, network) { + return Err(CommitHeaderRangeError::HeaderFrontierUnavailable { + anchor_height, + missing_height: block::Height(1), + }); + } + + Ok(rebuilt) + } + /// Deletes the block header at `height`. /// /// This is only used by rollback tests to prove modern rollback targets do not need pre-upgrade diff --git a/zebra-state/src/service/finalized_state/zebra_db/block/tests/prune.rs b/zebra-state/src/service/finalized_state/zebra_db/block/tests/prune.rs index 64d0f4453a3..536b8dd52eb 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block/tests/prune.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block/tests/prune.rs @@ -49,7 +49,7 @@ fn new_state_with_blocks(config: &Config, network: &Network) -> FinalizedState { .expect("test data deserializes"); state - .commit_finalized_direct(block.into(), None, None, None, "prune tests") + .commit_finalized_direct(block.into(), None, None, "prune tests") .expect("test block is valid"); } @@ -80,7 +80,7 @@ fn new_state_with_checkpoint_retention( .expect("test data deserializes"); state - .commit_finalized_direct(block.into(), None, None, None, "checkpoint retention tests") + .commit_finalized_direct(block.into(), None, None, "checkpoint retention tests") .expect("test block is valid"); } @@ -351,7 +351,7 @@ fn checkpoint_retention_hands_off_to_online_pruning_at_start() { .expect("test data deserializes"); state - .commit_finalized_direct(block.into(), None, None, None, "checkpoint handoff tests") + .commit_finalized_direct(block.into(), None, None, "checkpoint handoff tests") .expect("test block is valid"); } @@ -386,7 +386,7 @@ fn checkpoint_retention_hands_off_to_online_pruning_at_start() { .expect("test data deserializes"); state - .commit_finalized_direct(block.into(), None, None, None, "checkpoint handoff tests") + .commit_finalized_direct(block.into(), None, None, "checkpoint handoff tests") .expect("handoff block is valid"); let online_prune_until = @@ -630,7 +630,7 @@ fn archive_to_pruned_checkpoint_sync_drains_archive_raw_transactions_before_skip .expect("test data deserializes"); archive_state - .commit_finalized_direct(block.into(), None, None, None, "archive phase") + .commit_finalized_direct(block.into(), None, None, "archive phase") .expect("archive block is valid"); } @@ -670,13 +670,7 @@ fn archive_to_pruned_checkpoint_sync_drains_archive_raw_transactions_before_skip .expect("test data deserializes"); pruned_state - .commit_finalized_direct( - block.into(), - None, - None, - None, - "archive to pruned checkpoint", - ) + .commit_finalized_direct(block.into(), None, None, "archive to pruned checkpoint") .expect("checkpoint block is valid"); assert_eq!( @@ -734,7 +728,7 @@ fn archive_backlog_flag_is_recomputed_when_reopening_a_pruned_database() { .expect("test data deserializes"); archive_state - .commit_finalized_direct(block.into(), None, None, None, "archive phase") + .commit_finalized_direct(block.into(), None, None, "archive phase") .expect("archive block is valid"); } std::mem::drop(archive_state); @@ -767,13 +761,7 @@ fn archive_backlog_flag_is_recomputed_when_reopening_a_pruned_database() { .zcash_deserialize_into() .expect("test data deserializes"); pruned_state - .commit_finalized_direct( - block.into(), - None, - None, - None, - "archive to pruned checkpoint", - ) + .commit_finalized_direct(block.into(), None, None, "archive to pruned checkpoint") .expect("checkpoint block is valid"); assert_eq!( pruned_state.db.lowest_retained_height(), @@ -854,13 +842,7 @@ fn contextual_commits_keep_raw_transactions_before_checkpoint_retention_start() .zcash_deserialize_into() .expect("genesis test data deserializes"); state - .commit_finalized_direct( - genesis.into(), - None, - None, - None, - "contextual retention tests", - ) + .commit_finalized_direct(genesis.into(), None, None, "contextual retention tests") .expect("genesis block is valid"); let block: Arc = blocks @@ -876,7 +858,7 @@ fn contextual_commits_keep_raw_transactions_before_checkpoint_retention_start() let finalizable = FinalizableBlock::new(contextually_verified, Treestate::default()); state - .commit_finalized_direct(finalizable, None, None, None, "contextual retention tests") + .commit_finalized_direct(finalizable, None, None, "contextual retention tests") .expect("contextual block is valid"); assert!( diff --git a/zebra-state/src/service/finalized_state/zebra_db/block/tests/snapshot.rs b/zebra-state/src/service/finalized_state/zebra_db/block/tests/snapshot.rs index 7efcde8250e..b875ed9903d 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block/tests/snapshot.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block/tests/snapshot.rs @@ -195,7 +195,7 @@ fn test_block_and_transaction_data_with_network(network: Network) { .expect("test data deserializes"); state - .commit_finalized_direct(block.into(), None, None, None, "snapshot tests") + .commit_finalized_direct(block.into(), None, None, "snapshot tests") .expect("test block is valid"); let mut settings = insta::Settings::clone_current(); diff --git a/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs b/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs index c0ecaec18cf..f75720a43d2 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block/tests/vectors.rs @@ -1236,6 +1236,13 @@ fn synthetic_headers_from_state( let mut previous_hash = anchor_hash; let mut previous_height = anchor_height; let mut nonce_tag = nonce_seed; + // Running ZIP-221 MMR of the (zero) `root_at` roots, so each synthetic header commits the + // ChainHistoryRoot that header-sync verification reconstructs from those same roots + // (design §6). Anchored at genesis in these tests, so it starts empty. + let mut history_tree = zebra_chain::history_tree::HistoryTree::default(); + let empty_sapling = sapling::tree::NoteCommitmentTree::default().root(); + let empty_orchard = orchard::tree::NoteCommitmentTree::default().root(); + let empty_ironwood = zebra_chain::ironwood::tree::NoteCommitmentTree::default().root(); (0..count) .map(|_| { @@ -1264,8 +1271,34 @@ fn synthetic_headers_from_state( header.nonce.0[0] = header.nonce.0[0].wrapping_add(nonce_tag); nonce_tag = nonce_tag.wrapping_add(1); + // Commit the running MMR root (ChainHistoryRoot) for Heartwood+ heights; the + // Heartwood activation block commits the all-zero reserved value; pre-Heartwood + // keeps the template's reserved commitment. + match history_tree.hash() { + Some(root) => header.commitment_bytes = <[u8; 32]>::from(root).into(), + None if Some(candidate_height) + == NetworkUpgrade::Heartwood.activation_height(&network) => + { + header.commitment_bytes = [0u8; 32].into(); + } + None => {} + } + let header = Arc::new(header); previous_hash = block::Hash::from(&*header); + history_tree + .push_from_parts( + &network, + &header, + candidate_height, + &empty_sapling, + &empty_orchard, + &empty_ironwood, + 0, + 0, + 0, + ) + .expect("folding the synthetic zero roots into the test MMR succeeds"); previous_height = candidate_height; context.insert(0, (header.difficulty_threshold, header.time)); context.truncate(crate::service::check::difficulty::POW_ADJUSTMENT_BLOCK_SPAN); diff --git a/zebra-state/src/service/finalized_state/zebra_db/chain.rs b/zebra-state/src/service/finalized_state/zebra_db/chain.rs index ff225f04257..001c846551f 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/chain.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/chain.rs @@ -157,6 +157,26 @@ impl ZebraDb { Arc::new(HistoryTree::from(history_tree)) } + /// Returns the running ZIP-221 history tree at the Zakura header-sync frontier. + /// + /// This is the authoritative header-sync verifier's running state (design §6): the tree + /// into which each committed header range's verified roots are folded, ahead of the + /// finalized body tip. Returns an empty tree if no range has been folded yet (a fresh + /// database, or one that has not yet reached Heartwood). + pub(crate) fn zakura_header_frontier_tree(&self) -> HistoryTree { + let cf = self + .db + .cf_handle(crate::service::finalized_state::ZAKURA_HEADER_FRONTIER_TREE) + .expect("ZAKURA_HEADER_FRONTIER_TREE column family exists"); + let parts: Option = self.db.zs_get(&cf, &()); + let inner = parts.map(|parts| { + parts + .with_network(&self.db.network()) + .expect("header-frontier tree decodes with the current HistoryTreeParts format") + }); + HistoryTree::from(inner) + } + /// Returns `Ok(())` if the stored tip history tree decodes with the current /// `HistoryTreeParts` format. /// diff --git a/zebra-state/src/service/finalized_state/zebra_db/prune.rs b/zebra-state/src/service/finalized_state/zebra_db/prune.rs index 6f1695002d7..e32c54f7988 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/prune.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/prune.rs @@ -355,7 +355,7 @@ mod tests { .expect("test data deserializes"); state - .commit_finalized_direct(block.into(), None, None, None, "offline prune tests") + .commit_finalized_direct(block.into(), None, None, "offline prune tests") .expect("test block is valid"); } diff --git a/zebra-state/src/service/tests.rs b/zebra-state/src/service/tests.rs index f87418ae045..f598174bbaf 100644 --- a/zebra-state/src/service/tests.rs +++ b/zebra-state/src/service/tests.rs @@ -867,6 +867,7 @@ async fn header_range_reads_include_non_finalized_best_chain_blocks() -> Result< Default::default(), Default::default(), Default::default(), + Default::default(), ValueBalance::fake_populated_pool(), ) .push(block1.prepare().test_with_zero_spent_utxos())? diff --git a/zebra-state/src/service/write.rs b/zebra-state/src/service/write.rs index 2a3f373b2cf..1e5821f9d39 100644 --- a/zebra-state/src/service/write.rs +++ b/zebra-state/src/service/write.rs @@ -485,22 +485,10 @@ impl WriteBlockWorkerTask { } } - // A non-handoff VCT fast block's supplied roots are authenticated by - // its successor's header. If the successor is not buffered yet, keep - // this block local and wait instead of surfacing a checkpoint commit - // error through the invalid-block reset path. - if finalized_lookahead.is_empty() - && finalized_state.vct_fast_needs_successor(ordered_block.0.height) - { - tracing::trace!( - height = ?ordered_block.0.height, - hash = ?ordered_block.0.hash, - "VCT: deferring fast checkpoint commit until successor is buffered" - ); - retry_finalized_block = Some(ordered_block); - std::thread::park_timeout(Duration::from_millis(10)); - continue; - } + // A VCT fast block's supplied roots were already verified against the header chain + // at header-sync commit (design §6), so the committer no longer needs the buffered + // successor to confirm them — the block commits directly, without the wait that + // previously coupled the write worker to the body-download pipeline. // Use the precompute for this block if we started it last iteration and // it is for this exact block; otherwise cancel it (so the spawned task @@ -538,13 +526,6 @@ impl WriteBlockWorkerTask { } } - // The buffered successor (if any) lets the committer verify this block's - // verified-commitment-trees fixture roots before trusting them: a block's - // roots are only committed by the next block's header. Its auth data root - // is already precomputed by the checkpoint verifier. - let next_checkpoint = finalized_lookahead - .front() - .map(|next| (next.0.block.clone(), next.0.auth_data_root)); let prev_note_commitment_trees = prev_finalized_note_commitment_trees.take(); let prev_note_commitment_trees_for_retry = prev_note_commitment_trees.clone(); @@ -556,7 +537,6 @@ impl WriteBlockWorkerTask { ordered_block, prev_note_commitment_trees, note_precompute, - next_checkpoint, ) { Ok((finalized, note_commitment_trees)) => { // Whether this successful commit consumed header-carried @@ -825,7 +805,7 @@ impl WriteBlockWorkerTask { tracing::trace!("finalizing block past the reorg limit"); let contextually_verified_with_trees = non_finalized_state.finalize(); prev_finalized_note_commitment_trees = finalized_state - .commit_finalized_direct(contextually_verified_with_trees, prev_finalized_note_commitment_trees.take(), None, None, "commit contextually-verified request") + .commit_finalized_direct(contextually_verified_with_trees, prev_finalized_note_commitment_trees.take(), None, "commit contextually-verified request") .expect( "unexpected finalized block commit error: note commitment and history trees were already checked by the non-finalized state", ).1.into(); diff --git a/zebra-state/src/tests/setup.rs b/zebra-state/src/tests/setup.rs index 90a43963547..34b1785a84d 100644 --- a/zebra-state/src/tests/setup.rs +++ b/zebra-state/src/tests/setup.rs @@ -113,7 +113,7 @@ pub(crate) fn new_state_with_mainnet_genesis( let genesis = CheckpointVerifiedBlock::from(genesis); finalized_state - .commit_finalized_direct(genesis.clone().into(), None, None, None, "test") + .commit_finalized_direct(genesis.clone().into(), None, None, "test") .expect("unexpected invalid genesis block test vector"); assert_eq!( diff --git a/zebrad/src/commands/start/zakura/header_sync_driver.rs b/zebrad/src/commands/start/zakura/header_sync_driver.rs index a1d9ca0bec7..a32d443a770 100644 --- a/zebrad/src/commands/start/zakura/header_sync_driver.rs +++ b/zebrad/src/commands/start/zakura/header_sync_driver.rs @@ -1112,10 +1112,16 @@ pub(crate) fn header_range_commit_failure_kind( // fork. Treat it as non-scoring so this stays a liveness/correctness guard, // not peer punishment. | zebra_state::CommitHeaderRangeError::LowerWorkConflict { .. } + // A local state inconsistency (the frontier tree could not be positioned at the anchor), + // not peer misbehavior: don't score, header sync retries. + | zebra_state::CommitHeaderRangeError::HeaderFrontierUnavailable { .. } | zebra_state::CommitHeaderRangeError::CommitResponseDropped => { HeaderSyncCommitFailureKind::Local } - zebra_state::CommitHeaderRangeError::EmptyRange + // The peer's supplied roots failed verification against our checkpoint-committed header + // chain (design §6): score it and refetch the range. + zebra_state::CommitHeaderRangeError::InvalidCommitmentRoots { .. } + | zebra_state::CommitHeaderRangeError::EmptyRange | zebra_state::CommitHeaderRangeError::RangeTooLong { .. } | zebra_state::CommitHeaderRangeError::BodySizeCountMismatch { .. } | zebra_state::CommitHeaderRangeError::TreeAuxRootCountMismatch { .. }