Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changes/unreleased/zebra-state-Changed-20260826-174326.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
project: zebra-state
kind: Changed
body: 'Best-chain selection among non-finalized chains with equal cumulative
work now prefers the chain whose tip block was received first, per the Zcash
protocol specification ("To break ties between leaf blocks, a node will prefer
the block that it received first"), with the tip block hash as the final
tie-breaker. The block verifier stamps each block''s receipt time, and the
non-finalized `Chain` tracks its tip''s stamp for the comparison.'
time: 2026-08-26T17:43:26.380768625Z
5 changes: 5 additions & 0 deletions zebra-consensus/src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ where
}

fn call(&mut self, request: Request) -> Self::Future {
// Stamp the time the verifier received this block: the non-finalized state's
// `Chain::cmp` uses it to prefer the first-received chain on equal-work ties.
let received_time = std::time::Instant::now();

let mut state_service = self.state_service.clone();
let mut transaction_verifier = self.transaction_verifier.clone();
let network = self.network.clone();
Expand Down Expand Up @@ -366,6 +370,7 @@ where
height,
new_outputs,
transaction_hashes,
received_time: Some(received_time),
};

// Return early for proposal requests.
Expand Down
2 changes: 2 additions & 0 deletions zebra-state/src/arbitrary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ impl ContextuallyVerifiedBlock {
height,
new_outputs,
transaction_hashes,
received_time,
} = block.into();

Self {
Expand All @@ -114,6 +115,7 @@ impl ContextuallyVerifiedBlock {
spent_outputs: new_outputs,
transaction_hashes,
chain_value_pool_change: ValueBalance::zero(),
received_time,
}
}
}
29 changes: 29 additions & 0 deletions zebra-state/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::{
ops::{Add, Deref, DerefMut, RangeInclusive},
pin::Pin,
sync::Arc,
time::Instant,
};

use tower::{BoxError, Service, ServiceExt};
Expand Down Expand Up @@ -269,6 +270,22 @@ pub struct SemanticallyVerifiedBlock {
/// A precomputed list of the hashes of the transactions in this block,
/// in the same order as `block.transactions`.
pub transaction_hashes: Arc<[transaction::Hash]>,

/// The local time at which the block verifier received this block, if it passed
/// through the block verifier.
///
/// Used by `Chain::cmp` to prefer the first-received chain when cumulative works
/// are equal, per the protocol specification:
///
/// > To break ties between leaf blocks, a node will prefer the block that it received first.
///
/// <https://zips.z.cash/protocol/protocol.pdf#blockchain>
///
/// This is node-local, in-memory metadata, not consensus data. It is `None` for
/// blocks constructed outside the block verifier (checkpoint sync, backup restore,
/// tests); `Chain::cmp` treats unstamped blocks as received before any stamped
/// block, like `zcashd`'s disk-loaded blocks, which all share `nSequenceId` 0.
pub received_time: Option<Instant>,
}

/// A block ready to be committed directly to the finalized state with
Expand Down Expand Up @@ -329,6 +346,11 @@ pub struct ContextuallyVerifiedBlock {

/// The sum of the chain value pool changes of all transactions in this block.
pub(crate) chain_value_pool_change: ValueBalance<NegativeAllowed>,

/// The local time at which the block verifier received this block, if it passed
/// through the block verifier, copied from the [`SemanticallyVerifiedBlock`] it was
/// built from. See that type's `received_time` field for details.
pub(crate) received_time: Option<Instant>,
}

/// Wraps note commitment trees and the history tree together.
Expand Down Expand Up @@ -507,6 +529,7 @@ impl ContextuallyVerifiedBlock {
height,
new_outputs,
transaction_hashes,
received_time,
} = semantically_verified;

// This is redundant for the non-finalized state,
Expand All @@ -526,6 +549,7 @@ impl ContextuallyVerifiedBlock {
&utxos_from_ordered_utxos(spent_outputs),
deferred_pool_balance_change,
)?,
received_time,
})
}
}
Expand Down Expand Up @@ -561,6 +585,7 @@ impl SemanticallyVerifiedBlock {
height,
new_outputs,
transaction_hashes,
received_time: None,
}
}
}
Expand All @@ -586,6 +611,7 @@ impl From<Arc<Block>> for SemanticallyVerifiedBlock {
height,
new_outputs,
transaction_hashes,
received_time: None,
}
}
}
Expand All @@ -598,6 +624,7 @@ impl From<ContextuallyVerifiedBlock> for SemanticallyVerifiedBlock {
height: valid.height,
new_outputs: valid.new_outputs,
transaction_hashes: valid.transaction_hashes,
received_time: valid.received_time,
}
}
}
Expand All @@ -610,6 +637,8 @@ impl From<FinalizedBlock> for SemanticallyVerifiedBlock {
height: finalized.height,
new_outputs: finalized.new_outputs,
transaction_hashes: finalized.transaction_hashes,
// The finalized state does not track receipt times, so there is no stamp.
received_time: None,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions zebra-state/src/service/chain_tip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ impl From<SemanticallyVerifiedBlock> for ChainTipBlock {
height,
new_outputs: _,
transaction_hashes,
received_time: _,
} = prepared;

Self {
Expand Down
1 change: 1 addition & 0 deletions zebra-state/src/service/check/tests/utxo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ fn remaining_transaction_value_scales_linearly() {
height: Height(1),
new_outputs: HashMap::new(),
transaction_hashes: vec![transaction::Hash([0; 32]); TX_COUNT + 1].into(),
received_time: None,
};

let start = Instant::now();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ fn intra_block_self_spend_chain_in_finalized_state() {
height,
new_outputs,
transaction_hashes,
received_time: None,
};
let finalized = FinalizedBlock::from_checkpoint_verified(
CheckpointVerifiedBlock(semantically_verified),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ fn test_block_db_round_trip_with(
height: Height(0),
new_outputs,
transaction_hashes,
received_time: None,
})
};

Expand Down
6 changes: 3 additions & 3 deletions zebra-state/src/service/non_finalized_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ impl NonFinalizedState {
/// Finalize the lowest height block in the non-finalized portion of the best
/// chain and update all side-chains to match.
pub fn finalize(&mut self) -> FinalizableBlock {
// Chain::cmp uses the partial cumulative work, and the hash of the tip block.
// Neither of these fields has interior mutability.
// Chain::cmp uses the partial cumulative work, the tip's receipt time, and the
// hash of the tip block. None of these fields has interior mutability.
// (And when the tip block is dropped for a chain, the chain is also dropped.)
#[allow(clippy::mutable_key_type)]
let chains = mem::take(&mut self.chain_set);
Expand Down Expand Up @@ -707,7 +707,7 @@ impl NonFinalizedState {
/// Returns the first chain satisfying the given predicate.
///
/// If multiple chains satisfy the predicate, returns the chain with the highest difficulty.
/// (Using the tip block hash tie-breaker.)
/// (Using the first-received, then tip block hash tie-breakers.)
pub fn find_chain<P>(&self, mut predicate: P) -> Option<Arc<Chain>>
where
P: FnMut(&Chain) -> bool,
Expand Down
93 changes: 55 additions & 38 deletions zebra-state/src/service/non_finalized_state/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::{
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
ops::{Deref, DerefMut, RangeInclusive},
sync::Arc,
time::Instant,
};

use chrono::{DateTime, Utc};
Expand Down Expand Up @@ -243,6 +244,16 @@ pub struct ChainInner {
/// because they are common to all non-finalized chains.
pub(super) partial_cumulative_work: PartialCumulativeWork,

/// The local time at which the block verifier received the current tip block, if it
/// passed through the block verifier.
///
/// Mirrors the tip block's `received_time` stamp, so that [`Chain::cmp`] can break
/// cumulative-work ties by first receipt without looking the tip block up. Kept in
/// sync with the tip by [`Chain::push`] and [`Chain::pop_tip`]; `None` when the tip
/// is unstamped (checkpoint sync, backup restore) or the chain is empty. See
/// [`ContextuallyVerifiedBlock::received_time`] for details.
pub(super) received_time: Option<Instant>,

// Chain Pools
//
/// The chain value pool balances of the tip of this [`Chain`], including the block value pool
Expand Down Expand Up @@ -306,6 +317,7 @@ impl Chain {
ironwood_nullifiers: Default::default(),
partial_transparent_transfers: Default::default(),
partial_cumulative_work: Default::default(),
received_time: Default::default(),
history_trees_by_height: Default::default(),
chain_value_pools: finalized_tip_chain_value_pools,
block_info_by_height: Default::default(),
Expand Down Expand Up @@ -372,6 +384,7 @@ impl Chain {
self.update_chain_tip_with(&block)?;

tracing::debug!(block = %block.block, "adding block to chain");
self.received_time = block.received_time;
self.blocks.insert(block.height, block);

Ok(self)
Expand Down Expand Up @@ -1494,6 +1507,12 @@ impl Chain {
);

self.revert_chain_with(&block, RevertPosition::Tip);

// Keep the equal-work tie-break key in sync with the new tip's receipt stamp.
self.received_time = self
.tip_block()
.expect("blocks is populated, asserted above")
.received_time;
}

/// Return the non-finalized tip height for this chain.
Expand Down Expand Up @@ -2623,17 +2642,20 @@ impl UpdateWith<(ValueBalance<NegativeAllowed>, Height, usize)> for Chain {
impl Ord for Chain {
/// Chain order for the [`NonFinalizedState`][1]'s `chain_set`.
///
/// Chains with higher cumulative Proof of Work are [`Ordering::Greater`],
/// breaking ties using the tip block hash.
///
/// Despite the consensus rules, Zebra uses the tip block hash as a
/// tie-breaker. Zebra blocks are downloaded in parallel, so download
/// timestamps may not be unique. (And Zebra currently doesn't track
/// download times, because [`Block`](block::Block)s are immutable.)
///
/// This departure from the consensus rules may delay network convergence,
/// for as long as the greater hash belongs to the later mined block.
/// But Zebra nodes should converge as soon as the tied work is broken.
/// Chains with higher cumulative Proof of Work are [`Ordering::Greater`].
/// Ties are broken by preferring the chain whose tip block was received
/// first (the earlier [`received_time`][3], which mirrors the stamp set by
/// the block verifier on the tip block), implementing the consensus rule
/// quoted below. Sibling blocks on Zcash always have equal work (`nBits`
/// is fully determined by their ancestors), so without first-received
/// preference, an already-adopted tip could be displaced by an equal-work
/// sibling arriving arbitrarily later.
///
/// The tip block hash remains as a final tie-breaker when receipt times
/// are equal, so that the order is total. Chains whose tip has no receipt
/// stamp (checkpoint sync, backup restore) compare as if received before
/// any stamped block, like `zcashd`'s disk-loaded blocks, which all share
/// `nSequenceId` 0.
///
/// "At a given point in time, each full validator is aware of a set of candidate blocks.
/// These form a tree rooted at the genesis block, where each node in the tree
Expand All @@ -2658,40 +2680,35 @@ impl Ord for Chain {
/// # Correctness
///
/// `Chain::cmp` is used in a `BTreeSet`, so the fields accessed by `cmp` must not have
/// interior mutability.
/// interior mutability. `received_time` mirrors the tip block's stamp, which is set by
/// the block verifier before the block is pushed onto a chain and never modified
/// afterwards; the field itself is only updated by [`Chain::push`] and
/// [`Chain::pop_tip`], which run while the chain is removed from the `chain_set`.
///
/// `cmp` returns [`Ordering::Equal`] only when both the cumulative work and
/// the tip hash match. The [`NonFinalizedState::chain_set`][2] is a
/// `BTreeSet<Arc<Chain>>`, so an attempt to insert a chain that compares
/// equal to an existing entry is a no-op rather than a process-fatal panic.
/// Callers that need to replace such a chain must remove the existing entry
/// first.
/// the tip hash match. Two chains with the same tip hash share the stored tip
/// block (and therefore its receipt time), so they always compare equal. The
/// [`NonFinalizedState::chain_set`][2] is a `BTreeSet<Arc<Chain>>`, so an
/// attempt to insert a chain that compares equal to an existing entry is a
/// no-op rather than a process-fatal panic. Callers that need to replace
/// such a chain must remove the existing entry first.
///
/// [1]: super::NonFinalizedState
/// [2]: super::NonFinalizedState::chain_set
/// [3]: ChainInner::received_time
fn cmp(&self, other: &Self) -> Ordering {
if self.partial_cumulative_work != other.partial_cumulative_work {
self.partial_cumulative_work
.cmp(&other.partial_cumulative_work)
} else {
let self_hash = self
.blocks
.values()
.last()
.expect("always at least 1 element")
.hash;

let other_hash = other
.blocks
.values()
.last()
.expect("always at least 1 element")
.hash;

self.partial_cumulative_work
.cmp(&other.partial_cumulative_work)
// Prefer the first-received tip: an EARLIER receipt time is a BETTER chain,
// so it must compare `Greater` (the best chain is the greatest in the set).
.then_with(|| self.received_time.cmp(&other.received_time).reverse())
// This comparison is a tie-breaker within the local node, so it does not need to
// be consistent with the ordering on `ExpandedDifficulty` and `block::Hash`.
self_hash.0.cmp(&other_hash.0)
}
.then_with(|| {
self.non_finalized_tip_hash()
.0
.cmp(&other.non_finalized_tip_hash().0)
})
}
}

Expand All @@ -2703,7 +2720,7 @@ impl PartialOrd for Chain {

impl PartialEq for Chain {
/// Chain equality for [`NonFinalizedState::chain_set`][1], using proof of
/// work, then the tip block hash as a tie-breaker.
/// work, then the chain's receipt time, then the tip block hash.
///
/// Two chains with the same cumulative work and tip hash are equal; the
/// `chain_set` uses this to keep tip hashes unique.
Expand Down
Loading
Loading