Injectable Skill: Fork Choice Audit
L1 trigger: L1_PATTERN=true AND (fork_choice/ OR ghost/ OR lmd/ OR consensus/tendermint/ OR fork.rs OR choice.rs detected in recon subsystem map)
Inject Into: depth-consensus-invariant
Language: Go and Rust
Finding prefix: [FC-N]
Status: v0.1 draft, Round 4 exemplars pending
Orchestrator Decomposition Guide
- Section 1, 2: depth-consensus-invariant (rule correctness)
- Section 3: depth-edge-case (equivocation + duplicate handling)
- Section 4: depth-state-trace (reorg state consistency)
When This Skill Activates
Recon identifies a fork-choice module. This skill applies whether the protocol uses LMD-GHOST (Ethereum beacon chain), longest-chain (Bitcoin, pre-merge Ethereum), Tendermint locking (Cosmos), or a custom fork-choice rule.
1. Identify the Rule
Before auditing, determine which rule is implemented. Extract from spec docs and code:
| Rule |
Signature |
Key invariants |
| LMD-GHOST |
"latest message driven — greedy heaviest observed subtree" |
Each validator's latest attestation contributes to a subtree weight; choose heaviest |
| Casper FFG + LMD-GHOST (Ethereum) |
LMD-GHOST bounded by finalized checkpoints |
Never revert past justified/finalized checkpoint |
| Tendermint BFT |
Round-based, locking on 2/3+ prevotes |
Locked validator cannot vote for conflicting proposal in same round |
| Nakamoto longest-chain |
Heaviest accumulated work |
Chain with most work wins; stale blocks discarded |
| HotStuff / Aptos / Sui |
3-phase: prepare, precommit, commit |
No two conflicting QCs at the same view |
Write the identified rule into the finding header so reviewers know which invariants apply.
2. Rule-Specific Invariants
2a. LMD-GHOST
- Heaviest subtree monotonicity: adding an attestation can only increase (never decrease) the subtree weight
- Latest-message tie-break: if two children have equal weight, tie-break is deterministic (typically lower root hash)
- Boundary with finality: fork choice never selects a block that would require reverting a finalized block
- Slot vs block: a slot can have zero, one, or multiple proposed blocks; fork choice must handle the missing-block case
Check: Enumerate every call site of find_head() / get_head(). For each, verify the return value is bounded by the latest finalized checkpoint.
2b. Tendermint BFT locking
- Lock only on 2/3+ prevotes: validator cannot lock without proof of 2/3 prevotes
- Lock carries across rounds: once locked in round R, validator votes locked block in rounds R+1, R+2, ... unless unlocked by a newer 2/3+ prevote
- Unlock on newer PoLC (Proof-of-Lock-Change): only unlock if a strictly newer round's 2/3+ prevotes are seen
- No equivocation across rounds: locked validator cannot vote for conflicting block in later round of same height
Check: Follow the lock_value, lock_round, valid_value, valid_round state variables. Verify every set_lock call is guarded by the 2/3 check.
2c. Nakamoto longest-chain
- Cumulative difficulty, not length: chain selection uses total work, not block count
- Stale block eviction: blocks building on non-canonical ancestors are discarded cleanly
- Reorg depth bound: some clients impose a max reorg depth (e.g., 64 blocks); verify the bound is documented and enforced
3. Equivocation and Duplicate Block Handling
Equivocation = a validator signing two conflicting attestations. Fork choice must:
- Detect equivocation via slashing conditions (double vote, surround vote, double proposal)
- Preserve both conflicting signatures as evidence
- Not crash or hang when two conflicting messages arrive in close proximity
- Not double-count the equivocator's weight — their vote should NOT contribute to fork-choice weight after equivocation is detected
Check:
- Ast-grep for "equivocation" / "double_vote" / "slashing" in fork-choice code
- Trace the weight computation: does it exclude equivocators' contributions?
- Duplicate block handling: if two blocks for the same slot arrive, does the fork-choice data structure gracefully hold both, or does the second overwrite the first? Overwrites are bug-prone.
Known exemplar class: Cosmos / Tendermint "Denial of Validators" attacks — multiple historical bugs where equivocation detection could itself be exploited to halt the chain.
Tag: [EQUIVOC:{loc}:{handling}]
4. Reorg State Consistency
When fork choice selects a new head, the node must reorg: revert state from the old head back to the common ancestor, then apply blocks from the common ancestor to the new head.
Check:
- Enumerate all state that must be rolled back on reorg: account balances, nonces, receipts, logs, fork-choice's own caches
- For each state category, verify the reorg path touches it
- Look for side-channel state that does NOT roll back: metrics, logs flushed to disk, cached RPC responses, indexer state
- On reorg, is any background task (indexer, RPC subscription, metrics) left in an inconsistent state?
Tag: [REORG-GAP:{state-category}]
4a. Fork block ordering / replay order
For every helper that returns blocks to apply during a reorg
(get_fork_blocks, fork_blocks, blocks_to_apply, ancestor_path,
rollback_path):
- Determine the expected order: ancestor → child → ... → new head for
forward application, or old head → ... → ancestor for rollback.
- Inspect whether the implementation collects by walking parent pointers
from head to ancestor and forgets to reverse the list.
- Check all consumers: if a function expects forward order but receives
reverse-chronological order, state replay can apply children before
parents.
- Emit a finding for any ambiguous helper that does not document and enforce
its order contract.
Tag: [REORG-ORDER:{function}:{expected}->{actual}]
Known exemplar class: Solana Sept 2022 stuck validator bug — fork choice could not revert to heaviest bank when its slot matched the last voted slot.
5. Slot vs Block Reasoning
A subtle class of bugs comes from conflating slot (time unit) with block (proposed content). Rules of thumb:
- Every slot has zero or one canonical block but the fork-choice data structure must handle both cases
- Slot N can be skipped (empty slot) and slot N+1's block parent points to slot N-1's block
- Validators vote on slots, not blocks — if slot N is empty, validators vote "empty" for N and contribute to the subtree rooted at N-1
Check: Ast-grep for "slot" in fork-choice code. For each, ask: does the code distinguish "no block at this slot" (valid) from "block not yet arrived" (network delay)?
6. Boundary Conditions
Apply the sweep specific to fork choice:
| State |
Test |
Expected |
Observed |
| Genesis |
fork choice on only genesis block |
returns genesis |
|
| Single child |
one block building on head |
returns the child |
|
| Tie |
two children with equal weight |
deterministic tie-break |
|
| Deep reorg |
new head requires reverting 50+ blocks |
completes without state corruption |
|
| Finality boundary |
new head would revert finalized block |
rejected |
|
| Equivocator weight |
equivocator's vote equals non-equivocator's vote |
equivocator excluded, non-equivocator wins |
|
| All votes missing |
no attestations in latest epoch |
returns last-known head |
|
Tag: [BOUNDARY:fork-choice:{test} → {result}]
7. Output schema
- Layer: consensus
- Bug class: fork-choice-rule / equivocation-handling / reorg-consistency / slot-block-conflation / boundary
- Preferred evidence tags:
[CONFORMANCE-PASS] (spec test against reference) > [NON-DET-PASS] > [DIFF-PASS] > [LSP-TRACE]
8. Known bug exemplars (v0.2 — Round 4 verified)
Solana duplicate-block fork-choice stuck-validator (September 30, 2022) — a validator's hot-spare malfunctioned and generated duplicate blocks for the same slot. Fork choice had no rule for same_leader + same_slot + different_payload. Consensus stalled until coordinated restart. Helius outage history. Skill catch point: Section 3 — enumerate leader-duplicate scenarios (same slot/different parent, same slot/same parent/different content, same slot/two leaders); assert fork choice resolves each without liveness loss.
Prysm Fusaka outdated-attestation state replay DoS (December 4, 2025) — Prysm v7.0.0 unnecessarily generated old beacon states while processing outdated attestations. Out-of-sync attestations referencing historical block roots triggered thousands of costly state replays, dropping validator participation to 75% and costing 382 ETH in missed rewards. Rated High not Critical because 9 other clients continued validating. Crypto.news analysis; postmortem. Skill catch point: new methodology step — identify every path that triggers historical state replay, assert the trigger is bounded by a recent-slot check. Stale attestations must be rejected before any state replay.
Lighthouse fork-choice timing bug (v2.5.0, fixed in v2.5.1 "Slippy") — two separate bugs introduced in v2.4.0 and v2.5.0 causing intermittent fork-choice errors. Self-resolved within seconds but noise in logs indicated deeper race conditions. Release v2.5.1 "Slippy". Skill catch point: Section 2a — for each tick-driven fork-choice update, verify ordering of: new block arrival, attestation arrival, slot tick, head update. Fuzz all permutations.
Tendermint lite-client bisection safety gap (issue #3244) — bisection binary-searched for blocks where validator set voting power changes by <1/3. With sufficient validator-set flux, an attacker could fool a lite client into accepting an invalid header at zero cost. tendermint #3244; attack spec. Skill catch point: for any skip/jump verification, assert the security argument depends on slashing-accountability of faulty validators between trusted and target heights, NOT on honest-majority assumption at target alone.
Methodology nuance from Round 4 (stale-state triggers are DoS vectors)
Add to Section 2a: The Prysm Fusaka lesson generalizes — any fork-choice handler that replays historical state on attacker-controlled input is a DoS vector. Specific check:
- For every attestation / message handler, trace the path: "does this trigger state reconstruction?"
- For every state reconstruction call, assert it is bounded by a recent-slot check
- Reject-before-replay, not reject-after-replay
Tag: [FC-STALE-DOS:{handler}:{replay-trigger}]
9. Fallback if primitives unavailable
- Identify fork-choice module by directory name
- Read the function that returns "current head" / "canonical chain"
- Grep for weight/score accumulation loops
- Check recent fork-choice fixes in
git log
Cross-references
- Related skills:
consensus-safety-invariants, bls-aggregation-audit (for attestation signature check), state-sync-pruning (reorg clears sync state)
- Consumed by:
depth-consensus-invariant
- Severity guide:
docs/l1-mode/severity-matrix.md
1---2name: fork-choice-audit3description: L1 trigger - audits fork-choice rule implementation (LMD-GHOST, Tendermint locking, Nakamoto longest-chain) for equivocation handling, slot-vs-block reasoning, duplicate block handling, and chain reorg correctness.4---56# Injectable Skill: Fork Choice Audit78> **L1 trigger**: `L1_PATTERN=true` AND (`fork_choice/` OR `ghost/` OR `lmd/` OR `consensus/tendermint/` OR `fork.rs` OR `choice.rs` detected in recon subsystem map)9> **Inject Into**: `depth-consensus-invariant`10> **Language**: Go and Rust11> **Finding prefix**: `[FC-N]`12> **Status**: v0.1 draft, Round 4 exemplars pending1314## Orchestrator Decomposition Guide1516- Section 1, 2: depth-consensus-invariant (rule correctness)17- Section 3: depth-edge-case (equivocation + duplicate handling)18- Section 4: depth-state-trace (reorg state consistency)1920## When This Skill Activates2122Recon identifies a fork-choice module. This skill applies whether the protocol uses LMD-GHOST (Ethereum beacon chain), longest-chain (Bitcoin, pre-merge Ethereum), Tendermint locking (Cosmos), or a custom fork-choice rule.2324## 1. Identify the Rule2526Before auditing, determine which rule is implemented. Extract from spec docs and code:2728| Rule | Signature | Key invariants |29|---|---|---|30| **LMD-GHOST** | "latest message driven — greedy heaviest observed subtree" | Each validator's latest attestation contributes to a subtree weight; choose heaviest |31| **Casper FFG + LMD-GHOST** (Ethereum) | LMD-GHOST bounded by finalized checkpoints | Never revert past justified/finalized checkpoint |32| **Tendermint BFT** | Round-based, locking on 2/3+ prevotes | Locked validator cannot vote for conflicting proposal in same round |33| **Nakamoto longest-chain** | Heaviest accumulated work | Chain with most work wins; stale blocks discarded |34| **HotStuff / Aptos / Sui** | 3-phase: prepare, precommit, commit | No two conflicting QCs at the same view |3536Write the identified rule into the finding header so reviewers know which invariants apply.3738## 2. Rule-Specific Invariants3940### 2a. LMD-GHOST41- **Heaviest subtree monotonicity**: adding an attestation can only increase (never decrease) the subtree weight42- **Latest-message tie-break**: if two children have equal weight, tie-break is deterministic (typically lower root hash)43- **Boundary with finality**: fork choice never selects a block that would require reverting a finalized block44- **Slot vs block**: a slot can have zero, one, or multiple proposed blocks; fork choice must handle the missing-block case4546**Check**: Enumerate every call site of `find_head()` / `get_head()`. For each, verify the return value is bounded by the latest finalized checkpoint.4748### 2b. Tendermint BFT locking49- **Lock only on 2/3+ prevotes**: validator cannot lock without proof of 2/3 prevotes50- **Lock carries across rounds**: once locked in round R, validator votes locked block in rounds R+1, R+2, ... unless unlocked by a newer 2/3+ prevote51- **Unlock on newer PoLC** (Proof-of-Lock-Change): only unlock if a strictly newer round's 2/3+ prevotes are seen52- **No equivocation across rounds**: locked validator cannot vote for conflicting block in later round of same height5354**Check**: Follow the `lock_value`, `lock_round`, `valid_value`, `valid_round` state variables. Verify every `set_lock` call is guarded by the 2/3 check.5556### 2c. Nakamoto longest-chain57- **Cumulative difficulty, not length**: chain selection uses total work, not block count58- **Stale block eviction**: blocks building on non-canonical ancestors are discarded cleanly59- **Reorg depth bound**: some clients impose a max reorg depth (e.g., 64 blocks); verify the bound is documented and enforced6061## 3. Equivocation and Duplicate Block Handling6263Equivocation = a validator signing two conflicting attestations. Fork choice must:64651. **Detect** equivocation via slashing conditions (double vote, surround vote, double proposal)662. **Preserve** both conflicting signatures as evidence673. **Not crash** or hang when two conflicting messages arrive in close proximity684. **Not double-count** the equivocator's weight — their vote should NOT contribute to fork-choice weight after equivocation is detected6970**Check**:71- Ast-grep for "equivocation" / "double_vote" / "slashing" in fork-choice code72- Trace the weight computation: does it exclude equivocators' contributions?73- Duplicate block handling: if two blocks for the same slot arrive, does the fork-choice data structure gracefully hold both, or does the second overwrite the first? Overwrites are bug-prone.7475**Known exemplar class**: Cosmos / Tendermint "Denial of Validators" attacks — multiple historical bugs where equivocation detection could itself be exploited to halt the chain.7677Tag: `[EQUIVOC:{loc}:{handling}]`7879## 4. Reorg State Consistency8081When fork choice selects a new head, the node must reorg: revert state from the old head back to the common ancestor, then apply blocks from the common ancestor to the new head.8283**Check**:841. Enumerate all state that must be rolled back on reorg: account balances, nonces, receipts, logs, fork-choice's own caches852. For each state category, verify the reorg path touches it863. Look for **side-channel state** that does NOT roll back: metrics, logs flushed to disk, cached RPC responses, indexer state874. On reorg, is any background task (indexer, RPC subscription, metrics) left in an inconsistent state?8889Tag: `[REORG-GAP:{state-category}]`9091### 4a. Fork block ordering / replay order9293For every helper that returns blocks to apply during a reorg94(`get_fork_blocks`, `fork_blocks`, `blocks_to_apply`, `ancestor_path`,95`rollback_path`):96971. Determine the expected order: ancestor → child → ... → new head for98 forward application, or old head → ... → ancestor for rollback.992. Inspect whether the implementation collects by walking parent pointers100 from head to ancestor and forgets to reverse the list.1013. Check all consumers: if a function expects forward order but receives102 reverse-chronological order, state replay can apply children before103 parents.1044. Emit a finding for any ambiguous helper that does not document and enforce105 its order contract.106107Tag: `[REORG-ORDER:{function}:{expected}->{actual}]`108109**Known exemplar class**: Solana Sept 2022 stuck validator bug — fork choice could not revert to heaviest bank when its slot matched the last voted slot.110111## 5. Slot vs Block Reasoning112113A subtle class of bugs comes from conflating slot (time unit) with block (proposed content). Rules of thumb:114115- **Every slot has zero or one canonical block** but the fork-choice data structure must handle both cases116- **Slot N can be skipped** (empty slot) and slot N+1's block parent points to slot N-1's block117- **Validators vote on slots, not blocks** — if slot N is empty, validators vote "empty" for N and contribute to the subtree rooted at N-1118119**Check**: Ast-grep for "slot" in fork-choice code. For each, ask: does the code distinguish "no block at this slot" (valid) from "block not yet arrived" (network delay)?120121## 6. Boundary Conditions122123Apply the sweep specific to fork choice:124125| State | Test | Expected | Observed |126|---|---|---|---|127| Genesis | fork choice on only genesis block | returns genesis | |128| Single child | one block building on head | returns the child | |129| Tie | two children with equal weight | deterministic tie-break | |130| Deep reorg | new head requires reverting 50+ blocks | completes without state corruption | |131| Finality boundary | new head would revert finalized block | rejected | |132| Equivocator weight | equivocator's vote equals non-equivocator's vote | equivocator excluded, non-equivocator wins | |133| All votes missing | no attestations in latest epoch | returns last-known head | |134135Tag: `[BOUNDARY:fork-choice:{test} → {result}]`136137## 7. Output schema138139- **Layer**: consensus140- **Bug class**: fork-choice-rule / equivocation-handling / reorg-consistency / slot-block-conflation / boundary141- **Preferred evidence tags**: `[CONFORMANCE-PASS]` (spec test against reference) > `[NON-DET-PASS]` > `[DIFF-PASS]` > `[LSP-TRACE]`142143## 8. Known bug exemplars (v0.2 — Round 4 verified)1441451. **Solana duplicate-block fork-choice stuck-validator (September 30, 2022)** — a validator's hot-spare malfunctioned and generated duplicate blocks for the same slot. Fork choice had no rule for `same_leader + same_slot + different_payload`. Consensus stalled until coordinated restart. [Helius outage history](https://www.helius.dev/blog/solana-outages-complete-history). **Skill catch point**: Section 3 — enumerate leader-duplicate scenarios (same slot/different parent, same slot/same parent/different content, same slot/two leaders); assert fork choice resolves each without liveness loss.1461472. **Prysm Fusaka outdated-attestation state replay DoS (December 4, 2025)** — Prysm v7.0.0 unnecessarily generated old beacon states while processing outdated attestations. Out-of-sync attestations referencing historical block roots triggered thousands of costly state replays, dropping validator participation to 75% and costing **382 ETH in missed rewards**. Rated **High** not Critical because 9 other clients continued validating. [Crypto.news analysis](https://crypto.news/what-broke-ethereums-fusaka-upgrade/); [postmortem](https://bitcoinethereumnews.com/ethereum/ethereum-prysm-fusaka-mainnet-postmortem-42-epoch-window-shows-18-5-missed-attestations-and-382-eth-lost-in-validator-rewards/). **Skill catch point**: new methodology step — identify every path that triggers historical state replay, assert the trigger is bounded by a recent-slot check. **Stale attestations must be rejected before any state replay.**1481493. **Lighthouse fork-choice timing bug (v2.5.0, fixed in v2.5.1 "Slippy")** — two separate bugs introduced in v2.4.0 and v2.5.0 causing intermittent fork-choice errors. Self-resolved within seconds but noise in logs indicated deeper race conditions. [Release v2.5.1 "Slippy"](https://github.com/sigp/lighthouse/releases/tag/v2.5.1). **Skill catch point**: Section 2a — for each tick-driven fork-choice update, verify ordering of: new block arrival, attestation arrival, slot tick, head update. Fuzz all permutations.1501514. **Tendermint lite-client bisection safety gap (issue #3244)** — bisection binary-searched for blocks where validator set voting power changes by <1/3. With sufficient validator-set flux, an attacker could fool a lite client into accepting an invalid header at zero cost. [tendermint #3244](https://github.com/tendermint/tendermint/issues/3244); [attack spec](https://docs.tendermint.com/master/spec/light-client/attacks/notes-on-evidence-handling.html). **Skill catch point**: for any skip/jump verification, assert the security argument depends on slashing-accountability of faulty validators between trusted and target heights, NOT on honest-majority assumption at target alone.152153### Methodology nuance from Round 4 (stale-state triggers are DoS vectors)154155**Add to Section 2a**: The Prysm Fusaka lesson generalizes — **any fork-choice handler that replays historical state on attacker-controlled input is a DoS vector**. Specific check:156157- For every attestation / message handler, trace the path: "does this trigger state reconstruction?"158- For every state reconstruction call, assert it is bounded by a recent-slot check159- Reject-before-replay, not reject-after-replay160161Tag: `[FC-STALE-DOS:{handler}:{replay-trigger}]`162163## 9. Fallback if primitives unavailable164165- Identify fork-choice module by directory name166- Read the function that returns "current head" / "canonical chain"167- Grep for weight/score accumulation loops168- Check recent fork-choice fixes in `git log`169170## Cross-references171172- Related skills: `consensus-safety-invariants`, `bls-aggregation-audit` (for attestation signature check), `state-sync-pruning` (reorg clears sync state)173- Consumed by: `depth-consensus-invariant`174- Severity guide: `docs/l1-mode/severity-matrix.md`