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), + ), + ], + ), + ), + ], + )