diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 3d70e08..6054539 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -740,12 +740,32 @@ pub fn get_attestation_target(store: &Store) -> Checkpoint { } /// 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). +/// +/// See: pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData { - // Get the head block the validator sees for this slot + 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: store.head(), + root: head_root, slot: store - .get_block_header(&store.head()) + .get_block_header(&head_root) .expect("head block exists") .slot, }; @@ -758,7 +778,7 @@ pub fn produce_attestation_data(store: &Store, slot: u64) -> AttestationData { slot, head: head_checkpoint, target: target_checkpoint, - source: store.latest_justified(), + source, } } @@ -1346,4 +1366,71 @@ mod tests { "Expected ParticipantsMismatch, got: {result:?}" ); } + + /// 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::repeat_byte(0x99), + 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" + ); + } }