Skip to content
Merged
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
117 changes: 30 additions & 87 deletions crates/blockchain/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,26 +626,15 @@ pub fn get_attestation_target_with_checkpoints(

/// Produce attestation data for the given slot.
///
/// The attestation source comes from the head state's justified checkpoint,
/// not the store-wide global max. This ensures voting aligns with the block
/// builder's attestation filter (which also uses head state).
/// The source comes from the store's global `latest_justified` checkpoint.
/// When the store's justified has advanced past the head state (a minority
/// fork justified a slot the head chain hasn't seen yet), the next block
/// produced on the head chain is expected to close the gap via the
/// fixed-point attestation loop in `build_block`.
///
/// See: <https://github.com/leanEthereum/leanSpec/pull/506>
/// See: <https://github.com/leanEthereum/leanSpec/pull/595>
pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData {
let head_root = store.head();
let head_state = store.get_state(&head_root).expect("head state exists");

// Derive source from head state's justified checkpoint.
// At genesis the checkpoint root is H256::ZERO; substitute the real
// genesis block root so attestation validation can look it up.
let source = if head_state.latest_block_header.slot == 0 {
Checkpoint {
root: head_root,
slot: head_state.latest_justified.slot,
}
} else {
head_state.latest_justified
};

let head_checkpoint = Checkpoint {
root: head_root,
Expand All @@ -655,15 +644,13 @@ pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData {
.slot,
};

// Calculate the target checkpoint for this attestation
let target_checkpoint = get_attestation_target(store);

// Construct attestation data
AttestationData {
slot,
head: head_checkpoint,
target: target_checkpoint,
source,
source: store.latest_justified(),
}
}

Expand Down Expand Up @@ -729,6 +716,19 @@ pub fn produce_block_with_signatures(
)?
};

// Invariant (leanSpec #595): the produced block must not lag the store's
// justified checkpoint. Otherwise peers processing this block would never
// see justification advance, degrading liveness: the fixed-point loop in
// `build_block` is expected to incorporate pool attestations that close
// any divergence inherited from a minority fork.
let store_justified_slot = store.latest_justified().slot;
if post_checkpoints.justified.slot < store_justified_slot {
return Err(StoreError::JustifiedDivergenceNotClosed {
block_justified_slot: post_checkpoints.justified.slot,
store_justified_slot,
});
}

metrics::observe_block_aggregated_payloads(signatures.len());

Ok((block, signatures, post_checkpoints))
Expand Down Expand Up @@ -832,6 +832,16 @@ pub enum StoreError {

#[error("Block contains {count} distinct AttestationData entries; maximum is {max}")]
TooManyAttestationData { count: usize, max: usize },

#[error(
"Produced block justified slot {block_justified_slot} \
is behind store justified slot {store_justified_slot}; \
fixed-point attestation loop did not converge"
)]
JustifiedDivergenceNotClosed {
block_justified_slot: u64,
store_justified_slot: u64,
},
}

/// Compute the bitwise union (OR) of two AggregationBits bitfields.
Expand Down Expand Up @@ -1485,73 +1495,6 @@ mod tests {
);
}

/// Attestation source must come from the head state's justified checkpoint,
/// not the store-wide global max.
///
/// When a non-head fork block advances store.latest_justified past
/// head_state.latest_justified, using the store value causes every
/// attestation to be rejected by the block builder (which filters
/// by head state), producing blocks with 0 attestations.
///
/// See: <https://github.com/leanEthereum/leanSpec/pull/506>
#[test]
fn produce_attestation_data_uses_head_state_justified() {
use ethlambda_storage::backend::InMemoryBackend;
use std::sync::Arc;

// Create a store at genesis with 3 validators.
let genesis_state = State::from_genesis(1000, vec![]);
let genesis_block = Block {
slot: 0,
proposer_index: 0,
parent_root: H256::ZERO,
state_root: H256::ZERO,
body: BlockBody {
attestations: AggregatedAttestations::default(),
},
};
let backend = Arc::new(InMemoryBackend::new());
let mut store = Store::get_forkchoice_store(backend, genesis_state, genesis_block);

let head_root = store.head();

// The head state's justified checkpoint is what the block builder
// filters attestations against. At genesis the root is H256::ZERO,
// so we apply the same correction used in produce_attestation_data.
let head_state = store.get_state(&head_root).expect("head state exists");
let head_justified = Checkpoint {
root: head_root,
slot: head_state.latest_justified.slot,
};

// Simulate a non-head fork advancing the store's global justified
// past what the head chain has seen.
let higher_justified = Checkpoint {
root: H256([0x99; 32]),
slot: 42,
};
store.update_checkpoints(ForkCheckpoints::new(
head_root,
Some(higher_justified),
None,
));

// Precondition: the global max is strictly ahead of the head state.
assert!(
store.latest_justified().slot > head_justified.slot,
"store justified should be ahead of head state justified"
);

// Produce attestation data. The source must come from the head state
// (slot 0), not from the global max (slot 42).
let attestation = produce_attestation_data(&store, 1);

assert_eq!(
attestation.source, head_justified,
"source must match head state's justified checkpoint, not store-wide max"
);
}

fn make_att_data(slot: u64) -> AttestationData {
AttestationData {
slot,
Expand Down
Loading