diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 4f41c2a1a1c..e73cf06c00a 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -68,7 +68,9 @@ use eth2::types::{ self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceExtraData, ForkChoiceNode, LightClientUpdatesQuery, PublishBlockRequest, ValidatorId, }; -use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; +use eth2::{ + BUILDER_URL_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER, +}; use health_metrics::observe::Observe; use lighthouse_network::Enr; use lighthouse_network::NetworkGlobals; @@ -106,7 +108,7 @@ use types::{ }; use validator::execution_payload_envelopes::get_validator_execution_payload_envelopes; use version::{ - ResponseIncludesVersion, V1, V2, add_consensus_version_header, add_ssz_content_type_header, + ResponseIncludesVersion, V1, V2, V4, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, inconsistent_fork_rejection, unsupported_version_rejection, }; @@ -384,6 +386,7 @@ pub async fn serve( let eth_v1 = single_version(any_version.clone(), V1); let eth_v2 = single_version(any_version.clone(), V2); + let eth_v4 = single_version(any_version.clone(), V4); // Create a `warp` filter that provides access to the network globals. let inner_network_globals = ctx.network_globals.clone(); @@ -819,6 +822,9 @@ pub async fn serve( */ let consensus_version_header_filter = warp::header::header::(CONSENSUS_VERSION_HEADER).boxed(); + // The winning builder's URL echoed by the VC on a Gloas block publish (beacon-APIs #630), so the + // node forwards the block to that builder. Optional: absent for self-build / p2p-won blocks. + let builder_url_header_filter = warp::header::optional::(BUILDER_URL_HEADER).boxed(); let optional_consensus_version_header_filter = warp::header::optional::(CONSENSUS_VERSION_HEADER).boxed(); @@ -855,6 +861,8 @@ pub async fn serve( &network_tx, BroadcastValidation::default(), duplicate_block_status_code, + // Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas). + None, ) .await }) @@ -892,6 +900,8 @@ pub async fn serve( &network_tx, BroadcastValidation::default(), duplicate_block_status_code, + // Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas). + None, ) .await }) @@ -909,13 +919,15 @@ pub async fn serve( .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) + .and(builder_url_header_filter.clone()) .then( move |validation_level: api_types::BroadcastValidationQuery, value: serde_json::Value, consensus_version: ForkName, task_spawner: TaskSpawner, chain: Arc>, - network_tx: UnboundedSender>| { + network_tx: UnboundedSender>, + builder_url: Option| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { let request = PublishBlockRequest::::context_deserialize( &value, @@ -932,6 +944,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -949,13 +962,15 @@ pub async fn serve( .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) + .and(builder_url_header_filter.clone()) .then( move |validation_level: api_types::BroadcastValidationQuery, block_bytes: Bytes, consensus_version: ForkName, task_spawner: TaskSpawner, chain: Arc>, - network_tx: UnboundedSender>| { + network_tx: UnboundedSender>, + builder_url: Option| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { let block_contents = PublishBlockRequest::::from_ssz_bytes( &block_bytes, @@ -971,6 +986,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -2570,6 +2586,14 @@ pub async fn serve( task_spawner_filter.clone(), ); + // POST v4/validator/blocks/{slot} + let post_validator_blocks_v4 = post_validator_blocks_v4( + eth_v4.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // GET validator/blinded_blocks/{slot} let get_validator_blinded_blocks = get_validator_blinded_blocks( eth_v1.clone(), @@ -2683,6 +2707,12 @@ pub async fn serve( chain_filter.clone(), task_spawner_filter.clone(), ); + // POST validator/builder_preferences + let post_validator_builder_preferences = post_validator_builder_preferences( + eth_v1.clone(), + chain_filter.clone(), + task_spawner_filter.clone(), + ); // POST validator/sync_committee_subscriptions let post_validator_sync_committee_subscriptions = post_validator_sync_committee_subscriptions( eth_v1.clone(), @@ -3496,6 +3526,8 @@ pub async fn serve( .uor(post_validator_sync_committee_subscriptions) .uor(post_validator_prepare_beacon_proposer) .uor(post_validator_register_validator) + .uor(post_validator_builder_preferences) + .uor(post_validator_blocks_v4) .uor(post_validator_liveness_epoch) .uor(post_lighthouse_liveness) .uor(post_lighthouse_database_reconstruct) diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 63420fbe2d0..49315790da0 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -1,10 +1,10 @@ use crate::{ build_block_contents, version::{ - ResponseIncludesVersion, add_consensus_block_value_header, add_consensus_version_header, - add_execution_payload_blinded_header, add_execution_payload_included_header, - add_execution_payload_value_header, add_ssz_content_type_header, beacon_response, - inconsistent_fork_rejection, + ResponseIncludesVersion, add_builder_url_header, add_consensus_block_value_header, + add_consensus_version_header, add_execution_payload_blinded_header, + add_execution_payload_included_header, add_execution_payload_value_header, + add_ssz_content_type_header, beacon_response, inconsistent_fork_rejection, }, }; use beacon_chain::graffiti_calculator::GraffitiSettings; @@ -17,9 +17,10 @@ use eth2::{ beacon_response::ForkVersionedResponse, types::{BlockAndEnvelope, ProduceBlockV4Metadata}, }; +use sensitive_url::SensitiveUrl; use ssz::Encode; use std::sync::Arc; -use tracing::instrument; +use tracing::{debug, instrument}; use types::{execution::BlockProductionVersion, *}; use warp::{ http::response::Builder, @@ -58,13 +59,30 @@ pub async fn produce_block_v4( chain: Arc>, slot: Slot, query: api_types::ValidatorBlocksQuery, + builder_config: api_types::BuilderConfig, ) -> Result { + // `produceBlockV4` is the Gloas block-production endpoint. + let fork_name = chain.spec.fork_name_at_slot::(slot); + if !fork_name.gloas_enabled() { + return Err(warp_utils::reject::custom_bad_request( + "produceBlockV4 is only valid for Gloas and later".to_string(), + )); + } + let include_payload = query.include_payload.ok_or_else(|| { warp_utils::reject::custom_bad_request( "include_payload query parameter is required".to_string(), ) })?; + // The resolved builder config is threaded into block production, where it drives direct-builder + // bid requests and the gossip/direct bid policy (see `produce_block_on_state_gloas`). + debug!( + %slot, + builders = builder_config.builders.len(), + "Received produceBlockV4 request" + ); + 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: {:?}", @@ -73,14 +91,9 @@ pub async fn produce_block_v4( })?; let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?; - // The GET route carries only a boost factor; direct builders arrive with the `BuilderConfig` - // body once this route is converted to POST (later in this PR stack). Until then the winning - // bid's builder URL is unused (`Eth-Builder-Url` also lands with the POST conversion). - let builder_config = api_types::BuilderConfig { - builder_boost_factor: query.builder_boost_factor.unwrap_or(DEFAULT_BOOST_FACTOR), - ..api_types::BuilderConfig::empty() - }; + // Gloas takes its bid boost policy from `builder_config` (global for gossip, per-builder for + // direct), so the V3-style `builder_boost_factor` query param is not used on this path. let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); let ( @@ -89,7 +102,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, - _builder_url, + builder_url, ) = chain .produce_block_with_verification_gloas( randao_reveal, @@ -110,6 +123,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, + builder_url, accept_header, &chain.spec, ) @@ -164,9 +178,13 @@ pub fn build_response_v4( consensus_block_value: u64, execution_payload_value: Uint256, payload_contents: Option>, + builder_url: Option, accept_header: Option, spec: &ChainSpec, ) -> Result { + // Stringify the winning builder's URL only here, at the `Eth-Builder-Url` header boundary; it is + // kept as a redacted `SensitiveUrl` everywhere upstream. + let builder_url = builder_url.map(|url| url.expose_full().to_string()); let fork_name = block .to_ref() .fork_name(spec) @@ -180,14 +198,15 @@ pub fn build_response_v4( consensus_block_value: consensus_block_value_wei, execution_payload_value, execution_payload_included, - builder_url: None, + builder_url: builder_url.clone(), }; 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) + let res = add_execution_payload_included_header(res, execution_payload_included); + add_builder_url_header(res, builder_url.as_deref()) }; // When the payload is included, bundle the block with the execution payload envelope, blobs and diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index a7336f2f6eb..5279a25b3be 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -19,6 +19,7 @@ use logging::crit; use network::NetworkMessage; use rand::prelude::SliceRandom; use reqwest::StatusCode; +use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; use std::marker::PhantomData; use std::sync::Arc; @@ -73,6 +74,62 @@ impl ProvenancedBlock> } } +/// If a direct builder won this block's payload bid, forward the signed block to that builder via +/// `submitSignedBeaconBlock` so it reveals the execution payload envelope. +/// +/// The builder's URL is the `Eth-Builder-Url` request header the VC echoed on publish (beacon-APIs +/// #630), so this works even on a beacon node that did not produce the block. `None` (self-built or +/// p2p-won), no configured builders, or a malformed URL are all no-ops. +/// +/// Fire-and-forget: the submission runs in a detached task; a failure is logged at high severity +/// (the validator has already signed the commitment) but never blocks the publish response. Runs +/// only once per block since it hangs off the single p2p-publish point. +fn forward_signed_block_to_winning_builder( + chain: &Arc>, + block: Arc>, + builder_url: Option<&str>, +) { + // The VC echoes the winning builder's URL in the `Eth-Builder-Url` request header (beacon-APIs + // #630); absent for a self-built block or a p2p-won bid, in which case there's nothing to forward. + let Some(builder_url) = builder_url else { + return; + }; + let Some(builders) = chain.builders.as_ref() else { + return; + }; + let url = match SensitiveUrl::parse(builder_url) { + Ok(url) => url, + Err(e) => { + warn!(error = ?e, "Ignoring malformed Eth-Builder-Url header"); + return; + } + }; + + let builders = builders.clone(); + let slot = block.slot(); + let block_root = block.canonical_root(); + + chain.task_executor.spawn( + async move { + match builders.forward_signed_block(&url, &block).await { + Ok(()) => info!( + %slot, + %block_root, + "Forwarded signed block to winning builder" + ), + Err(e) => error!( + %slot, + %block_root, + builder_url = ?url, + error = ?e, + "Failed to forward signed block to winning builder" + ), + } + }, + "forward_signed_block_to_builder", + ); +} + /// Handles a request from the HTTP API for full blocks. #[allow(clippy::too_many_arguments)] #[instrument( @@ -88,6 +145,9 @@ pub async fn publish_block>( network_tx: &UnboundedSender>, validation_level: BroadcastValidation, duplicate_status_code: StatusCode, + // The `Eth-Builder-Url` request header (beacon-APIs #630): when a direct builder won the block's + // payload bid, its URL, so the block is forwarded there for envelope reveal. + builder_url: Option, ) -> Result { let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let block_publishing_delay_for_testing = chain.config.block_publishing_delay; @@ -141,6 +201,14 @@ pub async fn publish_block>( BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) })?; + // If a direct builder won this block's payload bid, forward the signed block to it so it + // reveals the execution payload envelope. + forward_signed_block_to_winning_builder( + &publish_chain, + block.clone(), + builder_url.as_deref(), + ); + Ok(()) }; @@ -570,6 +638,8 @@ pub async fn publish_blinded_block( network_tx, validation_level, duplicate_status_code, + // Blinded (mev-boost) publish predates the Gloas builder-URL round-trip. + None, ) .await } else { diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 7b904260b8e..f07273003fb 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, V4, add_ssz_content_type_header, unsupported_version_rejection}; +use crate::version::{V1, V2, V3, add_ssz_content_type_header, 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; @@ -14,12 +14,13 @@ use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainT use bls::PublicKeyBytes; use bytes::Bytes; use context_deserialize::ContextDeserialize; -use eth2::CONSENSUS_VERSION_HEADER; use eth2::types::{ - Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, - StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, - ValidatorAttestationDataQuery, ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, + Accept, BeaconCommitteeSubscription, BuilderConfig, BuilderPreferenceEntry, EndpointVersion, + Failure, GenericResponse, MAX_SUBMITTED_BUILDER_PREFERENCES, StandardLivenessResponseData, + StateId as CoreStateId, ValidatorAggregateAttestationQuery, ValidatorAttestationDataQuery, + ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, }; +use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; use reqwest::StatusCode; @@ -483,8 +484,12 @@ pub fn get_validator_blocks( not_synced_filter?; - if endpoint_version == V4 { - produce_block_v4(accept_header, chain, slot, query).await + // Gloas block production is served via `POST v4/validator/blocks`. + let fork_name = chain.spec.fork_name_at_slot::(slot); + if fork_name.gloas_enabled() { + Err(warp_utils::reject::custom_bad_request( + "Gloas block production requires POST v4/validator/blocks".to_string(), + )) } else if endpoint_version == V3 { produce_block_v3(accept_header, chain, slot, query).await } else { @@ -496,6 +501,84 @@ pub fn get_validator_blocks( .boxed() } +// POST v4/validator/blocks/{slot} +// +// The Gloas block-production endpoint. Carries the validator's resolved `BuilderConfig` as the +// request body, accepted as either JSON or SSZ (selected by `Content-Type`; `application/octet-stream` +// => SSZ). The `Eth-Consensus-Version` request header is required (per beacon-APIs #630); the body +// is not fork-versioned, so like the builder-preferences endpoint the header is validated but only +// logged. +pub fn post_validator_blocks_v4( + eth_v4: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v4 + .and(warp::path("validator")) + .and(warp::path("blocks")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid slot".to_string(), + )) + })) + .and(warp::path::end()) + .and(warp::header::optional::("accept")) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(not_while_syncing_filter) + .and(warp::query::()) + .and( + warp::header::optional::(CONTENT_TYPE_HEADER) + .and(warp::body::bytes()) + .and_then(|content_type: Option, body: Bytes| async move { + let builder_config: BuilderConfig = if content_type.as_deref() + == Some(SSZ_CONTENT_TYPE_HEADER) + { + BuilderConfig::from_ssz_bytes(&body).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })? + } else { + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + // A zero-length `url` or auth `data` makes the body itself invalid (beacon-APIs + // #630) — a 400, unlike per-entry bid failures, which are isolated. + for entry in builder_config.builders.iter() { + entry.validate().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid builder entry: {e}" + )) + })?; + } + Ok::<_, Rejection>(builder_config) + }), + ) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + accept_header: Option, + consensus_version: ForkName, + not_synced_filter: Result<(), Rejection>, + query: ValidatorBlocksQuery, + builder_config: BuilderConfig, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + debug!( + ?slot, + %consensus_version, + "Block production request from HTTP API (v4)" + ); + not_synced_filter?; + produce_block_v4(accept_header, chain, slot, query, builder_config).await + }) + }, + ) + .boxed() +} + // POST validator/liveness/{epoch} pub fn post_validator_liveness_epoch( eth_v1: EthV1Filter, @@ -770,6 +853,126 @@ pub fn post_validator_register_validator( .boxed() } +// POST validator/builder_preferences +// +// Accepts the `BuilderPreferenceEntry` list as either JSON or SSZ, selected by the request's +// `Content-Type` (`application/octet-stream` => SSZ, otherwise JSON). A required +// `Eth-Consensus-Version` header carries the consensus version the preferences belong to (per +// beacon-APIs #630); it is not needed to decode the (currently single-fork) body, so it is only +// logged. +pub fn post_validator_builder_preferences( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("builder_preferences")) + .and(warp::path::end()) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .and( + warp::header::optional::(CONTENT_TYPE_HEADER) + .and(warp::body::bytes()) + .and_then(|content_type: Option, body: Bytes| async move { + let entries: Vec = if content_type.as_deref() + == Some(SSZ_CONTENT_TYPE_HEADER) + { + Vec::from_ssz_bytes(&body).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })? + } else { + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + // The submission list is bounded (SSZ `List[BuilderPreferencesEntry, 4096]`, + // JSON `maxItems: 4096`, per beacon-APIs #630); a longer body is invalid. + if entries.len() > MAX_SUBMITTED_BUILDER_PREFERENCES { + return Err(warp_utils::reject::custom_bad_request(format!( + "too many builder preference entries: {} exceeds the limit of {}", + entries.len(), + MAX_SUBMITTED_BUILDER_PREFERENCES + ))); + } + // A zero-length `url` or auth `data` makes the body itself invalid (beacon-APIs + // #630) — a 400, unlike per-entry submission failures, which are isolated. + for entry in &entries { + entry.validate().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid builder preference entry: {e}" + )) + })?; + } + Ok::<_, Rejection>(entries) + }), + ) + .then( + |consensus_version: ForkName, + task_spawner: TaskSpawner, + chain: Arc>, + entries: Vec| async move { + let (tx, rx) = oneshot::channel(); + + let initial_result = task_spawner + .spawn_async_with_rejection_no_conversion(Priority::P0, async move { + // The builder service is only present when the Gloas fork is scheduled. + let builders = chain + .builders + .as_ref() + .ok_or(BeaconChainError::BuilderMissing) + .map_err(warp_utils::reject::unhandled_error)? + .clone(); + + debug!( + count = entries.len(), + %consensus_version, + "Received submit builder preferences request" + ); + + // Submitting to a builder can be slow (they frequently time out), so the + // fan-out runs in a detached task rather than holding a `BeaconProcessor` + // worker. The service submits each entry independently and best-effort, + // returning the failures by index (per beacon-APIs #630). + tokio::task::spawn(async move { + let response = match builders + .submit_builder_preferences(entries, consensus_version) + .await + { + Ok(()) => Ok(warp::reply::reply().into_response()), + Err(failures) => Err(warp_utils::reject::indexed_bad_request( + "error submitting builder preferences".to_string(), + failures + .into_iter() + .map(|f| Failure::new(f.index, f.error.to_string())) + .collect(), + )), + }; + let _ = tx.send(response); + }); + + Ok(warp::reply::reply().into_response()) + }) + .await; + + if initial_result.is_err() { + return convert_rejection(initial_result).await; + } + + convert_rejection(rx.await.unwrap_or_else(|_| { + Ok(warp::reply::with_status( + warp::reply::json(&"No response from channel"), + warp::http::StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response()) + })) + .await + }, + ) + .boxed() +} + // POST validator/prepare_beacon_proposer pub fn post_validator_prepare_beacon_proposer( eth_v1: EthV1Filter, diff --git a/beacon_node/http_api/src/version.rs b/beacon_node/http_api/src/version.rs index 6f441636b49..63914feb049 100644 --- a/beacon_node/http_api/src/version.rs +++ b/beacon_node/http_api/src/version.rs @@ -4,8 +4,8 @@ use eth2::beacon_response::{ ExecutionOptimisticFinalizedMetadata, ForkVersionedResponse, UnversionedResponse, }; use eth2::{ - CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, - EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, + BUILDER_URL_HEADER, CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, + CONTENT_TYPE_HEADER, EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, SSZ_CONTENT_TYPE_HEADER, }; use serde::Serialize; @@ -116,6 +116,15 @@ pub fn add_execution_payload_value_header( .into_response() } +/// Add the `Eth-Builder-Url` header (the winning builder's URL) to a response, when present. +/// Absent for a self-built block or a block won by a p2p bid. +pub fn add_builder_url_header(reply: T, builder_url: Option<&str>) -> Response { + match builder_url { + Some(url) => reply::with_header(reply, BUILDER_URL_HEADER, url).into_response(), + None => reply.into_response(), + } +} + /// Add the `Eth-Consensus-Block-Value` header to a response. pub fn add_consensus_block_value_header( reply: T, diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 4d2be52a0d5..5db04d7d136 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -433,6 +433,7 @@ pub async fn consensus_partial_pass_only_consensus() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; @@ -610,7 +611,7 @@ pub async fn equivocation_consensus_early_equivocation() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), blobs_a), validation_level, - None + None, ) .await .is_ok() @@ -763,6 +764,7 @@ pub async fn equivocation_consensus_late_equivocation() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 4cd76c03dc2..dd60201fcb8 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -27,7 +27,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, Hash256, MinimalEthSpec, + Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, ForkName, Hash256, MinimalEthSpec, ProposerPreparationData, Slot, }; @@ -727,7 +727,15 @@ 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, false, None, None) + .post_validator_blocks_v4::( + slot_c, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); ( diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index a020c633c82..807aa2c1040 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -810,7 +810,15 @@ pub async fn fork_choice_before_proposal() { 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) + .post_validator_blocks_v4::( + slot_d, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() .0 diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 38bcd05a77c..94d7f15f44b 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4812,7 +4812,15 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5004,6 +5012,124 @@ impl ApiTester { self } + pub async fn test_block_production_v4_missing_consensus_version_header_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 url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + ) + .await + .unwrap(); + + // A valid body, but no `Eth-Consensus-Version` header: the header is required + // (beacon-APIs #630), so the request must fail with a 400. + let response = reqwest::Client::new() + .post(url) + .json(ð2::types::BuilderConfig::empty()) + .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_block_production_v4_zero_length_entry_fields_return_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 url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + ) + .await + .unwrap(); + + let valid_auth = eth2::types::SignedRequestAuth { + message: eth2::types::RequestAuth { + data: eth2::types::RequestAuthData::new(b"http://builder.example.com".to_vec()) + .unwrap(), + slot, + }, + signature: Signature::empty(), + }; + let entry = |url: &str, auth: eth2::types::SignedRequestAuth| eth2::types::BuilderEntry { + url: url.parse().unwrap(), + auth, + builder_pubkeys: <_>::default(), + max_execution_payment: 0, + min_bid: 0, + builder_boost_factor: 100, + }; + + // A zero-length `url` and a zero-length auth `data` each make the body invalid + // (beacon-APIs #630), so the request must fail with a 400. + let empty_url_entry = entry("", valid_auth.clone()); + let mut empty_data_auth = valid_auth; + empty_data_auth.message.data = eth2::types::RequestAuthData::default(); + let empty_data_entry = entry("http://builder.example.com", empty_data_auth); + + for bad_entry in [empty_url_entry, empty_data_entry] { + let config = serde_json::json!({ + "min_bid": "0", + "builder_boost_factor": "100", + "builders": [bad_entry], + }); + let response = reqwest::Client::new() + .post(url.clone()) + .header(eth2::CONSENSUS_VERSION_HEADER, "gloas") + .json(&config) + .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; @@ -5177,7 +5303,15 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5252,7 +5386,15 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4_ssz::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5324,12 +5466,28 @@ impl ApiTester { let (response, metadata) = if ssz { self.client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, true, None, None) + .post_validator_blocks_v4_ssz::( + slot, + &randao_reveal, + None, + true, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() } else { self.client - .get_validator_blocks_v4::(slot, &randao_reveal, None, true, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + true, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() }; @@ -5870,7 +6028,15 @@ impl ApiTester { // Produce and publish a block. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5953,7 +6119,15 @@ impl ApiTester { // Produce and publish a block, but withhold its envelope. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -8915,7 +9089,15 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -9221,7 +9403,6 @@ impl ApiTester { let epoch = self.chain.epoch().unwrap(); let (_, randao_reveal) = self.get_test_randao(slot, epoch).await; let graffiti = Some(Graffiti::from([0; GRAFFITI_BYTES_LEN])); - // When GraffitiPolicy is None let no_graffiti_policy_path = self .client @@ -10093,6 +10274,10 @@ async fn envelope_api() { .await .test_block_production_v4_missing_include_payload_returns_400() .await + .test_block_production_v4_missing_consensus_version_header_returns_400() + .await + .test_block_production_v4_zero_length_entry_fields_return_400() + .await .test_envelope_post_consensus_invalid_returns_400_no_broadcast() .await .test_envelope_post_gossip_partial_pass_returns_202()