diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 4bb01bc..ac37527 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -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: +/// See: 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, @@ -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(), } } @@ -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)) @@ -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. @@ -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: - #[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,