fix(fork-choice): state store invariants; reject attestations off the finalized subtree#1179
Merged
tcoratger merged 2 commits intoJul 4, 2026
Conversation
… finalized subtree Four fork-choice store properties were relied on but never stated as invariants, each a clause a formal Store well-formedness predicate would have to assume. Three hold by construction today and are now documented; one is closed with a typed rejection. M-1 (justified descends from finalized): documented as an invariant on the justified checkpoint and on the slot-only checkpoint advance. It holds by construction: every post-state finalizes an ancestor of what it justifies, and the head walk anchors at the justified root. M-2 (admission mirrors pruning): attestation admission now rejects a vote whose head does not descend from the finalized block, mirroring the prune predicate. Without it a re-gossiped stale aggregate re-entered the pool after pruning dropped it. Such a vote never changed the head, so this is DoS and idempotence hardening, not a safety fix. Adds a typed rejection reason and a proving vector; the pruning test is reworked to seed stale entries without the now-rejected below-finalized gossip. M-3 (pruning against a reorg-mutable finalized): documented only. See the PR description for why monotone finalization is deliberately not adopted. M-4 (blocks and states share one key set): documented as an invariant on the store; the dependent asserts are relabelled as provably-unreachable internal checks. The block-builder test harness was synthesizing block aggregates through the gossip admission path, which the real block processing never uses. It now seeds the signature pool directly, so block-carried attestations are not gossip-validated against the current finalized checkpoint. Refs leanEthereum#1176 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d-bearing reason Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Addresses the four unstated fork-choice store invariants in #1176. After a four-specialist analysis (fork-choice safety, API design, tests, docs), the conclusion is that none of the four is an exploitable bug today — three hold by construction and are now documented; one is closed with a typed rejection. This is primarily an invariant-stating PR plus one robustness hardening.
blocks.keys() == states.keys()M-1 — justified descends from finalized
Documented as an invariant on the justified checkpoint and on the slot-only
advance_to. It holds by construction: every post-state finalizes an ancestor of what it justifies, andupdate_headanchors the LMD walk at the justified root, so the head (and the head-derived finalized) stay on one chain with justified. No code change.M-2 — attestation admission now mirrors the prune predicate
validate_attestationpreviously never checked that a vote's head descends fromlatest_finalized, whileprune_stale_attestation_datadrops exactly those heads. So a re-gossiped stale aggregate re-entered the pool after pruning removed it. Such a vote carries zero fork-choice weight (an off-finalized head never sits under the justified anchor the LMD walk descends from), so this is not a safety fix — it closes a pool-inflation / signature-verification DoS amplifier and restores prune idempotence.RejectionReason.HEAD_NOT_DESCENDANT_OF_FINALIZED._checkpoint_is_ancestor(store, latest_finalized, head)), so a head equal to the finalized checkpoint is admitted — genesis votes are not wrongly rejected.Test-harness fix included
The regression gate surfaced that the block-builder harness (
build_signed_block_with_store) was synthesizing a block's aggregate by replaying signatures through the gossip path (on_gossip_attestation→validate_attestation). The realon_blocknever gossip-validates block-carried attestations — it verifies proofs and runs the state transition. The harness now seeds the signature pool directly, so it no longer re-validates block attestations against the current finalized checkpoint. The pruning test is reworked to seed stale entries via still-valid gossip that a later finalization strands, and a new proving vector locks the guard (prune → re-gossip → rejected, asserting the full message).M-3 — pruning against a reorg-mutable finalized (documented, not changed)
We deliberately do not make
latest_finalizedmonotone, and this is the one item worth maintainer attention.The tempting fix — take the slot-wise max of the old and head-derived finalized in
update_head, matching the monotonefinalized_checkpointin consensus-specs — is unsafe here. leanSpec anchors fork choice at the justified root, not finalized, and has nofilter_block_treeforcing the head to descend from finalized.update_headkeepslatest_finalizedon the canonical chain only by re-deriving it from the head each call. Pinning it monotonically lets it drift onto an orphaned branch, where it (a) is no longer an ancestor of the head and (b) inflates the LMD staleness cutoff, silently dropping live votes for the canonical branch. It buys nothing under honest majority (the two values coincide whenever accountable safety holds) and is strictly worse under a ≥1/3 safety fault. The existing prune gate (prune only fires when finalized advances to an on-chain value) prevents the catastrophic mass-prune, but the invariant break and cutoff distortion are real.A genuinely monotone, economically-final
latest_finalizedwould require adopting the consensus-specs machinery wholesale: guard the justified advance so justified only moves to descendants of finalized, filter the block tree to descendants of finalized, and re-anchor LMD-GHOST at finalized. That is a fork-choice redesign, not a localized fix.Open question for maintainers: do we want to migrate leanSpec's fork choice to be finalized-anchored (
filter_block_treestyle) so that a monotone finalized becomes well-defined? If yes, that belongs in its own design proposal. This PR documents the invariantupdate_headactually maintains (finalized is always an ancestor of the head, reorg-mutable, not a safety guarantee).M-4 — blocks/states share one key set
Documented as an invariant on the store; the dependent asserts are relabelled as provably-unreachable internal checks. Blocks and states are only ever written as a matched pair, so the invariant holds by construction. A Pydantic
model_validatorwould be the wrong tool here — it is bypassed bymodel_copyand would swallow the domain error — so the honest remedy is documentation plus the existing asserts.Credit
Found via the Lean 4 formalization of this spec (NyxFoundation/formal-leanSpec): each item is a clause a
Store.WellFormedpredicate had to assume because the spec did not guarantee it.Testing
New and reworked consensus vectors fill green and deterministically;
just checkpasses.Refs #1176
🤖 Generated with Claude Code