diff --git a/src/lean_spec/spec/forks/lstar/errors.py b/src/lean_spec/spec/forks/lstar/errors.py index 7dece283b..a71da229c 100644 --- a/src/lean_spec/spec/forks/lstar/errors.py +++ b/src/lean_spec/spec/forks/lstar/errors.py @@ -10,6 +10,12 @@ class RejectionReason(StrEnum): BLOCK_SLOT_NOT_IN_FUTURE = "BLOCK_SLOT_NOT_IN_FUTURE" """The block slot is not strictly greater than the current state slot.""" + BLOCK_SLOT_GAP_TOO_LARGE = "BLOCK_SLOT_GAP_TOO_LARGE" + """The block slot runs so far beyond its parent it would force an unbounded empty-slot walk.""" + + BLOCK_TOO_FAR_IN_FUTURE = "BLOCK_TOO_FAR_IN_FUTURE" + """The block slot is beyond the store's accepted future horizon.""" + BLOCK_OLDER_THAN_LATEST_HEADER = "BLOCK_OLDER_THAN_LATEST_HEADER" """The block slot is not newer than the latest block header.""" diff --git a/src/lean_spec/spec/forks/lstar/fork_choice.py b/src/lean_spec/spec/forks/lstar/fork_choice.py index 1f3292ad5..4fa7b94fe 100644 --- a/src/lean_spec/spec/forks/lstar/fork_choice.py +++ b/src/lean_spec/spec/forks/lstar/fork_choice.py @@ -9,6 +9,7 @@ from lean_spec.spec.forks.lstar._base import LstarSpecBase, LstarStore from lean_spec.spec.forks.lstar.config import ( GOSSIP_DISPARITY_INTERVALS, + HISTORICAL_ROOTS_LIMIT, INTERVALS_PER_SLOT, ) from lean_spec.spec.forks.lstar.containers import ( @@ -542,6 +543,8 @@ def on_block( Raises: SpecRejectionError: UNKNOWN_PARENT_BLOCK if the parent state is not in the store. + SpecRejectionError: BLOCK_SLOT_GAP_TOO_LARGE if the slot runs too far beyond the parent. + SpecRejectionError: BLOCK_TOO_FAR_IN_FUTURE if the slot is past the future horizon. SpecRejectionError: DUPLICATE_ATTESTATION_DATA if the block repeats an AttestationData. SpecRejectionError: TOO_MANY_ATTESTATION_DATA if the block exceeds the data cap. """ @@ -569,6 +572,21 @@ def on_block( f"Sync parent chain before processing block at slot {block.slot}.", ) + # The empty-slot loop in the transition runs once per slot from the parent to the block. + # + # A block far beyond its parent, or far in the future, would spin that loop unboundedly. + if int(block.slot) - int(parent_state.slot) > int(HISTORICAL_ROOTS_LIMIT): + raise SpecRejectionError( + RejectionReason.BLOCK_SLOT_GAP_TOO_LARGE, + "Block slot is too far beyond its parent", + ) + current_slot = int(store.time) // int(INTERVALS_PER_SLOT) + if int(block.slot) > current_slot + 1: + raise SpecRejectionError( + RejectionReason.BLOCK_TOO_FAR_IN_FUTURE, + "Block too far in future", + ) + # Reject a block body that repeats the same vote data. # # Collapsing the votes to their distinct data exposes any repeat: diff --git a/tests/consensus/lstar/fork_choice/test_block_future_horizon.py b/tests/consensus/lstar/fork_choice/test_block_future_horizon.py new file mode 100644 index 000000000..6f8fbb83f --- /dev/null +++ b/tests/consensus/lstar/fork_choice/test_block_future_horizon.py @@ -0,0 +1,136 @@ +"""Fork Choice: blocks past the clock's future horizon are rejected.""" + +import pytest + +from consensus_testing import ( + BlockSpec, + BlockStep, + ExpectedRejection, + ForkChoiceTestFiller, + StoreChecks, + TickStep, +) +from lean_spec.spec.forks import Interval, RejectionReason, Slot + +pytestmark = pytest.mark.valid_until("Lstar") + + +def test_block_beyond_future_horizon_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + A block two slots past the store clock is rejected as too far in future. + + Given + ----- + - 4 validators. + - the chain: + genesis + - the store clock sits at genesis and never ticks. + + When + ---- + - a block at slot 2 arrives while the clock reports current slot 0. + + Then + ---- + - the store rejects the block with reason block too far in future. + - store time stays at interval 0. + - the head stays at genesis (slot 0). + """ + fork_choice_test( + steps=[ + BlockStep( + block=BlockSpec(slot=Slot(2)), + tick_to_slot=False, + valid=False, + expected_rejection=ExpectedRejection( + reason=RejectionReason.BLOCK_TOO_FAR_IN_FUTURE, + exact_message="Block too far in future", + ), + checks=StoreChecks(time=Interval(0), head_slot=Slot(0)), + ), + ], + ) + + +def test_block_at_clock_horizon_edge_imported( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + A block exactly one slot past the clock imports at the future horizon edge. + + Given + ----- + - 4 validators. + - the chain: + genesis -> block(2) + - the clock ticks to the start of slot 1 (interval 5), so current slot is 1. + + When + ---- + - a block at slot 2 arrives without advancing the clock. + + Then + ---- + - the horizon is current slot plus one, so slot 2 is admissible. + - store time stays at interval 5. + - the head advances to the slot 2 block. + """ + fork_choice_test( + steps=[ + TickStep( + interval=5, + checks=StoreChecks(time=Interval(5), head_slot=Slot(0)), + ), + BlockStep( + block=BlockSpec(slot=Slot(2)), + tick_to_slot=False, + checks=StoreChecks(time=Interval(5), head_slot=Slot(2)), + ), + ], + ) + + +def test_block_one_past_horizon_rejected( + fork_choice_test: ForkChoiceTestFiller, +) -> None: + """ + A block two slots past the clock is rejected even at a non-genesis clock. + + Given + ----- + - 4 validators. + - the chain: + genesis + - the clock ticks to the start of slot 1 (interval 5), so current slot is 1. + + When + ---- + - a block at slot 3 arrives without advancing the clock. + + Then + ---- + - the horizon is current slot plus one, so slot 3 exceeds it. + - the store rejects the block with reason block too far in future. + - store time stays at interval 5. + - the head stays at genesis (slot 0). + """ + fork_choice_test( + steps=[ + TickStep( + interval=5, + checks=StoreChecks(time=Interval(5), head_slot=Slot(0)), + ), + BlockStep( + block=BlockSpec(slot=Slot(3)), + tick_to_slot=False, + valid=False, + expected_rejection=ExpectedRejection( + reason=RejectionReason.BLOCK_TOO_FAR_IN_FUTURE, + exact_message="Block too far in future", + ), + checks=StoreChecks(time=Interval(5), head_slot=Slot(0)), + ), + ], + )