From bf50ee6840ccaa0fe4cc90020c9320b4b7c4d959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:16:30 -0300 Subject: [PATCH] forkchoice: seed anchor checkpoints from the anchor slot Store.from_anchor previously built the store's justified/finalized checkpoints by copying the slot from the anchor state's embedded latest_justified/latest_finalized (which can be strictly less than the anchor slot) while overriding only the root to the anchor block root. That produced a slot/root-inconsistent checkpoint: the slot pointed at pre-anchor history while the root identified the anchor block itself. Seed both checkpoints from the anchor itself: slot = anchor.slot, root = anchor_root. The anchor state's embedded checkpoints are intentionally ignored, matching the "store treats the anchor as the new genesis" framing already used at the checkpoint-sync entry point. Genesis initialization is unaffected: at genesis the anchor state's embedded checkpoints already sit at slot 0 = anchor.slot, so the new seeding produces identical values. Also updates the checkpoint-sync tests and a misleading inline comment that described the previous (pre-anchor-slot) behavior. --- src/lean_spec/subspecs/forkchoice/store.py | 19 ++++++++------ .../devnet/fc/test_checkpoint_sync.py | 26 +++++++++++++------ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/lean_spec/subspecs/forkchoice/store.py b/src/lean_spec/subspecs/forkchoice/store.py index 764e7ba3..5b333553 100644 --- a/src/lean_spec/subspecs/forkchoice/store.py +++ b/src/lean_spec/subspecs/forkchoice/store.py @@ -210,18 +210,21 @@ def from_anchor( # Read the slot at which the anchor block was proposed. anchor_slot = anchor_block.slot - # Initialize checkpoints from this state. + # Seed both checkpoints from the anchor block itself. # - # We explicitly set the root to the anchor block root. - # The state internally might have zero-hash checkpoints (if genesis), - # but the Store must treat the anchor block as the justified/finalized point. + # The store treats the anchor as the new "genesis" for fork choice: + # all history below it is pruned. The justified and finalized checkpoints + # therefore point at the anchor block with the anchor's own slot, + # regardless of what the anchor state's embedded checkpoints say. + anchor_checkpoint = Checkpoint(root=anchor_root, slot=anchor_slot) + return cls( time=Interval.from_slot(anchor_slot), config=state.config, head=anchor_root, safe_target=anchor_root, - latest_justified=state.latest_justified.model_copy(update={"root": anchor_root}), - latest_finalized=state.latest_finalized.model_copy(update={"root": anchor_root}), + latest_justified=anchor_checkpoint, + latest_finalized=anchor_checkpoint, blocks={anchor_root: anchor_block}, states={anchor_root: state}, validator_id=validator_id, @@ -515,8 +518,8 @@ def on_block( # Keep the checkpoint with the higher slot. # On slot ties, prefer the store's own checkpoint. # - # The store's checkpoint is pinned to the anchor block root at init. - # The anchor state may hold a pre-anchor root. + # The store's checkpoint is pinned to the anchor at init and only + # moves forward via real justification/finalization events. # On ties the store's view is authoritative. latest_justified = max( self.latest_justified, post_state.latest_justified, key=lambda c: c.slot diff --git a/tests/consensus/devnet/fc/test_checkpoint_sync.py b/tests/consensus/devnet/fc/test_checkpoint_sync.py index 15c07a90..c9657fb5 100644 --- a/tests/consensus/devnet/fc/test_checkpoint_sync.py +++ b/tests/consensus/devnet/fc/test_checkpoint_sync.py @@ -47,7 +47,8 @@ def test_store_init_from_non_genesis_anchor( ----------------------------- - Head points to the anchor block. - - Latest justified and latest finalized both reference the anchor root. + - Latest justified and latest finalized both reference the anchor root + at the anchor's own slot (beacon-chain seeding convention). - Store clock is at the anchor slot (no pre-anchor intervals tracked). - The anchor block is the only entry in the store's block map. @@ -78,9 +79,12 @@ def test_store_init_from_non_genesis_anchor( time=anchor_time_intervals, head_slot=ANCHOR_SLOT, head_root_label="genesis", - latest_justified_slot=anchor_state.latest_justified.slot, + # Both checkpoints are seeded from the anchor itself: + # slot = anchor.slot, root = anchor_root. The anchor + # state's embedded checkpoint slots are intentionally ignored. + latest_justified_slot=ANCHOR_SLOT, latest_justified_root_label="genesis", - latest_finalized_slot=anchor_state.latest_finalized.slot, + latest_finalized_slot=ANCHOR_SLOT, latest_finalized_root_label="genesis", safe_target_root_label="genesis", labels_in_store=["genesis"], @@ -135,9 +139,11 @@ def test_extend_chain_from_non_genesis_anchor( checks=StoreChecks( head_slot=Slot(11), head_root_label="block_11", - latest_justified_slot=anchor_state.latest_justified.slot, + # Empty blocks carry no attestations, so neither checkpoint + # advances past the anchor seeding. + latest_justified_slot=ANCHOR_SLOT, latest_justified_root_label="genesis", - latest_finalized_slot=anchor_state.latest_finalized.slot, + latest_finalized_slot=ANCHOR_SLOT, latest_finalized_root_label="genesis", labels_in_store=["genesis", "block_11"], ), @@ -151,7 +157,9 @@ def test_extend_chain_from_non_genesis_anchor( checks=StoreChecks( head_slot=Slot(12), head_root_label="block_12", + latest_justified_slot=ANCHOR_SLOT, latest_justified_root_label="genesis", + latest_finalized_slot=ANCHOR_SLOT, latest_finalized_root_label="genesis", labels_in_store=["genesis", "block_11", "block_12"], ), @@ -165,7 +173,9 @@ def test_extend_chain_from_non_genesis_anchor( checks=StoreChecks( head_slot=Slot(13), head_root_label="block_13", + latest_justified_slot=ANCHOR_SLOT, latest_justified_root_label="genesis", + latest_finalized_slot=ANCHOR_SLOT, latest_finalized_root_label="genesis", labels_in_store=["genesis", "block_11", "block_12", "block_13"], ), @@ -238,9 +248,9 @@ def test_fork_off_non_genesis_anchor( # unambiguously heavier. # # Source is pinned to the anchor block because the store - # only knows about blocks at or after the anchor. The - # anchor state's internal justified root still points to - # pre-anchor history that was discarded on checkpoint sync. + # only knows about blocks at or after the anchor, and the + # store's latest_justified checkpoint is seeded at + # (anchor.slot, anchor_root). BlockStep( block=BlockSpec( slot=Slot(12),