From 2bc74eb1c1089d0d14482768ece42fb71c9af843 Mon Sep 17 00:00:00 2001 From: eth2353 <70237279+eth2353@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:28:36 +0200 Subject: [PATCH] Add `produceBlockV4WithBid` Beacon API endpoint --- .../src/block_production/bid_selection.rs | 98 ++++++- .../src/block_production/gloas.rs | 158 +++++++++-- .../beacon_chain/src/block_production/mod.rs | 2 +- beacon_node/beacon_chain/src/lib.rs | 2 +- .../direct_verified_bid.rs | 30 ++- .../gossip_verified_bid.rs | 10 +- beacon_node/beacon_chain/src/test_utils.rs | 6 + .../beacon_chain/tests/block_production.rs | 163 ++++++++++++ beacon_node/http_api/src/lib.rs | 9 + beacon_node/http_api/src/produce_block.rs | 19 +- beacon_node/http_api/src/validator/mod.rs | 102 +++++++- beacon_node/http_api/tests/tests.rs | 245 ++++++++++++++++++ common/warp_utils/src/reject.rs | 12 + 13 files changed, 800 insertions(+), 56 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/bid_selection.rs b/beacon_node/beacon_chain/src/block_production/bid_selection.rs index 01f756d71d9..7fadf4ce417 100644 --- a/beacon_node/beacon_chain/src/block_production/bid_selection.rs +++ b/beacon_node/beacon_chain/src/block_production/bid_selection.rs @@ -7,15 +7,22 @@ //! its [`BidSource`]. The source is the single home for per-source data: `Local` carries the //! [`ExecutionPayloadData`] needed to build the envelope plus its EL block value, `Direct` carries //! the builder URL (to route the winning block back via `Eth-Builder-Url`) plus the proposer's -//! `max_execution_payment` cap, and `Gossip` carries nothing. There is no separate "winning bid" -//! type — the winner *is* a [`BidCandidate`], and the caller matches on its `source`. +//! `max_execution_payment` cap, and `Gossip` and client-selected `ApiSupplied` carry nothing. There is +//! no separate "winning bid" type — the winner *is* a [`BidCandidate`], and the caller matches on +//! its `source`. //! -//! All value math lives on [`BidCandidate`] and is computed on demand — nothing is precomputed. A -//! candidate's ranking key is its trusted value (the local block value, or a bid's clamped value) -//! scaled by `builder_boost_factor`, all in wei so the local EL block value compares directly. The -//! ordering is: the EL's `shouldOverrideBuilder`, then whether the bid clears its `min_bid` floor, -//! then the boosted value, then ties go to the local build, then to the earlier candidate. `min_bid` -//! is ranked, not filtered, so a below-floor bid is a last resort rather than a dropped one. +//! [`BidCandidate`] computes values on demand, in wei. Candidates normally compete on their +//! trusted value multiplied by `builder_boost_factor`. The local build wins ties with external +//! bids; otherwise, the earlier candidate wins a tie. +//! +//! Some preferences take priority over value. The EL's `shouldOverrideBuilder` takes precedence +//! over everything else. Next, candidates meeting their `min_bid` floor rank above those that +//! do not. Bids below the floor remain available as a last resort. +//! +//! For an API-supplied bid, `builder_boost_factor = u64::MAX` means prefer that bid even if the +//! local payload pays more. This preference does not bypass validation or health checks: block +//! production excludes invalid bids and applies the circuit breaker before selection, and the +//! EL's `shouldOverrideBuilder` still takes precedence here. //! //! Consumed by `gloas.rs` block production via [`select_payload_bid`]. @@ -62,6 +69,8 @@ pub enum BidSource { /// A bid from the `execution_payload_bid` gossip topic. Its `execution_payment` is zero, so there /// is nothing to clamp. Gossip, + /// A client-selected bid, trusting its full execution payment. + ApiSupplied, /// A bid fetched directly from a builder. Carries its URL (to route a winning block back via /// `submitSignedBeaconBlock` / `Eth-Builder-Url`) and the proposer's `max_execution_payment` cap /// for this builder. @@ -121,6 +130,18 @@ impl BidCandidate { } } + pub fn supplied( + signed_bid: Arc>, + builder_boost_factor: u64, + ) -> Self { + Self { + signed_bid, + builder_boost_factor, + min_bid: 0, + source: BidSource::ApiSupplied, + } + } + /// A direct-builder candidate under this builder's resolved policy. /// /// `max_execution_payment` is the largest `execution_payment` (gwei) the proposer trusts from this @@ -156,6 +177,9 @@ impl BidCandidate { let bid = &self.signed_bid.message; match &self.source { BidSource::Local { block_value, .. } => *block_value, + BidSource::ApiSupplied => { + gwei_to_wei(bid.value).saturating_add(gwei_to_wei(bid.execution_payment)) + } BidSource::Gossip => gwei_to_wei(bid.value), // gossip `execution_payment` is zero BidSource::Direct { max_execution_payment, @@ -168,18 +192,20 @@ impl BidCandidate { /// Lexicographic selection key (greater = better): `shouldOverrideBuilder`, then whether the bid /// clears its `min_bid` floor, then the boosted value (`trusted_value × builder_boost_factor`, in - /// wei — `u64::MAX` multiplies through rather than acting as an absolute override, so a - /// zero-value bid ranks 0 and loses to any non-zero local build), then the local build wins ties - /// over externals. + /// wei), then the local build wins ties over externals. For client-supplied bids only, + /// `u64::MAX` prefers an eligible bid regardless of value, below the EL override. Validation + /// and circuit-breaker checks happen before selection. Other sources retain V4's multiplier + /// semantics. /// /// Ranking `min_bid` rather than filtering means a below-floor bid still wins when it's the only /// viable option — the local build failed and every bid is under the floor — instead of missing /// the slot. Whenever *any* candidate clears the floor (the local build always does), the /// below-floor ones lose regardless of value, exactly as a hard filter would. - fn rank_key(&self) -> (bool, bool, Uint256, bool) { + fn rank_key(&self) -> (bool, bool, bool, Uint256, bool) { ( self.overrides_builder(), self.meets_min_bid(), + matches!(self.source, BidSource::ApiSupplied) && self.builder_boost_factor == u64::MAX, self.trusted_value() .saturating_mul(Uint256::from(self.builder_boost_factor)), self.is_local(), @@ -200,7 +226,7 @@ impl BidCandidate { let bid = &self.signed_bid.message; match &self.source { BidSource::Local { block_value, .. } => *block_value, - _ => gwei_to_wei(bid.value.saturating_add(bid.execution_payment)), + _ => gwei_to_wei(bid.value).saturating_add(gwei_to_wei(bid.execution_payment)), } } @@ -351,6 +377,7 @@ mod tests { let source = match &win.source { BidSource::Local { .. } => "local", BidSource::Gossip => "gossip", + BidSource::ApiSupplied => "supplied", BidSource::Direct { .. } => "direct", }; ( @@ -361,6 +388,51 @@ mod tests { ) } + #[test] + fn supplied_bid_boost_and_execution_payment() { + // Zero boost prefers even a zero-value local payload. Maximum boost prefers even a + // zero-value supplied bid. Neutral boost includes the client-trusted execution payment. + for (value, payment, boost, local_value, expected_local) in [ + (10, 20, 0, 0, true), + (0, 0, u64::MAX, 100, false), + (10, 20, 100, 25, false), + (10, 20, 100, 30, true), + (10, 20, 50, 20, true), + (u64::MAX, u64::MAX, u64::MAX - 1, 100, false), + ] { + let win = select_payload_bid(vec![ + BidCandidate::supplied(signed_bid(DIRECT_BUILDER, value, payment), boost), + local(local_value, false), + ]) + .unwrap(); + assert_eq!(win.is_local(), expected_local); + if !expected_local { + assert_eq!(win.payload_value(), gwei(value) + gwei(payment)); + assert!(win.builder_url().is_none()); + } + } + } + + #[test] + fn supplied_bid_cannot_override_execution_engine() { + let win = select_payload_bid(vec![ + BidCandidate::supplied(signed_bid(DIRECT_BUILDER, 100, 100), u64::MAX), + local(0, true), + ]) + .unwrap(); + assert!(win.is_local()); + } + + #[test] + fn supplied_bid_zero_boost_survives_local_failure() { + let win = select_payload_bid(vec![BidCandidate::supplied( + signed_bid(DIRECT_BUILDER, 0, 0), + 0, + )]) + .unwrap(); + assert!(!win.is_local()); + } + #[test] fn local_only_wins() { let win = select_payload_bid(vec![local(7, false)]).unwrap(); diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 194cc87f82c..82062ce315f 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -6,7 +6,7 @@ use proto_array::PayloadStatus; use bls::{PublicKeyBytes, Signature}; use execution_layer::{ - BlockProposalContentsGloas, BuilderParams, DEFAULT_GAS_LIMIT, PayloadAttributes, + BlockProposalContentsGloas, BuilderParams, ChainHealth, DEFAULT_GAS_LIMIT, PayloadAttributes, PayloadParameters, }; use operation_pool::CompactAttestationRef; @@ -45,7 +45,9 @@ use sensitive_url::SensitiveUrl; use crate::block_production::bid_selection::{self, BidCandidate, BidSource, ExecutionPayloadData}; use crate::payload_bid_verification::PayloadBidError; -use crate::payload_bid_verification::direct_verified_bid::verify_direct_bid; +use crate::payload_bid_verification::direct_verified_bid::{ + verify_bid_for_block, verify_direct_bid, +}; use crate::payload_bid_verification::gossip_verified_bid::{ builder_exit_requested, verify_bid_state_conditions, }; @@ -83,6 +85,22 @@ type BlockProductionResult = ( Option, ); +/// External bid input for Gloas block production. +pub enum BlockProductionBidSource { + Builders(BuilderConfig), + /// A bid selected by the validator client, falling back to gossip if invalid. + ApiSupplied { + signed_bid: Arc>, + builder_boost_factor: u64, + }, +} + +impl From for BlockProductionBidSource { + fn from(config: BuilderConfig) -> Self { + Self::Builders(config) + } +} + pub type PreparePayloadResult = Result, BlockProductionError>; pub type PreparePayloadHandle = JoinHandle>>; @@ -123,7 +141,7 @@ impl BeaconChain { slot: Slot, graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, - builder_config: BuilderConfig, + bid_source: impl Into>, ) -> Result, BlockProductionError> { metrics::inc_counter(&metrics::BLOCK_PRODUCTION_REQUESTS); let _complete_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_TIMES); @@ -161,7 +179,7 @@ impl BeaconChain { randao_reveal, graffiti_settings, verification, - builder_config, + bid_source, ) .await } @@ -179,13 +197,10 @@ impl BeaconChain { randao_reveal: Signature, graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, - builder_config: BuilderConfig, + bid_source: impl Into>, ) -> Result, BlockProductionError> { - debug!( - slot = %produce_at_slot, - direct_builders = builder_config.builders.len(), - "Producing Gloas block" - ); + let bid_source = bid_source.into(); + debug!(slot = %produce_at_slot, "Producing Gloas block"); let should_build_on_full = self .canonical_head @@ -285,13 +300,65 @@ impl BeaconChain { // read `state`, so they race without contention. A local EL failure is not fatal — we fall // back to an external bid when one is available; only a total absence of viable bids fails // production. - let acquire_fut = self.acquire_external_bid_candidates( - ctx, - &builder_config, - proposer_preferences.as_deref(), - &state, - &parent_execution_requests, - ); + let acquire_fut = async { + match bid_source { + BlockProductionBidSource::Builders(builder_config) => { + self.acquire_external_bid_candidates( + ctx, + &builder_config, + proposer_preferences.as_deref(), + &state, + &parent_execution_requests, + ) + .await + } + BlockProductionBidSource::ApiSupplied { + signed_bid, + builder_boost_factor, + } => { + let chain = self.clone(); + let health = match self.task_executor.spawn_blocking_handle( + move || chain.is_healthy(&parent_root), + "supplied_bid_chain_health", + ) { + Some(handle) => handle.await.ok().and_then(Result::ok), + None => None, + }; + match health { + Some(ChainHealth::Healthy) => {} + _ => { + warn!("Skipping external bids because the circuit breaker is active"); + return Vec::new(); + } + } + if let Some(candidate) = self + .validate_supplied_bid( + &ctx, + signed_bid, + builder_boost_factor, + &state, + &parent_execution_requests, + ) + .await + { + vec![candidate] + } else { + // The client selected the supplied bid, so only consult gossip if that bid + // is unusable. No direct builder requests are made by this endpoint. + let mut config = BuilderConfig::empty(); + config.builder_boost_factor = builder_boost_factor; + self.acquire_external_bid_candidates( + ctx, + &config, + proposer_preferences.as_deref(), + &state, + &parent_execution_requests, + ) + .await + } + } + } + }; let local_fut = self.clone().produce_execution_payload_bid( &state, parent_envelope, @@ -686,7 +753,7 @@ impl BeaconChain { // `payload_data` (`Some` only for a local build) drives envelope construction below. let payload_data = match source { BidSource::Local { payload_data, .. } => Some(*payload_data), - BidSource::Gossip | BidSource::Direct { .. } => None, + BidSource::Gossip | BidSource::Direct { .. } | BidSource::ApiSupplied => None, }; let PartialBeaconBlock { @@ -1102,6 +1169,61 @@ impl BeaconChain { externals } + /// Validate a client-selected bid before it can compete with the local payload. Failures + /// discard only the bid, so block production can continue with the local execution engine. + async fn validate_supplied_bid( + self: &Arc, + ctx: &BidRequestContext, + signed_bid: Arc>, + builder_boost_factor: u64, + state: &BeaconState, + parent_execution_requests: &ExecutionRequestsGloas, + ) -> Option> { + if state + .get_builder(signed_bid.message.builder_index) + .is_ok_and(|builder| builder_exit_requested(builder, parent_execution_requests)) + { + warn!("Skipping supplied bid from a builder the parent payload exits"); + return None; + } + + let executed_ancestor_gas_limit = self + .observed_execution_payloads + .get_gas_limit(ctx.executed_ancestor_hash)?; + let state = state.clone(); + let spec = self.spec.clone(); + let bid = signed_bid.clone(); + let slot = ctx.slot; + let executed_ancestor_hash = ctx.executed_ancestor_hash; + let parent_root = ctx.parent_root; + let result = self + .task_executor + .spawn_blocking_handle( + move || { + verify_bid_for_block( + &bid, + slot, + executed_ancestor_hash, + parent_root, + executed_ancestor_gas_limit, + &Default::default(), + None, + &state, + &spec, + ) + }, + "verify_supplied_bid", + )? + .await; + match result { + Ok(Ok(())) => Some(BidCandidate::supplied(signed_bid, builder_boost_factor)), + error => { + warn!(?error, "Skipping invalid supplied execution payload bid"); + None + } + } + } + /// Request direct bids from the configured builders and return each valid one as a selection /// candidate. /// diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 45538acc4ae..ebbf1dde371 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -14,7 +14,7 @@ use crate::{ mod bid_selection; mod gloas; -pub use gloas::PayloadEnvelopeContents; +pub use gloas::{BlockProductionBidSource, PayloadEnvelopeContents}; /// State loaded from the database for block production. pub(crate) struct BlockProductionState { diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 07eee2f6110..26994f89351 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -78,7 +78,7 @@ pub use self::beacon_chain::{ ProduceBlockVerification, StateSkipConfig, WhenSlotSkipped, }; pub use self::beacon_snapshot::BeaconSnapshot; -pub use self::block_production::PayloadEnvelopeContents; +pub use self::block_production::{BlockProductionBidSource, 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/direct_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs index f57d5628695..d6cc38742a4 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 @@ -44,6 +44,34 @@ pub fn verify_direct_bid( proposer_preferences: &SignedProposerPreferences, state: &BeaconState, spec: &ChainSpec, +) -> Result<(), PayloadBidError> { + verify_bid_for_block( + signed_bid, + proposal_slot, + executed_ancestor_hash, + parent_block_root, + executed_ancestor_gas_limit, + expected_builder_pubkeys, + Some(proposer_preferences), + state, + spec, + ) +} + +/// Shared validation for builder responses and bids supplied by the validator client. +/// A client-selected bid needs no cached proposer preferences: the client chose its fee recipient +/// and gas target, but all parent, consensus, gas adjustment and signature checks still apply. +#[allow(clippy::too_many_arguments)] +pub(crate) fn verify_bid_for_block( + signed_bid: &SignedExecutionPayloadBid, + proposal_slot: Slot, + executed_ancestor_hash: ExecutionBlockHash, + parent_block_root: Hash256, + executed_ancestor_gas_limit: u64, + expected_builder_pubkeys: &BuilderPubkeys, + proposer_preferences: Option<&SignedProposerPreferences>, + state: &BeaconState, + spec: &ChainSpec, ) -> Result<(), PayloadBidError> { let bid = &signed_bid.message; @@ -76,7 +104,7 @@ pub fn verify_direct_bid( if !is_gas_limit_target_compatible( executed_ancestor_gas_limit, bid.gas_limit, - proposer_preferences.message.target_gas_limit, + proposer_preferences.map_or(bid.gas_limit, |prefs| prefs.message.target_gas_limit), )? { return Err(PayloadBidError::InvalidGasLimit); } 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 851bf821de7..181a8aa5368 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 @@ -83,11 +83,11 @@ fn verify_bid_blobs( Ok(()) } -/// 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. +/// Verify that a builder-API or client-supplied bid is consistent with the current chain state +/// and, when provided, proposer preferences. /// /// 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): +/// [`verify_bid_for_block`](crate::payload_bid_verification::direct_verified_bid::verify_bid_for_block): /// 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 @@ -96,13 +96,13 @@ fn verify_bid_blobs( pub(crate) fn verify_direct_bid_consistency( bid: &ExecutionPayloadBid, current_slot: Slot, - proposer_preferences: &SignedProposerPreferences, + proposer_preferences: Option<&SignedProposerPreferences>, head_state: &BeaconState, spec: &ChainSpec, ) -> Result<(), PayloadBidError> { verify_bid_slot(bid.slot, current_slot)?; - if bid.fee_recipient != proposer_preferences.message.fee_recipient { + if proposer_preferences.is_some_and(|prefs| bid.fee_recipient != prefs.message.fee_recipient) { return Err(PayloadBidError::InvalidFeeRecipient); } diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 77772b07f5c..5f11698af58 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -3135,6 +3135,12 @@ where .await .expect("newPayload should succeed"); + // Mirror production import so subsequent builder bids can validate the parent gas limit. + self.chain.observed_execution_payloads.insert( + signed_envelope.message.payload.block_hash, + signed_envelope.message.payload.gas_limit, + ); + // Store the envelope and the data columns derived from the block. // // Production stores columns inside `import_available_execution_payload_envelope` after diff --git a/beacon_node/beacon_chain/tests/block_production.rs b/beacon_node/beacon_chain/tests/block_production.rs index 8a076aec8fd..0e94b508789 100644 --- a/beacon_node/beacon_chain/tests/block_production.rs +++ b/beacon_node/beacon_chain/tests/block_production.rs @@ -127,3 +127,166 @@ async fn gloas_block_production_filters_exits_with_parent_partial_withdrawals() spec.far_future_epoch ); } + +/// Client-selected bids use the same block construction as V4, with p2p/local fallback. +#[tokio::test] +async fn gloas_block_production_with_supplied_bid() { + use beacon_chain::{ + BlockProductionBidSource, ProduceBlockVerification, graffiti_calculator::GraffitiSettings, + payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid, + }; + use bls::Signature; + use state_processing::state_advance::complete_state_advance; + use types::{ + Builder, Domain, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, ForkName, + SignedExecutionPayloadBid, SignedRoot, Uint256, + consts::gloas::{BUILDER_INDEX_SELF_BUILD, PAYLOAD_BUILDER_VERSION}, + }; + + let spec = Arc::new(ForkName::Gloas.make_genesis_spec(E::default_spec())); + let harness = BeaconChainHarness::builder(E::default()) + .spec(spec.clone()) + .deterministic_keypairs(32) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + harness + .execution_block_generator() + .set_generate_blobs(false); + harness + .extend_to_slot(Slot::new(E::slots_per_epoch() * 4)) + .await; + harness.advance_slot(); + let slot = harness.get_current_slot(); + let head = harness.chain.canonical_head.cached_head(); + let parent_root = head.head_block_root(); + let parent_payload_status = head.head_payload_status(); + let parent_envelope = head.snapshot.execution_envelope.clone(); + drop(head); + assert!( + harness.chain.is_healthy(&parent_root).unwrap() == execution_layer::ChainHealth::Healthy + ); + + // Add a funded builder to the production state; no mutation of the canonical state is needed + // because these tests inspect the produced blocks rather than importing them. + let mut state = harness.get_current_state(); + complete_state_advance(&mut state, None, slot, None, &spec).unwrap(); + let builder_key = &harness.validator_keypairs[0]; + state + .builders_mut() + .unwrap() + .push(Builder { + pubkey: builder_key.pk.clone().into(), + version: PAYLOAD_BUILDER_VERSION, + execution_address: Address::repeat_byte(1), + balance: spec.min_deposit_amount + 1_000_000_000, + deposit_epoch: Epoch::new(0), + withdrawable_epoch: spec.far_future_epoch, + }) + .unwrap(); + state.build_caches(&spec).unwrap(); + assert!(state.is_active_builder(0, &spec).unwrap()); + let parent_bid = state.latest_execution_payload_bid().unwrap(); + let mut bid = SignedExecutionPayloadBid { + message: ExecutionPayloadBid:: { + slot, + parent_block_hash: parent_bid.block_hash, + parent_block_root: parent_root, + block_hash: ExecutionBlockHash::repeat_byte(42), + prev_randao: *state.get_randao_mix(state.current_epoch()).unwrap(), + gas_limit: parent_bid.gas_limit, + builder_index: 0, + value: 1_000_000_000, + execution_payment: 2_000_000_000, + ..Default::default() + }, + signature: Signature::empty(), + }; + let domain = spec.get_domain( + state.current_epoch(), + Domain::BeaconBuilder, + &spec.fork_at_epoch(state.current_epoch()), + state.genesis_validators_root(), + ); + bid.signature = builder_key.sk.sign(bid.message.signing_root(domain)); + let proposer_index = state.get_beacon_proposer_index(slot, &spec).unwrap(); + let randao_reveal = harness.sign_randao_reveal(&state, proposer_index, slot); + + for case in [ + "valid", + "prefer_local", + "wrong_slot", + "wrong_parent", + "bad_signature", + "p2p_fallback", + "circuit_breaker", + ] { + let mut supplied = bid.clone(); + match case { + "wrong_slot" => supplied.message.slot += 1, + "wrong_parent" => supplied.message.parent_block_root = Hash256::repeat_byte(99), + "bad_signature" | "p2p_fallback" => supplied.signature = Signature::empty(), + _ => {} + } + if case == "p2p_fallback" { + let mut gossip_bid = bid.clone(); + gossip_bid.message.execution_payment = 0; + gossip_bid.signature = builder_key.sk.sign(gossip_bid.message.signing_root(domain)); + harness + .chain + .gossip_verified_payload_bid_cache + .observe_bid(GossipVerifiedPayloadBid { + signed_bid: Arc::new(gossip_bid), + }); + } + if case == "circuit_breaker" { + // Leave the valid gossip fallback cached too; neither external bid may be used. + harness.chain.slot_clock.set_slot(slot.as_u64() + 10); + assert!( + harness.chain.is_healthy(&parent_root).unwrap() + != execution_layer::ChainHealth::Healthy + ); + } + let (block, _, _, value, payload, builder_url) = harness + .chain + .produce_block_on_state_gloas( + state.clone(), + None, + parent_root, + parent_payload_status, + parent_envelope.clone(), + slot, + randao_reveal.clone(), + GraffitiSettings::new(None, None), + ProduceBlockVerification::VerifyRandao, + BlockProductionBidSource::ApiSupplied { + signed_bid: Arc::new(supplied), + builder_boost_factor: if case == "prefer_local" { 0 } else { u64::MAX }, + }, + ) + .await + .unwrap_or_else(|error| panic!("{case}: {error:?}")); + let produced_bid = block.body().signed_execution_payload_bid().unwrap().clone(); + if matches!(case, "valid" | "p2p_fallback") { + assert_eq!(produced_bid.message.builder_index, 0, "{case}"); + assert_eq!(produced_bid.message.block_hash, bid.message.block_hash); + assert!(payload.is_none()); + let expected_gwei = if case == "valid" { + 3_000_000_000u64 + } else { + 1_000_000_000 + }; + assert_eq!( + value, + Uint256::from(expected_gwei) * Uint256::from(1_000_000_000u64) + ); + } else { + assert_eq!( + produced_bid.message.builder_index, BUILDER_INDEX_SELF_BUILD, + "{case}" + ); + assert!(payload.is_some()); + } + assert!(builder_url.is_none()); + } +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index e73cf06c00a..91105c56aeb 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -261,6 +261,7 @@ pub fn prometheus_metrics() -> warp::filters::log::Log( task_spawner_filter.clone(), ); + let post_validator_blocks_v4_with_bid = post_validator_blocks_v4_with_bid( + 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(), @@ -3528,6 +3536,7 @@ pub async fn serve( .uor(post_validator_register_validator) .uor(post_validator_builder_preferences) .uor(post_validator_blocks_v4) + .uor(post_validator_blocks_v4_with_bid) .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 49315790da0..33006c5547a 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -9,8 +9,8 @@ use crate::{ }; use beacon_chain::graffiti_calculator::GraffitiSettings; use beacon_chain::{ - BeaconBlockResponseWrapper, BeaconChain, BeaconChainTypes, PayloadEnvelopeContents, - ProduceBlockVerification, + BeaconBlockResponseWrapper, BeaconChain, BeaconChainTypes, BlockProductionBidSource, + PayloadEnvelopeContents, ProduceBlockVerification, }; use eth2::types::{self as api_types, ProduceBlockV3Metadata, SkipRandaoVerification}; use eth2::{ @@ -59,7 +59,7 @@ pub async fn produce_block_v4( chain: Arc>, slot: Slot, query: api_types::ValidatorBlocksQuery, - builder_config: api_types::BuilderConfig, + bid_source: BlockProductionBidSource, ) -> Result { // `produceBlockV4` is the Gloas block-production endpoint. let fork_name = chain.spec.fork_name_at_slot::(slot); @@ -75,13 +75,7 @@ pub async fn produce_block_v4( ) })?; - // 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" - ); + debug!(%slot, "Received produceBlockV4 request"); let randao_reveal = query.randao_reveal.decompress().map_err(|e| { warp_utils::reject::custom_bad_request(format!( @@ -92,8 +86,7 @@ pub async fn produce_block_v4( let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?; - // 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. + // Bid policy comes from the builder config or the supplied bid's required boost factor. let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); let ( @@ -109,7 +102,7 @@ pub async fn produce_block_v4( slot, graffiti_settings, randao_verification, - builder_config, + bid_source, ) .await .map_err(|e| { diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index ecb362985e4..f5c30e7d48a 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -10,7 +10,9 @@ use crate::version::{V1, V2, V3, add_ssz_content_type_header, unsupported_versio use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; -use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; +use beacon_chain::{ + AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes, BlockProductionBidSource, +}; use bls::PublicKeyBytes; use bytes::Bytes; use context_deserialize::ContextDeserialize; @@ -32,8 +34,8 @@ use tokio::sync::oneshot; use tracing::{debug, error, info, warn}; use types::{ BeaconState, Epoch, EthSpec, ForkName, ProposerPreparationData, SignedAggregateAndProof, - SignedContributionAndProof, SignedProposerPreferences, SignedValidatorRegistrationData, Slot, - SyncContributionData, ValidatorSubscription, + SignedContributionAndProof, SignedExecutionPayloadBid, SignedProposerPreferences, + SignedValidatorRegistrationData, Slot, SyncContributionData, ValidatorSubscription, }; use warp::{Filter, Rejection, Reply, http::response::Builder}; use warp_utils::reject::convert_rejection; @@ -583,7 +585,99 @@ pub fn post_validator_blocks_v4( "Block production request from HTTP API (v4)" ); not_synced_filter?; - produce_block_v4(accept_header, chain, slot, query, builder_config).await + produce_block_v4(accept_header, chain, slot, query, builder_config.into()).await + }) + }, + ) + .boxed() +} + +// POST v4/validator/blocks/{slot}/with_bid +pub fn post_validator_blocks_v4_with_bid( + 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("with_bid")) + .and(warp::path::end()) + .and(warp::header::optional::("accept").and_then( + |header: Option| async move { + header + .map(|value| { + value + .parse::() + .map_err(warp_utils::reject::not_acceptable) + }) + .transpose() + }, + )) + .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(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + accept_header: Option, + consensus_version: ForkName, + not_synced_filter: Result<(), Rejection>, + query: ValidatorBlocksQuery, + content_type: Option, + body: Bytes, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + debug!(?slot, %consensus_version, "Block production request with supplied bid"); + not_synced_filter?; + let builder_boost_factor = query.builder_boost_factor.ok_or_else(|| { + warp_utils::reject::custom_bad_request( + "builder_boost_factor query parameter is required".to_string(), + ) + })?; + let signed_bid = if is_ssz_content_type(content_type.as_deref()) { + SignedExecutionPayloadBid::::from_ssz_bytes(&body).map_err( + |e| { + warp_utils::reject::custom_bad_request(format!( + "invalid SSZ: {e:?}" + )) + }, + )? + } else { + if content_type + .as_deref() + .and_then(|value| value.split(';').next()) + .is_some_and(|value| value.trim() != "application/json") + { + return Err(warp_utils::reject::unsupported_media_type( + "expected application/json or application/octet-stream".to_string(), + )); + } + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + produce_block_v4( + accept_header, + chain, + slot, + query, + BlockProductionBidSource::ApiSupplied { + signed_bid: Arc::new(signed_bid), + builder_boost_factor, + }, + ) + .await }) }, ) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 0fc3fd17cec..55ab8eb0c2f 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4968,6 +4968,232 @@ impl ApiTester { self } + pub async fn test_block_production_v4_with_bid(self) -> Self { + use ssz::Encode; + + let slot = self.chain.slot().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::(slot); + let fork = self.chain.canonical_head.cached_head().head_fork(); + let (_, randao_reveal) = self + .proposer_setup( + slot, + slot.epoch(E::slots_per_epoch()), + &fork, + self.chain.genesis_validators_root, + ) + .await; + let mut url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + true, + None, + ) + .await + .unwrap(); + url.path_segments_mut().unwrap().push("with_bid"); + url.query_pairs_mut() + .append_pair("builder_boost_factor", &u64::MAX.to_string()); + let bid = SignedExecutionPayloadBid::::empty(); + let client = reqwest::Client::new(); + + if !fork_name.gloas_enabled() { + let response = client + .post(url) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) + .json(&bid) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + return self; + } + + // A well-formed but invalid bid must still produce a local block. Exercise both request + // encodings independently of response negotiation, with and without the payload. + for include_payload in [false, true] { + let mut request_url = url.clone(); + let pairs: Vec<_> = request_url + .query_pairs() + .filter(|(key, _)| key != "include_payload") + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + request_url + .query_pairs_mut() + .clear() + .extend_pairs(pairs) + .append_pair("include_payload", &include_payload.to_string()); + for request_ssz in [false, true] { + for response_ssz in [false, true] { + let request = client + .post(request_url.clone()) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) + .header( + "Accept", + if response_ssz { + "application/octet-stream" + } else { + "application/json" + }, + ); + let response = if request_ssz { + request + .header( + CONTENT_TYPE_HEADER, + "application/octet-stream; charset=binary", + ) + .body(bid.as_ssz_bytes()) + } else { + request.json(&bid) + } + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let metadata = ProduceBlockV4Metadata::try_from(response.headers()).unwrap(); + assert_eq!(metadata.execution_payload_included, include_payload); + assert!(metadata.builder_url.is_none()); + let block = if response_ssz { + let bytes = response.bytes().await.unwrap(); + if include_payload { + let contents = + BlockAndEnvelope::::from_ssz_bytes_for_fork(&bytes, fork_name) + .unwrap(); + self.assert_envelope_fields( + &contents.execution_payload_envelope, + contents.block.tree_hash_root(), + slot, + ); + contents.block + } else { + BeaconBlock::::from_ssz_bytes_for_fork(&bytes, fork_name).unwrap() + } + } else if include_payload { + let contents = response.json::, ProduceBlockV4Metadata>>().await.unwrap(); + assert_eq!( + contents.metadata.execution_payload_included, + include_payload + ); + contents.data.block + } else { + response + .json::, ProduceBlockV4Metadata>>() + .await + .unwrap() + .data + }; + assert_eq!(block.slot(), slot); + assert_eq!(metadata.consensus_version, fork_name); + assert_eq!( + metadata.execution_payload_value, + Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI) + ); + assert_eq!( + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message + .builder_index, + BUILDER_INDEX_SELF_BUILD + ); + assert!( + self.chain + .pending_payload_envelopes + .read() + .get_by_block_root(block.tree_hash_root()) + .is_some() + ); + } + } + } + + for missing in ["builder_boost_factor", "include_payload", "randao_reveal"] { + let mut missing_url = url.clone(); + let pairs: Vec<_> = missing_url + .query_pairs() + .filter(|(key, _)| key != missing) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + missing_url.query_pairs_mut().clear().extend_pairs(pairs); + let response = client + .post(missing_url) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) + .json(&bid) + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "missing {missing}" + ); + } + let response = client.post(url.clone()).json(&bid).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + for (content_type, body, expected) in [ + ( + "application/json", + b"{}".as_slice(), + StatusCode::BAD_REQUEST, + ), + ( + "application/octet-stream", + b"invalid".as_slice(), + StatusCode::BAD_REQUEST, + ), + ( + "text/plain", + b"invalid".as_slice(), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ), + ] { + let response = client + .post(url.clone()) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) + .header(CONTENT_TYPE_HEADER, content_type) + .body(body.to_vec()) + .send() + .await + .unwrap(); + assert_eq!(response.status(), expected); + } + let response = client + .post(url.clone()) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) + .header("Accept", "text/plain") + .json(&bid) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + for boost in ["-1", "18446744073709551616"] { + let mut invalid_url = url.clone(); + let pairs: Vec<_> = invalid_url + .query_pairs() + .filter(|(key, _)| key != "builder_boost_factor") + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + invalid_url + .query_pairs_mut() + .clear() + .extend_pairs(pairs) + .append_pair("builder_boost_factor", boost); + let response = client + .post(invalid_url) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) + .json(&bid) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + self + } + pub async fn test_block_production_v4_missing_include_payload_returns_400(self) -> Self { if !self.chain.spec.is_gloas_scheduled() { return self; @@ -10240,6 +10466,25 @@ async fn block_production_v3_ssz_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn block_production_v4_with_bid() { + ApiTester::new_from_config(ApiTesterConfig { + spec: ForkName::Gloas.make_genesis_spec(E::default_spec()), + ..Default::default() + }) + .await + .test_block_production_v4_with_bid() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn block_production_v4_with_bid_pre_gloas() { + ApiTester::new() + .await + .test_block_production_v4_with_bid() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn block_production_v4() { ApiTester::new_with_hard_forks() diff --git a/common/warp_utils/src/reject.rs b/common/warp_utils/src/reject.rs index 408551bb9b2..4d725d8b98e 100644 --- a/common/warp_utils/src/reject.rs +++ b/common/warp_utils/src/reject.rs @@ -139,6 +139,15 @@ pub fn invalid_auth(msg: String) -> warp::reject::Rejection { warp::reject::custom(InvalidAuthorization(msg)) } +#[derive(Debug)] +pub struct NotAcceptable(pub String); + +impl Reject for NotAcceptable {} + +pub fn not_acceptable(msg: String) -> warp::reject::Rejection { + warp::reject::custom(NotAcceptable(msg)) +} + #[derive(Debug)] pub struct UnsupportedMediaType(pub String); @@ -182,6 +191,9 @@ pub async fn handle_rejection(err: warp::Rejection) -> Result() { + code = StatusCode::NOT_ACCEPTABLE; + message = e.0.clone(); } else if err.find::().is_some() { code = StatusCode::UNSUPPORTED_MEDIA_TYPE; message = "UNSUPPORTED_MEDIA_TYPE".to_string();