From cbc7d222ad95721ff93103ef97514dc1a1704525 Mon Sep 17 00:00:00 2001 From: roman Date: Tue, 30 Jun 2026 20:34:36 -0600 Subject: [PATCH] feat(state): authenticate VCT-fast blocks from the successor header The finalized committer authenticates a verified-commitment-trees fast block's peer-supplied note-commitment roots against its successor's header commitment. Previously it waited for the successor block *body* to be buffered; since header sync runs far ahead of the body frontier, that coupled the committer to the body-download pipeline and could deadlock at a checkpoint/VCT boundary (the writer holds block N awaiting N+1's body, while N+1's body cannot be applied until N commits). Authenticate against the already-committed successor *header* instead. At NU5+ the successor's `hashBlockCommitments` binds the MMR root together with the successor's own ZIP-244 auth-data root, so that auth-data root is carried per-height over header sync and stored in the serving index, letting the committer verify without the successor body. Propagation: - carry `auth_data_root` in `BlockCommitmentRoots` (wire) and the `commitment_roots_by_height` serving-index value (64->96 bytes, backward compatible), consolidated under state DB format 27.3.0; - bump the Zakura header-sync stream format to v5 (breaking wire change). Consumption: - extract a header-driven `header_commitment_is_valid_for_chain_history` core; the block-based check delegates to it (behavior preserved); - `CommitmentRootVerification::header_only` becomes body-free (header + height + auth-data root); - the writer sources the successor from the buffered look-ahead or the committed header chain, and only waits at the genuine header tip. --- zebra-replay-bench/src/apply.rs | 8 +- zebra-state/CHANGELOG.md | 9 +- zebra-state/src/service/check.rs | 69 ++++++--- zebra-state/src/service/finalized_state.rs | 51 +++++-- .../finalized_state/commitment_aux_verify.rs | 141 +++++++++++++++--- .../src/service/finalized_state/tests/prop.rs | 44 +++--- zebra-state/src/service/tests.rs | 1 + zebra-state/src/service/write.rs | 39 +++-- 8 files changed, 276 insertions(+), 86 deletions(-) diff --git a/zebra-replay-bench/src/apply.rs b/zebra-replay-bench/src/apply.rs index 2a202f34ba4..f4f0f648014 100644 --- a/zebra-replay-bench/src/apply.rs +++ b/zebra-replay-bench/src/apply.rs @@ -135,7 +135,13 @@ pub fn run( (successor, auth) } }; - Some((successor, Some(auth))) + Some(( + successor.header.clone(), + successor + .coinbase_height() + .expect("replay blocks have a coinbase height"), + auth, + )) } else { None }; diff --git a/zebra-state/CHANGELOG.md b/zebra-state/CHANGELOG.md index 45b7d3376f3..e86919c9b18 100644 --- a/zebra-state/CHANGELOG.md +++ b/zebra-state/CHANGELOG.md @@ -29,8 +29,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 supplied roots against its own header commitments during header sync, without downloading the block body. Rows grow to 152 bytes with a backward-compatible `FromDisk`; still consolidated under `27.3.0`. Carried in the (already breaking, still version 5) Zakura header-sync stream - format, with `auth_data_root` serialized last. This is the data-carrying half only — nothing - consumes the new fields yet. + format, with `auth_data_root` serialized last. +- The finalized committer now authenticates a verified-commitment-trees fast block against its + already-committed successor *header* (the header chain runs far ahead of the body frontier) + plus that stored auth-data root, instead of waiting for the successor *body*, removing a + commit-pipeline stall at checkpoint/VCT boundaries. Heights whose serving-index row predates + the auth-data root (an earlier pre-release build, zero root) fall back to the body-wait commit + path until re-served. - Added the `vct_upgrade_metadata` column family, recording the upgrade height `U` (the lowest height this binary committed). `tree_aux` root serving now stitches the per-height trees below `U` with the serving index at and above `U`, so a node that upgraded mid-chain serves a range diff --git a/zebra-state/src/service/check.rs b/zebra-state/src/service/check.rs index e5281b3af12..d815695db1d 100644 --- a/zebra-state/src/service/check.rs +++ b/zebra-state/src/service/check.rs @@ -165,16 +165,24 @@ where Ok(()) } -/// Check that `block` is contextually valid for `network`, using -/// the `history_tree` up to and including the previous block. -#[tracing::instrument(skip(block, history_tree))] -pub(crate) fn block_commitment_is_valid_for_chain_history( - block: Arc, +/// Check that a block's header commitment is valid for `network`, using the +/// `history_tree` up to and including the previous block, the block's `height`, and a +/// resolved ZIP-244 `auth_data_root`. +/// +/// This is the body-free core of [`block_commitment_is_valid_for_chain_history`]: it +/// reads only header fields, so the verified-commitment-trees fast path can confirm a +/// block's supplied note-commitment roots against its already-committed *successor's* +/// header before the successor's body has been downloaded. The `auth_data_root` is the +/// successor's own ZIP-244 auth-data root, carried over header sync (it cannot be +/// recomputed without the successor body). +pub(crate) fn header_commitment_is_valid_for_chain_history( + header: &block::Header, + height: block::Height, network: &Network, history_tree: &HistoryTree, - precomputed_auth_data_root: Option, + auth_data_root: AuthDataRoot, ) -> Result<(), ValidateContextError> { - match block.commitment(network)? { + match header.commitment(network, height)? { block::Commitment::PreSaplingReserved(_) | block::Commitment::FinalSaplingRoot(_) | block::Commitment::ChainHistoryActivationReserved => { @@ -200,7 +208,7 @@ pub(crate) fn block_commitment_is_valid_for_chain_history( // // https://zips.z.cash/protocol/protocol.pdf#blockheader // - // The network is checked by [`Block::commitment`] above; it will only + // The network is checked by [`Header::commitment`] above; it will only // return the chain history root if it's Heartwood or Canopy. let history_tree_root = history_tree .hash() @@ -222,25 +230,18 @@ pub(crate) fn block_commitment_is_valid_for_chain_history( // > [NU5 onward] hashBlockCommitments MUST be set to the value of // > hashBlockCommitments for this block, as specified in [ZIP-244]. // - // The network is checked by [`Block::commitment`] above; it will only + // The network is checked by [`Header::commitment`] above; it will only // return the block commitments if it's NU5 onward. 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, @@ -261,6 +262,38 @@ pub(crate) fn block_commitment_is_valid_for_chain_history( } } +/// Check that `block` is contextually valid for `network`, using +/// the `history_tree` up to and including the previous block. +#[tracing::instrument(skip(block, history_tree))] +pub(crate) fn block_commitment_is_valid_for_chain_history( + block: Arc, + network: &Network, + history_tree: &HistoryTree, + precomputed_auth_data_root: Option, +) -> Result<(), ValidateContextError> { + // The original `Block::commitment` errored with `MissingBlockHeight` for a block with + // no coinbase height; preserve that here. + let height = block + .coinbase_height() + .ok_or(CommitmentError::MissingBlockHeight { + block_hash: block.hash(), + })?; + + // 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. Only the + // NU5+ commitment actually consumes it. + 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, + ) +} + /// Returns `ValidateContextError::OrphanedBlock` if the height of the given /// block is less than or equal to the finalized tip height. fn block_is_not_orphaned( diff --git a/zebra-state/src/service/finalized_state.rs b/zebra-state/src/service/finalized_state.rs index dee5aa73c0d..3186a19f894 100644 --- a/zebra-state/src/service/finalized_state.rs +++ b/zebra-state/src/service/finalized_state.rs @@ -23,7 +23,7 @@ use std::{ }; use zebra_chain::{ - block::{self, merkle::AuthDataRoot, Block}, + block::{self, merkle::AuthDataRoot}, ironwood, orchard, parallel::tree::{BlockNotePrecompute, NoteCommitmentTrees}, parameters::Network, @@ -780,6 +780,30 @@ impl FinalizedState { self.db.network() } + /// Source the successor of `height` — its header, height, and ZIP-244 auth-data root — + /// from the already-committed header chain, so the committer can authenticate + /// `height`'s supplied verified-commitment-trees roots without the successor *body*. + /// + /// Header sync commits headers (and their per-height roots, including the auth-data + /// root) far ahead of the body frontier, so the successor header is normally already + /// present. Returns `None` when it is not (e.g. at the header tip, or before a v2 + /// header-sync peer has served the auth-data root for that height), in which case the + /// caller waits for the successor body instead. + pub(crate) fn successor_header_auth_for( + &self, + height: block::Height, + ) -> Option<(Arc, block::Height, AuthDataRoot)> { + let successor = (height + 1)?; + let header = self.db.zakura_header(successor)?; + let auth_data_root = self + .db + .zakura_header_commitment_roots_by_height_range(successor..=successor) + .first() + .filter(|root| root.height == successor) + .map(|root| root.auth_data_root)?; + Some((header, successor, auth_data_root)) + } + /// Commit a checkpoint-verified block to the state. /// /// It's the caller's responsibility to ensure that blocks are committed in @@ -789,7 +813,7 @@ impl FinalizedState { ordered_block: QueuedCheckpointVerified, prev_note_commitment_trees: Option, note_precompute: Option, - next_checkpoint: Option<(Arc, Option)>, + next_checkpoint: Option<(Arc, block::Height, AuthDataRoot)>, ) -> Result< (CheckpointVerifiedBlock, NoteCommitmentTrees), (QueuedCheckpointVerified, CommitCheckpointVerifiedError), @@ -848,12 +872,14 @@ 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)>, + // The successor block's header, height, and ZIP-244 auth-data root, used to + // verify this block's fixture roots before the fast path trusts them. Only the + // successor *header* and auth-data root are needed (not its body), so this can be + // sourced from the committed header chain when the successor body has not been + // downloaded yet. `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, block::Height, AuthDataRoot)>, source: &str, ) -> Result<(block::Hash, NoteCommitmentTrees), CommitCheckpointVerifiedError> { let ( @@ -948,10 +974,11 @@ impl FinalizedState { is_prevalidated, ), ]; - if let Some((next_block, next_auth)) = &next_checkpoint { + if let Some((next_header, next_height, next_auth)) = &next_checkpoint { verification_items.push( commitment_aux_verify::CommitmentRootVerification::header_only( - next_block.clone(), + next_header.clone(), + *next_height, *next_auth, ), ); @@ -973,10 +1000,10 @@ impl FinalizedState { self.vct_reject_supplied_root(height, error) })?; - if let Some((next_block, _next_auth)) = &next_checkpoint { + if let Some((next_header, _next_height, _next_auth)) = &next_checkpoint { self.vct_prevalidated_next = Some(( (height + 1).expect("checkpoint block heights are valid"), - next_block.hash(), + next_header.hash(), )); } else if self .vct 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..29ecf73ba09 100644 --- a/zebra-state/src/service/finalized_state/commitment_aux_verify.rs +++ b/zebra-state/src/service/finalized_state/commitment_aux_verify.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use zebra_chain::{ - block::{merkle::AuthDataRoot, Block, Height}, + block::{merkle::AuthDataRoot, Block, Header, Height}, history_tree::HistoryTree, orchard, parameters::{Network, NetworkUpgrade}, @@ -29,9 +29,16 @@ 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) header: Arc
, + pub(crate) height: Height, + /// The full block, needed only to fold this block's supplied roots into the MMR + /// (`tree.push` builds the leaf from the body). `None` for a header-only successor + /// confirmation item, whose body may not be downloaded yet. + pub(crate) block: Option>, pub(crate) roots: Option<(sapling::tree::Root, orchard::tree::Root)>, - pub(crate) precomputed_auth_data_root: Option, + /// This block's own ZIP-244 auth-data root — the co-input to its NU5+ header + /// commitment. Resolved at construction because the body may be absent later. + pub(crate) auth_data_root: AuthDataRoot, pub(crate) skip_parent_check: bool, } @@ -43,22 +50,35 @@ impl CommitmentRootVerification { precomputed_auth_data_root: Option, skip_parent_check: bool, ) -> Self { + let height = block + .coinbase_height() + .expect("checkpoint-verified blocks have a coinbase height"); + let auth_data_root = precomputed_auth_data_root.unwrap_or_else(|| block.auth_data_root()); CommitmentRootVerification { - block, + header: block.header.clone(), + height, + block: Some(block), roots: Some((sapling_root, orchard_root)), - precomputed_auth_data_root, + auth_data_root, skip_parent_check, } } + /// A confirmation-only item built from a block's already-committed *successor*: its + /// header authenticates the predecessor's folded roots, and `auth_data_root` is the + /// successor's own ZIP-244 auth-data root (carried over header sync), so no successor + /// body is needed. pub(crate) fn header_only( - block: Arc, - precomputed_auth_data_root: Option, + header: Arc
, + height: Height, + auth_data_root: AuthDataRoot, ) -> Self { CommitmentRootVerification { - block, + header, + height, + block: None, roots: None, - precomputed_auth_data_root, + auth_data_root, skip_parent_check: false, } } @@ -168,24 +188,25 @@ where { for item in items { let CommitmentRootVerification { + header, + height, block, roots, - precomputed_auth_data_root, + 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. + // i.e. against every root already folded in. Driven by the header (+ height + + // auth-data root) so a header-only successor item can confirm the predecessor + // without its own body. if !skip_parent_check { - check::block_commitment_is_valid_for_chain_history( - block.clone(), + check::header_commitment_is_valid_for_chain_history( + &header, + height, network, &tree, - precomputed_auth_data_root, + auth_data_root, ) .map_err(|error| (height, error))?; } @@ -194,6 +215,9 @@ where continue; }; + // An item that folds roots into the MMR always carries its block body. + let block = block.expect("a root-folding verification item carries its block body"); + 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) @@ -394,6 +418,87 @@ mod tests { ); } + /// The successor that confirms a block's folded roots can be a *header-only* item — + /// its header plus ZIP-244 auth-data root, with no body. This is the body-free path + /// the committer uses to authenticate a block against its already-committed successor + /// header before the successor's body has been downloaded. The folded root is still + /// confirmed, and a wrong folded root is still rejected — purely from the successor + /// header. + #[test] + fn header_only_successor_confirms_and_rejects_wrong_root() { + 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(); + let next_height = Height(activation + 1); + let next_auth = next_block.auth_data_root(); + + // Positive: `with_roots(act)` folds the real root, and a *header-only* successor + // item (no body) confirms it. + let ok_items = vec![ + CommitmentRootVerification::with_roots( + act_block.clone(), + act_root, + empty_orchard_root, + None, + false, + ), + CommitmentRootVerification::header_only( + next_block.header.clone(), + next_height, + next_auth, + ), + ]; + verify_commitment_roots(&Mainnet, empty_history_tree(), ok_items) + .expect("a header-only successor confirms the real folded root"); + + // Negative: a wrong folded root is rejected by the header-only successor alone. + assert_ne!(act_root, next_root, "test needs two distinct roots"); + let bad_items = vec![ + CommitmentRootVerification::with_roots( + act_block, + next_root, + empty_orchard_root, + None, + false, + ), + CommitmentRootVerification::header_only( + next_block.header.clone(), + next_height, + next_auth, + ), + ]; + let (fail_height, _error) = + verify_commitment_roots(&Mainnet, empty_history_tree(), bad_items) + .expect_err("a wrong folded root must be rejected by the header-only successor"); + assert_eq!( + fail_height.0, + activation + 1, + "the wrong root at H is detected at the header-only successor H+1" + ); + } + /// Real NU5/V2-range verification over the POC range (1,707,211..=1,717,210), /// exercising the actual [`verify_commitment_roots`] on production data. /// diff --git a/zebra-state/src/service/finalized_state/tests/prop.rs b/zebra-state/src/service/finalized_state/tests/prop.rs index 261edd3db72..7052398a6f8 100644 --- a/zebra-state/src/service/finalized_state/tests/prop.rs +++ b/zebra-state/src/service/finalized_state/tests/prop.rs @@ -347,7 +347,7 @@ 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)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct fast") .expect("verified fast commit succeeds"); } @@ -365,7 +365,7 @@ fn vct_fast_path_matches_legacy_and_rejects_wrong_roots() -> Result<()> { 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)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); no_successor .commit_finalized_direct(cv.into(), None, None, next, "vct no-successor seed") .expect("verified fast commit succeeds with successor"); @@ -397,7 +397,7 @@ 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)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); if bad.commit_finalized_direct(cv.into(), None, None, next, "vct bad").is_err() { error_height = Some(i); break; @@ -428,7 +428,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)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); if bad_orchard.commit_finalized_direct(cv.into(), None, None, next, "vct bad orchard").is_err() { orchard_error_height = Some(i); break; @@ -513,7 +513,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)); + let next = (i < last).then(|| (blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); match fast.commit_finalized_direct(cv.into(), None, None, next, "vct hole fast") { Ok(_) => {} Err(error) => { @@ -603,14 +603,14 @@ 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)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "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 next = Some((blocks[hole + 1].block.header.clone(), blocks[hole + 1].block.coinbase_height().unwrap(), blocks[hole + 1].block.auth_data_root())); let result = fast.commit_finalized((cv, rsp_tx), None, None, next); let Err((returned_block, error)) = result else { panic!("missing frozen-frontier root should return the queued block for retry"); @@ -715,7 +715,7 @@ fn vct_peer_source_defers_unverifiable_tip_root_until_successor() -> Result<()> // 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)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct defer pre-tip") .expect("pre-tip fast commits succeed"); } @@ -755,7 +755,7 @@ fn vct_peer_source_defers_unverifiable_tip_root_until_successor() -> Result<()> // 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)); + let next = Some((blocks[tip_target + 1].block.header.clone(), blocks[tip_target + 1].block.coinbase_height().unwrap(), blocks[tip_target + 1].block.auth_data_root())); 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!( @@ -863,14 +863,14 @@ fn vct_peer_source_bad_root_refill_commits_same_height() -> Result<()> { for i in 0..target { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); 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 next = Some((blocks[target + 1].block.header.clone(), blocks[target + 1].block.coinbase_height().unwrap(), blocks[target + 1].block.auth_data_root())); 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"); @@ -1002,7 +1002,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)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct reopen fast") .expect("verified fast commit succeeds"); } @@ -1036,7 +1036,7 @@ 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 next = Some((blocks[hole + 1].block.header.clone(), blocks[hole + 1].block.coinbase_height().unwrap(), blocks[hole + 1].block.auth_data_root())); let error = reopened .commit_finalized_direct(cv.into(), None, None, next, "vct reopen hole") .expect_err("a frozen-frontier hole must refuse after reopen, not recompute"); @@ -1057,7 +1057,7 @@ 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)); + let next = Some((blocks[hole + 1].block.header.clone(), blocks[hole + 1].block.coinbase_height().unwrap(), blocks[hole + 1].block.auth_data_root())); reopened .commit_finalized_direct(cv.into(), None, None, next, "vct reopen refill") .expect("the height commits once its root is fetched"); @@ -1155,7 +1155,7 @@ fn vct_fast_sync_handoff_marks_database_and_resumes() -> Result<()> { 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)); + let next = (i < last).then(|| (blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct fast handoff") .expect("verified fast commit succeeds"); } @@ -1232,7 +1232,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)); + let next = (i < last).then(|| (blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); match bad_handoff.commit_finalized_direct(cv.into(), None, None, next, "vct bad handoff") { Ok(_) => {} Err(error) => { @@ -1347,7 +1347,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)); + let next = (i < handoff_index).then(|| (blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct switch fast prefix") .expect("verified fast prefix commits"); } @@ -1418,7 +1418,7 @@ 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)); + let next = (i < post_handoff_tip).then(|| (blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast_suffix .commit_finalized_direct(cv.into(), None, None, next, "vct switch fast suffix") .expect("fast suffix commits after manual prefix"); @@ -1506,7 +1506,7 @@ fn vct_dedup_skips_redundant_check_and_guards_stale_cache() -> Result<()> { // 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)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct dedup fast") .expect("verified fast commit succeeds"); }; @@ -1629,7 +1629,7 @@ fn vct_clear_prevalidation_cache_disarms_skip_then_dedup_resumes() -> Result<()> let commit = |fast: &mut FinalizedState, i: usize| { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct clear fast") .expect("verified fast commit succeeds"); }; @@ -1756,7 +1756,7 @@ fn vct_db_produced_payload_round_trips_to_byte_identical_state() -> Result<()> { ); for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct round-trip fast") .expect("verified fast commit from DB-produced roots succeeds"); } @@ -1882,7 +1882,7 @@ fn vct_peer_source_filled_incrementally_drives_byte_identical_state() -> Result< fast.enable_vct_fast_source(Box::new(peer_source), true); for i in 0..=last { let cv = CheckpointVerifiedBlock::from(blocks[i].block.clone()); - let next = Some((blocks[i + 1].block.clone(), None)); + let next = Some((blocks[i + 1].block.header.clone(), blocks[i + 1].block.coinbase_height().unwrap(), blocks[i + 1].block.auth_data_root())); fast.commit_finalized_direct(cv.into(), None, None, next, "vct peer-source fast") .expect("verified fast commit from peer-source roots succeeds"); } 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..9ff240273ac 100644 --- a/zebra-state/src/service/write.rs +++ b/zebra-state/src/service/write.rs @@ -485,17 +485,34 @@ 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() + // A non-handoff VCT fast block's supplied roots are authenticated by its + // successor's header (a block's roots are only committed by the next block's + // header). Source the successor's header, height, and ZIP-244 auth-data root + // from the buffered look-ahead when present, otherwise from the already-committed + // header chain — header sync runs far ahead of the body frontier, so the + // committer authenticates against the successor *header* and never has to wait + // on the successor *body*. + let next_checkpoint = match finalized_lookahead.front() { + Some(next) => Some(( + next.0.block.header.clone(), + next.0.height, + next.0 + .auth_data_root + .unwrap_or_else(|| next.0.block.auth_data_root()), + )), + None => finalized_state.successor_header_auth_for(ordered_block.0.height), + }; + + // Only when neither the successor body nor its committed header is available + // (we are at the header tip) keep this block local and wait, instead of + // surfacing a checkpoint commit error through the invalid-block reset path. + if next_checkpoint.is_none() && 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" + "VCT: deferring fast checkpoint commit until the successor header is committed" ); retry_finalized_block = Some(ordered_block); std::thread::park_timeout(Duration::from_millis(10)); @@ -538,13 +555,9 @@ 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)); + // `next_checkpoint` (the successor header + height + auth-data root used to + // authenticate this block's fixture roots) was resolved above, from the + // buffered successor or the committed header chain. let prev_note_commitment_trees = prev_finalized_note_commitment_trees.take(); let prev_note_commitment_trees_for_retry = prev_note_commitment_trees.clone();