Skip to content

fix(state): prefer the first-received chain on equal-work ties - #11341

Open
arya2 wants to merge 4 commits into
mainfrom
block-sequencing
Open

fix(state): prefer the first-received chain on equal-work ties#11341
arya2 wants to merge 4 commits into
mainfrom
block-sequencing

Conversation

@arya2

@arya2 arya2 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Motivation

The Zcash protocol specification breaks ties between candidate chain tips with
equal cumulative work by preferring the block the node received first:

To break ties between leaf blocks, a node will prefer the block that it received first.

Sibling blocks on Zcash always have equal work (nBits is fully determined by
their ancestors), so this is the common case for races at the chain tip. Zebra
previously broke equal-work ties by tip block hash, which let a later-arriving
equal-work sibling displace the already-adopted tip, causing avoidable reorgs
and divergence from zcashd's nSequenceId first-received behavior.

Closes #11240.

Solution

  • The block verifier now stamps each block with the local time it was received
    (zebra-consensus/src/block.rs), plumbed through SemanticallyVerifiedBlock
    into ContextuallyVerifiedBlock. This is node-local, in-memory metadata:
    blocks from checkpoint sync, backup restore, and tests are unstamped (None).
  • The non-finalized Chain tracks its tip's stamp in a new
    Chain::received_time field, kept in sync by Chain::push and
    Chain::pop_tip (which also covers fork, invalidate_block, and
    reconsideration replays).
  • Chain::cmp breaks cumulative-work ties by preferring the earlier receipt
    time, keeping the tip block hash as the final tie-breaker so the order stays
    total. Unstamped chains compare as received before stamped ones, like
    zcashd's disk-loaded blocks, which all share nSequenceId 0. Reconsidered
    chains keep their original stamps, matching zcashd's behavior across
    invalidate/reconsider.

Tests

  • New regression test equal_work_ties_prefer_first_received: commits two
    equal-work siblings with controlled receipt times in both receipt orders and
    checks the first-received sibling stays best both times (one order fails
    under hash tie-breaking by construction), then checks strictly more work
    still overrides receipt order.
  • New test chain_received_time_tracks_tip: the chain's receipt time mirrors
    its tip block's stamp across pushes and fork truncation.
  • cargo test -p zebra-state --lib: 157 passed.
  • cargo fmt --all -- --check, cargo clippy -p zebra-state --all-targets -- -D warnings, and cargo doc -p zebra-state --no-deps are all clean.

Specifications & References

AI Disclosure

  • No AI tools were used in this PR
  • AI tools were used: Kimi Code CLI for the Chain::received_time refactor,
    the tracking test, the changelog fragment, the doc-comment updates, and code
    review. The initial receipt-time feature commit was written by the branch
    author.

PR Checklist

  • The PR title follows conventional commits format: type(scope): description
  • The PR follows the contribution guidelines.
  • This change was discussed in an issue or with the team beforehand.
  • The solution is tested.
  • The documentation and changelogs are up to date.

arya2 added 3 commits August 21, 2026 21:41
…work tie-break

Pull the receipt-time tie-break key up from the tip block into a new
Chain::received_time field, kept in sync with the tip by push and
pop_tip, so Chain::cmp reads its ordering keys directly off the chain
instead of looking up the tip block. The tip block hash remains the
final tie-breaker when receipt times are equal.
@arya2 arya2 self-assigned this Aug 26, 2026
@arya2 arya2 added the consensus Consensus-critical code: validation, cryptography, script label Aug 26, 2026
@v12-auditor

v12-auditor Bot commented Aug 26, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found one issue worth reviewing.

Open the full results here.

FindingSeverityDetails
F-252752 🟠 High
Replica drops first-received fork choice

The primary node’s equal-work fork choice now depends on received_time, but the non-finalized-state replication stream transports only each block’s hash and serialized bytes. NonFinalizedBlocksListener streams every tracked chain in primary best-first order, yet TrustedChainSync reconstructs every received block through SemanticallyVerifiedBlock::with_hash, which unconditionally sets received_time to None. If equal-work sibling A was received first by the primary while sibling B has the greater comparator hash, the primary selects A, but the replica commits both unstamped branches and falls through to the hash tie-breaker, selecting B. Stream ordering does not preserve the decision because both branches remain in the replica’s BTreeSet, and known-tip filtering carries no receipt metadata. The replica then publishes B as its best non-finalized tip and exposes best-chain reads from a branch the trusted primary did not select.

Analyzed six files, diff bd97879...3cc4a8a.

@conradoplg conradoplg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I like this one better.

I think this warrants a zebrad changelog entry.

Claude findings I agree with below. For C I think it would be best to avoid redundancy, but not a huge deal, your call.


A. The RPC mirror gets no first-received preference at all

zebra-rpc/src/sync.rs:375 and :495 build blocks with SemanticallyVerifiedBlock::with_hash, which sets received_time: None. Every chain in TrustedChainSync's non-finalized state therefore has received_time ==
None, all ties fall through to tip hash — exactly main's old behaviour — while the node it mirrors uses receipt time.

So on an equal-work fork, zebrad's getbestblockhash and the Zaino-facing mirror can name different tips. This is new: before this branch both used hash and always agreed. It's transient (resolves as soon as
work breaks the tie) but it's a real inconsistency between two views of the same node.

Cheap fix: stamp Some(Instant::now()) in sync.rs where the block is constructed. NonFinalizedBlocksListener streams best-chain-first (chain_iter() is chain_set.iter().rev()), so arrival order at the mirror
reproduces the source node's preference.

C. The denormalized Chain::received_time is correct today, but it's a new unenforced invariant

It duplicates the tip block's stamp and is a BTreeSet ordering key, so any path that changes a tip without updating it corrupts the set. I traced all of them:

  • push — sets it after the fallible update_chain_tip_with, before inserting ✓
  • pop_tip — sets it from the new tip ✓
  • fork — loops pop_tip ✓
  • Chain::invalidate_block — fork + pop_tip ✓
  • reconsider_block — re-pushes stored blocks through push ✓
  • pop_root — doesn't change the tip. Single-block case leaves the field stale, but the chain is then empty and finalize() guards re-insertion with !is_empty() ✓

The new expect("blocks is populated, asserted above") in pop_tip can't fire independently: the pre-existing assert!(!self.blocks.is_empty()) runs first, so the message is accurate.

So: no defect. But it's worth asking whether the denormalization earns its keep. cmp previously did one blocks.values().next_back() — a BTreeMap lookup on a chain, called O(log n) times per insert with n ≤ 10
forks. That's negligible, and reading the tip directly would make the invariant unfalsifiable by construction. Your call, but the trade is a correctness liability for a perf gain that won't be measurable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

consensus Consensus-critical code: validation, cryptography, script

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Zebra reorgs away from an already-adopted tip on equal-work ties, using tip hash instead of first-seen

2 participants