Skip to content

fix(forkchoice): derive finalized from canonical head, not an independent max#1001

Open
GrapeBaBa wants to merge 1 commit into
leanEthereum:mainfrom
GrapeBaBa:fix/forkchoice-finalized-tracks-canonical-head
Open

fix(forkchoice): derive finalized from canonical head, not an independent max#1001
GrapeBaBa wants to merge 1 commit into
leanEthereum:mainfrom
GrapeBaBa:fix/forkchoice-finalized-tracks-canonical-head

Conversation

@GrapeBaBa

@GrapeBaBa GrapeBaBa commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Problem

Fixes #1000.

on_block advanced store.latest_finalized by an independent monotonic max over every imported block's post-state, decoupled from store.latest_justified and the head. A fork that finalizes a higher slot but then loses head selection left its finalized checkpoint latched in the store, so store.latest_finalized could sit on a dead branch that is not an ancestor of the head, while head / latest_justified were on a different branch whose state finalized is lower.

get_attestation_target decides target justifiability against store.latest_finalized, while process_attestations validates targets against the canonical head-state's (lower) finalized. Once the two finalized slots diverge, is_justifiable_after disagrees, every advancing target is rejected, and finalization freezes — first observed as a network-wide finalization freeze in a long-running multi-subnet devnet.

The original forkchoice store (#53) and the 3sf-mini reference derive finalized from the chosen (head) state; #194 changed it to the independent max (see #1000 for the full root-cause trace).

Fix

Derive store.latest_finalized from the canonical head's chain after head selection:

  • take the finalized slot named by the head's state, and
  • recover its root as the head's own ancestor at that slot.
finalized_slot = store.states[store.head].latest_finalized.slot
finalized_root = store.head
while store.blocks[finalized_root].slot > finalized_slot:
    finalized_root = store.blocks[finalized_root].parent_root
store = store.model_copy(
    update={"latest_finalized": Checkpoint(root=finalized_root, slot=finalized_slot)}
)

The checkpoint then always lies on the head chain (a fork that loses head selection cannot pin its finalized), and crucially store.latest_finalized.slot == head-state.latest_finalized.slot, so the slot a validator attests against matches the slot the state transition validates against — the desync that froze finalization is gone. This matches 3sf-mini, where the finalized checkpoint is read from the chosen head's state.

Taking the root as the head's ancestor (rather than copying head-state.latest_finalized.root) also recovers the correct root for a checkpoint-sync anchor: a state can never name its own block as finalized, so the anchor state carries a placeholder root while the head's ancestor at the anchor slot is the real anchor block.

@GrapeBaBa GrapeBaBa force-pushed the fix/forkchoice-finalized-tracks-canonical-head branch from aa1702d to 17f85ea Compare June 13, 2026 13:40
@GrapeBaBa GrapeBaBa marked this pull request as draft June 13, 2026 14:48
…dent max

The store advanced latest_finalized by an independent monotonic max over every
imported block's post-state, decoupling it from the head. A fork that finalized
a higher slot but then lost head selection left its finalized checkpoint latched
in the store. get_attestation_target derives the attestation target against
store.latest_finalized, while the state transition validates targets against the
canonical state's latest_finalized; once the two finalized slots diverged,
is_justifiable_after disagreed and every advancing target was rejected, so
finalization froze.

Derive the finalized checkpoint from the canonical head's chain instead: take
the finalized slot named by the head state, and recover its root as the head's
own ancestor at that slot. The checkpoint then always lies on the head chain,
the finalized slot a validator attests against matches the slot the state
transition validates against, and a checkpoint-sync anchor survives because the
head descends from it.

The fork choice is not clamped to finalized descendants: leanSpec finalization
is order dependent, so a fork that finalizes a higher slot can still lose head
selection, and clamping would latch the head onto that losing fork. Deriving
finalized from the head keeps it on the head chain without constraining head
selection.
@GrapeBaBa GrapeBaBa force-pushed the fix/forkchoice-finalized-tracks-canonical-head branch from 17f85ea to 66beecb Compare June 13, 2026 16:49
@GrapeBaBa GrapeBaBa marked this pull request as ready for review June 14, 2026 02:14
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.

Fork choice: store.latest_finalized can latch a losing fork's higher finalized, freezing finalization

1 participant