Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions packages/testing/src/consensus_testing/test_types/store_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion src/lean_spec/spec/forks/lstar/block_production.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 21 additions & 11 deletions src/lean_spec/spec/forks/lstar/fork_choice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
193 changes: 183 additions & 10 deletions tests/consensus/lstar/fork_choice/test_equivocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand All @@ -258,18 +258,19 @@ 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
----
- both votes arrive by gossip and are accepted.

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(
Expand Down Expand Up @@ -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)],
Expand All @@ -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),
Expand All @@ -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),
),
],
),
),
],
)
Loading