Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 6 additions & 15 deletions lean_client/fork_choice/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,25 +49,16 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<()
);

// Validate checkpoint slots match block slots.
// Skip the source slot-match when the root is the store's known justified or
// finalized checkpoint: after checkpoint sync the anchor block sits at
// anchor_slot in store.blocks but the checkpoint carries the actual historical
// slot from the downloaded state (e.g. 100994 vs anchor 101002).
let source_block = &store.blocks[&data.source.root];
let target_block = &store.blocks[&data.target.root];
let head_block = &store.blocks[&data.head.root];

let source_is_trusted_checkpoint = data.source.root == store.latest_justified.root
|| data.source.root == store.latest_finalized.root;

if !source_is_trusted_checkpoint {
ensure!(
source_block.slot == data.source.slot,
"Source checkpoint slot mismatch: checkpoint {} vs block {}",
data.source.slot.0,
source_block.slot.0
);
}
ensure!(
source_block.slot == data.source.slot,
"Source checkpoint slot mismatch: checkpoint {} vs block {}",
data.source.slot.0,
source_block.slot.0
);

ensure!(
target_block.slot == data.target.slot,
Expand Down
53 changes: 24 additions & 29 deletions lean_client/fork_choice/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,19 +207,18 @@ pub fn get_forkchoice_store(
block_header.hash_tree_root()
};

// Substitute anchor_root for the checkpoint roots (the historical
// justified/finalized blocks are not in our store), but keep the actual
// slots from the downloaded state. validate_attestation_data skips
// the block-slot match when the source root is a known checkpoint.
let latest_justified = Checkpoint {
// Seed both checkpoints from the anchor block itself: (root=anchor_root,
// slot=anchor_slot). The store treats the anchor as the new "genesis" for
// fork choice — pre-anchor history is pruned — so the embedded checkpoints
// from the downloaded state are intentionally ignored. This keeps the
// checkpoint slot/root pair internally consistent with the block at
// anchor_root, mirroring the beacon-chain seeding convention.
let anchor_checkpoint = Checkpoint {
root: block_root,
slot: anchor_state.latest_justified.slot,
};

let latest_finalized = Checkpoint {
root: block_root,
slot: anchor_state.latest_finalized.slot,
slot: block_slot,
};
let latest_justified = anchor_checkpoint.clone();
let latest_finalized = anchor_checkpoint;

// Store the original anchor_state - do NOT modify it
// Modifying checkpoints would change its hash_tree_root(), breaking the
Expand Down Expand Up @@ -454,12 +453,17 @@ fn extract_attestations_from_aggregated_payloads(

/// Update safe target from aggregated attestations.
///
/// Safe target is computed by merging both aggregated payload pools:
/// - latest_known_aggregated_payloads: from block bodies (on-chain)
/// - latest_new_aggregated_payloads: from gossip aggregation topic
/// Runs at interval 3 of the slot cycle, strictly before the migration step at
/// interval 4 that promotes `latest_new_aggregated_payloads` into
/// `latest_known_aggregated_payloads`. Only the "new" pool is consulted here.
///
/// Both pools are merged because at interval 3 (when this runs), the migration
/// to "known" (interval 4) hasn't happened yet.
/// Safe target is an *availability* signal: a block is "safe" when 2/3+ of
/// validators currently online — as seen by this node right now — vote for a
/// descendant of it. Votes already living in the "known" pool reflect
/// historical knowledge (block-included attestations, gossip migrated in
/// previous slots, locally-stored self-attestations); counting them would let
/// safe target keep advancing on stale evidence even when live participation
/// has collapsed, defeating the signal's purpose.
pub fn update_safe_target(store: &mut Store) {
let n_validators = if let Some(state) = store.states.get(&store.head) {
state.validators.len_usize()
Expand All @@ -472,20 +476,11 @@ pub fn update_safe_target(store: &mut Store) {
let min_score = (n_validators * 2 + 2) / 3;
let root = store.latest_justified.root;

// Merge both aggregated payload pools to see all attestations
let mut all_payloads: HashMap<H256, Vec<AggregatedSignatureProof>> =
store.latest_known_aggregated_payloads.clone();

for (data_root, proofs) in &store.latest_new_aggregated_payloads {
all_payloads
.entry(*data_root)
.or_default()
.extend(proofs.clone());
}

// Extract per-validator attestations from merged payloads
// Extract per-validator attestations from the "new" pool only.
// The "known" pool is intentionally excluded — see the doc comment above
// for the availability rationale tied to the interval-3/interval-4 ordering.
let attestations = extract_attestations_from_aggregated_payloads(
&all_payloads,
&store.latest_new_aggregated_payloads,
&store.attestation_data_by_root,
);

Expand Down
Loading