diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 7eff6fb99a3..c0737442082 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -6953,6 +6953,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 f84ad65e12a..05a2ce4c953 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, ExecutionRequestsGloas, FullPayload, Graffiti, - Hash256, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, + Hash256, KzgProofs, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, SyncAggregate, Withdrawal, Withdrawals, }; @@ -48,7 +48,19 @@ 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 = ( + Arc>, + KzgProofs, + Arc>, +); + +type BlockProductionResult = ( + BeaconBlock, + BeaconState, + ConsensusBlockValue, + Option>, +); pub type PreparePayloadResult = Result, BlockProductionError>; pub type PreparePayloadHandle = JoinHandle>>; @@ -653,7 +665,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 { @@ -681,23 +693,25 @@ 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, _) = payload_data.blobs_and_proofs; - self.pending_payload_envelopes.write().insert( - envelope_slot, - PendingEnvelopeData { - envelope: signed_envelope.message, - blobs: Some(blobs), - }, - ); + let (blobs, kzg_proofs) = payload_data.blobs_and_proofs; + 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((envelope, kzg_proofs, blobs)) + } else { + None + }; metrics::inc_counter(&metrics::BLOCK_PRODUCTION_SUCCESSES); @@ -708,7 +722,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/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index bc803efe932..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( @@ -376,6 +346,69 @@ 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![]); + } + + 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(), + 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>>()?; + + Ok(blob_cells_and_proofs_vec) +} + /// 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/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/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs index 5d4e461f265..a8ad373162d 100644 --- a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -1,27 +1,27 @@ -//! 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 types::{BlobsList, EthSpec, ExecutionPayloadEnvelope, Slot}; +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,39 +42,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 the beacon block root it commits to. + pub fn get_by_block_root( + &self, + beacon_block_root: Hash256, + ) -> Option<&Arc>> { + self.envelopes + .get(&beacon_block_root) + .map(|data| &data.envelope) } - /// 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 slot. + pub fn get_by_slot(&self, slot: Slot) -> Option<&Arc>> { + self.envelopes + .values() + .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. @@ -95,18 +118,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: ExecutionRequestsGloas::default(), builder_index: 0, - beacon_block_root: Hash256::ZERO, + beacon_block_root, parent_beacon_block_root: Hash256::ZERO, - }, + }), blobs: None, } } @@ -115,32 +138,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 same_slot_different_roots_coexist() { + let mut cache = PendingPayloadEnvelopes::::default(); + let slot = Slot::new(1); + let root_a = Hash256::repeat_byte(1); + let root_b = Hash256::repeat_byte(2); + + cache.insert(make_envelope(slot, root_a)); + cache.insert(make_envelope(slot, root_b)); + + 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); } @@ -148,38 +192,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] @@ -188,11 +234,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); @@ -200,7 +246,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 ce51a82cec9..473bcea5a59 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, @@ -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 4a2b8121a43..83949e699b8 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -623,7 +623,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, @@ -640,21 +640,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(), @@ -672,7 +671,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" @@ -684,7 +683,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 bad05ec9191..941f1485753 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -12,21 +12,68 @@ 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}; 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 types::{BlockImportSource, EthSpec, SignedExecutionPayloadEnvelope}; +use types::{ + BlobsList, BlockImportSource, EthSpec, KzgProofs, SignedBlindedExecutionPayloadEnvelope, + SignedExecutionPayloadEnvelope, +}; use warp::{ Filter, Rejection, http::response::Builder, reply::{Reply, 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). Used during self-build block production, or builder + /// bid production. + Blinded(Box>), +} + +impl SignedEnvelopeSubmission { + 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(if payload_blinded { + Self::Blinded(Box::new( + SignedBlindedExecutionPayloadEnvelope::from_ssz_bytes(bytes) + .map_err(invalid_ssz)?, + )) + } else { + Self::Full(Box::new( + SignedExecutionPayloadEnvelopeContents::from_ssz_bytes(bytes) + .map_err(invalid_ssz)?, + )) + }) + } + + fn from_json(payload_blinded: bool, bytes: &[u8]) -> Result { + let invalid_json = + |e| warp_utils::reject::custom_bad_request(format!("invalid JSON: {e:?}")); + Ok(if payload_blinded { + Self::Blinded(Box::new( + serde_json::from_slice(bytes).map_err(invalid_json)?, + )) + } else { + Self::Full(Box::new( + serde_json::from_slice(bytes).map_err(invalid_json)?, + )) + }) + } +} + // POST beacon/execution_payload_envelopes (SSZ) pub(crate) fn post_beacon_execution_payload_envelopes_ssz( eth_v1: EthV1Filter, @@ -38,22 +85,23 @@ 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 = + SignedEnvelopeSubmission::from_ssz_bytes(payload_blinded, &body_bytes)?; + publish_execution_payload_envelope(submission, chain, &network_tx).await }) }, ) @@ -71,17 +119,23 @@ 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 = + SignedEnvelopeSubmission::from_json(payload_blinded, &body_bytes)?; + publish_execution_payload_envelope(submission, chain, &network_tx).await }) }, ) @@ -91,19 +145,43 @@ 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 { - 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 is_full_submission = matches!(&submission, SignedEnvelopeSubmission::Full(_)); + let (envelope, blobs_and_proofs) = match submission { + SignedEnvelopeSubmission::Full(contents) => { + let SignedExecutionPayloadEnvelopeContents { + signed_execution_payload_envelope: envelope, + kzg_proofs, + blobs, + } = *contents; + 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))) + } + }; + + let slot = envelope.slot(); + let beacon_block_root = envelope.message.beacon_block_root; + info!( %slot, %beacon_block_root, @@ -111,20 +189,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, }; @@ -141,7 +220,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)), @@ -150,7 +231,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 @@ -168,15 +251,25 @@ 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(), warp::http::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. `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. 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 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 { let gossip_verified_columns = match column_build_future.await { Ok(columns) => columns, @@ -186,7 +279,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()) + }; } }; @@ -197,7 +294,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()); @@ -232,17 +335,58 @@ 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, Rejection> { + let mut cache = chain.pending_payload_envelopes.write(); + + let envelope = cache + .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 + )) + })?; + + let blobs = cache.take_blobs(blinded.message.beacon_block_root); + + Ok(( + SignedExecutionPayloadEnvelope { + message: Arc::unwrap_or_clone(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, + blobs: Arc>, + 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()))?; @@ -259,15 +403,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/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index f2514e6c315..e93095047ae 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 { @@ -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,64 @@ 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) => Builder::new() - .status(200) - .body(block.as_ssz_bytes()) - .map(add_ssz_content_type_header) - .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)) - .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: Arc::unwrap_or_clone(execution_payload_envelope), + kzg_proofs, + blobs: Arc::unwrap_or_clone(blobs), + } + .as_ssz_bytes(), + None => block.as_ssz_bytes(), + }; + Builder::new() + .status(200) + .body(ssz_bytes) + .map(add_ssz_content_type_header) + .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: Arc::unwrap_or_clone( + execution_payload_envelope, + ), + kzg_proofs, + blobs: Arc::unwrap_or_clone(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/src/validator/execution_payload_envelopes.rs b/beacon_node/http_api/src/validator/execution_payload_envelopes.rs index e5c9cbe0db1..428f78dfa18 100644 --- a/beacon_node/http_api/src/validator/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/validator/execution_payload_envelopes.rs @@ -46,7 +46,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(), }; Builder::new() .status(200) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index b37bd7d37db..0dbe531df41 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; @@ -461,9 +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() { + 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/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 { 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/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 bd2a6f17559..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; @@ -4366,7 +4378,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); @@ -4407,8 +4419,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; } @@ -4432,10 +4445,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); @@ -4470,8 +4483,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; } @@ -4493,11 +4507,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); @@ -4531,7 +4546,247 @@ 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 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 = + 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)); + + // 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_by_slot(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_envelope_contents(&contents, 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 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 = + 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)); + + // 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_by_slot(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_envelope_contents_ssz(&contents, fork_name) + .await + .unwrap(); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + } + + 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_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) + .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 contents for signing/publishing. + fn unwrap_v4_block_contents( + &self, + response: ProduceBlockV4Response, + metadata: &ProduceBlockV4Metadata, + slot: Slot, + ) -> BlockAndEnvelope { + // 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") + } + }; + + 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 + .chain + .pending_payload_envelopes + .read() + .get_by_slot(slot) + .cloned() + .expect("envelope should exist in pending cache for local building"); + assert_eq!(block_contents.execution_payload_envelope, *cached_envelope); + + block_contents + } + 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(); @@ -4556,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; @@ -4946,10 +5205,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); @@ -5029,10 +5288,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); @@ -5670,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(); @@ -5697,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(); @@ -5725,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(); @@ -5876,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() @@ -5953,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(); @@ -6040,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::() @@ -6133,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::() @@ -6224,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. @@ -6314,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. @@ -6388,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(); @@ -6452,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. @@ -6561,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 { @@ -6712,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; @@ -6832,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); @@ -6911,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() @@ -6980,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() @@ -7049,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() @@ -7117,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() @@ -7186,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() @@ -7845,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 @@ -8066,7 +8405,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, None, ) @@ -8081,7 +8420,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, Some(GraffitiPolicy::AppendClientVersions), ) @@ -8106,7 +8445,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, Some(GraffitiPolicy::PreserveUserGraffiti), ) @@ -8713,15 +9052,15 @@ 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_with_payload_ssz() .await - .test_block_production_v4_ssz() + .test_block_production_v4_blinded_envelope_no_cache() .await; } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 24bb873992a..78dea4b4261 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -457,16 +457,67 @@ impl BeaconNodeHttpClient { body: &T, timeout: Option, fork: ForkName, + ) -> Result { + self.post_generic_with_envelope_headers(url, body, timeout, fork, None) + .await + } + + /// 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: Option, + ) -> Result { + let mut builder = self + .client + .post(url) + .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(), + ); + } + let response = builder.json(body).send().await?; + success_or_error(response).await + } + + /// 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: Option, ) -> 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?; + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&fork.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"), + ); + let response = builder.headers(headers).body(body).send().await?; success_or_error(response).await } @@ -495,21 +546,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` @@ -2568,7 +2606,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 { @@ -2593,10 +2631,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() @@ -2620,16 +2656,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, @@ -2643,6 +2673,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, @@ -2650,16 +2684,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, @@ -2678,12 +2706,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?; @@ -2697,10 +2743,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, @@ -2714,6 +2760,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, @@ -2721,10 +2769,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, @@ -2746,14 +2794,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?; @@ -2799,12 +2858,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() @@ -2812,11 +2867,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 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, + ) -> 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, + Some(true), ) .await?; @@ -2824,23 +2895,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, + Some(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 build the payload. + 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, + Some(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, + Some(false), ) .await?; diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index f2b9a5ca64f..ae2a920d777 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1863,6 +1863,108 @@ 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)] +#[allow(clippy::large_enum_variant)] +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, + } + } +} + +/// 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..9c8f1dc4f61 --- /dev/null +++ b/consensus/types/src/execution/blinded_execution_payload_envelope.rs @@ -0,0 +1,71 @@ +use crate::execution::ExecutionRequestsGloas; +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: ExecutionRequestsGloas, + #[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: ExecutionRequestsGloas::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 befc27ceab6..b08ee2e1a92 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}; @@ -40,6 +43,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, 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)?