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 a25d3e60..3acc9b3e 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -4,6 +4,7 @@ from collections import defaultdict +from lean_spec.subspecs.chain.clock import Interval from lean_spec.subspecs.containers.attestation import ( AggregatedAttestation, Attestation, @@ -420,6 +421,13 @@ def build_signed_block_with_store( parent_state, block_registry, key_manager ) + # Advance the local store clock to the block's slot before gossiping. + # In-body attestations carry data.slot = self.slot; the Store's time + # check rejects votes whose slot has not yet started locally. + block_slot_interval = Interval.from_slot(self.slot) + if store.time < block_slot_interval: + store, _ = store.on_tick(block_slot_interval, has_proposal=True, is_aggregator=True) + # Gossip valid attestation signatures into the Store. # This runs signature verification through the spec's validation path. for attestation in valid_attestations: diff --git a/src/lean_spec/subspecs/chain/config.py b/src/lean_spec/subspecs/chain/config.py index ec3f9d0f..dff4e4f9 100644 --- a/src/lean_spec/subspecs/chain/config.py +++ b/src/lean_spec/subspecs/chain/config.py @@ -7,6 +7,16 @@ INTERVALS_PER_SLOT: Final = Uint64(5) """Number of intervals per slot for forkchoice processing.""" +GOSSIP_DISPARITY_INTERVALS: Final = Uint64(1) +""" +Future-slot tolerance for gossip attestations, in intervals. + +Bounds the clock skew the time check is willing to absorb when admitting +a vote whose slot has not yet started locally. + +One interval is roughly 800 ms. +""" + SECONDS_PER_SLOT: Final = Uint64(4) """The fixed duration of a single slot in seconds.""" diff --git a/src/lean_spec/subspecs/forkchoice/store.py b/src/lean_spec/subspecs/forkchoice/store.py index 17810b2f..4298bcb7 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -11,6 +11,7 @@ from lean_spec.subspecs.chain.clock import Interval from lean_spec.subspecs.chain.config import ( + GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, JUSTIFICATION_LOOKBACK_SLOTS, MAX_ATTESTATIONS_DATA, @@ -281,7 +282,7 @@ def validate_attestation(self, attestation_data: AttestationData) -> None: 2. A vote cannot span backwards in time (source > target). 3. The head must be at least as recent as source and target. 4. Checkpoint slots must match the actual block slots. - 5. A vote cannot be for a future slot. + 5. The vote's slot must have started locally (a small disparity margin is allowed). Args: attestation_data: AttestationData whose checkpoints and slot should be validated. @@ -317,10 +318,16 @@ def validate_attestation(self, attestation_data: AttestationData) -> None: # Time Check # - # Validate attestation is not too far in the future - # We allow a small margin for clock disparity (1 slot), but no further. - current_slot = Slot(self.time // INTERVALS_PER_SLOT) - assert data.slot <= current_slot + Slot(1), "Attestation too far in future" + # Honest validators emit votes only after their slot has begun. + # Allow a small disparity margin for clock skew between peers. + # + # The bound is in intervals, not slots: a whole-slot margin would + # let an adversary pre-publish next-slot aggregates ahead of any + # honest validator. + attestation_start_interval = Interval.from_slot(data.slot) + assert attestation_start_interval <= self.time + GOSSIP_DISPARITY_INTERVALS, ( + "Attestation too far in future" + ) def on_gossip_attestation( self, diff --git a/tests/consensus/devnet/fc/test_block_production.py b/tests/consensus/devnet/fc/test_block_production.py index 7db3e9af..d628eeae 100644 --- a/tests/consensus/devnet/fc/test_block_production.py +++ b/tests/consensus/devnet/fc/test_block_production.py @@ -36,9 +36,9 @@ def test_block_builder_fixed_point_advances_justification( Scenario -------- - Four validators. Linear chain through slot 4:: + Four validators. Linear chain through slot 5:: - genesis(0) -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4) + genesis(0) -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4) -> block_5(5) Two gossip attestations with different sources: @@ -66,7 +66,7 @@ def test_block_builder_fixed_point_advances_justification( # Chain setup # =========== # - # genesis(0) -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4) + # genesis(0) -> block_1(1) -> block_2(2) -> block_3(3) -> block_4(4) -> block_5(5) # # Slot 3 carries a supermajority attestation that justifies slot 1. # This establishes the baseline: justified=1, finalized=0. @@ -104,7 +104,7 @@ def test_block_builder_fixed_point_advances_justification( latest_finalized_slot=Slot(0), ), ), - # Extend to slot 4. No attestations, no checkpoint change. + # Extend to slot 4 then slot 5. No attestations, no checkpoint change. BlockStep( block=BlockSpec(slot=Slot(4), label="block_4"), checks=StoreChecks( @@ -112,6 +112,13 @@ def test_block_builder_fixed_point_advances_justification( latest_justified_slot=Slot(1), ), ), + BlockStep( + block=BlockSpec(slot=Slot(5), label="block_5"), + checks=StoreChecks( + head_slot=Slot(5), + latest_justified_slot=Slot(1), + ), + ), # Attestation delivery # ==================== # @@ -125,14 +132,14 @@ def test_block_builder_fixed_point_advances_justification( # # Timing: # - # 18s = interval 22 = slot 4, interval 2 (aggregate interval) + # 22s = interval 27 = slot 5, interval 2 (aggregate interval) # The attestation pool is empty here, so nothing is lost. # - # 20s = interval 25 = slot 5, interval 0 - # Passes through interval 24 (slot 4, interval 4) which + # 24s = interval 30 = slot 6, interval 0 + # Passes through interval 29 (slot 5, interval 4) which # migrates attestations from the "new" pool to "known". - # Advance past the aggregate interval while the pool is empty. - TickStep(time=18), + # Advance to the aggregate interval while the pool is empty. + TickStep(time=22), # Attestation A: source=1, target=2 # 3/4 validators. Matches justified=1 on the first pass. GossipAggregatedAttestationStep( @@ -166,7 +173,7 @@ def test_block_builder_fixed_point_advances_justification( source_slot=Slot(2), ), ), - # Migrate attestations: tick to 20s (interval 24 fires acceptance). + # Migrate attestations: tick to 24s (interval 29 fires acceptance). # # Invariant: justified is still slot 1. # Attestations are stored but not processed until a block is built. @@ -175,7 +182,7 @@ def test_block_builder_fixed_point_advances_justification( # - V0 appears only in A (source=1, target=2) # - V3 appears only in B (source=2, target=4) TickStep( - time=20, + time=24, checks=StoreChecks( latest_justified_slot=Slot(1), attestation_checks=[ @@ -217,9 +224,9 @@ def test_block_builder_fixed_point_advances_justification( # finalized = 1 # block body = 2 aggregated attestations BlockStep( - block=BlockSpec(slot=Slot(5), label="block_5"), + block=BlockSpec(slot=Slot(6), label="block_6"), checks=StoreChecks( - head_slot=Slot(5), + head_slot=Slot(6), latest_justified_slot=Slot(4), latest_justified_root_label="block_4", latest_finalized_slot=Slot(1), @@ -303,7 +310,7 @@ def test_produce_block_enforces_max_attestations_data_limit( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=validators, - slot=Slot(block_production_slot), + slot=Slot(num_target_blocks), target_slot=Slot(n), target_root_label=f"block_{n}", ), @@ -380,11 +387,11 @@ def test_produce_block_includes_pending_attestations( # Advance past the aggregate interval while the pool is empty. TickStep(time=10), # Validators 1 & 2 gossip an aggregated attestation targeting block_2. - # slot=3 is one slot ahead of current (slot 2): within the allowed margin. + # data.slot=2 matches the current slot. GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(1), ValidatorIndex(2)], - slot=Slot(3), + slot=Slot(2), target_slot=Slot(2), target_root_label="block_2", ), @@ -402,7 +409,7 @@ def test_produce_block_includes_pending_attestations( block_attestations=[ AggregatedAttestationCheck( participants={1, 2}, - attestation_slot=Slot(3), + attestation_slot=Slot(2), target_slot=Slot(2), ), ], diff --git a/tests/consensus/devnet/fc/test_gossip_aggregated_attestation_validation.py b/tests/consensus/devnet/fc/test_gossip_aggregated_attestation_validation.py index 4ad78838..35eda6ae 100644 --- a/tests/consensus/devnet/fc/test_gossip_aggregated_attestation_validation.py +++ b/tests/consensus/devnet/fc/test_gossip_aggregated_attestation_validation.py @@ -8,8 +8,11 @@ GossipAggregatedAttestationSpec, GossipAggregatedAttestationStep, StoreChecks, + TickStep, ) +from lean_spec.subspecs.chain.clock import Interval +from lean_spec.subspecs.chain.config import GOSSIP_DISPARITY_INTERVALS from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex from lean_spec.types import Bytes32 @@ -17,6 +20,13 @@ pytestmark = pytest.mark.valid_until("Devnet") +SLOT_3_BOUNDARY_INTERVAL = int(Interval.from_slot(Slot(3)) - GOSSIP_DISPARITY_INTERVALS) +"""Latest local interval that still admits a slot-3 aggregate.""" + +SLOT_3_JUST_BEYOND_BOUNDARY_INTERVAL = SLOT_3_BOUNDARY_INTERVAL - 1 +"""First local interval that rejects a slot-3 aggregate.""" + + def test_valid_gossip_aggregated_attestation( fork_choice_test: ForkChoiceTestFiller, ) -> None: @@ -169,7 +179,7 @@ def test_aggregated_attestation_source_after_target_rejected( def test_aggregated_attestation_too_far_in_future_rejected( fork_choice_test: ForkChoiceTestFiller, ) -> None: - """Attestations that are too far in the future are rejected.""" + """Attestations whose slot is multiple slots ahead of local time are rejected.""" fork_choice_test( steps=[ BlockStep( @@ -192,3 +202,126 @@ def test_aggregated_attestation_too_far_in_future_rejected( ), ] ) + + +def test_aggregated_attestation_at_disparity_boundary_allowed( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Aggregate exactly at the disparity boundary is allowed. + + Scenario + -------- + Build a chain through slot 2. + Tick to the latest local interval that still admits a slot-3 vote. + Gossip a slot-3 aggregate. + + Expected: + + - Aggregate is validated and stored. + """ + 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), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + TickStep(interval=SLOT_3_BOUNDARY_INTERVAL), + GossipAggregatedAttestationStep( + attestation=GossipAggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + ), + ] + ) + + +def test_aggregated_attestation_just_beyond_disparity_boundary_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Aggregate one interval beyond the disparity boundary is rejected. + + Scenario + -------- + Build a chain through slot 2. + Tick to one interval before the disparity boundary for a slot-3 vote. + Gossip a slot-3 aggregate. + + Expected: + + - Validation fails with "Attestation too far in future" error. + """ + 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), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + TickStep(interval=SLOT_3_JUST_BEYOND_BOUNDARY_INTERVAL), + GossipAggregatedAttestationStep( + attestation=GossipAggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + valid=False, + expected_error="Attestation too far in future", + ), + ] + ) + + +def test_aggregated_attestation_one_full_slot_in_future_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Aggregate a full slot ahead of local time is rejected. + + Regression: an earlier rule admitted aggregates up to a full slot ahead. + That window let an adversary pre-publish next-slot aggregates before + any honest validator could produce them. + + Scenario + -------- + Build a chain through slot 2. + At slot-2 interval 0, gossip a slot-3 aggregate (5 intervals ahead). + + Expected: + + - Validation fails with "Attestation too far in future" error. + """ + 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), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + GossipAggregatedAttestationStep( + attestation=GossipAggregatedAttestationSpec( + validator_ids=[ValidatorIndex(1)], + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + valid=False, + expected_error="Attestation too far in future", + ), + ] + ) diff --git a/tests/consensus/devnet/fc/test_gossip_attestation_validation.py b/tests/consensus/devnet/fc/test_gossip_attestation_validation.py index e0e20ad0..f4168e43 100644 --- a/tests/consensus/devnet/fc/test_gossip_attestation_validation.py +++ b/tests/consensus/devnet/fc/test_gossip_attestation_validation.py @@ -8,8 +8,11 @@ ForkChoiceTestFiller, GossipAttestationSpec, StoreChecks, + TickStep, ) +from lean_spec.subspecs.chain.clock import Interval +from lean_spec.subspecs.chain.config import GOSSIP_DISPARITY_INTERVALS from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex from lean_spec.types import Bytes32 @@ -17,6 +20,13 @@ pytestmark = pytest.mark.valid_until("Devnet") +SLOT_3_BOUNDARY_INTERVAL = int(Interval.from_slot(Slot(3)) - GOSSIP_DISPARITY_INTERVALS) +"""Latest local interval that still admits a slot-3 vote.""" + +SLOT_3_JUST_BEYOND_BOUNDARY_INTERVAL = SLOT_3_BOUNDARY_INTERVAL - 1 +"""First local interval that rejects a slot-3 vote.""" + + def test_valid_gossip_attestation( fork_choice_test: ForkChoiceTestFiller, ) -> None: @@ -131,19 +141,104 @@ def test_attestation_too_far_in_future_rejected( ) -def test_attestation_one_slot_in_future_allowed( +def test_attestation_at_disparity_boundary_allowed( fork_choice_test: ForkChoiceTestFiller, ) -> None: """ - Attestation exactly one slot in the future is allowed. + Attestation exactly at the disparity boundary is allowed. Scenario -------- - Build a chain with blocks at slots 1 and 2. - Submit attestation for slot 3 (one slot in future, allowed margin). + Build a chain through slot 2. + Tick to the latest local interval that still admits a slot-3 vote. + Submit a slot-3 attestation. + + Expected: + + - Attestation is validated successfully. + """ + 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), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + TickStep(interval=SLOT_3_BOUNDARY_INTERVAL), + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(1), + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + ), + ], + ) + + +def test_attestation_just_beyond_disparity_boundary_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Attestation one interval beyond the disparity boundary is rejected. + + Scenario + -------- + Build a chain through slot 2. + Tick to one interval before the disparity boundary for a slot-3 vote. + Submit a slot-3 attestation. + + Expected: + + - Validation fails with "Attestation too far in future" error. + """ + 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), label="block_2"), + checks=StoreChecks(head_slot=Slot(2)), + ), + TickStep(interval=SLOT_3_JUST_BEYOND_BOUNDARY_INTERVAL), + AttestationStep( + attestation=GossipAttestationSpec( + validator_id=ValidatorIndex(1), + slot=Slot(3), + target_slot=Slot(2), + target_root_label="block_2", + ), + valid=False, + expected_error="Attestation too far in future", + ), + ], + ) + + +def test_attestation_one_full_slot_in_future_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + Attestation a full slot ahead of local time is rejected. + + Regression: an earlier rule admitted votes up to a full slot ahead. + That window let an adversary pre-publish next-slot aggregates before + any honest validator could produce them. + + Scenario + -------- + Build a chain through slot 2. + At slot-2 interval 0, submit a slot-3 attestation (5 intervals ahead). Expected: - - Attestation is validated successfully + + - Validation fails with "Attestation too far in future" error. """ fork_choice_test( steps=[ @@ -162,6 +257,8 @@ def test_attestation_one_slot_in_future_allowed( target_slot=Slot(2), target_root_label="block_2", ), + valid=False, + expected_error="Attestation too far in future", ), ], ) diff --git a/tests/consensus/devnet/fc/test_safe_target.py b/tests/consensus/devnet/fc/test_safe_target.py index dc291e78..a5ab69da 100644 --- a/tests/consensus/devnet/fc/test_safe_target.py +++ b/tests/consensus/devnet/fc/test_safe_target.py @@ -288,7 +288,7 @@ def test_safe_target_follows_heavier_fork_on_split( BlockStep( block=BlockSpec(slot=Slot(3), parent_label="block_1", label="block_b"), ), - TickStep(time=14), + TickStep(time=18), # Supermajority (4/6) attests to block_b. GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( @@ -318,7 +318,7 @@ def test_safe_target_follows_heavier_fork_on_split( # block_1 gets weight 6 (all validators walk through it). # At the fork, only block_b survives the min_score filter. TickStep( - time=15, + time=19, checks=StoreChecks( safe_target_slot=Slot(3), safe_target_root_label="block_b", @@ -372,7 +372,7 @@ def test_safe_target_is_conservative_relative_to_lmd_ghost_head( block=BlockSpec(slot=Slot(3), label="block_3"), checks=StoreChecks(head_slot=Slot(3), head_root_label="block_3"), ), - TickStep(time=14), + TickStep(time=18), # 6/8 vote for block_2. Weight: block_1 += 6, block_2 += 6. GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( @@ -405,7 +405,7 @@ def test_safe_target_is_conservative_relative_to_lmd_ghost_head( # Safe walk stops at block_2 (block_3 below threshold). # LMD-GHOST continues to block_3 (no threshold). TickStep( - time=15, + time=19, checks=StoreChecks( head_slot=Slot(3), head_root_label="block_3", @@ -489,7 +489,7 @@ def test_safe_target_ignores_known_pool_at_interval_3( ], ), ), - TickStep(time=14), + TickStep(time=18), # Gossip 2 more attestations into the "new" pool. # Combined with "known": total weight = 4 = threshold. GossipAggregatedAttestationStep( @@ -522,7 +522,7 @@ def test_safe_target_ignores_known_pool_at_interval_3( # Interval 3: only the "new" pool is considered. # Weight at block_1 = 2 < 4, so the walk cannot leave genesis. TickStep( - time=15, + time=19, checks=StoreChecks( head_slot=Slot(3), head_root_label="block_3", diff --git a/tests/consensus/devnet/fc/test_store_pruning.py b/tests/consensus/devnet/fc/test_store_pruning.py index 5d01e28d..e5dacd46 100644 --- a/tests/consensus/devnet/fc/test_store_pruning.py +++ b/tests/consensus/devnet/fc/test_store_pruning.py @@ -362,7 +362,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(1), target_root_label="block_1", source_root_label="genesis", @@ -372,7 +372,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(2), target_root_label="block_2", source_root_label="block_1", @@ -382,7 +382,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(3), target_root_label="block_3", source_root_label="block_2", @@ -392,7 +392,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(4), target_root_label="block_4", source_root_label="block_3", @@ -402,7 +402,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(0), ValidatorIndex(1), ValidatorIndex(2)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(5), target_root_label="block_5", source_root_label="block_3", @@ -426,7 +426,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(3), ValidatorIndex(4), ValidatorIndex(5)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(1), target_root_label="block_1", source_root_label="genesis", @@ -436,7 +436,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(3), ValidatorIndex(4), ValidatorIndex(5)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(2), target_root_label="block_2", source_root_label="block_1", @@ -446,7 +446,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(3), ValidatorIndex(4), ValidatorIndex(5)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(3), target_root_label="block_3", source_root_label="block_2", @@ -456,7 +456,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(3), ValidatorIndex(4), ValidatorIndex(5)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(4), target_root_label="block_4", source_root_label="block_3", @@ -466,7 +466,7 @@ def test_finalization_prunes_stale_attestation_signatures( GossipAggregatedAttestationStep( attestation=GossipAggregatedAttestationSpec( validator_ids=[ValidatorIndex(3), ValidatorIndex(4), ValidatorIndex(5)], - slot=Slot(6), + slot=Slot(5), target_slot=Slot(5), target_root_label="block_5", source_root_label="block_3", @@ -478,7 +478,7 @@ def test_finalization_prunes_stale_attestation_signatures( AttestationStep( attestation=GossipAttestationSpec( validator_id=ValidatorIndex(6), - slot=Slot(6), + slot=Slot(5), target_slot=Slot(1), target_root_label="block_1", source_root_label="genesis", @@ -489,7 +489,7 @@ def test_finalization_prunes_stale_attestation_signatures( AttestationStep( attestation=GossipAttestationSpec( validator_id=ValidatorIndex(6), - slot=Slot(6), + slot=Slot(5), target_slot=Slot(2), target_root_label="block_2", source_root_label="block_1", @@ -500,7 +500,7 @@ def test_finalization_prunes_stale_attestation_signatures( AttestationStep( attestation=GossipAttestationSpec( validator_id=ValidatorIndex(6), - slot=Slot(6), + slot=Slot(5), target_slot=Slot(3), target_root_label="block_3", source_root_label="block_2", @@ -511,7 +511,7 @@ def test_finalization_prunes_stale_attestation_signatures( AttestationStep( attestation=GossipAttestationSpec( validator_id=ValidatorIndex(6), - slot=Slot(6), + slot=Slot(5), target_slot=Slot(4), target_root_label="block_4", source_root_label="block_3", @@ -523,7 +523,7 @@ def test_finalization_prunes_stale_attestation_signatures( AttestationStep( attestation=GossipAttestationSpec( validator_id=ValidatorIndex(6), - slot=Slot(6), + slot=Slot(5), target_slot=Slot(5), target_root_label="block_5", source_root_label="block_3", diff --git a/tests/lean_spec/helpers/builders.py b/tests/lean_spec/helpers/builders.py index 6251e321..0882293f 100644 --- a/tests/lean_spec/helpers/builders.py +++ b/tests/lean_spec/helpers/builders.py @@ -359,6 +359,7 @@ def make_store_with_attestation_data( validator_id=validator_id, key_manager=key_manager, ) + store = store.model_copy(update={"time": Interval.from_slot(attestation_slot)}) attestation_data = store.produce_attestation_data(attestation_slot) return store, attestation_data diff --git a/tests/lean_spec/subspecs/forkchoice/test_store_attestations.py b/tests/lean_spec/subspecs/forkchoice/test_store_attestations.py index e5803380..36d61ad3 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_store_attestations.py +++ b/tests/lean_spec/subspecs/forkchoice/test_store_attestations.py @@ -5,6 +5,7 @@ import pytest from consensus_testing.keys import XmssKeyManager +from lean_spec.subspecs.chain.clock import Interval from lean_spec.subspecs.chain.config import INTERVALS_PER_SLOT from lean_spec.subspecs.containers.attestation import ( AttestationData, @@ -744,6 +745,8 @@ def test_gossip_to_aggregation_to_storage(self, key_manager: XmssKeyManager) -> store = make_store( num_validators=num_validators, key_manager=key_manager, validator_id=aggregator_id ) + # Advance the clock to slot 1 so the attestation's slot has begun. + store = store.model_copy(update={"time": Interval.from_slot(Slot(1))}) attestation_data = store.produce_attestation_data(Slot(1)) data_root = hash_tree_root(attestation_data) diff --git a/tests/lean_spec/subspecs/forkchoice/test_validate_attestation.py b/tests/lean_spec/subspecs/forkchoice/test_validate_attestation.py index c3e42cbe..df3690c7 100644 --- a/tests/lean_spec/subspecs/forkchoice/test_validate_attestation.py +++ b/tests/lean_spec/subspecs/forkchoice/test_validate_attestation.py @@ -4,12 +4,30 @@ import pytest +from lean_spec.subspecs.chain.clock import Interval +from lean_spec.subspecs.chain.config import GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT from lean_spec.subspecs.containers import Attestation, AttestationData, Checkpoint from lean_spec.subspecs.containers.slot import Slot from lean_spec.subspecs.containers.validator import ValidatorIndex from lean_spec.subspecs.forkchoice import Store from lean_spec.subspecs.ssz.hash import hash_tree_root +# Slot used by every time-check case below. +ATTESTATION_SLOT = Slot(2) +"""Slot of the attestation under test.""" + +ATTESTATION_START_INTERVAL = Interval.from_slot(ATTESTATION_SLOT) +"""First interval at which ATTESTATION_SLOT begins.""" + +DISPARITY_BOUNDARY_INTERVAL = ATTESTATION_START_INTERVAL - GOSSIP_DISPARITY_INTERVALS +"""Latest local interval that still admits the attestation.""" + +JUST_BEYOND_DISPARITY_BOUNDARY_INTERVAL = DISPARITY_BOUNDARY_INTERVAL - Interval(1) +"""First local interval that rejects the attestation.""" + +ONE_FULL_SLOT_BEHIND_INTERVAL = ATTESTATION_START_INTERVAL - INTERVALS_PER_SLOT +"""Local interval one full slot behind the attestation's slot start.""" + class TestValidateAttestationHeadChecks: """Head checkpoint must be consistent and at least as recent as source and target.""" @@ -178,3 +196,81 @@ def test_head_equal_to_source_and_target_passes( ) store.validate_attestation(attestation.data) + + +class TestValidateAttestationTimeCheck: + """ + Time check boundaries. + + Each case sets `store.time` explicitly to isolate the time check from + on_tick side effects (aggregation, safe-target update, acceptance). + """ + + @staticmethod + def _build_two_block_chain(store: Store) -> tuple[Store, AttestationData]: + """Produce blocks at slots 1 and ATTESTATION_SLOT; return ATTESTATION_SLOT data.""" + store, _, _ = store.produce_block_with_signatures(Slot(1), ValidatorIndex(1)) + store, block_2, _ = store.produce_block_with_signatures( + ATTESTATION_SLOT, ValidatorIndex(int(ATTESTATION_SLOT)) + ) + block_2_root = hash_tree_root(block_2) + genesis_root = store.latest_justified.root + + data = AttestationData( + slot=ATTESTATION_SLOT, + head=Checkpoint(root=block_2_root, slot=ATTESTATION_SLOT), + target=Checkpoint(root=block_2_root, slot=ATTESTATION_SLOT), + source=Checkpoint(root=genesis_root, slot=Slot(0)), + ) + return store, data + + def test_attestation_at_current_slot_passes(self, observer_store: Store) -> None: + """A vote at the current slot is always accepted, every interval.""" + store, data = self._build_two_block_chain(observer_store) + + # Sweep every interval in the attestation's slot. + for offset in range(int(INTERVALS_PER_SLOT)): + local = store.model_copy(update={"time": ATTESTATION_START_INTERVAL + Interval(offset)}) + local.validate_attestation(data) + + def test_attestation_in_past_passes(self, observer_store: Store) -> None: + """A vote from a past slot is always accepted.""" + store, data = self._build_two_block_chain(observer_store) + + # Place the local clock several slots ahead. + far_future = ATTESTATION_START_INTERVAL + INTERVALS_PER_SLOT * Interval(10) + store = store.model_copy(update={"time": far_future}) + store.validate_attestation(data) + + def test_attestation_at_disparity_boundary_passes(self, observer_store: Store) -> None: + """At the disparity boundary the attestation is still accepted.""" + store, data = self._build_two_block_chain(observer_store) + + store = store.model_copy(update={"time": DISPARITY_BOUNDARY_INTERVAL}) + store.validate_attestation(data) + + def test_attestation_just_beyond_disparity_boundary_rejected( + self, observer_store: Store + ) -> None: + """One interval past the disparity boundary the attestation is rejected.""" + store, data = self._build_two_block_chain(observer_store) + + store = store.model_copy(update={"time": JUST_BEYOND_DISPARITY_BOUNDARY_INTERVAL}) + + with pytest.raises(AssertionError, match="Attestation too far in future"): + store.validate_attestation(data) + + def test_attestation_one_full_slot_in_future_rejected(self, observer_store: Store) -> None: + """ + Regression: a full-slot future window must be rejected. + + An earlier rule admitted votes up to a full slot ahead. + That window let an adversary pre-publish next-slot aggregates + before any honest validator could produce them. + """ + store, data = self._build_two_block_chain(observer_store) + + store = store.model_copy(update={"time": ONE_FULL_SLOT_BEHIND_INTERVAL}) + + with pytest.raises(AssertionError, match="Attestation too far in future"): + store.validate_attestation(data)