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 e65c8bf2018..55ef009f463 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. @@ -127,6 +136,19 @@ impl BidCandidate { } } + pub fn supplied( + signed_bid: Arc>, + builder_boost_factor: u64, + ) -> Self { + Self { + signed_bid, + builder_boost_factor, + min_bid: 0, + demoted: false, + 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 @@ -169,6 +191,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, @@ -180,10 +205,11 @@ impl BidCandidate { } /// Lexicographic selection key (greater = better): whether the candidate is undemoted by the - /// circuit breaker, then `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. + /// circuit breaker, then `shouldOverrideBuilder`, then whether the bid clears its `min_bid` + /// floor, then for client-supplied bids only whether `builder_boost_factor` is `u64::MAX`, then + /// the boosted value (`trusted_value × builder_boost_factor`, in wei), then the local build wins + /// ties over externals. For other sources `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. /// /// 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 @@ -193,11 +219,12 @@ impl BidCandidate { /// Circuit-breaker demotion is ranked rather than filtered for the same reason: when the breaker /// trips the local build always wins if it exists, but a demoted external bid still beats /// missing the slot when the local build fails. - fn rank_key(&self) -> (bool, bool, bool, Uint256, bool) { + fn rank_key(&self) -> (bool, bool, bool, bool, Uint256, bool) { ( !self.demoted, 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(), @@ -371,6 +398,7 @@ mod tests { let source = match &win.source { BidSource::Local { .. } => "local", BidSource::Gossip => "gossip", + BidSource::ApiSupplied => "supplied", BidSource::Direct { .. } => "direct", }; ( @@ -381,6 +409,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 2734bd1e8cb..7ff35e8de93 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -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 @@ -287,7 +302,7 @@ impl BeaconChain { // production. let acquire_fut = self.acquire_external_bid_candidates( ctx, - &builder_config, + bid_source, proposer_preferences.as_deref(), &state, &parent_execution_requests, @@ -706,7 +721,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 { @@ -1117,10 +1132,14 @@ impl BeaconChain { /// gas limit and fee recipient). Acquisition is best-effort: any direct failure — including a /// missing builder service — is logged and skipped, never aborting block production, which can /// still proceed on the local build and gossip bids. + /// + /// For a bid supplied by the validator client, no direct builders are contacted. The gossip bid + /// is a candidate only if the supplied bid is invalid. Both pass the same circuit-breaker checks + /// as other external bids. async fn acquire_external_bid_candidates( self: &Arc, ctx: BidRequestContext, - builder_config: &BuilderConfig, + bid_source: BlockProductionBidSource, proposer_preferences: Option<&SignedProposerPreferences>, state: &BeaconState, parent_execution_requests: &ExecutionRequestsGloas, @@ -1167,36 +1186,64 @@ impl BeaconChain { let mut externals = Vec::new(); - // Direct bids: only when there are builders to contact and the proposer submitted - // preferences to validate against. - if !builder_config.builders.is_empty() { - if let Some(proposer_preferences) = proposer_preferences { + let (gossip_builder_boost_factor, gossip_min_bid) = match bid_source { + BlockProductionBidSource::Builders(builder_config) => { + // Direct bids: only when there are builders to contact and the proposer submitted + // preferences to validate against. + if !builder_config.builders.is_empty() { + if let Some(proposer_preferences) = proposer_preferences { + externals.extend( + self.acquire_direct_bid_candidates( + &ctx, + &builder_config, + proposer_preferences, + state, + ) + .await, + ); + } else { + // Direct bids can't be validated without the proposer's fee recipient / + // gas-limit target, so builders configured with no available preferences + // are skipped. + warn!( + "Builders are configured but no proposer preferences are available; \ + skipping direct builder bids for this proposal" + ); + } + } + (builder_config.builder_boost_factor, builder_config.min_bid) + } + BlockProductionBidSource::ApiSupplied { + signed_bid, + builder_boost_factor, + } => { externals.extend( - self.acquire_direct_bid_candidates( + self.validate_supplied_bid( &ctx, - builder_config, - proposer_preferences, + signed_bid, + builder_boost_factor, state, + parent_execution_requests, ) .await, ); - } else { - // Direct bids can't be validated without the proposer's fee recipient / gas-limit - // target, so builders configured with no available preferences are skipped. - warn!( - "Builders are configured but no proposer preferences are available; skipping \ - direct builder bids for this proposal" - ); + (builder_boost_factor, 0) } - } + }; - if let Some(gossip_bid) = self.gossip_verified_payload_bid_cache.get_highest_bid( - ctx.slot, - BidParent { - parent_block_hash: ctx.executed_ancestor_hash, - parent_block_root: ctx.parent_root, - }, - ) { + // The client selected the supplied bid, so only consult gossip if that bid is unusable. + let supplied_bid_valid = externals + .iter() + .any(|candidate| matches!(candidate.source, BidSource::ApiSupplied)); + if !supplied_bid_valid + && let Some(gossip_bid) = self.gossip_verified_payload_bid_cache.get_highest_bid( + ctx.slot, + BidParent { + parent_block_hash: ctx.executed_ancestor_hash, + parent_block_root: ctx.parent_root, + }, + ) + { // The gossip bid was validated against the head state at gossip time; its builder's // eligibility or coverage can go stale before production. Re-check against the production // state and drop it if it would now fail `per_block_processing`, so a stale gossip bid @@ -1205,8 +1252,8 @@ impl BeaconChain { Ok(_) => { externals.push(BidCandidate::gossip( gossip_bid, - builder_config.builder_boost_factor, - builder_config.min_bid, + gossip_builder_boost_factor, + gossip_min_bid, )); } Err(error) => { @@ -1280,6 +1327,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 59a81528a90..15e0ee3fa50 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 0b4c5fdbf36..6be7045c4ac 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -81,7 +81,7 @@ pub use self::beacon_chain::{ ProduceBlockVerification, StateSkipConfig, WhenSlotSkipped, }; pub use self::beacon_snapshot::{BeaconSnapshot, ChainDumpSnapshot}; -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 4fae2b3eff1..340ebd93f2d 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(); @@ -83,7 +111,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 74cfb08c79c..eac80a7ee29 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,14 @@ fn verify_bid_blobs( pub(crate) fn verify_direct_bid_consistency( bid: ExecutionPayloadBidRef, 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 e387f952c49..6910382a7d6 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -3451,6 +3451,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 cf7a4300efb..379466aedaa 100644 --- a/beacon_node/beacon_chain/tests/block_production.rs +++ b/beacon_node/beacon_chain/tests/block_production.rs @@ -1,22 +1,24 @@ use beacon_chain::{ - BlockProductionError, ProduceBlockVerification, + BlockProductionBidSource, BlockProductionError, ProduceBlockVerification, graffiti_calculator::GraffitiSettings, observed_operations::ObservationOutcome, payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid, test_utils::{BeaconChainHarness, EphemeralHarnessType, test_spec}, }; -use bls::PublicKeyBytes; +use bls::{PublicKeyBytes, Signature}; use eth2::types::{BuilderConfig, GraffitiPolicy}; use ethereum_hashing::hash; use genesis::{generate_deterministic_keypairs, interop_genesis_state}; use proto_array::PayloadStatus; use ssz::Encode; +use state_processing::state_advance::complete_state_advance; use std::sync::Arc; use types::{ - Address, BeaconBlock, BeaconState, Checkpoint, Epoch, ExecutionBlockHash, + Address, BeaconBlock, BeaconState, Builder, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, ExecutionPayloadBidGloas, ExecutionPayloadHeader, ExecutionPayloadHeaderFulu, - ExecutionRequests, ExecutionRequestsGloas, Hash256, SignedExecutionPayloadBid, Slot, Spec, - WithdrawalRequest, consts::gloas::PAYLOAD_BUILDER_VERSION, + ExecutionRequests, ExecutionRequestsGloas, Hash256, SignedExecutionPayloadBid, + SignedExecutionPayloadBidGloas, Slot, Spec, Uint256, WithdrawalRequest, + consts::gloas::{BUILDER_INDEX_SELF_BUILD, PAYLOAD_BUILDER_VERSION}, }; type E = Spec; @@ -142,6 +144,152 @@ async fn gloas_block_production_filters_exits_with_parent_partial_withdrawals() ); } +/// Client-selected bids use the same block construction as V4, with p2p/local fallback. +#[tokio::test] +async fn gloas_block_production_with_supplied_bid() { + let spec = test_spec::(); + if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { + return; + } + let spec = Arc::new(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); + + // 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 bid = ExecutionPayloadBidGloas:: { + 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() + }; + 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", + ] { + let mut supplied = bid.clone(); + match case { + "wrong_slot" => supplied.slot += 1, + "wrong_parent" => supplied.parent_block_root = Hash256::repeat_byte(99), + _ => {} + } + let supplied = if matches!(case, "bad_signature" | "p2p_fallback") { + Arc::new(SignedExecutionPayloadBid::Gloas( + SignedExecutionPayloadBidGloas { + message: supplied, + signature: Signature::empty(), + }, + )) + } else { + harness.sign_payload_bid(supplied, &state) + }; + if case == "p2p_fallback" { + let mut gossip_bid = bid.clone(); + gossip_bid.execution_payment = 0; + harness + .chain + .gossip_verified_payload_bid_cache + .observe_bid(GossipVerifiedPayloadBid { + signed_bid: harness.sign_payload_bid(gossip_bid, &state), + }); + } + 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: 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_as_signed_execution_payload_bid(); + if matches!(case, "valid" | "p2p_fallback") { + assert_eq!(produced_bid.message().builder_index(), 0, "{case}"); + assert_eq!(produced_bid.message().block_hash(), bid.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()); + } +} + const BUILDER_INDEX: u64 = 0; const BUILDER_BALANCE: u64 = 2_000_000_000; const BID_VALUE: u64 = 1_000_000; diff --git a/beacon_node/beacon_chain/tests/circuit_breaker.rs b/beacon_node/beacon_chain/tests/circuit_breaker.rs index a8d484d28d4..b3689ff8c7d 100644 --- a/beacon_node/beacon_chain/tests/circuit_breaker.rs +++ b/beacon_node/beacon_chain/tests/circuit_breaker.rs @@ -2,14 +2,15 @@ //! external bids during block production, and the bans recorded for builders that fail to reveal. use beacon_chain::circuit_breaker::BanEntry; +use beacon_chain::graffiti_calculator::GraffitiSettings; use beacon_chain::payload_envelope_verification::gossip_verified_envelope::GossipVerifiedEnvelope; use beacon_chain::test_utils::{ AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, PayloadAttestationVote, fork_name_from_env, test_spec, }; use beacon_chain::{ - AvailabilityProcessingStatus, BlockError, BlockProductionError, ChainConfig, - NotifyExecutionLayer, + AvailabilityProcessingStatus, BlockError, BlockProductionBidSource, BlockProductionError, + ChainConfig, NotifyExecutionLayer, ProduceBlockVerification, }; use bls::PublicKeyBytes; use execution_layer::{PayloadStatusV1, PayloadStatusV1Status}; @@ -22,7 +23,7 @@ use std::time::Duration; use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ BeaconState, BlockImportSource, BuilderIndex, EthSpec, ExecutionBlockHash, Hash256, - MinimalEthSpec, SignedBeaconBlock, Slot, + MinimalEthSpec, SignedBeaconBlock, SignedExecutionPayloadBid, Slot, }; type E = MinimalEthSpec; @@ -863,6 +864,83 @@ async fn tripped_breaker_falls_back_to_gossip_bid_when_local_build_fails() { assert_eq!(block_builder_index(&block_contents.0), BUILDER_A); } +/// Produce a block at the current slot on `state` with `signed_bid` supplied by the validator +/// client, and return the builder index that won. +async fn produce_with_supplied_bid( + harness: &Harness, + mut state: BeaconState, + signed_bid: Arc>, +) -> BuilderIndex { + recompute_head(harness).await; + let slot = harness.get_current_slot(); + complete_state_advance(&mut state, None, slot, None, &harness.spec).unwrap(); + state.build_caches(&harness.spec).unwrap(); + let proposer_index = state + .get_beacon_proposer_index(slot, &harness.spec) + .unwrap(); + let randao_reveal = harness.sign_randao_reveal(&state, proposer_index, slot); + let parent_root = *state.get_block_root(slot - 1).unwrap(); + let (block, ..) = Box::pin(harness.chain.produce_block_on_state_gloas( + state, + None, + parent_root, + PayloadStatus::Empty, + None, + slot, + randao_reveal, + GraffitiSettings::new(None, None), + ProduceBlockVerification::VerifyRandao, + BlockProductionBidSource::ApiSupplied { + signed_bid, + builder_boost_factor: u64::MAX, + }, + )) + .await + .unwrap(); + block + .body() + .signed_execution_payload_bid() + .unwrap() + .message() + .builder_index() +} + +#[tokio::test] +async fn tripped_breaker_demotes_supplied_bid() { + let Some(harness) = gloas_harness(ChainConfig { + // A single missed payload trips the consecutive rule. + builder_fallback_skips: 0, + ..ChainConfig::default() + }) else { + return; + }; + Box::pin(finalize(&harness)).await; + let state = Box::pin(withhold_payloads(&harness, 1)).await; + harness.advance_slot(); + let slot = harness.get_current_slot(); + assert_breaker_tripped(&harness, &state, slot).await; + let signed_bid = Arc::new(harness.make_signed_gossip_bid( + &state, + slot, + PayloadStatus::Empty, + BUILDER_A, + BID_VALUE, + )); + + // The local build wins even over a supplied bid at the maximum boost factor. + assert_eq!( + produce_with_supplied_bid(&harness, state.clone(), signed_bid.clone()).await, + BUILDER_INDEX_SELF_BUILD + ); + + // With the local build failing, the demoted supplied bid still saves the slot. + fail_local_builds_on(&harness, empty_parent_block_hash(&state)); + assert_eq!( + produce_with_supplied_bid(&harness, state, signed_bid).await, + BUILDER_A + ); +} + #[tokio::test] async fn tripped_breaker_never_falls_back_to_banned_builder() { let Some(harness) = gloas_harness(ChainConfig { diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 53dd48b3829..2522b1d39c0 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -263,6 +263,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(), @@ -3610,6 +3618,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 b87858e3c40..d97c0f96daf 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; @@ -33,8 +35,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; @@ -582,7 +584,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 44fe51a6c2b..2aa7cb697fe 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -5502,6 +5502,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; @@ -10853,6 +11079,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 59286bd04b3..d5858f1850d 100644 --- a/common/warp_utils/src/reject.rs +++ b/common/warp_utils/src/reject.rs @@ -128,6 +128,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); @@ -171,6 +180,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();