Skip to content

Commit 57d4339

Browse files
tcoratgerclaude
andauthored
fix(fork-choice): make the equal-slot equivocation tie deterministic (#1181)
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) <noreply@anthropic.com>
1 parent 6a2714a commit 57d4339

4 files changed

Lines changed: 224 additions & 29 deletions

File tree

packages/testing/src/consensus_testing/test_types/store_checks.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -307,11 +307,19 @@ def _resolve(label: str) -> Bytes32:
307307
# Per-validator attestation content checks
308308
if "attestation_checks" in fields:
309309
assert self.attestation_checks is not None
310+
311+
# Vote ordering key mirroring the fork-choice rule.
312+
#
313+
# A higher slot wins.
314+
# An equal-slot tie breaks toward the larger canonical attestation-data root.
315+
# This makes the extracted winner independent of arrival or insertion order.
316+
def _canonical_precedence(vote: AttestationData) -> tuple[Slot, Bytes32]:
317+
return (vote.slot, hash_tree_root(vote))
318+
310319
for attestation_check in self.attestation_checks:
311-
# Map each validator to its highest-slot vote in the named pool.
320+
# Map each validator to its winning vote in the named pool.
312321
#
313322
# The checker inspects pool content before pruning, so no finality cutoff applies.
314-
# On equal slots the first vote seen wins, matching the fork-choice rule.
315323
extracted_attestations: dict[ValidatorIndex, AttestationData] = {}
316324
if attestation_check.location == "signatures":
317325
# The raw signature pool groups one entry per validator under each vote.
@@ -320,7 +328,9 @@ def _resolve(label: str) -> Bytes32:
320328
for signature_entry in entries:
321329
voter_index = signature_entry.validator_index
322330
previous_vote = extracted_attestations.get(voter_index)
323-
if previous_vote is None or previous_vote.slot < attestation_data.slot:
331+
if previous_vote is None or _canonical_precedence(
332+
previous_vote
333+
) < _canonical_precedence(attestation_data):
324334
extracted_attestations[voter_index] = attestation_data
325335
else:
326336
# The aggregated pools group proofs covering many validators under each vote.
@@ -334,10 +344,9 @@ def _resolve(label: str) -> Bytes32:
334344
for proof in proofs:
335345
for participant_index in proof.participants.to_validator_indices():
336346
previous_vote = extracted_attestations.get(participant_index)
337-
if (
338-
previous_vote is None
339-
or previous_vote.slot < attestation_data.slot
340-
):
347+
if previous_vote is None or _canonical_precedence(
348+
previous_vote
349+
) < _canonical_precedence(attestation_data):
341350
extracted_attestations[participant_index] = attestation_data
342351

343352
if attestation_check.validator not in extracted_attestations:

src/lean_spec/spec/forks/lstar/block_production.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,11 @@ def build_block(
123123
processed_attestation_data: set[AttestationData] = set()
124124

125125
# Order candidates by target slot, once.
126+
# The canonical root breaks target-slot ties so every node builds the same block.
127+
# It also makes the truncation cutoff content-derived, never arrival-order derived.
126128
candidates_in_target_slot_order = sorted(
127-
aggregated_payloads.items(), key=lambda item: item[0].target.slot
129+
aggregated_payloads.items(),
130+
key=lambda item: (item[0].target.slot, hash_tree_root(item[0])),
128131
)
129132

130133
# Fixed-point selection.

src/lean_spec/spec/forks/lstar/fork_choice.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -660,8 +660,9 @@ def _extract_attestations_from_aggregated_payloads(
660660
Map each participating validator to the latest vote it cast.
661661
662662
This is the LMD view fork choice runs on.
663-
Two votes share a slot only across distinct attestation data, never within one data.
664-
On such an equal-slot tie the strict comparison keeps the first distinct data inserted.
663+
An equivocator can cast two distinct votes at one slot.
664+
An equal-slot tie breaks toward the larger canonical attestation-data root.
665+
The result is therefore independent of arrival or insertion order.
665666
666667
A vote whose head sits at or below the finalized slot carries no fork-choice weight.
667668
Such stale votes are skipped here, so callers pass their pool without pre-filtering.
@@ -675,20 +676,29 @@ def _extract_attestations_from_aggregated_payloads(
675676
"""
676677
latest_vote_by_validator: dict[ValidatorIndex, AttestationData] = {}
677678

678-
# Walk every vote, every proof for it, and every validator the proof covers.
679-
for attestation_data, proofs in aggregated_payloads.items():
680-
# Skip votes whose head no longer outlives the finalized slot.
679+
# Process votes newest-first, breaking an equal-slot tie toward the larger canonical root.
680+
# This is the same rule the block tiebreak applies to block roots.
681+
# The sort key runs hash_tree_root once per distinct vote, never per validator.
682+
votes_by_canonical_precedence = sorted(
683+
aggregated_payloads.items(),
684+
key=lambda vote_and_proofs: (
685+
vote_and_proofs[0].slot,
686+
hash_tree_root(vote_and_proofs[0]),
687+
),
688+
reverse=True,
689+
)
690+
691+
for attestation_data, proofs in votes_by_canonical_precedence:
692+
# A vote whose head sits at or below the finalized slot carries no weight.
681693
if attestation_data.head.slot <= latest_finalized_slot:
682694
continue
683-
684-
# Every proof here shares one attestation data, so they share one slot.
685-
# The strict slot comparison below never overwrites between them.
695+
# Every proof here carries the identical attestation data.
696+
# Whichever proof the loop visits, the stored vote is the same.
686697
# Set iteration order is therefore non-consensus and safe to leave native.
687698
for proof in proofs:
688699
for validator_index in proof.participants.to_validator_indices():
689-
# Keep this vote only when it is newer than the one already stored.
690-
previous_vote = latest_vote_by_validator.get(validator_index)
691-
if previous_vote is None or previous_vote.slot < attestation_data.slot:
700+
# Descending order means the first vote seen for a validator is its winner.
701+
if validator_index not in latest_vote_by_validator:
692702
latest_vote_by_validator[validator_index] = attestation_data
693703

694704
return latest_vote_by_validator

tests/consensus/lstar/fork_choice/test_equivocation.py

Lines changed: 183 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def test_same_slot_equivocating_attesters_count_once(
246246
fork_choice_test: ForkChoiceTestFiller,
247247
) -> None:
248248
"""
249-
An equivocating validator is counted once, on the fork it first voted.
249+
An equivocating validator is counted once, on the canonical-root fork.
250250
251251
Given
252252
-----
@@ -258,18 +258,19 @@ def test_same_slot_equivocating_attesters_count_once(
258258
- one vote at slot 3 targets fork_a from V0, V1, V2.
259259
- one vote at slot 3 targets fork_b from V0, V1, V3, V4.
260260
- V0 and V1 equivocate by voting on both forks at the same slot.
261-
- fork_a is gossiped first, so V0 and V1's first votes stick to it.
261+
- the two votes tie on slot, so the larger attestation-data root wins.
262+
- the fork_b vote carries the larger root.
262263
263264
When
264265
----
265266
- both votes arrive by gossip and are accepted.
266267
267268
Then
268269
----
269-
- V0 and V1 count once, toward fork_a.
270-
- fork_a has effective weight 3 from V0, V1, V2.
271-
- fork_b has effective weight 2 from V3, V4.
272-
- head stays on fork_a.
270+
- V0 and V1 count once, toward fork_b.
271+
- fork_b has effective weight 4 from V0, V1, V3, V4.
272+
- fork_a has effective weight 1 from V2.
273+
- head is fork_b.
273274
- no slot is justified by the below-threshold votes.
274275
"""
275276
fork_choice_test(
@@ -316,8 +317,8 @@ def test_same_slot_equivocating_attesters_count_once(
316317
TickStep(
317318
time=16,
318319
checks=StoreChecks(
319-
head_slot=Slot(2),
320-
head_root_label="fork_a",
320+
head_slot=Slot(3),
321+
head_root_label="fork_b",
321322
latest_justified_slot=Slot(0),
322323
latest_finalized_slot=Slot(0),
323324
latest_known_aggregated_target_slots=[Slot(2), Slot(3)],
@@ -326,13 +327,13 @@ def test_same_slot_equivocating_attesters_count_once(
326327
validator=ValidatorIndex(0),
327328
location="known",
328329
attestation_slot=Slot(3),
329-
target_slot=Slot(2),
330+
target_slot=Slot(3),
330331
),
331332
AttestationCheck(
332333
validator=ValidatorIndex(1),
333334
location="known",
334335
attestation_slot=Slot(3),
335-
target_slot=Slot(2),
336+
target_slot=Slot(3),
336337
),
337338
AttestationCheck(
338339
validator=ValidatorIndex(2),
@@ -357,3 +358,175 @@ def test_same_slot_equivocating_attesters_count_once(
357358
),
358359
],
359360
)
361+
362+
363+
def test_equivocation_head_independent_of_arrival_order_a_then_b(
364+
fork_choice_test: ForkChoiceTestFiller,
365+
) -> None:
366+
"""
367+
Head ignores arrival order: fork_a arriving first still picks the canonical-root fork.
368+
369+
Given
370+
-----
371+
- 6 validators; a slot needs 4 votes (2/3) to be justified.
372+
- the chain:
373+
genesis -> common(1)
374+
- fork_a(2)
375+
- fork_b(3)
376+
- one vote at slot 3 targets fork_a from V0, V1.
377+
- one vote at slot 3 targets fork_b from V0, V2.
378+
- V0 equivocates by voting on both forks at the same slot.
379+
- V1 backs fork_a alone; V2 backs fork_b alone.
380+
- without V0 the two forks tie one-to-one, so V0's vote decides the head.
381+
- the two votes tie on slot, so the larger attestation-data root wins.
382+
- the fork_a vote carries the larger root.
383+
384+
When
385+
----
386+
- the fork_a vote arrives first, then the fork_b vote.
387+
388+
Then
389+
----
390+
- V0 counts once, toward the larger-root fork.
391+
- the larger-root fork has effective weight 2.
392+
- the smaller-root fork has effective weight 1.
393+
- head is fork_a.
394+
- no slot is justified by the below-threshold votes.
395+
"""
396+
fork_choice_test(
397+
anchor_state=build_genesis_state(num_validators=6),
398+
steps=[
399+
BlockStep(
400+
block=BlockSpec(slot=Slot(1), label="common"),
401+
checks=StoreChecks(head_slot=Slot(1), head_root_label="common"),
402+
),
403+
BlockStep(
404+
block=BlockSpec(slot=Slot(2), parent_label="common", label="fork_a"),
405+
checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a"),
406+
),
407+
BlockStep(
408+
block=BlockSpec(slot=Slot(3), parent_label="common", label="fork_b"),
409+
checks=StoreChecks(lexicographic_head_among=["fork_a", "fork_b"]),
410+
),
411+
TickStep(interval=18),
412+
GossipAggregatedAttestationStep(
413+
attestation=AggregatedAttestationSpec(
414+
validator_indices=[ValidatorIndex(0), ValidatorIndex(1)],
415+
slot=Slot(3),
416+
target_slot=Slot(2),
417+
target_root_label="fork_a",
418+
),
419+
),
420+
GossipAggregatedAttestationStep(
421+
attestation=AggregatedAttestationSpec(
422+
validator_indices=[ValidatorIndex(0), ValidatorIndex(2)],
423+
slot=Slot(3),
424+
target_slot=Slot(3),
425+
target_root_label="fork_b",
426+
),
427+
),
428+
TickStep(
429+
time=16,
430+
checks=StoreChecks(
431+
head_slot=Slot(2),
432+
head_root_label="fork_a",
433+
latest_justified_slot=Slot(0),
434+
latest_finalized_slot=Slot(0),
435+
attestation_checks=[
436+
AttestationCheck(
437+
validator=ValidatorIndex(0),
438+
location="known",
439+
attestation_slot=Slot(3),
440+
target_slot=Slot(2),
441+
),
442+
],
443+
),
444+
),
445+
],
446+
)
447+
448+
449+
def test_equivocation_head_independent_of_arrival_order_b_then_a(
450+
fork_choice_test: ForkChoiceTestFiller,
451+
) -> None:
452+
"""
453+
Head ignores arrival order: fork_b arriving first still picks the canonical-root fork.
454+
455+
Given
456+
-----
457+
- 6 validators; a slot needs 4 votes (2/3) to be justified.
458+
- the chain:
459+
genesis -> common(1)
460+
- fork_a(2)
461+
- fork_b(3)
462+
- one vote at slot 3 targets fork_a from V0, V1.
463+
- one vote at slot 3 targets fork_b from V0, V2.
464+
- V0 equivocates by voting on both forks at the same slot.
465+
- V1 backs fork_a alone; V2 backs fork_b alone.
466+
- without V0 the two forks tie one-to-one, so V0's vote decides the head.
467+
- the two votes tie on slot, so the larger attestation-data root wins.
468+
- the fork_a vote carries the larger root.
469+
470+
When
471+
----
472+
- the fork_b vote arrives first, then the fork_a vote.
473+
474+
Then
475+
----
476+
- V0 counts once, toward the larger-root fork.
477+
- the larger-root fork has effective weight 2.
478+
- the smaller-root fork has effective weight 1.
479+
- head is fork_a, matching the opposite arrival order.
480+
- no slot is justified by the below-threshold votes.
481+
"""
482+
fork_choice_test(
483+
anchor_state=build_genesis_state(num_validators=6),
484+
steps=[
485+
BlockStep(
486+
block=BlockSpec(slot=Slot(1), label="common"),
487+
checks=StoreChecks(head_slot=Slot(1), head_root_label="common"),
488+
),
489+
BlockStep(
490+
block=BlockSpec(slot=Slot(2), parent_label="common", label="fork_a"),
491+
checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a"),
492+
),
493+
BlockStep(
494+
block=BlockSpec(slot=Slot(3), parent_label="common", label="fork_b"),
495+
checks=StoreChecks(lexicographic_head_among=["fork_a", "fork_b"]),
496+
),
497+
TickStep(interval=18),
498+
GossipAggregatedAttestationStep(
499+
attestation=AggregatedAttestationSpec(
500+
validator_indices=[ValidatorIndex(0), ValidatorIndex(2)],
501+
slot=Slot(3),
502+
target_slot=Slot(3),
503+
target_root_label="fork_b",
504+
),
505+
),
506+
GossipAggregatedAttestationStep(
507+
attestation=AggregatedAttestationSpec(
508+
validator_indices=[ValidatorIndex(0), ValidatorIndex(1)],
509+
slot=Slot(3),
510+
target_slot=Slot(2),
511+
target_root_label="fork_a",
512+
),
513+
),
514+
TickStep(
515+
time=16,
516+
checks=StoreChecks(
517+
head_slot=Slot(2),
518+
head_root_label="fork_a",
519+
latest_justified_slot=Slot(0),
520+
latest_finalized_slot=Slot(0),
521+
attestation_checks=[
522+
AttestationCheck(
523+
validator=ValidatorIndex(0),
524+
location="known",
525+
attestation_slot=Slot(3),
526+
target_slot=Slot(2),
527+
),
528+
],
529+
),
530+
),
531+
],
532+
)

0 commit comments

Comments
 (0)