From a959c5f6401840f3afebe286470a68da86645f1d Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 23 Feb 2026 12:55:50 +1100 Subject: [PATCH 1/5] Add payload support to `BlockReplayer` --- beacon_node/beacon_chain/tests/rewards.rs | 3 +- beacon_node/beacon_chain/tests/store_tests.rs | 3 +- .../http_api/src/attestation_performance.rs | 3 +- .../http_api/src/block_packing_efficiency.rs | 3 +- beacon_node/http_api/src/block_rewards.rs | 6 +- .../http_api/src/sync_committee_rewards.rs | 3 +- beacon_node/store/src/hot_cold_store.rs | 3 +- beacon_node/store/src/reconstruct.rs | 1 + .../state_processing/src/block_replayer.rs | 104 +++++++++++++++++- .../src/envelope_processing.rs | 2 - .../src/per_block_processing/tests.rs | 2 +- 11 files changed, 117 insertions(+), 16 deletions(-) diff --git a/beacon_node/beacon_chain/tests/rewards.rs b/beacon_node/beacon_chain/tests/rewards.rs index bc7c98041f3..1889c1f625a 100644 --- a/beacon_node/beacon_chain/tests/rewards.rs +++ b/beacon_node/beacon_chain/tests/rewards.rs @@ -845,13 +845,14 @@ async fn check_all_base_rewards_for_subset( .state_at_slot(Slot::new(slot - 1), StateSkipConfig::WithoutStateRoots) .unwrap(); + // TODO(gloas): handle payloads? let mut pre_state = BlockReplayer::>::new( parent_state, &harness.spec, ) .no_signature_verification() .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) + .apply_blocks(vec![], vec![], Some(block.slot())) .unwrap() .into_state(); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 6bea5f60133..365513bbb4f 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -697,6 +697,7 @@ async fn block_replayer_hooks() { let mut pre_block_slots = vec![]; let mut post_block_slots = vec![]; + // TODO(gloas): handle payloads? let mut replay_state = BlockReplayer::::new(state, &chain.spec) .pre_slot_hook(Box::new(|_, state| { pre_slots.push(state.slot()); @@ -724,7 +725,7 @@ async fn block_replayer_hooks() { post_block_slots.push(block.slot()); Ok(()) })) - .apply_blocks(blocks, None) + .apply_blocks(blocks, vec![], None) .unwrap() .into_state(); diff --git a/beacon_node/http_api/src/attestation_performance.rs b/beacon_node/http_api/src/attestation_performance.rs index 6e285829d22..05ed36e68b1 100644 --- a/beacon_node/http_api/src/attestation_performance.rs +++ b/beacon_node/http_api/src/attestation_performance.rs @@ -205,8 +205,9 @@ pub fn get_attestation_performance( }) .collect::, _>>()?; + // TODO(gloas): add payloads replayer = replayer - .apply_blocks(blocks, None) + .apply_blocks(blocks, vec![], None) .map_err(|e| custom_server_error(format!("{:?}", e)))?; } diff --git a/beacon_node/http_api/src/block_packing_efficiency.rs b/beacon_node/http_api/src/block_packing_efficiency.rs index 3772470b281..725a0648a55 100644 --- a/beacon_node/http_api/src/block_packing_efficiency.rs +++ b/beacon_node/http_api/src/block_packing_efficiency.rs @@ -398,8 +398,9 @@ pub fn get_block_packing_efficiency( }) .collect::, _>>()?; + // TODO(gloas): add payloads replayer = replayer - .apply_blocks(blocks, None) + .apply_blocks(blocks, vec![], None) .map_err(|e: PackingEfficiencyError| custom_server_error(format!("{:?}", e)))?; } diff --git a/beacon_node/http_api/src/block_rewards.rs b/beacon_node/http_api/src/block_rewards.rs index 891f024bf9c..8b355bf140b 100644 --- a/beacon_node/http_api/src/block_rewards.rs +++ b/beacon_node/http_api/src/block_rewards.rs @@ -56,6 +56,7 @@ pub fn get_block_rewards( let mut reward_cache = Default::default(); let mut block_rewards = Vec::with_capacity(blocks.len()); + // TODO(gloas): handle payloads let block_replayer = BlockReplayer::new(state, &chain.spec) .pre_block_hook(Box::new(|state, block| { state.build_all_committee_caches(&chain.spec)?; @@ -78,7 +79,7 @@ pub fn get_block_rewards( ) .no_signature_verification() .minimal_block_root_verification() - .apply_blocks(blocks, None) + .apply_blocks(blocks, vec![], None) .map_err(unhandled_error)?; if block_replayer.state_root_miss() { @@ -138,11 +139,12 @@ pub fn compute_block_rewards( )) })?; + // TODO(gloas): handle payloads? let block_replayer = BlockReplayer::new(parent_state, &chain.spec) .no_signature_verification() .state_root_iter([Ok((parent_block.state_root(), parent_block.slot()))].into_iter()) .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) + .apply_blocks(vec![], vec![], Some(block.slot())) .map_err(unhandled_error::)?; if block_replayer.state_root_miss() { diff --git a/beacon_node/http_api/src/sync_committee_rewards.rs b/beacon_node/http_api/src/sync_committee_rewards.rs index 9bc1f6ead4d..8715fc2b1e5 100644 --- a/beacon_node/http_api/src/sync_committee_rewards.rs +++ b/beacon_node/http_api/src/sync_committee_rewards.rs @@ -66,11 +66,12 @@ pub fn get_state_before_applying_block( }) .map_err(|e| custom_not_found(format!("Parent state is not available! {:?}", e)))?; + // TODO(gloas): handle payloads? let replayer = BlockReplayer::new(parent_state, &chain.spec) .no_signature_verification() .state_root_iter([Ok((parent_block.state_root(), parent_block.slot()))].into_iter()) .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) + .apply_blocks(vec![], vec![], Some(block.slot())) .map_err(unhandled_error::)?; Ok(replayer.into_state()) diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 6e165702a27..00415bbd2b7 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -2548,8 +2548,9 @@ impl, Cold: ItemStore> HotColdDB block_replayer = block_replayer.pre_slot_hook(pre_slot_hook); } + // TODO(gloas): plumb through payloads here block_replayer - .apply_blocks(blocks, Some(target_slot)) + .apply_blocks(blocks, vec![], Some(target_slot)) .map(|block_replayer| { if have_state_root_iterator && block_replayer.state_root_miss() { warn!( diff --git a/beacon_node/store/src/reconstruct.rs b/beacon_node/store/src/reconstruct.rs index 7aca692ef9b..e51543c3a23 100644 --- a/beacon_node/store/src/reconstruct.rs +++ b/beacon_node/store/src/reconstruct.rs @@ -67,6 +67,7 @@ where state.build_caches(&self.spec)?; + // TODO(gloas): handle payload envelope replay process_results(block_root_iter, |iter| -> Result<(), Error> { let mut io_batch = vec![]; diff --git a/consensus/state_processing/src/block_replayer.rs b/consensus/state_processing/src/block_replayer.rs index 56e667cdd37..63299cbf700 100644 --- a/consensus/state_processing/src/block_replayer.rs +++ b/consensus/state_processing/src/block_replayer.rs @@ -1,14 +1,19 @@ use crate::{ BlockProcessingError, BlockSignatureStrategy, ConsensusContext, SlotProcessingError, - VerifyBlockRoot, per_block_processing, per_epoch_processing::EpochProcessingSummary, + VerifyBlockRoot, VerifySignatures, + envelope_processing::{ + EnvelopeProcessingError, VerifyStateRoot, process_execution_payload_envelope, + }, + per_block_processing, + per_epoch_processing::EpochProcessingSummary, per_slot_processing, }; use itertools::Itertools; use std::iter::Peekable; use std::marker::PhantomData; use types::{ - BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, Hash256, SignedBeaconBlock, - Slot, + BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, ExecutionBlockHash, Hash256, + SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, }; pub type PreBlockHook<'a, E, Error> = Box< @@ -24,7 +29,7 @@ pub type PostSlotHook<'a, E, Error> = Box< >; pub type StateRootIterDefault = std::iter::Empty>; -/// Efficiently apply blocks to a state while configuring various parameters. +/// Efficiently apply blocks and payloads to a state while configuring various parameters. /// /// Usage follows a builder pattern. pub struct BlockReplayer< @@ -41,6 +46,11 @@ pub struct BlockReplayer< post_block_hook: Option>, pre_slot_hook: Option>, post_slot_hook: Option>, + /// Iterator over state roots for all *block* states. + /// + /// Pre-Gloas, this is all states. Post-Gloas, this is *just* the states corresponding to beacon + /// blocks. For states corresponding to payloads, we read the state root from the payload + /// envelope. pub(crate) state_root_iter: Option>, state_root_miss: bool, _phantom: PhantomData, @@ -50,7 +60,13 @@ pub struct BlockReplayer< pub enum BlockReplayError { SlotProcessing(SlotProcessingError), BlockProcessing(BlockProcessingError), + EnvelopeProcessing(EnvelopeProcessingError), BeaconState(BeaconStateError), + /// A payload envelope for this `slot` was required but not provided. + MissingPayloadEnvelope { + slot: Slot, + block_hash: ExecutionBlockHash, + }, } impl From for BlockReplayError { @@ -65,6 +81,12 @@ impl From for BlockReplayError { } } +impl From for BlockReplayError { + fn from(e: EnvelopeProcessingError) -> Self { + Self::EnvelopeProcessing(e) + } +} + impl From for BlockReplayError { fn from(e: BeaconStateError) -> Self { Self::BeaconState(e) @@ -215,8 +237,11 @@ where pub fn apply_blocks( mut self, blocks: Vec>>, + payload_envelopes: Vec>, target_slot: Option, ) -> Result { + let mut envelopes_iter = payload_envelopes.into_iter(); + for (i, block) in blocks.iter().enumerate() { // Allow one additional block at the start which is only used for its state root. if i == 0 && block.slot() <= self.state.slot() { @@ -224,7 +249,74 @@ where } while self.state.slot() < block.slot() { - let state_root = self.get_state_root(&blocks, i)?; + let block_state_root = self.get_state_root(&blocks, i)?; + + // Apply the payload for the *previous* block if the bid in the current block + // indicates that the parent is full. + // TODO(gloas): check this condition at the fork boundary. + let state_root = if self.state.slot() == self.state.latest_block_header().slot + && block.fork_name_unchecked().gloas_enabled() + { + let state_block_hash = self + .state + .latest_execution_payload_bid() + .map_err(BlockReplayError::from)? + .block_hash; + let parent_block_hash = block + .message() + .body() + .signed_execution_payload_bid() + .map_err(BlockReplayError::from)? + .message + .parent_block_hash; + + // Similar to `is_parent_block_full`, but reading the block hash from the + // not-yet-applied `block`. + if state_block_hash == parent_block_hash { + if let Some(envelope) = envelopes_iter.next() + && envelope.message.slot == self.state.slot() + { + // TODO(gloas): bulk signature verification could be relevant here? + let verify_payload_signatures = + if let BlockSignatureStrategy::NoVerification = + self.block_sig_strategy + { + VerifySignatures::False + } else { + VerifySignatures::True + }; + // TODO(gloas): state root verif enabled during initial + // prototyping/testing + let verify_state_root = VerifyStateRoot::True; + process_execution_payload_envelope( + &mut self.state, + Some(block_state_root), + &envelope, + verify_payload_signatures, + verify_state_root, + self.spec, + ) + .map_err(BlockReplayError::from)?; + + // State root for next slot processing is now the envelope's state root. + envelope.message.state_root + } else { + return Err(BlockReplayError::MissingPayloadEnvelope { + slot: block.slot(), + block_hash: state_block_hash, + } + .into()); + } + } else { + // Empty payload at this slot, the state root is unchanged from when the + // beacon block was applied. + block_state_root + } + } else { + // Pre-Gloas or at skipped slots post-Gloas, the state root of the parent state + // is always the output from `self.get_state_root`. + block_state_root + }; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { pre_slot_hook(state_root, &mut self.state)?; @@ -268,6 +360,8 @@ where } } + // TODO(gloas): apply last payload, but how to know if it *should* be applied? + if let Some(target_slot) = target_slot { while self.state.slot() < target_slot { let state_root = self.get_state_root(&blocks, blocks.len())?; diff --git a/consensus/state_processing/src/envelope_processing.rs b/consensus/state_processing/src/envelope_processing.rs index c2cfeae5d36..1e3c54f1e14 100644 --- a/consensus/state_processing/src/envelope_processing.rs +++ b/consensus/state_processing/src/envelope_processing.rs @@ -241,8 +241,6 @@ pub fn process_execution_payload_envelope( // TODO(gloas): newPayload happens here in the spec, ensure we wire that up correctly process_deposit_requests_post_gloas(state, &execution_requests.deposits, spec)?; - - // TODO(gloas): gotta update these process_withdrawal_requests(state, &execution_requests.withdrawals, spec)?; process_consolidation_requests(state, &execution_requests.consolidations, spec)?; diff --git a/consensus/state_processing/src/per_block_processing/tests.rs b/consensus/state_processing/src/per_block_processing/tests.rs index 739717b33ff..cbcde715bc7 100644 --- a/consensus/state_processing/src/per_block_processing/tests.rs +++ b/consensus/state_processing/src/per_block_processing/tests.rs @@ -1140,7 +1140,7 @@ async fn block_replayer_peeking_state_roots() { let block_replayer = BlockReplayer::new(parent_state, &harness.chain.spec) .state_root_iter(state_root_iter.into_iter()) .no_signature_verification() - .apply_blocks(vec![target_block], None) + .apply_blocks(vec![target_block], vec![], None) .unwrap(); assert_eq!( From afc6fb137cd90be64e6372bee27b34537cb3a90a Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 23 Feb 2026 15:43:19 +1100 Subject: [PATCH 2/5] Connect up DB replay_blocks/load_blocks --- beacon_node/beacon_chain/src/fork_revert.rs | 3 +- beacon_node/beacon_chain/tests/store_tests.rs | 5 +- beacon_node/http_api/src/block_rewards.rs | 5 +- beacon_node/store/src/hot_cold_store.rs | 73 ++++++++++++++++--- .../state_processing/src/block_replayer.rs | 13 +--- .../types/src/block/signed_beacon_block.rs | 27 +++++++ 6 files changed, 99 insertions(+), 27 deletions(-) diff --git a/beacon_node/beacon_chain/src/fork_revert.rs b/beacon_node/beacon_chain/src/fork_revert.rs index 4db79790d38..44424bbad9e 100644 --- a/beacon_node/beacon_chain/src/fork_revert.rs +++ b/beacon_node/beacon_chain/src/fork_revert.rs @@ -159,7 +159,8 @@ pub fn reset_fork_choice_to_finalization, Cold: It // Replay blocks from finalized checkpoint back to head. // We do not replay attestations presently, relying on the absence of other blocks // to guarantee `head_block_root` as the head. - let blocks = store + // TODO(gloas): this code doesn't work anyway, could just delete all of it + let (blocks, _envelopes) = store .load_blocks_to_replay(finalized_slot + 1, head_state.slot(), head_block_root) .map_err(|e| format!("Error loading blocks to replay for fork choice: {:?}", e))?; diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 365513bbb4f..ef7179aadbe 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -688,7 +688,7 @@ async fn block_replayer_hooks() { .add_attested_blocks_at_slots(state.clone(), state_root, &block_slots, &all_validators) .await; - let blocks = store + let (blocks, envelopes) = store .load_blocks_to_replay(Slot::new(0), max_slot, end_block_root.into()) .unwrap(); @@ -697,7 +697,6 @@ async fn block_replayer_hooks() { let mut pre_block_slots = vec![]; let mut post_block_slots = vec![]; - // TODO(gloas): handle payloads? let mut replay_state = BlockReplayer::::new(state, &chain.spec) .pre_slot_hook(Box::new(|_, state| { pre_slots.push(state.slot()); @@ -725,7 +724,7 @@ async fn block_replayer_hooks() { post_block_slots.push(block.slot()); Ok(()) })) - .apply_blocks(blocks, vec![], None) + .apply_blocks(blocks, envelopes, None) .unwrap() .into_state(); diff --git a/beacon_node/http_api/src/block_rewards.rs b/beacon_node/http_api/src/block_rewards.rs index 8b355bf140b..cdb3d650ea8 100644 --- a/beacon_node/http_api/src/block_rewards.rs +++ b/beacon_node/http_api/src/block_rewards.rs @@ -32,7 +32,7 @@ pub fn get_block_rewards( .map_err(unhandled_error)? .ok_or_else(|| custom_bad_request(format!("block at end slot {} unknown", end_slot)))?; - let blocks = chain + let (blocks, envelopes) = chain .store .load_blocks_to_replay(start_slot, end_slot, end_block_root) .map_err(|e| unhandled_error(BeaconChainError::from(e)))?; @@ -56,7 +56,6 @@ pub fn get_block_rewards( let mut reward_cache = Default::default(); let mut block_rewards = Vec::with_capacity(blocks.len()); - // TODO(gloas): handle payloads let block_replayer = BlockReplayer::new(state, &chain.spec) .pre_block_hook(Box::new(|state, block| { state.build_all_committee_caches(&chain.spec)?; @@ -79,7 +78,7 @@ pub fn get_block_rewards( ) .no_signature_verification() .minimal_block_root_verification() - .apply_blocks(blocks, vec![], None) + .apply_blocks(blocks, envelopes, None) .map_err(unhandled_error)?; if block_replayer.state_root_miss() { diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 00415bbd2b7..8fbc0824d71 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -186,6 +186,7 @@ pub enum HotColdDBError { MissingHotHDiff(Hash256), MissingHDiff(Slot), MissingExecutionPayload(Hash256), + MissingExecutionPayloadEnvelope(Hash256), MissingFullBlockExecutionPayloadPruned(Hash256, Slot), MissingAnchorInfo, MissingFrozenBlockSlot(Hash256), @@ -2020,7 +2021,8 @@ impl, Cold: ItemStore> HotColdDB return Ok(base_state); } - let blocks = self.load_blocks_to_replay(base_state.slot(), slot, latest_block_root)?; + let (blocks, envelopes) = + self.load_blocks_to_replay(base_state.slot(), slot, latest_block_root)?; let _t = metrics::start_timer(&metrics::STORE_BEACON_REPLAY_HOT_BLOCKS_TIME); // If replaying blocks, and `update_cache` is true, also cache the epoch boundary @@ -2053,6 +2055,7 @@ impl, Cold: ItemStore> HotColdDB self.replay_blocks( base_state, blocks, + envelopes, slot, no_state_root_iter(), Some(Box::new(state_cache_hook)), @@ -2357,6 +2360,8 @@ impl, Cold: ItemStore> HotColdDB } let blocks = self.load_cold_blocks(base_state.slot() + 1, slot)?; + // TODO(gloas): load payload envelopes + let envelopes = vec![]; // Include state root for base state as it is required by block processing to not // have to hash the state. @@ -2365,7 +2370,14 @@ impl, Cold: ItemStore> HotColdDB self.forwards_state_roots_iterator_until(base_state.slot(), slot, || { Err(Error::StateShouldNotBeRequired(slot)) })?; - let state = self.replay_blocks(base_state, blocks, slot, Some(state_root_iter), None)?; + let state = self.replay_blocks( + base_state, + blocks, + envelopes, + slot, + Some(state_root_iter), + None, + )?; debug!( target_slot = %slot, replay_time_ms = metrics::stop_timer_with_duration(replay_timer).as_millis(), @@ -2480,18 +2492,31 @@ impl, Cold: ItemStore> HotColdDB })? } - /// Load the blocks between `start_slot` and `end_slot` by backtracking from `end_block_hash`. + /// Load the blocks & envelopes between `start_slot` and `end_slot` by backtracking from + /// `end_block_root`. /// /// Blocks are returned in slot-ascending order, suitable for replaying on a state with slot /// equal to `start_slot`, to reach a state with slot equal to `end_slot`. + /// + /// Payloads are also returned in slot-ascending order, but only payloads forming part of + /// the chain are loaded (payloads for EMPTY slots are omitted). Prior to Gloas, an empty + /// vec of payloads will be returned. + // TODO(gloas): handle last payload + #[allow(clippy::type_complexity)] pub fn load_blocks_to_replay( &self, start_slot: Slot, end_slot: Slot, - end_block_hash: Hash256, - ) -> Result>>, Error> { + end_block_root: Hash256, + ) -> Result< + ( + Vec>, + Vec>, + ), + Error, + > { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_HOT_BLOCKS_TIME); - let mut blocks = ParentRootBlockIterator::new(self, end_block_hash) + let mut blocks = ParentRootBlockIterator::new(self, end_block_root) .map(|result| result.map(|(_, block)| block)) // Include the block at the end slot (if any), it needs to be // replayed in order to construct the canonical state at `end_slot`. @@ -2518,7 +2543,35 @@ impl, Cold: ItemStore> HotColdDB }) .collect::, _>>()?; blocks.reverse(); - Ok(blocks) + + // If Gloas is not enabled for any slots in the range, just return `blocks`. + if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() + && !self.spec.fork_name_at_slot::(end_slot).gloas_enabled() + { + return Ok((blocks, vec![])); + } + + // Load envelopes. + let mut envelopes = vec![]; + + for (block, next_block) in blocks.iter().tuple_windows() { + if block.fork_name_unchecked().gloas_enabled() { + // Check next block to see if this block's payload is canonical on this chain. + let block_hash = block.payload_bid_block_hash()?; + if !next_block.is_parent_block_full(block_hash) { + // No payload at this slot (empty), nothing to load. + continue; + } + // Using `parent_root` avoids computation. + let block_root = next_block.parent_root(); + let envelope = self + .get_payload_envelope(&block_root)? + .ok_or(HotColdDBError::MissingExecutionPayloadEnvelope(block_root))?; + envelopes.push(envelope); + } + } + + Ok((blocks, envelopes)) } /// Replay `blocks` on top of `state` until `target_slot` is reached. @@ -2528,7 +2581,8 @@ impl, Cold: ItemStore> HotColdDB pub fn replay_blocks( &self, state: BeaconState, - blocks: Vec>>, + blocks: Vec>, + envelopes: Vec>, target_slot: Slot, state_root_iter: Option>>, pre_slot_hook: Option>, @@ -2548,9 +2602,8 @@ impl, Cold: ItemStore> HotColdDB block_replayer = block_replayer.pre_slot_hook(pre_slot_hook); } - // TODO(gloas): plumb through payloads here block_replayer - .apply_blocks(blocks, vec![], Some(target_slot)) + .apply_blocks(blocks, envelopes, Some(target_slot)) .map(|block_replayer| { if have_state_root_iterator && block_replayer.state_root_miss() { warn!( diff --git a/consensus/state_processing/src/block_replayer.rs b/consensus/state_processing/src/block_replayer.rs index 63299cbf700..ff97cebe729 100644 --- a/consensus/state_processing/src/block_replayer.rs +++ b/consensus/state_processing/src/block_replayer.rs @@ -257,22 +257,15 @@ where let state_root = if self.state.slot() == self.state.latest_block_header().slot && block.fork_name_unchecked().gloas_enabled() { - let state_block_hash = self + let latest_bid_block_hash = self .state .latest_execution_payload_bid() .map_err(BlockReplayError::from)? .block_hash; - let parent_block_hash = block - .message() - .body() - .signed_execution_payload_bid() - .map_err(BlockReplayError::from)? - .message - .parent_block_hash; // Similar to `is_parent_block_full`, but reading the block hash from the // not-yet-applied `block`. - if state_block_hash == parent_block_hash { + if block.is_parent_block_full(latest_bid_block_hash) { if let Some(envelope) = envelopes_iter.next() && envelope.message.slot == self.state.slot() { @@ -303,7 +296,7 @@ where } else { return Err(BlockReplayError::MissingPayloadEnvelope { slot: block.slot(), - block_hash: state_block_hash, + block_hash: latest_bid_block_hash, } .into()); } diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index aeb3c18d957..b7b1d9d2a2b 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -14,6 +14,7 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ + ExecutionBlockHash, block::{ BLOB_KZG_COMMITMENTS_INDEX, BeaconBlock, BeaconBlockAltair, BeaconBlockBase, BeaconBlockBellatrix, BeaconBlockBodyBellatrix, BeaconBlockBodyCapella, @@ -365,6 +366,32 @@ impl> SignedBeaconBlock format_kzg_commitments(commitments.as_ref()) } + + /// Convenience accessor for the block's bid's `block_hash`. + /// + /// This method returns an error prior to Gloas. + pub fn payload_bid_block_hash(&self) -> Result { + self.message() + .body() + .signed_execution_payload_bid() + .map(|bid| bid.message.block_hash) + } + + /// Check if the `parent_hash` in this block's `signed_payload_bid` matches `block_hash`. + /// + /// This function is useful post-Gloas for determining if the parent block is full, *without* + /// necessarily needing access to a beacon state. The passed in `parent_block_hash` MUST be the + /// `block_hash` from the parent beacon block's bid. If the parent beacon state is available + /// this can alternatively be fetched from `state.latest_payload_bid`. + /// + /// This function returns `false` for all blocks prior to Gloas. + pub fn is_parent_block_full(&self, parent_block_hash: ExecutionBlockHash) -> bool { + let Ok(signed_payload_bid) = self.message().body().signed_execution_payload_bid() else { + // Prior to Gloas. + return false; + }; + signed_payload_bid.message.parent_block_hash == parent_block_hash + } } // We can convert pre-Bellatrix blocks without payloads into blocks with payloads. From a2e0068b85ca05dc932ce14d3e818594e55c1838 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 23 Feb 2026 16:09:10 +1100 Subject: [PATCH 3/5] Payloads for cold blocks --- beacon_node/store/src/hot_cold_store.rs | 45 +++++++++++++++++++------ 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 8fbc0824d71..d858ef904e7 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -2359,9 +2359,7 @@ impl, Cold: ItemStore> HotColdDB return Ok(base_state); } - let blocks = self.load_cold_blocks(base_state.slot() + 1, slot)?; - // TODO(gloas): load payload envelopes - let envelopes = vec![]; + let (blocks, envelopes) = self.load_cold_blocks(base_state.slot() + 1, slot)?; // Include state root for base state as it is required by block processing to not // have to hash the state. @@ -2470,26 +2468,44 @@ impl, Cold: ItemStore> HotColdDB } } - /// Load cold blocks between `start_slot` and `end_slot` inclusive. + /// Load cold blocks and payload envelopes between `start_slot` and `end_slot` inclusive. + #[allow(clippy::type_complexity)] pub fn load_cold_blocks( &self, start_slot: Slot, end_slot: Slot, - ) -> Result>, Error> { + ) -> Result< + ( + Vec>, + Vec>, + ), + Error, + > { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_COLD_BLOCKS_TIME); let block_root_iter = self.forwards_block_roots_iterator_until(start_slot, end_slot, || { Err(Error::StateShouldNotBeRequired(end_slot)) })?; - process_results(block_root_iter, |iter| { + let blocks = process_results(block_root_iter, |iter| { iter.map(|(block_root, _slot)| block_root) .dedup() .map(|block_root| { self.get_blinded_block(&block_root)? .ok_or(Error::MissingBlock(block_root)) }) - .collect() - })? + .collect::, Error>>() + }) + .flatten()?; + + // If Gloas is not enabled for any slots in the range, just return `blocks`. + if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() + && !self.spec.fork_name_at_slot::(end_slot).gloas_enabled() + { + return Ok((blocks, vec![])); + } + let envelopes = self.load_payload_envelopes_for_blocks(&blocks)?; + + Ok((blocks, envelopes)) } /// Load the blocks & envelopes between `start_slot` and `end_slot` by backtracking from @@ -2551,7 +2567,15 @@ impl, Cold: ItemStore> HotColdDB return Ok((blocks, vec![])); } - // Load envelopes. + let envelopes = self.load_payload_envelopes_for_blocks(&blocks)?; + + Ok((blocks, envelopes)) + } + + pub fn load_payload_envelopes_for_blocks( + &self, + blocks: &[SignedBlindedBeaconBlock], + ) -> Result>, Error> { let mut envelopes = vec![]; for (block, next_block) in blocks.iter().tuple_windows() { @@ -2570,8 +2594,7 @@ impl, Cold: ItemStore> HotColdDB envelopes.push(envelope); } } - - Ok((blocks, envelopes)) + Ok(envelopes) } /// Replay `blocks` on top of `state` until `target_slot` is reached. From b3d2e85e55509210809e32aa8e491e1a119dae7f Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 23 Feb 2026 17:28:46 +1100 Subject: [PATCH 4/5] Avoid Result::flatten (would require MSRV bump) --- beacon_node/store/src/hot_cold_store.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index d858ef904e7..849099ecfb5 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -2494,8 +2494,7 @@ impl, Cold: ItemStore> HotColdDB .ok_or(Error::MissingBlock(block_root)) }) .collect::, Error>>() - }) - .flatten()?; + })??; // If Gloas is not enabled for any slots in the range, just return `blocks`. if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() From a3f31835ab6bb1e2903e1f95c9a96668482486de Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 23 Feb 2026 21:14:27 +1100 Subject: [PATCH 5/5] Add StatePayloadStatus to BlockReplayer --- .../state_processing/src/block_replayer.rs | 130 +++++++++++------- consensus/types/src/execution/mod.rs | 2 + .../src/execution/state_payload_status.rs | 18 +++ 3 files changed, 104 insertions(+), 46 deletions(-) create mode 100644 consensus/types/src/execution/state_payload_status.rs diff --git a/consensus/state_processing/src/block_replayer.rs b/consensus/state_processing/src/block_replayer.rs index ff97cebe729..22096293af0 100644 --- a/consensus/state_processing/src/block_replayer.rs +++ b/consensus/state_processing/src/block_replayer.rs @@ -12,8 +12,8 @@ use itertools::Itertools; use std::iter::Peekable; use std::marker::PhantomData; use types::{ - BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, ExecutionBlockHash, Hash256, - SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, + BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, Hash256, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, execution::StatePayloadStatus, }; pub type PreBlockHook<'a, E, Error> = Box< @@ -53,6 +53,13 @@ pub struct BlockReplayer< /// envelope. pub(crate) state_root_iter: Option>, state_root_miss: bool, + /// The payload status of the state desired as the end result of block replay. + /// + /// This dictates whether a payload should be applied after applying the last block. + /// + /// Prior to Gloas, this should always be set to `StatePayloadStatus::Pending` to indicate + /// that no envelope needs to be applied. + desired_state_payload_status: StatePayloadStatus, _phantom: PhantomData, } @@ -65,7 +72,6 @@ pub enum BlockReplayError { /// A payload envelope for this `slot` was required but not provided. MissingPayloadEnvelope { slot: Slot, - block_hash: ExecutionBlockHash, }, } @@ -118,6 +124,7 @@ where post_slot_hook: None, state_root_iter: None, state_root_miss: false, + desired_state_payload_status: StatePayloadStatus::Pending, _phantom: PhantomData, } } @@ -183,6 +190,14 @@ where self } + /// Set the desired payload status of the state reached by replay. + /// + /// This determines whether to apply a payload after applying the last block. + pub fn desired_state_payload_status(mut self, payload_status: StatePayloadStatus) -> Self { + self.desired_state_payload_status = payload_status; + self + } + /// Compute the state root for `self.state` as efficiently as possible. /// /// This function MUST only be called when `self.state` is a post-state, i.e. it MUST not be @@ -230,6 +245,38 @@ where Ok(state_root) } + /// Apply an execution payload envelope to `self.state`. + /// + /// The `block_state_root` MUST be the `state_root` of the most recently applied block. + /// + /// Returns the `state_root` of `self.state` after payload application. + fn apply_payload_envelope( + &mut self, + envelope: &SignedExecutionPayloadEnvelope, + block_state_root: Hash256, + ) -> Result { + // TODO(gloas): bulk signature verification could be relevant here? + let verify_payload_signatures = + if let BlockSignatureStrategy::NoVerification = self.block_sig_strategy { + VerifySignatures::False + } else { + VerifySignatures::True + }; + // TODO(gloas): state root verif enabled during initial prototyping + let verify_state_root = VerifyStateRoot::True; + process_execution_payload_envelope( + &mut self.state, + Some(block_state_root), + envelope, + verify_payload_signatures, + verify_state_root, + self.spec, + ) + .map_err(BlockReplayError::from)?; + + Ok(envelope.message.state_root) + } + /// Apply `blocks` atop `self.state`, taking care of slot processing. /// /// If `target_slot` is provided then the state will be advanced through to `target_slot` @@ -242,6 +289,16 @@ where ) -> Result { let mut envelopes_iter = payload_envelopes.into_iter(); + let mut next_envelope_at_slot = |slot| { + if let Some(envelope) = envelopes_iter.next() + && envelope.message.slot == slot + { + Ok(envelope) + } else { + Err(BlockReplayError::MissingPayloadEnvelope { slot }) + } + }; + for (i, block) in blocks.iter().enumerate() { // Allow one additional block at the start which is only used for its state root. if i == 0 && block.slot() <= self.state.slot() { @@ -249,13 +306,12 @@ where } while self.state.slot() < block.slot() { - let block_state_root = self.get_state_root(&blocks, i)?; + let mut state_root = self.get_state_root(&blocks, i)?; // Apply the payload for the *previous* block if the bid in the current block - // indicates that the parent is full. - // TODO(gloas): check this condition at the fork boundary. - let state_root = if self.state.slot() == self.state.latest_block_header().slot - && block.fork_name_unchecked().gloas_enabled() + // indicates that the parent is full (and it hasn't already been applied). + state_root = if block.fork_name_unchecked().gloas_enabled() + && self.state.slot() == self.state.latest_block_header().slot { let latest_bid_block_hash = self .state @@ -266,49 +322,18 @@ where // Similar to `is_parent_block_full`, but reading the block hash from the // not-yet-applied `block`. if block.is_parent_block_full(latest_bid_block_hash) { - if let Some(envelope) = envelopes_iter.next() - && envelope.message.slot == self.state.slot() - { - // TODO(gloas): bulk signature verification could be relevant here? - let verify_payload_signatures = - if let BlockSignatureStrategy::NoVerification = - self.block_sig_strategy - { - VerifySignatures::False - } else { - VerifySignatures::True - }; - // TODO(gloas): state root verif enabled during initial - // prototyping/testing - let verify_state_root = VerifyStateRoot::True; - process_execution_payload_envelope( - &mut self.state, - Some(block_state_root), - &envelope, - verify_payload_signatures, - verify_state_root, - self.spec, - ) - .map_err(BlockReplayError::from)?; - - // State root for next slot processing is now the envelope's state root. - envelope.message.state_root - } else { - return Err(BlockReplayError::MissingPayloadEnvelope { - slot: block.slot(), - block_hash: latest_bid_block_hash, - } - .into()); - } + let envelope = next_envelope_at_slot(self.state.slot())?; + // State root for the next slot processing is now the envelope's state root. + self.apply_payload_envelope(&envelope, state_root)? } else { // Empty payload at this slot, the state root is unchanged from when the // beacon block was applied. - block_state_root + state_root } } else { // Pre-Gloas or at skipped slots post-Gloas, the state root of the parent state // is always the output from `self.get_state_root`. - block_state_root + state_root }; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { @@ -353,11 +378,24 @@ where } } - // TODO(gloas): apply last payload, but how to know if it *should* be applied? + // Apply the last payload if desired. + let mut opt_state_root = if let StatePayloadStatus::Full = self.desired_state_payload_status + && let Some(last_block) = blocks.last() + { + let envelope = next_envelope_at_slot(self.state.slot())?; + Some(self.apply_payload_envelope(&envelope, last_block.state_root())?) + } else { + None + }; if let Some(target_slot) = target_slot { while self.state.slot() < target_slot { - let state_root = self.get_state_root(&blocks, blocks.len())?; + // Read state root from `opt_state_root` if a payload was just applied. + let state_root = if let Some(root) = opt_state_root.take() { + root + } else { + self.get_state_root(&blocks, blocks.len())? + }; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { pre_slot_hook(state_root, &mut self.state)?; diff --git a/consensus/types/src/execution/mod.rs b/consensus/types/src/execution/mod.rs index a3d4ed87301..591be32b24e 100644 --- a/consensus/types/src/execution/mod.rs +++ b/consensus/types/src/execution/mod.rs @@ -12,6 +12,7 @@ mod payload; mod signed_bls_to_execution_change; mod signed_execution_payload_bid; mod signed_execution_payload_envelope; +mod state_payload_status; pub use bls_to_execution_change::BlsToExecutionChange; pub use eth1_data::Eth1Data; @@ -41,3 +42,4 @@ pub use payload::{ pub use signed_bls_to_execution_change::SignedBlsToExecutionChange; pub use signed_execution_payload_bid::SignedExecutionPayloadBid; pub use signed_execution_payload_envelope::SignedExecutionPayloadEnvelope; +pub use state_payload_status::StatePayloadStatus; diff --git a/consensus/types/src/execution/state_payload_status.rs b/consensus/types/src/execution/state_payload_status.rs new file mode 100644 index 00000000000..053ed14ec46 --- /dev/null +++ b/consensus/types/src/execution/state_payload_status.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +/// Payload status as it applies to a `BeaconState` post-Gloas. +/// +/// A state can either be a post-state for a block (in which case we call it `Pending`) or a +/// payload envelope (`Full`). When handling states it is often necessary to know which of these +/// two variants is required. +/// +/// Note that states at skipped slots could be either `Pending` or `Full`, depending on whether +/// the payload for the most-recently applied block was also applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StatePayloadStatus { + /// For states produced by `process_block` executed on a `BeaconBlock`. + Pending, + /// For states produced by `process_execution_payload` on a `ExecutionPayloadEnvelope`. + Full, +}