From d901998da179594329d90e321d45bc2e4b3d848f Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Sun, 5 Apr 2026 19:13:58 -0400 Subject: [PATCH 01/22] introduce `PayloadStatus` in `ProposerKey` --- beacon_node/beacon_chain/src/beacon_chain.rs | 64 +++++++++++++------- beacon_node/execution_layer/src/lib.rs | 12 +++- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index e226c707a4e..4e9ba0dc86e 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4657,30 +4657,31 @@ impl BeaconChain { proposal_slot: Slot, ) -> Result, Error> { let cached_head = self.canonical_head.cached_head(); + let head_payload_status = cached_head.head_payload_status().as_state_payload_status(); let head_state = &cached_head.snapshot.beacon_state; let parent_block_root = forkchoice_update_params.head_root; - let (unadvanced_state, unadvanced_state_root) = - if cached_head.head_block_root() == parent_block_root { - (Cow::Borrowed(head_state), cached_head.head_state_root()) - } else { - // TODO(gloas): this function needs updating to be envelope-aware - // See: https://github.com/sigp/lighthouse/issues/8957 - let block = self - .get_blinded_block(&parent_block_root)? - .ok_or(Error::MissingBeaconBlock(parent_block_root))?; - let (state_root, state) = self - .store - .get_advanced_hot_state( - parent_block_root, - StatePayloadStatus::Pending, - proposal_slot, - block.state_root(), - )? - .ok_or(Error::MissingBeaconState(block.state_root()))?; - (Cow::Owned(state), state_root) - }; + let (unadvanced_state, unadvanced_state_root) = if cached_head.head_block_root() + == parent_block_root + && head_state.payload_status() == head_payload_status + { + (Cow::Borrowed(head_state), cached_head.head_state_root()) + } else { + let block = self + .get_blinded_block(&parent_block_root)? + .ok_or(Error::MissingBeaconBlock(parent_block_root))?; + let (state_root, state) = self + .store + .get_advanced_hot_state( + parent_block_root, + head_payload_status, + proposal_slot, + block.state_root(), + )? + .ok_or(Error::MissingBeaconState(block.state_root()))?; + (Cow::Owned(state), state_root) + }; // Parent state epoch is the same as the proposal, we don't need to advance because the // list of expected withdrawals can only change after an epoch advance or a @@ -5916,13 +5917,21 @@ impl BeaconChain { fcu_params.head_root, &cached_head, )?; - Ok::<_, Error>(Some((fcu_params, pre_payload_attributes))) + let head_payload_status = + cached_head.head_payload_status().as_state_payload_status(); + Ok::<_, Error>(Some(( + fcu_params, + pre_payload_attributes, + head_payload_status, + ))) }, "prepare_beacon_proposer_head_read", ) .await??; - let Some((forkchoice_update_params, Some(pre_payload_attributes))) = maybe_prep_data else { + let Some((forkchoice_update_params, Some(pre_payload_attributes), head_payload_status)) = + maybe_prep_data + else { // Appropriate log messages have already been logged above and in // `get_pre_payload_attributes`. return Ok(None); @@ -5944,7 +5953,7 @@ impl BeaconChain { // considerable time to compute if a state load is required. let head_root = forkchoice_update_params.head_root; let payload_attributes = if let Some(payload_attributes) = execution_layer - .payload_attributes(prepare_slot, head_root) + .payload_attributes(prepare_slot, head_root, head_payload_status) .await { payload_attributes @@ -5984,6 +5993,7 @@ impl BeaconChain { .insert_proposer( prepare_slot, head_root, + head_payload_status, proposer, payload_attributes.clone(), ) @@ -5995,6 +6005,7 @@ impl BeaconChain { %prepare_slot, validator = proposer, parent_root = ?head_root, + payload_status = ?head_payload_status, "Prepared beacon proposer" ); payload_attributes @@ -6112,6 +6123,12 @@ impl BeaconChain { return Ok(()); }; + let head_payload_status = self + .canonical_head + .cached_head() + .head_payload_status() + .as_state_payload_status(); + let forkchoice_updated_response = execution_layer .notify_forkchoice_updated( head_hash, @@ -6119,6 +6136,7 @@ impl BeaconChain { finalized_hash, current_slot, head_block_root, + head_payload_status, ) .await .map_err(Error::ExecutionForkChoiceUpdateFailed); diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 90968fa213c..6742bd820d2 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -49,7 +49,7 @@ use types::execution::BlockProductionVersion; use types::kzg_ext::KzgCommitments; use types::{ AbstractExecPayload, BlobsList, ExecutionPayloadDeneb, ExecutionRequests, KzgProofs, - SignedBlindedBeaconBlock, + SignedBlindedBeaconBlock, StatePayloadStatus, }; use types::{ BeaconStateError, BlindedPayload, ChainSpec, Epoch, ExecPayload, ExecutionPayloadBellatrix, @@ -403,6 +403,7 @@ impl ProposerPreparationDataEntry { pub struct ProposerKey { slot: Slot, head_block_root: Hash256, + head_payload_status: StatePayloadStatus, } #[derive(PartialEq, Clone)] @@ -1461,12 +1462,14 @@ impl ExecutionLayer { &self, slot: Slot, head_block_root: Hash256, + head_payload_status: StatePayloadStatus, validator_index: u64, payload_attributes: PayloadAttributes, ) -> bool { let proposers_key = ProposerKey { slot, head_block_root, + head_payload_status, }; let existing = self.proposers().write().await.insert( @@ -1491,10 +1494,12 @@ impl ExecutionLayer { &self, current_slot: Slot, head_block_root: Hash256, + head_payload_status: StatePayloadStatus, ) -> Option { let proposers_key = ProposerKey { slot: current_slot, head_block_root, + head_payload_status, }; let proposer = self.proposers().read().await.get(&proposers_key).cloned()?; @@ -1518,6 +1523,7 @@ impl ExecutionLayer { finalized_block_hash: ExecutionBlockHash, current_slot: Slot, head_block_root: Hash256, + head_payload_status: StatePayloadStatus, ) -> Result { let _timer = metrics::start_timer_vec( &metrics::EXECUTION_LAYER_REQUEST_TIMES, @@ -1534,7 +1540,9 @@ impl ExecutionLayer { ); let next_slot = current_slot + 1; - let payload_attributes = self.payload_attributes(next_slot, head_block_root).await; + let payload_attributes = self + .payload_attributes(next_slot, head_block_root, head_payload_status) + .await; // Compute the "lookahead", the time between when the payload will be produced and now. if let Some(ref payload_attributes) = payload_attributes From b85fa196fe0041cbcfc3349907497f3429ad4d31 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Sun, 5 Apr 2026 19:14:28 -0400 Subject: [PATCH 02/22] adopt tests to the new `ProposerKey` struct --- .../src/test_utils/mock_builder.rs | 16 +++++++++++++-- .../src/test_utils/mock_execution_layer.rs | 12 ++++++++++- .../src/test_rig.rs | 20 +++++++++++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index 7b6c4e8310c..88dfbb9c8f2 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -36,7 +36,8 @@ use types::builder::{ use types::{ Address, BeaconState, ChainSpec, Epoch, EthSpec, ExecPayload, ExecutionPayload, ExecutionPayloadHeaderRefMut, ExecutionRequests, ForkName, ForkVersionDecode, Hash256, - SignedBlindedBeaconBlock, SignedRoot, SignedValidatorRegistrationData, Slot, Uint256, + SignedBlindedBeaconBlock, SignedRoot, SignedValidatorRegistrationData, Slot, + StatePayloadStatus, Uint256, }; use warp::reply::{self, Reply}; use warp::{Filter, Rejection}; @@ -800,6 +801,10 @@ impl MockBuilder { let head_block_root = head_block_root.unwrap_or(head.canonical_root()); + // TODO(gloas): currently the tests are pre-Gloas and we are not considering + // other payload statuses. edit once the epbs features are added here. + let head_payload_status = StatePayloadStatus::Pending; + let head_execution_payload = head .message() .body() @@ -926,7 +931,13 @@ impl MockBuilder { ); self.el - .insert_proposer(slot, head_block_root, val_index, payload_attributes.clone()) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + val_index, + payload_attributes.clone(), + ) .await; let forkchoice_update_params = ForkchoiceUpdateParameters { @@ -944,6 +955,7 @@ impl MockBuilder { finalized_execution_hash, slot - 1, head_block_root, + head_payload_status, ) .await .map_err(|e| format!("fcu call failed : {:?}", e))?; diff --git a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs index 91966ff65e3..6536bc4a62f 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs @@ -90,6 +90,7 @@ impl MockExecutionLayer { let timestamp = block_number; let prev_randao = Hash256::from_low_u64_be(block_number); let head_block_root = Hash256::repeat_byte(42); + let head_payload_status = StatePayloadStatus::Pending; let forkchoice_update_params = ForkchoiceUpdateParameters { head_root: head_block_root, head_hash: Some(parent_hash), @@ -103,7 +104,13 @@ impl MockExecutionLayer { let slot = Slot::new(0); let validator_index = 0; self.el - .insert_proposer(slot, head_block_root, validator_index, payload_attributes) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + validator_index, + payload_attributes, + ) .await; self.el @@ -113,6 +120,7 @@ impl MockExecutionLayer { ExecutionBlockHash::zero(), slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -262,6 +270,7 @@ impl MockExecutionLayer { // Use junk values for slot/head-root to ensure there is no payload supplied. let slot = Slot::new(0); let head_block_root = Hash256::repeat_byte(13); + // TODO(gloas): reconsider the state_payload_status self.el .notify_forkchoice_updated( block_hash, @@ -269,6 +278,7 @@ impl MockExecutionLayer { ExecutionBlockHash::zero(), slot, head_block_root, + StatePayloadStatus::Pending, ) .await .unwrap(); diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index 6bf4a1aa529..5c09dfb11fd 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -24,7 +24,7 @@ use tokio::time::sleep; use types::execution::BlockProductionVersion; use types::{ Address, ChainSpec, EthSpec, ExecutionBlockHash, ExecutionPayload, ExecutionPayloadHeader, - ForkName, Hash256, MainnetEthSpec, Slot, Uint256, + ForkName, Hash256, MainnetEthSpec, Slot, StatePayloadStatus, Uint256, }; const EXECUTION_ENGINE_START_TIMEOUT: Duration = Duration::from_secs(60); @@ -200,6 +200,9 @@ impl TestRig { pub async fn perform_tests(&self) { self.wait_until_synced().await; + // TODO(gloas): this needs to be for post-Gloas cases + let head_payload_status = StatePayloadStatus::Pending; + // Create a local signer in case we need to sign transactions locally let private_key_signer: PrivateKeySigner = PRIVATE_KEYS[0].parse().expect("Invalid private key"); @@ -308,6 +311,7 @@ impl TestRig { .insert_proposer( Slot::new(1), // Insert proposer for the next slot head_root, + StatePayloadStatus::Pending, proposer_index, PayloadAttributes::new( timestamp, @@ -331,6 +335,7 @@ impl TestRig { finalized_block_hash, Slot::new(0), Hash256::zero(), + head_payload_status, ) .await .unwrap(); @@ -409,6 +414,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -450,6 +456,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -583,7 +590,13 @@ impl TestRig { let validator_index = 0; self.ee_a .execution_layer - .insert_proposer(slot, head_block_root, validator_index, payload_attributes) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + validator_index, + payload_attributes, + ) .await; let status = self .ee_a @@ -594,6 +607,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -631,6 +645,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -684,6 +699,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); From f0b01d08bce45ed48da4f3beaa5c4cb266ef6d4c Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 6 Apr 2026 12:24:37 -0400 Subject: [PATCH 03/22] resolving divergent `StatePayloadStatus` values --- beacon_node/beacon_chain/src/beacon_chain.rs | 8 ++------ beacon_node/beacon_chain/src/canonical_head.rs | 7 ++++++- beacon_node/client/src/builder.rs | 9 +++++++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 4e9ba0dc86e..9b2478e4a2f 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -6058,6 +6058,7 @@ impl BeaconChain { self.update_execution_engine_forkchoice( current_slot, forkchoice_update_params, + head_payload_status, OverrideForkchoiceUpdate::AlreadyApplied, ) .await?; @@ -6070,6 +6071,7 @@ impl BeaconChain { self: &Arc, current_slot: Slot, input_params: ForkchoiceUpdateParameters, + head_payload_status: StatePayloadStatus, override_forkchoice_update: OverrideForkchoiceUpdate, ) -> Result<(), Error> { let execution_layer = self @@ -6123,12 +6125,6 @@ impl BeaconChain { return Ok(()); }; - let head_payload_status = self - .canonical_head - .cached_head() - .head_payload_status() - .as_state_payload_status(); - let forkchoice_updated_response = execution_layer .notify_forkchoice_updated( head_hash, diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index cd53d0ef7cf..b5589093418 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -800,7 +800,10 @@ impl BeaconChain { // The execution layer updates might attempt to take a write-lock on fork choice, so it's // important to ensure the fork-choice lock isn't being held. let el_update_handle = - spawn_execution_layer_updates(self.clone(), new_forkchoice_update_parameters)?; + spawn_execution_layer_updates( + self.clone(), new_forkchoice_update_parameters, + new_payload_status.as_state_payload_status(), + )?; // We have completed recomputing the head and it's now valid for another process to do the // same. @@ -1154,6 +1157,7 @@ fn perform_debug_logging( fn spawn_execution_layer_updates( chain: Arc>, forkchoice_update_params: ForkchoiceUpdateParameters, + head_payload_status: StatePayloadStatus, ) -> Result>, Error> { let current_slot = chain .slot_clock @@ -1176,6 +1180,7 @@ fn spawn_execution_layer_updates( .update_execution_engine_forkchoice( current_slot, forkchoice_update_params, + head_payload_status, OverrideForkchoiceUpdate::Yes, ) .await diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 865599b9bd2..70025e7c4db 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -721,9 +721,13 @@ where if let Some(execution_layer) = beacon_chain.execution_layer.as_ref() { // Only send a head update *after* genesis. if let Ok(current_slot) = beacon_chain.slot() { - let params = beacon_chain + let cached_head = beacon_chain .canonical_head - .cached_head() + .cached_head(); + let head_payload_status = cached_head + .head_payload_status() + .as_state_payload_status(); + let params = cached_head .forkchoice_update_parameters(); if params .head_hash @@ -737,6 +741,7 @@ where .update_execution_engine_forkchoice( current_slot, params, + head_payload_status, Default::default(), ) .await; From 5007b75df7735f905da9c3478b3df317f5257b37 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 6 Apr 2026 12:38:01 -0400 Subject: [PATCH 04/22] fmt --- beacon_node/beacon_chain/src/canonical_head.rs | 6 +++--- beacon_node/client/src/builder.rs | 12 ++++-------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index b5589093418..6af74acbf16 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -799,9 +799,9 @@ impl BeaconChain { // The execution layer updates might attempt to take a write-lock on fork choice, so it's // important to ensure the fork-choice lock isn't being held. - let el_update_handle = - spawn_execution_layer_updates( - self.clone(), new_forkchoice_update_parameters, + let el_update_handle = spawn_execution_layer_updates( + self.clone(), + new_forkchoice_update_parameters, new_payload_status.as_state_payload_status(), )?; diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 70025e7c4db..0b31a6ca89f 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -721,14 +721,10 @@ where if let Some(execution_layer) = beacon_chain.execution_layer.as_ref() { // Only send a head update *after* genesis. if let Ok(current_slot) = beacon_chain.slot() { - let cached_head = beacon_chain - .canonical_head - .cached_head(); - let head_payload_status = cached_head - .head_payload_status() - .as_state_payload_status(); - let params = cached_head - .forkchoice_update_parameters(); + let cached_head = beacon_chain.canonical_head.cached_head(); + let head_payload_status = + cached_head.head_payload_status().as_state_payload_status(); + let params = cached_head.forkchoice_update_parameters(); if params .head_hash .is_some_and(|hash| hash != ExecutionBlockHash::zero()) From 7ae5dd4d7bf48e5548bd08e325c8fa444d745641 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 6 Apr 2026 17:29:12 -0400 Subject: [PATCH 05/22] rounding up some todos --- beacon_node/beacon_chain/src/canonical_head.rs | 7 ++----- .../beacon_chain/src/state_advance_timer.rs | 14 ++++++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 6af74acbf16..a06323014c1 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -315,12 +315,10 @@ impl CanonicalHead { .ok_or(Error::MissingBeaconBlock(beacon_block_root))?; let current_slot = fork_choice.fc_store().get_current_slot(); - // TODO(gloas): pass a better payload status once fork choice is implemented - let payload_status = StatePayloadStatus::Pending; let (_, beacon_state) = store .get_advanced_hot_state( beacon_block_root, - payload_status, + head_payload_status.as_state_payload_status(), current_slot, beacon_block.state_root(), )? @@ -692,8 +690,7 @@ impl BeaconChain { .get_full_block(&new_view.head_block_root)? .ok_or(Error::MissingBeaconBlock(new_view.head_block_root))?; - // TODO(gloas): update once we have fork choice - let payload_status = StatePayloadStatus::Pending; + let payload_status = new_payload_status.as_state_payload_status(); let (_, beacon_state) = self .store .get_advanced_hot_state( diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index 4c070e7ecc4..9f1f30fc0b4 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -275,18 +275,20 @@ fn advance_head(beacon_chain: &Arc>) -> Resu } } - let (head_block_root, head_block_state_root) = { - let snapshot = beacon_chain.head_snapshot(); - (snapshot.beacon_block_root, snapshot.beacon_state_root()) + let (head_block_root, head_block_state_root, head_payload_status) = { + let cached_head = beacon_chain.canonical_head.cached_head(); + ( + cached_head.snapshot.beacon_block_root, + cached_head.snapshot.beacon_state_root(), + cached_head.head_payload_status().as_state_payload_status(), + ) }; - // TODO(gloas): do better once we have fork choice - let payload_status = StatePayloadStatus::Pending; let (head_state_root, mut state) = beacon_chain .store .get_advanced_hot_state( head_block_root, - payload_status, + head_payload_status, current_slot, head_block_state_root, )? From 8cef49f79a9039b1785ab70d157b3fa790099239 Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Mon, 6 Apr 2026 17:39:21 -0400 Subject: [PATCH 06/22] fmt --- beacon_node/beacon_chain/src/state_advance_timer.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index 9f1f30fc0b4..a8a961c7d20 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -28,7 +28,6 @@ use tokio::time::{Instant, sleep, sleep_until}; use tracing::{Instrument, debug, debug_span, error, instrument, warn}; use types::{ AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot, - StatePayloadStatus, }; /// If the head slot is more than `MAX_ADVANCE_DISTANCE` from the current slot, then don't perform From 98b9d7187ff22749c3c540fe437450bc43c017ec Mon Sep 17 00:00:00 2001 From: hopinheimer Date: Wed, 8 Apr 2026 02:39:19 -0400 Subject: [PATCH 07/22] fmt again :| --- beacon_node/beacon_chain/src/state_advance_timer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index a8a961c7d20..0abe20ec557 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -26,9 +26,7 @@ use std::sync::{ use task_executor::TaskExecutor; use tokio::time::{Instant, sleep, sleep_until}; use tracing::{Instrument, debug, debug_span, error, instrument, warn}; -use types::{ - AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot, -}; +use types::{AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot}; /// If the head slot is more than `MAX_ADVANCE_DISTANCE` from the current slot, then don't perform /// the state advancement. From 63881d04583e7005479da6027255ab3ca94428ff Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 08:56:47 +1000 Subject: [PATCH 08/22] Use fork_choice::PayloadStatus --- beacon_node/beacon_chain/src/beacon_chain.rs | 8 ++++---- beacon_node/beacon_chain/src/canonical_head.rs | 4 ++-- beacon_node/beacon_chain/src/state_advance_timer.rs | 3 +-- beacon_node/client/src/builder.rs | 3 +-- beacon_node/execution_layer/src/lib.rs | 6 +++--- .../execution_layer/src/test_utils/mock_builder.rs | 9 ++++----- .../src/test_utils/mock_execution_layer.rs | 5 +++-- consensus/proto_array/src/proto_array_fork_choice.rs | 2 +- testing/execution_engine_integration/src/test_rig.rs | 6 +++--- 9 files changed, 22 insertions(+), 24 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 02b36ead4a6..4151c70bc68 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4706,7 +4706,8 @@ impl BeaconChain { proposal_slot: Slot, ) -> Result, Error> { let cached_head = self.canonical_head.cached_head(); - let head_payload_status = cached_head.head_payload_status().as_state_payload_status(); + // TODO(gloas): wire this up again + let _head_payload_status = cached_head.head_payload_status(); let head_state = &cached_head.snapshot.beacon_state; let parent_block_root = forkchoice_update_params.head_root; @@ -5961,8 +5962,7 @@ impl BeaconChain { fcu_params.head_root, &cached_head, )?; - let head_payload_status = - cached_head.head_payload_status().as_state_payload_status(); + let head_payload_status = cached_head.head_payload_status(); Ok::<_, Error>(Some(( fcu_params, pre_payload_attributes, @@ -6122,7 +6122,7 @@ impl BeaconChain { self: &Arc, current_slot: Slot, input_params: ForkchoiceUpdateParameters, - head_payload_status: StatePayloadStatus, + head_payload_status: fork_choice::PayloadStatus, override_forkchoice_update: OverrideForkchoiceUpdate, ) -> Result<(), Error> { let execution_layer = self diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 789405628d1..ee35ae9eda5 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -817,7 +817,7 @@ impl BeaconChain { let el_update_handle = spawn_execution_layer_updates( self.clone(), new_forkchoice_update_parameters, - new_payload_status.as_state_payload_status(), + new_payload_status, )?; // We have completed recomputing the head and it's now valid for another process to do the @@ -1176,7 +1176,7 @@ fn perform_debug_logging( fn spawn_execution_layer_updates( chain: Arc>, forkchoice_update_params: ForkchoiceUpdateParameters, - head_payload_status: StatePayloadStatus, + head_payload_status: PayloadStatus, ) -> Result>, Error> { let current_slot = chain .slot_clock diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index ff5779b2f9d..19c533fa913 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -272,12 +272,11 @@ fn advance_head(beacon_chain: &Arc>) -> Resu } } - let (head_block_root, head_block_state_root, head_payload_status) = { + let (head_block_root, head_block_state_root) = { let cached_head = beacon_chain.canonical_head.cached_head(); ( cached_head.snapshot.beacon_block_root, cached_head.snapshot.beacon_state_root(), - cached_head.head_payload_status().as_state_payload_status(), ) }; diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 0b31a6ca89f..9dfb8304bc8 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -722,8 +722,7 @@ where // Only send a head update *after* genesis. if let Ok(current_slot) = beacon_chain.slot() { let cached_head = beacon_chain.canonical_head.cached_head(); - let head_payload_status = - cached_head.head_payload_status().as_state_payload_status(); + let head_payload_status = cached_head.head_payload_status(); let params = cached_head.forkchoice_update_parameters(); if params .head_hash diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 0b5245c5f79..ebaf74d7316 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -1462,7 +1462,7 @@ impl ExecutionLayer { &self, slot: Slot, head_block_root: Hash256, - head_payload_status: StatePayloadStatus, + head_payload_status: fork_choice::PayloadStatus, validator_index: u64, payload_attributes: PayloadAttributes, ) -> bool { @@ -1494,7 +1494,7 @@ impl ExecutionLayer { &self, current_slot: Slot, head_block_root: Hash256, - head_payload_status: StatePayloadStatus, + head_payload_status: fork_choice::PayloadStatus, ) -> Option { let proposers_key = ProposerKey { slot: current_slot, @@ -1523,7 +1523,7 @@ impl ExecutionLayer { finalized_block_hash: ExecutionBlockHash, current_slot: Slot, head_block_root: Hash256, - head_payload_status: StatePayloadStatus, + head_payload_status: fork_choice::PayloadStatus, ) -> Result { let _timer = metrics::start_timer_vec( &metrics::EXECUTION_LAYER_REQUEST_TIMES, diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index c8bdcd09a93..d6243a7c4d8 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -36,8 +36,7 @@ use types::builder::{ use types::{ Address, BeaconState, ChainSpec, Epoch, EthSpec, ExecPayload, ExecutionPayload, ExecutionPayloadHeaderRefMut, ExecutionRequests, ForkName, ForkVersionDecode, Hash256, - SignedBlindedBeaconBlock, SignedRoot, SignedValidatorRegistrationData, Slot, - StatePayloadStatus, Uint256, + SignedBlindedBeaconBlock, SignedRoot, SignedValidatorRegistrationData, Slot, Uint256, }; use warp::reply::{self, Reply}; use warp::{Filter, Rejection}; @@ -801,9 +800,9 @@ impl MockBuilder { let head_block_root = head_block_root.unwrap_or(head.canonical_root()); - // TODO(gloas): currently the tests are pre-Gloas and we are not considering - // other payload statuses. edit once the epbs features are added here. - let head_payload_status = StatePayloadStatus::Pending; + // TODO(gloas): Currently the tests are pre-Gloas and we are not considering + // other payload statuses. This codepath may not be relevant for Gloas. + let head_payload_status = fork_choice::PayloadStatus::Pending; let head_execution_payload = head .message() diff --git a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs index 4eb10886990..5b721bcab29 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs @@ -90,7 +90,8 @@ impl MockExecutionLayer { let timestamp = block_number; let prev_randao = Hash256::from_low_u64_be(block_number); let head_block_root = Hash256::repeat_byte(42); - let head_payload_status = StatePayloadStatus::Pending; + // TODO(gloas): allow statuses other than Pending? + let head_payload_status = fork_choice::PayloadStatus::Pending; let forkchoice_update_params = ForkchoiceUpdateParameters { head_root: head_block_root, head_hash: Some(parent_hash), @@ -296,7 +297,7 @@ impl MockExecutionLayer { ExecutionBlockHash::zero(), slot, head_block_root, - StatePayloadStatus::Pending, + fork_choice::PayloadStatus::Pending, ) .await .unwrap(); diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 577e89baa10..8a18620cf9f 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -101,7 +101,7 @@ pub enum ExecutionStatus { } /// Represents the status of an execution payload post-Gloas. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)] #[ssz(enum_behaviour = "tag")] #[repr(u8)] pub enum PayloadStatus { diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index 00389886c0d..ed6b5787b53 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -24,7 +24,7 @@ use tokio::time::sleep; use types::execution::BlockProductionVersion; use types::{ Address, ChainSpec, EthSpec, ExecutionBlockHash, ExecutionPayload, ExecutionPayloadHeader, - ForkName, Hash256, MainnetEthSpec, Slot, StatePayloadStatus, Uint256, + ForkName, Hash256, MainnetEthSpec, Slot, Uint256, }; const EXECUTION_ENGINE_START_TIMEOUT: Duration = Duration::from_secs(60); @@ -201,7 +201,7 @@ impl TestRig { self.wait_until_synced().await; // TODO(gloas): this needs to be for post-Gloas cases - let head_payload_status = StatePayloadStatus::Pending; + let head_payload_status = fork_choice::PayloadStatus::Pending; // Create a local signer in case we need to sign transactions locally let private_key_signer: PrivateKeySigner = @@ -311,7 +311,7 @@ impl TestRig { .insert_proposer( Slot::new(1), // Insert proposer for the next slot head_root, - StatePayloadStatus::Pending, + fork_choice::PayloadStatus::Pending, proposer_index, PayloadAttributes::new( timestamp, From c1000922072adfa37f6344ff287ae58662b75e36 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 08:58:12 +1000 Subject: [PATCH 09/22] Revert changes in state advance --- beacon_node/beacon_chain/src/state_advance_timer.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index 19c533fa913..cb916cb5142 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -273,11 +273,8 @@ fn advance_head(beacon_chain: &Arc>) -> Resu } let (head_block_root, head_block_state_root) = { - let cached_head = beacon_chain.canonical_head.cached_head(); - ( - cached_head.snapshot.beacon_block_root, - cached_head.snapshot.beacon_state_root(), - ) + let snapshot = beacon_chain.head_snapshot(); + (snapshot.beacon_block_root, snapshot.beacon_state_root()) }; let (head_state_root, mut state) = beacon_chain From 011c453dd6be9ed311191d6305f9481370a1c90e Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 11:00:36 +1000 Subject: [PATCH 10/22] Add test spec for prepare_payload_on_full_parent --- beacon_node/beacon_chain/tests/main.rs | 1 + .../beacon_chain/tests/prepare_payload.rs | 132 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 beacon_node/beacon_chain/tests/prepare_payload.rs diff --git a/beacon_node/beacon_chain/tests/main.rs b/beacon_node/beacon_chain/tests/main.rs index e02c488ac6f..d31db128c5e 100644 --- a/beacon_node/beacon_chain/tests/main.rs +++ b/beacon_node/beacon_chain/tests/main.rs @@ -6,6 +6,7 @@ mod column_verification; mod events; mod op_verification; mod payload_invalidation; +mod prepare_payload; mod rewards; mod schema_stability; mod store_tests; diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs new file mode 100644 index 00000000000..0f286c06437 --- /dev/null +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -0,0 +1,132 @@ +#![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] + +use beacon_chain::test_utils::{ + AttestationStrategy, BeaconChainHarness, BlockStrategy, DiskHarnessType, test_spec, +}; +use beacon_chain::{ChainConfig, custody_context::NodeCustodyType}; +use bls::Keypair; +use logging::create_test_tracing_subscriber; +use slot_clock::SlotClock; +use std::sync::{Arc, LazyLock}; +use store::database::interface::BeaconNodeBackend; +use store::{HotColdDB, StoreConfig}; +use tempfile::{TempDir, tempdir}; +use types::*; + +// Should ideally be divisible by 3. +pub const LOW_VALIDATOR_COUNT: usize = 32; +pub const HIGH_VALIDATOR_COUNT: usize = 64; + +// When set to true, cache any states fetched from the db. +pub const CACHE_STATE_IN_TESTS: bool = true; + +/// A cached set of keys. +static KEYPAIRS: LazyLock> = + LazyLock::new(|| types::test_utils::generate_deterministic_keypairs(HIGH_VALIDATOR_COUNT)); + +type E = MinimalEthSpec; +type TestHarness = BeaconChainHarness>; + +fn get_store(db_path: &TempDir) -> Arc, BeaconNodeBackend>> { + let store_config = StoreConfig { + prune_payloads: false, + ..StoreConfig::default() + }; + get_store_generic(db_path, store_config, test_spec::()) +} + +fn get_store_generic( + db_path: &TempDir, + config: StoreConfig, + spec: ChainSpec, +) -> Arc, BeaconNodeBackend>> { + create_test_tracing_subscriber(); + let hot_path = db_path.path().join("chain_db"); + let cold_path = db_path.path().join("freezer_db"); + let blobs_path = db_path.path().join("blobs_db"); + + HotColdDB::open( + &hot_path, + &cold_path, + &blobs_path, + |_, _, _| Ok(()), + config, + spec.into(), + ) + .expect("disk store should initialize") +} + +fn get_harness( + store: Arc, BeaconNodeBackend>>, + validator_count: usize, +) -> TestHarness { + // Most tests expect to retain historic states, so we use this as the default. + let chain_config = ChainConfig { + archive: true, + ..ChainConfig::default() + }; + get_harness_generic( + store, + validator_count, + chain_config, + NodeCustodyType::Fullnode, + ) +} + +fn get_harness_generic( + store: Arc, BeaconNodeBackend>>, + validator_count: usize, + chain_config: ChainConfig, + node_custody_type: NodeCustodyType, +) -> TestHarness { + let harness = TestHarness::builder(MinimalEthSpec) + .spec(store.get_chain_spec().clone()) + .keypairs(KEYPAIRS[0..validator_count].to_vec()) + .fresh_disk_store(store) + .mock_execution_layer() + .chain_config(chain_config) + .node_custody_type(node_custody_type) + .build(); + harness.advance_slot(); + harness +} + +#[tokio::test] +async fn prepare_payload_on_full_parent() { + // Post-Gloas test. + if !test_spec::() + .fork_name_at_slot::(Slot::new(0)) + .gloas_enabled() + { + return; + } + + let num_blocks_produced = E::slots_per_epoch(); + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + harness + .extend_chain( + num_blocks_produced as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce a block with a payload that affects withdrawals for the next slot. + // This requires injecting at least one valid and actionable withdrawal into the execution + // requests. The mock builder needs updating to support this. + // TODO(claude): fill this in + + // Verify that the withdrawals computed from the block's state differ from the withdrawal's + // computed from the block's state with its payload applied by + // `apply_parent_execution_payload`. + // TODO(claude): fill this in + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the applied execution_requests). + // TODO(claude): fill this in +} From c95910a50ac7262a31096975e46e532cbc1b5ed9 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 13:05:02 +1000 Subject: [PATCH 11/22] Claude's first attempt at regression test (and fix) --- beacon_node/beacon_chain/src/beacon_chain.rs | 39 +++- .../beacon_chain/tests/prepare_payload.rs | 191 ++++++++++++++++-- .../src/engine_api/json_structures.rs | 30 ++- .../test_utils/execution_block_generator.rs | 28 ++- .../src/test_utils/handle_rpc.rs | 19 +- 5 files changed, 282 insertions(+), 25 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 4151c70bc68..b6a6e024aa1 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -111,8 +111,8 @@ use state_processing::{ epoch_cache::initialize_epoch_cache, per_block_processing, per_block_processing::{ - VerifySignatures, errors::AttestationValidationError, get_expected_withdrawals, - verify_attestation_for_block_inclusion, + VerifySignatures, apply_parent_execution_payload, errors::AttestationValidationError, + get_expected_withdrawals, verify_attestation_for_block_inclusion, }, per_slot_processing, state_advance::{complete_state_advance, partial_state_advance}, @@ -4706,8 +4706,7 @@ impl BeaconChain { proposal_slot: Slot, ) -> Result, Error> { let cached_head = self.canonical_head.cached_head(); - // TODO(gloas): wire this up again - let _head_payload_status = cached_head.head_payload_status(); + let head_payload_status = cached_head.head_payload_status(); let head_state = &cached_head.snapshot.beacon_state; let parent_block_root = forkchoice_update_params.head_root; @@ -4716,7 +4715,7 @@ impl BeaconChain { if cached_head.head_block_root() == parent_block_root { (Cow::Borrowed(head_state), cached_head.head_state_root()) } else { - // TODO(gloas): this function needs updating to be envelope-aware + // TODO(gloas): this branch needs envelope-awareness for non-head parents // See: https://github.com/sigp/lighthouse/issues/8957 let block = self .get_blinded_block(&parent_block_root)? @@ -4728,6 +4727,36 @@ impl BeaconChain { (Cow::Owned(state), state_root) }; + // For Gloas, when the head payload is Full, we need to apply the parent's + // execution requests to the Pending state to get the correct withdrawals. + // The cached head state is always Pending (post-block, pre-envelope), but + // withdrawals must be computed from the Full state (post-envelope). + // TODO(gloas): this is Claude's wrong attempt, payload application should happen AFTER + // advancing the slot + let unadvanced_state = if head_payload_status == fork_choice::PayloadStatus::Full + && cached_head.head_block_root() == parent_block_root + { + if let Some(envelope) = cached_head.snapshot.execution_envelope.as_ref() { + let parent_bid = unadvanced_state + .latest_execution_payload_bid() + .map_err(Error::BeaconStateError)? + .clone(); + let mut full_state = unadvanced_state.into_owned(); + apply_parent_execution_payload( + &mut full_state, + &parent_bid, + &envelope.message.execution_requests, + &self.spec, + ) + .map_err(Error::PrepareProposerFailed)?; + Cow::Owned(full_state) + } else { + unadvanced_state + } + } else { + unadvanced_state + }; + // Parent state epoch is the same as the proposal, we don't need to advance because the // list of expected withdrawals can only change after an epoch advance or a // block application. diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 0f286c06437..f7aadbbd10d 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -6,8 +6,12 @@ use beacon_chain::test_utils::{ }; use beacon_chain::{ChainConfig, custody_context::NodeCustodyType}; use bls::Keypair; +use eth2::types::ProposerPreparationData; use logging::create_test_tracing_subscriber; -use slot_clock::SlotClock; +use ssz_types::VariableList; +use state_processing::per_block_processing::{ + apply_parent_execution_payload, withdrawals::get_expected_withdrawals, +}; use std::sync::{Arc, LazyLock}; use store::database::interface::BeaconNodeBackend; use store::{HotColdDB, StoreConfig}; @@ -95,17 +99,24 @@ fn get_harness_generic( #[tokio::test] async fn prepare_payload_on_full_parent() { // Post-Gloas test. - if !test_spec::() - .fork_name_at_slot::(Slot::new(0)) - .gloas_enabled() - { + let spec = test_spec::(); + if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { return; } - let num_blocks_produced = E::slots_per_epoch(); + let num_blocks_produced = E::slots_per_epoch() * 3; let db_path = tempdir().unwrap(); let store = get_store(&db_path); - let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + let chain_config = ChainConfig { + archive: true, + ..ChainConfig::default() + }; + let harness = get_harness_generic( + store.clone(), + LOW_VALIDATOR_COUNT, + chain_config, + NodeCustodyType::Fullnode, + ); harness .extend_chain( @@ -115,18 +126,172 @@ async fn prepare_payload_on_full_parent() { ) .await; + // Advance the slot so the next extend_chain produces at a fresh slot. + harness.advance_slot(); + // Produce a block with a payload that affects withdrawals for the next slot. - // This requires injecting at least one valid and actionable withdrawal into the execution - // requests. The mock builder needs updating to support this. - // TODO(claude): fill this in + // This requires injecting at least one valid and actionable consolidation request + // (switch-to-compounding) into the execution requests. A switch-to-compounding request + // changes a validator's withdrawal credentials from 0x01 (eth1) to 0x02 (compounding), + // which reduces their balance to min_activation_balance and queues the excess as a + // pending deposit. This removes the validator from the partial withdrawal sweep. + // + // We target an odd-indexed validator since odd validators are created with eth1 withdrawal + // credentials in the interop genesis builder. + let target_validator_index = 1_usize; + let head_state = &harness + .chain + .canonical_head + .cached_head() + .snapshot + .beacon_state; + let validator = head_state + .get_validator(target_validator_index) + .expect("validator should exist"); + + // Sanity check: the validator has eth1 withdrawal credentials (0x01 prefix). + assert!( + validator.has_eth1_withdrawal_credential(&spec), + "validator {target_validator_index} should have eth1 withdrawal credentials" + ); + // Sanity check: the validator is partially withdrawable (has excess balance from + // attestation rewards after 1 epoch). + let balance = head_state + .get_balance(target_validator_index) + .expect("should get balance"); + assert!( + balance > spec.min_activation_balance, + "validator should have excess balance from attestation rewards: balance={balance}, \ + min_activation_balance={}", + spec.min_activation_balance + ); - // Verify that the withdrawals computed from the block's state differ from the withdrawal's + let source_address = validator + .get_execution_withdrawal_address(&spec) + .expect("validator should have execution withdrawal address"); + + let consolidation_request = ConsolidationRequest { + source_address, + source_pubkey: validator.pubkey, + target_pubkey: validator.pubkey, + }; + + let execution_requests = ExecutionRequests:: { + deposits: VariableList::empty(), + withdrawals: VariableList::empty(), + consolidations: VariableList::new(vec![consolidation_request]) + .expect("should create consolidation requests list"), + }; + + // Inject the execution requests into the mock EL so the next payload includes them. + harness + .execution_block_generator() + .set_next_execution_requests(execution_requests); + + // Produce and import one more block. Its envelope will contain the consolidation request. + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Verify that the withdrawals computed from the block's state differ from the withdrawals // computed from the block's state with its payload applied by // `apply_parent_execution_payload`. - // TODO(claude): fill this in + let cached_head = harness.chain.canonical_head.cached_head(); + let pending_state = &cached_head.snapshot.beacon_state; + let envelope = cached_head + .snapshot + .execution_envelope + .as_ref() + .expect("head should have execution envelope (Full status)"); + let parent_bid = pending_state + .latest_execution_payload_bid() + .expect("should get latest bid"); + + // Withdrawals from the Pending state (without execution requests applied). + let withdrawals_pending: Withdrawals = get_expected_withdrawals(pending_state, &spec) + .expect("should get pending withdrawals") + .into(); + + // Withdrawals from the Full state (with execution requests applied). + let mut full_state = pending_state.clone(); + apply_parent_execution_payload( + &mut full_state, + parent_bid, + &envelope.message.execution_requests, + &spec, + ) + .expect("should apply parent execution payload"); + let withdrawals_full: Withdrawals = get_expected_withdrawals(&full_state, &spec) + .expect("should get full withdrawals") + .into(); + + assert_ne!( + withdrawals_pending, withdrawals_full, + "Applying execution requests should change the expected withdrawals" + ); // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution // layer payload attributes cache with the correct withdrawals (the ones taking into account // the applied execution_requests). - // TODO(claude): fill this in + let current_slot = harness.chain.slot().expect("should get slot"); + let prepare_slot = current_slot + 1; + let proposer_index = pending_state + .get_beacon_proposer_index(prepare_slot, &spec) + .expect("should get proposer index"); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .expect("prepare_beacon_proposer should succeed"); + + // Extract the payload attributes that were sent to the EL via forkchoiceUpdated. + let mock_el = harness + .mock_execution_layer + .as_ref() + .expect("should have mock execution layer"); + let previous_request = mock_el + .server + .take_previous_request() + .expect("should have a previous forkchoiceUpdated request"); + let params = previous_request + .get("params") + .expect("should have params field"); + let payload_attributes_json = params.get(1).expect("should have payload attributes param"); + + // The payload attributes should be V4 for Gloas. + let attributes: execution_layer::json_structures::JsonPayloadAttributesV4 = + serde_json::from_value(payload_attributes_json.clone()) + .expect("should deserialize V4 payload attributes"); + + let actual_withdrawals: Vec = + attributes.withdrawals.into_iter().map(Into::into).collect(); + let expected_withdrawals: Vec = withdrawals_full.to_vec(); + + assert_eq!( + actual_withdrawals, expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the Full state \ + (with execution requests applied)" + ); } diff --git a/beacon_node/execution_layer/src/engine_api/json_structures.rs b/beacon_node/execution_layer/src/engine_api/json_structures.rs index a77861981fb..25773a0a305 100644 --- a/beacon_node/execution_layer/src/engine_api/json_structures.rs +++ b/beacon_node/execution_layer/src/engine_api/json_structures.rs @@ -1,7 +1,7 @@ use super::*; use alloy_rlp::RlpEncodable; use serde::{Deserialize, Serialize}; -use ssz::{Decode, TryFromIter}; +use ssz::{Decode, Encode, TryFromIter}; use ssz_types::{FixedVector, VariableList, typenum::Unsigned}; use strum::EnumString; use superstruct::superstruct; @@ -481,6 +481,34 @@ pub enum RequestsError { #[serde(transparent)] pub struct JsonExecutionRequests(pub Vec); +impl From> for JsonExecutionRequests { + fn from(requests: ExecutionRequests) -> Self { + let mut result = Vec::new(); + if !requests.deposits.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Deposit.to_u8(), + hex::encode(requests.deposits.as_ssz_bytes()) + )); + } + if !requests.withdrawals.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Withdrawal.to_u8(), + hex::encode(requests.withdrawals.as_ssz_bytes()) + )); + } + if !requests.consolidations.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Consolidation.to_u8(), + hex::encode(requests.consolidations.as_ssz_bytes()) + )); + } + JsonExecutionRequests(result) + } +} + impl TryFrom for ExecutionRequests { type Error = RequestsError; diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index ace6276b756..16d8c03062e 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -26,8 +26,8 @@ use tree_hash_derive::TreeHash; use types::{ Blob, ChainSpec, EthSpec, ExecutionBlockHash, ExecutionPayload, ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadDeneb, ExecutionPayloadElectra, ExecutionPayloadFulu, - ExecutionPayloadGloas, ExecutionPayloadHeader, ForkName, Hash256, KzgProofs, Transaction, - Transactions, Uint256, + ExecutionPayloadGloas, ExecutionPayloadHeader, ExecutionRequests, ForkName, Hash256, KzgProofs, + Transaction, Transactions, Uint256, }; const TEST_BLOB_BUNDLE: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle.ssz"); @@ -161,6 +161,14 @@ pub struct ExecutionBlockGenerator { pub blobs_bundles: HashMap>, pub kzg: Option>, rng: Arc>, + /* + * Execution requests (electra+) + */ + /// Per-payload execution requests returned by `getPayload`. + execution_requests: HashMap>, + /// If set, the next call to `build_new_execution_payload` will associate these + /// execution requests with the generated payload ID. + next_execution_requests: Option>, } fn make_rng() -> Arc> { @@ -199,6 +207,8 @@ impl ExecutionBlockGenerator { blobs_bundles: <_>::default(), kzg, rng: make_rng(), + execution_requests: <_>::default(), + next_execution_requests: None, }; generator.insert_pow_block(0).unwrap(); @@ -458,6 +468,15 @@ impl ExecutionBlockGenerator { self.blobs_bundles.get(id).cloned() } + pub fn get_execution_requests(&self, id: &PayloadId) -> Option> { + self.execution_requests.get(id).cloned() + } + + /// Set execution requests to be returned alongside the next generated payload. + pub fn set_next_execution_requests(&mut self, requests: ExecutionRequests) { + self.next_execution_requests = Some(requests); + } + /// Look up a blob and proof by versioned hash across all stored bundles. pub fn get_blob_and_proof(&self, versioned_hash: &Hash256) -> Option> { self.blobs_bundles @@ -763,6 +782,11 @@ impl ExecutionBlockGenerator { }, }; + // Store execution requests for this payload if configured. + if let Some(requests) = self.next_execution_requests.take() { + self.execution_requests.insert(id, requests); + } + let fork_name = execution_payload.fork_name(); if fork_name.deneb_enabled() { // get random number between 0 and 1 blobs by default diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index 30542899964..64eecccc583 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -295,6 +295,10 @@ pub async fn handle_rpc( })?; let maybe_blobs = ctx.execution_block_generator.write().get_blobs_bundle(&id); + let maybe_execution_requests = ctx + .execution_block_generator + .read() + .get_execution_requests(&id); // validate method called correctly according to shanghai fork time if ctx @@ -432,8 +436,10 @@ pub async fn handle_rpc( ))? .into(), should_override_builder: false, - // TODO(electra): add EL requests in mock el - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .clone() + .unwrap_or_default() + .into(), }) .unwrap() } @@ -453,7 +459,10 @@ pub async fn handle_rpc( ))? .into(), should_override_builder: false, - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .clone() + .unwrap_or_default() + .into(), }) .unwrap() } @@ -473,7 +482,9 @@ pub async fn handle_rpc( ))? .into(), should_override_builder: false, - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .unwrap_or_default() + .into(), }) .unwrap() } From 50d725f70e9e71dfff1a002e4a152d3e5ffbadf8 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 13:43:50 +1000 Subject: [PATCH 12/22] Cleaner (and more correct) implementation --- beacon_node/beacon_chain/src/beacon_chain.rs | 87 ++++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index b6a6e024aa1..98bdc799c31 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4706,17 +4706,21 @@ impl BeaconChain { proposal_slot: Slot, ) -> Result, Error> { let cached_head = self.canonical_head.cached_head(); - let head_payload_status = cached_head.head_payload_status(); + let head_block = &cached_head.snapshot.beacon_block; + let head_block_root = cached_head.head_block_root(); let head_state = &cached_head.snapshot.beacon_state; let parent_block_root = forkchoice_update_params.head_root; - let (unadvanced_state, unadvanced_state_root) = - if cached_head.head_block_root() == parent_block_root { - (Cow::Borrowed(head_state), cached_head.head_state_root()) + let (unadvanced_state, unadvanced_state_root, parent_block) = + if parent_block_root == head_block_root { + // TODO(gloas): could optimise out this clone_as_blinded + ( + Cow::Borrowed(head_state), + cached_head.head_state_root(), + Arc::new(head_block.clone_as_blinded()), + ) } else { - // TODO(gloas): this branch needs envelope-awareness for non-head parents - // See: https://github.com/sigp/lighthouse/issues/8957 let block = self .get_blinded_block(&parent_block_root)? .ok_or(Error::MissingBeaconBlock(parent_block_root))?; @@ -4724,50 +4728,19 @@ impl BeaconChain { .store .get_advanced_hot_state(parent_block_root, proposal_slot, block.state_root())? .ok_or(Error::MissingBeaconState(block.state_root()))?; - (Cow::Owned(state), state_root) + (Cow::Owned(state), state_root, Arc::new(block)) }; - // For Gloas, when the head payload is Full, we need to apply the parent's - // execution requests to the Pending state to get the correct withdrawals. - // The cached head state is always Pending (post-block, pre-envelope), but - // withdrawals must be computed from the Full state (post-envelope). - // TODO(gloas): this is Claude's wrong attempt, payload application should happen AFTER - // advancing the slot - let unadvanced_state = if head_payload_status == fork_choice::PayloadStatus::Full - && cached_head.head_block_root() == parent_block_root + let parent_payload_status = if parent_block.fork_name_unchecked().gloas_enabled() + && forkchoice_update_params.head_hash == Some(parent_block.payload_bid_block_hash()?) { - if let Some(envelope) = cached_head.snapshot.execution_envelope.as_ref() { - let parent_bid = unadvanced_state - .latest_execution_payload_bid() - .map_err(Error::BeaconStateError)? - .clone(); - let mut full_state = unadvanced_state.into_owned(); - apply_parent_execution_payload( - &mut full_state, - &parent_bid, - &envelope.message.execution_requests, - &self.spec, - ) - .map_err(Error::PrepareProposerFailed)?; - Cow::Owned(full_state) - } else { - unadvanced_state - } + fork_choice::PayloadStatus::Full } else { - unadvanced_state + fork_choice::PayloadStatus::Empty }; - // Parent state epoch is the same as the proposal, we don't need to advance because the - // list of expected withdrawals can only change after an epoch advance or a - // block application. - let proposal_epoch = proposal_slot.epoch(T::EthSpec::slots_per_epoch()); - if head_state.current_epoch() == proposal_epoch { - return get_expected_withdrawals(&unadvanced_state, &self.spec) - .map(Into::into) - .map_err(Error::PrepareProposerFailed); - } - // Advance the state using the partial method. + // TODO(gloas): re-optimise this for pre-Gloas (tweak slot to advance to) debug!( %proposal_slot, ?parent_block_root, @@ -4777,9 +4750,35 @@ impl BeaconChain { partial_state_advance( &mut advanced_state, Some(unadvanced_state_root), - proposal_epoch.start_slot(T::EthSpec::slots_per_epoch()), + proposal_slot, &self.spec, )?; + + // For Gloas, when the head payload is Full, we need to apply the parent's + // execution requests to the state to get the correct withdrawals. + if parent_payload_status == fork_choice::PayloadStatus::Full { + let envelope = if parent_block_root == head_block_root { + cached_head.snapshot.execution_envelope.clone() + } else { + self.store + .get_payload_envelope(&parent_block_root)? + .map(Arc::new) + } + .ok_or(Error::MissingExecutionPayloadEnvelope(parent_block_root))?; + + // TODO(gloas): could remove this in favour of reading it in + // apply_parent_execution_payload. Spec simplification. + let parent_bid = advanced_state.latest_execution_payload_bid()?.clone(); + + apply_parent_execution_payload( + &mut advanced_state, + &parent_bid, + &envelope.message.execution_requests, + &self.spec, + ) + .map_err(Error::PrepareProposerFailed)?; + } + get_expected_withdrawals(&advanced_state, &self.spec) .map(Into::into) .map_err(Error::PrepareProposerFailed) From e6e299097c49f976a9967956371079be5d04e832 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 15:46:37 +1000 Subject: [PATCH 13/22] Simplify --- beacon_node/beacon_chain/src/test_utils.rs | 30 +++++++++ .../beacon_chain/tests/prepare_payload.rs | 64 ++----------------- 2 files changed, 36 insertions(+), 58 deletions(-) diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index e84f9ad983b..3e0f34a31eb 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -770,6 +770,36 @@ where .execution_block_generator() } + /// Create a switch-to-compounding `ConsolidationRequest` for the given validator. + /// + /// Panics if the validator doesn't exist, doesn't have eth1 withdrawal credentials, + /// or doesn't have an execution withdrawal address. + pub fn make_switch_to_compounding_request( + &self, + validator_index: usize, + ) -> ConsolidationRequest { + let head = self.chain.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + let validator = head_state + .get_validator(validator_index) + .expect("validator should exist"); + + assert!( + validator.has_eth1_withdrawal_credential(&self.spec), + "validator {validator_index} should have eth1 withdrawal credentials" + ); + + let source_address = validator + .get_execution_withdrawal_address(&self.spec) + .expect("validator should have execution withdrawal address"); + + ConsolidationRequest { + source_address, + source_pubkey: validator.pubkey, + target_pubkey: validator.pubkey, + } + } + pub fn set_mock_builder( &mut self, beacon_url: SensitiveUrl, diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index f7aadbbd10d..4542e179d2b 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -22,9 +22,6 @@ use types::*; pub const LOW_VALIDATOR_COUNT: usize = 32; pub const HIGH_VALIDATOR_COUNT: usize = 64; -// When set to true, cache any states fetched from the db. -pub const CACHE_STATE_IN_TESTS: bool = true; - /// A cached set of keys. static KEYPAIRS: LazyLock> = LazyLock::new(|| types::test_utils::generate_deterministic_keypairs(HIGH_VALIDATOR_COUNT)); @@ -107,16 +104,7 @@ async fn prepare_payload_on_full_parent() { let num_blocks_produced = E::slots_per_epoch() * 3; let db_path = tempdir().unwrap(); let store = get_store(&db_path); - let chain_config = ChainConfig { - archive: true, - ..ChainConfig::default() - }; - let harness = get_harness_generic( - store.clone(), - LOW_VALIDATOR_COUNT, - chain_config, - NodeCustodyType::Fullnode, - ); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); harness .extend_chain( @@ -130,51 +118,11 @@ async fn prepare_payload_on_full_parent() { harness.advance_slot(); // Produce a block with a payload that affects withdrawals for the next slot. - // This requires injecting at least one valid and actionable consolidation request - // (switch-to-compounding) into the execution requests. A switch-to-compounding request - // changes a validator's withdrawal credentials from 0x01 (eth1) to 0x02 (compounding), - // which reduces their balance to min_activation_balance and queues the excess as a - // pending deposit. This removes the validator from the partial withdrawal sweep. - // - // We target an odd-indexed validator since odd validators are created with eth1 withdrawal - // credentials in the interop genesis builder. - let target_validator_index = 1_usize; - let head_state = &harness - .chain - .canonical_head - .cached_head() - .snapshot - .beacon_state; - let validator = head_state - .get_validator(target_validator_index) - .expect("validator should exist"); - - // Sanity check: the validator has eth1 withdrawal credentials (0x01 prefix). - assert!( - validator.has_eth1_withdrawal_credential(&spec), - "validator {target_validator_index} should have eth1 withdrawal credentials" - ); - // Sanity check: the validator is partially withdrawable (has excess balance from - // attestation rewards after 1 epoch). - let balance = head_state - .get_balance(target_validator_index) - .expect("should get balance"); - assert!( - balance > spec.min_activation_balance, - "validator should have excess balance from attestation rewards: balance={balance}, \ - min_activation_balance={}", - spec.min_activation_balance - ); - - let source_address = validator - .get_execution_withdrawal_address(&spec) - .expect("validator should have execution withdrawal address"); - - let consolidation_request = ConsolidationRequest { - source_address, - source_pubkey: validator.pubkey, - target_pubkey: validator.pubkey, - }; + // A switch-to-compounding consolidation changes withdrawal credentials from 0x01 to 0x02, + // which queues the validator's excess balance as a pending deposit and removes it from the + // partial withdrawal sweep. We target an odd-indexed validator since odd validators are + // created with eth1 withdrawal credentials in the interop genesis builder. + let consolidation_request = harness.make_switch_to_compounding_request(1); let execution_requests = ExecutionRequests:: { deposits: VariableList::empty(), From eb24b8607af578fb506c34082135f26e962bef2d Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 16:08:34 +1000 Subject: [PATCH 14/22] Comment simplifications --- beacon_node/beacon_chain/tests/prepare_payload.rs | 3 +-- beacon_node/execution_layer/src/lib.rs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 4542e179d2b..d01dce3255d 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -127,8 +127,7 @@ async fn prepare_payload_on_full_parent() { let execution_requests = ExecutionRequests:: { deposits: VariableList::empty(), withdrawals: VariableList::empty(), - consolidations: VariableList::new(vec![consolidation_request]) - .expect("should create consolidation requests list"), + consolidations: VariableList::new(vec![consolidation_request]).unwrap(), }; // Inject the execution requests into the mock EL so the next payload includes them. diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index ebaf74d7316..c9423a5357a 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -1488,8 +1488,8 @@ impl ExecutionLayer { } /// If there has been a proposer registered via `Self::insert_proposer` with a matching `slot` - /// `head_block_root`, then return the appropriate `PayloadAttributes` for inclusion in - /// `forkchoiceUpdated` calls. + /// `head_block_root`, and `head_payload_status` then return the appropriate `PayloadAttributes` + /// for inclusion in `forkchoiceUpdated` calls. pub async fn payload_attributes( &self, current_slot: Slot, From 66809290571f783ed393beed6fbb0d81e7b00d9a Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 16:17:04 +1000 Subject: [PATCH 15/22] Simplify a little more --- .../beacon_chain/tests/prepare_payload.rs | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index d01dce3255d..34bc73474ba 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -213,31 +213,20 @@ async fn prepare_payload_on_full_parent() { .await .expect("prepare_beacon_proposer should succeed"); - // Extract the payload attributes that were sent to the EL via forkchoiceUpdated. - let mock_el = harness - .mock_execution_layer - .as_ref() - .expect("should have mock execution layer"); - let previous_request = mock_el - .server - .take_previous_request() - .expect("should have a previous forkchoiceUpdated request"); - let params = previous_request - .get("params") - .expect("should have params field"); - let payload_attributes_json = params.get(1).expect("should have payload attributes param"); - - // The payload attributes should be V4 for Gloas. - let attributes: execution_layer::json_structures::JsonPayloadAttributesV4 = - serde_json::from_value(payload_attributes_json.clone()) - .expect("should deserialize V4 payload attributes"); - - let actual_withdrawals: Vec = - attributes.withdrawals.into_iter().map(Into::into).collect(); + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let head_payload_status = fork_choice::PayloadStatus::Full; + let attributes = el + .payload_attributes(prepare_slot, head_root, head_payload_status) + .await + .expect("should have cached payload attributes for prepare_slot"); + + let actual_withdrawals = attributes.withdrawals().unwrap(); let expected_withdrawals: Vec = withdrawals_full.to_vec(); assert_eq!( - actual_withdrawals, expected_withdrawals, + actual_withdrawals, &expected_withdrawals, "prepare_beacon_proposer should use withdrawals computed from the Full state \ (with execution requests applied)" ); From 3be778e46dfa8ff07b5923f6763903365cf0ff57 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 16:48:42 +1000 Subject: [PATCH 16/22] Add test for empty parent payload --- .../beacon_chain/tests/prepare_payload.rs | 65 +++++++++++++------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 34bc73474ba..dea82e7e97c 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -7,6 +7,7 @@ use beacon_chain::test_utils::{ use beacon_chain::{ChainConfig, custody_context::NodeCustodyType}; use bls::Keypair; use eth2::types::ProposerPreparationData; +use fork_choice::PayloadStatus; use logging::create_test_tracing_subscriber; use ssz_types::VariableList; use state_processing::per_block_processing::{ @@ -95,6 +96,15 @@ fn get_harness_generic( #[tokio::test] async fn prepare_payload_on_full_parent() { + prepare_payload_generic(PayloadStatus::Full).await; +} + +#[tokio::test] +async fn prepare_payload_on_empty_parent() { + prepare_payload_generic(PayloadStatus::Empty).await; +} + +async fn prepare_payload_generic(parent_payload_status: PayloadStatus) { // Post-Gloas test. let spec = test_spec::(); if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { @@ -102,6 +112,7 @@ async fn prepare_payload_on_full_parent() { } let num_blocks_produced = E::slots_per_epoch() * 3; + let parent_slot = Slot::new(num_blocks_produced) + 1; let db_path = tempdir().unwrap(); let store = get_store(&db_path); let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); @@ -136,30 +147,43 @@ async fn prepare_payload_on_full_parent() { .set_next_execution_requests(execution_requests); // Produce and import one more block. Its envelope will contain the consolidation request. - harness - .extend_chain( - 1, - BlockStrategy::OnCanonicalHead, - AttestationStrategy::AllValidators, + // TODO(gloas): all this ugly plumbing could be avoided with some more "implicit" context + // methods + let state = harness.get_current_state(); + let (block_contents, opt_envelope, parent_block_state) = + harness.make_block_with_envelope(state, parent_slot).await; + let envelope = opt_envelope.unwrap(); + let block_root = harness + .process_block( + parent_slot, + block_contents.0.canonical_root(), + block_contents.clone(), ) - .await; + .await + .unwrap(); + + if parent_payload_status == PayloadStatus::Full { + harness + .process_envelope( + block_root.into(), + envelope.clone(), + &parent_block_state, + block_contents.0.state_root(), + ) + .await; + } // Verify that the withdrawals computed from the block's state differ from the withdrawals // computed from the block's state with its payload applied by // `apply_parent_execution_payload`. let cached_head = harness.chain.canonical_head.cached_head(); let pending_state = &cached_head.snapshot.beacon_state; - let envelope = cached_head - .snapshot - .execution_envelope - .as_ref() - .expect("head should have execution envelope (Full status)"); let parent_bid = pending_state .latest_execution_payload_bid() .expect("should get latest bid"); - // Withdrawals from the Pending state (without execution requests applied). - let withdrawals_pending: Withdrawals = get_expected_withdrawals(pending_state, &spec) + // Withdrawals from the empty state (without execution requests applied). + let withdrawals_empty: Withdrawals = get_expected_withdrawals(pending_state, &spec) .expect("should get pending withdrawals") .into(); @@ -177,7 +201,7 @@ async fn prepare_payload_on_full_parent() { .into(); assert_ne!( - withdrawals_pending, withdrawals_full, + withdrawals_empty, withdrawals_full, "Applying execution requests should change the expected withdrawals" ); @@ -216,18 +240,21 @@ async fn prepare_payload_on_full_parent() { // Read the payload attributes from the EL cache and verify the withdrawals. let el = harness.chain.execution_layer.as_ref().unwrap(); let head_root = harness.head_block_root(); - let head_payload_status = fork_choice::PayloadStatus::Full; let attributes = el - .payload_attributes(prepare_slot, head_root, head_payload_status) + .payload_attributes(prepare_slot, head_root, parent_payload_status) .await .expect("should have cached payload attributes for prepare_slot"); let actual_withdrawals = attributes.withdrawals().unwrap(); - let expected_withdrawals: Vec = withdrawals_full.to_vec(); + let expected_withdrawals: Vec = if parent_payload_status == PayloadStatus::Full { + withdrawals_full.to_vec() + } else { + withdrawals_empty.to_vec() + }; assert_eq!( actual_withdrawals, &expected_withdrawals, - "prepare_beacon_proposer should use withdrawals computed from the Full state \ - (with execution requests applied)" + "prepare_beacon_proposer should use withdrawals computed from the \ + {parent_payload_status:?} state" ); } From 5d14f083ba9fc4d011b3dc2deee153962b9722af Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 23 Apr 2026 11:08:17 +1000 Subject: [PATCH 17/22] New tests for epoch advance --- .../beacon_chain/tests/prepare_payload.rs | 147 ++++++++++++++---- 1 file changed, 115 insertions(+), 32 deletions(-) diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index dea82e7e97c..42f84b4489c 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -10,8 +10,9 @@ use eth2::types::ProposerPreparationData; use fork_choice::PayloadStatus; use logging::create_test_tracing_subscriber; use ssz_types::VariableList; -use state_processing::per_block_processing::{ - apply_parent_execution_payload, withdrawals::get_expected_withdrawals, +use state_processing::{ + per_block_processing::{apply_parent_execution_payload, withdrawals::get_expected_withdrawals}, + state_advance::complete_state_advance, }; use std::sync::{Arc, LazyLock}; use store::database::interface::BeaconNodeBackend; @@ -95,24 +96,69 @@ fn get_harness_generic( } #[tokio::test] -async fn prepare_payload_on_full_parent() { - prepare_payload_generic(PayloadStatus::Full).await; +async fn prepare_payload_on_full_parent_next_slot() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(3 * E::slots_per_epoch() + 2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_full_parent_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(4 * E::slots_per_epoch()), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_full_parent_uneven_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(5 * E::slots_per_epoch() - 1), + ) + .await; } #[tokio::test] -async fn prepare_payload_on_empty_parent() { - prepare_payload_generic(PayloadStatus::Empty).await; +async fn prepare_payload_on_empty_parent_next_slot() { + prepare_payload_generic( + PayloadStatus::Empty, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(3 * E::slots_per_epoch() + 2), + ) + .await; } -async fn prepare_payload_generic(parent_payload_status: PayloadStatus) { +#[tokio::test] +async fn prepare_payload_on_empty_parent_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Empty, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(4 * E::slots_per_epoch()), + ) + .await; +} + +async fn prepare_payload_generic( + parent_payload_status: PayloadStatus, + parent_block_slot: Slot, + prepare_slot: Slot, +) { + assert!(parent_block_slot > 0); + // Post-Gloas test. let spec = test_spec::(); if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { return; } - let num_blocks_produced = E::slots_per_epoch() * 3; - let parent_slot = Slot::new(num_blocks_produced) + 1; + let num_blocks_produced = parent_block_slot.as_u64() - 1; let db_path = tempdir().unwrap(); let store = get_store(&db_path); let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); @@ -150,18 +196,20 @@ async fn prepare_payload_generic(parent_payload_status: PayloadStatus) { // TODO(gloas): all this ugly plumbing could be avoided with some more "implicit" context // methods let state = harness.get_current_state(); - let (block_contents, opt_envelope, parent_block_state) = - harness.make_block_with_envelope(state, parent_slot).await; + let (block_contents, opt_envelope, parent_block_state) = harness + .make_block_with_envelope(state, parent_block_slot) + .await; let envelope = opt_envelope.unwrap(); let block_root = harness .process_block( - parent_slot, + parent_block_slot, block_contents.0.canonical_root(), block_contents.clone(), ) .await .unwrap(); + // TODO(gloas): try a case where head is empty even though envelope is processed if parent_payload_status == PayloadStatus::Full { harness .process_envelope( @@ -177,40 +225,75 @@ async fn prepare_payload_generic(parent_payload_status: PayloadStatus) { // computed from the block's state with its payload applied by // `apply_parent_execution_payload`. let cached_head = harness.chain.canonical_head.cached_head(); - let pending_state = &cached_head.snapshot.beacon_state; - let parent_bid = pending_state + let unadvanced_empty_state = &cached_head.snapshot.beacon_state; + let parent_bid = unadvanced_empty_state .latest_execution_payload_bid() - .expect("should get latest bid"); + .unwrap(); - // Withdrawals from the empty state (without execution requests applied). - let withdrawals_empty: Withdrawals = get_expected_withdrawals(pending_state, &spec) - .expect("should get pending withdrawals") - .into(); + let mut advanced_empty_state = unadvanced_empty_state.clone(); + complete_state_advance(&mut advanced_empty_state, None, prepare_slot, &spec).unwrap(); - // Withdrawals from the Full state (with execution requests applied). - let mut full_state = pending_state.clone(); + let mut unadvanced_full_state = unadvanced_empty_state.clone(); apply_parent_execution_payload( - &mut full_state, + &mut unadvanced_full_state, parent_bid, &envelope.message.execution_requests, &spec, ) - .expect("should apply parent execution payload"); - let withdrawals_full: Withdrawals = get_expected_withdrawals(&full_state, &spec) - .expect("should get full withdrawals") - .into(); + .unwrap(); + + let mut advanced_full_state = advanced_empty_state.clone(); + apply_parent_execution_payload( + &mut advanced_full_state, + parent_bid, + &envelope.message.execution_requests, + &spec, + ) + .unwrap(); + + let withdrawals_unadvanced_empty: Withdrawals = + get_expected_withdrawals(&unadvanced_empty_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced_empty: Withdrawals = + get_expected_withdrawals(&advanced_empty_state, &spec) + .unwrap() + .into(); + let withdrawals_unadvanced_full: Withdrawals = + get_expected_withdrawals(&unadvanced_full_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced_full: Withdrawals = + get_expected_withdrawals(&advanced_full_state, &spec) + .unwrap() + .into(); assert_ne!( - withdrawals_empty, withdrawals_full, + withdrawals_advanced_empty, withdrawals_advanced_full, "Applying execution requests should change the expected withdrawals" ); + let expect_state_advance_to_change_withdrawals = + prepare_slot.epoch(E::slots_per_epoch()) > parent_block_slot.epoch(E::slots_per_epoch()); + if expect_state_advance_to_change_withdrawals { + if parent_payload_status == fork_choice::PayloadStatus::Full { + assert_ne!( + withdrawals_unadvanced_full, withdrawals_advanced_full, + "Advancing the state should change the withdrawals" + ); + } else { + assert_ne!( + withdrawals_unadvanced_empty, withdrawals_advanced_empty, + "Advancing the state should change the withdrawals" + ); + } + } + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution // layer payload attributes cache with the correct withdrawals (the ones taking into account // the applied execution_requests). - let current_slot = harness.chain.slot().expect("should get slot"); - let prepare_slot = current_slot + 1; - let proposer_index = pending_state + let current_slot = prepare_slot - 1; + let proposer_index = advanced_empty_state .get_beacon_proposer_index(prepare_slot, &spec) .expect("should get proposer index"); @@ -247,9 +330,9 @@ async fn prepare_payload_generic(parent_payload_status: PayloadStatus) { let actual_withdrawals = attributes.withdrawals().unwrap(); let expected_withdrawals: Vec = if parent_payload_status == PayloadStatus::Full { - withdrawals_full.to_vec() + withdrawals_advanced_full.to_vec() } else { - withdrawals_empty.to_vec() + withdrawals_advanced_empty.to_vec() }; assert_eq!( From 56b311ee1e603519bdce15c671f48062af16db9a Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 23 Apr 2026 11:23:48 +1000 Subject: [PATCH 18/22] Genesis payload attributes test (failing) --- .../beacon_chain/tests/prepare_payload.rs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 42f84b4489c..3079d03aa78 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -341,3 +341,95 @@ async fn prepare_payload_generic( {parent_payload_status:?} state" ); } + +#[tokio::test] +async fn prepare_payload_on_genesis_next_slot() { + prepare_payload_on_genesis_generic(Slot::new(1)).await; +} + +async fn prepare_payload_on_genesis_generic(prepare_slot: Slot) { + // Post-Gloas test. + let spec = test_spec::(); + if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { + return; + } + + // Genesis is always considered Empty. + let parent_payload_status = PayloadStatus::Empty; + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + // Verify that the withdrawals computed from the block's state differ from the withdrawals + // computed from the block's state with its payload applied by + // `apply_parent_execution_payload`. + let cached_head = harness.chain.canonical_head.cached_head(); + let unadvanced_state = &cached_head.snapshot.beacon_state; + + let mut advanced_state = unadvanced_state.clone(); + complete_state_advance(&mut advanced_state, None, prepare_slot, &spec).unwrap(); + + let withdrawals_unadvanced: Withdrawals = get_expected_withdrawals(&unadvanced_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced: Withdrawals = get_expected_withdrawals(&advanced_state, &spec) + .unwrap() + .into(); + + let expect_state_advance_to_change_withdrawals = prepare_slot.epoch(E::slots_per_epoch()) > 0; + if expect_state_advance_to_change_withdrawals { + assert_ne!( + withdrawals_unadvanced, withdrawals_advanced, + "Advancing the state should change the withdrawals" + ); + } + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the applied execution_requests). + let current_slot = prepare_slot - 1; + let proposer_index = advanced_state + .get_beacon_proposer_index(prepare_slot, &spec) + .unwrap(); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let attributes = el + .payload_attributes(prepare_slot, head_root, parent_payload_status) + .await + .unwrap(); + + let actual_withdrawals = attributes.withdrawals().unwrap(); + let expected_withdrawals: Vec = withdrawals_advanced.to_vec(); + + assert_eq!( + actual_withdrawals, &expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the \ + {parent_payload_status:?} genesis state" + ); +} From e9e1e44a83c95321bf44424955c1f39ceb18c9d8 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 23 Apr 2026 11:40:45 +1000 Subject: [PATCH 19/22] Fix Gloas genesis in get_expected_withdrawals --- beacon_node/beacon_chain/src/beacon_chain.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 98bdc799c31..b0b884420b2 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4732,6 +4732,7 @@ impl BeaconChain { }; let parent_payload_status = if parent_block.fork_name_unchecked().gloas_enabled() + && parent_block.payload_bid_block_hash()? != ExecutionBlockHash::default() && forkchoice_update_params.head_hash == Some(parent_block.payload_bid_block_hash()?) { fork_choice::PayloadStatus::Full From d92380479f3ddeec563acadc09377c18efbe8b56 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 23 Apr 2026 14:59:22 +1000 Subject: [PATCH 20/22] Fork boundary test --- .../beacon_chain/tests/prepare_payload.rs | 156 +++++++++++++++++- 1 file changed, 148 insertions(+), 8 deletions(-) diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 3079d03aa78..d258667db22 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -31,18 +31,21 @@ static KEYPAIRS: LazyLock> = type E = MinimalEthSpec; type TestHarness = BeaconChainHarness>; -fn get_store(db_path: &TempDir) -> Arc, BeaconNodeBackend>> { +fn get_store( + db_path: &TempDir, + spec: Arc, +) -> Arc, BeaconNodeBackend>> { let store_config = StoreConfig { prune_payloads: false, ..StoreConfig::default() }; - get_store_generic(db_path, store_config, test_spec::()) + get_store_generic(db_path, store_config, spec) } fn get_store_generic( db_path: &TempDir, config: StoreConfig, - spec: ChainSpec, + spec: Arc, ) -> Arc, BeaconNodeBackend>> { create_test_tracing_subscriber(); let hot_path = db_path.path().join("chain_db"); @@ -153,14 +156,14 @@ async fn prepare_payload_generic( assert!(parent_block_slot > 0); // Post-Gloas test. - let spec = test_spec::(); + let spec = Arc::new(test_spec::()); if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { return; } let num_blocks_produced = parent_block_slot.as_u64() - 1; let db_path = tempdir().unwrap(); - let store = get_store(&db_path); + let store = get_store(&db_path, spec.clone()); let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); harness @@ -347,9 +350,14 @@ async fn prepare_payload_on_genesis_next_slot() { prepare_payload_on_genesis_generic(Slot::new(1)).await; } +#[tokio::test] +async fn prepare_payload_on_genesis_skip_two_epochs() { + prepare_payload_on_genesis_generic(Slot::new(2 * E::slots_per_epoch())).await; +} + async fn prepare_payload_on_genesis_generic(prepare_slot: Slot) { // Post-Gloas test. - let spec = test_spec::(); + let spec = Arc::new(test_spec::()); if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { return; } @@ -358,9 +366,141 @@ async fn prepare_payload_on_genesis_generic(prepare_slot: Slot) { let parent_payload_status = PayloadStatus::Empty; let db_path = tempdir().unwrap(); - let store = get_store(&db_path); + let store = get_store(&db_path, spec.clone()); let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + // At genesis withdrawals are empty (because nothing has happened yet), so we don't assert + // anything about the advanced vs unadvanced state. This test just exists to test that + // calculating payload attributes at genesis works and doesn't error. + let cached_head = harness.chain.canonical_head.cached_head(); + let unadvanced_state = &cached_head.snapshot.beacon_state; + + let mut advanced_state = unadvanced_state.clone(); + complete_state_advance(&mut advanced_state, None, prepare_slot, &spec).unwrap(); + + let withdrawals_advanced: Withdrawals = get_expected_withdrawals(&advanced_state, &spec) + .unwrap() + .into(); + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the state advance). + let current_slot = prepare_slot - 1; + let proposer_index = advanced_state + .get_beacon_proposer_index(prepare_slot, &spec) + .unwrap(); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let attributes = el + .payload_attributes(prepare_slot, head_root, parent_payload_status) + .await + .unwrap(); + + let actual_withdrawals = attributes.withdrawals().unwrap(); + let expected_withdrawals: Vec = withdrawals_advanced.to_vec(); + + assert_eq!( + actual_withdrawals, &expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the \ + {parent_payload_status:?} advanced genesis state" + ); + assert!(actual_withdrawals.is_empty()); +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_no_skip() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 1, + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_one_prior() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 2, + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_one_after() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 1, + Slot::new(2 * E::slots_per_epoch()) + 1, + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_whole_epoch() { + prepare_payload_on_fork_boundary( + Slot::new(E::slots_per_epoch()), + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +async fn prepare_payload_on_fork_boundary( + parent_block_slot: Slot, + prepare_slot: Slot, + gloas_fork_epoch: Epoch, +) { + // Post-Gloas test. + let mut spec = test_spec::(); + if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { + return; + } + spec.gloas_fork_epoch = Some(gloas_fork_epoch); + let spec = Arc::new(spec); + + // Pre-Gloas blocks are always considered Empty. + let parent_payload_status = PayloadStatus::Empty; + + let num_blocks_produced = parent_block_slot.as_u64(); + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + harness + .extend_chain( + num_blocks_produced as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + // Verify that the withdrawals computed from the block's state differ from the withdrawals // computed from the block's state with its payload applied by // `apply_parent_execution_payload`. @@ -430,6 +570,6 @@ async fn prepare_payload_on_genesis_generic(prepare_slot: Slot) { assert_eq!( actual_withdrawals, &expected_withdrawals, "prepare_beacon_proposer should use withdrawals computed from the \ - {parent_payload_status:?} genesis state" + advanced state" ); } From 451d2f252930576dca5abb5fd7b84cb843244529 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 23 Apr 2026 20:38:59 +1000 Subject: [PATCH 21/22] Clippy --- beacon_node/beacon_chain/tests/prepare_payload.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index d258667db22..dc4f999eb2b 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -58,7 +58,7 @@ fn get_store_generic( &blobs_path, |_, _, _| Ok(()), config, - spec.into(), + spec, ) .expect("disk store should initialize") } @@ -255,7 +255,7 @@ async fn prepare_payload_generic( .unwrap(); let withdrawals_unadvanced_empty: Withdrawals = - get_expected_withdrawals(&unadvanced_empty_state, &spec) + get_expected_withdrawals(unadvanced_empty_state, &spec) .unwrap() .into(); let withdrawals_advanced_empty: Withdrawals = @@ -510,7 +510,7 @@ async fn prepare_payload_on_fork_boundary( let mut advanced_state = unadvanced_state.clone(); complete_state_advance(&mut advanced_state, None, prepare_slot, &spec).unwrap(); - let withdrawals_unadvanced: Withdrawals = get_expected_withdrawals(&unadvanced_state, &spec) + let withdrawals_unadvanced: Withdrawals = get_expected_withdrawals(unadvanced_state, &spec) .unwrap() .into(); let withdrawals_advanced: Withdrawals = get_expected_withdrawals(&advanced_state, &spec) From 8b733554ceb9a3baae63eaaae667b4cfb3d33759 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 23 Apr 2026 21:04:09 +1000 Subject: [PATCH 22/22] Resolve/clarify remaining TODOs --- beacon_node/beacon_chain/src/beacon_chain.rs | 24 ++++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index b0b884420b2..7844453a089 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4712,13 +4712,12 @@ impl BeaconChain { let parent_block_root = forkchoice_update_params.head_root; - let (unadvanced_state, unadvanced_state_root, parent_block) = + let (unadvanced_state, unadvanced_state_root, parent_bid_block_hash) = if parent_block_root == head_block_root { - // TODO(gloas): could optimise out this clone_as_blinded ( Cow::Borrowed(head_state), cached_head.head_state_root(), - Arc::new(head_block.clone_as_blinded()), + head_block.payload_bid_block_hash().ok(), ) } else { let block = self @@ -4728,12 +4727,16 @@ impl BeaconChain { .store .get_advanced_hot_state(parent_block_root, proposal_slot, block.state_root())? .ok_or(Error::MissingBeaconState(block.state_root()))?; - (Cow::Owned(state), state_root, Arc::new(block)) + ( + Cow::Owned(state), + state_root, + block.payload_bid_block_hash().ok(), + ) }; - let parent_payload_status = if parent_block.fork_name_unchecked().gloas_enabled() - && parent_block.payload_bid_block_hash()? != ExecutionBlockHash::default() - && forkchoice_update_params.head_hash == Some(parent_block.payload_bid_block_hash()?) + let parent_payload_status = if let Some(block_hash) = parent_bid_block_hash + && block_hash != ExecutionBlockHash::default() + && forkchoice_update_params.head_hash == Some(block_hash) { fork_choice::PayloadStatus::Full } else { @@ -4741,7 +4744,10 @@ impl BeaconChain { }; // Advance the state using the partial method. - // TODO(gloas): re-optimise this for pre-Gloas (tweak slot to advance to) + // TODO(gloas): we might want to optimise this further by using: + // - `get_advanced_hot_state` instead of the cached head + // - restoring the pre-Gloas optimisation to avoid advancing further than the epoch + // boundary debug!( %proposal_slot, ?parent_block_root, @@ -4767,8 +4773,6 @@ impl BeaconChain { } .ok_or(Error::MissingExecutionPayloadEnvelope(parent_block_root))?; - // TODO(gloas): could remove this in favour of reading it in - // apply_parent_execution_payload. Spec simplification. let parent_bid = advanced_state.latest_execution_payload_bid()?.clone(); apply_parent_execution_payload(