diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs index 9983df131d8..f57d5628695 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs @@ -1,6 +1,6 @@ use crate::payload_bid_verification::{ PayloadBidError, - gossip_verified_bid::{is_gas_limit_target_compatible, verify_bid_consistency}, + gossip_verified_bid::{is_gas_limit_target_compatible, verify_direct_bid_consistency}, }; use eth2::types::BuilderPubkeys; use state_processing::signature_sets::{ @@ -14,7 +14,8 @@ use types::{ /// Fully validate a bid fetched directly from a builder, for inclusion in a block being produced. /// /// This performs all validation a direct builder bid must pass before it can be selected: -/// - the consensus-consistency checks shared with the gossip verifier via [`verify_bid_consistency`] +/// - the consensus-consistency checks shared with the gossip verifier, bundled for this path in +/// [`verify_direct_bid_consistency`] /// (fee recipient, blob count, builder eligibility/version, and that the builder's collateral /// covers the bid value), /// - that the bid matches the block being produced — the exact `proposal_slot`, the selected @@ -81,7 +82,7 @@ pub fn verify_direct_bid( } // Consensus-consistency checks shared with the gossip verifier. - verify_bid_consistency(bid, proposal_slot, proposer_preferences, state, spec)?; + verify_direct_bid_consistency(bid, proposal_slot, proposer_preferences, state, spec)?; // If the requesting `BuilderEntry` named builder pubkeys, the bid must come from one of them: // the builder at `bid.builder_index` must have one of those pubkeys (the `builder_pubkeys` @@ -267,6 +268,38 @@ mod tests { )); } + #[test] + fn rejects_block_hash_equal_to_parent_block_hash() { + let (state, spec) = state_and_spec(); + // Passes every earlier check (slot, ancestor hash, parent root, RANDAO, gas limit), then + // claims a `block_hash` equal to its `parent_block_hash` — the consensus assert from + // `process_execution_payload_bid` that must be front-run before selection. + let executed_ancestor = ExecutionBlockHash::repeat_byte(7); + let mut bid = signed_bid( + Slot::new(1), + executed_ancestor, + Hash256::ZERO, + Hash256::ZERO, + ); + bid.message.block_hash = executed_ancestor; + bid.message.gas_limit = EXECUTED_ANCESTOR_GAS_LIMIT; + let result = verify_direct_bid( + &bid, + Slot::new(1), + executed_ancestor, + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::BlockHashEqualsParentBlockHash { .. }) + )); + } + #[test] fn rejects_gas_limit_incompatible_with_parent() { let (state, spec) = state_and_spec(); @@ -305,6 +338,9 @@ mod tests { Hash256::ZERO, ); bid.message.gas_limit = EXECUTED_ANCESTOR_GAS_LIMIT; + // A default (zero) `block_hash` would equal the zero parent hash and trip the + // block-hash-equals-parent rejection before the checks this test targets. + bid.message.block_hash = ExecutionBlockHash::repeat_byte(1); let result = verify_direct_bid( &bid, Slot::new(1), diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs index 25d82ccc971..851bf821de7 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs @@ -44,14 +44,26 @@ fn verify_bid_payment_and_blobs( }); } + verify_bid_block_hash_not_parent(bid)?; + + verify_bid_blobs(bid, spec) +} + +/// Reject a bid whose `block_hash` equals its `parent_block_hash`. +/// +/// `process_execution_payload_bid` enforces this in `per_block_processing`, so every bid intake — +/// gossip *and* direct (builder-API) — must front-run it: a bid that fails only at block +/// processing has already won selection and costs the proposer the slot. +pub(crate) fn verify_bid_block_hash_not_parent( + bid: &ExecutionPayloadBid, +) -> Result<(), PayloadBidError> { if bid.block_hash == bid.parent_block_hash { return Err(PayloadBidError::BlockHashEqualsParentBlockHash { slot: bid.slot, block_hash: bid.block_hash, }); } - - verify_bid_blobs(bid, spec) + Ok(()) } fn verify_bid_blobs( @@ -71,12 +83,17 @@ fn verify_bid_blobs( Ok(()) } -/// Verify that an execution payload bid is consistent with the current chain state -/// and proposer preferences. +/// Verify that a direct (builder-API) bid is consistent with the current chain state +/// and proposer preferences: the direct path's bundle of the shared bid checks. /// -/// These checks are shared by gossip and direct bids. Source-specific checks (e.g. the gossip-only -/// requirement that `execution_payment == 0`) are applied by the caller. -pub(crate) fn verify_bid_consistency( +/// The individual checks are shared with gossip, but this bundle's only caller is +/// [`verify_direct_bid`](crate::payload_bid_verification::direct_verified_bid::verify_direct_bid): +/// the gossip verifier applies the same helpers (`verify_bid_slot`, `verify_bid_blobs`, +/// `verify_bid_block_hash_not_parent`, `verify_bid_state_conditions`) piecewise, in gossip-spec +/// order, interleaved with gossip-only work (cache checks, the preferences lookup, fork-choice +/// rules). A check that must cover both intakes belongs in one of those shared helpers — adding +/// it only here leaves gossip uncovered. +pub(crate) fn verify_direct_bid_consistency( bid: &ExecutionPayloadBid, current_slot: Slot, proposer_preferences: &SignedProposerPreferences, @@ -89,6 +106,11 @@ pub(crate) fn verify_bid_consistency( return Err(PayloadBidError::InvalidFeeRecipient); } + // Mirrors the consensus assert in `process_execution_payload_bid`. The gossip path applies + // this earlier (via `verify_bid_payment_and_blobs`); repeating it here keeps the direct path + // covered without depending on the gossip caller's composition. + verify_bid_block_hash_not_parent(bid)?; + verify_bid_blobs(bid, spec)?; verify_bid_state_conditions(bid, head_state, spec) diff --git a/beacon_node/builder_client/src/builder_http_client.rs b/beacon_node/builder_client/src/builder_http_client.rs index a857d13226c..12241036b3f 100644 --- a/beacon_node/builder_client/src/builder_http_client.rs +++ b/beacon_node/builder_client/src/builder_http_client.rs @@ -41,6 +41,11 @@ const DATE_MILLISECONDS: HeaderName = HeaderName::from_static("date-milliseconds #[derive(Clone)] pub struct BuilderHttpClient { client: reqwest::Client, + /// Client for `submitSignedBeaconBlock` only. The target URL arrives over the wire (the + /// `Eth-Builder-Url` request header echoed by the VC) and is an SSRF risk, so beacon-APIs + /// `publishBlock` requires that the forwarding request "MUST NOT follow redirects" — reqwest's + /// redirect policy is client-wide, hence a dedicated client with redirects disabled. + no_redirect_client: reqwest::Client, user_agent: String, /// Only use json for all request/response types. disable_ssz: bool, @@ -50,8 +55,13 @@ impl BuilderHttpClient { pub fn new(user_agent: Option, disable_ssz: bool) -> Result { let user_agent = user_agent.unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); let client = reqwest::Client::builder().user_agent(&user_agent).build()?; + let no_redirect_client = reqwest::Client::builder() + .user_agent(&user_agent) + .redirect(reqwest::redirect::Policy::none()) + .build()?; Ok(Self { client, + no_redirect_client, user_agent, disable_ssz, }) @@ -234,6 +244,10 @@ impl BuilderHttpClient { /// /// `ssz_request` selects the request-body encoding: SSZ when `true` and the client has SSZ /// enabled, otherwise JSON. + /// + /// Sent via [`Self::no_redirect_client`]: `builder_url` is wire input (`Eth-Builder-Url`), and + /// the spec forbids following redirects on this request. A redirect response surfaces as + /// [`Error::StatusCode`] like any other non-202. pub async fn submit_signed_beacon_block( &self, builder_url: &SensitiveUrl, @@ -263,7 +277,7 @@ impl BuilderHttpClient { HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, ); - self.client + self.no_redirect_client .post(path) .timeout(timeout) .headers(headers) @@ -274,7 +288,7 @@ impl BuilderHttpClient { HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, ); - self.client + self.no_redirect_client .post(path) .timeout(timeout) .headers(headers) 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..ecb362985e4 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,95 @@ pub fn get_validator_blocks( .boxed() } +/// Does the request's `Content-Type` header select SSZ? +/// +/// Tolerates media-type parameters (`application/octet-stream; ...`) and surrounding whitespace; +/// anything else (including an absent header) selects JSON. +fn is_ssz_content_type(content_type: Option<&str>) -> bool { + content_type + .and_then(|header| header.split(';').next()) + .is_some_and(|media_type| media_type.trim() == SSZ_CONTENT_TYPE_HEADER) +} + +// 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 is_ssz_content_type(content_type.as_deref()) { + 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 +864,133 @@ pub fn post_validator_register_validator( .boxed() } +// POST validator/builder_preferences +// +// Accepts the `BuilderPreferenceEntry` list as either JSON or SSZ. 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 is_ssz_content_type(content_type.as_deref()) { + 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; a + // node without one can't submit preferences anywhere, which is the + // caller's misconfiguration (not a server fault), so reject with a 400. + let builders = chain + .builders + .as_ref() + .ok_or_else(|| { + warp_utils::reject::custom_bad_request( + "this beacon node has no builder service (the Gloas fork is \ + not scheduled on its network)" + .to_string(), + ) + })? + .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 f1a4ed02bb4..f31746ddf70 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 90c7f37b02c..df4fdaf0904 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -817,7 +817,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 8f9dcac5a04..0fc3fd17cec 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4813,7 +4813,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(); @@ -5005,6 +5013,248 @@ 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 + } + + /// The `POST validator/builder_preferences` URL, for raw requests that bypass the eth2 client + /// (which always sets the required header). + fn builder_preferences_url(&self) -> reqwest::Url { + let mut url = self.client.server().expose_full().clone(); + url.path_segments_mut() + .unwrap() + .push("eth") + .push("v1") + .push("validator") + .push("builder_preferences"); + url + } + + /// A `BuilderPreferenceEntry` that passes the endpoint's body validation. + fn valid_builder_preference_entry() -> eth2::types::BuilderPreferenceEntry { + eth2::types::BuilderPreferenceEntry { + proposer_pubkey: PublicKeyBytes::empty(), + url: "http://builder.example.com".parse().unwrap(), + auth: eth2::types::SignedRequestAuth { + message: eth2::types::RequestAuth { + data: eth2::types::RequestAuthData::new(b"http://builder.example.com".to_vec()) + .unwrap(), + slot: Slot::new(0), + }, + signature: Signature::empty(), + }, + max_execution_payment: 0, + } + } + + pub async fn test_builder_preferences_missing_consensus_version_header_returns_400( + self, + ) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + // 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(self.builder_preferences_url()) + .json(&vec![Self::valid_builder_preference_entry()]) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + self + } + + pub async fn test_builder_preferences_zero_length_entry_fields_return_400(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + // 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 mut empty_url_entry = Self::valid_builder_preference_entry(); + empty_url_entry.url = "".parse().unwrap(); + let mut empty_data_entry = Self::valid_builder_preference_entry(); + empty_data_entry.auth.message.data = eth2::types::RequestAuthData::default(); + + for bad_entry in [empty_url_entry, empty_data_entry] { + let response = reqwest::Client::new() + .post(self.builder_preferences_url()) + .header(eth2::CONSENSUS_VERSION_HEADER, "gloas") + .json(&vec![bad_entry]) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + self + } + + pub async fn test_builder_preferences_oversize_list_returns_400(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + // The submission list is bounded at `MAX_SUBMITTED_BUILDER_PREFERENCES` entries + // (beacon-APIs #630); one more is an invalid body. + let entries = vec![ + Self::valid_builder_preference_entry(); + eth2::types::MAX_SUBMITTED_BUILDER_PREFERENCES + 1 + ]; + let response = reqwest::Client::new() + .post(self.builder_preferences_url()) + .header(eth2::CONSENSUS_VERSION_HEADER, "gloas") + .json(&entries) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + self + } + + pub async fn test_builder_preferences_without_builder_service_returns_400(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + // The test harness never wires a builder service into the chain, so a well-formed + // submission reaches the handler and must be rejected as a client-side misconfiguration + // (400 with a self-explanatory message), not a 500. + let response = reqwest::Client::new() + .post(self.builder_preferences_url()) + .header(eth2::CONSENSUS_VERSION_HEADER, "gloas") + .json(&vec![Self::valid_builder_preference_entry()]) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response.text().await.unwrap(); + assert!( + body.contains("no builder service"), + "unexpected error body: {body}" + ); + + self + } + pub async fn test_envelope_post_when_syncing_returns_503(mut self) -> Self { if !self.chain.spec.is_gloas_scheduled() { return self; @@ -5178,7 +5428,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(); @@ -5253,7 +5511,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(); @@ -5325,12 +5591,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() }; @@ -5871,7 +6153,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(); @@ -5954,7 +6244,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(); @@ -8923,7 +9221,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(); @@ -9229,7 +9535,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 @@ -10101,6 +10406,18 @@ 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_builder_preferences_missing_consensus_version_header_returns_400() + .await + .test_builder_preferences_zero_length_entry_fields_return_400() + .await + .test_builder_preferences_oversize_list_returns_400() + .await + .test_builder_preferences_without_builder_service_returns_400() + .await .test_envelope_post_consensus_invalid_returns_400_no_broadcast() .await .test_envelope_post_gossip_partial_pass_returns_202()