fix: enforce attestation checkpoint ancestry#419
Conversation
🤖 Kimi Code ReviewThis PR implements critical consensus validation (leanSpec #833) ensuring attestation consistency: source → target → head must form a single parent chain. This prevents FFG/LMD vote splitting attacks where a validator might cast a canonical FFG vote (source/target) but pair it with a non-canonical LMD head. Review SummaryCorrectness & Security: ✅ Correct. The ancestry checks in both
Code Quality: Good. Well-documented, follows Rust idioms ( Minor ObservationsPerformance consideration ( Test robustness ( State transition consistency ( VerdictApprove. This is a correct and necessary consensus-layer fix with good test coverage. The ancestry validation logic aligns with the Ethereum consensus specification requirements for vote consistency. Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
The added unit coverage is useful, and the intended chain-consistency rule itself looks reasonable. I couldn’t run the Rust tests in this environment because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is the review: PR Review:
|
68d2072 to
02287c3
Compare
Greptile SummaryPorts leanSpec #833 to enforce that attestation checkpoints form a single parent chain, not just a slot-ordered sequence. Two enforcement points are updated: gossip validation walks parent links through the fork-choice store, and the state-transition chain-match function now also checks the
Confidence Score: 3/5The state-transition change is correct and safe, but the gossip-layer ancestry walk in store.rs is unbounded and should be capped before shipping. The new checkpoint_is_ancestor walk is O(descendant.slot minus ancestor.slot) and is invoked before the time check. An adversarial peer can trigger a walk proportional to the full chain height by referencing genesis as source, causing significant CPU cost per gossiped attestation. The state-transition side is straightforward and correct. crates/blockchain/src/store.rs — specifically the checkpoint_is_ancestor function and its call sites in validate_attestation_data.
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/store.rs | Adds checkpoint_is_ancestor helper and two ancestry assertions in validate_attestation_data; the walk is unbounded and runs before the time check, creating an O(chain-height) DoS path per gossiped attestation when source is at genesis. |
| crates/blockchain/state_transition/src/lib.rs | Extends attestation_data_matches_chain to reject zero-hash, out-of-range, and off-canonical head checkpoints by checking historical_block_hashes at head_slot; logic is correct and well-tested. |
Comments Outside Diff (1)
-
crates/blockchain/src/store.rs, line 197-207 (link)Unbounded ancestry walk enables O(chain-height) DoS per attestation
checkpoint_is_ancestorwalks every parent header fromdescendant.slotdown toancestor.slot, making its cost O(descendant.slot − ancestor.slot). The time check only boundsdata.slot, not the source/target slot gap. An adversarial peer can setsource = { slot: 0, root: genesis_root }andtarget = { root: current_canonical_tip, slot: N }— both real blocks already in the store — causing a walk of N steps before the time check fires. On a mature chain this means O(N) header fetches per gossiped attestation.Consider adding a depth limit to
checkpoint_is_ancestor(e.g. refuse to walk more thanMAX_ANCESTOR_WALK_DEPTHsteps and returnfalse). Legitimate attestations only span a small, protocol-bounded epoch gap, so a cap of a few hundred slots would never reject a valid vote while closing the DoS path.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/blockchain/src/store.rs Line: 197-207 Comment: **Unbounded ancestry walk enables O(chain-height) DoS per attestation** `checkpoint_is_ancestor` walks every parent header from `descendant.slot` down to `ancestor.slot`, making its cost O(descendant.slot − ancestor.slot). The time check only bounds `data.slot`, not the source/target slot gap. An adversarial peer can set `source = { slot: 0, root: genesis_root }` and `target = { root: current_canonical_tip, slot: N }` — both real blocks already in the store — causing a walk of N steps before the time check fires. On a mature chain this means O(N) header fetches per gossiped attestation. Consider adding a depth limit to `checkpoint_is_ancestor` (e.g. refuse to walk more than `MAX_ANCESTOR_WALK_DEPTH` steps and return `false`). Legitimate attestations only span a small, protocol-bounded epoch gap, so a cap of a few hundred slots would never reject a valid vote while closing the DoS path. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
crates/blockchain/src/store.rs:197-207
**Unbounded ancestry walk enables O(chain-height) DoS per attestation**
`checkpoint_is_ancestor` walks every parent header from `descendant.slot` down to `ancestor.slot`, making its cost O(descendant.slot − ancestor.slot). The time check only bounds `data.slot`, not the source/target slot gap. An adversarial peer can set `source = { slot: 0, root: genesis_root }` and `target = { root: current_canonical_tip, slot: N }` — both real blocks already in the store — causing a walk of N steps before the time check fires. On a mature chain this means O(N) header fetches per gossiped attestation.
Consider adding a depth limit to `checkpoint_is_ancestor` (e.g. refuse to walk more than `MAX_ANCESTOR_WALK_DEPTH` steps and return `false`). Legitimate attestations only span a small, protocol-bounded epoch gap, so a cap of a few hundred slots would never reject a valid vote while closing the DoS path.
```suggestion
/// Maximum number of parent-link hops `checkpoint_is_ancestor` will follow.
/// Honest attestations span at most a few epochs; a larger gap indicates either
/// a malformed or adversarial vote and is conservatively rejected.
const MAX_ANCESTOR_WALK_DEPTH: u64 = 1024;
fn checkpoint_is_ancestor(store: &Store, ancestor: &Checkpoint, descendant: &Checkpoint) -> bool {
if ancestor.slot > descendant.slot {
return false;
}
if descendant.slot - ancestor.slot > MAX_ANCESTOR_WALK_DEPTH {
return false;
}
let mut current_root = descendant.root;
while let Some(current_header) = store.get_block_header(¤t_root) {
```
Reviews (1): Last reviewed commit: "fix: enforce attestation checkpoint ance..." | Re-trigger Greptile
1be4e64 to
7285d56
Compare
Port leanSpec PR #833 (leanEthereum/leanSpec#833). Reject attestations whose checkpoints are slot-ordered but not on the same chain. Fork choice weights by the attested head root and accumulates weight to every ancestor of that head, so a known-but-sibling head lets a validator present a canonical source -> target FFG vote while injecting LMD-GHOST weight into a non-canonical branch: a vote-splitting vector. Two complementary checks: - Gossip validation (validate_attestation_data) requires source -> target and target -> head ancestry. checkpoint_is_ancestor walks parent links in the fork-choice store's block tree, capped at MAX_ANCESTRY_WALK_SLOTS (128) to bound per-attestation work; a missing block, skipped slot, or exceeding the cap is treated conservatively as non-ancestry. Adds StoreError::SourceNotAncestorOfTarget and TargetNotAncestorOfHead. - State transition (attestation_data_matches_chain) now also matches head.root against the canonical historical_block_hashes view, not only source and target. Justification is keyed on target.root, so this keeps counted votes internally consistent: no decoupled FFG vote on the canonical chain paired with an LMD head on a sibling fork.
7285d56 to
7922eff
Compare
Summary
Ports leanSpec PR #833: reject attestations whose checkpoints are slot-ordered but not on the same chain.
This enforces the topology implied by
source <= target <= headin two places that previously only checked slots and availability:validate_attestation_data,store.rs) now verifiessource -> targetandtarget -> headancestry by walking parent links through the fork-choice store's block tree.attestation_data_matches_chain,state_transition/src/lib.rs) now matcheshead.rootagainst the canonicalhistorical_block_hashesview, not onlysourceandtarget.Root cause
validate_attestation_datachecked that source/target/head blocks were known and that their checkpoint slots matched the block slots. It did not check that target was actually an ancestor of head, or that source was an ancestor of target.Because fork choice weights by
attestation_data.head.rootand_accumulate_ancestor_weightswalks upward from the attested head giving weight to that branch's ancestors, accepting a known-but-siblingheadlets a validator present a canonicalsource -> targetFFG vote while injecting LMD-GHOST weight into a non-canonical branch. That is a fork-choice vote-splitting vector.attestation_data_matches_chainis the state-transition complement. Justification is still keyed ontarget.root, so a sibling head does not justify a non-canonical target; checkingheadthere keeps counted votes internally consistent (no decoupled FFG vote on the canonical chain with an LMD head on a sibling fork).The two checks use different reference frames intentionally (matching leanSpec): gossip validation has the fork-choice store tree available, while state transition validates against the linear canonical
historical_block_hashesview.Notes on the gossip walk
checkpoint_is_ancestorwalks parent links viaget_block_header, capped atMAX_ANCESTRY_WALK_SLOTS(128) to bound per-attestation work on the gossip hot path. A missing block, a skipped (zero-hash) slot, or exceeding the cap is treated conservatively as non-ancestry (the vote is rejected).Changes
crates/blockchain/src/store.rscheckpoint_is_ancestorhelper (parent walk, 128-slot cap); two ancestry checks invalidate_attestation_data; newStoreError::SourceNotAncestorOfTarget/TargetNotAncestorOfHeadvariantscrates/blockchain/state_transition/src/lib.rsattestation_data_matches_chainnow also rejects zero-hash / out-of-range / mismatchedheadTests
New unit tests, all passing:
store::validate_attestation_rejects_head_on_sibling_forkstore::validate_attestation_rejects_source_on_sibling_forkstore::validate_attestation_accepts_proper_ancestor_chainstate_transition::matches_chain_rejects_off_canonical_head(off-canonical / zero-hash / out-of-range head, plus a canonical control)make fmtandmake lintclean.