fix: align fork-choice finality and aggregate validation with leanSpec#437
Conversation
Three spec-test fixtures from the latest leanSpec release failed: - test_losing_fork_higher_finalized_does_not_latch: finalized was latched monotonically in on_block_core. The spec derives the finalized checkpoint from the chosen head's post-state in update_head and pins it to the head's own ancestry, so a fork switch can move finalization downward. Move that computation into update_head; keep justified monotonic (advance_to) as the spec does. - test_gossip_aggregated_attestation_empty_participants_rejected: an aggregate naming no participants slipped through the mocked-proof path. Reject empty participants (EMPTY_AGGREGATION_BITS) before bounds-check and verification, matching the spec's to_validator_indices(). - test_genesis_registry_holds_exact_validator_entries: the stf runner rejected the new post-state 'validators' registry check (deny_unknown_fields). Parse it and compare each validator's index and public keys.
🤖 Codex Code Review
No other clear correctness problems stood out in the diff. The empty-aggregation-bits check is a good validation hardening step, and the extra state-transition validator assertions improve spec-test coverage. I could not run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is my full code review of PR #437: Code Review: PR #437 — fix: align fork-choice finality and aggregate validation with leanSpecOverviewThree focused spec-test fixes: non-monotonic finality tracking, empty-participant aggregate rejection, and a test-runner schema gap for the validator registry. All three are correctness fixes, not refactors. The changes are narrow and well-scoped. Fix 1:
|
Greptile SummaryThis PR fixes three failing leanSpec spec-test fixtures by aligning fork-choice finality tracking and aggregate-attestation validation with the spec. The finalized checkpoint is now derived from the chosen head's post-state ancestry on every
Confidence Score: 3/5Safe to merge for the spec-test fixtures; the skipped-slot handling in recompute_finalized warrants verification before deploying against a live chain where epoch-boundary blocks are occasionally missing. The finality ancestry walk returns None — leaving the stored finalized checkpoint unchanged — whenever the block at the finalized slot is absent from the store. The comment attributes this only to checkpoint-sync anchors, but skipped slots at epoch boundaries are real in a live network. If that None path triggers while a stale wrong-fork finalized value is already stored, the client silently misreports finality. crates/blockchain/src/store.rs — specifically the recompute_finalized function and its None return path
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/store.rs | Core fork-choice fix: moves finalized-checkpoint derivation from monotonic latching in on_block_core to a per-call ancestry walk in update_head; also adds EmptyAggregationBits guard. Logic is sound; one concern around the skipped-slot None path in recompute_finalized. |
| crates/blockchain/state_transition/tests/stf_spectests.rs | Test-runner update to destructure and compare the new validators registry field; length check guards the indexed access correctly. |
| crates/blockchain/state_transition/tests/types.rs | Schema extension adds optional validators field to PostState; deny_unknown_fields is now satisfied and the From impl in test-fixtures handles deserialization correctly. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Gossip
participant on_block_core
participant update_head
participant recompute_finalized
participant Store
Gossip->>on_block_core: new block arrives
on_block_core->>Store: "store block & state"
note over on_block_core: advance justified (monotonic)<br/>finalized NOT latched here
on_block_core->>update_head: trigger head update
update_head->>Store: compute_lmd_ghost_head(latest_justified.root)
Store-->>update_head: new_head
update_head->>recompute_finalized: recompute_finalized(store, new_head)
recompute_finalized->>Store: get_state(new_head) → finalized_slot
recompute_finalized->>Store: walk ancestors until slot ≤ finalized_slot
Store-->>recompute_finalized: ancestor block at finalized_slot
recompute_finalized-->>update_head: "Option<Checkpoint>"
update_head->>Store: update_checkpoints(head, None, finalized)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Gossip
participant on_block_core
participant update_head
participant recompute_finalized
participant Store
Gossip->>on_block_core: new block arrives
on_block_core->>Store: "store block & state"
note over on_block_core: advance justified (monotonic)<br/>finalized NOT latched here
on_block_core->>update_head: trigger head update
update_head->>Store: compute_lmd_ghost_head(latest_justified.root)
Store-->>update_head: new_head
update_head->>recompute_finalized: recompute_finalized(store, new_head)
recompute_finalized->>Store: get_state(new_head) → finalized_slot
recompute_finalized->>Store: walk ancestors until slot ≤ finalized_slot
Store-->>recompute_finalized: ancestor block at finalized_slot
recompute_finalized-->>update_head: "Option<Checkpoint>"
update_head->>Store: update_checkpoints(head, None, finalized)
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
crates/blockchain/src/store.rs:64-65
`recompute_finalized` is now called on every `update_head` invocation (including the steady-state where the head hasn't moved). Each call issues `get_block_header` calls for every slot between the head and the finalized epoch — ~64 store reads per slot (every 4 s) on a live chain. Consider short-circuiting when `new_head == old_head` and the stored finalized slot already matches what the walk would return.
```suggestion
// Skip the ancestry walk when the head is unchanged and finalized is already
// up-to-date; avoids O(epoch_depth) store reads + a DB write every slot.
let finalized = if new_head != old_head
|| store
.get_state(&new_head)
.map(|s| s.latest_finalized.slot)
!= Some(store.latest_finalized().slot)
{
recompute_finalized(store, new_head)
} else {
None
};
store.update_checkpoints(ForkCheckpoints::new(new_head, None, finalized));
```
### Issue 2 of 2
crates/blockchain/src/store.rs:108-127
**Skipped-slot edge case silently preserves stale finalized**
When `finalized_slot` falls on a skipped slot, `filter(|header| header.slot == finalized_slot)` returns `None`, causing `recompute_finalized` to return `None` and `update_head` to leave the stored finalized checkpoint unchanged. The comment attributes this only to checkpoint-sync anchors, but skipped slots at epoch boundaries occur in normal operation too. If the previous finalized checkpoint was on a losing fork, silently keeping it leaves a stale wrong-fork value. Consider falling back to the nearest ancestor with `slot ≤ finalized_slot`, matching how the beacon spec's `get_block_root_at_slot` resolves epoch-boundary roots.
Reviews (1): Last reviewed commit: "fix: align fork-choice finality and aggr..." | Re-trigger Greptile
The head's post-state already records latest_finalized with both root and slot, and that root is an ancestor of the head by construction. Read it directly and confirm the DB holds the block, rather than re-deriving the root by walking the parent chain. Returns None (keep existing checkpoint) when the finalized block is absent, e.g. a checkpoint-sync anchor's pre-anchor history.
The single-use recompute_finalized helper added little over an inline expression; fold it into update_head.
Summary
CI (
make test) is failing against the latest leanSpec fixtures. This fixes the 3 failing fork-choice / state-transition spec-test fixtures.test_losing_fork_higher_finalized_does_not_latchfinalizedwas latched monotonically (max-seen) inon_block_corefinalizedfrom the chosen head's post-state inupdate_headand pin it to the head's own ancestry (matches leanSpecupdate_head); keepjustifiedmonotonic viaadvance_totest_gossip_aggregated_attestation_empty_participants_rejectedEmptyAggregationBits/EMPTY_AGGREGATION_BITS) before bounds-check and verification, mirroring the spec'sto_validator_indices()test_genesis_registry_holds_exact_validator_entriesPostState(deny_unknown_fields) rejected the new post-statevalidatorsregistry checkvalidatorsand compare each entry's index and public keysWhy the finality change matters
Finality is not a monotonic max over everything ever seen. The spec derives the finalized checkpoint from the chosen head's post-state and pins it to the head's ancestry. When fork choice switches to a fork that finalized less than a now-losing fork, finalized must follow the head downward; otherwise a losing fork's finalization persists on a chain that never contained it.
Notes
stfgenesis) comes from an even-newer fixtures drop and is a test-runner schema gap, not a client bug; fixed here so the next CI run stays green.Testing
cargo test --workspace --release— all greenmake fmt,make lint(-D warnings) — clean