fix(fork-choice): make the equal-slot equivocation tie deterministic (leanSpec #1181)#503
Conversation
Port leanSpec #1181. An equivocator can sign two distinct votes for one slot; extract_latest_attestations resolved the equal-slot tie by insertion (arrival) order, so two honest nodes seeing the same votes in different orders could pick different heads permanently. Resolve the tie by the larger canonical attestation-data root instead (the pool key is already hash_tree_root(data)), making the extracted head a pure function of pool contents. Both the aggregated and gossip signature pools are fixed. Block production already used a deterministic data_root tiebreak (leanSpec #1149), so it is unchanged. The unit test that locked in the old first-seen behavior is rewritten to assert order-independence.
🤖 Kimi Code ReviewOverall Assessment: Correct and necessary consensus fix. The change eliminates insertion-order nondeterminism in fork-choice vote extraction by using canonical (slot, data_root) ordering, preventing potential safety/liveness issues from network-level reordering. Issues & Suggestions: 1. Performance Regression (O(n) → O(n log n))
2. Documentation Consistency
3. Unnecessary
4. Test Robustness
Security/Consensus Verification:
Code Quality:
** Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code ReviewFindings
No obvious memory-safety or error-handling issues stood out in this diff. I couldn’t run the Rust tests in this environment because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Greptile SummaryThis PR fixes a determinism bug in fork-choice vote extraction where an equivocating validator's weight could land on different branches across nodes depending on message arrival order. It replaces insertion-order-based tiebreaking with a sort-then-first-wins approach that is a pure function of pool contents.
Confidence Score: 4/5The determinism fix is logically sound and the core sorting+or_insert_with pattern correctly implements the canonical-root tiebreak in both buffer types. The only gaps are a stale test doc comment and a missing parallel test for the gossip path. The sorting approach and or_insert_with semantics correctly resolve the non-determinism for both PayloadBuffer and GossipSignatureBuffer. GossipSignatureBuffer::extract_latest_attestations receives the same logic change but no new test covering the equal-slot equivocation case, leaving a coverage gap that could hide future regressions in that path. crates/storage/src/store.rs — specifically the GossipSignatureBuffer::extract_latest_attestations function, which lacks an equivocation tiebreak test comparable to the one added for PayloadBuffer.
|
| Filename | Overview |
|---|---|
| crates/storage/src/store.rs | Core change: both extract_latest_attestations implementations now sort descending by (slot, data_root) and use or_insert_with for first-wins semantics. Logic is correct and symmetric. Minor: drain's test doc comment retains stale equivocation reasoning, and GossipSignatureBuffer lacks a parallel equivocation tiebreak test. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[extract_latest_attestations called] --> B[Collect self.data.iter into Vec]
B --> C["sort_unstable_by: descending (slot, data_root)"]
C --> D[Iterate entries in sorted order]
D --> E[For each validator ID in entry]
E --> F{result already has this validator?}
F -- No --> G[or_insert_with: assign current entry.data]
F -- Yes --> H[Skip — canonical winner already recorded]
G --> E
H --> E
E -- done --> D
D -- done --> I[Return result HashMap]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[extract_latest_attestations called] --> B[Collect self.data.iter into Vec]
B --> C["sort_unstable_by: descending (slot, data_root)"]
C --> D[Iterate entries in sorted order]
D --> E[For each validator ID in entry]
E --> F{result already has this validator?}
F -- No --> G[or_insert_with: assign current entry.data]
F -- Yes --> H[Skip — canonical winner already recorded]
G --> E
H --> E
E -- done --> D
D -- done --> I[Return result HashMap]
Comments Outside Diff (2)
-
crates/storage/src/store.rs, line 2382-2385 (link)The
drain_preserves_insertion_ordertest doc comment still attributes drain order to "preserving same-slot equivocation semantics through the new → known migration," but that rationale is no longer accurate after this PR —extract_latest_attestationsnow sorts independently of insertion order. The drain function comment itself (fn drain) was correctly updated; this test-level doc comment was missed. The test itself is still valid (drain order matters for other downstream consumers), but the stated reason is stale.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/storage/src/store.rs Line: 2382-2385 Comment: The `drain_preserves_insertion_order` test doc comment still attributes drain order to "preserving same-slot equivocation semantics through the new → known migration," but that rationale is no longer accurate after this PR — `extract_latest_attestations` now sorts independently of insertion order. The drain function comment itself (`fn drain`) was correctly updated; this test-level doc comment was missed. The test itself is still valid (drain order matters for other downstream consumers), but the stated reason is stale. How can I resolve this? If you propose a fix, please make it concise.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
-
crates/storage/src/store.rs, line 474-487 (link)Missing equivocation-tiebreak test for
GossipSignatureBufferPayloadBuffer::extract_latest_attestationsnow hasextract_latest_attestations_canonical_root_wins_on_slot_tievalidating both insertion orders, butGossipSignatureBuffer::extract_latest_attestationshas no equivalent. Since both implementations share the same sorting logic and are described as mirrors of each other, a gap here means a silent regression in the gossip path would not be caught by the test suite. Adding a parallel test that inserts two equal-slot equivocating signatures in both orders and asserts the canonical-root winner is consistent would complete the coverage.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/storage/src/store.rs Line: 474-487 Comment: **Missing equivocation-tiebreak test for `GossipSignatureBuffer`** `PayloadBuffer::extract_latest_attestations` now has `extract_latest_attestations_canonical_root_wins_on_slot_tie` validating both insertion orders, but `GossipSignatureBuffer::extract_latest_attestations` has no equivalent. Since both implementations share the same sorting logic and are described as mirrors of each other, a gap here means a silent regression in the gossip path would not be caught by the test suite. Adding a parallel test that inserts two equal-slot equivocating signatures in both orders and asserts the canonical-root winner is consistent would complete the coverage. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
crates/storage/src/store.rs:2382-2385
The `drain_preserves_insertion_order` test doc comment still attributes drain order to "preserving same-slot equivocation semantics through the new → known migration," but that rationale is no longer accurate after this PR — `extract_latest_attestations` now sorts independently of insertion order. The drain function comment itself (`fn drain`) was correctly updated; this test-level doc comment was missed. The test itself is still valid (drain order matters for other downstream consumers), but the stated reason is stale.
```suggestion
/// `drain` must hand back entries in insertion order so that
/// `promote_new_aggregated_payloads` lands them in known_payloads in the
/// same order (deterministic re-insertion, independent of HashMap iteration).
```
### Issue 2 of 2
crates/storage/src/store.rs:474-487
**Missing equivocation-tiebreak test for `GossipSignatureBuffer`**
`PayloadBuffer::extract_latest_attestations` now has `extract_latest_attestations_canonical_root_wins_on_slot_tie` validating both insertion orders, but `GossipSignatureBuffer::extract_latest_attestations` has no equivalent. Since both implementations share the same sorting logic and are described as mirrors of each other, a gap here means a silent regression in the gossip path would not be caught by the test suite. Adding a parallel test that inserts two equal-slot equivocating signatures in both orders and asserts the canonical-root winner is consistent would complete the coverage.
Reviews (1): Last reviewed commit: "fix(fork-choice): make the equal-slot eq..." | Re-trigger Greptile
What
Ports leanSpec #1181: make the equal-slot equivocation tie in fork choice deterministic.
Why
An equivocating validator can sign two distinct votes
AandBfor the same slot. Nothing rejects the second (the pool is keyed by attestation data, and the two data differ), so both are admitted.extract_latest_attestationsthen resolved the equal-slot tie with a strictexisting.slot < entry.data.slotwhile iterating in insertion (arrival) order, so the winner was simply whichever was seen first. Two honest nodes with the same blocks and votes but different arrival order could land the equivocator's weight on different branches and pick different heads permanently — a determinism/safety bug that unit tests miss because it only appears across nodes.Changes
crates/storage/src/store.rs— bothextract_latest_attestationsimplementations (the aggregatedPayloadBufferand the rawGossipSignatureBuffer):hash_tree_root(data), so the tie needs no extra hashing.Block production is not changed: ethlambda's block builder already uses
data_rootas its deterministic final tiebreak (EntryScore::ordering_key, from leanSpec #1149), so it is already order-independent.The
draindoc comment is updated (vote-extraction determinism no longer depends on drain order), and the unit test that asserted first-seen-wins is rewritten to assert order-independence (larger canonical root wins in both arrival orders).Tests
extract_latest_attestations_canonical_root_wins_on_slot_tie(rewritten from..._first_inserted_wins_on_slot_tie)cargo fmt,clippy -D warnings, and the storage lib tests (44) pass.