From 6cbb414972ea54eb79c962476343de03ed4f8690 Mon Sep 17 00:00:00 2001 From: d4m014 Date: Sat, 11 Apr 2026 11:01:46 +0100 Subject: [PATCH 1/2] write equivocating proposer tests --- .../consensus/devnet/fc/test_equivocation.py | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 tests/consensus/devnet/fc/test_equivocation.py diff --git a/tests/consensus/devnet/fc/test_equivocation.py b/tests/consensus/devnet/fc/test_equivocation.py new file mode 100644 index 00000000..8c6622b1 --- /dev/null +++ b/tests/consensus/devnet/fc/test_equivocation.py @@ -0,0 +1,267 @@ +""" +Equivocating Proposer Tests. + +Tests fork choice behavior when a proposer publishes two different blocks for +the same slot (equivocation). + +Covered scenarios: + +- Both equivocating blocks are accepted by the store. + Head resolves via lexicographic tiebreaker when neither fork has more weight. + +- Validators split attestations across equivocating forks. + Head follows the fork with more attestation weight. + When weights are equal, the lexicographic tiebreaker applies. + When one fork gains an extra vote, the head switches to that fork. +""" + +import pytest +from consensus_testing import ( + AggregatedAttestationSpec, + AttestationStep, + BlockSpec, + BlockStep, + ForkChoiceTestFiller, + GossipAttestationSpec, + StoreChecks, + TickStep, + generate_pre_state, +) + +from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex + +pytestmark = pytest.mark.valid_until("Devnet") + + +def test_equivocating_proposer_two_blocks_at_same_slot( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Two blocks at the same slot from the same proposer are both accepted. + + Scenario + -------- + - Slot 1: Build common ancestor + - Slot 2: Proposer publishes first block with an attestation from validator 0 + - Slot 2: Same proposer publishes second block with an attestation from validator 1 + + Both equivocating blocks include an attestation targeting the common ancestor. + This ensures different block bodies (and therefore different roots) without + giving either block an attestation-weight advantage over the other. + + Expected Behavior + ----------------- + - Both blocks are accepted by the fork choice store. + - After the first equivocating block, head is at slot 2. + - After the second equivocating block, both have equal weight. + - Head is chosen by lexicographic tiebreaker among the two equivocating roots. + - Head remains at slot 2 throughout. + """ + fork_choice_test( + steps=[ + # Common ancestor at slot 1 + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks( + head_slot=Slot(1), + head_root_label="block_1", + ), + ), + # First equivocating block at slot 2 + BlockStep( + block=BlockSpec( + slot=Slot(2), + parent_label="block_1", + label="equivocation_a", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + valid=True, + checks=StoreChecks( + head_slot=Slot(2), + head_root_label="equivocation_a", + ), + ), + # Second equivocating block at slot 2 with different attestation + BlockStep( + block=BlockSpec( + slot=Slot(2), + parent_label="block_1", + label="equivocation_b", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + valid=True, + checks=StoreChecks( + head_slot=Slot(2), + lexicographic_head_among=["equivocation_a", "equivocation_b"], + ), + ), + ], + ) + + +def test_equivocating_proposer_with_split_attestations( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Attestations split across equivocating forks; head follows weight. + + Scenario + -------- + Six validators. An equivocating proposer produces two blocks at slot 2. + Honest validators split their attestations across the two forks: + + - Slot 1: Common ancestor (block_1) + - Slot 2: fork_a (equivocation with V0 in-block attestation for block_1) + - Slot 2: fork_b (equivocation with V1 in-block attestation for block_1) + + Phase 1 -- 2 vs 2: + V0, V1 gossip-attest to fork_a. V2, V3 gossip-attest to fork_b. + Equal weight triggers the lexicographic tiebreaker. + + Phase 2 -- 3 vs 2: + V4 gossip-attests to fork_b, breaking the tie. + fork_b now has 3 attestations vs fork_a's 2. + + Both equivocating blocks carry a different in-block attestation targeting + the common ancestor. This gives them different block roots without + providing an attestation-weight advantage to either fork. + + Expected Behavior + ----------------- + - Phase 1: Head is chosen by lexicographic tiebreaker among fork_a, fork_b. + - Phase 2: Head is fork_b (3 > 2 attestation weight). + - Both forks remain in the store throughout. + """ + fork_choice_test( + anchor_state=generate_pre_state(num_validators=6), + steps=[ + # Common ancestor at slot 1 + BlockStep( + block=BlockSpec(slot=Slot(1), label="block_1"), + checks=StoreChecks( + head_slot=Slot(1), + head_root_label="block_1", + ), + ), + # First equivocating block at slot 2 + # In-block attestation from V0 targeting block_1 (differentiates body) + BlockStep( + block=BlockSpec( + slot=Slot(2), + parent_label="block_1", + label="fork_a", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + valid=True, + checks=StoreChecks( + head_slot=Slot(2), + head_root_label="fork_a", + ), + ), + # Second equivocating block at slot 2 + # In-block attestation from V1 targeting block_1 (differentiates body) + BlockStep( + block=BlockSpec( + slot=Slot(2), + parent_label="block_1", + label="fork_b", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), + valid=True, + checks=StoreChecks( + head_slot=Slot(2), + lexicographic_head_among=["fork_a", "fork_b"], + ), + ), + # Phase 1: V0, V1 gossip-attest to fork_a (2 votes) + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(0), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a", + ), + ), + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(1), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a", + ), + ), + # Phase 1: V2, V3 gossip-attest to fork_b (2 votes) + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(2), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b", + ), + ), + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(3), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b", + ), + ), + # Tick to accept gossip attestations (interval 4 of slot 2 = interval 14) + # time=12s -> 12000ms / 800 = interval 15, passing through interval 14 + TickStep( + time=12, + checks=StoreChecks( + head_slot=Slot(2), + lexicographic_head_among=["fork_a", "fork_b"], + ), + ), + # Phase 2: V4 gossip-attests to fork_b (now 3 vs 2) + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(4), + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b", + ), + ), + # Tick to accept V4's attestation (interval 4 of slot 3 = interval 19) + # time=16s -> 16000ms / 800 = interval 20, passing through interval 19 + TickStep( + time=16, + checks=StoreChecks( + head_slot=Slot(2), + head_root_label="fork_b", + ), + ), + ], + ) From 1ba953e9848c63e612107a14e132f4b36668a973 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:15:11 +0200 Subject: [PATCH 2/2] Update tests/consensus/devnet/fc/test_equivocation.py --- tests/consensus/devnet/fc/test_equivocation.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/tests/consensus/devnet/fc/test_equivocation.py b/tests/consensus/devnet/fc/test_equivocation.py index 8c6622b1..80baad03 100644 --- a/tests/consensus/devnet/fc/test_equivocation.py +++ b/tests/consensus/devnet/fc/test_equivocation.py @@ -1,19 +1,4 @@ -""" -Equivocating Proposer Tests. - -Tests fork choice behavior when a proposer publishes two different blocks for -the same slot (equivocation). - -Covered scenarios: - -- Both equivocating blocks are accepted by the store. - Head resolves via lexicographic tiebreaker when neither fork has more weight. - -- Validators split attestations across equivocating forks. - Head follows the fork with more attestation weight. - When weights are equal, the lexicographic tiebreaker applies. - When one fork gains an extra vote, the head switches to that fork. -""" +"""Equivocating Proposer Tests.""" import pytest from consensus_testing import (