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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/lean_spec/spec/forks/lstar/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
18 changes: 18 additions & 0 deletions src/lean_spec/spec/forks/lstar/fork_choice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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:
Expand Down
136 changes: 136 additions & 0 deletions tests/consensus/lstar/fork_choice/test_block_future_horizon.py
Original file line number Diff line number Diff line change
@@ -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)),
),
],
)
Loading