Skip to content

Commit 4ca7d27

Browse files
tcoratgerclaude
andauthored
fix(fork-choice): bound a block's slot before the empty-slot loop runs (leanEthereum#1182)
The empty-slot loop in the state transition runs once per slot from the parent to the block, so its length is block.slot minus parent.slot, and on_block placed no upper bound on block.slot before reaching it. Because the proposer of a slot is slot modulo the validator count, one key is the valid proposer for infinitely many slots, so a single signed block at slot 2**63 forced roughly 2**63 iterations from one message. The always present genesis parent made a far-slot block reachable, and the cost was paid before the post-state root was ever checked. Reject the block in on_block, before the transition, with two guards: - Parent-gap cap: reject when block.slot minus parent.slot exceeds the historical-roots limit. This bounds the actual loop variable directly and is clock independent. It is lossless: a block with a larger gap would overflow the capped historical-block-hashes list during header processing anyway, so this rejects only already doomed blocks, with no false rejections and no liveness cost. - Clock horizon: reject when block.slot exceeds the current slot by more than one slot. This mirrors the attestation future-slot guard and keeps far-future blocks out of the store. The margin is a whole slot, not the attestation path's one interval, so an intended early block still imports. Both guards work in Python integers so the near-2**64 wire slot never overflows, and both run before signature verification so a far-future block is rejected cheaply. The guards live only in on_block, the untrusted-input boundary; block production advances slots through a different path and is unaffected. Adds BLOCK_SLOT_GAP_TOO_LARGE and BLOCK_TOO_FAR_IN_FUTURE reasons, and consensus vectors for the clock-horizon rejection and its boundary. The parent-gap cap is verified by construction (a larger-gap block is not representable) and the early-block-arrival vectors still import. Closes leanEthereum#1171 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ae104fa commit 4ca7d27

3 files changed

Lines changed: 160 additions & 0 deletions

File tree

src/lean_spec/spec/forks/lstar/errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ class RejectionReason(StrEnum):
1010
BLOCK_SLOT_NOT_IN_FUTURE = "BLOCK_SLOT_NOT_IN_FUTURE"
1111
"""The block slot is not strictly greater than the current state slot."""
1212

13+
BLOCK_SLOT_GAP_TOO_LARGE = "BLOCK_SLOT_GAP_TOO_LARGE"
14+
"""The block slot runs so far beyond its parent it would force an unbounded empty-slot walk."""
15+
16+
BLOCK_TOO_FAR_IN_FUTURE = "BLOCK_TOO_FAR_IN_FUTURE"
17+
"""The block slot is beyond the store's accepted future horizon."""
18+
1319
BLOCK_OLDER_THAN_LATEST_HEADER = "BLOCK_OLDER_THAN_LATEST_HEADER"
1420
"""The block slot is not newer than the latest block header."""
1521

src/lean_spec/spec/forks/lstar/fork_choice.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from lean_spec.spec.forks.lstar._base import LstarSpecBase, LstarStore
1010
from lean_spec.spec.forks.lstar.config import (
1111
GOSSIP_DISPARITY_INTERVALS,
12+
HISTORICAL_ROOTS_LIMIT,
1213
INTERVALS_PER_SLOT,
1314
)
1415
from lean_spec.spec.forks.lstar.containers import (
@@ -557,6 +558,8 @@ def on_block(
557558
558559
Raises:
559560
SpecRejectionError: UNKNOWN_PARENT_BLOCK if the parent state is not in the store.
561+
SpecRejectionError: BLOCK_SLOT_GAP_TOO_LARGE if the slot runs too far beyond the parent.
562+
SpecRejectionError: BLOCK_TOO_FAR_IN_FUTURE if the slot is past the future horizon.
560563
SpecRejectionError: DUPLICATE_ATTESTATION_DATA if the block repeats an AttestationData.
561564
SpecRejectionError: TOO_MANY_ATTESTATION_DATA if the block exceeds the data cap.
562565
"""
@@ -584,6 +587,21 @@ def on_block(
584587
f"Sync parent chain before processing block at slot {block.slot}.",
585588
)
586589

590+
# The empty-slot loop in the transition runs once per slot from the parent to the block.
591+
#
592+
# A block far beyond its parent, or far in the future, would spin that loop unboundedly.
593+
if int(block.slot) - int(parent_state.slot) > int(HISTORICAL_ROOTS_LIMIT):
594+
raise SpecRejectionError(
595+
RejectionReason.BLOCK_SLOT_GAP_TOO_LARGE,
596+
"Block slot is too far beyond its parent",
597+
)
598+
current_slot = int(store.time) // int(INTERVALS_PER_SLOT)
599+
if int(block.slot) > current_slot + 1:
600+
raise SpecRejectionError(
601+
RejectionReason.BLOCK_TOO_FAR_IN_FUTURE,
602+
"Block too far in future",
603+
)
604+
587605
# Reject a block body that repeats the same vote data.
588606
#
589607
# Collapsing the votes to their distinct data exposes any repeat:
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Fork Choice: blocks past the clock's future horizon are rejected."""
2+
3+
import pytest
4+
5+
from consensus_testing import (
6+
BlockSpec,
7+
BlockStep,
8+
ExpectedRejection,
9+
ForkChoiceTestFiller,
10+
StoreChecks,
11+
TickStep,
12+
)
13+
from lean_spec.spec.forks import Interval, RejectionReason, Slot
14+
15+
pytestmark = pytest.mark.valid_until("Lstar")
16+
17+
18+
def test_block_beyond_future_horizon_rejected(
19+
fork_choice_test: ForkChoiceTestFiller,
20+
) -> None:
21+
"""
22+
A block two slots past the store clock is rejected as too far in future.
23+
24+
Given
25+
-----
26+
- 4 validators.
27+
- the chain:
28+
genesis
29+
- the store clock sits at genesis and never ticks.
30+
31+
When
32+
----
33+
- a block at slot 2 arrives while the clock reports current slot 0.
34+
35+
Then
36+
----
37+
- the store rejects the block with reason block too far in future.
38+
- store time stays at interval 0.
39+
- the head stays at genesis (slot 0).
40+
"""
41+
fork_choice_test(
42+
steps=[
43+
BlockStep(
44+
block=BlockSpec(slot=Slot(2)),
45+
tick_to_slot=False,
46+
valid=False,
47+
expected_rejection=ExpectedRejection(
48+
reason=RejectionReason.BLOCK_TOO_FAR_IN_FUTURE,
49+
exact_message="Block too far in future",
50+
),
51+
checks=StoreChecks(time=Interval(0), head_slot=Slot(0)),
52+
),
53+
],
54+
)
55+
56+
57+
def test_block_at_clock_horizon_edge_imported(
58+
fork_choice_test: ForkChoiceTestFiller,
59+
) -> None:
60+
"""
61+
A block exactly one slot past the clock imports at the future horizon edge.
62+
63+
Given
64+
-----
65+
- 4 validators.
66+
- the chain:
67+
genesis -> block(2)
68+
- the clock ticks to the start of slot 1 (interval 5), so current slot is 1.
69+
70+
When
71+
----
72+
- a block at slot 2 arrives without advancing the clock.
73+
74+
Then
75+
----
76+
- the horizon is current slot plus one, so slot 2 is admissible.
77+
- store time stays at interval 5.
78+
- the head advances to the slot 2 block.
79+
"""
80+
fork_choice_test(
81+
steps=[
82+
TickStep(
83+
interval=5,
84+
checks=StoreChecks(time=Interval(5), head_slot=Slot(0)),
85+
),
86+
BlockStep(
87+
block=BlockSpec(slot=Slot(2)),
88+
tick_to_slot=False,
89+
checks=StoreChecks(time=Interval(5), head_slot=Slot(2)),
90+
),
91+
],
92+
)
93+
94+
95+
def test_block_one_past_horizon_rejected(
96+
fork_choice_test: ForkChoiceTestFiller,
97+
) -> None:
98+
"""
99+
A block two slots past the clock is rejected even at a non-genesis clock.
100+
101+
Given
102+
-----
103+
- 4 validators.
104+
- the chain:
105+
genesis
106+
- the clock ticks to the start of slot 1 (interval 5), so current slot is 1.
107+
108+
When
109+
----
110+
- a block at slot 3 arrives without advancing the clock.
111+
112+
Then
113+
----
114+
- the horizon is current slot plus one, so slot 3 exceeds it.
115+
- the store rejects the block with reason block too far in future.
116+
- store time stays at interval 5.
117+
- the head stays at genesis (slot 0).
118+
"""
119+
fork_choice_test(
120+
steps=[
121+
TickStep(
122+
interval=5,
123+
checks=StoreChecks(time=Interval(5), head_slot=Slot(0)),
124+
),
125+
BlockStep(
126+
block=BlockSpec(slot=Slot(3)),
127+
tick_to_slot=False,
128+
valid=False,
129+
expected_rejection=ExpectedRejection(
130+
reason=RejectionReason.BLOCK_TOO_FAR_IN_FUTURE,
131+
exact_message="Block too far in future",
132+
),
133+
checks=StoreChecks(time=Interval(5), head_slot=Slot(0)),
134+
),
135+
],
136+
)

0 commit comments

Comments
 (0)