From d382a7362e60e82ea77389472a857ba4819474af Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:00:10 +0200 Subject: [PATCH] fix(fork-choice): make the equal-slot equivocation tie deterministic When a validator equivocates by signing two distinct votes for the same slot, fork choice kept whichever vote arrived first. Both votes are admitted and aggregated, and the latest-vote extraction resolved the equal-slot tie by dict iteration, which is arrival order. Two honest nodes holding the same blocks and votes but receiving them in different orders could pick different heads permanently. The block-level tiebreak sits one layer too late: the equivocator's weight has already landed on different branches, so the branch weights are unequal and it never fires. Resolve the equal-slot tie by the largest canonical attestation-data root, the same rule the block tiebreak applies to block roots. The latest-vote extraction now sorts the pool by slot then data root and keeps the first vote per validator, so the head is a pure function of store contents, independent of arrival or insertion order. An equivocator is counted once, on one branch every node agrees on. This needs no slashing or equivocation-tracking construct, which this fork lacks. Block production gains the same secondary sort key, so a proposer builds the same block regardless of arrival order. The testing fixture's own vote checker mirrored the old first-seen rule and is brought into lockstep with the spec. An existing vector that asserted the arrival-order winner is corrected to the deterministic winner, and two new vectors deliver the same equivocating votes in opposite orders and assert an identical head. Refs #1172 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_types/store_checks.py | 23 ++- .../spec/forks/lstar/block_production.py | 5 +- src/lean_spec/spec/forks/lstar/fork_choice.py | 32 ++- .../lstar/fork_choice/test_equivocation.py | 193 +++++++++++++++++- 4 files changed, 224 insertions(+), 29 deletions(-) diff --git a/packages/testing/src/consensus_testing/test_types/store_checks.py b/packages/testing/src/consensus_testing/test_types/store_checks.py index eee1fd376..4ea414dbb 100644 --- a/packages/testing/src/consensus_testing/test_types/store_checks.py +++ b/packages/testing/src/consensus_testing/test_types/store_checks.py @@ -307,11 +307,19 @@ def _resolve(label: str) -> Bytes32: # Per-validator attestation content checks if "attestation_checks" in fields: assert self.attestation_checks is not None + + # Vote ordering key mirroring the fork-choice rule. + # + # A higher slot wins. + # An equal-slot tie breaks toward the larger canonical attestation-data root. + # This makes the extracted winner independent of arrival or insertion order. + def _canonical_precedence(vote: AttestationData) -> tuple[Slot, Bytes32]: + return (vote.slot, hash_tree_root(vote)) + for attestation_check in self.attestation_checks: - # Map each validator to its highest-slot vote in the named pool. + # Map each validator to its winning vote in the named pool. # # The checker inspects pool content before pruning, so no finality cutoff applies. - # On equal slots the first vote seen wins, matching the fork-choice rule. extracted_attestations: dict[ValidatorIndex, AttestationData] = {} if attestation_check.location == "signatures": # The raw signature pool groups one entry per validator under each vote. @@ -320,7 +328,9 @@ def _resolve(label: str) -> Bytes32: for signature_entry in entries: voter_index = signature_entry.validator_index previous_vote = extracted_attestations.get(voter_index) - if previous_vote is None or previous_vote.slot < attestation_data.slot: + if previous_vote is None or _canonical_precedence( + previous_vote + ) < _canonical_precedence(attestation_data): extracted_attestations[voter_index] = attestation_data else: # The aggregated pools group proofs covering many validators under each vote. @@ -334,10 +344,9 @@ def _resolve(label: str) -> Bytes32: for proof in proofs: for participant_index in proof.participants.to_validator_indices(): previous_vote = extracted_attestations.get(participant_index) - if ( - previous_vote is None - or previous_vote.slot < attestation_data.slot - ): + if previous_vote is None or _canonical_precedence( + previous_vote + ) < _canonical_precedence(attestation_data): extracted_attestations[participant_index] = attestation_data if attestation_check.validator not in extracted_attestations: diff --git a/src/lean_spec/spec/forks/lstar/block_production.py b/src/lean_spec/spec/forks/lstar/block_production.py index a3e7ee7ac..7bf2cc957 100644 --- a/src/lean_spec/spec/forks/lstar/block_production.py +++ b/src/lean_spec/spec/forks/lstar/block_production.py @@ -123,8 +123,11 @@ def build_block( processed_attestation_data: set[AttestationData] = set() # Order candidates by target slot, once. + # The canonical root breaks target-slot ties so every node builds the same block. + # It also makes the truncation cutoff content-derived, never arrival-order derived. candidates_in_target_slot_order = sorted( - aggregated_payloads.items(), key=lambda item: item[0].target.slot + aggregated_payloads.items(), + key=lambda item: (item[0].target.slot, hash_tree_root(item[0])), ) # Fixed-point selection. diff --git a/src/lean_spec/spec/forks/lstar/fork_choice.py b/src/lean_spec/spec/forks/lstar/fork_choice.py index 1f3292ad5..1ed52d1ea 100644 --- a/src/lean_spec/spec/forks/lstar/fork_choice.py +++ b/src/lean_spec/spec/forks/lstar/fork_choice.py @@ -645,8 +645,9 @@ def _extract_attestations_from_aggregated_payloads( Map each participating validator to the latest vote it cast. This is the LMD view fork choice runs on. - Two votes share a slot only across distinct attestation data, never within one data. - On such an equal-slot tie the strict comparison keeps the first distinct data inserted. + An equivocator can cast two distinct votes at one slot. + An equal-slot tie breaks toward the larger canonical attestation-data root. + The result is therefore independent of arrival or insertion order. A vote whose head sits at or below the finalized slot carries no fork-choice weight. Such stale votes are skipped here, so callers pass their pool without pre-filtering. @@ -660,20 +661,29 @@ def _extract_attestations_from_aggregated_payloads( """ latest_vote_by_validator: dict[ValidatorIndex, AttestationData] = {} - # Walk every vote, every proof for it, and every validator the proof covers. - for attestation_data, proofs in aggregated_payloads.items(): - # Skip votes whose head no longer outlives the finalized slot. + # Process votes newest-first, breaking an equal-slot tie toward the larger canonical root. + # This is the same rule the block tiebreak applies to block roots. + # The sort key runs hash_tree_root once per distinct vote, never per validator. + votes_by_canonical_precedence = sorted( + aggregated_payloads.items(), + key=lambda vote_and_proofs: ( + vote_and_proofs[0].slot, + hash_tree_root(vote_and_proofs[0]), + ), + reverse=True, + ) + + for attestation_data, proofs in votes_by_canonical_precedence: + # A vote whose head sits at or below the finalized slot carries no weight. if attestation_data.head.slot <= latest_finalized_slot: continue - - # Every proof here shares one attestation data, so they share one slot. - # The strict slot comparison below never overwrites between them. + # Every proof here carries the identical attestation data. + # Whichever proof the loop visits, the stored vote is the same. # Set iteration order is therefore non-consensus and safe to leave native. for proof in proofs: for validator_index in proof.participants.to_validator_indices(): - # Keep this vote only when it is newer than the one already stored. - previous_vote = latest_vote_by_validator.get(validator_index) - if previous_vote is None or previous_vote.slot < attestation_data.slot: + # Descending order means the first vote seen for a validator is its winner. + if validator_index not in latest_vote_by_validator: latest_vote_by_validator[validator_index] = attestation_data return latest_vote_by_validator diff --git a/tests/consensus/lstar/fork_choice/test_equivocation.py b/tests/consensus/lstar/fork_choice/test_equivocation.py index aee6105af..68d16144e 100644 --- a/tests/consensus/lstar/fork_choice/test_equivocation.py +++ b/tests/consensus/lstar/fork_choice/test_equivocation.py @@ -246,7 +246,7 @@ def test_same_slot_equivocating_attesters_count_once( fork_choice_test: ForkChoiceTestFiller, ) -> None: """ - An equivocating validator is counted once, on the fork it first voted. + An equivocating validator is counted once, on the canonical-root fork. Given ----- @@ -258,7 +258,8 @@ def test_same_slot_equivocating_attesters_count_once( - one vote at slot 3 targets fork_a from V0, V1, V2. - one vote at slot 3 targets fork_b from V0, V1, V3, V4. - V0 and V1 equivocate by voting on both forks at the same slot. - - fork_a is gossiped first, so V0 and V1's first votes stick to it. + - the two votes tie on slot, so the larger attestation-data root wins. + - the fork_b vote carries the larger root. When ---- @@ -266,10 +267,10 @@ def test_same_slot_equivocating_attesters_count_once( Then ---- - - V0 and V1 count once, toward fork_a. - - fork_a has effective weight 3 from V0, V1, V2. - - fork_b has effective weight 2 from V3, V4. - - head stays on fork_a. + - V0 and V1 count once, toward fork_b. + - fork_b has effective weight 4 from V0, V1, V3, V4. + - fork_a has effective weight 1 from V2. + - head is fork_b. - no slot is justified by the below-threshold votes. """ fork_choice_test( @@ -316,8 +317,8 @@ def test_same_slot_equivocating_attesters_count_once( TickStep( time=16, checks=StoreChecks( - head_slot=Slot(2), - head_root_label="fork_a", + head_slot=Slot(3), + head_root_label="fork_b", latest_justified_slot=Slot(0), latest_finalized_slot=Slot(0), latest_known_aggregated_target_slots=[Slot(2), Slot(3)], @@ -326,13 +327,13 @@ def test_same_slot_equivocating_attesters_count_once( validator=ValidatorIndex(0), location="known", attestation_slot=Slot(3), - target_slot=Slot(2), + target_slot=Slot(3), ), AttestationCheck( validator=ValidatorIndex(1), location="known", attestation_slot=Slot(3), - target_slot=Slot(2), + target_slot=Slot(3), ), AttestationCheck( validator=ValidatorIndex(2), @@ -357,3 +358,175 @@ def test_same_slot_equivocating_attesters_count_once( ), ], ) + + +def test_equivocation_head_independent_of_arrival_order_a_then_b( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Head ignores arrival order: fork_a arriving first still picks the canonical-root fork. + + Given + ----- + - 6 validators; a slot needs 4 votes (2/3) to be justified. + - the chain: + genesis -> common(1) + - fork_a(2) + - fork_b(3) + - one vote at slot 3 targets fork_a from V0, V1. + - one vote at slot 3 targets fork_b from V0, V2. + - V0 equivocates by voting on both forks at the same slot. + - V1 backs fork_a alone; V2 backs fork_b alone. + - without V0 the two forks tie one-to-one, so V0's vote decides the head. + - the two votes tie on slot, so the larger attestation-data root wins. + - the fork_a vote carries the larger root. + + When + ---- + - the fork_a vote arrives first, then the fork_b vote. + + Then + ---- + - V0 counts once, toward the larger-root fork. + - the larger-root fork has effective weight 2. + - the smaller-root fork has effective weight 1. + - head is fork_a. + - no slot is justified by the below-threshold votes. + """ + fork_choice_test( + anchor_state=build_genesis_state(num_validators=6), + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="common"), + checks=StoreChecks(head_slot=Slot(1), head_root_label="common"), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), parent_label="common", label="fork_a"), + checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a"), + ), + BlockStep( + block=BlockSpec(slot=Slot(3), parent_label="common", label="fork_b"), + checks=StoreChecks(lexicographic_head_among=["fork_a", "fork_b"]), + ), + TickStep(interval=18), + GossipAggregatedAttestationStep( + attestation=AggregatedAttestationSpec( + validator_indices=[ValidatorIndex(0), ValidatorIndex(1)], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="fork_a", + ), + ), + GossipAggregatedAttestationStep( + attestation=AggregatedAttestationSpec( + validator_indices=[ValidatorIndex(0), ValidatorIndex(2)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_b", + ), + ), + TickStep( + time=16, + checks=StoreChecks( + head_slot=Slot(2), + head_root_label="fork_a", + latest_justified_slot=Slot(0), + latest_finalized_slot=Slot(0), + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(0), + location="known", + attestation_slot=Slot(3), + target_slot=Slot(2), + ), + ], + ), + ), + ], + ) + + +def test_equivocation_head_independent_of_arrival_order_b_then_a( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Head ignores arrival order: fork_b arriving first still picks the canonical-root fork. + + Given + ----- + - 6 validators; a slot needs 4 votes (2/3) to be justified. + - the chain: + genesis -> common(1) + - fork_a(2) + - fork_b(3) + - one vote at slot 3 targets fork_a from V0, V1. + - one vote at slot 3 targets fork_b from V0, V2. + - V0 equivocates by voting on both forks at the same slot. + - V1 backs fork_a alone; V2 backs fork_b alone. + - without V0 the two forks tie one-to-one, so V0's vote decides the head. + - the two votes tie on slot, so the larger attestation-data root wins. + - the fork_a vote carries the larger root. + + When + ---- + - the fork_b vote arrives first, then the fork_a vote. + + Then + ---- + - V0 counts once, toward the larger-root fork. + - the larger-root fork has effective weight 2. + - the smaller-root fork has effective weight 1. + - head is fork_a, matching the opposite arrival order. + - no slot is justified by the below-threshold votes. + """ + fork_choice_test( + anchor_state=build_genesis_state(num_validators=6), + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(1), label="common"), + checks=StoreChecks(head_slot=Slot(1), head_root_label="common"), + ), + BlockStep( + block=BlockSpec(slot=Slot(2), parent_label="common", label="fork_a"), + checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a"), + ), + BlockStep( + block=BlockSpec(slot=Slot(3), parent_label="common", label="fork_b"), + checks=StoreChecks(lexicographic_head_among=["fork_a", "fork_b"]), + ), + TickStep(interval=18), + GossipAggregatedAttestationStep( + attestation=AggregatedAttestationSpec( + validator_indices=[ValidatorIndex(0), ValidatorIndex(2)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_b", + ), + ), + GossipAggregatedAttestationStep( + attestation=AggregatedAttestationSpec( + validator_indices=[ValidatorIndex(0), ValidatorIndex(1)], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="fork_a", + ), + ), + TickStep( + time=16, + checks=StoreChecks( + head_slot=Slot(2), + head_root_label="fork_a", + latest_justified_slot=Slot(0), + latest_finalized_slot=Slot(0), + attestation_checks=[ + AttestationCheck( + validator=ValidatorIndex(0), + location="known", + attestation_slot=Slot(3), + target_slot=Slot(2), + ), + ], + ), + ), + ], + )