Skip to content

Commit 6a2714a

Browse files
tcoratgerclaude
andauthored
fix(lstar): separate protocol rejections from internal assertions (#1180)
Protocol rejections and internal invariant asserts shared one exception type: the rejection error subclassed AssertionError, and bare asserts reachable from input raised the same type. A transliterating client could not tell a validation failure every client must reproduce from an invariant that can never fire, and python -O strips bare asserts while keeping the typed raises, so the reference's accept/reject set depended on an interpreter flag. Changes: - The rejection error now subclasses a new spec-error root instead of AssertionError, so the reason code, not the Python base class, is the client contract. This also makes it safe to raise inside a pydantic validator, matching how the SSZ error is defined. The one catch site that relied on the old base is narrowed to catch the rejection directly. - Document the convention that any unhandled exception during block import or the state transition means the input is invalid, with the companion rule that a bare assert marks only a provably-unreachable invariant while every reachable validity check raises a typed rejection. - The justification-index assert is kept and documented as a provably-unreachable invariant; the already-justified skip upstream guarantees an in-range index. - The finalization rebase now drops a tracked tally whose root is off the canonical chain instead of asserting. On a state rebuilt from untrusted bytes such a root can be absent from the slot map; the tally is inert, so dropping it makes the transition total and removes a -O KeyError divergence. Matches the Lean model. Locked with a new vector. - The anchor type guards are documented as by-construction internal checks, not a client rejection path. Refs #1173 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9033157 commit 6a2714a

6 files changed

Lines changed: 125 additions & 15 deletions

File tree

src/lean_spec/node/validator/service.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
SignedAttestation,
2727
SignedBlock,
2828
Slot,
29+
SpecRejectionError,
2930
ValidatorIndex,
3031
)
3132
from lean_spec.spec.forks.lstar.containers import MultiMessageAggregate, SingleMessageAggregate
@@ -209,8 +210,8 @@ async def _maybe_produce_block(self, slot: Slot) -> None:
209210
if self.on_block is not None:
210211
await self.on_block(signed_block)
211212

212-
except AssertionError as exception:
213-
# Slot-boundary races can fail proposer validation.
213+
except SpecRejectionError as exception:
214+
# Slot-boundary races can fail proposer validation with a rejection.
214215
# Skip block production; the attestation at interval 1 still happens.
215216
logger.debug(
216217
"Block production skipped for validator %d at slot %d: %s",

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,19 +114,23 @@ class RejectionReason(StrEnum):
114114
"""The input bytes cannot be decoded into the expected structure."""
115115

116116

117-
class SpecRejectionError(AssertionError):
117+
class SpecError(Exception):
118+
"""Base class for every error the spec raises."""
119+
120+
121+
class SpecRejectionError(SpecError):
118122
"""
119123
A rejection carrying its language-neutral reason.
120124
121-
Subclassing the assertion error keeps existing rejection handlers working.
125+
Clients match on the reason code, not the Python base class.
122126
"""
123127

124128
def __init__(self, reason: RejectionReason, message: str) -> None:
125129
"""
126130
Bind the rejection to its reason.
127131
128132
Args:
129-
reason: Language-neutral reason clients assert against.
133+
reason: Language-neutral reason clients match against.
130134
message: Human-readable explanation for logs and debugging.
131135
"""
132136
super().__init__(message)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def create_store(
100100
SpecRejectionError: ANCHOR_STATE_ROOT_MISMATCH if the anchor block's
101101
state root does not match the hash of the state.
102102
"""
103+
# Internal type guards: the anchor is always the concrete fork state and block.
104+
# These narrow the generic protocol types; they are never a client rejection path.
103105
assert isinstance(state, State)
104106
assert isinstance(anchor_block, Block)
105107

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,8 @@ def process_attestations(
337337
if target.slot > latest_justified.slot:
338338
latest_justified = target
339339

340-
# The justifiable filter above guarantees an in-range index.
340+
# Invariant: the already-justified skip drops targets at or below finalized.
341+
# So the index is in range; the assert guards an internal fact, not a rejection.
341342
justified_index = target.slot.justified_index_after(finalized_slot)
342343
assert justified_index is not None
343344

@@ -365,13 +366,12 @@ def process_attestations(
365366
delta = int(finalized_slot - old_finalized_slot)
366367
if delta > 0:
367368
justified_slots = JustifiedSlots(data=justified_slots.data[delta:])
368-
assert all(root in root_to_slot for root in justifications), (
369-
"Justification root missing from root_to_slot"
370-
)
369+
# A root absent from the slot map is off-chain and cannot justify.
370+
# Drop such a tally, do not track or reject it.
371371
justifications = {
372372
root: votes
373373
for root, votes in justifications.items()
374-
if root_to_slot[root] > finalized_slot
374+
if root in root_to_slot and root_to_slot[root] > finalized_slot
375375
}
376376

377377
# Re-pack the vote map into the flat SSZ layout, roots first.

tests/consensus/lstar/state_transition/test_finalization.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,16 @@
1212
from lean_spec.spec.crypto.merkleization import hash_tree_root
1313
from lean_spec.spec.forks import Slot, ValidatorIndex
1414
from lean_spec.spec.forks.lstar.containers import (
15+
BlockHeader,
16+
Checkpoint,
17+
HistoricalBlockHashes,
1518
JustificationRoots,
1619
JustificationValidators,
1720
JustifiedSlots,
21+
State,
1822
)
1923
from lean_spec.spec.forks.lstar.spec import LstarSpec
20-
from lean_spec.spec.ssz import Boolean
24+
from lean_spec.spec.ssz import Boolean, Bytes32, Uint64
2125

2226
pytestmark = pytest.mark.valid_until("Lstar")
2327

@@ -1374,3 +1378,100 @@ def test_rebased_finalization_prunes_stale_votes_and_preserves_future_votes(
13741378
),
13751379
),
13761380
)
1381+
1382+
1383+
def test_finalization_rebase_drops_off_chain_pending_tally(
1384+
state_transition_test: StateTransitionTestFiller,
1385+
) -> None:
1386+
"""
1387+
Finalization drops a pending tally whose root is off the canonical chain.
1388+
1389+
Given
1390+
-----
1391+
- 4 validators; a slot needs 3 votes (2/3) to be justified.
1392+
- a hand-built pre-state at slot 3 over the chain:
1393+
genesis(0) -> block_1(1) -> block_2(2) -> block_3(3)
1394+
- slot 1 is justified, rooted at block_1.
1395+
- finalized is slot 0, rooted at the genesis anchor.
1396+
- a phantom root carries a pending tally.
1397+
- the phantom root appears nowhere in the chain history.
1398+
- the phantom tally holds V0 alone of 4.
1399+
- block_4 carries V0, V1, V2's vote from block_1 to block_2.
1400+
1401+
When
1402+
----
1403+
- the chain processes block_4 on top of the pre-state.
1404+
1405+
Then
1406+
----
1407+
- the state slot is 4.
1408+
- block_4's supermajority justifies slot 2.
1409+
- justified slot is 2, rooted at block_2.
1410+
- the adjacent source slot 1 finalizes.
1411+
- finalized slot is 1, rooted at block_1.
1412+
- the justified-slots bitfield is [True, False] relative to slot 1.
1413+
- the off-chain phantom root is dropped from the pending roots.
1414+
- the phantom tally is dropped from the pending voters.
1415+
"""
1416+
genesis = build_genesis_state()
1417+
1418+
genesis_anchor_root = Bytes32(b"\x11" * 32)
1419+
block_1_root = Bytes32(b"\x22" * 32)
1420+
block_2_root = Bytes32(b"\x33" * 32)
1421+
phantom_justification_root = Bytes32(b"\xff" * 32)
1422+
1423+
pre = State(
1424+
config=genesis.config,
1425+
slot=Slot(3),
1426+
latest_block_header=BlockHeader(
1427+
slot=Slot(3),
1428+
proposer_index=ValidatorIndex.proposer_for_slot(Slot(3), Uint64(4)),
1429+
parent_root=block_2_root,
1430+
state_root=Bytes32.zero(),
1431+
body_root=genesis.latest_block_header.body_root,
1432+
),
1433+
latest_justified=Checkpoint(root=block_1_root, slot=Slot(1)),
1434+
latest_finalized=Checkpoint(root=genesis_anchor_root, slot=Slot(0)),
1435+
historical_block_hashes=HistoricalBlockHashes(
1436+
data=[genesis_anchor_root, block_1_root, block_2_root]
1437+
),
1438+
justified_slots=JustifiedSlots(data=[Boolean(True), Boolean(False)]),
1439+
validators=genesis.validators,
1440+
justifications_roots=JustificationRoots(data=[phantom_justification_root]),
1441+
justifications_validators=JustificationValidators(
1442+
data=[Boolean(True), Boolean(False), Boolean(False), Boolean(False)]
1443+
),
1444+
)
1445+
1446+
state_transition_test(
1447+
pre=pre,
1448+
blocks=[
1449+
BlockSpec(
1450+
slot=Slot(4),
1451+
forced_attestations=[
1452+
AggregatedAttestationSpec(
1453+
validator_indices=[
1454+
ValidatorIndex(0),
1455+
ValidatorIndex(1),
1456+
ValidatorIndex(2),
1457+
],
1458+
slot=Slot(4),
1459+
source_slot=Slot(1),
1460+
source_root=block_1_root,
1461+
target_slot=Slot(2),
1462+
target_root=block_2_root,
1463+
),
1464+
],
1465+
),
1466+
],
1467+
post=StateExpectation(
1468+
slot=Slot(4),
1469+
latest_justified_slot=Slot(2),
1470+
latest_justified_root=block_2_root,
1471+
latest_finalized_slot=Slot(1),
1472+
latest_finalized_root=block_1_root,
1473+
justified_slots=JustifiedSlots(data=[Boolean(True), Boolean(False)]),
1474+
justifications_roots=JustificationRoots(data=[]),
1475+
justifications_validators=JustificationValidators(data=[]),
1476+
),
1477+
)

tests/node/validator/test_service.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from lean_spec.node.validator.registry import ValidatorEntry
2323
from lean_spec.spec.crypto.merkleization import hash_tree_root
2424
from lean_spec.spec.crypto.xmss import TARGET_SIGNATURE_SCHEME
25-
from lean_spec.spec.forks import Slot, ValidatorIndex
25+
from lean_spec.spec.forks import RejectionReason, Slot, SpecRejectionError, ValidatorIndex
2626
from lean_spec.spec.forks.lstar import Store
2727
from lean_spec.spec.forks.lstar.config import MILLISECONDS_PER_INTERVAL
2828
from lean_spec.spec.forks.lstar.containers import (
@@ -395,13 +395,13 @@ async def test_non_proposer_does_not_produce(
395395

396396
assert blocks == []
397397

398-
async def test_assertion_error_is_logged_and_skipped(
398+
async def test_rejection_is_logged_and_skipped(
399399
self,
400400
sync_service: SyncService,
401401
real_registry: ValidatorRegistry,
402402
caplog: pytest.LogCaptureFixture,
403403
) -> None:
404-
"""Store AssertionError during block production is caught; no block emitted."""
404+
"""A proposer-validation rejection during block production is caught; no block emitted."""
405405
blocks: list[SignedBlock] = []
406406

407407
service = ValidatorService(
@@ -414,7 +414,9 @@ async def test_assertion_error_is_logged_and_skipped(
414414
with patch.object(
415415
service.spec,
416416
"produce_block_with_signatures",
417-
side_effect=AssertionError("mismatch"),
417+
side_effect=SpecRejectionError(
418+
RejectionReason.WRONG_PROPOSER, "not the scheduled proposer"
419+
),
418420
):
419421
# Slot 0: proposer is validator 0 (0 % 8 = 0), which is in the registry.
420422
await service._maybe_produce_block(Slot(0))

0 commit comments

Comments
 (0)