From 693596817c0f4f92e62227e80fd1dae2a65b2b36 Mon Sep 17 00:00:00 2001 From: unnawut Date: Wed, 18 Mar 2026 21:55:43 +0700 Subject: [PATCH 1/5] call on_tick in test fixtures --- .../consensus_testing/test_fixtures/fork_choice.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index 19864b45..c0d22fb8 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -292,8 +292,18 @@ def make_fixture(self) -> Self: scheme=LEAN_ENV_TO_SCHEMES[self.lean_env], ) - # Optionally simulate the proposer's gossip attestation. + # Simulate the proposer's gossip attestation. + # + # In the real system, attestation happens at interval >= 1 + # after the block is processed. Advance time to interval 1 + # and update head to match the real validator service behavior. if step.block.gossip_proposer_attestation: + att_interval = Interval(int(block.slot) * int(INTERVALS_PER_SLOT) + 1) + store, _ = store.on_tick( + att_interval, has_proposal=False, is_aggregator=True + ) + store = store.update_head() + proposer_index = block.proposer_index proposer_att_data = store.produce_attestation_data(block.slot) proposer_gossip_att = SignedAttestation( From f1235989148c090c5aaee613efb80d82ae5fb8b9 Mon Sep 17 00:00:00 2001 From: unnawut Date: Wed, 18 Mar 2026 21:55:53 +0700 Subject: [PATCH 2/5] default gossip_proposer_attestation to True --- .../test_types/block_spec.py | 2 +- .../fc/test_finalization_mid_processing.py | 18 ++++++++----- .../devnet/fc/test_fork_choice_reorgs.py | 26 +++++++++---------- .../devnet/fc/test_signature_aggregation.py | 16 +++++++++--- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/packages/testing/src/consensus_testing/test_types/block_spec.py b/packages/testing/src/consensus_testing/test_types/block_spec.py index 708741cb..ca6790fc 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -132,7 +132,7 @@ class BlockSpec(CamelModel): Enables more realistic test vectors without manual specification. """ - gossip_proposer_attestation: bool = False + gossip_proposer_attestation: bool = True """ Simulate the proposer gossiping an attestation after block production. diff --git a/tests/consensus/devnet/fc/test_finalization_mid_processing.py b/tests/consensus/devnet/fc/test_finalization_mid_processing.py index fbbe74ae..e4088525 100644 --- a/tests/consensus/devnet/fc/test_finalization_mid_processing.py +++ b/tests/consensus/devnet/fc/test_finalization_mid_processing.py @@ -56,12 +56,16 @@ def test_finalization_advances_mid_attestation_processing( fork_choice_test( steps=[ # Build chain through slot 7 + # + # Disable proposer gossip attestation for these blocks to + # control justification timing precisely. This test needs + # specific justification state at each step. BlockStep( - block=BlockSpec(slot=Slot(1), label="block_1"), + block=BlockSpec(slot=Slot(1), label="block_1", gossip_proposer_attestation=False), checks=StoreChecks(head_slot=Slot(1)), ), BlockStep( - block=BlockSpec(slot=Slot(2), label="block_2"), + block=BlockSpec(slot=Slot(2), label="block_2", gossip_proposer_attestation=False), checks=StoreChecks(head_slot=Slot(2)), ), # Slot 3: Justify slot 1 (source=0 -> target=1) @@ -70,6 +74,7 @@ def test_finalization_advances_mid_attestation_processing( block=BlockSpec( slot=Slot(3), label="block_3", + gossip_proposer_attestation=False, attestations=[ AggregatedAttestationSpec( validator_ids=[ @@ -91,25 +96,26 @@ def test_finalization_advances_mid_attestation_processing( ), # Extend chain to slot 7 BlockStep( - block=BlockSpec(slot=Slot(4), label="block_4"), + block=BlockSpec(slot=Slot(4), label="block_4", gossip_proposer_attestation=False), checks=StoreChecks(head_slot=Slot(4)), ), BlockStep( - block=BlockSpec(slot=Slot(5), label="block_5"), + block=BlockSpec(slot=Slot(5), label="block_5", gossip_proposer_attestation=False), checks=StoreChecks(head_slot=Slot(5)), ), BlockStep( - block=BlockSpec(slot=Slot(6), label="block_6"), + block=BlockSpec(slot=Slot(6), label="block_6", gossip_proposer_attestation=False), checks=StoreChecks(head_slot=Slot(6)), ), BlockStep( - block=BlockSpec(slot=Slot(7), label="block_7"), + block=BlockSpec(slot=Slot(7), label="block_7", gossip_proposer_attestation=False), checks=StoreChecks(head_slot=Slot(7)), ), # Slot 8: The critical block with both attestations BlockStep( block=BlockSpec( slot=Slot(8), + gossip_proposer_attestation=False, attestations=[ # Attestation A: Justify slot 2 and finalize slot 1 # Source will be slot 1 (latest_justified from parent state) diff --git a/tests/consensus/devnet/fc/test_fork_choice_reorgs.py b/tests/consensus/devnet/fc/test_fork_choice_reorgs.py index 573a6b17..6e806fa5 100644 --- a/tests/consensus/devnet/fc/test_fork_choice_reorgs.py +++ b/tests/consensus/devnet/fc/test_fork_choice_reorgs.py @@ -362,8 +362,11 @@ def test_reorg_with_slot_gaps( anchor_state=generate_pre_state(num_validators=10), steps=[ # Base at slot 1 + # + # Disable proposer gossip on all blocks for precise weight control. + # This test focuses on reorg behavior with slot gaps, not attestation flow. BlockStep( - block=BlockSpec(slot=Slot(1), label="base"), + block=BlockSpec(slot=Slot(1), label="base", gossip_proposer_attestation=False), checks=StoreChecks( head_slot=Slot(1), head_root_label="base", @@ -375,7 +378,6 @@ def test_reorg_with_slot_gaps( slot=Slot(3), parent_label="base", label="fork_a_3", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(3), @@ -384,7 +386,12 @@ def test_reorg_with_slot_gaps( ), # Fork B at slot 4 (competing, missed slot 2-3) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="base", label="fork_b_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="base", + label="fork_b_4", + gossip_proposer_attestation=False, + ), ), # Fork A at slot 7 (missed slots 4-6) BlockStep( @@ -392,7 +399,6 @@ def test_reorg_with_slot_gaps( slot=Slot(7), parent_label="fork_a_3", label="fork_a_7", - gossip_proposer_attestation=True, ), ), # Accept fork_a_7's proposer attestation to ensure it counts in fork choice. @@ -413,6 +419,7 @@ def test_reorg_with_slot_gaps( slot=Slot(8), parent_label="fork_b_4", label="fork_b_8", + gossip_proposer_attestation=False, attestations=[ AggregatedAttestationSpec( validator_ids=[ @@ -433,6 +440,7 @@ def test_reorg_with_slot_gaps( slot=Slot(9), parent_label="fork_b_8", label="fork_b_9", + gossip_proposer_attestation=False, ), ), # After acceptance, fork_b has 3 attestation votes in the block body @@ -617,7 +625,6 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(2), parent_label="base", label="fork_a_2", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(2), @@ -629,7 +636,6 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(3), parent_label="fork_a_2", label="fork_a_3", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(3), @@ -641,7 +647,6 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(4), parent_label="fork_a_3", label="fork_a_4", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(4), @@ -653,7 +658,6 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(5), parent_label="fork_a_4", label="fork_a_5", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(5), @@ -665,7 +669,6 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(6), parent_label="fork_a_5", label="fork_a_6", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(6), @@ -877,7 +880,6 @@ def test_reorg_on_newly_justified_slot( slot=Slot(2), parent_label="base", label="fork_a_1", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(2), @@ -891,7 +893,6 @@ def test_reorg_on_newly_justified_slot( slot=Slot(3), parent_label="fork_a_1", label="fork_a_2", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(3), @@ -905,7 +906,6 @@ def test_reorg_on_newly_justified_slot( slot=Slot(4), parent_label="fork_a_2", label="fork_a_3", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(4), @@ -919,7 +919,6 @@ def test_reorg_on_newly_justified_slot( slot=Slot(5), parent_label="base", label="fork_b_1", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(4), @@ -936,6 +935,7 @@ def test_reorg_on_newly_justified_slot( slot=Slot(6), parent_label="fork_b_1", label="fork_b_2", + gossip_proposer_attestation=False, attestations=[ # Aggregated attestation from validators 0, 1, 5, 6, 7, 8 # fork_b_1 should be able to justify without extra attestations diff --git a/tests/consensus/devnet/fc/test_signature_aggregation.py b/tests/consensus/devnet/fc/test_signature_aggregation.py index 1c2ebdaa..5cdfbc60 100644 --- a/tests/consensus/devnet/fc/test_signature_aggregation.py +++ b/tests/consensus/devnet/fc/test_signature_aggregation.py @@ -36,12 +36,17 @@ def test_multiple_specs_same_target_merge_into_one( fork_choice_test( steps=[ BlockStep( - block=BlockSpec(slot=Slot(1), label="block_1"), + block=BlockSpec( + slot=Slot(1), + label="block_1", + gossip_proposer_attestation=False, + ), checks=StoreChecks(head_slot=Slot(1)), ), BlockStep( block=BlockSpec( slot=Slot(2), + gossip_proposer_attestation=False, attestations=[ AggregatedAttestationSpec( validator_ids=[ValidatorIndex(0), ValidatorIndex(1)], @@ -231,12 +236,17 @@ def test_all_validators_attest_in_single_aggregation( fork_choice_test( steps=[ BlockStep( - block=BlockSpec(slot=Slot(1), label="block_1"), + block=BlockSpec( + slot=Slot(1), + label="block_1", + gossip_proposer_attestation=False, + ), checks=StoreChecks(head_slot=Slot(1)), ), BlockStep( block=BlockSpec( slot=Slot(2), + gossip_proposer_attestation=False, attestations=[ AggregatedAttestationSpec( validator_ids=[ @@ -292,7 +302,6 @@ def test_auto_collect_proposer_attestations( block=BlockSpec( slot=Slot(1), label="block_1", - gossip_proposer_attestation=True, ), checks=StoreChecks( head_slot=Slot(1), @@ -346,7 +355,6 @@ def test_auto_collect_combined_with_explicit_attestations( block=BlockSpec( slot=Slot(1), label="block_1", - gossip_proposer_attestation=True, ), checks=StoreChecks(head_slot=Slot(1)), ), From 77b1c6673bb66ab98b91e09586736deb803ddc2d Mon Sep 17 00:00:00 2001 From: unnawut Date: Fri, 20 Mar 2026 11:37:19 +0700 Subject: [PATCH 3/5] remove gossip_proposer_attestation and include_store_attestations --- .../test_fixtures/fork_choice.py | 48 +----- .../test_types/block_spec.py | 25 --- .../fc/test_attestation_target_selection.py | 147 ++++++++++++++++-- .../fc/test_finalization_mid_processing.py | 18 +-- .../devnet/fc/test_fork_choice_head.py | 95 +++++++++-- .../fc/test_lexicographic_tiebreaker.py | 34 +++- .../devnet/fc/test_signature_aggregation.py | 119 -------------- 7 files changed, 255 insertions(+), 231 deletions(-) diff --git a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py index c0d22fb8..f4d010d8 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py +++ b/packages/testing/src/consensus_testing/test_fixtures/fork_choice.py @@ -292,33 +292,6 @@ def make_fixture(self) -> Self: scheme=LEAN_ENV_TO_SCHEMES[self.lean_env], ) - # Simulate the proposer's gossip attestation. - # - # In the real system, attestation happens at interval >= 1 - # after the block is processed. Advance time to interval 1 - # and update head to match the real validator service behavior. - if step.block.gossip_proposer_attestation: - att_interval = Interval(int(block.slot) * int(INTERVALS_PER_SLOT) + 1) - store, _ = store.on_tick( - att_interval, has_proposal=False, is_aggregator=True - ) - store = store.update_head() - - proposer_index = block.proposer_index - proposer_att_data = store.produce_attestation_data(block.slot) - proposer_gossip_att = SignedAttestation( - validator_id=proposer_index, - data=proposer_att_data, - signature=key_manager.sign_attestation_data( - proposer_index, proposer_att_data - ), - ) - store = store.on_gossip_attestation( - proposer_gossip_att, - scheme=LEAN_ENV_TO_SCHEMES[self.lean_env], - is_aggregator=True, - ) - case AttestationStep(): # Process a gossip attestation. # Gossip attestations arrive outside of blocks. @@ -445,25 +418,7 @@ def _build_block_from_spec( aggregation_store, _ = working_store.aggregate() merged_store = aggregation_store.accept_new_attestations() - # Two sources of attestations: - # 1. Explicit attestations from the spec (always included) - # 2. Store attestations (only if include_store_attestations is True) - available_attestations: list[Attestation] - known_block_roots: set[Bytes32] | None = None - - if spec.include_store_attestations: - # Extract from merged payloads (contains both known and newly aggregated) - attestation_map = merged_store.extract_attestations_from_aggregated_payloads( - merged_store.latest_known_aggregated_payloads - ) - store_attestations = [ - Attestation(validator_id=vid, data=data) for vid, data in attestation_map.items() - ] - available_attestations = store_attestations + attestations - known_block_roots = set(store.blocks.keys()) - else: - # Use only explicit attestations from the spec - available_attestations = attestations + available_attestations = attestations # Build the block using spec logic. # @@ -476,7 +431,6 @@ def _build_block_from_spec( parent_root=parent_root, attestations=available_attestations, available_attestations=available_attestations, - known_block_roots=known_block_roots, aggregated_payloads=merged_store.latest_known_aggregated_payloads, ) diff --git a/packages/testing/src/consensus_testing/test_types/block_spec.py b/packages/testing/src/consensus_testing/test_types/block_spec.py index ca6790fc..de1ee733 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -116,28 +116,3 @@ class BlockSpec(CamelModel): Useful for tests that intentionally exercise slot mismatch failures. """ - - include_store_attestations: bool = False - """ - Automatically include available attestations in the block body. - - When True: - - Previous proposers' attestations flow into subsequent blocks - - Gossip attestations are automatically collected - - Combined with any explicitly specified attestations - - When False (default): - - Only explicitly specified attestations are included - - Enables more realistic test vectors without manual specification. - """ - - gossip_proposer_attestation: bool = True - """ - Simulate the proposer gossiping an attestation after block production. - - In the real system, the proposer gossips an attestation at interval 1 - using the attestation key, separate from the block's proposal-key - signature. This flag simulates that gossip so later blocks can - auto-collect it via include_store_attestations. - """ diff --git a/tests/consensus/devnet/fc/test_attestation_target_selection.py b/tests/consensus/devnet/fc/test_attestation_target_selection.py index 8044a7fa..12cebd38 100644 --- a/tests/consensus/devnet/fc/test_attestation_target_selection.py +++ b/tests/consensus/devnet/fc/test_attestation_target_selection.py @@ -2,6 +2,7 @@ import pytest from consensus_testing import ( + AggregatedAttestationSpec, BlockSpec, BlockStep, ForkChoiceTestFiller, @@ -9,6 +10,7 @@ ) from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex pytestmark = pytest.mark.valid_until("Devnet") @@ -84,35 +86,78 @@ def test_attestation_target_advances_with_attestations( fork_choice_test( steps=[ BlockStep( - block=BlockSpec(slot=Slot(1)), + block=BlockSpec(slot=Slot(1), label="block_1"), checks=StoreChecks( head_slot=Slot(1), attestation_target_slot=Slot(0), # Still at genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(2)), + block=BlockSpec( + slot=Slot(2), + label="block_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), checks=StoreChecks( head_slot=Slot(2), attestation_target_slot=Slot(0), # Still at genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(3)), + block=BlockSpec( + slot=Slot(3), + label="block_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), attestation_target_slot=Slot(0), # Still at genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(4)), + block=BlockSpec( + slot=Slot(4), + label="block_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="block_3", + ), + ], + ), checks=StoreChecks( head_slot=Slot(4), attestation_target_slot=Slot(1), # Advances to slot 1 ), ), BlockStep( - block=BlockSpec(slot=Slot(5)), + block=BlockSpec( + slot=Slot(5), + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="block_4", + ), + ], + ), checks=StoreChecks( head_slot=Slot(5), attestation_target_slot=Slot(2), # Continues advancing @@ -198,56 +243,132 @@ def test_attestation_target_with_extended_chain( fork_choice_test( steps=[ BlockStep( - block=BlockSpec(slot=Slot(1)), + block=BlockSpec(slot=Slot(1), label="block_1"), checks=StoreChecks( head_slot=Slot(1), attestation_target_slot=Slot(0), # Genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(2)), + block=BlockSpec( + slot=Slot(2), + label="block_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="block_1", + ), + ], + ), checks=StoreChecks( head_slot=Slot(2), attestation_target_slot=Slot(0), # Still genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(3)), + block=BlockSpec( + slot=Slot(3), + label="block_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="block_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), attestation_target_slot=Slot(0), # Still genesis ), ), BlockStep( - block=BlockSpec(slot=Slot(4)), + block=BlockSpec( + slot=Slot(4), + label="block_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="block_3", + ), + ], + ), checks=StoreChecks( head_slot=Slot(4), attestation_target_slot=Slot(1), # Advances to slot 1 ), ), BlockStep( - block=BlockSpec(slot=Slot(5)), + block=BlockSpec( + slot=Slot(5), + label="block_5", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="block_4", + ), + ], + ), checks=StoreChecks( head_slot=Slot(5), attestation_target_slot=Slot(2), # Stable at 2 ), ), BlockStep( - block=BlockSpec(slot=Slot(6)), + block=BlockSpec( + slot=Slot(6), + label="block_6", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="block_5", + ), + ], + ), checks=StoreChecks( head_slot=Slot(6), attestation_target_slot=Slot(3), # Continues to advance ), ), BlockStep( - block=BlockSpec(slot=Slot(7)), + block=BlockSpec( + slot=Slot(7), + label="block_7", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(6), + target_slot=Slot(6), + target_root_label="block_6", + ), + ], + ), checks=StoreChecks( head_slot=Slot(7), attestation_target_slot=Slot(4), # Continues advancing ), ), BlockStep( - block=BlockSpec(slot=Slot(8)), + block=BlockSpec( + slot=Slot(8), + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(7), + target_slot=Slot(7), + target_root_label="block_7", + ), + ], + ), checks=StoreChecks( head_slot=Slot(8), attestation_target_slot=Slot(5), # Continues advancing diff --git a/tests/consensus/devnet/fc/test_finalization_mid_processing.py b/tests/consensus/devnet/fc/test_finalization_mid_processing.py index e4088525..fbbe74ae 100644 --- a/tests/consensus/devnet/fc/test_finalization_mid_processing.py +++ b/tests/consensus/devnet/fc/test_finalization_mid_processing.py @@ -56,16 +56,12 @@ def test_finalization_advances_mid_attestation_processing( fork_choice_test( steps=[ # Build chain through slot 7 - # - # Disable proposer gossip attestation for these blocks to - # control justification timing precisely. This test needs - # specific justification state at each step. BlockStep( - block=BlockSpec(slot=Slot(1), label="block_1", gossip_proposer_attestation=False), + block=BlockSpec(slot=Slot(1), label="block_1"), checks=StoreChecks(head_slot=Slot(1)), ), BlockStep( - block=BlockSpec(slot=Slot(2), label="block_2", gossip_proposer_attestation=False), + block=BlockSpec(slot=Slot(2), label="block_2"), checks=StoreChecks(head_slot=Slot(2)), ), # Slot 3: Justify slot 1 (source=0 -> target=1) @@ -74,7 +70,6 @@ def test_finalization_advances_mid_attestation_processing( block=BlockSpec( slot=Slot(3), label="block_3", - gossip_proposer_attestation=False, attestations=[ AggregatedAttestationSpec( validator_ids=[ @@ -96,26 +91,25 @@ def test_finalization_advances_mid_attestation_processing( ), # Extend chain to slot 7 BlockStep( - block=BlockSpec(slot=Slot(4), label="block_4", gossip_proposer_attestation=False), + block=BlockSpec(slot=Slot(4), label="block_4"), checks=StoreChecks(head_slot=Slot(4)), ), BlockStep( - block=BlockSpec(slot=Slot(5), label="block_5", gossip_proposer_attestation=False), + block=BlockSpec(slot=Slot(5), label="block_5"), checks=StoreChecks(head_slot=Slot(5)), ), BlockStep( - block=BlockSpec(slot=Slot(6), label="block_6", gossip_proposer_attestation=False), + block=BlockSpec(slot=Slot(6), label="block_6"), checks=StoreChecks(head_slot=Slot(6)), ), BlockStep( - block=BlockSpec(slot=Slot(7), label="block_7", gossip_proposer_attestation=False), + block=BlockSpec(slot=Slot(7), label="block_7"), checks=StoreChecks(head_slot=Slot(7)), ), # Slot 8: The critical block with both attestations BlockStep( block=BlockSpec( slot=Slot(8), - gossip_proposer_attestation=False, attestations=[ # Attestation A: Justify slot 2 and finalize slot 1 # Source will be slot 1 (latest_justified from parent state) diff --git a/tests/consensus/devnet/fc/test_fork_choice_head.py b/tests/consensus/devnet/fc/test_fork_choice_head.py index b515ab01..d6453a4e 100644 --- a/tests/consensus/devnet/fc/test_fork_choice_head.py +++ b/tests/consensus/devnet/fc/test_fork_choice_head.py @@ -2,6 +2,7 @@ import pytest from consensus_testing import ( + AggregatedAttestationSpec, BlockSpec, BlockStep, ForkChoiceTestFiller, @@ -9,6 +10,7 @@ ) from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex pytestmark = pytest.mark.valid_until("Devnet") @@ -274,21 +276,32 @@ def test_head_switches_to_heavier_fork( ), checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a"), ), - # Competing fork B at slot 2 + # Competing fork B at slot 2 (equal weight, tiebreaker decides) BlockStep( block=BlockSpec( slot=Slot(2), parent_label="common", label="fork_b", ), - checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a"), + checks=StoreChecks( + head_slot=Slot(2), + lexicographic_head_among=["fork_a", "fork_b"], + ), ), - # Extend fork B - gives it more weight + # Extend fork B with an attestation for fork_b → gives it more weight BlockStep( block=BlockSpec( slot=Slot(3), - parent_label="fork_b", # Build on fork B + parent_label="fork_b", label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b", + ), + ], ), checks=StoreChecks(head_slot=Slot(3), head_root_label="fork_b_3"), ), @@ -330,34 +343,94 @@ def test_head_with_deep_fork_split( block=BlockSpec(slot=Slot(1), label="common"), checks=StoreChecks(head_slot=Slot(1), head_root_label="common"), ), - # Fork A: slots 2, 3, 4 + # Fork A: slots 2, 3, 4 with attestations building weight BlockStep( block=BlockSpec(slot=Slot(2), parent_label="common", label="fork_a_2"), checks=StoreChecks(head_slot=Slot(2), head_root_label="fork_a_2"), ), BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_a_2", + label="fork_a_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_2", + ), + ], + ), checks=StoreChecks(head_slot=Slot(3), head_root_label="fork_a_3"), ), BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_a_3", label="fork_a_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="fork_a_3", + label="fork_a_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], + ), checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), ), - # Fork B: slots 2, 3, 4, 5 (longer) + # Fork B: slots 2, 3, 4, 5 with more attestations to overtake BlockStep( block=BlockSpec(slot=Slot(2), parent_label="common", label="fork_b_2"), checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), ), BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_b_2", + label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b_2", + ), + ], + ), checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), ), BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="fork_b_3", + label="fork_b_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_b_3", + ), + ], + ), checks=StoreChecks(head_slot=Slot(4), head_root_label="fork_a_4"), ), BlockStep( - block=BlockSpec(slot=Slot(5), parent_label="fork_b_4", label="fork_b_5"), + block=BlockSpec( + slot=Slot(5), + parent_label="fork_b_4", + label="fork_b_5", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="fork_b_4", + ), + ], + ), checks=StoreChecks(head_slot=Slot(5), head_root_label="fork_b_5"), ), ], diff --git a/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py b/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py index 858d51f2..32605283 100644 --- a/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py +++ b/tests/consensus/devnet/fc/test_lexicographic_tiebreaker.py @@ -7,6 +7,7 @@ import pytest from consensus_testing import ( + AggregatedAttestationSpec, BlockSpec, BlockStep, ForkChoiceTestFiller, @@ -14,6 +15,7 @@ ) from lean_spec.subspecs.containers.slot import Slot +from lean_spec.subspecs.containers.validator import ValidatorIndex pytestmark = pytest.mark.valid_until("Devnet") @@ -62,7 +64,19 @@ def test_equal_weight_forks_use_lexicographic_tiebreaker( ), ), BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_a_2", + label="fork_a_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), head_root_label="fork_a_3", @@ -73,15 +87,27 @@ def test_equal_weight_forks_use_lexicographic_tiebreaker( block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), checks=StoreChecks( head_slot=Slot(3), - # Head remains on fork_a_3 (it has more weight: 2 blocks vs 1) + # Head remains on fork_a_3 (it has more weight: 1 vs 0) head_root_label="fork_a_3", ), ), BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_b_2", + label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), - # Both forks now have equal weight (2 blocks each) + # Both forks now have equal weight (1 attestation each) # # Tiebreaker determines the winner lexicographic_head_among=["fork_a_3", "fork_b_3"], diff --git a/tests/consensus/devnet/fc/test_signature_aggregation.py b/tests/consensus/devnet/fc/test_signature_aggregation.py index 5cdfbc60..27f9a364 100644 --- a/tests/consensus/devnet/fc/test_signature_aggregation.py +++ b/tests/consensus/devnet/fc/test_signature_aggregation.py @@ -39,14 +39,12 @@ def test_multiple_specs_same_target_merge_into_one( block=BlockSpec( slot=Slot(1), label="block_1", - gossip_proposer_attestation=False, ), checks=StoreChecks(head_slot=Slot(1)), ), BlockStep( block=BlockSpec( slot=Slot(2), - gossip_proposer_attestation=False, attestations=[ AggregatedAttestationSpec( validator_ids=[ValidatorIndex(0), ValidatorIndex(1)], @@ -239,14 +237,12 @@ def test_all_validators_attest_in_single_aggregation( block=BlockSpec( slot=Slot(1), label="block_1", - gossip_proposer_attestation=False, ), checks=StoreChecks(head_slot=Slot(1)), ), BlockStep( block=BlockSpec( slot=Slot(2), - gossip_proposer_attestation=False, attestations=[ AggregatedAttestationSpec( validator_ids=[ @@ -275,118 +271,3 @@ def test_all_validators_attest_in_single_aggregation( ), ], ) - - -def test_auto_collect_proposer_attestations( - fork_choice_test: ForkChoiceTestFiller, -) -> None: - """ - Proposer gossip attestations ARE auto-collected into future block bodies. - - Scenario - -------- - With automatic attestation collection enabled: - - Block 1: proposer (validator 1) gossips attestation using attestation key - - Block 2: auto-collection picks up the proposer's gossip attestation - - Expected - -------- - With dual keys, proposers gossip a separate attestation using their - attestation key (in addition to the proposal-key signature in the block - envelope). This gossip attestation enters the normal aggregation pipeline - and gets auto-collected into the next block body. - """ - fork_choice_test( - steps=[ - BlockStep( - block=BlockSpec( - slot=Slot(1), - label="block_1", - ), - checks=StoreChecks( - head_slot=Slot(1), - block_attestation_count=0, - ), - ), - BlockStep( - block=BlockSpec( - slot=Slot(2), - label="block_2", - include_store_attestations=True, - ), - checks=StoreChecks( - head_slot=Slot(2), - block_attestation_count=1, - block_attestations=[ - AggregatedAttestationCheck( - participants={1}, - attestation_slot=Slot(1), - target_slot=Slot(0), - ), - ], - ), - ), - ], - ) - - -def test_auto_collect_combined_with_explicit_attestations( - fork_choice_test: ForkChoiceTestFiller, -) -> None: - """ - Combine auto-collection with explicit attestation specs. - - Scenario - -------- - Block 2 uses both mechanisms: - - Auto-collection picks up block 1's proposer gossip attestation (validator 1) - - Explicit spec adds validators 0 and 3 - - Expected - -------- - Block body contains both the auto-collected proposer gossip attestation - and the explicit attestations. The proposer's gossip attestation (attestation - key) enters the normal aggregation pipeline and merges with any explicit - attestations targeting the same data. - """ - fork_choice_test( - steps=[ - BlockStep( - block=BlockSpec( - slot=Slot(1), - label="block_1", - ), - checks=StoreChecks(head_slot=Slot(1)), - ), - BlockStep( - block=BlockSpec( - slot=Slot(2), - include_store_attestations=True, - attestations=[ - AggregatedAttestationSpec( - validator_ids=[ValidatorIndex(0), ValidatorIndex(3)], - slot=Slot(1), - target_slot=Slot(1), - target_root_label="block_1", - ), - ], - ), - checks=StoreChecks( - head_slot=Slot(2), - block_attestation_count=2, - block_attestations=[ - AggregatedAttestationCheck( - participants={1}, - attestation_slot=Slot(1), - target_slot=Slot(0), - ), - AggregatedAttestationCheck( - participants={0, 3}, - attestation_slot=Slot(1), - target_slot=Slot(1), - ), - ], - ), - ), - ], - ) From 4ff54eb1b96265f1b62d186324aaab8aee386b7b Mon Sep 17 00:00:00 2001 From: unnawut Date: Fri, 20 Mar 2026 11:54:45 +0700 Subject: [PATCH 4/5] more fork choice test updates --- .../fc/test_attestation_target_selection.py | 102 ++-- .../devnet/fc/test_fork_choice_reorgs.py | 454 ++++++++++++------ 2 files changed, 364 insertions(+), 192 deletions(-) diff --git a/tests/consensus/devnet/fc/test_attestation_target_selection.py b/tests/consensus/devnet/fc/test_attestation_target_selection.py index 12cebd38..7726c066 100644 --- a/tests/consensus/devnet/fc/test_attestation_target_selection.py +++ b/tests/consensus/devnet/fc/test_attestation_target_selection.py @@ -409,51 +409,67 @@ def test_attestation_target_justifiable_constraint( The test verifies that the target selection algorithm respects these rules and never selects a non-justifiable target. """ - fork_choice_test( - steps=[ + num_validators = 4 + expected_targets = { + 1: 0, # 3-slot walkback reaches safe target at slot 0 + 2: 0, # 3-slot walkback reaches safe target at slot 0 + 3: 0, # 3-slot walkback reaches safe target at slot 0 + 4: 1, # delta = 4 - 3 - 0 = 1, Rule 1: delta 1 ≤ 5 + 5: 2, # delta = 5 - 3 - 0 = 2, Rule 1: delta 2 ≤ 5 + 6: 3, # delta = 6 - 3 - 0 = 3, Rule 1: delta 3 ≤ 5 + 7: 4, # delta = 7 - 3 - 0 = 4, Rule 1: delta 4 ≤ 5 + 8: 5, # delta = 8 - 3 - 0 = 5, Rule 1: delta 5 ≤ 5 + 9: 6, # delta = 6 - 0 = 6, Rule 3: pronic number (2*3) + 10: 6, # delta = 10 - 3 - 0 = 7 + 11: 6, # delta = 11 - 3 - 0 = 8 + 12: 9, # delta = 9 - 0 = 9, Rule 2: perfect square (3^2) + 13: 9, # delta = 13 - 3 - 0 = 10 + 14: 9, # delta = 14 - 3 - 0 = 11 + 15: 12, # delta = 15 - 3 - 0 = 12, Rule 3: pronic number (3*4) + 16: 12, # delta = 16 - 3 - 0 = 13 + 17: 12, # delta = 17 - 3 - 0 = 14 + 18: 12, # delta = 18 - 3 - 0 = 15 + 19: 16, # delta = 19 - 3 - 0 = 16, Rule 2: perfect square (4^2) + 20: 16, # delta = 20 - 3 - 0 = 17 + 21: 16, # delta = 21 - 3 - 0 = 18 + 22: 16, # delta = 22 - 3 - 0 = 19 + 23: 20, # delta = 23 - 3 - 0 = 20, Rule 3: pronic number (4*5) + 24: 20, # delta = 24 - 3 - 0 = 21 + 25: 20, # delta = 25 - 3 - 0 = 22 + 26: 20, # delta = 26 - 3 - 0 = 23 + 27: 20, # delta = 27 - 3 - 0 = 24 + 28: 25, # delta = 28 - 3 - 0 = 25, Rule 2: perfect square (5^2) + 29: 25, # delta = 29 - 3 - 0 = 26 + 30: 25, # delta = 30 - 3 - 0 = 27 + } + + steps = [] + for i in range(1, 31): + label = f"block_{i}" + attestations = None + if i >= 2: + prev_slot = i - 1 + prev_proposer = ValidatorIndex(prev_slot % num_validators) + attestations = [ + AggregatedAttestationSpec( + validator_ids=[prev_proposer], + slot=Slot(prev_slot), + target_slot=Slot(prev_slot), + target_root_label=f"block_{prev_slot}", + ), + ] + steps.append( BlockStep( - block=BlockSpec(slot=Slot(i)), + block=BlockSpec( + slot=Slot(i), + label=label, + attestations=attestations, + ), checks=StoreChecks( head_slot=Slot(i), - attestation_target_slot=Slot( - # Mapping of current slot -> expected target slot - # delta = current_slot - JUSTIFICATION_LOOKBACK_SLOTS - finalized_slot - # delta = current_slot - 3 - 0 - { - 1: 0, # 3-slot walkback reaches safe target at slot 0 - 2: 0, # 3-slot walkback reaches safe target at slot 0 - 3: 0, # 3-slot walkback reaches safe target at slot 0 - 4: 1, # delta = 4 - 3 - 0 = 1, Rule 1: delta 1 ≤ 5 - 5: 2, # delta = 5 - 3 - 0 = 2, Rule 1: delta 2 ≤ 5 - 6: 3, # delta = 6 - 3 - 0 = 3, Rule 1: delta 3 ≤ 5 - 7: 4, # delta = 7 - 3 - 0 = 4, Rule 1: delta 4 ≤ 5 - 8: 5, # delta = 8 - 3 - 0 = 5, Rule 1: delta 5 ≤ 5 - 9: 6, # delta = 6 - 0 = 6, Rule 3: pronic number (2*3) - 10: 6, # delta = 10 - 3 - 0 = 7 - 11: 6, # delta = 11 - 3 - 0 = 8 - 12: 9, # delta = 9 - 0 = 9, Rule 2: perfect square (3^2) - 13: 9, # delta = 13 - 3 - 0 = 10 - 14: 9, # delta = 14 - 3 - 0 = 11 - 15: 12, # delta = 15 - 3 - 0 = 12, Rule 3: pronic number (3*4) - 16: 12, # delta = 16 - 3 - 0 = 13 - 17: 12, # delta = 17 - 3 - 0 = 14 - 18: 12, # delta = 18 - 3 - 0 = 15 - 19: 16, # delta = 19 - 3 - 0 = 16, Rule 2: perfect square (4^2) - 20: 16, # delta = 20 - 3 - 0 = 17 - 21: 16, # delta = 21 - 3 - 0 = 18 - 22: 16, # delta = 22 - 3 - 0 = 19 - 23: 20, # delta = 23 - 3 - 0 = 20, Rule 3: pronic number (4*5) - 24: 20, # delta = 24 - 3 - 0 = 21 - 25: 20, # delta = 25 - 3 - 0 = 22 - 26: 20, # delta = 26 - 3 - 0 = 23 - 27: 20, # delta = 27 - 3 - 0 = 24 - 28: 25, # delta = 28 - 3 - 0 = 25, Rule 2: perfect square (5^2) - 29: 25, # delta = 29 - 3 - 0 = 26 - 30: 25, # delta = 30 - 3 - 0 = 27 - }[i] - ), + attestation_target_slot=Slot(expected_targets[i]), ), ) - for i in range(1, 31) - ], - ) + ) + + fork_choice_test(steps=steps) diff --git a/tests/consensus/devnet/fc/test_fork_choice_reorgs.py b/tests/consensus/devnet/fc/test_fork_choice_reorgs.py index 6e806fa5..dba8edc4 100644 --- a/tests/consensus/devnet/fc/test_fork_choice_reorgs.py +++ b/tests/consensus/devnet/fc/test_fork_choice_reorgs.py @@ -7,7 +7,6 @@ BlockStep, ForkChoiceTestFiller, StoreChecks, - TickStep, generate_pre_state, ) @@ -82,15 +81,23 @@ def test_simple_one_block_reorg( ), checks=StoreChecks( head_slot=Slot(2), - head_root_label="fork_a_2", # Equal weight, head unchanged + lexicographic_head_among=["fork_a_2", "fork_b_2"], ), ), - # Extend fork B → triggers reorg to fork B + # Extend fork B with attestation → triggers reorg to fork B BlockStep( block=BlockSpec( slot=Slot(3), parent_label="fork_b_2", label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b_2", + ), + ], ), checks=StoreChecks( head_slot=Slot(3), @@ -110,37 +117,37 @@ def test_two_block_reorg_progressive_building( Scenario -------- - Slot 1: Common ancestor - - Slots 2-3: Fork A extends to 2 blocks ahead - - Slots 2-4: Fork B slowly catches up, then overtakes + - Slots 2-3: Fork A extends to 2 blocks + - Slots 4-6: Fork B starts late from base, catches up, then overtakes Chain State Evolution: Slot 1: base Slot 2: base ← fork_a_2 (head) - base ← fork_b_2 - Slot 3: base ← fork_a_2 ← fork_a_3 (head) - base ← fork_b_2 - Slot 4: base ← fork_a_2 ← fork_a_3 (was head) - base ← fork_b_2 ← fork_b_3 (tie at depth 2) - Slot 5: base ← fork_a_2 ← fork_a_3 (abandoned) - base ← fork_b_2 ← fork_b_3 ← fork_b_4 (head - REORG!) + Slot 3: base ← fork_a_2 ← fork_a_3 (head, weight=1) + Slot 4: base ← fork_a_2 ← fork_a_3 (head, weight=1) + base ← fork_b_4 + Slot 5: base ← fork_a_2 ← fork_a_3 (head, weight=1) + base ← fork_b_4 ← fork_b_5 + Slot 6: base ← fork_a_2 ← fork_a_3 (weight=1, abandoned) + base ← fork_b_4 ← fork_b_5 ← fork_b_6 (head, weight=2 - REORG!) Expected Behavior ----------------- - 1. Fork A leads for slots 2-3 (2 blocks ahead) - 2. Fork B catches up at slot 4 (both at depth 2) - 3. Fork B overtakes at slot 5 (3 blocks vs 2) + 1. Fork A leads for slots 2-3 (2 blocks, weight=1) + 2. Fork B starts late at slot 4 (builds on base) + 3. Fork B overtakes at slot 6 (weight=2 > fork A's 1) 4. Two-block reorg: fork_a_2 and fork_a_3 become non-canonical Reorg Details: - **Depth**: 2 blocks - **Trigger**: Progressive building on alternative fork - - **Weight advantage**: Fork B has 3 proposer attestations vs 2 + - **Weight advantage**: Fork B has 2 attestations vs 1 Why This Matters ---------------- Demonstrates that an initially leading fork can be overtaken if: - Proposers switch to building on the alternative fork - - The alternative fork accumulates more blocks over time + - The alternative fork accumulates more attestations over time - Network temporarily favored one fork but consensus shifted """ fork_choice_test( @@ -161,36 +168,60 @@ def test_two_block_reorg_progressive_building( head_root_label="fork_a_2", ), ), - # Fork B: slot 2 (starts competing) + # Fork A: slot 3 (extends lead with attestation, weight=1) BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_a_2", + label="fork_a_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_2", + ), + ], + ), checks=StoreChecks( - head_slot=Slot(2), - head_root_label="fork_a_2", # Fork A maintains lead + head_slot=Slot(3), + head_root_label="fork_a_3", # Fork A leads (weight=1) ), ), - # Fork A: slot 3 (extends lead) + # Fork B: slot 4 (starts late from base, fork A still leads) BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), + block=BlockSpec(slot=Slot(4), parent_label="base", label="fork_b_4"), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_a_3", # Fork A leads by 2 blocks + head_root_label="fork_a_3", # Fork A leads (weight=1 vs 0) ), ), - # Fork B: slot 3 (catches up to depth 2) + # Fork B: slot 5 BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), + block=BlockSpec(slot=Slot(5), parent_label="fork_b_4", label="fork_b_5"), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_a_3", # Tie at depth 2, fork_a wins tie + head_root_label="fork_a_3", # Fork A still leads (weight=1 vs 0) ), ), - # Fork B: slot 4 (extends to depth 3, overtakes) + # Fork B: slot 6 (2 attestations overtake fork A's 1) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), + block=BlockSpec( + slot=Slot(6), + parent_label="fork_b_5", + label="fork_b_6", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1), ValidatorIndex(3)], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="fork_b_5", + ), + ], + ), checks=StoreChecks( - head_slot=Slot(4), - head_root_label="fork_b_4", # REORG! 2-block deep + head_slot=Slot(6), + head_root_label="fork_b_6", # REORG! 2-block deep ), ), ], @@ -201,38 +232,22 @@ def test_three_block_deep_reorg( fork_choice_test: ForkChoiceTestFiller, ) -> None: """ - Deep three-block reorg from established fork to alternative. + Three-block reorg where a shorter fork wins via attestation weight. Scenario -------- - Slot 1: Common base - - Slots 2-4: Fork A builds 3-block lead - - Slots 2-5: Fork B slowly builds, then surpasses with 4 blocks - - Timeline: - Slot 2: Fork A leads (1 vs 0) - Slot 3: Fork A leads (2 vs 1) - Slot 4: Fork A leads (3 vs 2) - Slot 5: Fork B overtakes (4 vs 3) → 3-block deep reorg + - Slots 2-4: Fork A builds 3-block chain with 2 attestations (validators 2, 3) + - Slot 5: Fork B branches from base with 0 weight, fork A still leads + - Slot 6: Fork B gains 3 attestations (validators 0, 2, 5) → reorg - Expected Behavior - ----------------- - 1. Fork A establishes 3-block canonical chain (slots 2-4) - 2. Fork B steadily builds parallel chain - 3. At slot 5, fork B has 4 blocks vs fork A's 3 blocks - 4. Fork choice switches to fork B - 5. Three blocks (fork_a slots 2-4) become non-canonical + Fork B is shorter (2 blocks) but heavier (3 attestations vs 1). + Validator 2 switches from fork A to fork B, reducing fork A's net weight. - Reorg Details: - - **Depth**: 3 blocks (deepest in this test suite) - - **Trigger**: Alternative fork becomes longer - - Why This Matters - ---------------- - Deep reorgs (3+ blocks) are rare in healthy networks but can happen: - - Network partitions lasting multiple slots - - Coordinated validator behavior (intentional or accidental) - - Major network latency events + Timeline: + Slot 4: Fork A = 2 (validators 2, 3), fork B = 0 + Slot 5: Fork A = 2, fork B = 0 (fork B has 1 block, still lighter) + Slot 6: Fork A = 1 (validator 3), fork B = 3 (validators 0, 2, 5) → reorg Properties verified: - Fork choice correctly switches even after multiple canonical blocks @@ -254,7 +269,7 @@ def test_three_block_deep_reorg( head_root_label="base", ), ), - # Fork A: slots 2-4 (builds 3-block lead) + # Fork A: slot 2 BlockStep( block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_a_2"), checks=StoreChecks( @@ -262,14 +277,6 @@ def test_three_block_deep_reorg( head_root_label="fork_a_2", ), ), - # Fork B: slot 2 (starts competing) - BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), - checks=StoreChecks( - head_slot=Slot(2), - head_root_label="fork_a_2", - ), - ), # Fork A: slot 3 BlockStep( block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), @@ -278,36 +285,58 @@ def test_three_block_deep_reorg( head_root_label="fork_a_3", ), ), - # Fork B: slot 3 + # Fork A: slot 4 (with attestations for fork_a_3, weight=2) BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), - checks=StoreChecks( - head_slot=Slot(3), - head_root_label="fork_a_3", # Fork A still leads + block=BlockSpec( + slot=Slot(4), + parent_label="fork_a_3", + label="fork_a_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2), ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], ), - ), - # Fork A: slot 4 (3 blocks deep) - BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_a_3", label="fork_a_4"), checks=StoreChecks( head_slot=Slot(4), head_root_label="fork_a_4", ), ), - # Fork B: slot 4 (catches up to 3 blocks) + # Fork B: slot 5 (branches from base, fork A still leads) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), + block=BlockSpec(slot=Slot(5), parent_label="base", label="fork_b_5"), checks=StoreChecks( head_slot=Slot(4), - head_root_label="fork_a_4", # Tie, fork_a wins + head_root_label="fork_a_4", # Fork A leads (weight=2 vs 0) ), ), - # Fork B: slot 5 (4 blocks deep, overtakes) + # Fork B: slot 6 (3 attestations overtake fork A's weight) + # Validator 2 switches allegiance: was attesting to fork A, now fork B. + # Net weight: fork A = 1 (validator 3), fork B = 3 (validators 0, 2, 5) BlockStep( - block=BlockSpec(slot=Slot(5), parent_label="fork_b_4", label="fork_b_5"), + block=BlockSpec( + slot=Slot(6), + parent_label="fork_b_5", + label="fork_b_6", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ + ValidatorIndex(0), + ValidatorIndex(2), + ValidatorIndex(5), + ], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="fork_b_5", + ), + ], + ), checks=StoreChecks( - head_slot=Slot(5), - head_root_label="fork_b_5", # DEEP REORG! 3 blocks + head_slot=Slot(6), + head_root_label="fork_b_6", # DEEP REORG! 3 blocks ), ), ], @@ -326,7 +355,7 @@ def test_reorg_with_slot_gaps( - Slot 3: Fork A (skipping slot 2) - Slot 4: Fork B (competing) - Slot 7: Fork A extended (skipping slots 4-6) - - Slot 8: Fork B extended (skipping slots 5-7) + - Slot 8: Fork B extended with explicit attestations (skipping slots 5-7) - Slot 9: Fork B extended again → triggers reorg Missed Slots: 2, 5, 6 (no blocks produced) @@ -335,13 +364,19 @@ def test_reorg_with_slot_gaps( ----------------- 1. Sparse block production doesn't affect fork choice logic 2. Weight calculation only considers actual blocks - 3. Reorg happens based on block count, not slot numbers - 4. Fork B with 3 blocks beats fork A with 2 blocks + 3. Reorg happens based on attestation weight, not slot numbers - Reorg Details: - - **Depth**: 2 blocks (fork_a slots 3, 7) - - **Trigger**: Progressive building despite gaps - - **Weight**: 3 proposer attestations vs 2 + Weight Dynamics + --------------- + Fork A accumulates 3 organic proposer gossip votes: + + - V3 (fork_a_3 proposer): votes for Fork A + - V4 (fork_b_4 proposer): votes for Fork A (head points to A when V4 attests) + - V7 (fork_a_7 proposer): votes for Fork A + + Fork B overcomes with 4 explicit attestations in fork_b_8's body + from non-proposer validators {0, 2, 5, 6}, then reinforced by + fork_b_8 and fork_b_9 proposer gossip. Why This Matters ---------------- @@ -362,11 +397,8 @@ def test_reorg_with_slot_gaps( anchor_state=generate_pre_state(num_validators=10), steps=[ # Base at slot 1 - # - # Disable proposer gossip on all blocks for precise weight control. - # This test focuses on reorg behavior with slot gaps, not attestation flow. BlockStep( - block=BlockSpec(slot=Slot(1), label="base", gossip_proposer_attestation=False), + block=BlockSpec(slot=Slot(1), label="base"), checks=StoreChecks( head_slot=Slot(1), head_root_label="base", @@ -390,21 +422,23 @@ def test_reorg_with_slot_gaps( slot=Slot(4), parent_label="base", label="fork_b_4", - gossip_proposer_attestation=False, ), ), - # Fork A at slot 7 (missed slots 4-6) + # Fork A at slot 7 (missed slots 4-6) with attestation for fork A BlockStep( block=BlockSpec( slot=Slot(7), parent_label="fork_a_3", label="fork_a_7", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], ), - ), - # Accept fork_a_7's proposer attestation to ensure it counts in fork choice. - # fork_a now has 2 attestation votes (from fork_a_3 and fork_a_7 proposers). - TickStep( - time=(7 * 5 + 4), # Slot 7, interval 4 (acceptance) checks=StoreChecks( head_slot=Slot(7), head_root_label="fork_a_7", @@ -412,20 +446,20 @@ def test_reorg_with_slot_gaps( ), # Fork B at slot 8 with explicit attestations for fork_b's chain. # - # Three validators explicitly attest to fork_b_4 as head, - # giving fork_b 3 attestation votes vs fork_a's 2. + # Four non-proposer validators explicitly attest to fork_b_4, + # giving Fork B 4 votes vs Fork A's 1. BlockStep( block=BlockSpec( slot=Slot(8), parent_label="fork_b_4", label="fork_b_8", - gossip_proposer_attestation=False, attestations=[ AggregatedAttestationSpec( validator_ids=[ + ValidatorIndex(0), + ValidatorIndex(2), ValidatorIndex(5), ValidatorIndex(6), - ValidatorIndex(7), ], slot=Slot(4), target_slot=Slot(4), @@ -434,20 +468,17 @@ def test_reorg_with_slot_gaps( ], ), ), - # Fork B at slot 9 (3 blocks + 3 attestation votes) + # Fork B at slot 9 BlockStep( block=BlockSpec( slot=Slot(9), parent_label="fork_b_8", label="fork_b_9", - gossip_proposer_attestation=False, ), - ), - # After acceptance, fork_b has 3 attestation votes in the block body - # while fork_a has 2 proposer gossip attestation votes. - # fork_b overtakes. REORG. - TickStep( - time=(9 * 5 + 4), # Slot 9, interval 4 (end of slot) + # Fork B overtakes Fork A. REORG. + # + # Fork A: 1 vote (V3 explicit) + # Fork B: 4 votes (V0, V2, V5, V6 explicit) checks=StoreChecks( head_slot=Slot(9), head_root_label="fork_b_9", # REORG with sparse blocks @@ -517,6 +548,9 @@ def test_three_way_fork_competition( ), ), # Three-way fork at slot 2 + # + # Each fork includes a different attestation in its body so that + # the blocks produce distinct roots (identical bodies → identical roots). BlockStep( block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_a_2"), checks=StoreChecks( @@ -525,41 +559,89 @@ def test_three_way_fork_competition( ), ), BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), + block=BlockSpec( + slot=Slot(2), + parent_label="base", + label="fork_b_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="base", + ), + ], + ), checks=StoreChecks( head_slot=Slot(2), - head_root_label="fork_a_2", # Tie-breaker maintains fork_a + lexicographic_head_among=["fork_a_2", "fork_b_2"], ), ), BlockStep( - block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_c_2"), + block=BlockSpec( + slot=Slot(2), + parent_label="base", + label="fork_c_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(1), + target_slot=Slot(1), + target_root_label="base", + ), + ], + ), checks=StoreChecks( head_slot=Slot(2), - head_root_label="fork_a_2", # Three-way tie, fork_a wins + lexicographic_head_among=["fork_a_2", "fork_b_2", "fork_c_2"], ), ), - # Fork C extends to slot 3 → takes lead + # Fork C extends to slot 3 with attestation → takes lead (weight=1) BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_c_2", label="fork_c_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_c_2", + label="fork_c_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_c_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_c_3", # Fork C now leads (2 blocks) + head_root_label="fork_c_3", # Fork C now leads (weight=1) ), ), - # Fork B extends to slot 3 → ties with fork C + # Fork B extends to slot 3 (no attestation, fork C still leads) BlockStep( block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_c_3", # Tie (both 2 blocks), fork_c maintains + head_root_label="fork_c_3", # Fork C leads (weight=1 vs 0) ), ), - # Fork B extends to slot 4 → wins with 3 blocks + # Fork B extends to slot 4 with 2 attestations → wins (weight=2 vs 1) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="fork_b_3", + label="fork_b_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1), ValidatorIndex(2)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_b_3", + ), + ], + ), checks=StoreChecks( head_slot=Slot(4), - head_root_label="fork_b_4", # Fork B wins (3 blocks > 2) + head_root_label="fork_b_4", # Fork B wins (weight=2 > 1) ), ), ], @@ -616,10 +698,7 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( head_root_label="base", ), ), - # Fork A builds 5-block lead. - # - # Each block's proposer gossips an attestation so it contributes - # weight to the fork choice. + # Fork A builds 5-block lead with attestations for weight. BlockStep( block=BlockSpec( slot=Slot(2), @@ -636,6 +715,14 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(3), parent_label="fork_a_2", label="fork_a_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_2", + ), + ], ), checks=StoreChecks( head_slot=Slot(3), @@ -647,6 +734,14 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(4), parent_label="fork_a_3", label="fork_a_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], ), checks=StoreChecks( head_slot=Slot(4), @@ -658,6 +753,14 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(5), parent_label="fork_a_4", label="fork_a_5", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(4)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="fork_a_4", + ), + ], ), checks=StoreChecks( head_slot=Slot(5), @@ -669,10 +772,18 @@ def test_reorg_prevention_heavy_fork_resists_light_competition( slot=Slot(6), parent_label="fork_a_5", label="fork_a_6", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(5)], + slot=Slot(5), + target_slot=Slot(5), + target_root_label="fork_a_5", + ), + ], ), checks=StoreChecks( head_slot=Slot(6), - head_root_label="fork_a_6", # Fork A has 6-block chain + head_root_label="fork_a_6", # Fork A has weight=4 ), ), # Fork B attempts to compete (starting from base, building late) @@ -770,52 +881,88 @@ def test_back_and_forth_reorg_oscillation( head_root_label="fork_a_2", # Fork A leads ), ), - # Fork B: slot 2 (ties) + # Fork B: slot 2 (ties, tiebreaker decides) BlockStep( block=BlockSpec(slot=Slot(2), parent_label="base", label="fork_b_2"), checks=StoreChecks( head_slot=Slot(2), - head_root_label="fork_a_2", # Tie, fork_a maintains + lexicographic_head_among=["fork_a_2", "fork_b_2"], ), ), - # Fork B: slot 3 (extends, takes lead) → REORG #1 + # Fork B: slot 3 with attestation → REORG #1 (B weight=1 vs A weight=0) BlockStep( - block=BlockSpec(slot=Slot(3), parent_label="fork_b_2", label="fork_b_3"), + block=BlockSpec( + slot=Slot(3), + parent_label="fork_b_2", + label="fork_b_3", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(3)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_b_2", + ), + ], + ), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_b_3", # Fork B now leads (2 vs 1) + head_root_label="fork_b_3", # Fork B leads (weight=1 vs 0) ), ), - # Fork A: slot 3 (catches up, ties) + # Fork A: slot 3 (catches up, no attestation) BlockStep( block=BlockSpec(slot=Slot(3), parent_label="fork_a_2", label="fork_a_3"), checks=StoreChecks( head_slot=Slot(3), - head_root_label="fork_b_3", # Tie (both 2), fork_b maintains + head_root_label="fork_b_3", # Fork B maintains (weight=1 vs 0) ), ), - # Fork A: slot 4 (extends, takes lead) → REORG #2 + # Fork A: slot 4 with 2 attestations → REORG #2 (A weight=2 vs B weight=1) BlockStep( - block=BlockSpec(slot=Slot(4), parent_label="fork_a_3", label="fork_a_4"), + block=BlockSpec( + slot=Slot(4), + parent_label="fork_a_3", + label="fork_a_4", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(0), ValidatorIndex(4)], + slot=Slot(3), + target_slot=Slot(3), + target_root_label="fork_a_3", + ), + ], + ), checks=StoreChecks( head_slot=Slot(4), - head_root_label="fork_a_4", # Fork A back on top (3 vs 2) + head_root_label="fork_a_4", # Fork A back on top (weight=2 vs 1) ), ), - # Fork B: slot 4 (catches up, ties) + # Fork B: slot 4 (catches up, no attestation) BlockStep( block=BlockSpec(slot=Slot(4), parent_label="fork_b_3", label="fork_b_4"), checks=StoreChecks( head_slot=Slot(4), - head_root_label="fork_a_4", # Tie (both 3), fork_a maintains + head_root_label="fork_a_4", # Fork A maintains (weight=2 vs 1) ), ), - # Fork B: slot 5 (extends, takes lead) → REORG #3 + # Fork B: slot 5 with 2 attestations → REORG #3 (B weight=3 vs A weight=2) BlockStep( - block=BlockSpec(slot=Slot(5), parent_label="fork_b_4", label="fork_b_5"), + block=BlockSpec( + slot=Slot(5), + parent_label="fork_b_4", + label="fork_b_5", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1), ValidatorIndex(5)], + slot=Slot(4), + target_slot=Slot(4), + target_root_label="fork_b_4", + ), + ], + ), checks=StoreChecks( head_slot=Slot(5), - head_root_label="fork_b_5", # Fork B wins final round (4 vs 3) + head_root_label="fork_b_5", # Fork B wins final round (weight=3 vs 2) ), ), ], @@ -867,14 +1014,13 @@ def test_reorg_on_newly_justified_slot( steps=[ # Common base at slot 1 BlockStep( - block=BlockSpec(slot=Slot(1), label="base", gossip_proposer_attestation=True), + block=BlockSpec(slot=Slot(1), label="base"), checks=StoreChecks( head_slot=Slot(1), head_root_label="base", ), ), # Fork A: slot 2 - # Fork A is the heaviest chain (1 block from justified slot) BlockStep( block=BlockSpec( slot=Slot(2), @@ -886,13 +1032,24 @@ def test_reorg_on_newly_justified_slot( head_root_label="fork_a_1", ), ), - # Fork A: slot 3 - # Fork A is the heaviest chain (2 blocks from justified slot) + # Fork A: slot 3 with attestation targeting fork_a_1 + # + # V2's head vote points to fork_a_1, which is in fork A's subtree. + # This gives fork A weight=1, ensuring it leads over fork B + # until fork B achieves justification. BlockStep( block=BlockSpec( slot=Slot(3), parent_label="fork_a_1", label="fork_a_2", + attestations=[ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex(2)], + slot=Slot(2), + target_slot=Slot(2), + target_root_label="fork_a_1", + ), + ], ), checks=StoreChecks( head_slot=Slot(3), @@ -935,7 +1092,6 @@ def test_reorg_on_newly_justified_slot( slot=Slot(6), parent_label="fork_b_1", label="fork_b_2", - gossip_proposer_attestation=False, attestations=[ # Aggregated attestation from validators 0, 1, 5, 6, 7, 8 # fork_b_1 should be able to justify without extra attestations From 92c96070a8bc9d66b306204807f205b18368fb3b Mon Sep 17 00:00:00 2001 From: unnawut Date: Fri, 20 Mar 2026 12:01:58 +0700 Subject: [PATCH 5/5] simplify test_attestation_target_justifiable_constraint --- .../fc/test_attestation_target_selection.py | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/consensus/devnet/fc/test_attestation_target_selection.py b/tests/consensus/devnet/fc/test_attestation_target_selection.py index 7726c066..a62fcb69 100644 --- a/tests/consensus/devnet/fc/test_attestation_target_selection.py +++ b/tests/consensus/devnet/fc/test_attestation_target_selection.py @@ -445,25 +445,24 @@ def test_attestation_target_justifiable_constraint( steps = [] for i in range(1, 31): - label = f"block_{i}" - attestations = None - if i >= 2: - prev_slot = i - 1 - prev_proposer = ValidatorIndex(prev_slot % num_validators) - attestations = [ - AggregatedAttestationSpec( - validator_ids=[prev_proposer], - slot=Slot(prev_slot), - target_slot=Slot(prev_slot), - target_root_label=f"block_{prev_slot}", - ), - ] steps.append( BlockStep( block=BlockSpec( slot=Slot(i), - label=label, - attestations=attestations, + label=f"block_{i}", + attestations=( + [ + AggregatedAttestationSpec( + validator_ids=[ValidatorIndex((i - 1) % num_validators)], + slot=Slot(i - 1), + target_slot=Slot(i - 1), + target_root_label=f"block_{i - 1}", + ), + ] + # Slot 1 can't attest to genesis (root 0x00 not in store.blocks) + if i >= 2 + else None + ), ), checks=StoreChecks( head_slot=Slot(i),