diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index bac9311101d..ef6009c2ace 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -7034,6 +7034,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 b81f013ab09..a3f7ee2eb41 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, Uint256, Withdrawal, Withdrawals, }; @@ -48,14 +48,23 @@ pub const BID_VALUE_SELF_BUILD: u64 = 0; pub const EXECUTION_PAYMENT_TRUSTLESS_BUILD: u64 = 0; type ConsensusBlockValue = u64; + +pub type PayloadEnvelopeContents = ( + Arc>, + KzgProofs, + Arc>, +); + /// Execution payload value in wei: the local payload's EL value when self-building, or the /// bid value when committing to a builder bid. type ExecutionPayloadValue = Uint256; + type BlockProductionResult = ( BeaconBlock, BeaconState, ConsensusBlockValue, ExecutionPayloadValue, + Option>, ); pub type PreparePayloadResult = Result, BlockProductionError>; @@ -537,11 +546,14 @@ impl BeaconChain { /// Complete a block by computing its state root, and /// - /// Return `(block, post_block_state, consensus_block_value, execution_payload_value)` where: + /// Return `(block, post_block_state, consensus_block_value, execution_payload_value, + /// payload_contents)` where: /// /// - `post_block_state` is the state post block application /// - `consensus_block_value` is the consensus-layer rewards for `block` /// - `execution_payload_value` is the wei value of the winning payload bid + /// - `payload_contents` is the locally-built envelope, KZG proofs and blobs (`None` when + /// committing to a builder bid) #[instrument(skip_all, level = "debug")] fn complete_partial_beacon_block_gloas( &self, @@ -682,7 +694,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 { @@ -710,23 +722,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); @@ -737,7 +751,13 @@ impl BeaconChain { "Produced beacon block" ); - Ok((block, state, consensus_block_value, execution_payload_value)) + Ok(( + block, + state, + consensus_block_value, + execution_payload_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/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index e414c058916..b95e55c637a 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -1,4 +1,3 @@ -use std::assert_matches; use std::sync::Arc; use std::time::Duration; @@ -413,7 +412,10 @@ fn invalid_bid_slot() { ctx.genesis_block_root, ); let result = GossipVerifiedPayloadBid::new(bid, &gossip); - assert_matches!(result, Err(PayloadBidError::InvalidBidSlot { .. })); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); } #[test] diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs index 5d4e461f265..cb9c01713dc 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,45 @@ 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); - } - - /// Get a pending envelope by slot. - pub fn get(&self, slot: Slot) -> Option<&ExecutionPayloadEnvelope> { - self.envelopes.get(&slot).map(|d| &d.envelope) + /// 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); } - /// 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()) + /// 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) } - /// Remove and return a pending envelope by slot. - pub fn remove(&mut self, slot: Slot) -> Option> { - self.envelopes.remove(&slot).map(|d| d.envelope) + /// 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()) } - /// Check if an envelope exists for the given slot. - pub fn contains(&self, slot: Slot) -> bool { - self.envelopes.contains_key(&slot) + /// Remove and return a pending envelope by the beacon block root it commits to. + pub fn remove( + &mut self, + beacon_block_root: Hash256, + ) -> Option>> { + self.envelopes + .remove(&beacon_block_root) + .map(|data| data.envelope) } /// 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 +101,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 +121,51 @@ 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_eq!(cache.len(), 0); - cache.insert(slot, data); + cache.insert(data); - assert!(cache.contains(slot)); assert_eq!(cache.len(), 1); - assert_eq!(cache.get(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() { 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(); - cache.insert(slot, data); - assert!(cache.contains(slot)); + cache.insert(data); + assert!(cache.get_by_block_root(block_root).is_some()); - let removed = cache.remove(slot); + let removed = cache.remove(block_root); assert_eq!(removed, Some(expected_envelope)); - assert!(!cache.contains(slot)); + assert!(cache.get_by_block_root(block_root).is_none()); assert_eq!(cache.len(), 0); } @@ -148,38 +173,39 @@ 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.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 +214,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 +226,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.get_by_block_root(Hash256::repeat_byte(1)).is_none()); // slot 5 < 8, pruned + assert!(cache.get_by_block_root(Hash256::repeat_byte(2)).is_some()); // 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 a1929137205..141506ee059 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1286,7 +1286,13 @@ where None }; - let (block, post_block_state, _consensus_block_value, _execution_payload_value) = self + let ( + block, + post_block_state, + _consensus_block_value, + _execution_payload_value, + _payload_contents, + ) = self .chain .produce_block_on_state_gloas( state, @@ -1314,7 +1320,7 @@ where .chain .pending_payload_envelopes .write() - .remove(slot) + .remove(signed_block.canonical_root()) .map(|envelope| { let epoch = slot.epoch(E::slots_per_epoch()); let domain = self.spec.get_domain( @@ -1326,7 +1332,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 b23ab6986d5..6de1c75348a 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, _payload_value) = harness + let (block, _post_state, _value, _payload_value, _payload_contents) = harness .chain .produce_block_on_state_gloas( state, @@ -639,14 +639,16 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { .await .unwrap(); - // The envelope + blobs should now be in the pending cache. + // The envelope + blobs should now be in the pending cache, keyed by the block root. + let block_root = block.canonical_root(); assert!( harness .chain .pending_payload_envelopes .read() - .contains(slot), - "Pending cache should contain an envelope for the produced slot" + .get_by_block_root(block_root) + .is_some(), + "Pending cache should contain an envelope for the produced block" ); // Take the blobs from the cache — this is what publish_execution_payload_envelope does. @@ -654,7 +656,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { .chain .pending_payload_envelopes .write() - .take_blobs(slot); + .take_blobs(block_root); assert!( blobs.is_some(), @@ -672,7 +674,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 +686,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { .chain .pending_payload_envelopes .read() - .get(slot) + .get_by_block_root(block_root) .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 914432b965f..85b6c6236c4 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelopes.rs @@ -1,7 +1,10 @@ use crate::block_id::BlockId; use crate::publish_blocks::{check_slashable, publish_column_sidecars}; use crate::task_spawner::{Priority, TaskSpawner}; -use crate::utils::{ChainFilter, EthV1Filter, NetworkTxFilter, ResponseFilter, TaskSpawnerFilter}; +use crate::utils::{ + ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, ResponseFilter, + TaskSpawnerFilter, +}; use crate::version::{ ResponseIncludesVersion, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, @@ -15,7 +18,7 @@ use beacon_chain::{ use bytes::Bytes; use eth2::{ BLOB_DATA_INCLUDED_HEADER, CONSENSUS_VERSION_HEADER, - types::{self as api_types, BroadcastValidation}, + types::{self as api_types, BroadcastValidation, SignedExecutionPayloadEnvelopeContents}, }; use lighthouse_network::PubsubMessage; use network::NetworkMessage; @@ -25,19 +28,70 @@ 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, ForkName, SignedExecutionPayloadEnvelope}; +use types::{BlockImportSource, EthSpec, ForkName, KzgProofs, SignedExecutionPayloadEnvelope}; use warp::{ Filter, Rejection, http::response::Builder, reply::{Reply, Response}, }; +/// Request body for `POST beacon/execution_payload_envelopes`, selected via the +/// `Eth-Blob-Data-Included` header. +pub enum SignedEnvelopeSubmission { + /// Envelope only (stateful flow); blobs and KZG proofs are taken from the node's + /// pending payload envelope cache. + EnvelopeOnly(Box>), + /// Full envelope bundled with blobs and KZG proofs (stateless flow), allowing + /// publication via a beacon node that did not build the payload. + EnvelopeAndBlobData(Box>), +} + +fn ensure_gloas_consensus_version(fork_name: ForkName) -> Result<(), Rejection> { + if !fork_name.gloas_enabled() { + return Err(warp_utils::reject::custom_bad_request(format!( + "Eth-Consensus-Version {fork_name} is not supported for execution payload envelopes" + ))); + } + Ok(()) +} + +impl SignedEnvelopeSubmission { + fn from_ssz_bytes(blob_data_included: bool, bytes: &[u8]) -> Result { + let invalid_ssz = |e| warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")); + Ok(if blob_data_included { + Self::EnvelopeAndBlobData(Box::new( + SignedExecutionPayloadEnvelopeContents::from_ssz_bytes(bytes) + .map_err(invalid_ssz)?, + )) + } else { + Self::EnvelopeOnly(Box::new( + SignedExecutionPayloadEnvelope::from_ssz_bytes(bytes).map_err(invalid_ssz)?, + )) + }) + } + + fn from_json(blob_data_included: bool, bytes: &[u8]) -> Result { + let invalid_json = + |e| warp_utils::reject::custom_bad_request(format!("invalid JSON: {e:?}")); + Ok(if blob_data_included { + Self::EnvelopeAndBlobData(Box::new( + serde_json::from_slice(bytes).map_err(invalid_json)?, + )) + } else { + Self::EnvelopeOnly(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, task_spawner_filter: TaskSpawnerFilter, chain_filter: ChainFilter, network_tx_filter: NetworkTxFilter, + not_while_syncing_filter: NotWhileSyncingFilter, ) -> ResponseFilter { eth_v1 .and(warp::path("beacon")) @@ -47,26 +101,26 @@ pub(crate) fn post_beacon_execution_payload_envelopes_ssz( .and(warp::header::(CONSENSUS_VERSION_HEADER)) .and(warp::header::(BLOB_DATA_INCLUDED_HEADER)) .and(warp::body::bytes()) + .and(not_while_syncing_filter) .and(task_spawner_filter) .and(chain_filter) .and(network_tx_filter) .then( |validation_level: api_types::BroadcastValidationQuery, - _fork_name: ForkName, + fork_name: ForkName, blob_data_included: bool, body_bytes: Bytes, + not_synced_filter: Result<(), Rejection>, task_spawner: TaskSpawner, chain: Arc>, network_tx: UnboundedSender>| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { - ensure_stateful_submission(blob_data_included)?; - let envelope = - SignedExecutionPayloadEnvelope::::from_ssz_bytes(&body_bytes) - .map_err(|e| { - warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) - })?; + not_synced_filter?; + ensure_gloas_consensus_version(fork_name)?; + let submission = + SignedEnvelopeSubmission::from_ssz_bytes(blob_data_included, &body_bytes)?; publish_execution_payload_envelope( - envelope, + submission, validation_level.broadcast_validation, chain, &network_tx, @@ -84,6 +138,7 @@ pub(crate) fn post_beacon_execution_payload_envelopes( task_spawner_filter: TaskSpawnerFilter, chain_filter: ChainFilter, network_tx_filter: NetworkTxFilter, + not_while_syncing_filter: NotWhileSyncingFilter, ) -> ResponseFilter { eth_v1 .and(warp::path("beacon")) @@ -93,24 +148,26 @@ pub(crate) fn post_beacon_execution_payload_envelopes( .and(warp::header::(CONSENSUS_VERSION_HEADER)) .and(warp::header::(BLOB_DATA_INCLUDED_HEADER)) .and(warp::body::bytes()) + .and(not_while_syncing_filter) .and(task_spawner_filter) .and(chain_filter) .and(network_tx_filter) .then( |validation_level: api_types::BroadcastValidationQuery, - _fork_name: ForkName, + fork_name: ForkName, blob_data_included: bool, body_bytes: Bytes, + not_synced_filter: Result<(), Rejection>, task_spawner: TaskSpawner, chain: Arc>, network_tx: UnboundedSender>| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { - ensure_stateful_submission(blob_data_included)?; - let envelope = serde_json::from_slice(&body_bytes).map_err(|e| { - warp_utils::reject::custom_bad_request(format!("invalid JSON: {e:?}")) - })?; + not_synced_filter?; + ensure_gloas_consensus_version(fork_name)?; + let submission = + SignedEnvelopeSubmission::from_json(blob_data_included, &body_bytes)?; publish_execution_payload_envelope( - envelope, + submission, validation_level.broadcast_validation, chain, &network_tx, @@ -122,33 +179,55 @@ pub(crate) fn post_beacon_execution_payload_envelopes( .boxed() } -// TODO(gloas): support Eth-Blob-Data-Included: true (stateless -// SignedExecutionPayloadEnvelopeContents) instead of rejecting it (#9568 / #8828). -fn ensure_stateful_submission(blob_data_included: bool) -> Result<(), Rejection> { - if blob_data_included { - return Err(warp_utils::reject::custom_bad_request( - "SignedExecutionPayloadEnvelopeContents is not supported".into(), - )); - } - Ok(()) -} - -/// Publishes a signed execution payload envelope to the network. +/// Publishes a signed execution payload envelope to the network. Implements +/// `POST /eth/v1/beacon/execution_payload_envelopes` per the in-flight beacon-APIs PRs +/// and +/// . pub async fn publish_execution_payload_envelope( - envelope: SignedExecutionPayloadEnvelope, + submission: SignedEnvelopeSubmission, validation_level: BroadcastValidation, 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 includes_blob_data = matches!( + &submission, + SignedEnvelopeSubmission::EnvelopeAndBlobData(_) + ); + let (envelope, blobs_and_proofs) = match submission { + SignedEnvelopeSubmission::EnvelopeAndBlobData(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::EnvelopeOnly(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(); + let beacon_block_root = envelope.message.beacon_block_root; + info!( %slot, %beacon_block_root, @@ -156,23 +235,21 @@ pub async fn publish_execution_payload_envelope( "Publishing signed execution payload envelope to network" ); - // TODO(gloas): if the block commits to blobs but none are cached, return 400 before - // publishing (beacon-APIs "no cached blobs and KZG proofs to attach"), rather than - // proceeding to a 202 via MissingComponents. - 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, }; @@ -187,6 +264,23 @@ pub async fn publish_execution_payload_envelope( )) })?; + // Reject stateful submissions before broadcast when the block commits to blobs but the + // node has none cached. + if !includes_blob_data && column_build_future.is_none() { + let commits_to_blobs = gossip_verified + .block + .message() + .body() + .signed_execution_payload_bid() + .is_ok_and(|bid| !bid.message.blob_kzg_commitments.is_empty()); + if commits_to_blobs { + return Err(warp_utils::reject::custom_bad_request( + "block commits to blobs but the beacon node has no cached blobs and KZG proofs to attach" + .into(), + )); + } + } + let network_tx_clone = network_tx.clone(); let block_for_equivocation_check = gossip_verified.block.clone(); let envelope_for_gossip = gossip_verified.signed_envelope.clone(); @@ -265,12 +359,25 @@ pub async fn publish_execution_payload_envelope( } }; - // Column failures leave `envelope_imported` unchanged for the response below. + // The envelope has already been published over gossip, only columns can fail from here. + // `EnvelopeAndBlobData`: the caller still holds the blobs so we return an error. The caller + // can retry against a different beacon node. + // `EnvelopeOnly`: the cached blobs have been consumed, a retry cannot rebuild the columns. We + // also cant retry against another beacon node. So we just log a failure and continue. if let Some(column_build_future) = column_build_future { match column_build_future.await { Ok(columns) => { - if publish_and_import_columns(&chain, network_tx, slot, columns).await { - envelope_imported = true; + match publish_and_import_columns(&chain, network_tx, slot, columns).await { + Ok(imported) => { + if imported { + envelope_imported = true; + } + } + Err(rejection) => { + if includes_blob_data { + return Err(rejection); + } + } } } Err(e) => { @@ -279,6 +386,9 @@ pub async fn publish_execution_payload_envelope( error = ?e, "Failed to build data columns after envelope publication" ); + if includes_blob_data { + return Err(e); + } } } } @@ -298,17 +408,16 @@ pub async fn publish_execution_payload_envelope( /// Publishes locally-built data columns and imports the node's sampling subset. /// -/// Returns `true` iff sampling-column processing completed envelope import. All failures are -/// logged and swallowed: the envelope is already on the wire, so the caller's final -/// 200-vs-202 decision reflects import state rather than column errors. +/// Returns `Ok(true)` if envelope import was completed. +/// Returns `Err` on publication failure. async fn publish_and_import_columns( chain: &Arc>, network_tx: &UnboundedSender>, slot: types::Slot, gossip_verified_columns: Vec>, -) -> bool { +) -> Result { if gossip_verified_columns.is_empty() { - return false; + return Ok(false); } if let Err(e) = publish_column_sidecars(network_tx, &gossip_verified_columns, chain) { @@ -317,7 +426,9 @@ async fn publish_and_import_columns( error = ?e, "Failed to publish data column sidecars after envelope publication" ); - return false; + return Err(warp_utils::reject::custom_server_error(format!( + "failed to publish data column sidecars: {e:?}" + ))); } let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); @@ -328,19 +439,19 @@ async fn publish_and_import_columns( .collect::>(); if sampling_columns.is_empty() { - return false; + return Ok(false); } match Box::pin(chain.process_gossip_data_columns(sampling_columns, || Ok(()))).await { - Ok(AvailabilityProcessingStatus::Imported(_, _)) => true, - Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => false, + Ok(AvailabilityProcessingStatus::Imported(_, _)) => Ok(true), + Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => Ok(false), Err(e) => { error!( %slot, error = ?e, "Failed to process sampling data columns during envelope publication" ); - false + Ok(false) } } } @@ -363,13 +474,22 @@ 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()))?; @@ -386,15 +506,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, @@ -433,7 +564,6 @@ fn build_gloas_data_columns( Ok(gossip_verified_columns) } -// TODO(gloas): add tests for this endpoint once we support importing payloads into the db // GET beacon/execution_payload_envelopes/{block_id} pub(crate) fn get_beacon_execution_payload_envelopes( eth_v1: EthV1Filter, diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index c48981f4d00..970869170ed 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1537,6 +1537,7 @@ pub async fn serve( task_spawner_filter.clone(), chain_filter.clone(), network_tx_filter.clone(), + not_while_syncing_filter.clone(), ); // POST beacon/execution_payload_envelopes (SSZ) @@ -1545,6 +1546,7 @@ pub async fn serve( task_spawner_filter.clone(), chain_filter.clone(), network_tx_filter.clone(), + not_while_syncing_filter.clone(), ); // POST beacon/execution_payload_bids diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 24d83cdc07f..f84a998923e 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; @@ -55,6 +59,12 @@ pub async fn produce_block_v4( slot: Slot, query: api_types::ValidatorBlocksQuery, ) -> Result { + let include_payload = query.include_payload.ok_or_else(|| { + warp_utils::reject::custom_bad_request( + "include_payload query parameter is required".to_string(), + ) + })?; + let randao_reveal = query.randao_reveal.decompress().map_err(|e| { warp_utils::reject::custom_bad_request(format!( "randao reveal is not a valid BLS signature: {:?}", @@ -71,27 +81,27 @@ pub async fn produce_block_v4( let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); - let (block, _block_state, consensus_block_value, execution_payload_value) = chain - .produce_block_with_verification_gloas( - randao_reveal, - slot, - graffiti_settings, - randao_verification, - builder_boost_factor, - ) - .await - .map_err(|e| { - warp_utils::reject::custom_bad_request(format!("failed to fetch a block: {:?}", e)) - })?; + let (block, _block_state, consensus_block_value, execution_payload_value, payload_contents) = + chain + .produce_block_with_verification_gloas( + randao_reveal, + slot, + graffiti_settings, + randao_verification, + builder_boost_factor, + ) + .await + .map_err(|e| { + 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 payload_contents = include_payload.then_some(payload_contents).flatten(); build_response_v4::( block, consensus_block_value, execution_payload_value, - execution_payload_included, + payload_contents, accept_header, &chain.spec, ) @@ -145,7 +155,7 @@ pub fn build_response_v4( block: BeaconBlock>, consensus_block_value: u64, execution_payload_value: Uint256, - execution_payload_included: bool, + payload_contents: Option>, accept_header: Option, spec: &ChainSpec, ) -> Result { @@ -155,6 +165,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, @@ -163,28 +174,60 @@ 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); + let res = add_execution_payload_value_header(res, execution_payload_value); + 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. + let data = match payload_contents { + Some((execution_payload_envelope, kzg_proofs, blobs)) => Ok(BlockAndEnvelope { + block, + execution_payload_envelope: Arc::unwrap_or_clone(execution_payload_envelope), + kzg_proofs, + blobs: Arc::unwrap_or_clone(blobs), + }), + None => Err(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_value_header(res, execution_payload_value)) - .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_value_header(res, execution_payload_value)) - .map(|res| add_execution_payload_included_header(res, execution_payload_included)), + Some(api_types::Accept::Ssz) => { + let ssz_bytes = match &data { + Ok(block_and_envelope) => block_and_envelope.as_ssz_bytes(), + Err(block) => 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 data { + Ok(block_and_envelope) => warp::reply::json(&ForkVersionedResponse { + version: fork_name, + metadata, + data: block_and_envelope, + }) + .into_response(), + Err(block) => 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 e038bbb8f0f..2a1a69edb28 100644 --- a/beacon_node/http_api/src/validator/execution_payload_envelopes.rs +++ b/beacon_node/http_api/src/validator/execution_payload_envelopes.rs @@ -52,9 +52,9 @@ pub fn get_validator_execution_payload_envelopes( let envelope = chain .pending_payload_envelopes .read() - .get(slot) - .filter(|envelope| envelope.beacon_block_root == beacon_block_root) + .get_by_block_root(beacon_block_root) .cloned() + .filter(|envelope| envelope.slot() == slot) .ok_or_else(|| { warp_utils::reject::custom_not_found(format!( "Execution payload envelope not available for slot {slot} and root {beacon_block_root:?}" @@ -79,7 +79,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 bed7a35b024..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, Some(false), 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 e675ad68641..a020c633c82 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -806,14 +806,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 dc744e2a74a..5f4b7bceb91 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4058,6 +4058,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; @@ -4122,6 +4126,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; @@ -4215,6 +4223,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; @@ -4372,7 +4384,7 @@ impl ApiTester { .chain .pending_payload_envelopes .read() - .get(slot) + .get_by_block_root(block_root) .cloned() .expect("envelope should exist in pending cache for local building"); assert_eq!(envelope.beacon_block_root, block_root); @@ -4438,10 +4450,10 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, Some(false), 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 proposer_index = block.proposer_index(); @@ -4495,6 +4507,8 @@ impl ApiTester { assert_eq!(err.status(), Some(StatusCode::BAD_REQUEST)); assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + self } @@ -4535,6 +4549,8 @@ impl ApiTester { assert_eq!(response.status(), StatusCode::ACCEPTED); assert!(self.network_rx.network_recv.recv().now_or_never().is_some()); + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + self } @@ -4576,11 +4592,207 @@ impl ApiTester { assert_eq!(err.status(), Some(StatusCode::BAD_REQUEST)); assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + + pub async fn test_block_production_v4_missing_include_payload_returns_400(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; + let Some((slot, epoch, _fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (_sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let mut url = self + .client + .get_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + None, + ) + .await + .unwrap(); + let query_pairs = url + .query_pairs() + .filter(|(key, _)| key != "include_payload") + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + url.query_pairs_mut().clear(); + for (key, value) in &query_pairs { + url.query_pairs_mut().append_pair(key, value); + } + + let response = reqwest::Client::new().get(url).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + + pub async fn test_envelope_post_when_syncing_returns_503(mut 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; + let Some((slot, epoch, fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (sk, _, envelope) = self + .build_and_post_block_for_envelope(slot, epoch, &fork, genesis_validators_root) + .await; + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + + // Simulate a syncing node: long-range sync with the head beyond the sync tolerance. + let network_globals = self.ctx.network_globals.as_ref().unwrap(); + *network_globals.sync_state.write() = SyncState::SyncingFinalized { + start_slot: Slot::new(0), + target_slot: Slot::new(u64::MAX), + }; + let head_slot = self.chain.canonical_head.cached_head().head_slot(); + let tolerance = self.chain.config.sync_tolerance_epochs * E::slots_per_epoch(); + let original_slot = self.chain.slot().unwrap(); + self.chain + .slot_clock + .set_slot(head_slot.as_u64() + tolerance + 1); + + while self.network_rx.network_recv.recv().now_or_never().is_some() {} + + let err = self + .client + .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name, None) + .await + .expect_err("envelope post should fail while syncing"); + + assert_eq!(err.status(), Some(StatusCode::SERVICE_UNAVAILABLE)); + assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + + *network_globals.sync_state.write() = SyncState::Synced; + self.chain.slot_clock.set_slot(original_slot.as_u64() + 1); + + self + } + + pub async fn test_envelope_post_stateful_no_cached_blobs_returns_400(mut 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; + let Some((slot, epoch, fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (sk, _, envelope) = self + .build_and_post_block_for_envelope(slot, epoch, &fork, genesis_validators_root) + .await; + + // Drain the cached blobs so the node has nothing to attach. The mock EL is configured + // with a minimum blob count, so the block's bid commits to blobs. + let drained_blobs = self + .chain + .pending_payload_envelopes + .write() + .take_blobs(envelope.beacon_block_root); + assert!( + drained_blobs.is_some_and(|blobs| !blobs.is_empty()), + "precondition: the block should commit to blobs" + ); + + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + + while self.network_rx.network_recv.recv().now_or_never().is_some() {} + + let err = self + .client + .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name, None) + .await + .expect_err("stateful envelope post without cached blobs should fail"); + + assert_eq!(err.status(), Some(StatusCode::BAD_REQUEST)); + assert!(self.network_rx.network_recv.recv().now_or_never().is_none()); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + + pub async fn test_get_beacon_execution_payload_envelopes(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; + let Some((slot, epoch, fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (sk, _, envelope) = self + .build_and_post_block_for_envelope(slot, epoch, &fork, genesis_validators_root) + .await; + let block_root = envelope.beacon_block_root; + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + + self.client + .post_beacon_execution_payload_envelopes(&signed_envelope, fork_name, None) + .await + .unwrap(); + + let json_envelope = self + .client + .get_beacon_execution_payload_envelopes::(CoreBlockId::Root(block_root)) + .await + .unwrap() + .expect("envelope should be returned") + .into_data(); + assert_eq!(json_envelope, signed_envelope); + + let ssz_envelope = self + .client + .get_beacon_execution_payload_envelopes_ssz::(CoreBlockId::Root(block_root)) + .await + .unwrap() + .expect("envelope should be returned"); + assert_eq!(ssz_envelope, signed_envelope); + + let missing = self + .client + .get_beacon_execution_payload_envelopes::(CoreBlockId::Root(Hash256::repeat_byte( + 0xab, + ))) + .await + .unwrap(); + assert!(missing.is_none()); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + self } - /// 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; } @@ -4604,10 +4816,10 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, Some(false), 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); @@ -4653,8 +4865,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; } @@ -4676,18 +4889,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, - Some(false), - 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); @@ -4721,7 +4928,143 @@ impl ApiTester { self } + /// Test V4 stateless block production with `include_payload=true` (JSON). The response + /// should bundle the execution payload envelope, blobs and KZG proofs. + pub async fn test_block_production_v4_with_payload(self) -> Self { + self.run_block_production_v4_with_payload(false).await + } + + /// Test V4 stateless block production with `include_payload=true` (SSZ). + pub async fn test_block_production_v4_with_payload_ssz(self) -> Self { + self.run_block_production_v4_with_payload(true).await + } + + async fn run_block_production_v4_with_payload(self, ssz: bool) -> 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) = if ssz { + self.client + .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, true, None, None) + .await + .unwrap() + } else { + 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(); + if ssz { + self.client + .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .await + .unwrap(); + } else { + 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(envelope.beacon_block_root); + 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, + }; + if ssz { + self.client + .post_beacon_execution_payload_envelope_contents_ssz(&contents, fork_name, None) + .await + .unwrap(); + } else { + self.client + .post_beacon_execution_payload_envelope_contents(&contents, fork_name, None) + .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 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") + } + }; + + let block_root = block_contents.block.tree_hash_root(); + self.assert_envelope_fields(&block_contents.execution_payload_envelope, block_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_block_root(block_root) + .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(); @@ -4746,6 +5089,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; @@ -5136,10 +5483,10 @@ impl ApiTester { // Produce and publish a block. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, Some(false), 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); @@ -5219,10 +5566,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, Some(false), 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); @@ -5860,6 +6207,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(); @@ -5887,6 +6238,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(); @@ -5915,6 +6270,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(); @@ -6066,6 +6425,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() @@ -6143,6 +6506,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(); @@ -6230,6 +6597,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::() @@ -6323,6 +6694,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::() @@ -6414,6 +6789,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. @@ -6504,6 +6883,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. @@ -6578,6 +6961,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(); @@ -6642,6 +7029,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. @@ -6751,6 +7142,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 { @@ -6902,6 +7297,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; @@ -7022,6 +7421,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); @@ -7101,6 +7504,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() @@ -7170,6 +7577,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() @@ -7239,6 +7650,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() @@ -7307,6 +7722,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() @@ -7376,6 +7795,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() @@ -8069,10 +8492,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(); let envelope = self .client @@ -8160,6 +8583,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 @@ -8381,7 +8808,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, None, ) @@ -8396,7 +8823,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, Some(GraffitiPolicy::AppendClientVersions), ) @@ -8421,7 +8848,7 @@ impl ApiTester { &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, - None, + false, builder_boost_factor, Some(GraffitiPolicy::PreserveUserGraffiti), ) @@ -9044,15 +9471,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_ssz() + .test_block_production_v4_without_payload_ssz() + .await + .test_block_production_v4_with_payload() + .await + .test_block_production_v4_with_payload_ssz() .await; } @@ -9192,35 +9617,25 @@ async fn payload_attestation_present_after_envelope_publish() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn envelope_post_consensus_invalid_returns_400_no_broadcast() { +async fn envelope_api() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } ApiTester::new_with_hard_forks() + .await + .test_block_production_v4_missing_include_payload_returns_400() .await .test_envelope_post_consensus_invalid_returns_400_no_broadcast() - .await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn envelope_post_gossip_partial_pass_returns_202() { - if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { - return; - } - ApiTester::new_with_hard_forks() .await .test_envelope_post_gossip_partial_pass_returns_202() - .await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn envelope_post_equivocation_returns_400() { - if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { - return; - } - ApiTester::new_with_hard_forks() .await .test_envelope_post_equivocation_returns_400() + .await + .test_envelope_post_stateful_no_cached_blobs_returns_400() + .await + .test_get_beacon_execution_payload_envelopes() + .await + .test_envelope_post_when_syncing_returns_503() .await; } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 3bda02935fb..2432931bb9d 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -459,15 +459,52 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, ) -> Result { - let builder = self + self.post_generic_with_envelope_headers(url, body, timeout, fork, None) + .await + } + + /// Generic POST function with `Eth-Consensus-Version` and optional + /// `Eth-Blob-Data-Included` headers. + async fn post_generic_with_envelope_headers( + &self, + url: U, + body: &T, + timeout: Option, + fork: ForkName, + blob_data_included: Option, + ) -> Result { + let mut builder = self .client .post(url) - .timeout(timeout.unwrap_or(self.timeouts.default)); - let response = builder + .timeout(timeout.unwrap_or(self.timeouts.default)) + .header(CONSENSUS_VERSION_HEADER, fork.to_string()); + if let Some(blob_data_included) = blob_data_included { + builder = builder.header(BLOB_DATA_INCLUDED_HEADER, blob_data_included.to_string()); + } + let response = builder.json(body).send().await?; + success_or_error(response).await + } + + /// Generic POST function with `Eth-Consensus-Version` and optional + /// `Eth-Blob-Data-Included` 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, + blob_data_included: Option, + ) -> Result { + let mut builder = self + .client + .post(url) + .timeout(timeout.unwrap_or(self.timeouts.default)) .header(CONSENSUS_VERSION_HEADER, fork.to_string()) - .json(body) - .send() - .await?; + .header("Content-Type", "application/octet-stream"); + if let Some(blob_data_included) = blob_data_included { + builder = builder.header(BLOB_DATA_INCLUDED_HEADER, blob_data_included.to_string()); + } + let response = builder.body(body).send().await?; success_or_error(response).await } @@ -496,21 +533,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` @@ -2569,7 +2593,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 { @@ -2594,10 +2618,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() @@ -2621,16 +2643,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, @@ -2644,6 +2660,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, @@ -2651,16 +2671,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, @@ -2679,12 +2693,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?; @@ -2698,10 +2730,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, @@ -2715,6 +2747,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, @@ -2722,10 +2756,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, @@ -2747,14 +2781,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?; @@ -2804,6 +2849,7 @@ impl BeaconNodeHttpClient { ExecutionPayloadEnvelope::from_ssz_bytes(&response_bytes).map_err(Error::InvalidSsz) } + /// Path for `v1/beacon/execution_payload_envelopes` pub fn post_beacon_execution_payload_envelopes_path( &self, validation_level: Option, @@ -2824,44 +2870,96 @@ impl BeaconNodeHttpClient { } /// `POST v1/beacon/execution_payload_envelopes` + /// + /// Submits the envelope alone (stateful flow); the beacon node attaches blobs and KZG + /// proofs from its cache, so this must be sent to the beacon node that built the + /// payload. pub async fn post_beacon_execution_payload_envelopes( &self, envelope: &SignedExecutionPayloadEnvelope, fork_name: ForkName, validation_level: Option, ) -> Result<(), Error> { - let response = self - .client - .post(self.post_beacon_execution_payload_envelopes_path(validation_level)?) - .timeout(self.timeouts.proposal) - .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) - .header(BLOB_DATA_INCLUDED_HEADER, "false") - .json(envelope) - .send() - .await?; - success_or_error(response).await?; + let path = self.post_beacon_execution_payload_envelopes_path(validation_level)?; + + self.post_generic_with_envelope_headers( + path, + envelope, + 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_envelopes`] for the request semantics. pub async fn post_beacon_execution_payload_envelopes_ssz( &self, envelope: &SignedExecutionPayloadEnvelope, fork_name: ForkName, validation_level: Option, ) -> Result<(), Error> { - let response = self - .client - .post(self.post_beacon_execution_payload_envelopes_path(validation_level)?) - .timeout(self.timeouts.proposal) - .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) - .header(BLOB_DATA_INCLUDED_HEADER, "false") - .header(CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER) - .body(envelope.as_ssz_bytes()) - .send() - .await?; - success_or_error(response).await?; + let path = self.post_beacon_execution_payload_envelopes_path(validation_level)?; + + self.post_generic_with_envelope_headers_and_ssz_body( + path, + envelope.as_ssz_bytes(), + Some(self.timeouts.proposal), + fork_name, + Some(false), + ) + .await?; + + 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, + validation_level: Option, + ) -> Result<(), Error> { + let path = self.post_beacon_execution_payload_envelopes_path(validation_level)?; + + self.post_generic_with_envelope_headers( + path, + contents, + Some(self.timeouts.proposal), + fork_name, + Some(true), + ) + .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, + validation_level: Option, + ) -> Result<(), Error> { + let path = self.post_beacon_execution_payload_envelopes_path(validation_level)?; + + self.post_generic_with_envelope_headers_and_ssz_body( + path, + contents.as_ssz_bytes(), + Some(self.timeouts.proposal), + fork_name, + Some(true), + ) + .await?; Ok(()) } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 5adc65b297e..af4ee394e32 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1897,6 +1897,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/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 1cf9c8cb1f5..a26b557adf2 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(), - Some(false), + 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(), - Some(false), + 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)?