Skip to content

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

Open
aphelionz wants to merge 1 commit into
ZcashFoundation:mainfrom
aphelionz:first-seen-tiebreak
Open

fix(state): prefer the first-received chain on equal-work ties#11245
aphelionz wants to merge 1 commit into
ZcashFoundation:mainfrom
aphelionz:first-seen-tiebreak

Conversation

@aphelionz

Copy link
Copy Markdown
Contributor

Motivation

Closes #11240.

The protocol spec, quoted in Chain::cmp's own docs: "To break ties between leaf blocks, a node will prefer the block that it received first." Zebra instead compares raw tip-hash bytes, and best_chain() is re-evaluated on every commit, so an already-validated, already-announced tip is displaced whenever an equal-work sibling with a higher-sorting hash arrives, no matter how much later. On Zcash, sibling blocks always tie on work (nBits is fully determined by ancestors) and equal-length fork subtrees stay tied, so this fires on every sibling race. #11240 documents mainnet blocks orphaned this way despite winning propagation by ~0.4s; the hash rule also removes the race penalty for block withholding, since a sibling released only after seeing a competitor's block still wins about half of ties, deterministically across all zebra nodes.

On the documented rationale for the current behavior (parallel downloads make receipt timestamps non-unique, and Zebra doesn't track download times): this change uses no timestamps. It stamps a monotonic sequence at the moment a block is committed to the non-finalized state, which is unique by construction, and makes commit order match arrival order in the one place they could diverge (children queued behind a missing parent). The "consistent across restarts and between nodes" side-effect of hash ordering is deliberately given up: that property is exactly what makes every zebra node prefer the same later-arriving sibling.

Solution

The zcashd nSequenceId analogue, adapted to zebra-state:

  • ContextuallyVerifiedBlock gains receipt_sequence: u64, stamped once in NonFinalizedState::validate_and_commit from a per-instance counter (the analogue of ReceivedBlockTransactions). Node-local, in-memory, never persisted; backup-restored blocks re-stamp in replay order, matching zcashd's disk-loaded blocks all sharing id 0. The stamp rides through fork/push/pop/invalidate, and reconsider_block replays stored blocks, so receipt order survives invalidate/reconsider.
  • Chain::cmp orders by cumulative work (unchanged), then the tip's receipt sequence (lower preferred), then the existing tip-hash comparison last. The hash fallback keeps the order total for unstamped (test-built, sequence-0) chains and keeps same-tip chains comparing Equal, preserving the Handle invalidateblock / reconsiderblock edge cases without panicking聽#10586 invariants.
  • NonFinalizedState::insert_with skips inserting a chain whose tip is already tracked: keeping the incumbent is first-received, and it preserves the duplicate-tip no-op that same-tip Equal used to provide on paths without a duplicate filter (the zebra-rpc sync mirror).
  • QueuedBlocks::by_parent becomes an IndexSet (with shift_remove on the prune path), so siblings queued while their parent was missing are dequeued, committed, and stamped in arrival order.
  • eq_internal_state excludes the counter, since a failed commit advances it without changing any chain.
  • Docs updated: Chain::cmp/PartialEq, find_chain, finalize, backup restore, the Handle invalidateblock / reconsiderblock edge cases without panicking聽#10586 test comments, and the state RFC's Ord section.

Strictly more cumulative work always still wins; the sequence only decides exact work ties, which is local policy (both blocks are valid), now aligned with the spec and zcashd.

Tests

  • equal_work_ties_prefer_first_seen: two equal-work siblings committed in both arrival orders keep the first-received tip best in both (one order fails under hash tie-breaking by construction); a strictly higher-work extension still overrides receipt order.
  • reconsidered_block_keeps_original_receipt_order: invalidate/reconsider preserves the original tie win.
  • dequeue_children_returns_siblings_in_arrival_order: queued siblings dequeue in insertion order.
  • Existing Handle invalidateblock / reconsiderblock edge cases without panicking聽#10586 regression tests and the fork/finalize property tests pass unchanged.
  • cargo fmt --all -- --check, cargo clippy -p zebra-state -p zebra-rpc --all-targets -- -D warnings, and cargo nextest run -p zebra-state -p zebra-rpc pass on this branch.

AI Disclosure

This change was developed with Claude (Claude Code): code, tests, and this description, under human direction and review. It ships in Shielded Labs' Zero v26 (a zebra downstream). The contributor is the responsible author.

Stamp each block with a receipt sequence when it is committed to the
non-finalized state (like zcashd's nSequenceId), break equal-work ties
in Chain::cmp by preferring the lower tip sequence (tip hash stays as
the final tie-breaker for unstamped test chains and Ord totality), keep
the incumbent chain when a duplicate tip is inserted, and dequeue
blocks that waited for a missing parent in arrival order so commit
order matches receipt order.

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

Labels

None yet

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

1 participant