Skip to content

fix(storage): store fork-choice votes independently - #552

Open
dicethedev wants to merge 2 commits into
lambdaclass:mainfrom
dicethedev:fix/fork-choice-votes
Open

fix(storage): store fork-choice votes independently#552
dicethedev wants to merge 2 commits into
lambdaclass:mainfrom
dicethedev:fix/fork-choice-votes

Conversation

@dicethedev

@dicethedev dicethedev commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🗒️ Description / Motivation

This PR fixes fork-choice vote tracking so validator votes are stored independently from aggregated proofs and gossip signatures.

Previously, fork-choice votes were derived from bounded in-memory proof/signature buffers. When those buffers evicted old entries through FIFO cleanup, a validator’s latest vote could disappear even though the vote should still count for fork choice. This PR adds a separate proof-less vote store so fork-choice weights are not affected by proof/signature retention.

What Changed

  • Updated crates/storage/src/store.rs

    • Added an independent latest-vote store keyed by validator index.
    • Records known fork-choice votes when aggregated payloads become known.
    • Records block-included attestation votes separately from proof storage.
    • Keeps proof buffers for aggregation/proposal behavior only.
    • Prunes stored votes once their target is finalized.
  • Updated crates/blockchain/src/store.rs

    • Inserts block-included attestation votes into the independent vote store during block processing.

Correctness / Behavior Guarantees

  • Fork-choice votes no longer disappear when proof or signature buffers evict entries.
  • Existing proof/signature buffer behavior is preserved for aggregation and block proposal.
  • Latest-vote selection keeps the existing rule: higher slot wins, and same-slot ties use the canonical attestation-data root.
  • Finalized-target votes are pruned once they can no longer affect fork choice.

Tests Added / Run

  • Added regression coverage for votes surviving aggregated proof FIFO eviction.
  • Added coverage for pruning finalized-target votes from the independent vote store.

Related Issues / PRs

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test (cargo test --workspace --profile release-fast) — all passing

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR separates latest fork-choice votes from bounded proof retention.

  • Adds a per-validator in-memory latest-vote store and deterministic replacement rule.
  • Records votes from known aggregates, promoted aggregates, and block-included attestations.
  • Prunes votes when finalization advances and adds FIFO-eviction and pruning tests.
  • Updates block processing to record included attestation votes independently of proofs.

Confidence Score: 4/5

This PR should not merge until vote pruning preserves latest messages whose heads remain relevant above the finalized checkpoint.

The new pruning predicate uses the attestation target, while LMD-GHOST consumes its head; after finalization this can remove live-branch voting weight from subsequent head calculations.

Files Needing Attention: crates/storage/src/store.rs

Important Files Changed

Filename Overview
crates/storage/src/store.rs Adds the independent latest-vote store and ingestion paths, but pruning by target slot can remove votes whose heads remain relevant to fork choice.
crates/blockchain/src/store.rs Records validated block-included attestation votes after persisting the imported block and state.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Known aggregate] --> V[Independent latest-vote store]
  B[Block-included attestation] --> V
  A --> P[Bounded proof buffer]
  V --> F[LMD-GHOST fork choice]
  C[Finalization advances] --> R[Prune votes by target slot]
  R --> V
Loading
Prompt To Fix All With AI
### Issue 1
crates/storage/src/store.rs:1087-1090
**Target-based pruning drops live votes**

When a validator's latest attestation targets a finalized checkpoint but its head remains on a live post-finalization branch, `prune_known_votes` removes the vote based solely on `target.slot`. LMD-GHOST weights `head.root`, so subsequent head calculations lose that validator's weight and can select the wrong branch until a replacement vote arrives.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(storage): store fork-choice votes in..." | Re-trigger Greptile

Comment thread crates/storage/src/store.rs Outdated

@MegaRedHand MegaRedHand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good

Left some comments about pruning. The issue didn't mention it, but the main issue with how we stored votes is that proof are pruned regularly, which shouldn't affect votes: LMD-GHOST uses the latest message from each validator, so otherwise no pruning should happen.

Also, an additional PR is needed to finish addressing the issue, since we still need to replace the payloads and proofs in fork-choice with the new votes.

Comment thread crates/storage/src/store.rs Outdated
Comment thread crates/storage/src/store.rs Outdated
Comment thread crates/storage/src/store.rs Outdated
Comment thread crates/storage/src/store.rs Outdated
Comment thread crates/storage/src/store.rs Outdated
Comment thread crates/storage/src/store.rs Outdated
Comment on lines +575 to +577
fn new_vote_store() -> Arc<Mutex<VoteStore>> {
Arc::new(Mutex::new(Vec::new()))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just Default::default(), right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this is Default::default() now. I removed the helper and wrapped the vote maps in ForkChoiceState as requested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MegaRedHand You can review again

Comment on lines +1452 to +1475
fn record_votes<I>(votes: &mut VoteStore, data: &AttestationData, validator_ids: I)
where
I: IntoIterator<Item = u64>,
{
for validator_id in validator_ids {
Self::record_vote(votes, validator_id, data);
}
}

fn record_known_votes<I>(&self, data: &AttestationData, validator_ids: I)
where
I: IntoIterator<Item = u64>,
{
let mut fork_choice = self.fork_choice.lock().unwrap();
Self::record_votes(&mut fork_choice.known_votes, data, validator_ids);
}

fn record_new_votes<I>(&self, data: &AttestationData, validator_ids: I)
where
I: IntoIterator<Item = u64>,
{
let mut fork_choice = self.fork_choice.lock().unwrap();
Self::record_votes(&mut fork_choice.new_votes, data, validator_ids);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should inline these. No reason to use generics for 2 lines of code

/// broken toward the larger canonical attestation-data root — the same rule the
/// block-level fork-choice tiebreak applies to block roots (leanSpec #1181). The
/// pool key is already `hash_tree_root(data)`, so the tie needs no extra hashing.
#[cfg(test)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why only tests? Can we remove this instead?

@MegaRedHand

Copy link
Copy Markdown
Collaborator

There are some conflicts

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.

Store fork-choice votes independently from proofs and signatures

2 participants