From cf39f649a780d4039e4b3f4139a4ba2b2b40eec2 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 2 Jul 2026 09:50:08 +0300 Subject: [PATCH 1/8] stateless endpoint impl --- .../src/block_production/gloas.rs | 29 ++- .../beacon_chain/src/block_production/mod.rs | 2 + beacon_node/beacon_chain/src/lib.rs | 1 + beacon_node/beacon_chain/src/test_utils.rs | 2 +- .../beacon_chain/tests/prepare_payload.rs | 2 +- beacon_node/http_api/src/produce_block.rs | 93 ++++++--- .../http_api/tests/gloas_reorg_tests.rs | 4 +- beacon_node/http_api/tests/tests.rs | 192 +++++++++++++++--- common/eth2/src/lib.rs | 95 +++++---- common/eth2/src/types.rs | 89 ++++++++ .../validator_services/src/block_service.rs | 8 +- 11 files changed, 414 insertions(+), 103 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 90fc60524a7..6e5abf4a74b 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -29,10 +29,10 @@ use tree_hash::TreeHash; use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ Address, Attestation, AttestationElectra, AttesterSlashing, AttesterSlashingElectra, - BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, + BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, BlobsList, BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, FullPayload, Graffiti, - Hash256, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, + Hash256, KzgProofs, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, SyncAggregate, Withdrawal, Withdrawals, }; @@ -48,7 +48,15 @@ pub const BID_VALUE_SELF_BUILD: u64 = 0; pub const EXECUTION_PAYMENT_TRUSTLESS_BUILD: u64 = 0; type ConsensusBlockValue = u64; -type BlockProductionResult = (BeaconBlock, BeaconState, ConsensusBlockValue); + +pub type PayloadEnvelopeContents = (ExecutionPayloadEnvelope, KzgProofs, BlobsList); + +type BlockProductionResult = ( + BeaconBlock, + BeaconState, + ConsensusBlockValue, + Option>, +); pub type PreparePayloadResult = Result, BlockProductionError>; pub type PreparePayloadHandle = JoinHandle>>; @@ -653,7 +661,7 @@ impl BeaconChain { // Construct and cache the ExecutionPayloadEnvelope if we have payload data. // For local building, we always have payload data. // For trustless building, the builder will provide the envelope separately. - if let Some(payload_data) = payload_data { + let payload_contents = if let Some(payload_data) = payload_data { let beacon_block_root = block.tree_hash_root(); let parent_beacon_block_root = block.parent_root(); let execution_payload_envelope = ExecutionPayloadEnvelope { @@ -683,12 +691,12 @@ impl BeaconChain { let envelope_slot = payload_data.slot; // TODO(gloas) might be safer to cache by root instead of by slot. // We should revisit this once this code path + beacon api spec matures - let (blobs, _) = payload_data.blobs_and_proofs; + let (blobs, kzg_proofs) = payload_data.blobs_and_proofs; self.pending_payload_envelopes.write().insert( envelope_slot, PendingEnvelopeData { - envelope: signed_envelope.message, - blobs: Some(blobs), + envelope: signed_envelope.message.clone(), + blobs: Some(blobs.clone()), }, ); @@ -697,7 +705,10 @@ impl BeaconChain { slot = %envelope_slot, "Cached pending execution payload envelope" ); - } + Some((signed_envelope.message, kzg_proofs, blobs)) + } else { + None + }; metrics::inc_counter(&metrics::BLOCK_PRODUCTION_SUCCESSES); @@ -708,7 +719,7 @@ impl BeaconChain { "Produced beacon block" ); - Ok((block, state, consensus_block_value)) + Ok((block, state, consensus_block_value, payload_contents)) } /// Produce a self-build `ExecutionPayloadBid` for some `slot` upon the given `state`. diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 1f29a47f698..74e39b09654 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -13,6 +13,8 @@ use crate::{ mod gloas; +pub use gloas::PayloadEnvelopeContents; + /// State loaded from the database for block production. pub(crate) struct BlockProductionState { pub state: BeaconState, diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 9795d360cac..83a08ac77bc 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -75,6 +75,7 @@ pub use self::beacon_chain::{ ProduceBlockVerification, StateSkipConfig, WhenSlotSkipped, }; pub use self::beacon_snapshot::BeaconSnapshot; +pub use self::block_production::PayloadEnvelopeContents; pub use self::chain_config::ChainConfig; pub use self::errors::{BeaconChainError, BlockProductionError}; pub use self::historical_blocks::HistoricalBlockError; diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index ce51a82cec9..7a96dd50881 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1259,7 +1259,7 @@ where None }; - let (block, post_block_state, _consensus_block_value) = self + let (block, post_block_state, _consensus_block_value, _payload_contents) = self .chain .produce_block_on_state_gloas( state, diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index de8bfb3865f..fcb09bbf552 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -621,7 +621,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { Some(GraffitiPolicy::PreserveUserGraffiti), ); - let (_block, _post_state, _value) = harness + let (_block, _post_state, _value, _payload_contents) = harness .chain .produce_block_on_state_gloas( state, diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index ed1ecb94561..28a9977b661 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -9,10 +9,14 @@ use crate::{ }; use beacon_chain::graffiti_calculator::GraffitiSettings; use beacon_chain::{ - BeaconBlockResponseWrapper, BeaconChain, BeaconChainTypes, ProduceBlockVerification, + BeaconBlockResponseWrapper, BeaconChain, BeaconChainTypes, PayloadEnvelopeContents, + ProduceBlockVerification, }; use eth2::types::{self as api_types, ProduceBlockV3Metadata, SkipRandaoVerification}; -use eth2::{beacon_response::ForkVersionedResponse, types::ProduceBlockV4Metadata}; +use eth2::{ + beacon_response::ForkVersionedResponse, + types::{BlockAndEnvelope, ProduceBlockV4Metadata}, +}; use ssz::Encode; use std::sync::Arc; use tracing::instrument; @@ -71,7 +75,7 @@ pub async fn produce_block_v4( let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); - let (block, _block_state, consensus_block_value) = chain + let (block, _block_state, consensus_block_value, payload_contents) = chain .produce_block_with_verification_gloas( randao_reveal, slot, @@ -84,13 +88,13 @@ pub async fn produce_block_v4( warp_utils::reject::custom_bad_request(format!("failed to fetch a block: {:?}", e)) })?; - // TODO(gloas): wire up for stateless mode (#8828). - let execution_payload_included = false; + let include_payload = query.include_payload.unwrap_or(true); + let payload_contents = include_payload.then_some(payload_contents).flatten(); build_response_v4::( block, consensus_block_value, - execution_payload_included, + payload_contents, accept_header, &chain.spec, ) @@ -143,7 +147,7 @@ pub async fn produce_block_v3( pub fn build_response_v4( block: BeaconBlock>, consensus_block_value: u64, - execution_payload_included: bool, + payload_contents: Option>, accept_header: Option, spec: &ChainSpec, ) -> Result, warp::Rejection> { @@ -153,6 +157,7 @@ pub fn build_response_v4( .map_err(inconsistent_fork_rejection)?; let consensus_block_value_wei = Uint256::from(consensus_block_value) * Uint256::from(1_000_000_000u64); + let execution_payload_included = payload_contents.is_some(); let metadata = ProduceBlockV4Metadata { consensus_version: fork_name, @@ -160,26 +165,62 @@ pub fn build_response_v4( execution_payload_included, }; + let add_v4_headers = |res: Response| { + let res = add_consensus_version_header(res, fork_name); + let res = add_consensus_block_value_header(res, consensus_block_value_wei); + add_execution_payload_included_header(res, execution_payload_included) + }; + + // When the payload is included, bundle the block with the execution payload envelope, blobs and + // KZG proofs ([`BlockAndEnvelope`]); otherwise return only the block. match accept_header { - Some(api_types::Accept::Ssz) => Response::builder() - .status(200) - .body(block.as_ssz_bytes().into()) - .map(|res: Response| add_ssz_content_type_header(res)) - .map(|res: Response| add_consensus_version_header(res, fork_name)) - .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)) - .map(|res| add_execution_payload_included_header(res, execution_payload_included)) - .map_err(|e| -> warp::Rejection { - warp_utils::reject::custom_server_error(format!("failed to create response: {}", e)) - }), - _ => Ok(warp::reply::json(&ForkVersionedResponse { - version: fork_name, - metadata, - data: block, - }) - .into_response()) - .map(|res| add_consensus_version_header(res, fork_name)) - .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)) - .map(|res| add_execution_payload_included_header(res, execution_payload_included)), + Some(api_types::Accept::Ssz) => { + let ssz_bytes = match payload_contents { + Some((execution_payload_envelope, kzg_proofs, blobs)) => BlockAndEnvelope { + block, + execution_payload_envelope, + kzg_proofs, + blobs, + } + .as_ssz_bytes(), + None => block.as_ssz_bytes(), + }; + Response::builder() + .status(200) + .body(ssz_bytes.into()) + .map(|res: Response| add_ssz_content_type_header(res)) + .map(add_v4_headers) + .map_err(|e| -> warp::Rejection { + warp_utils::reject::custom_server_error(format!( + "failed to create response: {}", + e + )) + }) + } + _ => { + let response = match payload_contents { + Some((execution_payload_envelope, kzg_proofs, blobs)) => { + warp::reply::json(&ForkVersionedResponse { + version: fork_name, + metadata, + data: BlockAndEnvelope { + block, + execution_payload_envelope, + kzg_proofs, + blobs, + }, + }) + .into_response() + } + None => warp::reply::json(&ForkVersionedResponse { + version: fork_name, + metadata, + data: block, + }) + .into_response(), + }; + Ok(add_v4_headers(response)) + } } } diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index e91413dcdf6..e9480eba92e 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -727,11 +727,11 @@ pub async fn proposer_boost_re_org_test( let (block_c, block_c_blobs) = { let (response, _) = tester .client - .get_validator_blocks_v4::(slot_c, &randao_reveal, None, None, None, None) + .get_validator_blocks_v4::(slot_c, &randao_reveal, None, false, None, None) .await .unwrap(); ( - Arc::new(harness.sign_beacon_block(response.data, &state_b)), + Arc::new(harness.sign_beacon_block(response.into_block(), &state_b)), None, ) }; diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 48678526459..8cb90a45243 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4399,8 +4399,9 @@ impl ApiTester { } } - /// Test V4 block production (JSON). Only runs if Gloas is scheduled. - pub async fn test_block_production_v4(self) -> Self { + /// Test V4 block production with `include_payload=false` (JSON). Only runs if Gloas is + /// scheduled. + pub async fn test_block_production_v4_without_payload(self) -> Self { if !self.chain.spec.is_gloas_scheduled() { return self; } @@ -4424,10 +4425,10 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None, None) + .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) .await .unwrap(); - let block = response.data; + let block = response.into_block(); self.assert_v4_block_metadata(&block, &metadata, slot); @@ -4462,8 +4463,9 @@ impl ApiTester { self } - /// Test V4 block production (SSZ). Only runs if Gloas is scheduled. - pub async fn test_block_production_v4_ssz(self) -> Self { + /// Test V4 block production with `include_payload=false` (SSZ). Only runs if Gloas is + /// scheduled. + pub async fn test_block_production_v4_without_payload_ssz(self) -> Self { if !self.chain.spec.is_gloas_scheduled() { return self; } @@ -4485,11 +4487,12 @@ impl ApiTester { .proposer_setup(slot, epoch, &fork, genesis_validators_root) .await; - let (block, metadata) = self + let (response, metadata) = self .client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, None, None, None) + .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, false, None, None) .await .unwrap(); + let block = response.into_block(); self.assert_v4_block_metadata(&block, &metadata, slot); @@ -4523,6 +4526,151 @@ impl ApiTester { self } + /// Test V4 stateless block production with `include_payload=true`. The response should + /// bundle the execution payload envelope, blobs and KZG proofs. + pub async fn test_block_production_v4_with_payload(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + for _ in 0..E::slots_per_epoch() * 3 { + let slot = self.chain.slot().unwrap(); + let epoch = self.chain.epoch().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::(slot); + + if !fork_name.gloas_enabled() { + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + continue; + } + + let (sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let (response, metadata) = self + .client + .get_validator_blocks_v4::(slot, &randao_reveal, None, true, None, None) + .await + .unwrap(); + + let (block, envelope) = self.unwrap_v4_block_contents(response, &metadata, slot); + + let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); + let signed_block_request = + PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); + self.client + .post_beacon_blocks_v2(&signed_block_request, None) + .await + .unwrap(); + assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); + + // Publish the bundled envelope directly (stateless flow: no separate fetch needed). + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + self.client + .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name) + .await + .unwrap(); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + } + + self + } + + /// Test V4 stateless block production with `include_payload=true`. + pub async fn test_block_production_v4_with_payload_ssz(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + for _ in 0..E::slots_per_epoch() * 3 { + let slot = self.chain.slot().unwrap(); + let epoch = self.chain.epoch().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::(slot); + + if !fork_name.gloas_enabled() { + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + continue; + } + + let (sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let (response, metadata) = self + .client + .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, true, None, None) + .await + .unwrap(); + + let (block, envelope) = self.unwrap_v4_block_contents(response, &metadata, slot); + + let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); + let signed_block_request = + PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); + self.client + .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .await + .unwrap(); + assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); + + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + self.client + .post_beacon_execution_payload_envelopes_ssz(&signed_envelope, fork_name) + .await + .unwrap(); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + } + + self + } + + /// Assert an `include_payload=true` v4 response carries the full block contents, verify the + /// bundled envelope, and return the block and envelope for signing/publishing. + fn unwrap_v4_block_contents( + &self, + response: ProduceBlockV4Response, + metadata: &ProduceBlockV4Metadata, + slot: Slot, + ) -> (BeaconBlock, ExecutionPayloadEnvelope) { + // Local building always has a payload to include. + assert!(metadata.execution_payload_included); + let block_contents = match response { + ProduceBlockV4Response::BlockAndEnvelope(block_contents) => block_contents, + ProduceBlockV4Response::BlockOnly(_) => { + panic!("expected block contents when include_payload=true") + } + }; + let BlockAndEnvelope { + block, + execution_payload_envelope: envelope, + .. + } = block_contents; + + self.assert_envelope_fields(&envelope, block.tree_hash_root(), slot); + + // The bundled envelope should match the one cached for the stateful flow. + let cached_envelope = self + .chain + .pending_payload_envelopes + .read() + .get(slot) + .cloned() + .expect("envelope should exist in pending cache for local building"); + assert_eq!(envelope, cached_envelope); + + (block, envelope) + } + pub async fn test_block_production_no_verify_randao(self) -> Self { for _ in 0..E::slots_per_epoch() { let slot = self.chain.slot().unwrap(); @@ -4938,10 +5086,10 @@ impl ApiTester { // Produce and publish a block. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None, None) + .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) .await .unwrap(); - let block = response.data; + let block = response.into_block(); let block_root = block.tree_hash_root(); let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); @@ -5021,10 +5169,10 @@ impl ApiTester { // Produce and publish a block, but withhold its envelope. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None, None) + .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) .await .unwrap(); - let block = response.data; + let block = response.into_block(); let block_root = block.tree_hash_root(); let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); @@ -8058,7 +8206,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, None, ) @@ -8073,7 +8221,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, Some(GraffitiPolicy::AppendClientVersions), ) @@ -8098,7 +8246,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, Some(GraffitiPolicy::PreserveUserGraffiti), ) @@ -8705,15 +8853,13 @@ async fn block_production_v3_ssz_with_skip_slots() { async fn block_production_v4() { ApiTester::new_with_hard_forks() .await - .test_block_production_v4() - .await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn block_production_v4_ssz() { - ApiTester::new_with_hard_forks() + .test_block_production_v4_without_payload() + .await + .test_block_production_v4_without_payload_ssz() + .await + .test_block_production_v4_with_payload() .await - .test_block_production_v4_ssz() + .test_block_production_v4_with_payload_ssz() .await; } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 46f553542a8..f4f8e7b573b 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -2565,7 +2565,7 @@ impl BeaconNodeHttpClient { randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, - include_payload: Option, + include_payload: bool, builder_booster_factor: Option, graffiti_policy: Option, ) -> Result { @@ -2590,10 +2590,8 @@ impl BeaconNodeHttpClient { .append_pair("skip_randao_verification", ""); } - if let Some(include_payload) = include_payload { - path.query_pairs_mut() - .append_pair("include_payload", &include_payload.to_string()); - } + path.query_pairs_mut() + .append_pair("include_payload", &include_payload.to_string()); if let Some(builder_booster_factor) = builder_booster_factor { path.query_pairs_mut() @@ -2617,16 +2615,10 @@ impl BeaconNodeHttpClient { slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, - include_payload: Option, + include_payload: bool, builder_booster_factor: Option, graffiti_policy: Option, - ) -> Result< - ( - ForkVersionedResponse, ProduceBlockV4Metadata>, - ProduceBlockV4Metadata, - ), - Error, - > { + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { self.get_validator_blocks_v4_modular( slot, randao_reveal, @@ -2640,6 +2632,10 @@ impl BeaconNodeHttpClient { } /// `GET v4/validator/blocks/{slot}` + /// + /// Returns either a bare block or the full [`BlockAndEnvelope`] (block + execution payload + /// envelope + blobs + KZG proofs) depending on the `Eth-Execution-Payload-Included` response + /// header. Note that a builder bid yields a bare block even when `include_payload=true`. #[allow(clippy::too_many_arguments)] pub async fn get_validator_blocks_v4_modular( &self, @@ -2647,16 +2643,10 @@ impl BeaconNodeHttpClient { randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, - include_payload: Option, + include_payload: bool, builder_booster_factor: Option, graffiti_policy: Option, - ) -> Result< - ( - ForkVersionedResponse, ProduceBlockV4Metadata>, - ProduceBlockV4Metadata, - ), - Error, - > { + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { let path = self .get_validator_blocks_v4_path( slot, @@ -2675,12 +2665,30 @@ impl BeaconNodeHttpClient { Accept::Json, self.timeouts.get_validator_block, |response, headers| async move { - let header_metadata = ProduceBlockV4Metadata::try_from(&headers) + let metadata = ProduceBlockV4Metadata::try_from(&headers) .map_err(Error::InvalidHeaders)?; - let block_response = response - .json::, ProduceBlockV4Metadata>>() - .await?; - Ok((block_response, header_metadata)) + let block_response = if metadata.execution_payload_included { + ProduceBlockV4Response::BlockAndEnvelope( + response + .json::, + ProduceBlockV4Metadata, + >>() + .await? + .data, + ) + } else { + ProduceBlockV4Response::BlockOnly( + response + .json::, + ProduceBlockV4Metadata, + >>() + .await? + .data, + ) + }; + Ok((block_response, metadata)) }, ) .await?; @@ -2694,10 +2702,10 @@ impl BeaconNodeHttpClient { slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, - include_payload: Option, + include_payload: bool, builder_booster_factor: Option, graffiti_policy: Option, - ) -> Result<(BeaconBlock, ProduceBlockV4Metadata), Error> { + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { self.get_validator_blocks_v4_modular_ssz::( slot, randao_reveal, @@ -2711,6 +2719,8 @@ impl BeaconNodeHttpClient { } /// `GET v4/validator/blocks/{slot}` in ssz format + /// + /// See [`Self::get_validator_blocks_v4_modular`] for the response semantics. #[allow(clippy::too_many_arguments)] pub async fn get_validator_blocks_v4_modular_ssz( &self, @@ -2718,10 +2728,10 @@ impl BeaconNodeHttpClient { randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, - include_payload: Option, + include_payload: bool, builder_booster_factor: Option, graffiti_policy: Option, - ) -> Result<(BeaconBlock, ProduceBlockV4Metadata), Error> { + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { let path = self .get_validator_blocks_v4_path( slot, @@ -2743,14 +2753,25 @@ impl BeaconNodeHttpClient { let metadata = ProduceBlockV4Metadata::try_from(&headers) .map_err(Error::InvalidHeaders)?; let response_bytes = response.bytes().await?; + let block_response = if metadata.execution_payload_included { + ProduceBlockV4Response::BlockAndEnvelope( + BlockAndEnvelope::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?, + ) + } else { + ProduceBlockV4Response::BlockOnly( + BeaconBlock::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?, + ) + }; - let block = BeaconBlock::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?; - - Ok((block, metadata)) + Ok((block_response, metadata)) }, ) .await?; diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 4d0bb48f540..411c21143f5 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1863,6 +1863,95 @@ pub struct ProduceBlockV4Metadata { pub execution_payload_included: bool, } +#[derive(Debug, Clone, PartialEq, Serialize, Encode)] +#[serde(bound = "E: EthSpec")] +pub struct BlockAndEnvelope { + pub block: BeaconBlock, + pub execution_payload_envelope: ExecutionPayloadEnvelope, + pub kzg_proofs: KzgProofs, + #[serde(with = "ssz_types::serde_utils::list_of_hex_fixed_vec")] + pub blobs: BlobsList, +} + +impl<'de, E: EthSpec> ContextDeserialize<'de, ForkName> for BlockAndEnvelope { + fn context_deserialize(deserializer: D, context: ForkName) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(bound = "E: EthSpec")] + struct Helper { + block: serde_json::Value, + execution_payload_envelope: ExecutionPayloadEnvelope, + kzg_proofs: KzgProofs, + #[serde(with = "ssz_types::serde_utils::list_of_hex_fixed_vec")] + blobs: BlobsList, + } + let helper = Helper::::deserialize(deserializer).map_err(serde::de::Error::custom)?; + + let block = BeaconBlock::context_deserialize(helper.block, context) + .map_err(serde::de::Error::custom)?; + + Ok(Self { + block, + execution_payload_envelope: helper.execution_payload_envelope, + kzg_proofs: helper.kzg_proofs, + blobs: helper.blobs, + }) + } +} + +impl BlockAndEnvelope { + /// SSZ decode with fork variant passed in explicitly (the `block` field is fork-versioned). + pub fn from_ssz_bytes_for_fork(bytes: &[u8], fork_name: ForkName) -> Result { + let mut builder = ssz::SszDecoderBuilder::new(bytes); + + builder.register_anonymous_variable_length_item()?; + builder.register_type::>()?; + builder.register_type::>()?; + builder.register_type::>()?; + + let mut decoder = builder.build()?; + let block = decoder + .decode_next_with(|bytes| BeaconBlock::from_ssz_bytes_for_fork(bytes, fork_name))?; + let execution_payload_envelope = decoder.decode_next()?; + let kzg_proofs = decoder.decode_next()?; + let blobs = decoder.decode_next()?; + + Ok(Self { + block, + execution_payload_envelope, + kzg_proofs, + blobs, + }) + } +} + +/// The data returned by `produce_block_v4`: either just the beacon block or the full +/// [`BlockAndEnvelope`]. Callers should branch on the `Eth-Execution-Payload-Included` header to +/// decide which variant to expect. +#[derive(Debug, Clone, PartialEq)] +pub enum ProduceBlockV4Response { + BlockOnly(BeaconBlock), + BlockAndEnvelope(BlockAndEnvelope), +} + +impl ProduceBlockV4Response { + pub fn block(&self) -> &BeaconBlock { + match self { + ProduceBlockV4Response::BlockOnly(block) => block, + ProduceBlockV4Response::BlockAndEnvelope(contents) => &contents.block, + } + } + + pub fn into_block(self) -> BeaconBlock { + match self { + ProduceBlockV4Response::BlockOnly(block) => block, + ProduceBlockV4Response::BlockAndEnvelope(contents) => contents.block, + } + } +} + impl FullBlockContents { pub fn new(block: BeaconBlock, blob_data: Option<(KzgProofs, BlobsList)>) -> Self { match blob_data { diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 06fd14360a1..b69131c9a43 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -478,7 +478,7 @@ impl BlockService { slot, randao_reveal_ref, graffiti.as_ref(), - None, + false, builder_boost_factor, self_ref.graffiti_policy, ) @@ -487,7 +487,7 @@ impl BlockService { .await; let block_response = match ssz_block_response { - Ok((ssz_block_response, _metadata)) => ssz_block_response, + Ok((ssz_block_response, _metadata)) => ssz_block_response.into_block(), Err(e) => { warn!( slot = slot.as_u64(), @@ -506,7 +506,7 @@ impl BlockService { slot, randao_reveal_ref, graffiti.as_ref(), - None, + false, builder_boost_factor, self_ref.graffiti_policy, ) @@ -518,7 +518,7 @@ impl BlockService { )) })?; - Ok(json_block_response.data) + Ok(json_block_response.into_block()) }) .await .map_err(BlockError::from)? From 3df74091c209ed22f132045839631f74a3769f32 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 2 Jul 2026 16:40:15 +0300 Subject: [PATCH 2/8] impl stateless block building API --- beacon_node/beacon_chain/src/kzg_utils.rs | 51 +++++ .../src/pending_payload_envelopes.rs | 31 ++- .../src/beacon/execution_payload_envelopes.rs | 202 +++++++++++++++--- beacon_node/http_api/tests/tests.rs | 114 ++++++++-- common/eth2/src/lib.rs | 143 +++++++++++-- common/eth2/src/types.rs | 12 ++ .../blinded_execution_payload_envelope.rs | 71 ++++++ consensus/types/src/execution/mod.rs | 4 + ...gned_blinded_execution_payload_envelope.rs | 33 +++ .../signed_execution_payload_envelope.rs | 14 +- 10 files changed, 607 insertions(+), 68 deletions(-) create mode 100644 consensus/types/src/execution/blinded_execution_payload_envelope.rs create mode 100644 consensus/types/src/execution/signed_blinded_execution_payload_envelope.rs diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index bc803efe932..ef27c0aaf1b 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -376,6 +376,57 @@ pub fn blobs_to_data_column_sidecars_gloas( .map_err(DataColumnSidecarError::BuildSidecarFailed) } +/// Build Gloas data column sidecars from blobs and pre-computed cell proofs, computing only the +/// cells locally. +pub fn blobs_to_data_column_sidecars_gloas_with_proofs( + blobs: &[&Blob], + cell_proofs: Vec, + beacon_block_root: Hash256, + slot: Slot, + kzg: &Kzg, + spec: &ChainSpec, +) -> Result, DataColumnSidecarError> { + if blobs.is_empty() { + return Ok(vec![]); + } + + if cell_proofs.len() != blobs.len() * E::number_of_columns() { + return Err(DataColumnSidecarError::InvalidCellProofLength { + expected: blobs.len() * E::number_of_columns(), + actual: cell_proofs.len(), + }); + } + + let proof_chunks = cell_proofs + .chunks_exact(E::number_of_columns()) + .collect::>(); + + // NOTE: assumes blobs and proofs are ordered by blob index + let zipped: Vec<_> = blobs.iter().zip(proof_chunks).collect(); + let blob_cells_and_proofs_vec = zipped + .into_par_iter() + .map(|(blob, proofs)| { + let blob = blob.as_ref().try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "blob should have a guaranteed size due to FixedVector: {e:?}" + )) + })?; + + kzg.compute_cells(blob).and_then(|cells| { + let proofs = proofs.try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "proof chunks should have exactly `number_of_columns` proofs: {e:?}" + )) + })?; + Ok((cells, proofs)) + }) + }) + .collect::, KzgError>>()?; + + build_data_column_sidecars_gloas(beacon_block_root, slot, blob_cells_and_proofs_vec, spec) + .map_err(DataColumnSidecarError::BuildSidecarFailed) +} + /// Build data column sidecars from a signed beacon block and its blobs. #[instrument(skip_all, level = "debug", fields(blob_count = blobs_and_proofs.len()))] pub fn blobs_to_partial_data_columns( diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs index 8f7568d0175..27e17c2f443 100644 --- a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -6,7 +6,7 @@ //! and publishes the payload. use std::collections::HashMap; -use types::{BlobsList, EthSpec, ExecutionPayloadEnvelope, Slot}; +use types::{BlobsList, EthSpec, ExecutionPayloadEnvelope, Hash256, Slot}; pub struct PendingEnvelopeData { pub envelope: ExecutionPayloadEnvelope, @@ -53,6 +53,17 @@ impl PendingPayloadEnvelopes { self.envelopes.get(&slot).map(|d| &d.envelope) } + /// Find a pending envelope by the beacon block root it commits to. + pub fn find_by_block_root( + &self, + beacon_block_root: Hash256, + ) -> Option<&ExecutionPayloadEnvelope> { + self.envelopes + .values() + .map(|d| &d.envelope) + .find(|envelope| envelope.beacon_block_root == beacon_block_root) + } + /// Remove and return the blobs and proofs for a slot, leaving the envelope in place. pub fn take_blobs(&mut self, slot: Slot) -> Option> { self.envelopes.get_mut(&slot).and_then(|d| d.blobs.take()) @@ -128,6 +139,24 @@ mod tests { assert_eq!(cache.get(slot), Some(&expected_envelope)); } + #[test] + fn find_by_block_root() { + let mut cache = PendingPayloadEnvelopes::::default(); + let slot = Slot::new(1); + let block_root = Hash256::repeat_byte(42); + + let mut data = make_envelope(slot); + data.envelope.beacon_block_root = block_root; + let expected_envelope = data.envelope.clone(); + cache.insert(slot, data); + + assert_eq!( + cache.find_by_block_root(block_root), + Some(&expected_envelope) + ); + assert_eq!(cache.find_by_block_root(Hash256::repeat_byte(43)), None); + } + #[test] fn remove() { let mut cache = PendingPayloadEnvelopes::::default(); diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs index 456d371c712..7fce951dfd9 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -12,7 +12,8 @@ use beacon_chain::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainTypes, NotifyExecutionLayer, }; use bytes::Bytes; -use eth2::types as api_types; +use eth2::EXECUTION_PAYLOAD_BLINDED_HEADER; +use eth2::types::{self as api_types, SignedExecutionPayloadEnvelopeContents}; use lighthouse_network::PubsubMessage; use network::NetworkMessage; use ssz::{Decode, Encode}; @@ -20,12 +21,26 @@ use std::future::Future; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tracing::{debug, error, info, warn}; -use types::{BlockImportSource, EthSpec, SignedExecutionPayloadEnvelope}; +use tree_hash::TreeHash; +use types::{ + BlobsList, BlockImportSource, EthSpec, KzgProofs, SignedBlindedExecutionPayloadEnvelope, + SignedExecutionPayloadEnvelope, +}; use warp::{ Filter, Rejection, Reply, hyper::{Body, Response}, }; +/// Request body for `POST beacon/execution_payload_envelopes`, selected via the +/// `Eth-Execution-Payload-Blinded` header. +pub enum SignedEnvelopeSubmission { + /// Full envelope with blobs and KZG proofs (stateless flow). + Full(Box>), + /// Blinded envelope; the full envelope and blobs are reconstructed from the pending + /// envelope cache (stateful flow). + Blinded(Box>), +} + // POST beacon/execution_payload_envelopes (SSZ) pub(crate) fn post_beacon_execution_payload_envelopes_ssz( eth_v1: EthV1Filter, @@ -37,22 +52,40 @@ pub(crate) fn post_beacon_execution_payload_envelopes_ssz( .and(warp::path("beacon")) .and(warp::path("execution_payload_envelopes")) .and(warp::path::end()) + .and(warp::header::header::( + EXECUTION_PAYLOAD_BLINDED_HEADER, + )) .and(warp::body::bytes()) .and(task_spawner_filter) .and(chain_filter) .and(network_tx_filter) .then( - |body_bytes: Bytes, + |payload_blinded: bool, + body_bytes: Bytes, task_spawner: TaskSpawner, chain: Arc>, network_tx: UnboundedSender>| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { - let envelope = - SignedExecutionPayloadEnvelope::::from_ssz_bytes(&body_bytes) - .map_err(|e| { - warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) - })?; - publish_execution_payload_envelope(envelope, chain, &network_tx).await + let submission = if payload_blinded { + SignedEnvelopeSubmission::Blinded(Box::new( + SignedBlindedExecutionPayloadEnvelope::from_ssz_bytes(&body_bytes) + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid SSZ: {e:?}" + )) + })?, + )) + } else { + SignedEnvelopeSubmission::Full(Box::new( + SignedExecutionPayloadEnvelopeContents::from_ssz_bytes(&body_bytes) + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid SSZ: {e:?}" + )) + })?, + )) + }; + publish_execution_payload_envelope(submission, chain, &network_tx).await }) }, ) @@ -70,17 +103,38 @@ pub(crate) fn post_beacon_execution_payload_envelopes( .and(warp::path("beacon")) .and(warp::path("execution_payload_envelopes")) .and(warp::path::end()) - .and(warp::body::json()) + .and(warp::header::header::( + EXECUTION_PAYLOAD_BLINDED_HEADER, + )) + .and(warp::body::bytes()) .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) .then( - |envelope: SignedExecutionPayloadEnvelope, + |payload_blinded: bool, + body_bytes: Bytes, task_spawner: TaskSpawner, chain: Arc>, network_tx: UnboundedSender>| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { - publish_execution_payload_envelope(envelope, chain, &network_tx).await + let submission = if payload_blinded { + SignedEnvelopeSubmission::Blinded(Box::new( + serde_json::from_slice(&body_bytes).map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid JSON: {e:?}" + )) + })?, + )) + } else { + SignedEnvelopeSubmission::Full(Box::new( + serde_json::from_slice(&body_bytes).map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid JSON: {e:?}" + )) + })?, + )) + }; + publish_execution_payload_envelope(submission, chain, &network_tx).await }) }, ) @@ -90,19 +144,34 @@ pub(crate) fn post_beacon_execution_payload_envelopes( /// `POST /eth/v1/beacon/execution_payload_envelopes` per the in-flight beacon-APIs PR /// . pub async fn publish_execution_payload_envelope( - envelope: SignedExecutionPayloadEnvelope, + submission: SignedEnvelopeSubmission, chain: Arc>, network_tx: &UnboundedSender>, ) -> Result, Rejection> { - let slot = envelope.slot(); - let beacon_block_root = envelope.message.beacon_block_root; - if !chain.spec.is_gloas_scheduled() { return Err(warp_utils::reject::custom_bad_request( "Execution payload envelopes are not supported before the Gloas fork".into(), )); } + let (envelope, blobs_and_proofs) = match submission { + SignedEnvelopeSubmission::Full(contents) => { + let SignedExecutionPayloadEnvelopeContents { + signed_execution_payload_envelope: envelope, + kzg_proofs, + blobs, + } = *contents; + (envelope, Some((blobs, Some(kzg_proofs)))) + } + SignedEnvelopeSubmission::Blinded(blinded) => { + let (envelope, blobs) = unblind_envelope_from_cache(&chain, &blinded)?; + (envelope, blobs.map(|blobs| (blobs, None))) + } + }; + + let slot = envelope.slot(); + let beacon_block_root = envelope.message.beacon_block_root; + info!( %slot, %beacon_block_root, @@ -110,20 +179,21 @@ pub async fn publish_execution_payload_envelope( "Publishing signed execution payload envelope to network" ); - let blobs_and_proofs = chain.pending_payload_envelopes.write().take_blobs(slot); - // Spawn the column-build task (CPU-bound KZG cell-and-proof computation) before // publishing the envelope so it runs in parallel with envelope gossip, narrowing // the window in which peers see envelope-without-columns. If envelope import // fails below, dropping this future drops the spawned `JoinHandle` (the running // closure on the blocking pool finishes and is then discarded — no work cancellation). let column_build_future = match blobs_and_proofs { - Some(blobs) if !blobs.is_empty() => Some(spawn_build_gloas_data_columns_task( - &chain, - beacon_block_root, - slot, - blobs, - )?), + Some((blobs, cell_proofs)) if !blobs.is_empty() => { + Some(spawn_build_gloas_data_columns_task( + &chain, + beacon_block_root, + slot, + blobs, + cell_proofs, + )?) + } _ => None, }; @@ -173,9 +243,9 @@ pub async fn publish_execution_payload_envelope( } }; - // From here on the envelope is on the wire. `take_blobs` already consumed the cache - // entry, so a retry would not republish columns; returning Err would mislead the - // caller. Log column-build/publish failures and fall through to `Ok`. + // From here on the envelope is on the wire, and for blinded submissions `take_blobs` + // already consumed the cache entry, so a retry would not republish columns; returning + // Err would mislead the caller. Log column-build/publish failures and fall through to `Ok`. if let Some(column_build_future) = column_build_future { let gossip_verified_columns = match column_build_future.await { Ok(columns) => columns, @@ -231,17 +301,68 @@ pub async fn publish_execution_payload_envelope( Ok(warp::reply().into_response()) } +/// Reconstruct the full signed envelope for a blinded submission from the pending envelope +/// cache, along with any cached blobs. +fn unblind_envelope_from_cache( + chain: &BeaconChain, + blinded: &SignedBlindedExecutionPayloadEnvelope, +) -> Result< + ( + SignedExecutionPayloadEnvelope, + Option>, + ), + Rejection, +> { + let mut cache = chain.pending_payload_envelopes.write(); + + let envelope = cache + .find_by_block_root(blinded.message.beacon_block_root) + .ok_or_else(|| { + warp_utils::reject::custom_bad_request(format!( + "no cached execution payload envelope for beacon block root {}", + blinded.message.beacon_block_root + )) + })? + .clone(); + + // Equal roots make the submitted signature valid over the full envelope. + if envelope.tree_hash_root() != blinded.message.tree_hash_root() { + return Err(warp_utils::reject::custom_bad_request( + "blinded envelope does not match the cached envelope".into(), + )); + } + + let blobs = cache.take_blobs(envelope.slot()); + + Ok(( + SignedExecutionPayloadEnvelope { + message: envelope, + signature: blinded.signature.clone(), + }, + blobs, + )) +} + fn spawn_build_gloas_data_columns_task( chain: &Arc>, beacon_block_root: types::Hash256, slot: types::Slot, blobs: types::BlobsList, + cell_proofs: Option>, ) -> Result>, Rejection>>, Rejection> { let chain_for_build = chain.clone(); let handle = chain .task_executor .spawn_blocking_handle( - move || build_gloas_data_columns(&chain_for_build, beacon_block_root, slot, &blobs), + move || { + build_gloas_data_columns( + &chain_for_build, + beacon_block_root, + slot, + &blobs, + cell_proofs, + ) + }, "build_gloas_data_columns", ) .ok_or_else(|| warp_utils::reject::custom_server_error("runtime shutdown".to_string()))?; @@ -258,15 +379,26 @@ fn build_gloas_data_columns( beacon_block_root: types::Hash256, slot: types::Slot, blobs: &types::BlobsList, + cell_proofs: Option>, ) -> Result>, Rejection> { let blob_refs: Vec<_> = blobs.iter().collect(); - let data_column_sidecars = beacon_chain::kzg_utils::blobs_to_data_column_sidecars_gloas( - &blob_refs, - beacon_block_root, - slot, - &chain.kzg, - &chain.spec, - ) + let data_column_sidecars = match cell_proofs { + Some(proofs) => beacon_chain::kzg_utils::blobs_to_data_column_sidecars_gloas_with_proofs( + &blob_refs, + proofs.to_vec(), + beacon_block_root, + slot, + &chain.kzg, + &chain.spec, + ), + None => beacon_chain::kzg_utils::blobs_to_data_column_sidecars_gloas( + &blob_refs, + beacon_block_root, + slot, + &chain.kzg, + &chain.spec, + ), + } .map_err(|e| { error!( error = ?e, diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 8cb90a45243..6b0005625c0 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4556,7 +4556,12 @@ impl ApiTester { .await .unwrap(); - let (block, envelope) = self.unwrap_v4_block_contents(response, &metadata, slot); + let BlockAndEnvelope { + block, + execution_payload_envelope: envelope, + kzg_proofs, + blobs, + } = self.unwrap_v4_block_contents(response, &metadata, slot); let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); let signed_block_request = @@ -4567,11 +4572,18 @@ impl ApiTester { .unwrap(); assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); - // Publish the bundled envelope directly (stateless flow: no separate fetch needed). + // Clear the pending cache to simulate publishing via a beacon node that did not + // produce the block, then publish the bundled envelope, blobs and proofs. + self.chain.pending_payload_envelopes.write().remove(slot); let signed_envelope = self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + let contents = SignedExecutionPayloadEnvelopeContents { + signed_execution_payload_envelope: signed_envelope, + kzg_proofs, + blobs, + }; self.client - .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name) + .post_beacon_execution_payload_envelope_contents(&contents, fork_name) .await .unwrap(); @@ -4610,7 +4622,12 @@ impl ApiTester { .await .unwrap(); - let (block, envelope) = self.unwrap_v4_block_contents(response, &metadata, slot); + let BlockAndEnvelope { + block, + execution_payload_envelope: envelope, + kzg_proofs, + blobs, + } = self.unwrap_v4_block_contents(response, &metadata, slot); let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); let signed_block_request = @@ -4621,10 +4638,18 @@ impl ApiTester { .unwrap(); assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); + // Clear the pending cache to simulate publishing via a beacon node that did not + // produce the block, then publish the bundled envelope, blobs and proofs. + self.chain.pending_payload_envelopes.write().remove(slot); let signed_envelope = self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + let contents = SignedExecutionPayloadEnvelopeContents { + signed_execution_payload_envelope: signed_envelope, + kzg_proofs, + blobs, + }; self.client - .post_beacon_execution_payload_envelopes_ssz(&signed_envelope, fork_name) + .post_beacon_execution_payload_envelope_contents_ssz(&contents, fork_name) .await .unwrap(); @@ -4634,14 +4659,72 @@ impl ApiTester { self } + /// Test that a blinded envelope submission is rejected when the beacon node has no cached + /// envelope to reconstruct from. Only runs if Gloas is scheduled. + pub async fn test_block_production_v4_blinded_envelope_no_cache(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + for _ in 0..E::slots_per_epoch() * 3 { + let slot = self.chain.slot().unwrap(); + let epoch = self.chain.epoch().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::(slot); + + if !fork_name.gloas_enabled() { + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + continue; + } + + let (sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let (response, _metadata) = self + .client + .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .await + .unwrap(); + let block = response.into_block(); + + let envelope = self + .client + .get_validator_execution_payload_envelopes::(slot) + .await + .unwrap() + .data; + self.assert_envelope_fields(&envelope, block.tree_hash_root(), slot); + + // Clear the cache so there is no envelope to reconstruct the blinded + // submission from. + self.chain.pending_payload_envelopes.write().remove(slot); + + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + let err = self + .client + .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name) + .await + .unwrap_err(); + assert_eq!(err.status().unwrap(), 400); + + return self; + } + + panic!("Gloas fork was never reached"); + } + /// Assert an `include_payload=true` v4 response carries the full block contents, verify the - /// bundled envelope, and return the block and envelope for signing/publishing. + /// bundled envelope, and return the block contents for signing/publishing. fn unwrap_v4_block_contents( &self, response: ProduceBlockV4Response, metadata: &ProduceBlockV4Metadata, slot: Slot, - ) -> (BeaconBlock, ExecutionPayloadEnvelope) { + ) -> BlockAndEnvelope { // Local building always has a payload to include. assert!(metadata.execution_payload_included); let block_contents = match response { @@ -4650,13 +4733,12 @@ impl ApiTester { panic!("expected block contents when include_payload=true") } }; - let BlockAndEnvelope { - block, - execution_payload_envelope: envelope, - .. - } = block_contents; - self.assert_envelope_fields(&envelope, block.tree_hash_root(), slot); + self.assert_envelope_fields( + &block_contents.execution_payload_envelope, + block_contents.block.tree_hash_root(), + slot, + ); // The bundled envelope should match the one cached for the stateful flow. let cached_envelope = self @@ -4666,9 +4748,9 @@ impl ApiTester { .get(slot) .cloned() .expect("envelope should exist in pending cache for local building"); - assert_eq!(envelope, cached_envelope); + assert_eq!(block_contents.execution_payload_envelope, cached_envelope); - (block, envelope) + block_contents } pub async fn test_block_production_no_verify_randao(self) -> Self { @@ -8860,6 +8942,8 @@ async fn block_production_v4() { .test_block_production_v4_with_payload() .await .test_block_production_v4_with_payload_ssz() + .await + .test_block_production_v4_blinded_envelope_no_cache() .await; } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index f4f8e7b573b..866f12f5138 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -467,6 +467,64 @@ impl BeaconNodeHttpClient { success_or_error(response).await } + /// Generic POST function with `Eth-Consensus-Version` and `Eth-Execution-Payload-Blinded` + /// headers. + async fn post_generic_with_envelope_headers( + &self, + url: U, + body: &T, + timeout: Option, + fork: ForkName, + payload_blinded: bool, + ) -> Result { + let builder = self + .client + .post(url) + .timeout(timeout.unwrap_or(self.timeouts.default)); + let response = builder + .header(CONSENSUS_VERSION_HEADER, fork.to_string()) + .header( + EXECUTION_PAYLOAD_BLINDED_HEADER, + payload_blinded.to_string(), + ) + .json(body) + .send() + .await?; + success_or_error(response).await + } + + /// Generic POST function with `Eth-Consensus-Version` and `Eth-Execution-Payload-Blinded` + /// headers and an SSZ body. + async fn post_generic_with_envelope_headers_and_ssz_body, U: IntoUrl>( + &self, + url: U, + body: T, + timeout: Option, + fork: ForkName, + payload_blinded: bool, + ) -> Result { + let builder = self + .client + .post(url) + .timeout(timeout.unwrap_or(self.timeouts.default)); + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&fork.to_string()).expect("Failed to create header value"), + ); + headers.insert( + EXECUTION_PAYLOAD_BLINDED_HEADER, + HeaderValue::from_str(&payload_blinded.to_string()) + .expect("Failed to create header value"), + ); + headers.insert( + "Content-Type", + HeaderValue::from_static("application/octet-stream"), + ); + let response = builder.headers(headers).body(body).send().await?; + success_or_error(response).await + } + /// Generic POST function that includes octet-stream content type header. async fn post_generic_with_ssz_header( &self, @@ -2817,12 +2875,8 @@ impl BeaconNodeHttpClient { ExecutionPayloadEnvelope::from_ssz_bytes(&response_bytes).map_err(Error::InvalidSsz) } - /// `POST v1/beacon/execution_payload_envelopes` - pub async fn post_beacon_execution_payload_envelopes( - &self, - envelope: &SignedExecutionPayloadEnvelope, - fork_name: ForkName, - ) -> Result<(), Error> { + /// Path for `v1/beacon/execution_payload_envelopes` + fn post_beacon_execution_payload_envelopes_path(&self) -> Result { let mut path = self.eth_path(V1)?; path.path_segments_mut() @@ -2830,11 +2884,27 @@ impl BeaconNodeHttpClient { .push("beacon") .push("execution_payload_envelopes"); - self.post_generic_with_consensus_version( + Ok(path) + } + + /// `POST v1/beacon/execution_payload_envelopes` + /// + /// Submits the blinded form of the envelope (stateful flow); the beacon node reconstructs + /// the full envelope and blobs from the cache populated during block production, so this + /// must be sent to the beacon node that produced the block. + pub async fn post_beacon_execution_payload_envelopes( + &self, + envelope: &SignedExecutionPayloadEnvelope, + fork_name: ForkName, + ) -> Result<(), Error> { + let path = self.post_beacon_execution_payload_envelopes_path()?; + + self.post_generic_with_envelope_headers( path, - envelope, + &envelope.clone_as_blinded(), Some(self.timeouts.proposal), fork_name, + true, ) .await?; @@ -2842,23 +2912,66 @@ impl BeaconNodeHttpClient { } /// `POST v1/beacon/execution_payload_envelopes` in SSZ format + /// + /// See [`Self::post_beacon_execution_payload_envelopes`] for the request semantics. pub async fn post_beacon_execution_payload_envelopes_ssz( &self, envelope: &SignedExecutionPayloadEnvelope, fork_name: ForkName, ) -> Result<(), Error> { - let mut path = self.eth_path(V1)?; + let path = self.post_beacon_execution_payload_envelopes_path()?; - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("beacon") - .push("execution_payload_envelopes"); + self.post_generic_with_envelope_headers_and_ssz_body( + path, + envelope.clone_as_blinded().as_ssz_bytes(), + Some(self.timeouts.proposal), + fork_name, + true, + ) + .await?; - self.post_generic_with_consensus_version_and_ssz_body( + Ok(()) + } + + /// `POST v1/beacon/execution_payload_envelopes` + /// + /// Submits the full envelope bundled with blobs and KZG proofs (stateless flow), allowing + /// publication via a beacon node that did not produce the block. + pub async fn post_beacon_execution_payload_envelope_contents( + &self, + contents: &SignedExecutionPayloadEnvelopeContents, + fork_name: ForkName, + ) -> Result<(), Error> { + let path = self.post_beacon_execution_payload_envelopes_path()?; + + self.post_generic_with_envelope_headers( + path, + contents, + Some(self.timeouts.proposal), + fork_name, + false, + ) + .await?; + + Ok(()) + } + + /// `POST v1/beacon/execution_payload_envelopes` in SSZ format + /// + /// See [`Self::post_beacon_execution_payload_envelope_contents`] for the request semantics. + pub async fn post_beacon_execution_payload_envelope_contents_ssz( + &self, + contents: &SignedExecutionPayloadEnvelopeContents, + fork_name: ForkName, + ) -> Result<(), Error> { + let path = self.post_beacon_execution_payload_envelopes_path()?; + + self.post_generic_with_envelope_headers_and_ssz_body( path, - envelope.as_ssz_bytes(), + contents.as_ssz_bytes(), Some(self.timeouts.proposal), fork_name, + false, ) .await?; diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 411c21143f5..69048068557 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1952,6 +1952,18 @@ impl ProduceBlockV4Response { } } +/// A signed execution payload envelope bundled with blobs and KZG proofs for stateless +/// publishing via `POST beacon/execution_payload_envelopes`, used when the receiving beacon node +/// does not have the blobs cached. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode)] +#[serde(bound = "E: EthSpec")] +pub struct SignedExecutionPayloadEnvelopeContents { + pub signed_execution_payload_envelope: SignedExecutionPayloadEnvelope, + pub kzg_proofs: KzgProofs, + #[serde(with = "ssz_types::serde_utils::list_of_hex_fixed_vec")] + pub blobs: BlobsList, +} + impl FullBlockContents { pub fn new(block: BeaconBlock, blob_data: Option<(KzgProofs, BlobsList)>) -> Self { match blob_data { diff --git a/consensus/types/src/execution/blinded_execution_payload_envelope.rs b/consensus/types/src/execution/blinded_execution_payload_envelope.rs new file mode 100644 index 00000000000..96f60b27cb4 --- /dev/null +++ b/consensus/types/src/execution/blinded_execution_payload_envelope.rs @@ -0,0 +1,71 @@ +use crate::execution::ExecutionRequests; +use crate::{EthSpec, ExecutionPayloadEnvelope, ForkName, Hash256, SignedRoot}; +use context_deserialize::context_deserialize; +use educe::Educe; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash::TreeHash; +use tree_hash_derive::TreeHash; + +/// Blinded form of [`ExecutionPayloadEnvelope`] with the `payload` field replaced by its hash +/// tree root. +/// +/// The field order mirrors the full envelope, so both forms have the same hash tree root and a +/// signature over one form is valid over the other. +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] +#[derive(Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe)] +#[educe(PartialEq, Hash(bound(E: EthSpec)))] +#[context_deserialize(ForkName)] +#[serde(bound = "E: EthSpec")] +pub struct BlindedExecutionPayloadEnvelope { + pub payload_root: Hash256, + pub execution_requests: ExecutionRequests, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_index: u64, + pub beacon_block_root: Hash256, + pub parent_beacon_block_root: Hash256, +} + +impl From<&ExecutionPayloadEnvelope> for BlindedExecutionPayloadEnvelope { + fn from(envelope: &ExecutionPayloadEnvelope) -> Self { + Self { + payload_root: envelope.payload.tree_hash_root(), + execution_requests: envelope.execution_requests.clone(), + builder_index: envelope.builder_index, + beacon_block_root: envelope.beacon_block_root, + parent_beacon_block_root: envelope.parent_beacon_block_root, + } + } +} + +impl SignedRoot for BlindedExecutionPayloadEnvelope {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ExecutionPayloadGloas, MainnetEthSpec, Slot}; + + ssz_and_tree_hash_tests!(BlindedExecutionPayloadEnvelope); + + #[test] + fn blinded_root_matches_full_root() { + let envelope = ExecutionPayloadEnvelope:: { + payload: ExecutionPayloadGloas { + slot_number: Slot::new(42), + ..ExecutionPayloadGloas::default() + }, + execution_requests: ExecutionRequests::default(), + builder_index: 7, + beacon_block_root: Hash256::repeat_byte(1), + parent_beacon_block_root: Hash256::repeat_byte(2), + }; + let blinded = BlindedExecutionPayloadEnvelope::from(&envelope); + + assert_eq!(blinded.payload_root, envelope.payload.tree_hash_root()); + assert_eq!(blinded.tree_hash_root(), envelope.tree_hash_root()); + } +} diff --git a/consensus/types/src/execution/mod.rs b/consensus/types/src/execution/mod.rs index a3d4ed87301..458e96da120 100644 --- a/consensus/types/src/execution/mod.rs +++ b/consensus/types/src/execution/mod.rs @@ -2,6 +2,7 @@ mod eth1_data; mod execution_block_header; #[macro_use] mod execution_payload; +mod blinded_execution_payload_envelope; mod bls_to_execution_change; mod dumb_macros; mod execution_payload_bid; @@ -9,10 +10,12 @@ mod execution_payload_envelope; mod execution_payload_header; mod execution_requests; mod payload; +mod signed_blinded_execution_payload_envelope; mod signed_bls_to_execution_change; mod signed_execution_payload_bid; mod signed_execution_payload_envelope; +pub use blinded_execution_payload_envelope::BlindedExecutionPayloadEnvelope; pub use bls_to_execution_change::BlsToExecutionChange; pub use eth1_data::Eth1Data; pub use execution_block_header::{EncodableExecutionBlockHeader, ExecutionBlockHeader}; @@ -38,6 +41,7 @@ pub use payload::{ FullPayloadCapella, FullPayloadDeneb, FullPayloadElectra, FullPayloadFulu, FullPayloadRef, OwnedExecPayload, }; +pub use signed_blinded_execution_payload_envelope::SignedBlindedExecutionPayloadEnvelope; pub use signed_bls_to_execution_change::SignedBlsToExecutionChange; pub use signed_execution_payload_bid::SignedExecutionPayloadBid; pub use signed_execution_payload_envelope::SignedExecutionPayloadEnvelope; diff --git a/consensus/types/src/execution/signed_blinded_execution_payload_envelope.rs b/consensus/types/src/execution/signed_blinded_execution_payload_envelope.rs new file mode 100644 index 00000000000..d9a4d3de0e5 --- /dev/null +++ b/consensus/types/src/execution/signed_blinded_execution_payload_envelope.rs @@ -0,0 +1,33 @@ +use crate::{BlindedExecutionPayloadEnvelope, EthSpec, ForkName}; +use bls::Signature; +use context_deserialize::context_deserialize; +use educe::Educe; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; + +/// Signed form of [`BlindedExecutionPayloadEnvelope`]. +/// +/// The signature is produced over the hash tree root of the message, which matches the hash tree +/// root of the corresponding full `ExecutionPayloadEnvelope` by construction. +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] +#[derive(Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe)] +#[educe(PartialEq, Hash(bound(E: EthSpec)))] +#[serde(bound = "E: EthSpec")] +#[context_deserialize(ForkName)] +pub struct SignedBlindedExecutionPayloadEnvelope { + pub message: BlindedExecutionPayloadEnvelope, + pub signature: Signature, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MainnetEthSpec; + + ssz_and_tree_hash_tests!(SignedBlindedExecutionPayloadEnvelope); +} diff --git a/consensus/types/src/execution/signed_execution_payload_envelope.rs b/consensus/types/src/execution/signed_execution_payload_envelope.rs index 316a580476d..1c0c34132ce 100644 --- a/consensus/types/src/execution/signed_execution_payload_envelope.rs +++ b/consensus/types/src/execution/signed_execution_payload_envelope.rs @@ -1,7 +1,7 @@ use crate::{ BeaconState, BeaconStateError, ChainSpec, Domain, Epoch, EthSpec, ExecutionBlockHash, - ExecutionPayloadEnvelope, Fork, ForkName, Hash256, SignedRoot, Slot, - consts::gloas::BUILDER_INDEX_SELF_BUILD, + ExecutionPayloadEnvelope, Fork, ForkName, Hash256, SignedBlindedExecutionPayloadEnvelope, + SignedRoot, Slot, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; use bls::{PublicKey, Signature}; use context_deserialize::context_deserialize; @@ -60,6 +60,16 @@ impl SignedExecutionPayloadEnvelope { self.message.payload.block_hash } + /// Convert to the blinded form, retaining the signature. + /// + /// The signature is valid over both forms as they have the same hash tree root. + pub fn clone_as_blinded(&self) -> SignedBlindedExecutionPayloadEnvelope { + SignedBlindedExecutionPayloadEnvelope { + message: (&self.message).into(), + signature: self.signature.clone(), + } + } + /// Verify `self.signature`. pub fn verify_signature( &self, From 1e3070fe7b00a167fc943a4ba5dd72877dce2dce Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Fri, 3 Jul 2026 15:02:09 +0300 Subject: [PATCH 3/8] fixes --- beacon_node/beacon_chain/src/beacon_chain.rs | 1 + .../src/block_production/gloas.rs | 23 ++- beacon_node/beacon_chain/src/kzg_utils.rs | 50 ++--- .../src/pending_payload_envelopes.rs | 156 ++++++++------- beacon_node/beacon_chain/src/test_utils.rs | 4 +- .../beacon_chain/tests/prepare_payload.rs | 21 +-- .../src/beacon/execution_payload_envelopes.rs | 177 +++++++++++------- beacon_node/http_api/src/produce_block.rs | 10 +- .../validator/execution_payload_envelopes.rs | 4 +- beacon_node/http_api/tests/tests.rs | 25 ++- common/eth2/src/lib.rs | 122 +++++++----- common/eth2/src/types.rs | 1 + 12 files changed, 330 insertions(+), 264 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 708a07021d0..28a54bf6daa 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -6878,6 +6878,7 @@ impl BeaconChain { self.envelope_times_cache.write().prune(slot); self.gossip_verified_payload_bid_cache.prune(slot); self.gossip_verified_proposer_preferences_cache.prune(slot); + self.pending_payload_envelopes.write().prune(slot); // Don't run heavy-weight tasks during sync. if self.best_slot() + MAX_PER_SLOT_FORK_CHOICE_DISTANCE < slot { diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 6e5abf4a74b..8b3b270901b 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -49,7 +49,11 @@ pub const EXECUTION_PAYMENT_TRUSTLESS_BUILD: u64 = 0; type ConsensusBlockValue = u64; -pub type PayloadEnvelopeContents = (ExecutionPayloadEnvelope, KzgProofs, BlobsList); +pub type PayloadEnvelopeContents = ( + Arc>, + KzgProofs, + Arc>, +); type BlockProductionResult = ( BeaconBlock, @@ -689,23 +693,22 @@ impl BeaconChain { // Cache the envelope for later retrieval by the validator for signing and publishing. let envelope_slot = payload_data.slot; - // TODO(gloas) might be safer to cache by root instead of by slot. - // We should revisit this once this code path + beacon api spec matures let (blobs, kzg_proofs) = payload_data.blobs_and_proofs; - self.pending_payload_envelopes.write().insert( - envelope_slot, - PendingEnvelopeData { - envelope: signed_envelope.message.clone(), + let envelope = Arc::new(signed_envelope.message); + let blobs = Arc::new(blobs); + self.pending_payload_envelopes + .write() + .insert(PendingEnvelopeData { + envelope: envelope.clone(), blobs: Some(blobs.clone()), - }, - ); + }); debug!( %beacon_block_root, slot = %envelope_slot, "Cached pending execution payload envelope" ); - Some((signed_envelope.message, kzg_proofs, blobs)) + Some((envelope, kzg_proofs, blobs)) } else { None }; diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index ef27c0aaf1b..293405a69d7 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -292,38 +292,8 @@ pub fn blobs_to_data_column_sidecars( .map_err(|_err| DataColumnSidecarError::PreDeneb)?; let signed_block_header = block.signed_block_header(); - if cell_proofs.len() != blobs.len() * E::number_of_columns() { - return Err(DataColumnSidecarError::InvalidCellProofLength { - expected: blobs.len() * E::number_of_columns(), - actual: cell_proofs.len(), - }); - } - - let proof_chunks = cell_proofs - .chunks_exact(E::number_of_columns()) - .collect::>(); - - // NOTE: assumes blob sidecars are ordered by index - let zipped: Vec<_> = blobs.iter().zip(proof_chunks).collect(); - let blob_cells_and_proofs_vec = zipped - .into_par_iter() - .map(|(blob, proofs)| { - let blob = blob.as_ref().try_into().map_err(|e| { - KzgError::InconsistentArrayLength(format!( - "blob should have a guaranteed size due to FixedVector: {e:?}" - )) - })?; - - kzg.compute_cells(blob).and_then(|cells| { - let proofs = proofs.try_into().map_err(|e| { - KzgError::InconsistentArrayLength(format!( - "proof chunks should have exactly `number_of_columns` proofs: {e:?}" - )) - })?; - Ok((cells, proofs)) - }) - }) - .collect::, KzgError>>()?; + let blob_cells_and_proofs_vec = + compute_cells_with_provided_proofs::(blobs, cell_proofs, kzg)?; if block.fork_name_unchecked().gloas_enabled() { build_data_column_sidecars_gloas( @@ -390,6 +360,19 @@ pub fn blobs_to_data_column_sidecars_gloas_with_proofs( return Ok(vec![]); } + let blob_cells_and_proofs_vec = + compute_cells_with_provided_proofs::(blobs, cell_proofs, kzg)?; + + build_data_column_sidecars_gloas(beacon_block_root, slot, blob_cells_and_proofs_vec, spec) + .map_err(DataColumnSidecarError::BuildSidecarFailed) +} + +/// Compute cells for each blob and pair them with the provided per-blob cell proofs. +fn compute_cells_with_provided_proofs( + blobs: &[&Blob], + cell_proofs: Vec, + kzg: &Kzg, +) -> Result, DataColumnSidecarError> { if cell_proofs.len() != blobs.len() * E::number_of_columns() { return Err(DataColumnSidecarError::InvalidCellProofLength { expected: blobs.len() * E::number_of_columns(), @@ -423,8 +406,7 @@ pub fn blobs_to_data_column_sidecars_gloas_with_proofs( }) .collect::, KzgError>>()?; - build_data_column_sidecars_gloas(beacon_block_root, slot, blob_cells_and_proofs_vec, spec) - .map_err(DataColumnSidecarError::BuildSidecarFailed) + Ok(blob_cells_and_proofs_vec) } /// Build data column sidecars from a signed beacon block and its blobs. diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs index 27e17c2f443..c2bf3e80bdc 100644 --- a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -6,22 +6,23 @@ //! and publishes the payload. use std::collections::HashMap; +use std::sync::Arc; use types::{BlobsList, EthSpec, ExecutionPayloadEnvelope, Hash256, Slot}; pub struct PendingEnvelopeData { - pub envelope: ExecutionPayloadEnvelope, - pub blobs: Option>, + pub envelope: Arc>, + pub blobs: Option>>, } /// Cache for pending execution payload envelopes awaiting publishing. /// -/// Envelopes are keyed by slot and pruned based on slot age. +/// Envelopes are keyed by the beacon block root they commit to and pruned based on slot age. /// This cache is only used for local building. pub struct PendingPayloadEnvelopes { /// Maximum number of slots to keep envelopes before pruning. max_slot_age: u64, - /// The envelopes, keyed by slot. - envelopes: HashMap>, + /// The envelopes, keyed by beacon block root. + envelopes: HashMap>, } impl Default for PendingPayloadEnvelopes { @@ -42,50 +43,62 @@ impl PendingPayloadEnvelopes { } } - /// Insert a pending envelope into the cache. - pub fn insert(&mut self, slot: Slot, data: PendingEnvelopeData) { - // TODO(gloas): we may want to check for duplicates here, which shouldn't be allowed - self.envelopes.insert(slot, data); + /// Insert a pending envelope into the cache, keyed by the beacon block root it commits to. + pub fn insert(&mut self, data: PendingEnvelopeData) { + self.envelopes.insert(data.envelope.beacon_block_root, data); } - /// Get a pending envelope by slot. - pub fn get(&self, slot: Slot) -> Option<&ExecutionPayloadEnvelope> { - self.envelopes.get(&slot).map(|d| &d.envelope) - } - - /// Find a pending envelope by the beacon block root it commits to. - pub fn find_by_block_root( + /// Get a pending envelope by the beacon block root it commits to. + pub fn get_by_block_root( &self, beacon_block_root: Hash256, - ) -> Option<&ExecutionPayloadEnvelope> { + ) -> Option<&Arc>> { + self.envelopes + .get(&beacon_block_root) + .map(|data| &data.envelope) + } + + /// Find a pending envelope by slot. + pub fn get_by_slot(&self, slot: Slot) -> Option<&Arc>> { self.envelopes .values() - .map(|d| &d.envelope) - .find(|envelope| envelope.beacon_block_root == beacon_block_root) + .map(|data| &data.envelope) + .find(|envelope| envelope.slot() == slot) } - /// Remove and return the blobs and proofs for a slot, leaving the envelope in place. - pub fn take_blobs(&mut self, slot: Slot) -> Option> { - self.envelopes.get_mut(&slot).and_then(|d| d.blobs.take()) + /// Remove and return the blobs for a beacon block root, leaving the envelope in place. + pub fn take_blobs(&mut self, beacon_block_root: Hash256) -> Option>> { + self.envelopes + .get_mut(&beacon_block_root) + .and_then(|data| data.blobs.take()) } /// Remove and return a pending envelope by slot. - pub fn remove(&mut self, slot: Slot) -> Option> { - self.envelopes.remove(&slot).map(|d| d.envelope) + pub fn remove_by_slot(&mut self, slot: Slot) -> Option>> { + let beacon_block_root = self + .envelopes + .iter() + .find(|(_, data)| data.envelope.slot() == slot) + .map(|(root, _)| *root)?; + self.envelopes + .remove(&beacon_block_root) + .map(|data| data.envelope) } /// Check if an envelope exists for the given slot. - pub fn contains(&self, slot: Slot) -> bool { - self.envelopes.contains_key(&slot) + pub fn contains_slot(&self, slot: Slot) -> bool { + self.envelopes + .values() + .any(|data| data.envelope.slot() == slot) } /// Prune envelopes older than `current_slot - max_slot_age`. /// /// This removes stale envelopes from blocks that were never published. - // TODO(gloas) implement pruning pub fn prune(&mut self, current_slot: Slot) { let min_slot = current_slot.saturating_sub(self.max_slot_age); - self.envelopes.retain(|slot, _| *slot >= min_slot); + self.envelopes + .retain(|_, data| data.envelope.slot() >= min_slot); } /// Returns the number of pending envelopes in the cache. @@ -106,18 +119,18 @@ mod tests { type E = MainnetEthSpec; - fn make_envelope(slot: Slot) -> PendingEnvelopeData { + fn make_envelope(slot: Slot, beacon_block_root: Hash256) -> PendingEnvelopeData { PendingEnvelopeData { - envelope: ExecutionPayloadEnvelope { + envelope: Arc::new(ExecutionPayloadEnvelope { payload: ExecutionPayloadGloas { slot_number: slot, ..ExecutionPayloadGloas::default() }, execution_requests: ExecutionRequests::default(), builder_index: 0, - beacon_block_root: Hash256::ZERO, + beacon_block_root, parent_beacon_block_root: Hash256::ZERO, - }, + }), blobs: None, } } @@ -126,50 +139,53 @@ mod tests { fn insert_and_get() { let mut cache = PendingPayloadEnvelopes::::default(); let slot = Slot::new(1); - let data = make_envelope(slot); + let block_root = Hash256::repeat_byte(42); + let data = make_envelope(slot, block_root); let expected_envelope = data.envelope.clone(); - assert!(!cache.contains(slot)); + assert!(!cache.contains_slot(slot)); assert_eq!(cache.len(), 0); - cache.insert(slot, data); + cache.insert(data); - assert!(cache.contains(slot)); + assert!(cache.contains_slot(slot)); assert_eq!(cache.len(), 1); - assert_eq!(cache.get(slot), Some(&expected_envelope)); + assert_eq!(cache.get_by_slot(slot), Some(&expected_envelope)); + assert_eq!( + cache.get_by_block_root(block_root), + Some(&expected_envelope) + ); + assert_eq!(cache.get_by_block_root(Hash256::repeat_byte(43)), None); } #[test] - fn find_by_block_root() { + fn same_slot_different_roots_coexist() { let mut cache = PendingPayloadEnvelopes::::default(); let slot = Slot::new(1); - let block_root = Hash256::repeat_byte(42); + let root_a = Hash256::repeat_byte(1); + let root_b = Hash256::repeat_byte(2); - let mut data = make_envelope(slot); - data.envelope.beacon_block_root = block_root; - let expected_envelope = data.envelope.clone(); - cache.insert(slot, data); + cache.insert(make_envelope(slot, root_a)); + cache.insert(make_envelope(slot, root_b)); - assert_eq!( - cache.find_by_block_root(block_root), - Some(&expected_envelope) - ); - assert_eq!(cache.find_by_block_root(Hash256::repeat_byte(43)), None); + assert_eq!(cache.len(), 2); + assert!(cache.get_by_block_root(root_a).is_some()); + assert!(cache.get_by_block_root(root_b).is_some()); } #[test] - fn remove() { + fn remove_by_slot() { let mut cache = PendingPayloadEnvelopes::::default(); let slot = Slot::new(1); - let data = make_envelope(slot); + let data = make_envelope(slot, Hash256::repeat_byte(42)); let expected_envelope = data.envelope.clone(); - cache.insert(slot, data); - assert!(cache.contains(slot)); + cache.insert(data); + assert!(cache.contains_slot(slot)); - let removed = cache.remove(slot); + let removed = cache.remove_by_slot(slot); assert_eq!(removed, Some(expected_envelope)); - assert!(!cache.contains(slot)); + assert!(!cache.contains_slot(slot)); assert_eq!(cache.len(), 0); } @@ -177,38 +193,40 @@ mod tests { fn take_blobs_returns_once() { let mut cache = PendingPayloadEnvelopes::::default(); let slot = Slot::new(1); + let block_root = Hash256::repeat_byte(42); - let blobs = BlobsList::::default(); + let blobs = Arc::new(BlobsList::::default()); let data = PendingEnvelopeData { - envelope: make_envelope(slot).envelope, + envelope: make_envelope(slot, block_root).envelope, blobs: Some(blobs), }; - cache.insert(slot, data); + cache.insert(data); // First take returns the blobs - let taken = cache.take_blobs(slot); + let taken = cache.take_blobs(block_root); assert!(taken.is_some()); // Second take returns None — blobs are consumed - let taken_again = cache.take_blobs(slot); + let taken_again = cache.take_blobs(block_root); assert!(taken_again.is_none()); // Envelope is still in the cache - assert!(cache.contains(slot)); - assert!(cache.get(slot).is_some()); + assert!(cache.contains_slot(slot)); + assert!(cache.get_by_block_root(block_root).is_some()); } #[test] fn take_blobs_returns_none_when_absent() { let mut cache = PendingPayloadEnvelopes::::default(); let slot = Slot::new(1); + let block_root = Hash256::repeat_byte(42); // Insert with no blobs - cache.insert(slot, make_envelope(slot)); - assert!(cache.take_blobs(slot).is_none()); + cache.insert(make_envelope(slot, block_root)); + assert!(cache.take_blobs(block_root).is_none()); - // Non-existent slot - assert!(cache.take_blobs(Slot::new(99)).is_none()); + // Non-existent block root + assert!(cache.take_blobs(Hash256::repeat_byte(99)).is_none()); } #[test] @@ -217,11 +235,11 @@ mod tests { // Insert envelope at slot 5 let slot_1 = Slot::new(5); - cache.insert(slot_1, make_envelope(slot_1)); + cache.insert(make_envelope(slot_1, Hash256::repeat_byte(1))); // Insert envelope at slot 10 let slot_2 = Slot::new(10); - cache.insert(slot_2, make_envelope(slot_2)); + cache.insert(make_envelope(slot_2, Hash256::repeat_byte(2))); assert_eq!(cache.len(), 2); @@ -229,7 +247,7 @@ mod tests { cache.prune(Slot::new(10)); assert_eq!(cache.len(), 1); - assert!(!cache.contains(slot_1)); // slot 5 < 8, pruned - assert!(cache.contains(slot_2)); // slot 10 >= 8, kept + assert!(!cache.contains_slot(slot_1)); // slot 5 < 8, pruned + assert!(cache.contains_slot(slot_2)); // slot 10 >= 8, kept } } diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 7a96dd50881..473bcea5a59 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1287,7 +1287,7 @@ where .chain .pending_payload_envelopes .write() - .remove(slot) + .remove_by_slot(slot) .map(|envelope| { let epoch = slot.epoch(E::slots_per_epoch()); let domain = self.spec.get_domain( @@ -1299,7 +1299,7 @@ where let message = envelope.signing_root(domain); let signature = self.validator_keypairs[proposer_index].sk.sign(message); SignedExecutionPayloadEnvelope { - message: envelope, + message: Arc::unwrap_or_clone(envelope), signature, } }); diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index fcb09bbf552..007e31912fb 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -638,21 +638,20 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { .unwrap(); // The envelope + blobs should now be in the pending cache. - assert!( - harness - .chain - .pending_payload_envelopes - .read() - .contains(slot), - "Pending cache should contain an envelope for the produced slot" - ); + let block_root = harness + .chain + .pending_payload_envelopes + .read() + .get_by_slot(slot) + .expect("Pending cache should contain an envelope for the produced slot") + .beacon_block_root; // Take the blobs from the cache — this is what publish_execution_payload_envelope does. let blobs = harness .chain .pending_payload_envelopes .write() - .take_blobs(slot); + .take_blobs(block_root); assert!( blobs.is_some(), @@ -670,7 +669,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { .chain .pending_payload_envelopes .write() - .take_blobs(slot); + .take_blobs(block_root); assert!( second_take.is_none(), "Blobs should only be consumable once" @@ -682,7 +681,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { .chain .pending_payload_envelopes .read() - .get(slot) + .get_by_slot(slot) .is_some(), "Envelope should remain in cache after taking blobs" ); diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs index 7fce951dfd9..5412f80da28 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -19,16 +19,16 @@ use network::NetworkMessage; use ssz::{Decode, Encode}; use std::future::Future; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::mpsc::UnboundedSender; use tracing::{debug, error, info, warn}; -use tree_hash::TreeHash; use types::{ BlobsList, BlockImportSource, EthSpec, KzgProofs, SignedBlindedExecutionPayloadEnvelope, SignedExecutionPayloadEnvelope, }; use warp::{ Filter, Rejection, Reply, - hyper::{Body, Response}, + hyper::{Body, Response, StatusCode}, }; /// Request body for `POST beacon/execution_payload_envelopes`, selected via the @@ -39,6 +39,44 @@ pub enum SignedEnvelopeSubmission { /// Blinded envelope; the full envelope and blobs are reconstructed from the pending /// envelope cache (stateful flow). Blinded(Box>), + /// Bare full envelope without blobs, submitted with no `Eth-Execution-Payload-Blinded` + /// header; blobs are taken from the pending envelope cache if present. + Legacy(Box>), +} + +impl SignedEnvelopeSubmission { + fn from_ssz_bytes(payload_blinded: Option, bytes: &[u8]) -> Result { + let invalid_ssz = |e| warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")); + Ok(match payload_blinded { + Some(true) => Self::Blinded(Box::new( + SignedBlindedExecutionPayloadEnvelope::from_ssz_bytes(bytes) + .map_err(invalid_ssz)?, + )), + Some(false) => Self::Full(Box::new( + SignedExecutionPayloadEnvelopeContents::from_ssz_bytes(bytes) + .map_err(invalid_ssz)?, + )), + None => Self::Legacy(Box::new( + SignedExecutionPayloadEnvelope::from_ssz_bytes(bytes).map_err(invalid_ssz)?, + )), + }) + } + + fn from_json(payload_blinded: Option, bytes: &[u8]) -> Result { + let invalid_json = + |e| warp_utils::reject::custom_bad_request(format!("invalid JSON: {e:?}")); + Ok(match payload_blinded { + Some(true) => Self::Blinded(Box::new( + serde_json::from_slice(bytes).map_err(invalid_json)?, + )), + Some(false) => Self::Full(Box::new( + serde_json::from_slice(bytes).map_err(invalid_json)?, + )), + None => Self::Legacy(Box::new( + serde_json::from_slice(bytes).map_err(invalid_json)?, + )), + }) + } } // POST beacon/execution_payload_envelopes (SSZ) @@ -52,7 +90,7 @@ pub(crate) fn post_beacon_execution_payload_envelopes_ssz( .and(warp::path("beacon")) .and(warp::path("execution_payload_envelopes")) .and(warp::path::end()) - .and(warp::header::header::( + .and(warp::header::optional::( EXECUTION_PAYLOAD_BLINDED_HEADER, )) .and(warp::body::bytes()) @@ -60,31 +98,14 @@ pub(crate) fn post_beacon_execution_payload_envelopes_ssz( .and(chain_filter) .and(network_tx_filter) .then( - |payload_blinded: bool, + |payload_blinded: Option, body_bytes: Bytes, task_spawner: TaskSpawner, chain: Arc>, network_tx: UnboundedSender>| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { - let submission = if payload_blinded { - SignedEnvelopeSubmission::Blinded(Box::new( - SignedBlindedExecutionPayloadEnvelope::from_ssz_bytes(&body_bytes) - .map_err(|e| { - warp_utils::reject::custom_bad_request(format!( - "invalid SSZ: {e:?}" - )) - })?, - )) - } else { - SignedEnvelopeSubmission::Full(Box::new( - SignedExecutionPayloadEnvelopeContents::from_ssz_bytes(&body_bytes) - .map_err(|e| { - warp_utils::reject::custom_bad_request(format!( - "invalid SSZ: {e:?}" - )) - })?, - )) - }; + let submission = + SignedEnvelopeSubmission::from_ssz_bytes(payload_blinded, &body_bytes)?; publish_execution_payload_envelope(submission, chain, &network_tx).await }) }, @@ -103,7 +124,7 @@ pub(crate) fn post_beacon_execution_payload_envelopes( .and(warp::path("beacon")) .and(warp::path("execution_payload_envelopes")) .and(warp::path::end()) - .and(warp::header::header::( + .and(warp::header::optional::( EXECUTION_PAYLOAD_BLINDED_HEADER, )) .and(warp::body::bytes()) @@ -111,29 +132,14 @@ pub(crate) fn post_beacon_execution_payload_envelopes( .and(chain_filter.clone()) .and(network_tx_filter.clone()) .then( - |payload_blinded: bool, + |payload_blinded: Option, body_bytes: Bytes, task_spawner: TaskSpawner, chain: Arc>, network_tx: UnboundedSender>| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { - let submission = if payload_blinded { - SignedEnvelopeSubmission::Blinded(Box::new( - serde_json::from_slice(&body_bytes).map_err(|e| { - warp_utils::reject::custom_bad_request(format!( - "invalid JSON: {e:?}" - )) - })?, - )) - } else { - SignedEnvelopeSubmission::Full(Box::new( - serde_json::from_slice(&body_bytes).map_err(|e| { - warp_utils::reject::custom_bad_request(format!( - "invalid JSON: {e:?}" - )) - })?, - )) - }; + let submission = + SignedEnvelopeSubmission::from_json(payload_blinded, &body_bytes)?; publish_execution_payload_envelope(submission, chain, &network_tx).await }) }, @@ -154,6 +160,7 @@ pub async fn publish_execution_payload_envelope( )); } + let is_full_submission = matches!(&submission, SignedEnvelopeSubmission::Full(_)); let (envelope, blobs_and_proofs) = match submission { SignedEnvelopeSubmission::Full(contents) => { let SignedExecutionPayloadEnvelopeContents { @@ -161,12 +168,27 @@ pub async fn publish_execution_payload_envelope( kzg_proofs, blobs, } = *contents; - (envelope, Some((blobs, Some(kzg_proofs)))) + let expected_proofs = blobs.len() * T::EthSpec::number_of_columns(); + if kzg_proofs.len() != expected_proofs { + return Err(warp_utils::reject::custom_bad_request(format!( + "invalid number of kzg proofs: expected {}, got {}", + expected_proofs, + kzg_proofs.len() + ))); + } + (envelope, Some((Arc::new(blobs), Some(kzg_proofs)))) } SignedEnvelopeSubmission::Blinded(blinded) => { let (envelope, blobs) = unblind_envelope_from_cache(&chain, &blinded)?; (envelope, blobs.map(|blobs| (blobs, None))) } + SignedEnvelopeSubmission::Legacy(envelope) => { + let blobs = chain + .pending_payload_envelopes + .write() + .take_blobs(envelope.message.beacon_block_root); + (*envelope, blobs.map(|blobs| (blobs, None))) + } }; let slot = envelope.slot(); @@ -210,7 +232,9 @@ pub async fn publish_execution_payload_envelope( let network_tx_clone = network_tx.clone(); let envelope_for_gossip = gossip_verified.signed_envelope.as_ref().clone(); - let publish_fn = || { + let publish_fn_completed = Arc::new(AtomicBool::new(false)); + let publish_fn_completed_clone = publish_fn_completed.clone(); + let publish_fn = move || { crate::utils::publish_pubsub_message( &network_tx_clone, PubsubMessage::ExecutionPayload(Box::new(envelope_for_gossip)), @@ -219,7 +243,9 @@ pub async fn publish_execution_payload_envelope( EnvelopeError::BeaconChainError(Box::new( beacon_chain::BeaconChainError::UnableToPublish, )) - }) + })?; + publish_fn_completed_clone.store(true, Ordering::SeqCst); + Ok(()) }; let import_result = chain @@ -237,15 +263,22 @@ pub async fn publish_execution_payload_envelope( Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => false, Err(e) => { warn!(%slot, error = ?e, "Failed to import execution payload envelope"); - return Err(warp_utils::reject::custom_server_error(format!( - "envelope import failed: {e}" - ))); + // Per the spec, return 202 if the envelope was broadcast but failed integration. + return if publish_fn_completed.load(Ordering::SeqCst) { + Ok(warp::reply::with_status(warp::reply(), StatusCode::ACCEPTED).into_response()) + } else { + Err(warp_utils::reject::custom_server_error(format!( + "envelope import failed: {e}" + ))) + }; } }; - // From here on the envelope is on the wire, and for blinded submissions `take_blobs` + // From here on the envelope is on the wire. For full (stateless) submissions the caller + // still holds the blobs, so return an error on column-build/publish failure to allow a + // retry or failover to another beacon node. For blinded/legacy submissions `take_blobs` // already consumed the cache entry, so a retry would not republish columns; returning - // Err would mislead the caller. Log column-build/publish failures and fall through to `Ok`. + // Err would mislead the caller — log and fall through to `Ok`. if let Some(column_build_future) = column_build_future { let gossip_verified_columns = match column_build_future.await { Ok(columns) => columns, @@ -255,7 +288,11 @@ pub async fn publish_execution_payload_envelope( error = ?e, "Failed to build data columns after envelope publication" ); - return Ok(warp::reply().into_response()); + return if is_full_submission { + Err(e) + } else { + Ok(warp::reply().into_response()) + }; } }; @@ -266,7 +303,13 @@ pub async fn publish_execution_payload_envelope( error = ?e, "Failed to publish data column sidecars after envelope publication" ); - return Ok(warp::reply().into_response()); + return if is_full_submission { + Err(warp_utils::reject::custom_server_error(format!( + "failed to publish data column sidecars: {e:?}" + ))) + } else { + Ok(warp::reply().into_response()) + }; } let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); @@ -301,42 +344,32 @@ pub async fn publish_execution_payload_envelope( Ok(warp::reply().into_response()) } +/// A reconstructed signed execution payload envelope paired with any cached blobs. +type UnblindedEnvelope = (SignedExecutionPayloadEnvelope, Option>>); + /// Reconstruct the full signed envelope for a blinded submission from the pending envelope /// cache, along with any cached blobs. fn unblind_envelope_from_cache( chain: &BeaconChain, blinded: &SignedBlindedExecutionPayloadEnvelope, -) -> Result< - ( - SignedExecutionPayloadEnvelope, - Option>, - ), - Rejection, -> { +) -> Result, Rejection> { let mut cache = chain.pending_payload_envelopes.write(); let envelope = cache - .find_by_block_root(blinded.message.beacon_block_root) + .get_by_block_root(blinded.message.beacon_block_root) + .cloned() .ok_or_else(|| { warp_utils::reject::custom_bad_request(format!( "no cached execution payload envelope for beacon block root {}", blinded.message.beacon_block_root )) - })? - .clone(); - - // Equal roots make the submitted signature valid over the full envelope. - if envelope.tree_hash_root() != blinded.message.tree_hash_root() { - return Err(warp_utils::reject::custom_bad_request( - "blinded envelope does not match the cached envelope".into(), - )); - } + })?; - let blobs = cache.take_blobs(envelope.slot()); + let blobs = cache.take_blobs(blinded.message.beacon_block_root); Ok(( SignedExecutionPayloadEnvelope { - message: envelope, + message: Arc::unwrap_or_clone(envelope), signature: blinded.signature.clone(), }, blobs, @@ -347,7 +380,7 @@ fn spawn_build_gloas_data_columns_task( chain: &Arc>, beacon_block_root: types::Hash256, slot: types::Slot, - blobs: types::BlobsList, + blobs: Arc>, cell_proofs: Option>, ) -> Result>, Rejection>>, Rejection> { let chain_for_build = chain.clone(); diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 28a9977b661..962be24041f 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -178,9 +178,9 @@ pub fn build_response_v4( let ssz_bytes = match payload_contents { Some((execution_payload_envelope, kzg_proofs, blobs)) => BlockAndEnvelope { block, - execution_payload_envelope, + execution_payload_envelope: Arc::unwrap_or_clone(execution_payload_envelope), kzg_proofs, - blobs, + blobs: Arc::unwrap_or_clone(blobs), } .as_ssz_bytes(), None => block.as_ssz_bytes(), @@ -205,9 +205,11 @@ pub fn build_response_v4( metadata, data: BlockAndEnvelope { block, - execution_payload_envelope, + execution_payload_envelope: Arc::unwrap_or_clone( + execution_payload_envelope, + ), kzg_proofs, - blobs, + blobs: Arc::unwrap_or_clone(blobs), }, }) .into_response() diff --git a/beacon_node/http_api/src/validator/execution_payload_envelopes.rs b/beacon_node/http_api/src/validator/execution_payload_envelopes.rs index 3a20b37c9b3..0eb878d56a9 100644 --- a/beacon_node/http_api/src/validator/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/validator/execution_payload_envelopes.rs @@ -47,7 +47,7 @@ pub fn get_validator_execution_payload_envelopes( let envelope = chain .pending_payload_envelopes .read() - .get(slot) + .get_by_slot(slot) .cloned() .ok_or_else(|| { warp_utils::reject::custom_not_found(format!( @@ -72,7 +72,7 @@ pub fn get_validator_execution_payload_envelopes( let json_response = ForkVersionedResponse { version: fork_name, metadata: EmptyMetadata {}, - data: envelope, + data: envelope.as_ref(), }; Response::builder() .status(200) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 6b0005625c0..3ea2e87c61c 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4358,7 +4358,7 @@ impl ApiTester { .chain .pending_payload_envelopes .read() - .get(slot) + .get_by_slot(slot) .cloned() .expect("envelope should exist in pending cache for local building"); assert_eq!(envelope.beacon_block_root, block_root); @@ -4453,7 +4453,7 @@ impl ApiTester { let signed_envelope = self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); self.client - .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name) + .post_beacon_execution_payload_envelopes_blinded(&signed_envelope, fork_name) .await .unwrap(); @@ -4574,7 +4574,10 @@ impl ApiTester { // Clear the pending cache to simulate publishing via a beacon node that did not // produce the block, then publish the bundled envelope, blobs and proofs. - self.chain.pending_payload_envelopes.write().remove(slot); + self.chain + .pending_payload_envelopes + .write() + .remove_by_slot(slot); let signed_envelope = self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); let contents = SignedExecutionPayloadEnvelopeContents { @@ -4640,7 +4643,10 @@ impl ApiTester { // Clear the pending cache to simulate publishing via a beacon node that did not // produce the block, then publish the bundled envelope, blobs and proofs. - self.chain.pending_payload_envelopes.write().remove(slot); + self.chain + .pending_payload_envelopes + .write() + .remove_by_slot(slot); let signed_envelope = self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); let contents = SignedExecutionPayloadEnvelopeContents { @@ -4700,13 +4706,16 @@ impl ApiTester { // Clear the cache so there is no envelope to reconstruct the blinded // submission from. - self.chain.pending_payload_envelopes.write().remove(slot); + self.chain + .pending_payload_envelopes + .write() + .remove_by_slot(slot); let signed_envelope = self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); let err = self .client - .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name) + .post_beacon_execution_payload_envelopes_blinded(&signed_envelope, fork_name) .await .unwrap_err(); assert_eq!(err.status().unwrap(), 400); @@ -4745,10 +4754,10 @@ impl ApiTester { .chain .pending_payload_envelopes .read() - .get(slot) + .get_by_slot(slot) .cloned() .expect("envelope should exist in pending cache for local building"); - assert_eq!(block_contents.execution_payload_envelope, cached_envelope); + assert_eq!(block_contents.execution_payload_envelope, *cached_envelope); block_contents } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 866f12f5138..ac89c7a6ab0 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -455,53 +455,44 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, ) -> Result { - let builder = self - .client - .post(url) - .timeout(timeout.unwrap_or(self.timeouts.default)); - let response = builder - .header(CONSENSUS_VERSION_HEADER, fork.to_string()) - .json(body) - .send() - .await?; - success_or_error(response).await + self.post_generic_with_envelope_headers(url, body, timeout, fork, None) + .await } - /// Generic POST function with `Eth-Consensus-Version` and `Eth-Execution-Payload-Blinded` - /// headers. + /// Generic POST function with `Eth-Consensus-Version` and optional + /// `Eth-Execution-Payload-Blinded` headers. async fn post_generic_with_envelope_headers( &self, url: U, body: &T, timeout: Option, fork: ForkName, - payload_blinded: bool, + payload_blinded: Option, ) -> Result { - let builder = self + let mut builder = self .client .post(url) - .timeout(timeout.unwrap_or(self.timeouts.default)); - let response = builder - .header(CONSENSUS_VERSION_HEADER, fork.to_string()) - .header( + .timeout(timeout.unwrap_or(self.timeouts.default)) + .header(CONSENSUS_VERSION_HEADER, fork.to_string()); + if let Some(payload_blinded) = payload_blinded { + builder = builder.header( EXECUTION_PAYLOAD_BLINDED_HEADER, payload_blinded.to_string(), - ) - .json(body) - .send() - .await?; + ); + } + let response = builder.json(body).send().await?; success_or_error(response).await } - /// Generic POST function with `Eth-Consensus-Version` and `Eth-Execution-Payload-Blinded` - /// headers and an SSZ body. + /// Generic POST function with `Eth-Consensus-Version` and optional + /// `Eth-Execution-Payload-Blinded` headers and an SSZ body. async fn post_generic_with_envelope_headers_and_ssz_body, U: IntoUrl>( &self, url: U, body: T, timeout: Option, fork: ForkName, - payload_blinded: bool, + payload_blinded: Option, ) -> Result { let builder = self .client @@ -512,11 +503,13 @@ impl BeaconNodeHttpClient { CONSENSUS_VERSION_HEADER, HeaderValue::from_str(&fork.to_string()).expect("Failed to create header value"), ); - headers.insert( - EXECUTION_PAYLOAD_BLINDED_HEADER, - HeaderValue::from_str(&payload_blinded.to_string()) - .expect("Failed to create header value"), - ); + if let Some(payload_blinded) = payload_blinded { + headers.insert( + EXECUTION_PAYLOAD_BLINDED_HEADER, + HeaderValue::from_str(&payload_blinded.to_string()) + .expect("Failed to create header value"), + ); + } headers.insert( "Content-Type", HeaderValue::from_static("application/octet-stream"), @@ -550,21 +543,8 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, ) -> Result { - let builder = self - .client - .post(url) - .timeout(timeout.unwrap_or(self.timeouts.default)); - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&fork.to_string()).expect("Failed to create header value"), - ); - headers.insert( - "Content-Type", - HeaderValue::from_static("application/octet-stream"), - ); - let response = builder.headers(headers).body(body).send().await?; - success_or_error(response).await + self.post_generic_with_envelope_headers_and_ssz_body(url, body, timeout, fork, None) + .await } /// `GET beacon/genesis` @@ -2887,12 +2867,50 @@ impl BeaconNodeHttpClient { Ok(path) } + /// `POST v1/beacon/execution_payload_envelopes` + pub async fn post_beacon_execution_payload_envelopes( + &self, + envelope: &SignedExecutionPayloadEnvelope, + fork_name: ForkName, + ) -> Result<(), Error> { + let path = self.post_beacon_execution_payload_envelopes_path()?; + + self.post_generic_with_consensus_version( + path, + envelope, + Some(self.timeouts.proposal), + fork_name, + ) + .await?; + + Ok(()) + } + + /// `POST v1/beacon/execution_payload_envelopes` in SSZ format + pub async fn post_beacon_execution_payload_envelopes_ssz( + &self, + envelope: &SignedExecutionPayloadEnvelope, + fork_name: ForkName, + ) -> Result<(), Error> { + let path = self.post_beacon_execution_payload_envelopes_path()?; + + self.post_generic_with_consensus_version_and_ssz_body( + path, + envelope.as_ssz_bytes(), + Some(self.timeouts.proposal), + fork_name, + ) + .await?; + + Ok(()) + } + /// `POST v1/beacon/execution_payload_envelopes` /// /// Submits the blinded form of the envelope (stateful flow); the beacon node reconstructs /// the full envelope and blobs from the cache populated during block production, so this /// must be sent to the beacon node that produced the block. - pub async fn post_beacon_execution_payload_envelopes( + pub async fn post_beacon_execution_payload_envelopes_blinded( &self, envelope: &SignedExecutionPayloadEnvelope, fork_name: ForkName, @@ -2904,7 +2922,7 @@ impl BeaconNodeHttpClient { &envelope.clone_as_blinded(), Some(self.timeouts.proposal), fork_name, - true, + Some(true), ) .await?; @@ -2913,8 +2931,8 @@ impl BeaconNodeHttpClient { /// `POST v1/beacon/execution_payload_envelopes` in SSZ format /// - /// See [`Self::post_beacon_execution_payload_envelopes`] for the request semantics. - pub async fn post_beacon_execution_payload_envelopes_ssz( + /// See [`Self::post_beacon_execution_payload_envelopes_blinded`] for the request semantics. + pub async fn post_beacon_execution_payload_envelopes_blinded_ssz( &self, envelope: &SignedExecutionPayloadEnvelope, fork_name: ForkName, @@ -2926,7 +2944,7 @@ impl BeaconNodeHttpClient { envelope.clone_as_blinded().as_ssz_bytes(), Some(self.timeouts.proposal), fork_name, - true, + Some(true), ) .await?; @@ -2949,7 +2967,7 @@ impl BeaconNodeHttpClient { contents, Some(self.timeouts.proposal), fork_name, - false, + Some(false), ) .await?; @@ -2971,7 +2989,7 @@ impl BeaconNodeHttpClient { contents.as_ssz_bytes(), Some(self.timeouts.proposal), fork_name, - false, + Some(false), ) .await?; diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 69048068557..3d00b7fa4f5 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1931,6 +1931,7 @@ impl BlockAndEnvelope { /// [`BlockAndEnvelope`]. Callers should branch on the `Eth-Execution-Payload-Included` header to /// decide which variant to expect. #[derive(Debug, Clone, PartialEq)] +#[allow(clippy::large_enum_variant)] pub enum ProduceBlockV4Response { BlockOnly(BeaconBlock), BlockAndEnvelope(BlockAndEnvelope), From b160ef78f2159914a5df8c7bc1208ad4565e59d8 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Fri, 3 Jul 2026 16:00:00 +0300 Subject: [PATCH 4/8] remove uneeded changes --- .../src/beacon/execution_payload_envelopes.rs | 52 +++++++------------ beacon_node/http_api/tests/tests.rs | 4 +- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs index 0aff2768050..68431790591 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -40,42 +40,35 @@ pub enum SignedEnvelopeSubmission { /// Blinded envelope; the full envelope and blobs are reconstructed from the pending /// envelope cache (stateful flow). Blinded(Box>), - /// Bare full envelope without blobs, submitted with no `Eth-Execution-Payload-Blinded` - /// header; blobs are taken from the pending envelope cache if present. - Legacy(Box>), } impl SignedEnvelopeSubmission { - fn from_ssz_bytes(payload_blinded: Option, bytes: &[u8]) -> Result { + fn from_ssz_bytes(payload_blinded: bool, bytes: &[u8]) -> Result { let invalid_ssz = |e| warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")); - Ok(match payload_blinded { - Some(true) => Self::Blinded(Box::new( + Ok(if payload_blinded { + Self::Blinded(Box::new( SignedBlindedExecutionPayloadEnvelope::from_ssz_bytes(bytes) .map_err(invalid_ssz)?, - )), - Some(false) => Self::Full(Box::new( + )) + } else { + Self::Full(Box::new( SignedExecutionPayloadEnvelopeContents::from_ssz_bytes(bytes) .map_err(invalid_ssz)?, - )), - None => Self::Legacy(Box::new( - SignedExecutionPayloadEnvelope::from_ssz_bytes(bytes).map_err(invalid_ssz)?, - )), + )) }) } - fn from_json(payload_blinded: Option, bytes: &[u8]) -> Result { + fn from_json(payload_blinded: bool, bytes: &[u8]) -> Result { let invalid_json = |e| warp_utils::reject::custom_bad_request(format!("invalid JSON: {e:?}")); - Ok(match payload_blinded { - Some(true) => Self::Blinded(Box::new( - serde_json::from_slice(bytes).map_err(invalid_json)?, - )), - Some(false) => Self::Full(Box::new( + Ok(if payload_blinded { + Self::Blinded(Box::new( serde_json::from_slice(bytes).map_err(invalid_json)?, - )), - None => Self::Legacy(Box::new( + )) + } else { + Self::Full(Box::new( serde_json::from_slice(bytes).map_err(invalid_json)?, - )), + )) }) } } @@ -91,7 +84,7 @@ pub(crate) fn post_beacon_execution_payload_envelopes_ssz( .and(warp::path("beacon")) .and(warp::path("execution_payload_envelopes")) .and(warp::path::end()) - .and(warp::header::optional::( + .and(warp::header::header::( EXECUTION_PAYLOAD_BLINDED_HEADER, )) .and(warp::body::bytes()) @@ -99,7 +92,7 @@ pub(crate) fn post_beacon_execution_payload_envelopes_ssz( .and(chain_filter) .and(network_tx_filter) .then( - |payload_blinded: Option, + |payload_blinded: bool, body_bytes: Bytes, task_spawner: TaskSpawner, chain: Arc>, @@ -125,7 +118,7 @@ pub(crate) fn post_beacon_execution_payload_envelopes( .and(warp::path("beacon")) .and(warp::path("execution_payload_envelopes")) .and(warp::path::end()) - .and(warp::header::optional::( + .and(warp::header::header::( EXECUTION_PAYLOAD_BLINDED_HEADER, )) .and(warp::body::bytes()) @@ -133,7 +126,7 @@ pub(crate) fn post_beacon_execution_payload_envelopes( .and(chain_filter.clone()) .and(network_tx_filter.clone()) .then( - |payload_blinded: Option, + |payload_blinded: bool, body_bytes: Bytes, task_spawner: TaskSpawner, chain: Arc>, @@ -183,13 +176,6 @@ pub async fn publish_execution_payload_envelope( let (envelope, blobs) = unblind_envelope_from_cache(&chain, &blinded)?; (envelope, blobs.map(|blobs| (blobs, None))) } - SignedEnvelopeSubmission::Legacy(envelope) => { - let blobs = chain - .pending_payload_envelopes - .write() - .take_blobs(envelope.message.beacon_block_root); - (*envelope, blobs.map(|blobs| (blobs, None))) - } }; let slot = envelope.slot(); @@ -280,7 +266,7 @@ pub async fn publish_execution_payload_envelope( // From here on the envelope is on the wire. For full (stateless) submissions the caller // still holds the blobs, so return an error on column-build/publish failure to allow a - // retry or failover to another beacon node. For blinded/legacy submissions `take_blobs` + // retry or failover to another beacon node. For blinded submissions `take_blobs` // already consumed the cache entry, so a retry would not republish columns; returning // Err would mislead the caller — log and fall through to `Ok`. if let Some(column_build_future) = column_build_future { diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 3ea2e87c61c..6e53a03874b 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4453,7 +4453,7 @@ impl ApiTester { let signed_envelope = self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); self.client - .post_beacon_execution_payload_envelopes_blinded(&signed_envelope, fork_name) + .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name) .await .unwrap(); @@ -4715,7 +4715,7 @@ impl ApiTester { self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); let err = self .client - .post_beacon_execution_payload_envelopes_blinded(&signed_envelope, fork_name) + .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name) .await .unwrap_err(); assert_eq!(err.status().unwrap(), 400); From 63c9834de676f52485c7ae58b5ec0acceab9b958 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sat, 4 Jul 2026 09:23:33 +0300 Subject: [PATCH 5/8] Fix comments --- .../beacon_chain/src/pending_payload_envelopes.rs | 11 +++++------ .../src/beacon/execution_payload_envelopes.rs | 3 ++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs index d90dfb11e4c..a8ad373162d 100644 --- a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -1,10 +1,9 @@ -//! Provides the `PendingPayloadEnvelopes` cache for storing execution payload envelopes -//! that have been produced during local block production. +//! Provides the `PendingPayloadEnvelopes` cache for storing execution payload envelopes for +//! payloads built by this beacon node. //! -//! For local building, the envelope is created during block production. -//! This cache holds the envelopes temporarily until the validator fetches, signs, -//! and publishes the payload. - +//! The cache is populated during self-build block production or by builder bid +//! production. It holds the envelopes temporarily until the payload builder fetches, +//! signs, and publishes the envelope. use std::collections::HashMap; use std::sync::Arc; use types::{BlobsList, EthSpec, ExecutionPayloadEnvelope, Hash256, Slot}; diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs index 68431790591..941f1485753 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -38,7 +38,8 @@ pub enum SignedEnvelopeSubmission { /// Full envelope with blobs and KZG proofs (stateless flow). Full(Box>), /// Blinded envelope; the full envelope and blobs are reconstructed from the pending - /// envelope cache (stateful flow). + /// envelope cache (stateful flow). Used during self-build block production, or builder + /// bid production. Blinded(Box>), } From 4a99a8ba8d161dc593a36d9200a6924afb484b18 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sat, 4 Jul 2026 10:22:42 +0300 Subject: [PATCH 6/8] Backwards compat --- beacon_node/http_api/src/validator/mod.rs | 8 +++++++- beacon_node/http_api/src/version.rs | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index b37bd7d37db..ddc6fdac60f 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -6,7 +6,7 @@ use crate::utils::{ AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; -use crate::version::{V1, V2, V3, unsupported_version_rejection}; +use crate::version::{V1, V2, V3, V4, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; @@ -464,6 +464,12 @@ pub fn get_validator_blocks( // Use V4 block production for Gloas fork let fork_name = chain.spec.fork_name_at_slot::(slot); if fork_name.gloas_enabled() { + let mut query = query; + // `include_payload` (default `true`) is a v4-only concept; v2/v3 + // responses must remain block-only. + if endpoint_version != V4 { + query.include_payload = Some(false); + } produce_block_v4(accept_header, chain, slot, query).await } else if endpoint_version == V3 { produce_block_v3(accept_header, chain, slot, query).await diff --git a/beacon_node/http_api/src/version.rs b/beacon_node/http_api/src/version.rs index bba16414164..6f441636b49 100644 --- a/beacon_node/http_api/src/version.rs +++ b/beacon_node/http_api/src/version.rs @@ -15,6 +15,7 @@ use warp::reply::{self, Reply, Response}; pub const V1: EndpointVersion = EndpointVersion(1); pub const V2: EndpointVersion = EndpointVersion(2); pub const V3: EndpointVersion = EndpointVersion(3); +pub const V4: EndpointVersion = EndpointVersion(4); #[derive(Debug, PartialEq, Clone, Serialize)] pub enum ResponseIncludesVersion { From e715022ee5a7c2aa405d68e09c1c149fbe1ebc9f Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sat, 4 Jul 2026 10:50:54 +0300 Subject: [PATCH 7/8] Fix --- common/eth2/src/lib.rs | 50 +++++------------------------------------- 1 file changed, 6 insertions(+), 44 deletions(-) diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index f899213869a..78dea4b4261 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -2870,50 +2870,12 @@ impl BeaconNodeHttpClient { Ok(path) } - /// `POST v1/beacon/execution_payload_envelopes` - pub async fn post_beacon_execution_payload_envelopes( - &self, - envelope: &SignedExecutionPayloadEnvelope, - fork_name: ForkName, - ) -> Result<(), Error> { - let path = self.post_beacon_execution_payload_envelopes_path()?; - - self.post_generic_with_consensus_version( - path, - envelope, - Some(self.timeouts.proposal), - fork_name, - ) - .await?; - - Ok(()) - } - - /// `POST v1/beacon/execution_payload_envelopes` in SSZ format - pub async fn post_beacon_execution_payload_envelopes_ssz( - &self, - envelope: &SignedExecutionPayloadEnvelope, - fork_name: ForkName, - ) -> Result<(), Error> { - let path = self.post_beacon_execution_payload_envelopes_path()?; - - self.post_generic_with_consensus_version_and_ssz_body( - path, - envelope.as_ssz_bytes(), - Some(self.timeouts.proposal), - fork_name, - ) - .await?; - - Ok(()) - } - /// `POST v1/beacon/execution_payload_envelopes` /// /// Submits the blinded form of the envelope (stateful flow); the beacon node reconstructs - /// the full envelope and blobs from the cache populated during block production, so this - /// must be sent to the beacon node that produced the block. - pub async fn post_beacon_execution_payload_envelopes_blinded( + /// the full envelope and blobs from its cache, so this must be sent to the beacon node + /// that built the payload (self-build block production or builder bid production). + pub async fn post_beacon_execution_payload_envelopes( &self, envelope: &SignedExecutionPayloadEnvelope, fork_name: ForkName, @@ -2934,8 +2896,8 @@ impl BeaconNodeHttpClient { /// `POST v1/beacon/execution_payload_envelopes` in SSZ format /// - /// See [`Self::post_beacon_execution_payload_envelopes_blinded`] for the request semantics. - pub async fn post_beacon_execution_payload_envelopes_blinded_ssz( + /// See [`Self::post_beacon_execution_payload_envelopes`] for the request semantics. + pub async fn post_beacon_execution_payload_envelopes_ssz( &self, envelope: &SignedExecutionPayloadEnvelope, fork_name: ForkName, @@ -2957,7 +2919,7 @@ impl BeaconNodeHttpClient { /// `POST v1/beacon/execution_payload_envelopes` /// /// Submits the full envelope bundled with blobs and KZG proofs (stateless flow), allowing - /// publication via a beacon node that did not produce the block. + /// publication via a beacon node that did not build the payload. pub async fn post_beacon_execution_payload_envelope_contents( &self, contents: &SignedExecutionPayloadEnvelopeContents, From bcab92d50dde2a7b69a92b7e96f17f268a8cd564 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 5 Jul 2026 13:33:51 +0300 Subject: [PATCH 8/8] Fix --- beacon_node/http_api/src/validator/mod.rs | 10 +- .../http_api/tests/interactive_tests.rs | 27 +++-- beacon_node/http_api/tests/tests.rs | 100 ++++++++++++++++++ 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index ddc6fdac60f..0dbe531df41 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -461,15 +461,7 @@ pub fn get_validator_blocks( not_synced_filter?; - // Use V4 block production for Gloas fork - let fork_name = chain.spec.fork_name_at_slot::(slot); - if fork_name.gloas_enabled() { - let mut query = query; - // `include_payload` (default `true`) is a v4-only concept; v2/v3 - // responses must remain block-only. - if endpoint_version != V4 { - query.include_payload = Some(false); - } + if endpoint_version == V4 { produce_block_v4(accept_header, chain, slot, query).await } else if endpoint_version == V3 { produce_block_v3(accept_header, chain, slot, query).await diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 437da7d4ea6..675ef112523 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -805,14 +805,25 @@ pub async fn fork_choice_before_proposal() { let randao_reveal = harness .sign_randao_reveal(&state_b, proposer_index, slot_d) .into(); - let block_d = tester - .client - .get_validator_blocks::(slot_d, &randao_reveal, None) - .await - .unwrap() - .into_data() - .deconstruct() - .0; + // Post-Gloas, block production is only supported via the v4 endpoint. + let block_d = if harness.spec.fork_name_at_slot::(slot_d).gloas_enabled() { + tester + .client + .get_validator_blocks_v4::(slot_d, &randao_reveal, None, false, None, None) + .await + .unwrap() + .0 + .into_block() + } else { + tester + .client + .get_validator_blocks::(slot_d, &randao_reveal, None) + .await + .unwrap() + .into_data() + .deconstruct() + .0 + }; // Head is now B. assert_eq!( diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index e49b1bd39cf..a83b0890333 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4056,6 +4056,10 @@ impl ApiTester { } pub async fn test_block_production(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let fork = self.chain.canonical_head.cached_head().head_fork(); let genesis_validators_root = self.chain.genesis_validators_root; @@ -4120,6 +4124,10 @@ impl ApiTester { } pub async fn test_block_production_ssz(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let fork = self.chain.canonical_head.cached_head().head_fork(); let genesis_validators_root = self.chain.genesis_validators_root; @@ -4213,6 +4221,10 @@ impl ApiTester { } pub async fn test_block_production_v3_ssz(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let fork = self.chain.canonical_head.cached_head().head_fork(); let genesis_validators_root = self.chain.genesis_validators_root; @@ -4771,6 +4783,10 @@ impl ApiTester { } pub async fn test_block_production_no_verify_randao(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } for _ in 0..E::slots_per_epoch() { let slot = self.chain.slot().unwrap(); @@ -4795,6 +4811,10 @@ impl ApiTester { } pub async fn test_block_production_verify_randao_invalid(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let fork = self.chain.canonical_head.cached_head().head_fork(); let genesis_validators_root = self.chain.genesis_validators_root; @@ -5909,6 +5929,10 @@ impl ApiTester { } pub async fn test_payload_v3_respects_registration(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let slot = self.chain.slot().unwrap(); let epoch = self.chain.epoch().unwrap(); @@ -5936,6 +5960,10 @@ impl ApiTester { } pub async fn test_payload_v3_zero_builder_boost_factor(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let slot = self.chain.slot().unwrap(); let epoch = self.chain.epoch().unwrap(); @@ -5964,6 +5992,10 @@ impl ApiTester { } pub async fn test_payload_v3_max_builder_boost_factor(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let slot = self.chain.slot().unwrap(); let epoch = self.chain.epoch().unwrap(); @@ -6115,6 +6147,10 @@ impl ApiTester { } pub async fn test_payload_v3_accepts_mutated_gas_limit(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } // Mutate gas limit. self.mock_builder .as_ref() @@ -6192,6 +6228,10 @@ impl ApiTester { } pub async fn test_payload_v3_accepts_changed_fee_recipient(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let test_fee_recipient = "0x4242424242424242424242424242424242424242" .parse::
() .unwrap(); @@ -6279,6 +6319,10 @@ impl ApiTester { } pub async fn test_payload_v3_rejects_invalid_parent_hash(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let invalid_parent_hash = "0x4242424242424242424242424242424242424242424242424242424242424242" .parse::() @@ -6372,6 +6416,10 @@ impl ApiTester { } pub async fn test_payload_v3_rejects_invalid_prev_randao(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let invalid_prev_randao = "0x4242424242424242424242424242424242424242424242424242424242424242" .parse::() @@ -6463,6 +6511,10 @@ impl ApiTester { } pub async fn test_payload_v3_rejects_invalid_block_number(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let invalid_block_number = 2; // Mutate block number. @@ -6553,6 +6605,10 @@ impl ApiTester { } pub async fn test_payload_v3_rejects_invalid_timestamp(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let invalid_timestamp = 2; // Mutate timestamp. @@ -6627,6 +6683,10 @@ impl ApiTester { } pub async fn test_payload_v3_rejects_invalid_signature(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } self.mock_builder.as_ref().unwrap().invalid_signatures(); let slot = self.chain.slot().unwrap(); @@ -6691,6 +6751,10 @@ impl ApiTester { } pub async fn test_builder_v3_chain_health_skips(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let slot = self.chain.slot().unwrap(); // Since we are proposing this slot, start the count from the previous slot. @@ -6800,6 +6864,10 @@ impl ApiTester { } pub async fn test_builder_v3_chain_health_skips_per_epoch(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } // Fill an epoch with `builder_fallback_skips_per_epoch` skip slots. for i in 0..E::slots_per_epoch() { if i == 0 || i as usize > self.chain.config.builder_fallback_skips_per_epoch { @@ -6951,6 +7019,10 @@ impl ApiTester { } pub async fn test_builder_v3_chain_health_epochs_since_finalization(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } let skips = E::slots_per_epoch() * self.chain.config.builder_fallback_epochs_since_finalization as u64; @@ -7071,6 +7143,10 @@ impl ApiTester { } pub async fn test_builder_v3_chain_health_optimistic_head(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } // Make sure the next payload verification will return optimistic before advancing the chain. self.harness.mock_execution_layer.as_ref().inspect(|el| { el.server.all_payloads_syncing(true); @@ -7150,6 +7226,10 @@ impl ApiTester { } pub async fn test_builder_payload_v3_chosen_when_more_profitable(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } // Mutate value. self.mock_builder .as_ref() @@ -7219,6 +7299,10 @@ impl ApiTester { } pub async fn test_local_payload_v3_chosen_when_equally_profitable(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } // Mutate value. self.mock_builder .as_ref() @@ -7288,6 +7372,10 @@ impl ApiTester { } pub async fn test_local_payload_v3_chosen_when_more_profitable(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } // Mutate value. self.mock_builder .as_ref() @@ -7356,6 +7444,10 @@ impl ApiTester { } pub async fn test_builder_works_post_deneb(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } // Ensure builder payload is chosen self.mock_builder .as_ref() @@ -7425,6 +7517,10 @@ impl ApiTester { } pub async fn test_lighthouse_rejects_invalid_withdrawals_root_v3(self) -> Self { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return self; + } // Ensure builder payload *would be* chosen self.mock_builder .as_ref() @@ -8084,6 +8180,10 @@ impl ApiTester { } pub async fn test_check_optimistic_responses(&mut self) { + // Pre-Gloas endpoint test; post-Gloas block production is v4-only. + if self.chain.spec.is_gloas_scheduled() { + return; + } // Check responses are not optimistic. let result = self .client