Skip to content

Commit 1056f2b

Browse files
tcoratgerclaude
andauthored
fix(forkchoice): correct LMD-GHOST orphan-skip, tighten checkpoint advance, assert start_root (leanEthereum#727)
* fix(forkchoice): correct LMD-GHOST orphan-skip, tighten checkpoint advance, assert start_root Three correctness fixes surfaced by the consensus-researcher review. Behavior on the hot path is preserved; an inconsistency between on_block and build_block tie semantics is resolved in favor of the documented "store-authoritative on tie" rule. 1. _compute_lmd_ghost_head orphan-skip was dead code. `if not block.parent_root: continue` was always False because `block.parent_root` is `Bytes32` (always 32 bytes, never empty). Genesis blocks landed under children_map[Bytes32.zero()] instead of being skipped. The bucket was never consulted because the walk anchors at the justified root and only descends. The misleading filter is removed; a comment now explains why genesis cannot pollute the walk. 2. Checkpoint advance semantics centralized in Checkpoint.advance_to. Two `max(...)` call sites in lstar/spec.py had opposite argument orders, which silently produced opposite tie behavior. The comment at on_block explicitly documents "store wins on tie"; build_block contradicted that. Both sites now use store.latest_*.advance_to(...) which encodes the documented intent in the type itself. 3. _compute_lmd_ghost_head now asserts start_root is a known block. Previously a bad anchor produced a cryptic KeyError deep in the weight loop. The assert states the invariant up front. An existing test that constructed a malformed store is updated to expect the clearer AssertionError. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(checkpoint): split module docstring sentences per doc rules Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6e74982 commit 1056f2b

5 files changed

Lines changed: 86 additions & 24 deletions

File tree

src/lean_spec/forks/lstar/spec.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,14 +1287,14 @@ def on_block(
12871287

12881288
# Propagate checkpoint advances from the post-state.
12891289
#
1290-
# Keep the checkpoint with the higher slot.
1291-
# On slot ties, prefer the store's own checkpoint.
1290+
# A candidate replaces the store's checkpoint only when its slot is strictly higher.
1291+
# On slot ties the store's view stays authoritative.
12921292
#
1293-
# The store's checkpoint is pinned to the anchor at init and only
1294-
# moves forward via real justification/finalization events.
1295-
# On ties the store's view is authoritative.
1296-
latest_justified = max(store.latest_justified, post_state.latest_justified)
1297-
latest_finalized = max(store.latest_finalized, post_state.latest_finalized)
1293+
# Why: the store's checkpoint is pinned at init.
1294+
# It advances only on real justification or finalization events.
1295+
# An incoming tie must not silently swap roots.
1296+
latest_justified = store.latest_justified.advance_to(post_state.latest_justified)
1297+
latest_finalized = store.latest_finalized.advance_to(post_state.latest_finalized)
12981298

12991299
store = store.model_copy(
13001300
update={
@@ -1416,6 +1416,10 @@ def _compute_lmd_ghost_head(
14161416
When two branches have equal weight, the one with the lexicographically
14171417
larger hash is chosen to break ties.
14181418
"""
1419+
# Invariant: the anchor must be a block the store already knows.
1420+
# A loud failure here beats a cryptic missing-key error deep in the weight loop.
1421+
assert start_root in store.blocks, f"start_root {start_root.hex()} not in store.blocks"
1422+
14191423
# Remember the slot of the anchor once and reuse it during the walk.
14201424
#
14211425
# This avoids repeated lookups inside the inner loop.
@@ -1439,17 +1443,15 @@ def _compute_lmd_ghost_head(
14391443
weights[current_root] += 1
14401444
current_root = store.blocks[current_root].parent_root
14411445

1442-
# Build the adjacency tree (parent -> children).
1446+
# Build the parent -> children adjacency.
14431447
#
1444-
# We use a defaultdict to avoid checking if keys exist.
1448+
# Genesis blocks land in the bucket keyed by the zero hash.
1449+
# That bucket is never consulted.
1450+
# The walk anchors at the latest justified root and only descends.
14451451
children_map: dict[Bytes32, list[Bytes32]] = defaultdict(list)
14461452

14471453
for root, block in store.blocks.items():
1448-
# 1. Structural check: skip blocks without parents (e.g., purely genesis/orphans)
1449-
if not block.parent_root:
1450-
continue
1451-
1452-
# 2. Heuristic check: prune branches early if they lack sufficient weight
1454+
# Prune low-weight branches early when a threshold is set.
14531455
if min_score > 0 and weights[root] < min_score:
14541456
continue
14551457

@@ -1948,10 +1950,12 @@ def produce_block_with_signatures(
19481950
# Update checkpoints from post-state.
19491951
#
19501952
# Locally produced blocks bypass normal block processing.
1951-
# We must manually propagate any checkpoint advances.
1952-
# Higher slots indicate more recent justified/finalized states.
1953-
latest_justified = max(final_post_state.latest_justified, store.latest_justified)
1954-
latest_finalized = max(final_post_state.latest_finalized, store.latest_finalized)
1953+
# Checkpoint advances must be propagated manually here.
1954+
#
1955+
# Tie semantics mirror the block-import path.
1956+
# A candidate needs a strictly higher slot to replace the store's view.
1957+
latest_justified = store.latest_justified.advance_to(final_post_state.latest_justified)
1958+
latest_finalized = store.latest_finalized.advance_to(final_post_state.latest_finalized)
19551959

19561960
# Persist block and state immutably.
19571961
new_store = store.model_copy(

src/lean_spec/types/checkpoint.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""
2-
Checkpoint Container.
2+
Checkpoint container.
33
4-
A checkpoint marks a specific moment in the chain. It combines a block
5-
identifier with a slot number. Checkpoints are used for justification and
6-
finalization.
4+
A checkpoint marks a specific moment in the chain.
5+
6+
It combines a block identifier with a slot number.
7+
8+
Checkpoints are used for justification and finalization.
79
"""
810

911
from lean_spec.types.byte_arrays import Bytes32
@@ -27,3 +29,13 @@ def __lt__(self, other: "Checkpoint") -> bool:
2729
return NotImplemented
2830
# Slot drives the order; equal slots leave the pair incomparable.
2931
return self.slot < other.slot
32+
33+
def advance_to(self, candidate: "Checkpoint") -> "Checkpoint":
34+
"""
35+
Return the later of two checkpoints, keeping self on a slot tie.
36+
37+
Forward-only progression for justified and finalized checkpoints.
38+
39+
The candidate replaces the receiver only when its slot is strictly higher.
40+
"""
41+
return candidate if candidate.slot > self.slot else self

tests/lean_spec/forks/lstar/forkchoice/test_compute_block_weights.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from __future__ import annotations
44

5+
import pytest
6+
57
from lean_spec.forks.lstar import Store
68
from lean_spec.forks.lstar.containers.attestation import AttestationData
79
from lean_spec.forks.lstar.spec import LstarSpec
810
from lean_spec.subspecs.ssz.hash import hash_tree_root
911
from lean_spec.subspecs.xmss.aggregation import AggregatedSignatureProof
10-
from lean_spec.types import Checkpoint, Slot, ValidatorIndex, ValidatorIndices
12+
from lean_spec.types import Bytes32, Checkpoint, Slot, ValidatorIndex, ValidatorIndices
1113
from lean_spec.types.byte_arrays import ByteListMiB
1214
from tests.lean_spec.helpers import make_bytes32, make_signed_block
1315

@@ -125,3 +127,15 @@ def test_multiple_attestations_accumulate(spec: LstarSpec, base_store: Store) ->
125127

126128
# Both validators contribute weight to block1
127129
assert weights == {block1_root: 2}
130+
131+
132+
def test_compute_lmd_ghost_head_rejects_unknown_start_root(
133+
spec: LstarSpec, base_store: Store
134+
) -> None:
135+
"""An anchor missing from the store fails the head-walk invariant loudly."""
136+
# A 32-byte root that is guaranteed not to be in the store.
137+
unknown_root = Bytes32(b"\xee" * 32)
138+
assert unknown_root not in base_store.blocks
139+
140+
with pytest.raises(AssertionError, match="not in store.blocks"):
141+
spec._compute_lmd_ghost_head(base_store, start_root=unknown_root, attestations={})

tests/lean_spec/forks/lstar/forkchoice/test_validator.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,10 @@ def test_produce_block_missing_parent_state(self, spec: LstarSpec) -> None:
407407
validator_id=TEST_VALIDATOR_ID,
408408
)
409409

410-
with pytest.raises(KeyError): # Missing head in get_proposal_head
410+
# The forkchoice head walk asserts that the justified root is known.
411+
# Calling produce_block on a store whose latest_justified.root is
412+
# missing from blocks must fail loudly with that invariant message.
413+
with pytest.raises(AssertionError, match="not in store.blocks"):
411414
spec.produce_block_with_signatures(store, Slot(1), ValidatorIndex(1))
412415

413416
def test_validator_operations_invalid_parameters(

tests/lean_spec/subspecs/containers/test_checkpoint.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,32 @@ def test_max_keeps_first_argument_on_slot_tie() -> None:
6666
b = Checkpoint(root=ROOT_B, slot=Slot(5))
6767
assert max(a, b) == a
6868
assert max(b, a) == b
69+
70+
71+
def test_advance_to_returns_candidate_on_higher_slot() -> None:
72+
"""A candidate at a strictly higher slot replaces the receiver."""
73+
current = Checkpoint(root=ROOT_A, slot=Slot(3))
74+
candidate = Checkpoint(root=ROOT_B, slot=Slot(4))
75+
assert current.advance_to(candidate) == candidate
76+
77+
78+
def test_advance_to_keeps_self_on_lower_slot() -> None:
79+
"""A candidate at a lower slot is ignored."""
80+
current = Checkpoint(root=ROOT_A, slot=Slot(4))
81+
candidate = Checkpoint(root=ROOT_B, slot=Slot(3))
82+
assert current.advance_to(candidate) == current
83+
84+
85+
def test_advance_to_keeps_self_on_slot_tie() -> None:
86+
"""On a slot tie the receiver wins regardless of root."""
87+
current = Checkpoint(root=ROOT_A, slot=Slot(7))
88+
candidate = Checkpoint(root=ROOT_B, slot=Slot(7))
89+
assert current.advance_to(candidate) == current
90+
# Symmetric: the receiver of the call always wins on a tie.
91+
assert candidate.advance_to(current) == candidate
92+
93+
94+
def test_advance_to_is_idempotent() -> None:
95+
"""Calling against the same checkpoint returns the receiver unchanged."""
96+
cp = Checkpoint(root=ROOT_A, slot=Slot(2))
97+
assert cp.advance_to(cp) == cp

0 commit comments

Comments
 (0)