From e326c5dfbe357d21261935c6923851710a9a2802 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 00:43:31 -0600 Subject: [PATCH 01/13] feat(state): stitch tree_aux root serving across the VCT upgrade height --- zebra-state/CHANGELOG.md | 7 + zebra-state/src/lib.rs | 1 + zebra-state/src/service.rs | 7 +- zebra-state/src/service/finalized_state.rs | 3 + .../service/finalized_state/commitment_aux.rs | 440 ++++++++++++++++++ .../finalized_state/zebra_db/rollback.rs | 2 +- 6 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 zebra-state/src/service/finalized_state/commitment_aux.rs diff --git a/zebra-state/CHANGELOG.md b/zebra-state/CHANGELOG.md index 7e523945c3a..318f6b23203 100644 --- a/zebra-state/CHANGELOG.md +++ b/zebra-state/CHANGELOG.md @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 entries so they use the Ironwood-capable entry size. - Extended value-pool disk serialization with an Ironwood slot after the deferred pool, and bumped the state database format version to `27.3.0`. +- `tree_aux` root serving now stitches the per-height trees below the + verified-commitment-trees upgrade height `U` with the + `commitment_roots_by_height` serving index at and above `U`, so a node that + upgraded mid-chain serves a range crossing `U` as one gap-free batch instead + of a short prefix that stalled the fetch client. +- Added `produce_final_frontiers_bytes` for deriving serialized checkpoint + final-frontier fixtures from a finalized database. ## [8.0.0] - 2026-06-02 diff --git a/zebra-state/src/lib.rs b/zebra-state/src/lib.rs index 59bc7fbd460..4d8b9061ee8 100644 --- a/zebra-state/src/lib.rs +++ b/zebra-state/src/lib.rs @@ -80,6 +80,7 @@ pub use service::finalized_state::{ preview_rollback_finalized_state, rollback_finalized_state, RollbackBackupSummary, RollbackFinalizedStateError, RollbackFinalizedStateOptions, RollbackFinalizedStateSummary, }; +pub use service::finalized_state::{produce_final_frontiers_bytes, FinalFrontiersGenerationError}; pub use service::{ finalized_state::{DiskWriteBatch, FromDisk, IntoDisk, WriteDisk, ZebraDb}, ReadStateService, diff --git a/zebra-state/src/service.rs b/zebra-state/src/service.rs index 2fcd28c2255..3fb010adb36 100644 --- a/zebra-state/src/service.rs +++ b/zebra-state/src/service.rs @@ -1502,7 +1502,7 @@ where .finalized_tip_height() .is_some_and(|finalized_tip| height <= finalized_tip) { - db.finalized_commitment_roots_by_height_range(height..=height) + finalized_state::serve_block_roots(db, height..=height) .into_iter() .next() } else if let Some(chain) = chain @@ -1515,6 +1515,11 @@ where chain.orchard_tree(height.into()), ) { (Some(sapling), Some(orchard)) => { + // The non-finalized chain holds the full block, so derive its shielded + // tx-counts and ZIP-244 auth-data root — the ZIP-221 leaf inputs the + // header and roots don't provide — to serve for header-sync verification + // (zero only if the block is unexpectedly absent). The Ironwood tree does + // not exist below Nu7, so its root is the empty-tree root here. let (sapling_tx, orchard_tx, ironwood_tx, auth_data_root) = chain .block(height.into()) .map(|block| { diff --git a/zebra-state/src/service/finalized_state.rs b/zebra-state/src/service/finalized_state.rs index 947d1731710..7d8662ae4be 100644 --- a/zebra-state/src/service/finalized_state.rs +++ b/zebra-state/src/service/finalized_state.rs @@ -74,6 +74,7 @@ static COMMIT_COMPUTE_POOL: LazyLock = LazyLock::new(|| { pub mod column_family; +pub(crate) mod commitment_aux; mod disk_db; mod disk_format; mod zebra_db; @@ -86,6 +87,8 @@ mod tests; #[allow(unused_imports)] pub use column_family::{TypedColumnFamily, WriteTypedBatch}; +pub(crate) use commitment_aux::serve_block_roots; +pub use commitment_aux::{produce_final_frontiers_bytes, FinalFrontiersGenerationError}; #[allow(unused_imports)] pub use disk_db::{DiskDb, DiskWriteBatch, ReadDisk, WriteDisk}; #[allow(unused_imports)] diff --git a/zebra-state/src/service/finalized_state/commitment_aux.rs b/zebra-state/src/service/finalized_state/commitment_aux.rs new file mode 100644 index 00000000000..fcf088fe10b --- /dev/null +++ b/zebra-state/src/service/finalized_state/commitment_aux.rs @@ -0,0 +1,440 @@ +//! Payload types and the producer/serving half of the verified-commitment-trees +//! fast path (`docs/design/verified-commitment-trees.md` §5). +//! +//! The fast path consumes per-block Sapling/Orchard roots and a final frontier at the +//! checkpoint handoff. This module provides the **producer** half +//! ([`produce_block_roots`] / [`produce_final_frontiers`]): deriving that payload from +//! an existing database's per-height trees. That is the read path a serving node runs, +//! and tests can feed the DB-produced payload back through the fast path in-process to +//! prove producer and consumer agreement without networking. +//! +//! [`serve_block_roots`] is the serving entry point: it stitches the +//! `commitment_roots_by_height` index with the per-height trees at the upgrade height, +//! so upgraded and fast-synced nodes keep serving one gap-free `tree_aux` payload. +//! +//! The consumer-side source seam (`CommitmentRootSource` and the transport-backed +//! peer source) lands with the committer fast path in a follow-up increment. + +use std::{fmt, sync::Arc}; + +use thiserror::Error; +use zebra_chain::{ + block::{self, merkle::AuthDataRoot}, + orchard, sapling, sprout, +}; + +use super::{FromDisk, IntoDisk, ZebraDb}; + +/// Per-block verified commitment roots — the essential fast-path payload (design §5.1), +/// the wire payload carried over `tree_aux` (increment 6a). Defined in `zebra-chain` so +/// `zebra-network` and `zebra-state` share it without a dependency cycle. +pub(super) use zebra_chain::parallel::commitment_aux::BlockCommitmentRoots; + +/// The verified final note-commitment frontiers at the checkpoint handoff height +/// (design §5.2). +/// +/// Fast mode skips the per-block frontier recompute below the checkpoint, so the +/// running Sapling/Orchard frontiers are never advanced. To let post-checkpoint +/// semantic verification resume, the real frontiers at the checkpoint are supplied +/// here, verified (`frontier.root() == the verified root at the checkpoint`), and +/// written as the tip treestate at the handoff. Subtree tips are not carried: the +/// resuming chain recomputes them from the frontier position. +#[derive(Clone, Debug)] +pub(super) struct FinalFrontiers { + pub(super) height: block::Height, + pub(super) sapling: Arc, + pub(super) orchard: Arc, + pub(super) sprout: Arc, +} + +/// Errors producing [`FinalFrontiers`] from a finalized database. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum FinalFrontiersGenerationError { + /// The database has no Sapling tree at the requested height. + #[error("missing Sapling final frontier tree at height {height:?}")] + MissingSaplingTree { + /// The requested final frontier height. + height: block::Height, + }, + + /// The database has no Orchard tree at the requested height. + #[error("missing Orchard final frontier tree at height {height:?}")] + MissingOrchardTree { + /// The requested final frontier height. + height: block::Height, + }, +} + +/// Errors parsing [`FinalFrontiers`] from the embedded/frontier-file byte format. +// The non-test consumer is the VCT embedded-frontier loader, which lands with the +// committer fast path in a follow-up increment; the round-trip test exercises it here. +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum FinalFrontiersParseError { + /// The input ended before the 4-byte height field. + MissingHeight { + /// The total number of bytes in the input. + actual_len: usize, + }, + /// The input ended before a tree blob's 4-byte length prefix. + MissingLength { + /// The tree whose length prefix was being read. + tree: &'static str, + /// Byte offset where the length prefix starts. + offset: usize, + /// Bytes remaining from `offset`. + remaining: usize, + }, + /// A tree blob's length prefix points past the end of the input. + TruncatedBlob { + /// The tree whose blob was being read. + tree: &'static str, + /// Byte offset where the blob starts. + offset: usize, + /// Blob length from the prefix. + expected_len: usize, + /// Bytes remaining from `offset`. + remaining: usize, + }, + /// A tree blob's length prefix overflows `usize` arithmetic. + LengthOverflow { + /// The tree whose blob was being read. + tree: &'static str, + /// Byte offset where the blob starts. + offset: usize, + /// Blob length from the prefix. + len: usize, + }, + /// The parser consumed all expected fields, but extra bytes remained. + TrailingBytes { + /// Byte offset where the trailing data starts. + offset: usize, + /// Number of trailing bytes. + trailing_len: usize, + }, +} + +impl fmt::Display for FinalFrontiersParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FinalFrontiersParseError::MissingHeight { actual_len } => write!( + f, + "missing final frontier height: expected 4 bytes, got {actual_len}" + ), + FinalFrontiersParseError::MissingLength { + tree, + offset, + remaining, + } => write!( + f, + "missing {tree} frontier length prefix at byte {offset}: expected 4 bytes, got {remaining}" + ), + FinalFrontiersParseError::TruncatedBlob { + tree, + offset, + expected_len, + remaining, + } => write!( + f, + "truncated {tree} frontier blob at byte {offset}: length prefix says {expected_len} bytes, but only {remaining} remain" + ), + FinalFrontiersParseError::LengthOverflow { tree, offset, len } => write!( + f, + "{tree} frontier blob length overflows at byte {offset}: {len} bytes" + ), + FinalFrontiersParseError::TrailingBytes { + offset, + trailing_len, + } => write!( + f, + "unexpected trailing final frontier bytes at byte {offset}: {trailing_len} bytes" + ), + } + } +} + +impl std::error::Error for FinalFrontiersParseError {} + +impl FinalFrontiers { + /// Serialize to the embedded byte format: height (u32 LE), then sapling, orchard, + /// and sprout trees, each as `u32`-LE-length-prefixed `IntoDisk` bytes. Used to + /// create embedded or test final-frontier fixtures. + pub(super) fn to_bytes(&self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&self.height.0.to_le_bytes()); + let blobs: [Vec; 3] = [ + IntoDisk::as_bytes(&*self.sapling), + IntoDisk::as_bytes(&*self.orchard), + IntoDisk::as_bytes(&*self.sprout), + ]; + for blob in blobs { + let len = u32::try_from(blob.len()).expect("note commitment tree fits in u32 bytes"); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&blob); + } + out + } + + /// Parse the embedded byte format written by [`Self::to_bytes`]. + // The non-test consumer is the VCT embedded-frontier loader, which lands with the + // committer fast path in a follow-up increment; the round-trip test exercises it here. + #[allow(dead_code)] + pub(super) fn from_bytes(bytes: &[u8]) -> Result { + let height_bytes = bytes + .get(0..4) + .ok_or(FinalFrontiersParseError::MissingHeight { + actual_len: bytes.len(), + })?; + let height_bytes: [u8; 4] = + height_bytes + .try_into() + .map_err(|_| FinalFrontiersParseError::MissingHeight { + actual_len: bytes.len(), + })?; + let height = block::Height(u32::from_le_bytes(height_bytes)); + + // Read three `u32`-length-prefixed blobs starting after the height. + let mut cursor: usize = 4; + let mut next_blob = |tree: &'static str| -> Result, FinalFrontiersParseError> { + let len_end = + cursor + .checked_add(4) + .ok_or(FinalFrontiersParseError::LengthOverflow { + tree, + offset: cursor, + len: 4, + })?; + let len_bytes = + bytes + .get(cursor..len_end) + .ok_or(FinalFrontiersParseError::MissingLength { + tree, + offset: cursor, + remaining: bytes.len().saturating_sub(cursor), + })?; + let len_bytes: [u8; 4] = + len_bytes + .try_into() + .map_err(|_| FinalFrontiersParseError::MissingLength { + tree, + offset: cursor, + remaining: bytes.len().saturating_sub(cursor), + })?; + // Zebra's supported platforms have at least 32-bit `usize`, so every + // u32 length prefix fits in memory indexes. + let len = u32::from_le_bytes(len_bytes) as usize; + cursor = len_end; + let blob_end = + cursor + .checked_add(len) + .ok_or(FinalFrontiersParseError::LengthOverflow { + tree, + offset: cursor, + len, + })?; + let blob = + bytes + .get(cursor..blob_end) + .ok_or(FinalFrontiersParseError::TruncatedBlob { + tree, + offset: cursor, + expected_len: len, + remaining: bytes.len().saturating_sub(cursor), + })?; + cursor = blob_end; + Ok(blob.to_vec()) + }; + let sapling = next_blob("sapling")?; + let orchard = next_blob("orchard")?; + let sprout = next_blob("sprout")?; + + if cursor != bytes.len() { + return Err(FinalFrontiersParseError::TrailingBytes { + offset: cursor, + trailing_len: bytes.len() - cursor, + }); + } + + Ok(FinalFrontiers { + height, + sapling: Arc::new(::from_bytes( + sapling, + )), + orchard: Arc::new(::from_bytes( + orchard, + )), + sprout: Arc::new(::from_bytes( + sprout, + )), + }) + } +} + +/// Produce the per-block roots payload for `range` from `db`'s per-height trees. +/// +/// This is the serving read path (the future `TreeAuxStatePort::read_block_roots`), +/// minus the network: it derives each root from the stored per-height tree, exactly +/// the value the fast path folds into the anchor set. It requires per-height trees, so +/// the caller restricts it to a non-fast-synced (archive/pre-index) database within the +/// tip, where the trees are present. As defense-in-depth on this peer-triggered read, a +/// height whose tree is unexpectedly absent stops the scan and serves the contiguous +/// prefix collected so far rather than panicking; the wire client validates contiguity +/// and treats a short batch as partial progress. +// The `ReadRequest::BlockRoots` serving read path; also exercised by the round-trip test. +pub(crate) fn produce_block_roots( + db: &ZebraDb, + range: std::ops::RangeInclusive, +) -> Vec { + let (start, end) = (range.start().0, range.end().0); + let mut roots = Vec::new(); + for h in start..=end { + let height = block::Height(h); + let (Some(sapling), Some(orchard)) = ( + db.sapling_tree_by_height(&height), + db.orchard_tree_by_height(&height), + ) else { + break; + }; + // Below the upgrade height the serving index does not exist, so derive the + // auth-data root and the shielded tx-counts from the locally stored block (this + // archival node holds the body for these heights). Zero only if the body is somehow + // absent, in which case the recipient simply re-fetches from a node that has it. + let block = db.block(height.into()); + let (sapling_tx, orchard_tx, ironwood_tx, auth_data_root) = block + .as_ref() + .map(|block| { + ( + block.sapling_transactions_count(), + block.orchard_transactions_count(), + block.ironwood_transactions_count(), + block.auth_data_root(), + ) + }) + .unwrap_or((0, 0, 0, AuthDataRoot::from([0u8; 32]))); + roots.push(BlockCommitmentRoots { + height, + sapling_root: sapling.root(), + orchard_root: orchard.root(), + // The Ironwood tree does not exist below Nu7, so its root is the empty-tree root + // for every currently-servable height (no per-height Ironwood tree store yet). + ironwood_root: zebra_chain::ironwood::tree::NoteCommitmentTree::default().root(), + sapling_tx, + orchard_tx, + ironwood_tx, + auth_data_root, + }); + } + roots +} + +/// Serve the per-block roots for `range`, stitching the two sources at the upgrade height `U`. +/// +/// The `commitment_roots_by_height` serving index only covers heights at and above `U` (the lowest +/// height this binary committed). Heights below `U` predate the index, so they are derived from the +/// per-height trees instead, and the two runs are concatenated. This is what lets a node that +/// upgraded mid-chain serve a request that straddles `U` as one gap-free batch, rather than the +/// short index-only prefix that would stall the client's minimum-progress check. +/// +/// Both sources stop at the first absent height, so the result is always a contiguous run from +/// `range.start()`; a tree gap below `U` is served as the prefix collected so far without reaching +/// into the index. A database that never recorded `U` — a pre-index archive node — derives the +/// whole range from the trees, the original archive fallback. +pub(crate) fn serve_block_roots( + db: &ZebraDb, + range: std::ops::RangeInclusive, +) -> Vec { + let Some(upgrade) = db.vct_upgrade_height() else { + return produce_block_roots(db, range); + }; + + let (start, end) = (*range.start(), *range.end()); + + // Wholly at/above `U`: the index covers it. (`U == 0` for a node that fast-synced from + // genesis takes this path for every request, never touching the absent per-height trees.) + if start >= upgrade { + return db.commitment_roots_by_height_range(range); + } + + // Below `U`: derive the per-height-tree run up to `U - 1` (`start < upgrade` so `upgrade >= 1`). + let trees_end = block::Height(end.0.min(upgrade.0 - 1)); + let mut roots = produce_block_roots(db, start..=trees_end); + + // Continue into the index only if the tree run is contiguous up to `U - 1`; a short run means a + // gap below `U`, so serve it alone and let the client retry the remainder. + if roots.last().map(|root| root.height) == Some(trees_end) && end >= upgrade { + roots.extend(db.commitment_roots_by_height_range(upgrade..=end)); + } + + roots +} + +/// Produce the final frontiers at `height` from `db`'s per-height trees. +/// +/// Sprout is frozen far below any modern checkpoint, so the tip Sprout tree is the frontier at +/// `height`. +pub(super) fn produce_final_frontiers( + db: &ZebraDb, + height: block::Height, +) -> Result { + let sapling = db + .sapling_tree_by_height(&height) + .ok_or(FinalFrontiersGenerationError::MissingSaplingTree { height })?; + let orchard = db + .orchard_tree_by_height(&height) + .ok_or(FinalFrontiersGenerationError::MissingOrchardTree { height })?; + + Ok(FinalFrontiers { + height, + sapling, + orchard, + sprout: db.sprout_tree_for_tip(), + }) +} + +/// Produce serialized final-frontier bytes for the checkpoint handoff at `height`. +/// +/// These bytes use the same format as the embedded `mainnet-frontier.bin` file consumed by +/// the VCT frontier loader (landing with the committer fast path in a follow-up increment). +pub fn produce_final_frontiers_bytes( + db: &ZebraDb, + height: block::Height, +) -> Result, FinalFrontiersGenerationError> { + Ok(produce_final_frontiers(db, height)?.to_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The final-frontier serialization round-trips: parsed frontiers carry the same + /// height and tree roots as the originals. + #[test] + fn final_frontiers_bytes_round_trips() { + let frontiers = FinalFrontiers { + height: block::Height(1_687_200), + sapling: Arc::new(Default::default()), + orchard: Arc::new(Default::default()), + sprout: Arc::new(Default::default()), + }; + + let parsed = + FinalFrontiers::from_bytes(&frontiers.to_bytes()).expect("frontiers should parse"); + + assert_eq!(parsed.height, frontiers.height, "height round-trips"); + assert_eq!( + parsed.sapling.root(), + frontiers.sapling.root(), + "sapling frontier round-trips" + ); + assert_eq!( + parsed.orchard.root(), + frontiers.orchard.root(), + "orchard frontier round-trips" + ); + assert_eq!( + parsed.sprout.root(), + frontiers.sprout.root(), + "sprout frontier round-trips" + ); + } +} diff --git a/zebra-state/src/service/finalized_state/zebra_db/rollback.rs b/zebra-state/src/service/finalized_state/zebra_db/rollback.rs index e37bee5bf55..ac8399cfcdc 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/rollback.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/rollback.rs @@ -1394,7 +1394,7 @@ mod tests { db.write_batch(batch) .expect("seeding the serving index succeeds"); - let served = db.commitment_roots_by_height_range(Height(4)..=Height(6)); + let served = crate::service::finalized_state::serve_block_roots(&db, Height(4)..=Height(6)); assert_eq!( served .into_iter() From eaa1c5a04462636139a7725e54e8c6b9677d6f3e Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 12:07:54 -0600 Subject: [PATCH 02/13] comments --- .../service/finalized_state/commitment_aux.rs | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/zebra-state/src/service/finalized_state/commitment_aux.rs b/zebra-state/src/service/finalized_state/commitment_aux.rs index fcf088fe10b..5d00683003a 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux.rs @@ -1,19 +1,14 @@ //! Payload types and the producer/serving half of the verified-commitment-trees -//! fast path (`docs/design/verified-commitment-trees.md` §5). +//! fast path (`docs/design/verified-commitment-trees.md`). //! //! The fast path consumes per-block Sapling/Orchard roots and a final frontier at the -//! checkpoint handoff. This module provides the **producer** half +//! last checkpoint. This module provides the **producer** half //! ([`produce_block_roots`] / [`produce_final_frontiers`]): deriving that payload from -//! an existing database's per-height trees. That is the read path a serving node runs, -//! and tests can feed the DB-produced payload back through the fast path in-process to -//! prove producer and consumer agreement without networking. +//! an existing database's per-height trees. //! //! [`serve_block_roots`] is the serving entry point: it stitches the //! `commitment_roots_by_height` index with the per-height trees at the upgrade height, //! so upgraded and fast-synced nodes keep serving one gap-free `tree_aux` payload. -//! -//! The consumer-side source seam (`CommitmentRootSource` and the transport-backed -//! peer source) lands with the committer fast path in a follow-up increment. use std::{fmt, sync::Arc}; @@ -25,15 +20,12 @@ use zebra_chain::{ use super::{FromDisk, IntoDisk, ZebraDb}; -/// Per-block verified commitment roots — the essential fast-path payload (design §5.1), -/// the wire payload carried over `tree_aux` (increment 6a). Defined in `zebra-chain` so -/// `zebra-network` and `zebra-state` share it without a dependency cycle. +/// Per-block verified commitment roots pub(super) use zebra_chain::parallel::commitment_aux::BlockCommitmentRoots; -/// The verified final note-commitment frontiers at the checkpoint handoff height -/// (design §5.2). +/// The verified final note-commitment frontiers at the last checkpoint height. /// -/// Fast mode skips the per-block frontier recompute below the checkpoint, so the +/// Verified-commitment-tree (VCT) mode skips the per-block frontier recompute below the checkpoint, so the /// running Sapling/Orchard frontiers are never advanced. To let post-checkpoint /// semantic verification resume, the real frontiers at the checkpoint are supplied /// here, verified (`frontier.root() == the verified root at the checkpoint`), and @@ -343,13 +335,14 @@ pub(crate) fn serve_block_roots( db: &ZebraDb, range: std::ops::RangeInclusive, ) -> Vec { + // Below the VCT upgrade height, we use the per-height trees to derive the roots. let Some(upgrade) = db.vct_upgrade_height() else { return produce_block_roots(db, range); }; let (start, end) = (*range.start(), *range.end()); - // Wholly at/above `U`: the index covers it. (`U == 0` for a node that fast-synced from + // Wholly at/above `U`: the VCT-specific index covers it. (`U == 0` for a node that fast-synced from // genesis takes this path for every request, never touching the absent per-height trees.) if start >= upgrade { return db.commitment_roots_by_height_range(range); From 6adf3107c08ab82d87561e43b511cdd105db4e63 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 12:09:55 -0600 Subject: [PATCH 03/13] comments --- .../src/service/finalized_state/commitment_aux.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/zebra-state/src/service/finalized_state/commitment_aux.rs b/zebra-state/src/service/finalized_state/commitment_aux.rs index 5d00683003a..0f20169cd32 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux.rs @@ -264,15 +264,7 @@ impl FinalFrontiers { /// Produce the per-block roots payload for `range` from `db`'s per-height trees. /// -/// This is the serving read path (the future `TreeAuxStatePort::read_block_roots`), -/// minus the network: it derives each root from the stored per-height tree, exactly -/// the value the fast path folds into the anchor set. It requires per-height trees, so -/// the caller restricts it to a non-fast-synced (archive/pre-index) database within the -/// tip, where the trees are present. As defense-in-depth on this peer-triggered read, a -/// height whose tree is unexpectedly absent stops the scan and serves the contiguous -/// prefix collected so far rather than panicking; the wire client validates contiguity -/// and treats a short batch as partial progress. -// The `ReadRequest::BlockRoots` serving read path; also exercised by the round-trip test. +/// Derives each root from the per-height note commitment tree. pub(crate) fn produce_block_roots( db: &ZebraDb, range: std::ops::RangeInclusive, From 6d21defbb4fa2e0adab518c56592842755adf9dd Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 12:10:19 -0600 Subject: [PATCH 04/13] comments --- zebra-state/src/service/finalized_state/commitment_aux.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zebra-state/src/service/finalized_state/commitment_aux.rs b/zebra-state/src/service/finalized_state/commitment_aux.rs index 0f20169cd32..ad4e9960b8d 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux.rs @@ -29,7 +29,7 @@ pub(super) use zebra_chain::parallel::commitment_aux::BlockCommitmentRoots; /// running Sapling/Orchard frontiers are never advanced. To let post-checkpoint /// semantic verification resume, the real frontiers at the checkpoint are supplied /// here, verified (`frontier.root() == the verified root at the checkpoint`), and -/// written as the tip treestate at the handoff. Subtree tips are not carried: the +/// written as the tip treestate at the last checkpoint. Subtree tips are not carried: the /// resuming chain recomputes them from the frontier position. #[derive(Clone, Debug)] pub(super) struct FinalFrontiers { From d708185f8f81ac7bbbef16b9e49568e5c9eba64c Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 12:28:52 -0600 Subject: [PATCH 05/13] clean up and tests --- .github/actions/setup-zebra-build/action.yml | 1 + zebra-state/src/service.rs | 5 - .../service/finalized_state/commitment_aux.rs | 333 +++++++++++++++++- .../finalized_state/zebra_db/rollback.rs | 37 -- 4 files changed, 324 insertions(+), 52 deletions(-) diff --git a/.github/actions/setup-zebra-build/action.yml b/.github/actions/setup-zebra-build/action.yml index b7ed4718729..fc74b86990e 100644 --- a/.github/actions/setup-zebra-build/action.yml +++ b/.github/actions/setup-zebra-build/action.yml @@ -7,6 +7,7 @@ runs: if: runner.os == 'Linux' shell: bash run: | + sudo rm -f /etc/apt/sources.list.d/{microsoft-prod,azure-cli}.{list,sources} sudo apt-get -qq update sudo apt-get -qq install -y --no-install-recommends protobuf-compiler librocksdb-dev echo "ROCKSDB_LIB_DIR=/usr/lib/" >> $GITHUB_ENV diff --git a/zebra-state/src/service.rs b/zebra-state/src/service.rs index 3fb010adb36..9756058763c 100644 --- a/zebra-state/src/service.rs +++ b/zebra-state/src/service.rs @@ -1515,11 +1515,6 @@ where chain.orchard_tree(height.into()), ) { (Some(sapling), Some(orchard)) => { - // The non-finalized chain holds the full block, so derive its shielded - // tx-counts and ZIP-244 auth-data root — the ZIP-221 leaf inputs the - // header and roots don't provide — to serve for header-sync verification - // (zero only if the block is unexpectedly absent). The Ironwood tree does - // not exist below Nu7, so its root is the empty-tree root here. let (sapling_tx, orchard_tx, ironwood_tx, auth_data_root) = chain .block(height.into()) .map(|block| { diff --git a/zebra-state/src/service/finalized_state/commitment_aux.rs b/zebra-state/src/service/finalized_state/commitment_aux.rs index ad4e9960b8d..3a67a79ced7 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux.rs @@ -1,14 +1,5 @@ //! Payload types and the producer/serving half of the verified-commitment-trees -//! fast path (`docs/design/verified-commitment-trees.md`). -//! -//! The fast path consumes per-block Sapling/Orchard roots and a final frontier at the -//! last checkpoint. This module provides the **producer** half -//! ([`produce_block_roots`] / [`produce_final_frontiers`]): deriving that payload from -//! an existing database's per-height trees. -//! -//! [`serve_block_roots`] is the serving entry point: it stitches the -//! `commitment_roots_by_height` index with the per-height trees at the upgrade height, -//! so upgraded and fast-synced nodes keep serving one gap-free `tree_aux` payload. +//! (`docs/design/verified-commitment-trees.md`). use std::{fmt, sync::Arc}; @@ -389,8 +380,283 @@ pub fn produce_final_frontiers_bytes( #[cfg(test)] mod tests { + use zebra_chain::{ironwood, parameters::Network}; + + use crate::{ + constants::{state_database_format_version_in_code, STATE_DATABASE_KIND}, + service::finalized_state::{ + disk_db::WriteDisk, DiskWriteBatch, STATE_COLUMN_FAMILIES_IN_CODE, + }, + Config, + }; + use super::*; + fn ephemeral_mainnet_db() -> ZebraDb { + let network = Network::Mainnet; + ZebraDb::new( + &Config::ephemeral(), + STATE_DATABASE_KIND, + &state_database_format_version_in_code(), + &network, + true, + STATE_COLUMN_FAMILIES_IN_CODE + .iter() + .map(ToString::to_string), + false, + ) + } + + fn sapling_note_commitment(value: u64) -> sapling::tree::NoteCommitmentUpdate { + let mut bytes = [0; 32]; + bytes[..8].copy_from_slice(&value.to_le_bytes()); + + Option::::from( + sapling::tree::NoteCommitmentUpdate::from_bytes(&bytes), + ) + .expect("small little-endian integers are canonical Jubjub field elements") + } + + fn sapling_tree(value: u64) -> sapling::tree::NoteCommitmentTree { + let mut tree = sapling::tree::NoteCommitmentTree::default(); + tree.append(sapling_note_commitment(value)) + .expect("single-note Sapling tree is not full"); + tree + } + + fn orchard_tree(value: u64) -> orchard::tree::NoteCommitmentTree { + let mut tree = orchard::tree::NoteCommitmentTree::default(); + tree.append(halo2::pasta::pallas::Base::from(value)) + .expect("single-note Orchard tree is not full"); + tree + } + + fn seed_trees(db: &ZebraDb, heights: impl IntoIterator) { + let mut batch = DiskWriteBatch::new(); + for height in heights { + let height = block::Height(height); + batch.create_sapling_tree(db, &height, &sapling_tree(u64::from(height.0))); + batch.create_orchard_tree(db, &height, &orchard_tree(u64::from(height.0))); + } + db.write_batch(batch).expect("seeding trees succeeds"); + } + + fn seed_sprout_tree(db: &ZebraDb, tree: &sprout::tree::NoteCommitmentTree) { + let mut batch = DiskWriteBatch::new(); + batch.update_sprout_tree(db, tree); + db.write_batch(batch).expect("seeding Sprout tree succeeds"); + } + + fn seed_finalized_tip(db: &ZebraDb, height: block::Height) { + let hash_by_height = db.db().cf_handle("hash_by_height").unwrap(); + let height_byte = + u8::try_from(height.0).expect("test heights fit in a byte for hash fixtures"); + let mut batch = DiskWriteBatch::new(); + batch.zs_insert(&hash_by_height, height, block::Hash([height_byte; 32])); + db.write_batch(batch) + .expect("seeding finalized tip succeeds"); + } + + fn seed_index_roots(db: &ZebraDb, heights: impl IntoIterator) { + let mut batch = DiskWriteBatch::new(); + for height in heights { + let height_byte = + u8::try_from(height).expect("test heights fit in a byte for auth root fixtures"); + batch.insert_commitment_roots_by_height( + db, + block::Height(height), + &sapling_tree(u64::from(height) + 100).root(), + &orchard_tree(u64::from(height) + 100).root(), + &ironwood::tree::NoteCommitmentTree::default().root(), + u64::from(height), + u64::from(height) + 1, + u64::from(height) + 2, + &AuthDataRoot::from([height_byte; 32]), + ); + } + db.write_batch(batch) + .expect("seeding commitment root index succeeds"); + } + + fn set_upgrade_height(db: &ZebraDb, height: block::Height) { + let mut batch = DiskWriteBatch::new(); + batch.update_vct_upgrade_marker(db, height); + db.write_batch(batch) + .expect("seeding VCT upgrade marker succeeds"); + } + + fn expected_tree_roots(height: u32) -> BlockCommitmentRoots { + BlockCommitmentRoots { + height: block::Height(height), + sapling_root: sapling_tree(u64::from(height)).root(), + orchard_root: orchard_tree(u64::from(height)).root(), + ironwood_root: ironwood::tree::NoteCommitmentTree::default().root(), + sapling_tx: 0, + orchard_tx: 0, + ironwood_tx: 0, + auth_data_root: AuthDataRoot::from([0; 32]), + } + } + + fn expected_index_roots(height: u32) -> BlockCommitmentRoots { + let height_byte = + u8::try_from(height).expect("test heights fit in a byte for auth root fixtures"); + BlockCommitmentRoots { + height: block::Height(height), + sapling_root: sapling_tree(u64::from(height) + 100).root(), + orchard_root: orchard_tree(u64::from(height) + 100).root(), + ironwood_root: ironwood::tree::NoteCommitmentTree::default().root(), + sapling_tx: u64::from(height), + orchard_tx: u64::from(height) + 1, + ironwood_tx: u64::from(height) + 2, + auth_data_root: AuthDataRoot::from([height_byte; 32]), + } + } + + #[test] + fn produce_block_roots_derives_contiguous_tree_roots() { + let _init_guard = zebra_test::init(); + let db = ephemeral_mainnet_db(); + seed_trees(&db, [1, 2]); + seed_finalized_tip(&db, block::Height(2)); + + let roots = produce_block_roots(&db, block::Height(1)..=block::Height(4)); + + assert_eq!( + roots, + vec![expected_tree_roots(1), expected_tree_roots(2)], + "tree-derived roots stop at the first missing height" + ); + } + + #[test] + fn serve_block_roots_without_upgrade_marker_uses_tree_fallback() { + let _init_guard = zebra_test::init(); + let db = ephemeral_mainnet_db(); + seed_trees(&db, [1, 2]); + seed_index_roots(&db, [1, 2]); + seed_finalized_tip(&db, block::Height(2)); + + let roots = serve_block_roots(&db, block::Height(1)..=block::Height(2)); + + assert_eq!( + roots, + vec![expected_tree_roots(1), expected_tree_roots(2)], + "pre-index archive databases derive roots from per-height trees" + ); + } + + #[test] + fn serve_block_roots_stitches_trees_to_index_at_upgrade() { + let _init_guard = zebra_test::init(); + let db = ephemeral_mainnet_db(); + seed_trees(&db, [1, 2]); + seed_index_roots(&db, [3, 4]); + seed_finalized_tip(&db, block::Height(4)); + set_upgrade_height(&db, block::Height(3)); + + let roots = serve_block_roots(&db, block::Height(1)..=block::Height(4)); + + assert_eq!( + roots, + vec![ + expected_tree_roots(1), + expected_tree_roots(2), + expected_index_roots(3), + expected_index_roots(4), + ], + "ranges crossing U are served as one contiguous tree/index run" + ); + } + + #[test] + fn serve_block_roots_does_not_cross_short_tree_prefix_below_upgrade() { + let _init_guard = zebra_test::init(); + let db = ephemeral_mainnet_db(); + seed_trees(&db, [1]); + seed_index_roots(&db, [3, 4]); + seed_finalized_tip(&db, block::Height(1)); + set_upgrade_height(&db, block::Height(3)); + + let roots = serve_block_roots(&db, block::Height(1)..=block::Height(4)); + + assert_eq!( + roots, + vec![expected_tree_roots(1)], + "a short tree-derived prefix below U is not extended with index rows" + ); + } + + #[test] + fn serve_block_roots_at_or_above_upgrade_uses_index() { + let _init_guard = zebra_test::init(); + let db = ephemeral_mainnet_db(); + seed_trees(&db, [3, 4]); + seed_index_roots(&db, [3, 4]); + seed_finalized_tip(&db, block::Height(4)); + set_upgrade_height(&db, block::Height(3)); + + let roots = serve_block_roots(&db, block::Height(3)..=block::Height(4)); + + assert_eq!( + roots, + vec![expected_index_roots(3), expected_index_roots(4)], + "requests at or above U are served from the compact index" + ); + } + + #[test] + fn produce_final_frontiers_reads_requested_trees_and_tip_sprout() { + let _init_guard = zebra_test::init(); + let db = ephemeral_mainnet_db(); + let height = block::Height(2); + let sprout = sprout::tree::NoteCommitmentTree::default(); + seed_trees(&db, [2]); + seed_sprout_tree(&db, &sprout); + seed_finalized_tip(&db, height); + + let frontiers = + produce_final_frontiers(&db, height).expect("seeded frontiers should be produced"); + + assert_eq!(frontiers.height, height); + assert_eq!(frontiers.sapling.root(), sapling_tree(2).root()); + assert_eq!(frontiers.orchard.root(), orchard_tree(2).root()); + assert_eq!(frontiers.sprout.root(), sprout.root()); + } + + #[test] + fn produce_final_frontiers_reports_missing_trees() { + let _init_guard = zebra_test::init(); + let db = ephemeral_mainnet_db(); + let height = block::Height(2); + + assert_eq!( + produce_final_frontiers(&db, height).expect_err("Sapling absence is reported first"), + FinalFrontiersGenerationError::MissingSaplingTree { height }, + ); + } + + #[test] + fn produce_final_frontiers_bytes_serializes_generated_frontiers() { + let _init_guard = zebra_test::init(); + let db = ephemeral_mainnet_db(); + let height = block::Height(2); + seed_trees(&db, [2]); + seed_sprout_tree(&db, &sprout::tree::NoteCommitmentTree::default()); + seed_finalized_tip(&db, height); + + let frontiers = + produce_final_frontiers(&db, height).expect("seeded frontiers should be produced"); + let bytes = produce_final_frontiers_bytes(&db, height) + .expect("seeded frontiers should serialize to bytes"); + + assert_eq!( + bytes, + frontiers.to_bytes(), + "public byte producer serializes the generated final frontiers" + ); + } + /// The final-frontier serialization round-trips: parsed frontiers carry the same /// height and tree roots as the originals. #[test] @@ -422,4 +688,51 @@ mod tests { "sprout frontier round-trips" ); } + + #[test] + fn final_frontiers_bytes_reject_malformed_payloads() { + assert_eq!( + FinalFrontiers::from_bytes(&[0, 0, 0]).expect_err("short height is rejected"), + FinalFrontiersParseError::MissingHeight { actual_len: 3 } + ); + + assert_eq!( + FinalFrontiers::from_bytes(&block::Height(1).0.to_le_bytes()) + .expect_err("missing first length prefix is rejected"), + FinalFrontiersParseError::MissingLength { + tree: "sapling", + offset: 4, + remaining: 0, + } + ); + + let mut truncated = block::Height(1).0.to_le_bytes().to_vec(); + truncated.extend_from_slice(&1u32.to_le_bytes()); + assert_eq!( + FinalFrontiers::from_bytes(&truncated).expect_err("truncated first blob is rejected"), + FinalFrontiersParseError::TruncatedBlob { + tree: "sapling", + offset: 8, + expected_len: 1, + remaining: 0, + } + ); + + let frontiers = FinalFrontiers { + height: block::Height(1_687_200), + sapling: Arc::new(Default::default()), + orchard: Arc::new(Default::default()), + sprout: Arc::new(Default::default()), + }; + let mut trailing = frontiers.to_bytes(); + trailing.push(0); + + assert_eq!( + FinalFrontiers::from_bytes(&trailing).expect_err("trailing bytes are rejected"), + FinalFrontiersParseError::TrailingBytes { + offset: frontiers.to_bytes().len(), + trailing_len: 1, + } + ); + } } diff --git a/zebra-state/src/service/finalized_state/zebra_db/rollback.rs b/zebra-state/src/service/finalized_state/zebra_db/rollback.rs index ac8399cfcdc..5cce82d505f 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/rollback.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/rollback.rs @@ -1368,43 +1368,6 @@ mod tests { } } - /// `serve_block_roots` reads a request that starts at or above the upgrade height `U` straight - /// from the serving index, without touching the per-height trees. - #[test] - fn serve_block_roots_serves_at_or_above_upgrade_from_index() { - let _init_guard = zebra_test::init(); - let db = ephemeral_mainnet_db(); - - // Index covers [4, 6]; the upgrade height is U = 4. - let mut batch = DiskWriteBatch::new(); - batch.update_vct_upgrade_marker(&db, Height(4)); - for height in 4u32..=6 { - batch.insert_commitment_roots_by_height( - &db, - Height(height), - &sapling_root(height.into()), - &orchard_root(height.into()), - &zebra_chain::ironwood::tree::NoteCommitmentTree::default().root(), - 0, - 0, - 0, - &zebra_chain::block::merkle::AuthDataRoot::from([0u8; 32]), - ); - } - db.write_batch(batch) - .expect("seeding the serving index succeeds"); - - let served = crate::service::finalized_state::serve_block_roots(&db, Height(4)..=Height(6)); - assert_eq!( - served - .into_iter() - .map(|root| root.height) - .collect::>(), - vec![Height(4), Height(5), Height(6)], - "a request at or above U is served from the index" - ); - } - /// `delete_zakura_headers_above` must truncate every Zakura header CF above the target, /// including the hash→height index, while leaving rows at or below the target intact. This /// is the consistency guarantee that lets a rolled-back snapshot re-sync bodies from its tip From 2ed6bc601c2edad638ee2fca4db1397eeaa843eb Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 12:30:53 -0600 Subject: [PATCH 06/13] comment --- .../src/service/finalized_state/commitment_aux.rs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/zebra-state/src/service/finalized_state/commitment_aux.rs b/zebra-state/src/service/finalized_state/commitment_aux.rs index 3a67a79ced7..cbbb9319a89 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux.rs @@ -302,18 +302,11 @@ pub(crate) fn produce_block_roots( roots } -/// Serve the per-block roots for `range`, stitching the two sources at the upgrade height `U`. +/// Serve the per-block roots for `range`, joining tree-derived roots below the VCT upgrade height +/// with indexed roots at and above it. /// -/// The `commitment_roots_by_height` serving index only covers heights at and above `U` (the lowest -/// height this binary committed). Heights below `U` predate the index, so they are derived from the -/// per-height trees instead, and the two runs are concatenated. This is what lets a node that -/// upgraded mid-chain serve a request that straddles `U` as one gap-free batch, rather than the -/// short index-only prefix that would stall the client's minimum-progress check. -/// -/// Both sources stop at the first absent height, so the result is always a contiguous run from -/// `range.start()`; a tree gap below `U` is served as the prefix collected so far without reaching -/// into the index. A database that never recorded `U` — a pre-index archive node — derives the -/// whole range from the trees, the original archive fallback. +/// Each source stops at the first missing height, so the result is always a contiguous prefix from +/// `range.start()`. Databases without a recorded upgrade height derive the whole range from trees. pub(crate) fn serve_block_roots( db: &ZebraDb, range: std::ops::RangeInclusive, From c2936127ac41fcdf5714fc386ef4b0d88f3d6295 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 01:21:13 -0600 Subject: [PATCH 07/13] feat(state): verify supplied commitment roots against header commitments --- zebra-chain/src/block/commitment.rs | 10 + zebra-state/src/service/check.rs | 4 + zebra-state/src/service/finalized_state.rs | 1 + .../finalized_state/commitment_aux_verify.rs | 529 ++++++++++++++++++ 4 files changed, 544 insertions(+) create mode 100644 zebra-state/src/service/finalized_state/commitment_aux_verify.rs diff --git a/zebra-chain/src/block/commitment.rs b/zebra-chain/src/block/commitment.rs index 7b1f4e6b23a..e0c3a8141ca 100644 --- a/zebra-chain/src/block/commitment.rs +++ b/zebra-chain/src/block/commitment.rs @@ -396,6 +396,16 @@ pub enum CommitmentError { actual: [u8; 32], }, + #[error( + "invalid pre-NU5 orchard root: expected the empty-tree root {:?}, actual: {:?}", + hex::encode(expected), + hex::encode(actual) + )] + InvalidPreNu5OrchardRoot { + expected: [u8; 32], + actual: [u8; 32], + }, + #[error("missing required block height: block commitments can't be parsed without a block height, block hash: {block_hash:?}")] MissingBlockHeight { block_hash: block::Hash }, diff --git a/zebra-state/src/service/check.rs b/zebra-state/src/service/check.rs index d9aa373d805..e5281b3af12 100644 --- a/zebra-state/src/service/check.rs +++ b/zebra-state/src/service/check.rs @@ -235,6 +235,10 @@ pub(crate) fn block_commitment_is_valid_for_chain_history( "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()); diff --git a/zebra-state/src/service/finalized_state.rs b/zebra-state/src/service/finalized_state.rs index 7d8662ae4be..4a1431eba8a 100644 --- a/zebra-state/src/service/finalized_state.rs +++ b/zebra-state/src/service/finalized_state.rs @@ -75,6 +75,7 @@ static COMMIT_COMPUTE_POOL: LazyLock = LazyLock::new(|| { pub mod column_family; pub(crate) mod commitment_aux; +pub(crate) mod commitment_aux_verify; mod disk_db; mod disk_format; mod zebra_db; diff --git a/zebra-state/src/service/finalized_state/commitment_aux_verify.rs b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs new file mode 100644 index 00000000000..a2ac4354ccb --- /dev/null +++ b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs @@ -0,0 +1,529 @@ +//! Read-only verification of supplied per-block note-commitment roots against the +//! checkpoint-committed block headers, via the ZIP-221 ChainHistory MMR. +//! +//! This is the "verify" half of the verified-commitment-trees design +//! (`docs/design/verified-commitment-trees.md` §6): given a sequence of per-block +//! Sapling/Orchard roots (from a fixture today, an untrusted peer later), confirm +//! they reconstruct a history tree consistent with the header commitments. The +//! commit path uses this module before persisting supplied roots. +//! +//! It reuses the existing consensus check +//! ([`block_commitment_is_valid_for_chain_history`](crate::service::check::block_commitment_is_valid_for_chain_history)) +//! and [`HistoryTree::push`], which build the V1/V2 leaf from the block body and the +//! supplied roots — so there is no new crypto here. + +// The non-test consumer is the committer fast path, which lands in a follow-up +// increment and removes this allow; the module's own tests exercise it here. +#![allow(dead_code)] + +use std::sync::Arc; + +use zebra_chain::{ + block::{merkle::AuthDataRoot, Block, Height}, + history_tree::HistoryTree, + orchard, + parameters::{Network, NetworkUpgrade}, + sapling, +}; + +use zebra_chain::block::{Commitment, CommitmentError}; + +use crate::{service::check, ValidateContextError}; + +/// One block-sized step in supplied commitment-root verification. +#[derive(Clone, Debug)] +pub(crate) struct CommitmentRootVerification { + pub(crate) block: Arc, + pub(crate) roots: Option<(sapling::tree::Root, orchard::tree::Root)>, + pub(crate) precomputed_auth_data_root: Option, + pub(crate) skip_parent_check: bool, +} + +impl CommitmentRootVerification { + pub(crate) fn with_roots( + block: Arc, + sapling_root: sapling::tree::Root, + orchard_root: orchard::tree::Root, + precomputed_auth_data_root: Option, + skip_parent_check: bool, + ) -> Self { + CommitmentRootVerification { + block, + roots: Some((sapling_root, orchard_root)), + precomputed_auth_data_root, + 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 +/// block header (design §6.1). +/// +/// The ZIP-221 history MMR does not exist below Heartwood, so +/// [`block_commitment_is_valid_for_chain_history`](check::block_commitment_is_valid_for_chain_history) +/// is a no-op there and cannot authenticate the supplied roots. This fills that gap: +/// +/// - Sapling..Heartwood: the header's `FinalSaplingRoot` commits the Sapling root +/// directly, so the supplied root must equal it. +/// - Pre-Sapling: the Sapling tree is empty, so the supplied root must be the +/// empty-tree root. +/// +/// Heartwood and later (`ChainHistoryRoot` / `ChainHistoryBlockTxAuthCommitment` / +/// the activation-reserved block) are authenticated by the MMR path and accepted +/// here. The Orchard root below NU5 is pinned separately by +/// [`verify_supplied_orchard_root_below_nu5`]. +pub(crate) fn verify_supplied_sapling_root_below_heartwood( + network: &Network, + block: &Block, + sapling_root: &sapling::tree::Root, +) -> Result<(), ValidateContextError> { + let expected = match block.commitment(network)? { + 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 Orchard root for a *pre-NU5* block (design §6.1). +/// +/// The Orchard tree does not activate until NU5, and no header below NU5 commits to an +/// Orchard root: the ZIP-221 V1 history leaf (Heartwood..Canopy) *ignores* the Orchard +/// root entirely (`zcash_history.rs`, `V1::block_to_history_node`), and below Heartwood +/// there is no MMR at all. So the MMR path that authenticates Orchard roots from NU5 +/// onward cannot vouch for any root below NU5 — yet the fast path folds the supplied +/// Orchard root into the anchor set for every block. Without this check an untrusted +/// source could inject an arbitrary Orchard anchor below NU5 that the legacy recompute +/// path never produces, breaking the §11 trust boundary and consensus equivalence. +/// +/// Below NU5 the Orchard tree is always the empty default, so the supplied root must +/// equal the empty-tree root. At and above NU5 activation the MMR path authenticates +/// the root, so this accepts. +pub(crate) fn verify_supplied_orchard_root_below_nu5( + network: &Network, + height: Height, + orchard_root: &orchard::tree::Root, +) -> Result<(), ValidateContextError> { + // At/above NU5 the ZIP-221 V2 MMR commits to the Orchard root, so it is + // authenticated there, not here. + if let Some(nu5_height) = NetworkUpgrade::Nu5.activation_height(network) { + if height >= nu5_height { + return Ok(()); + } + } + + let expected = orchard::tree::NoteCommitmentTree::default().root(); + if orchard_root != &expected { + return Err(ValidateContextError::InvalidBlockCommitment( + CommitmentError::InvalidPreNu5OrchardRoot { + expected: <[u8; 32]>::from(expected), + actual: <[u8; 32]>::from(*orchard_root), + }, + )); + } + + Ok(()) +} + +/// 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` +/// (the parent block's history tree). +/// +/// Returns the final history tree on success, or `(height, error)` for the first +/// block whose header commitment rejects the roots folded in so far. +/// +/// # Lag +/// +/// A block's commitment commits to the history tree as of its *parent*, so the root +/// supplied for height `H` is only confirmed when height `H + 1` is processed. Over a +/// contiguous range `[start..=end]` this therefore confirms the roots at +/// `[start..=end - 1]`; pass the block at `end + 1` to confirm the root at `end`. +pub(crate) fn verify_commitment_roots( + network: &Network, + mut tree: HistoryTree, + items: I, +) -> Result +where + I: IntoIterator, +{ + for item in items { + let CommitmentRootVerification { + block, + roots, + precomputed_auth_data_root, + skip_parent_check, + } = item; + + let height = block + .coinbase_height() + .expect("checkpoint-verified blocks have a coinbase height"); + + // Validate this block's header commitment against the current (parent) tree, + // i.e. against every root already folded in. + if !skip_parent_check { + check::block_commitment_is_valid_for_chain_history( + block.clone(), + network, + &tree, + precomputed_auth_data_root, + ) + .map_err(|error| (height, error))?; + } + + let Some((sapling_root, orchard_root)) = roots else { + continue; + }; + + verify_supplied_sapling_root_below_heartwood(network, &block, &sapling_root) + .map_err(|error| (height, error))?; + verify_supplied_orchard_root_below_nu5(network, height, &orchard_root) + .map_err(|error| (height, error))?; + + // Fold this block's supplied roots into the running MMR (builds the leaf + // from the block body tx-counts + the roots). + tree.push( + network, + block, + &sapling_root, + &orchard_root, + &Default::default(), + ) + .map_err(Arc::new) + .map_err(ValidateContextError::from) + .map_err(|error| (height, error))?; + } + + Ok(tree) +} + +#[cfg(test)] +mod tests { + use super::*; + + use zebra_chain::{ + block::Block, + parameters::{ + testnet::{ConfiguredActivationHeights, RegtestParameters}, + Network::Mainnet, + NetworkUpgrade, + }, + serialization::ZcashDeserializeInto, + }; + + /// Build an empty [`HistoryTree`] (the genesis block is pre-Heartwood). + fn empty_history_tree() -> HistoryTree { + let genesis = Arc::new( + zebra_test::vectors::BLOCK_MAINNET_GENESIS_BYTES + .zcash_deserialize_into::() + .expect("genesis deserializes"), + ); + HistoryTree::from_block( + &Mainnet, + genesis, + &Default::default(), + &Default::default(), + &Default::default(), + ) + .expect("empty history tree for a pre-Heartwood block") + } + + /// A distinct, valid Orchard root that is *not* the empty-tree root, for the + /// negative cases. Zero is a valid Pallas base field element, and the empty + /// Orchard tree root is an uncommitted-leaf hash, so the two differ. + fn non_empty_orchard_root() -> orchard::tree::Root { + let empty = orchard::tree::NoteCommitmentTree::default().root(); + let wrong = orchard::tree::Root::try_from([0u8; 32]) + .expect("zero is a valid pallas base field element"); + assert_ne!( + wrong, empty, + "the negative cases need a root distinct from the empty-tree root" + ); + wrong + } + + fn verification_item( + block: Arc, + sapling_root: sapling::tree::Root, + orchard_root: orchard::tree::Root, + ) -> CommitmentRootVerification { + CommitmentRootVerification::with_roots(block, sapling_root, orchard_root, None, false) + } + + /// Below NU5 the supplied Orchard root must equal the empty-tree root (no header + /// commits to it there), and any other root is rejected. At/above NU5 the MMR + /// authenticates it, so this check accepts unconditionally. + #[test] + fn pins_orchard_root_to_empty_below_nu5_and_defers_above() { + let nu5 = NetworkUpgrade::Nu5 + .activation_height(&Mainnet) + .expect("mainnet has NU5"); + let empty = orchard::tree::NoteCommitmentTree::default().root(); + let wrong = non_empty_orchard_root(); + + // Below NU5: the empty root is accepted, a non-empty root is rejected. + let pre_nu5 = Height(nu5.0 - 1); + verify_supplied_orchard_root_below_nu5(&Mainnet, pre_nu5, &empty) + .expect("the empty-tree root is accepted below NU5"); + let error = verify_supplied_orchard_root_below_nu5(&Mainnet, pre_nu5, &wrong) + .expect_err("a non-empty orchard root must be rejected below NU5"); + assert!( + matches!( + error, + ValidateContextError::InvalidBlockCommitment( + CommitmentError::InvalidPreNu5OrchardRoot { .. } + ) + ), + "rejection uses the dedicated pre-NU5 orchard error, got: {error:?}" + ); + + // Pre-Sapling/Heartwood (well below NU5) is also pinned to empty. + verify_supplied_orchard_root_below_nu5(&Mainnet, Height(1), &empty) + .expect("the empty-tree root is accepted at low heights"); + verify_supplied_orchard_root_below_nu5(&Mainnet, Height(1), &wrong) + .expect_err("a non-empty orchard root must be rejected at low heights"); + + // At and above NU5 the MMR path authenticates the root, so even a non-empty + // root is accepted here (it is checked elsewhere). + verify_supplied_orchard_root_below_nu5(&Mainnet, nu5, &wrong) + .expect("at NU5 the root is authenticated by the MMR, not pinned here"); + verify_supplied_orchard_root_below_nu5(&Mainnet, Height(nu5.0 + 1), &wrong) + .expect("above NU5 the root is authenticated by the MMR, not pinned here"); + } + + #[test] + fn pins_orchard_root_to_empty_when_nu5_is_unconfigured() { + let network = zebra_chain::parameters::Network::new_regtest(RegtestParameters { + activation_heights: ConfiguredActivationHeights { + nu5: None, + ..Default::default() + }, + ..Default::default() + }); + let empty = orchard::tree::NoteCommitmentTree::default().root(); + let wrong = non_empty_orchard_root(); + + verify_supplied_orchard_root_below_nu5(&network, Height(1), &empty) + .expect("the empty-tree root is accepted when NU5 is unconfigured"); + let error = verify_supplied_orchard_root_below_nu5(&network, Height(1), &wrong) + .expect_err("a non-empty orchard root must be rejected when NU5 is unconfigured"); + assert!( + matches!( + error, + ValidateContextError::InvalidBlockCommitment( + CommitmentError::InvalidPreNu5OrchardRoot { .. } + ) + ), + "rejection uses the dedicated pre-NU5 orchard error, got: {error:?}" + ); + } + + /// The verifier confirms real Sapling roots over the Heartwood activation and its + /// next block (the V1 `ChainHistoryRoot` path), and rejects a wrong root at the + /// *next* block (the one-block lag). + #[test] + fn verifies_real_roots_and_rejects_a_wrong_root_at_next_height() { + let (blocks, sapling_roots) = Mainnet.block_sapling_roots_map(); + let activation = NetworkUpgrade::Heartwood + .activation_height(&Mainnet) + .expect("mainnet has Heartwood") + .0; + + let block_at = |height: u32| -> Arc { + Arc::new( + blocks + .get(&height) + .expect("test vector block exists") + .zcash_deserialize_into::() + .expect("block deserializes"), + ) + }; + let root_at = |height: u32| -> sapling::tree::Root { + sapling::tree::Root::try_from(**sapling_roots.get(&height).expect("root vector exists")) + .expect("valid root") + }; + + let act_block = block_at(activation); + let next_block = block_at(activation + 1); + let act_root = root_at(activation); + let next_root = root_at(activation + 1); + let empty_orchard_root = orchard::tree::NoteCommitmentTree::default().root(); + + // Positive: the real roots reconstruct a tree the next block's header commits to. + let ok_items = vec![ + verification_item(act_block.clone(), act_root, empty_orchard_root), + verification_item(next_block.clone(), next_root, empty_orchard_root), + ]; + verify_commitment_roots(&Mainnet, empty_history_tree(), ok_items) + .expect("real roots verify against the headers"); + + // Negative + lag: a wrong root at the activation height (here, the next + // block's root, which is a valid but different root) is only caught when the + // following block's commitment is checked. + assert_ne!(act_root, next_root, "test needs two distinct roots"); + let bad_items = vec![ + verification_item(act_block, next_root, empty_orchard_root), + verification_item(next_block, next_root, empty_orchard_root), + ]; + let (fail_height, _error) = + verify_commitment_roots(&Mainnet, empty_history_tree(), bad_items) + .expect_err("a wrong root must be rejected"); + assert_eq!( + fail_height.0, + activation + 1, + "a wrong root at H is detected at H+1 (the lag)" + ); + } + + /// Real NU5/V2-range verification over the POC range (1,707,211..=1,717,210), + /// exercising the actual [`verify_commitment_roots`] on production data. + /// + /// Gated by env vars so it stays out of normal CI. Requires two read-only forks + /// of the RUNBOOK 1.707M master snapshot: + /// - `VCT_SEED_DB`: an *unsynced* `cp -al` fork (its tip history tree at height + /// 1,707,210 is the seed — mid-NU5-epoch, so no activation boundary to handle). + /// - `VCT_ARCHIVE_DB`: an archive fork synced to >= 1,717,211 (provides the blocks + /// and per-height roots). + /// + /// Run: + /// ```text + /// VCT_SEED_DB= VCT_ARCHIVE_DB= \ + /// cargo test -p zebra-state --lib commitment_aux_verify -- --ignored --nocapture + /// ``` + #[ignore] + #[test] + #[allow(clippy::print_stderr)] // intentional progress output for a manual run + fn verifies_real_nu5_range_over_synced_forks() { + use std::path::PathBuf; + + use crate::{ + constants::{state_database_format_version_in_code, STATE_DATABASE_KIND}, + service::finalized_state::{ZebraDb, STATE_COLUMN_FAMILIES_IN_CODE}, + Config, + }; + + let (Some(seed_dir), Some(archive_dir)) = ( + std::env::var_os("VCT_SEED_DB"), + std::env::var_os("VCT_ARCHIVE_DB"), + ) else { + eprintln!("skipping: set VCT_SEED_DB (unsynced fork) and VCT_ARCHIVE_DB (synced fork)"); + return; + }; + + let open = |dir: PathBuf| -> ZebraDb { + let config = Config { + cache_dir: dir, + ephemeral: false, + ..Default::default() + }; + ZebraDb::new( + &config, + STATE_DATABASE_KIND, + &state_database_format_version_in_code(), + &Mainnet, + true, // skip format upgrades + STATE_COLUMN_FAMILIES_IN_CODE + .iter() + .map(ToString::to_string), + true, // read-only + ) + }; + + let seed_db = open(PathBuf::from(seed_dir)); + let archive_db = open(PathBuf::from(archive_dir)); + + let start = 1_707_211u32; + let end = 1_717_210u32; + + // Seed: the history tree at 1,707,210 (the unsynced fork's tip). + let seed = (*seed_db.history_tree()).clone(); + assert_eq!( + seed_db.finalized_tip_height().map(|h| h.0), + Some(start - 1), + "VCT_SEED_DB must be the unsynced 1,707,210 master fork" + ); + assert!( + archive_db.finalized_tip_height().map(|h| h.0).unwrap_or(0) > end, + "VCT_ARCHIVE_DB must be synced to at least {}", + end + 1 + ); + + // Build (block, sapling_root, orchard_root) for [start..=end+1]; the +1 block + // confirms the in-range root at `end` via the one-block lag. + let item_at = |h: u32| -> CommitmentRootVerification { + let block = archive_db + .block(Height(h).into()) + .expect("archive fork has the block"); + let sapling_root = archive_db + .sapling_tree_by_height(&Height(h)) + .expect("archive fork has the per-height Sapling tree") + .root(); + let orchard_root = archive_db + .orchard_tree_by_height(&Height(h)) + .expect("archive fork has the per-height Orchard tree") + .root(); + verification_item(block, sapling_root, orchard_root) + }; + let items: Vec<_> = (start..=end + 1).map(item_at).collect(); + + // Positive: every supplied root in the range is confirmed by the V2 headers. + verify_commitment_roots(&Mainnet, seed.clone(), items.clone()) + .expect("real NU5 roots verify against the headers"); + eprintln!("VCT NU5 positive: {} blocks verified", items.len()); + + // Negative + lag: corrupt one root mid-range with a distinct valid root (the + // range's first root, certainly different after thousands of sandblast blocks); + // expect rejection at H+1. + let bad_offset = 5_000usize; + let bad_height = start + bad_offset as u32; + let wrong_root = items[0].roots.expect("test verification item has roots").0; + let mut bad_items = items; + assert_ne!( + bad_items[bad_offset] + .roots + .expect("test verification item has roots") + .0, + wrong_root, + "need a distinct wrong root" + ); + bad_items[bad_offset] + .roots + .as_mut() + .expect("test verification item has roots") + .0 = wrong_root; + let (fail_height, _error) = verify_commitment_roots(&Mainnet, seed, bad_items) + .expect_err("a wrong NU5 root must be rejected"); + assert_eq!( + fail_height.0, + bad_height + 1, + "a wrong root at H is detected at H+1 (the lag)" + ); + eprintln!( + "VCT NU5 negative: wrong root at {bad_height} rejected at {}", + fail_height.0 + ); + } +} From 7048bb0e1f17aae7afbf8acba3e60c316cf2ded3 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 12:43:46 -0600 Subject: [PATCH 08/13] clean ups --- .../finalized_state/commitment_aux_verify.rs | 159 +++++++++++++----- 1 file changed, 116 insertions(+), 43 deletions(-) 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 a2ac4354ccb..8e0ebd788a5 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux_verify.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs @@ -1,20 +1,10 @@ //! Read-only verification of supplied per-block note-commitment roots against the //! checkpoint-committed block headers, via the ZIP-221 ChainHistory MMR. //! -//! This is the "verify" half of the verified-commitment-trees design -//! (`docs/design/verified-commitment-trees.md` §6): given a sequence of per-block +//! This is the "verify" component of the verified-commitment-trees design +//! (`docs/design/verified-commitment-trees.md`). Given a sequence of per-block //! Sapling/Orchard roots (from a fixture today, an untrusted peer later), confirm -//! they reconstruct a history tree consistent with the header commitments. The -//! commit path uses this module before persisting supplied roots. -//! -//! It reuses the existing consensus check -//! ([`block_commitment_is_valid_for_chain_history`](crate::service::check::block_commitment_is_valid_for_chain_history)) -//! and [`HistoryTree::push`], which build the V1/V2 leaf from the block body and the -//! supplied roots — so there is no new crypto here. - -// The non-test consumer is the committer fast path, which lands in a follow-up -// increment and removes this allow; the module's own tests exercise it here. -#![allow(dead_code)] +//! they reconstruct a history tree consistent with the header commitments. use std::sync::Arc; @@ -69,21 +59,13 @@ impl CommitmentRootVerification { } /// Verifies a supplied Sapling root for a *pre-Heartwood* block directly against the -/// block header (design §6.1). +/// block header. /// -/// The ZIP-221 history MMR does not exist below Heartwood, so -/// [`block_commitment_is_valid_for_chain_history`](check::block_commitment_is_valid_for_chain_history) +/// The ZIP-221 history MMR does not exist below Heartwood, so `block_commitment_is_valid_for_chain_history` /// is a no-op there and cannot authenticate the supplied roots. This fills that gap: -/// /// - Sapling..Heartwood: the header's `FinalSaplingRoot` commits the Sapling root /// directly, so the supplied root must equal it. -/// - Pre-Sapling: the Sapling tree is empty, so the supplied root must be the -/// empty-tree root. -/// -/// Heartwood and later (`ChainHistoryRoot` / `ChainHistoryBlockTxAuthCommitment` / -/// the activation-reserved block) are authenticated by the MMR path and accepted -/// here. The Orchard root below NU5 is pinned separately by -/// [`verify_supplied_orchard_root_below_nu5`]. +/// - Pre-Sapling: the Sapling tree is empty, so the supplied root must be the empty-tree root. pub(crate) fn verify_supplied_sapling_root_below_heartwood( network: &Network, block: &Block, @@ -265,6 +247,23 @@ mod tests { wrong } + fn mainnet_block_at(height: u32) -> Arc { + let (blocks, _) = Mainnet.block_sapling_roots_map(); + Arc::new( + blocks + .get(&height) + .expect("test vector block exists") + .zcash_deserialize_into::() + .expect("block deserializes"), + ) + } + + fn mainnet_sapling_root_at(height: u32) -> sapling::tree::Root { + let (_, sapling_roots) = Mainnet.block_sapling_roots_map(); + sapling::tree::Root::try_from(**sapling_roots.get(&height).expect("root vector exists")) + .expect("valid root") + } + fn verification_item( block: Arc, sapling_root: sapling::tree::Root, @@ -273,6 +272,95 @@ mod tests { CommitmentRootVerification::with_roots(block, sapling_root, orchard_root, None, false) } + #[test] + fn commitment_root_verification_constructors_set_expected_fields() { + let block = mainnet_block_at(1); + let sapling_root = sapling::tree::NoteCommitmentTree::default().root(); + let orchard_root = orchard::tree::NoteCommitmentTree::default().root(); + + let with_roots = CommitmentRootVerification::with_roots( + block.clone(), + sapling_root, + orchard_root, + None, + true, + ); + assert!(Arc::ptr_eq(&with_roots.block, &block)); + assert_eq!(with_roots.roots, Some((sapling_root, orchard_root))); + assert_eq!(with_roots.precomputed_auth_data_root, None); + assert!(with_roots.skip_parent_check); + + let header_only = CommitmentRootVerification::header_only(block.clone(), None); + assert!(Arc::ptr_eq(&header_only.block, &block)); + assert_eq!(header_only.roots, None); + assert_eq!(header_only.precomputed_auth_data_root, None); + assert!(!header_only.skip_parent_check); + } + + /// Below Heartwood the supplied Sapling root is authenticated directly by the + /// header commitment (or pinned to empty before Sapling). At/above Heartwood, + /// the MMR path authenticates it instead, so this direct check accepts. + #[test] + fn pins_sapling_root_below_heartwood_to_header_or_empty() { + let empty = sapling::tree::NoteCommitmentTree::default().root(); + let sapling_root = mainnet_sapling_root_at(419_200); + let different_sapling_root = mainnet_sapling_root_at(419_201); + assert_ne!( + empty, different_sapling_root, + "the pre-Sapling negative case needs a non-empty root" + ); + assert_ne!( + sapling_root, different_sapling_root, + "the negative cases need two distinct roots" + ); + + let pre_sapling_block = mainnet_block_at(1); + verify_supplied_sapling_root_below_heartwood(&Mainnet, &pre_sapling_block, &empty) + .expect("the empty-tree root is accepted before Sapling"); + let error = verify_supplied_sapling_root_below_heartwood( + &Mainnet, + &pre_sapling_block, + &different_sapling_root, + ) + .expect_err("a non-empty Sapling root must be rejected before Sapling"); + assert!( + matches!( + error, + ValidateContextError::InvalidBlockCommitment( + CommitmentError::InvalidFinalSaplingRoot { .. } + ) + ), + "rejection uses the final Sapling root error, got: {error:?}" + ); + + let sapling_block = mainnet_block_at(419_200); + verify_supplied_sapling_root_below_heartwood(&Mainnet, &sapling_block, &sapling_root) + .expect("the header's final Sapling root is accepted before Heartwood"); + let error = verify_supplied_sapling_root_below_heartwood( + &Mainnet, + &sapling_block, + &different_sapling_root, + ) + .expect_err("a Sapling root different from the header root must be rejected"); + assert!( + matches!( + error, + ValidateContextError::InvalidBlockCommitment( + CommitmentError::InvalidFinalSaplingRoot { .. } + ) + ), + "rejection uses the final Sapling root error, got: {error:?}" + ); + + let heartwood_block = mainnet_block_at(903_000); + verify_supplied_sapling_root_below_heartwood( + &Mainnet, + &heartwood_block, + &different_sapling_root, + ) + .expect("at Heartwood the root is authenticated by the MMR, not pinned here"); + } + /// Below NU5 the supplied Orchard root must equal the empty-tree root (no header /// commits to it there), and any other root is rejected. At/above NU5 the MMR /// authenticates it, so this check accepts unconditionally. @@ -346,30 +434,15 @@ mod tests { /// *next* block (the one-block lag). #[test] fn verifies_real_roots_and_rejects_a_wrong_root_at_next_height() { - let (blocks, sapling_roots) = Mainnet.block_sapling_roots_map(); let activation = NetworkUpgrade::Heartwood .activation_height(&Mainnet) .expect("mainnet has Heartwood") .0; - let block_at = |height: u32| -> Arc { - Arc::new( - blocks - .get(&height) - .expect("test vector block exists") - .zcash_deserialize_into::() - .expect("block deserializes"), - ) - }; - let root_at = |height: u32| -> sapling::tree::Root { - sapling::tree::Root::try_from(**sapling_roots.get(&height).expect("root vector exists")) - .expect("valid root") - }; - - let act_block = block_at(activation); - let next_block = block_at(activation + 1); - let act_root = root_at(activation); - let next_root = root_at(activation + 1); + let act_block = mainnet_block_at(activation); + let next_block = mainnet_block_at(activation + 1); + let act_root = mainnet_sapling_root_at(activation); + let next_root = mainnet_sapling_root_at(activation + 1); let empty_orchard_root = orchard::tree::NoteCommitmentTree::default().root(); // Positive: the real roots reconstruct a tree the next block's header commits to. From add7dd145a7cc6bad9ddce8c595d9961b33775f8 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 13:09:32 -0600 Subject: [PATCH 09/13] clean up --- .../finalized_state/commitment_aux_verify.rs | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) 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 8e0ebd788a5..a46824d491d 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux_verify.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs @@ -30,6 +30,8 @@ pub(crate) struct CommitmentRootVerification { } impl CommitmentRootVerification { + /// Verify this block's parent-history commitment, then fold the supplied + /// per-block roots into the running history tree for the next block. pub(crate) fn with_roots( block: Arc, sapling_root: sapling::tree::Root, @@ -45,6 +47,10 @@ impl CommitmentRootVerification { } } + /// Verify this block's parent-history commitment without folding in roots. + /// + /// This confirms the roots already accumulated in the running tree, which is useful + /// for the final one-block lag: the roots at height `H` are checked by height `H + 1`. pub(crate) fn header_only( block: Arc, precomputed_auth_data_root: Option, @@ -146,19 +152,19 @@ pub(crate) fn verify_supplied_orchard_root_below_nu5( /// `[start..=end - 1]`; pass the block at `end + 1` to confirm the root at `end`. pub(crate) fn verify_commitment_roots( network: &Network, - mut tree: HistoryTree, - items: I, + mut history_tree: HistoryTree, + blocks_to_verify: I, ) -> Result where I: IntoIterator, { - for item in items { + for block_verify in blocks_to_verify { let CommitmentRootVerification { block, roots, precomputed_auth_data_root, skip_parent_check, - } = item; + } = block_verify; let height = block .coinbase_height() @@ -166,11 +172,23 @@ where // Validate this block's header commitment against the current (parent) tree, // i.e. against every root already folded in. + // We allow the caller to control skipping this check + // in case the caller has already verified the parent tree + // For example, a block execution loop is: + // 1. Verify block X against block X - 1 history tree + // 2. Wait for block X + 1 body to verify against block X history tree + // * This is so that we do not commit block X before we have verified its roots. + // 3. Verify block X + 1 against block X history tree + // + // Note that, when we are processing block X + 1 step 1, we are ovrlapping + // with step 3 of the prior iteration so verification can be skipped in that case + // for perf reasons. if !skip_parent_check { + // This block + history tree up to and including the previous block. check::block_commitment_is_valid_for_chain_history( block.clone(), network, - &tree, + &history_tree, precomputed_auth_data_root, ) .map_err(|error| (height, error))?; @@ -187,11 +205,13 @@ where // Fold this block's supplied roots into the running MMR (builds the leaf // from the block body tx-counts + the roots). - tree.push( + history_tree.push( network, block, &sapling_root, &orchard_root, + // TODO: add ironwood root + // https://linear.app/zcale/issue/ZCA-746/wire-up-ironwood-into-vct &Default::default(), ) .map_err(Arc::new) @@ -199,7 +219,7 @@ where .map_err(|error| (height, error))?; } - Ok(tree) + Ok(history_tree) } #[cfg(test)] From 8927602abcb414ef04098e7679114c21cba8b107 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 13:14:40 -0600 Subject: [PATCH 10/13] simplify comment --- .../finalized_state/commitment_aux_verify.rs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) 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 a46824d491d..79c661ce6d3 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux_verify.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs @@ -96,20 +96,10 @@ pub(crate) fn verify_supplied_sapling_root_below_heartwood( Ok(()) } -/// Verifies a supplied Orchard root for a *pre-NU5* block (design §6.1). +/// Verifies a supplied Orchard root for a pre-NU5 block. /// -/// The Orchard tree does not activate until NU5, and no header below NU5 commits to an -/// Orchard root: the ZIP-221 V1 history leaf (Heartwood..Canopy) *ignores* the Orchard -/// root entirely (`zcash_history.rs`, `V1::block_to_history_node`), and below Heartwood -/// there is no MMR at all. So the MMR path that authenticates Orchard roots from NU5 -/// onward cannot vouch for any root below NU5 — yet the fast path folds the supplied -/// Orchard root into the anchor set for every block. Without this check an untrusted -/// source could inject an arbitrary Orchard anchor below NU5 that the legacy recompute -/// path never produces, breaking the §11 trust boundary and consensus equivalence. -/// -/// Below NU5 the Orchard tree is always the empty default, so the supplied root must -/// equal the empty-tree root. At and above NU5 activation the MMR path authenticates -/// the root, so this accepts. +/// Blocks before NU5 do not commit to Orchard roots, so the MMR cannot +/// authenticate them. The supplied root must therefore be the empty-tree root. pub(crate) fn verify_supplied_orchard_root_below_nu5( network: &Network, height: Height, From 3d3cb02aedb56b4e0cfe942d060eeff6314ff56a Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 13:21:17 -0600 Subject: [PATCH 11/13] lints --- .../finalized_state/commitment_aux_verify.rs | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) 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 79c661ce6d3..f5cd2a57558 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux_verify.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs @@ -6,6 +6,8 @@ //! Sapling/Orchard roots (from a fixture today, an untrusted peer later), confirm //! they reconstruct a history tree consistent with the header commitments. +#![cfg_attr(not(test), allow(dead_code))] + use std::sync::Arc; use zebra_chain::{ @@ -195,18 +197,19 @@ where // Fold this block's supplied roots into the running MMR (builds the leaf // from the block body tx-counts + the roots). - history_tree.push( - network, - block, - &sapling_root, - &orchard_root, - // TODO: add ironwood root - // https://linear.app/zcale/issue/ZCA-746/wire-up-ironwood-into-vct - &Default::default(), - ) - .map_err(Arc::new) - .map_err(ValidateContextError::from) - .map_err(|error| (height, error))?; + history_tree + .push( + network, + block, + &sapling_root, + &orchard_root, + // TODO: add ironwood root + // https://linear.app/zcale/issue/ZCA-746/wire-up-ironwood-into-vct + &Default::default(), + ) + .map_err(Arc::new) + .map_err(ValidateContextError::from) + .map_err(|error| (height, error))?; } Ok(history_tree) From cc199db977da3339e8598fa760f8df4cf03c474b Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 13:26:22 -0600 Subject: [PATCH 12/13] address v12 --- zebra-chain/src/block/commitment.rs | 10 +++ zebra-state/src/service/check.rs | 17 +++-- .../src/service/check/tests/vectors.rs | 76 +++++++++++++++++++ .../src/service/finalized_state/tests/prop.rs | 2 +- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/zebra-chain/src/block/commitment.rs b/zebra-chain/src/block/commitment.rs index e0c3a8141ca..0b4df4c118f 100644 --- a/zebra-chain/src/block/commitment.rs +++ b/zebra-chain/src/block/commitment.rs @@ -396,6 +396,16 @@ pub enum CommitmentError { actual: [u8; 32], }, + #[error( + "invalid auth data root: expected {:?}, actual: {:?}", + hex::encode(expected), + hex::encode(actual) + )] + InvalidAuthDataRoot { + expected: [u8; 32], + actual: [u8; 32], + }, + #[error( "invalid pre-NU5 orchard root: expected the empty-tree root {:?}, actual: {:?}", hex::encode(expected), diff --git a/zebra-state/src/service/check.rs b/zebra-state/src/service/check.rs index e5281b3af12..f7868eb5ef4 100644 --- a/zebra-state/src/service/check.rs +++ b/zebra-state/src/service/check.rs @@ -235,12 +235,17 @@ pub(crate) fn block_commitment_is_valid_for_chain_history( "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 auth_data_root = block.auth_data_root(); + if let Some(precomputed_auth_data_root) = precomputed_auth_data_root { + if precomputed_auth_data_root != auth_data_root { + return Err(ValidateContextError::InvalidBlockCommitment( + CommitmentError::InvalidAuthDataRoot { + actual: precomputed_auth_data_root.into(), + expected: auth_data_root.into(), + }, + )); + } + } let hash_block_commitments = ChainHistoryBlockTxAuthCommitmentHash::from_commitments( &history_tree_root, diff --git a/zebra-state/src/service/check/tests/vectors.rs b/zebra-state/src/service/check/tests/vectors.rs index 83326a03dbd..2eccf28ca04 100644 --- a/zebra-state/src/service/check/tests/vectors.rs +++ b/zebra-state/src/service/check/tests/vectors.rs @@ -3,12 +3,16 @@ use chrono::{DateTime, Duration}; use zebra_chain::{ + block::{merkle::AuthDataRoot, ChainHistoryBlockTxAuthCommitmentHash, CommitmentError}, + history_tree::HistoryTree, parameters::{Network, NetworkUpgrade}, + sapling, serialization::ZcashDeserializeInto, work::difficulty::ParameterDifficulty, }; use super::super::*; +use crate::tests::FakeChainHelper; #[test] fn test_orphan_consensus_check() { @@ -53,6 +57,78 @@ fn test_sequential_height_check() { .expect_err("parent height is way more, should panic"); } +#[test] +fn block_commitment_binds_precomputed_auth_data_root_to_block_body() { + let _init_guard = zebra_test::init(); + + let network = Network::Mainnet; + let parent_height = 1_687_106; + let (blocks, sapling_roots) = network.block_sapling_roots_map(); + + let parent = Arc::new( + blocks + .get(&parent_height) + .expect("NU5 parent test vector exists") + .zcash_deserialize_into::() + .expect("NU5 parent block deserializes"), + ); + let sapling_root = sapling::tree::Root::try_from( + **sapling_roots + .get(&parent_height) + .expect("NU5 parent Sapling root exists"), + ) + .expect("Sapling root vector is valid"); + let history_tree = HistoryTree::from_block( + &network, + parent.clone(), + &sapling_root, + &Default::default(), + &Default::default(), + ) + .expect("NU5 parent builds a history tree"); + + let child = parent.make_fake_child(); + let auth_data_root = child.auth_data_root(); + let hash_block_commitments = ChainHistoryBlockTxAuthCommitmentHash::from_commitments( + &history_tree + .hash() + .expect("NU5 parent history tree has a root"), + &auth_data_root, + ); + let block_commitment: [u8; 32] = hash_block_commitments.into(); + let child = child.set_block_commitment(block_commitment); + + block_commitment_is_valid_for_chain_history( + child.clone(), + &network, + &history_tree, + Some(auth_data_root), + ) + .expect("a matching precomputed auth data root is accepted"); + + let forged_auth_data_root = AuthDataRoot::from([0x42; 32]); + assert_ne!( + forged_auth_data_root, auth_data_root, + "the forged root must differ from the block body root" + ); + let error = block_commitment_is_valid_for_chain_history( + child, + &network, + &history_tree, + Some(forged_auth_data_root), + ) + .expect_err("a forged precomputed auth data root must be rejected"); + + assert!(matches!( + error, + ValidateContextError::InvalidBlockCommitment(CommitmentError::InvalidAuthDataRoot { + expected, + actual, + }) if expected == <[u8; 32]>::from(auth_data_root) + && actual == <[u8; 32]>::from(forged_auth_data_root) + )); +} + #[test] fn header_daa_accepts_valid_threshold_with_full_context() { let _init_guard = zebra_test::init(); diff --git a/zebra-state/src/service/finalized_state/tests/prop.rs b/zebra-state/src/service/finalized_state/tests/prop.rs index 74ebd0f5aaa..206657b6de2 100644 --- a/zebra-state/src/service/finalized_state/tests/prop.rs +++ b/zebra-state/src/service/finalized_state/tests/prop.rs @@ -140,7 +140,7 @@ fn all_upgrades_and_wrong_commitments_with_fake_activation_heights() -> Result<( if matches!( source.as_ref(), crate::ValidateContextError::InvalidBlockCommitment( - zebra_chain::block::CommitmentError::InvalidChainHistoryBlockTxAuthCommitment { .. } + zebra_chain::block::CommitmentError::InvalidAuthDataRoot { .. } ) ) ); From 517d6260abde7b6cdb3805e1819ab6cb531cda7d Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 2 Jul 2026 15:22:29 -0600 Subject: [PATCH 13/13] revert v12 --- zebra-state/src/service/check.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/zebra-state/src/service/check.rs b/zebra-state/src/service/check.rs index f7868eb5ef4..50a088b7a21 100644 --- a/zebra-state/src/service/check.rs +++ b/zebra-state/src/service/check.rs @@ -235,18 +235,8 @@ pub(crate) fn block_commitment_is_valid_for_chain_history( "the history tree of the previous block must exist \ since the current block has a ChainHistoryBlockTxAuthCommitment", ); - let auth_data_root = block.auth_data_root(); - if let Some(precomputed_auth_data_root) = precomputed_auth_data_root { - if precomputed_auth_data_root != auth_data_root { - return Err(ValidateContextError::InvalidBlockCommitment( - CommitmentError::InvalidAuthDataRoot { - actual: precomputed_auth_data_root.into(), - expected: auth_data_root.into(), - }, - )); - } - } - + 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, &auth_data_root,