Skip to content

Commit f48f6bd

Browse files
tcoratgerclaude
andauthored
fix(lstar): reject malformed justification state with typed errors (leanEthereum#1178)
Several partial functions enforced their preconditions by crashing with an untyped exception instead of rejecting cleanly. In the honest block flow the preconditions hold, but these functions are reachable on a state reconstructed from untrusted bytes over sync or the database, where the crash type differs per transliterating language. This is robustness hardening, not a consensus change: every rejection path already funnels to dropping the block, so an assertion error, a value error, and a typed rejection are consensus-equivalent. The value is a defined, language-neutral rejection in place of an incidental crash. Changes: - Slot justifiability is now total. A slot before the finalized boundary returns False instead of asserting; the equal-slot case still returns True through the immediate-justification window. - Attestation processing validates the justification bookkeeping before unpacking the flat vote list into per-root segments. It rejects an empty registry, a vote list whose length is not the tracked-root count times the validator count, and a zero hash among the tracked roots. Two typed rejection reasons are added for the latter two. Vectors: - Justifiability gains two cases below the finalized boundary, which the previous assert could not produce. - Two state-transition rejection vectors craft a decoded state that violates the length invariant and one that carries a zero-hash root. Closes leanEthereum#1174 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aaac891 commit f48f6bd

6 files changed

Lines changed: 214 additions & 11 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
@@ -89,6 +89,12 @@ class RejectionReason(StrEnum):
8989
JUSTIFIED_SLOT_OUT_OF_RANGE = "JUSTIFIED_SLOT_OUT_OF_RANGE"
9090
"""A justification query named a slot beyond the tracked justification window."""
9191

92+
ZERO_HASH_JUSTIFICATION_ROOT = "ZERO_HASH_JUSTIFICATION_ROOT"
93+
"""A tracked justification root is the zero hash, which is not a valid root."""
94+
95+
JUSTIFICATION_VOTES_LENGTH_MISMATCH = "JUSTIFICATION_VOTES_LENGTH_MISMATCH"
96+
"""The flat vote list length is not the tracked-root count times the validator count."""
97+
9298
# Cryptographic verification
9399
INVALID_SIGNATURE = "INVALID_SIGNATURE"
94100
"""An attestation signature or aggregate proof fails cryptographic verification."""

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,12 @@ def is_justifiable_after(self, finalized_slot: Slot) -> bool:
4141
finalized_slot: The last slot that was finalized.
4242
4343
Returns:
44-
True if the slot is justifiable, False otherwise.
45-
46-
Raises:
47-
AssertionError: If this slot is earlier than the finalized slot.
44+
True if the slot is a justification candidate.
45+
False otherwise, including any slot before the finalized slot.
4846
"""
49-
# Ensure the candidate slot is not before the finalized slot.
50-
assert self >= finalized_slot, "Candidate slot must not be before finalized slot"
47+
# A slot before the finalized boundary is already settled, never a future candidate.
48+
if self < finalized_slot:
49+
return False
5150

5251
# Calculate the distance in slots from the last finalized slot.
5352
# Convert to int for pure arithmetic operations below.

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,15 @@ def process_attestations(
197197
Raises:
198198
SpecRejectionError: TOO_MANY_ATTESTATION_DATA if the distinct data
199199
count exceeds the per-block cap.
200-
SpecRejectionError: EMPTY_AGGREGATION_BITS if an attestation that passes
201-
the vote filters has no set bits.
200+
SpecRejectionError: EMPTY_VALIDATOR_REGISTRY if the state holds no
201+
validators to segment the vote tracking against.
202+
SpecRejectionError: JUSTIFICATION_VOTES_LENGTH_MISMATCH if the flat
203+
vote list length is not the tracked-root count times the
204+
validator count.
205+
SpecRejectionError: ZERO_HASH_JUSTIFICATION_ROOT if a tracked
206+
justification root is the zero hash.
207+
SpecRejectionError: EMPTY_AGGREGATION_BITS if an attestation that
208+
passes the vote filters has no set bits.
202209
SpecRejectionError: VALIDATOR_INDEX_OUT_OF_RANGE if a set bit points
203210
outside the validator registry.
204211
"""
@@ -222,10 +229,33 @@ def process_attestations(
222229
# votes: [<--N-->][<--N-->] ...
223230
#
224231
# Slicing per segment recovers a vote list per root.
225-
assert not any(root == ZERO_HASH for root in state.justifications_roots), (
226-
"zero hash is not allowed in justifications roots"
227-
)
228232
validator_count = len(state.validators)
233+
234+
# An empty registry leaves no segment width, so the flat layout cannot be recovered.
235+
# The header stage already guards this, but the unpack below relies on it directly.
236+
if validator_count == 0:
237+
raise SpecRejectionError(
238+
RejectionReason.EMPTY_VALIDATOR_REGISTRY,
239+
"State holds no validators to segment justification votes against",
240+
)
241+
242+
# The flat vote list must hold exactly one full validator segment per tracked root.
243+
# A mismatched length means the segments no longer line up with the roots.
244+
expected_vote_count = len(state.justifications_roots) * validator_count
245+
if len(state.justifications_validators) != expected_vote_count:
246+
raise SpecRejectionError(
247+
RejectionReason.JUSTIFICATION_VOTES_LENGTH_MISMATCH,
248+
"Justification vote list length does not equal tracked-root count times "
249+
"validator count",
250+
)
251+
252+
# The zero hash marks a skipped slot, never a real block, so it cannot track votes.
253+
if any(root == ZERO_HASH for root in state.justifications_roots):
254+
raise SpecRejectionError(
255+
RejectionReason.ZERO_HASH_JUSTIFICATION_ROOT,
256+
"Tracked justification roots contain the zero hash",
257+
)
258+
229259
justifications = {
230260
root: list(validator_votes)
231261
for root, validator_votes in zip(

tests/consensus/lstar/state_transition/test_justifiability.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,3 +707,49 @@ def test_nonzero_finalized_delta_12(
707707
- the candidate is justifiable.
708708
"""
709709
justifiability_test(slot=512, finalized_slot=500)
710+
711+
712+
def test_slot_one_before_finalized_not_justifiable(
713+
justifiability_test: JustifiabilityTestFiller,
714+
) -> None:
715+
"""
716+
A slot one before the finalized slot is not justifiable.
717+
718+
Given
719+
-----
720+
- the finalized slot is 10.
721+
- the candidate slot is 9.
722+
- the delta is -1, before the finalized boundary.
723+
724+
When
725+
----
726+
- the candidate is checked for justifiability.
727+
728+
Then
729+
----
730+
- the candidate is not justifiable.
731+
"""
732+
justifiability_test(slot=9, finalized_slot=10)
733+
734+
735+
def test_slot_far_before_finalized_not_justifiable(
736+
justifiability_test: JustifiabilityTestFiller,
737+
) -> None:
738+
"""
739+
A slot far before the finalized slot is not justifiable.
740+
741+
Given
742+
-----
743+
- the finalized slot is 100.
744+
- the candidate slot is 90.
745+
- the delta is -10, well before the finalized boundary.
746+
747+
When
748+
----
749+
- the candidate is checked for justifiability.
750+
751+
Then
752+
----
753+
- the candidate is not justifiable.
754+
"""
755+
justifiability_test(slot=90, finalized_slot=100)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""State Transition: justification vote-list layout guard"""
2+
3+
import pytest
4+
5+
from consensus_testing import (
6+
BlockSpec,
7+
ExpectedRejection,
8+
StateTransitionTestFiller,
9+
build_genesis_state,
10+
)
11+
from lean_spec.spec.forks import RejectionReason, Slot
12+
from lean_spec.spec.forks.lstar.containers import JustificationRoots, JustificationValidators
13+
from lean_spec.spec.ssz import Boolean, Bytes32
14+
15+
pytestmark = pytest.mark.valid_until("Lstar")
16+
17+
18+
def test_vote_list_length_not_root_count_times_validators_rejects_block(
19+
state_transition_test: StateTransitionTestFiller,
20+
) -> None:
21+
"""
22+
A flat vote list whose length is not the tracked-root count times the validators rejects.
23+
24+
Given
25+
-----
26+
- 4 validators.
27+
- the tracked justification roots hold one non-zero root.
28+
- a full layout needs 4 bits (1 root times 4 validators).
29+
- the flat vote list holds only 2 bits.
30+
- the vote list length does not match the required layout.
31+
32+
When
33+
----
34+
- a block at slot 1 is processed.
35+
36+
Then
37+
----
38+
- the flat vote list cannot be segmented into full validator rounds.
39+
- the block is rejected with JUSTIFICATION_VOTES_LENGTH_MISMATCH.
40+
- the message states the vote list length does not equal the tracked-root count times the
41+
validator count.
42+
"""
43+
state_transition_test(
44+
pre=build_genesis_state(num_validators=4).model_copy(
45+
update={
46+
"justifications_roots": JustificationRoots(data=[Bytes32(b"\x11" * 32)]),
47+
"justifications_validators": JustificationValidators(
48+
data=[Boolean(False), Boolean(False)]
49+
),
50+
}
51+
),
52+
blocks=[
53+
BlockSpec(slot=Slot(1)),
54+
],
55+
post=None,
56+
expected_rejection=ExpectedRejection(
57+
reason=RejectionReason.JUSTIFICATION_VOTES_LENGTH_MISMATCH,
58+
exact_message=(
59+
"Justification vote list length does not equal tracked-root count times "
60+
"validator count"
61+
),
62+
),
63+
)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""State Transition: zero-hash justification root guard"""
2+
3+
import pytest
4+
5+
from consensus_testing import (
6+
BlockSpec,
7+
ExpectedRejection,
8+
StateTransitionTestFiller,
9+
build_genesis_state,
10+
)
11+
from lean_spec.spec.forks import RejectionReason, Slot
12+
from lean_spec.spec.forks.lstar.containers import JustificationRoots, JustificationValidators
13+
from lean_spec.spec.ssz import ZERO_HASH, Boolean
14+
15+
pytestmark = pytest.mark.valid_until("Lstar")
16+
17+
18+
def test_zero_hash_tracked_justification_root_rejects_block(
19+
state_transition_test: StateTransitionTestFiller,
20+
) -> None:
21+
"""
22+
A tracked justification root equal to the zero hash rejects the block.
23+
24+
Given
25+
-----
26+
- 4 validators.
27+
- the tracked justification roots hold the zero hash.
28+
- the flat vote list holds 4 bits, one full validator round.
29+
- the vote list length matches the required layout.
30+
31+
When
32+
----
33+
- a block at slot 1 is processed.
34+
35+
Then
36+
----
37+
- the length guard passes, so the zero-hash guard is reached.
38+
- the zero hash marks a skipped slot and cannot track votes.
39+
- the block is rejected with ZERO_HASH_JUSTIFICATION_ROOT.
40+
- the message states the tracked justification roots contain the zero hash.
41+
"""
42+
state_transition_test(
43+
pre=build_genesis_state(num_validators=4).model_copy(
44+
update={
45+
"justifications_roots": JustificationRoots(data=[ZERO_HASH]),
46+
"justifications_validators": JustificationValidators(
47+
data=[Boolean(False), Boolean(False), Boolean(False), Boolean(False)]
48+
),
49+
}
50+
),
51+
blocks=[
52+
BlockSpec(slot=Slot(1)),
53+
],
54+
post=None,
55+
expected_rejection=ExpectedRejection(
56+
reason=RejectionReason.ZERO_HASH_JUSTIFICATION_ROOT,
57+
exact_message="Tracked justification roots contain the zero hash",
58+
),
59+
)

0 commit comments

Comments
 (0)