Skip to content

fix(forkchoice): correct LMD-GHOST orphan-skip, tighten checkpoint advance, assert start_root#727

Merged
tcoratger merged 2 commits into
leanEthereum:mainfrom
tcoratger:refactor/forkchoice-correctness-fixes
May 17, 2026
Merged

fix(forkchoice): correct LMD-GHOST orphan-skip, tighten checkpoint advance, assert start_root#727
tcoratger merged 2 commits into
leanEthereum:mainfrom
tcoratger:refactor/forkchoice-correctness-fixes

Conversation

@tcoratger
Copy link
Copy Markdown
Collaborator

Summary

Three correctness fixes surfaced by the consensus-researcher review.

  • Removes a dead orphan-skip branch in LMD-GHOST whose comment lied about
    its behavior.
  • Centralizes checkpoint advance semantics in Checkpoint.advance_to,
    resolving an inconsistency where on_block and build_block had
    opposite tie semantics because of different max() argument orders.
  • Adds an invariant assert at the head walk so a bad anchor fails loudly
    with a useful message instead of a KeyError deep in the weight loop.

Behavior on the hot path is preserved. The only intentional semantic
change is at build_block on slot ties: the store now wins consistently
with the documented intent at on_block.

What was wrong

1. Dead orphan-skip in _compute_lmd_ghost_head

if not block.parent_root:
    continue  # skip blocks without parents (e.g., purely genesis/orphans)

block.parent_root is Bytes32 — always 32 bytes, so bool(parent_root)
is always True. The branch never fired. Genesis blocks landed in
children_map[Bytes32.zero()] instead of being skipped. The bucket was
never consulted because the walk anchors at the justified root and only
descends, so the bug was silent. The filter is removed and the comment
now explains why genesis cannot pollute the walk.

2. Inconsistent tie semantics across max() call sites

# on_block:    "On slot ties, prefer the store's own checkpoint" (documented)
max(store.latest_justified, post_state.latest_justified)

# build_block: same docstring, opposite argument order, opposite behavior
max(final_post_state.latest_justified, store.latest_justified)

Slot-only __lt__ plus max() means tie behavior depends on argument
order. The two sites had opposite orders. The fix introduces
Checkpoint.advance_to(candidate) which encodes the documented "receiver
wins on tie" rule in the type. Both call sites now read:
store.latest_*.advance_to(post_state.latest_*).

3. Missing invariant on _compute_lmd_ghost_head

Both callers pass store.latest_justified.root which is always a known
block. A future misuse would have produced a cryptic KeyError deep in
the weight loop. A one-line assert states the invariant up front.

Test plan

  • New unit tests for Checkpoint.advance_to covering higher slot,
    lower slot, slot tie, and idempotence
  • New unit test for _compute_lmd_ghost_head rejecting an unknown
    anchor
  • Existing test_produce_block_missing_parent_state updated to
    expect the clearer AssertionError
  • All 100 tests in tests/lean_spec/subspecs/containers/test_checkpoint.py
    and tests/lean_spec/forks/lstar/forkchoice/ pass
  • ruff check, ruff format --check, ty check all green

🤖 Generated with Claude Code

tcoratger and others added 2 commits May 17, 2026 23:48
…vance, 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>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant