From 74a86e2908a2a312b0053ca7835fe66e6c1f573f Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:48:14 -0500 Subject: [PATCH] Add Gloas bid selection, block production, and bid gossip processing (Gloas builder API 3/5) Third PR of the Gloas builder API stack: - beacon_chain: direct builder bid verification (spawned on the blocking executor), gossip-bid refinements, wei-domain bid selection (`BidCandidate`/`BidSource`), and Gloas block production that assembles local, gossip, and direct-builder candidates - network: process payload-bid and proposer-preference gossip, including the new `PayloadBidError` variants - client: construct the `Builders` service when the Gloas fork is scheduled and wire it into the beacon chain The HTTP API still serves `GET` produceBlockV4 at this point; the `POST` conversion and the `Eth-Builder-Url` round-trip land in the next PR. Change-Id: Ieccaee5db81bf871c19cef1b79fc48283be9dd51 --- Cargo.lock | 2 + beacon_node/beacon_chain/Cargo.toml | 1 + beacon_node/beacon_chain/src/beacon_chain.rs | 4 + .../src/block_production/bid_selection.rs | 567 ++++++++++++++++ .../src/block_production/gloas.rs | 623 +++++++++--------- .../beacon_chain/src/block_production/mod.rs | 1 + beacon_node/beacon_chain/src/builder.rs | 10 + beacon_node/beacon_chain/src/errors.rs | 2 + .../direct_verified_bid.rs | 324 +++++++++ .../gossip_verified_bid.rs | 55 +- .../src/payload_bid_verification/mod.rs | 18 + .../payload_bid_cache.rs | 130 ++-- .../src/payload_bid_verification/tests.rs | 48 +- beacon_node/beacon_chain/src/test_utils.rs | 3 +- .../beacon_chain/tests/prepare_payload.rs | 7 +- .../builder_client/src/builder_http_client.rs | 5 +- beacon_node/builder_client/src/builders.rs | 9 +- beacon_node/client/Cargo.toml | 1 + beacon_node/client/src/builder.rs | 24 + beacon_node/http_api/src/produce_block.rs | 42 +- .../gossip_methods.rs | 13 +- common/builder_types/src/builder_config.rs | 4 +- common/builder_types/src/builder_entry.rs | 4 +- 23 files changed, 1505 insertions(+), 392 deletions(-) create mode 100644 beacon_node/beacon_chain/src/block_production/bid_selection.rs create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs diff --git a/Cargo.lock b/Cargo.lock index 2900ac29632..3e9744e92c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,7 @@ dependencies = [ "beacon_chain", "bitvec", "bls", + "builder_client", "criterion", "educe", "eth2", @@ -2016,6 +2017,7 @@ version = "0.2.0" dependencies = [ "beacon_chain", "beacon_processor", + "builder_client", "directory", "dirs", "environment", diff --git a/beacon_node/beacon_chain/Cargo.toml b/beacon_node/beacon_chain/Cargo.toml index b545e614445..5657c096efe 100644 --- a/beacon_node/beacon_chain/Cargo.toml +++ b/beacon_node/beacon_chain/Cargo.toml @@ -24,6 +24,7 @@ alloy-primitives = { workspace = true } arbitrary = { workspace = true, optional = true } bitvec = { workspace = true } bls = { workspace = true } +builder_client = { workspace = true } educe = { workspace = true } eth2 = { workspace = true, features = ["lighthouse", "network"] } eth2_network_config = { workspace = true } diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 690e6ea9f54..28686acd910 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -91,6 +91,7 @@ use crate::{ CachedHead, metrics, }; use bls::{PublicKey, PublicKeyBytes, Signature}; +use builder_client::Builders; use eth2::beacon_response::ForkVersionedResponse; use eth2::types::{ EventKind, PtcDuty, SseBlobSidecar, SseBlock, SseDataColumnSidecar, @@ -462,6 +463,9 @@ pub struct BeaconChain { pub execution_layer: Option>, /// Client for the EIP-8025 proof engine, if one is configured. pub proof_engine: Option>, + /// Orchestrates direct builder bid requests and preference submissions over the Gloas Builder + /// API. Present only when the Gloas fork is scheduled. + pub builders: Option>, /// Stores information about the canonical head and finalized/justified checkpoints of the /// chain. Also contains the fork choice struct, for computing the canonical head. pub canonical_head: CanonicalHead, diff --git a/beacon_node/beacon_chain/src/block_production/bid_selection.rs b/beacon_node/beacon_chain/src/block_production/bid_selection.rs new file mode 100644 index 00000000000..01f756d71d9 --- /dev/null +++ b/beacon_node/beacon_chain/src/block_production/bid_selection.rs @@ -0,0 +1,567 @@ +//! Fork-agnostic ePBS payload-bid selection. +//! +//! Given the candidate bids for a slot, pick the winner. Selection is **value-based**: it never +//! inspects payload contents, only each candidate's ranking key. +//! +//! Every candidate — the local self-build and each external bid — is one [`BidCandidate`], tagged by +//! 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`. +//! +//! 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. +//! +//! Consumed by `gloas.rs` block production via [`select_payload_bid`]. + +use sensitive_url::SensitiveUrl; +use std::sync::Arc; +use types::{ + EthSpec, ExecutionPayloadGloas, ExecutionRequestsGloas, SignedExecutionPayloadBid, Slot, + Uint256, +}; + +const GWEI_TO_WEI: u64 = 1_000_000_000; + +/// The neutral `builder_boost_factor` (100% -> ×1). The local build competes at neutral boost. +const NEUTRAL_BOOST_FACTOR: u64 = 100; + +/// Convert a gwei figure to wei. Saturating, though realistic values are nowhere near the ceiling. +fn gwei_to_wei(gwei: u64) -> Uint256 { + Uint256::from(gwei).saturating_mul(Uint256::from(GWEI_TO_WEI)) +} + +/// Data needed to construct an `ExecutionPayloadEnvelope`, carried by the local candidate and +/// materialized only if it wins. +/// +/// Fork-coupling seam: `payload`/`execution_requests` are concrete Gloas types. Selection never +/// inspects them. +pub struct ExecutionPayloadData { + pub payload: ExecutionPayloadGloas, + pub execution_requests: ExecutionRequestsGloas, + pub builder_index: u64, + pub slot: Slot, + pub blobs_and_proofs: (types::BlobsList, types::KzgProofs), +} + +/// Where a payload bid came from, and the per-source data the winner needs (plus each source's +/// ranking input). +pub enum BidSource { + /// The locally-built payload. Carries the envelope data (boxed to keep the enum small), the EL's + /// `shouldOverrideBuilder` signal, and the EL block value (its ranking value, in wei). + Local { + payload_data: Box>, + should_override_builder: bool, + block_value: Uint256, + }, + /// A bid from the `execution_payload_bid` gossip topic. Its `execution_payment` is zero, so there + /// is nothing to clamp. + Gossip, + /// 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. + Direct { + builder_url: SensitiveUrl, + max_execution_payment: u64, + }, +} + +/// A payload-bid candidate: the committed bid, the proposer's boost for it, and its [`BidSource`]. +/// +/// Everything derivable (trusted value, ranking key, reported value) is a method — nothing is stored +/// that could be recomputed from these fields. +pub struct BidCandidate { + pub signed_bid: Arc>, + /// The proposer's boost multiplier for this candidate; `100` (neutral) for the local build. + builder_boost_factor: u64, + /// The proposer's `min_bid` acceptance floor (gwei) for this candidate. `0` for the local + /// build: the floor is a policy on builder payments, and the proposer's own block has none, + /// so it always ranks in the meets-floor tier. + min_bid: u64, + pub source: BidSource, +} + +impl BidCandidate { + /// The local self-build candidate, competing at neutral boost. `block_value` is its EL block + /// value (wei), used both to rank and to report. + pub fn local( + signed_bid: SignedExecutionPayloadBid, + payload_data: ExecutionPayloadData, + block_value: Uint256, + should_override_builder: bool, + ) -> Self { + Self { + signed_bid: Arc::new(signed_bid), + builder_boost_factor: NEUTRAL_BOOST_FACTOR, + min_bid: 0, + source: BidSource::Local { + payload_data: Box::new(payload_data), + should_override_builder, + block_value, + }, + } + } + + /// A gossip candidate under the global `builder_boost_factor` and `min_bid`. + pub fn gossip( + signed_bid: Arc>, + builder_boost_factor: u64, + min_bid: u64, + ) -> Self { + Self { + signed_bid, + builder_boost_factor, + min_bid, + source: BidSource::Gossip, + } + } + + /// A direct-builder candidate under this builder's resolved policy. + /// + /// `max_execution_payment` is the largest `execution_payment` (gwei) the proposer trusts from this + /// builder (`u64::MAX` = no clamp, `0` = untrusted); over-cap payment is clamped out of the + /// ranking value but still reported. `builder_boost_factor`: `100` neutral, `0` prefers local, + /// `u64::MAX` strongly favors the builder. Note it is a plain multiplier, not the pre-Gloas + /// absolute "always prefer" override: a zero-value bid still ranks 0 (`0 × u64::MAX == 0`), so a + /// non-zero local build outranks it. `min_bid` is the acceptance floor (gwei). + pub fn direct( + signed_bid: Arc>, + builder_boost_factor: u64, + max_execution_payment: u64, + min_bid: u64, + builder_url: SensitiveUrl, + ) -> Self { + Self { + signed_bid, + builder_boost_factor, + min_bid, + source: BidSource::Direct { + builder_url, + max_execution_payment, + }, + } + } + + /// The trusted value ranking is based on, in **wei**: the local EL block value, or a bid's + /// `value + min(execution_payment, max_execution_payment)`. Untrusted payment above the cap is + /// excluded so it can't sway ranking. The sum is taken in the wei domain, where two gwei-sized + /// terms are far below `Uint256::MAX`, so it is exact (a u64 gwei sum could saturate under a + /// hostile `execution_payment` with the cap disabled). + fn trusted_value(&self) -> Uint256 { + let bid = &self.signed_bid.message; + match &self.source { + BidSource::Local { block_value, .. } => *block_value, + BidSource::Gossip => gwei_to_wei(bid.value), // gossip `execution_payment` is zero + BidSource::Direct { + max_execution_payment, + .. + } => gwei_to_wei(bid.value).saturating_add(gwei_to_wei( + bid.execution_payment.min(*max_execution_payment), + )), + } + } + + /// 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. + /// + /// 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) { + ( + self.overrides_builder(), + self.meets_min_bid(), + self.trusted_value() + .saturating_mul(Uint256::from(self.builder_boost_factor)), + self.is_local(), + ) + } + + /// Whether the bid clears its `min_bid` floor: its trusted value is at least the floor. Untrusted + /// payment (excluded from the trusted value) can't be used to clear it. The local build's floor + /// is `0` (there is no builder payment to hold to a floor), so it always clears. + fn meets_min_bid(&self) -> bool { + self.trusted_value() >= gwei_to_wei(self.min_bid) + } + + /// The wei value reported for the winner (`Eth-Execution-Payload-Value`): the local block value, + /// or the **unclamped** `value + execution_payment` (the proposer's real revenue; the clamp is a + /// ranking-only trust bound). + pub fn payload_value(&self) -> Uint256 { + 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)), + } + } + + /// The winning builder's URL, if this bid came through the builder-API (direct) channel. + /// + /// Kept as a [`SensitiveUrl`] so it stays redacted in logs; the caller stringifies it only at + /// the `Eth-Builder-Url` header boundary. + pub fn builder_url(&self) -> Option<&SensitiveUrl> { + match &self.source { + BidSource::Direct { builder_url, .. } => Some(builder_url), + _ => None, + } + } + + pub fn is_local(&self) -> bool { + matches!(self.source, BidSource::Local { .. }) + } + + fn overrides_builder(&self) -> bool { + matches!( + self.source, + BidSource::Local { + should_override_builder: true, + .. + } + ) + } +} + +/// Select the winning payload bid. +/// +/// The total order is defined by [`rank_key`](BidCandidate::rank_key). On a full tie the earlier +/// candidate is kept. Returns `None` only when there are no candidates — the caller treats that as +/// block-production failure. +pub fn select_payload_bid(candidates: Vec>) -> Option> { + // `reduce` keeps `best` unless `candidate` is *strictly* greater, so the earliest of any tied + // maxima wins. + candidates.into_iter().reduce(|best, candidate| { + if candidate.rank_key() > best.rank_key() { + candidate + } else { + best + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use bls::Signature; + use ssz_types::VariableList; + use types::{ExecutionPayloadBid, MainnetEthSpec}; + + type TestSpec = MainnetEthSpec; + + const GOSSIP_BUILDER: u64 = 111; + const DIRECT_BUILDER: u64 = 222; + const LOCAL_BUILDER: u64 = 0; + + const NEUTRAL_BOOST: u64 = 100; + const NO_CLAMP: u64 = u64::MAX; + const DIRECT_URL: &str = "http://builder.example.com"; + + fn gwei(n: u64) -> Uint256 { + gwei_to_wei(n) + } + + fn direct_url() -> SensitiveUrl { + SensitiveUrl::parse(DIRECT_URL).expect("valid test url") + } + + fn signed_bid( + builder_index: u64, + value_gwei: u64, + payment_gwei: u64, + ) -> Arc> { + Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + builder_index, + value: value_gwei, + execution_payment: payment_gwei, + ..Default::default() + }, + signature: Signature::empty(), + }) + } + + fn gossip(value_gwei: u64, boost: u64) -> BidCandidate { + BidCandidate::gossip(signed_bid(GOSSIP_BUILDER, value_gwei, 0), boost, 0) + } + + fn gossip_min_bid(value_gwei: u64, min_bid: u64) -> BidCandidate { + BidCandidate::gossip( + signed_bid(GOSSIP_BUILDER, value_gwei, 0), + NEUTRAL_BOOST, + min_bid, + ) + } + + fn direct( + value_gwei: u64, + payment_gwei: u64, + boost: u64, + max_payment: u64, + ) -> BidCandidate { + BidCandidate::direct( + signed_bid(DIRECT_BUILDER, value_gwei, payment_gwei), + boost, + max_payment, + 0, + direct_url(), + ) + } + + fn direct_min_bid(value_gwei: u64, max_payment: u64, min_bid: u64) -> BidCandidate { + BidCandidate::direct( + signed_bid(DIRECT_BUILDER, value_gwei, 0), + NEUTRAL_BOOST, + max_payment, + min_bid, + direct_url(), + ) + } + + fn local(block_value_gwei: u64, should_override_builder: bool) -> BidCandidate { + BidCandidate::local( + SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + builder_index: LOCAL_BUILDER, + ..Default::default() + }, + signature: Signature::empty(), + }, + ExecutionPayloadData { + payload: ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequestsGloas::default(), + builder_index: LOCAL_BUILDER, + slot: Slot::new(0), + blobs_and_proofs: (VariableList::empty(), VariableList::empty()), + }, + gwei(block_value_gwei), + should_override_builder, + ) + } + + /// `(winning_builder_index, is_local, payload_value_wei, source_label)`. + fn outcome(win: BidCandidate) -> (u64, bool, Uint256, &'static str) { + let source = match &win.source { + BidSource::Local { .. } => "local", + BidSource::Gossip => "gossip", + BidSource::Direct { .. } => "direct", + }; + ( + win.signed_bid.message.builder_index, + win.is_local(), + win.payload_value(), + source, + ) + } + + #[test] + fn local_only_wins() { + let win = select_payload_bid(vec![local(7, false)]).unwrap(); + assert_eq!(outcome(win), (LOCAL_BUILDER, true, gwei(7), "local")); + } + + #[test] + fn external_only_wins_when_no_local() { + let win = select_payload_bid(vec![gossip(5, NEUTRAL_BOOST)]).unwrap(); + assert_eq!(outcome(win), (GOSSIP_BUILDER, false, gwei(5), "gossip")); + } + + #[test] + fn nothing_viable_is_none() { + assert!(select_payload_bid::(vec![]).is_none()); + } + + #[test] + fn el_override_beats_any_external() { + let win = select_payload_bid(vec![local(1, true), direct(1000, 1000, u64::MAX, NO_CLAMP)]) + .unwrap(); + assert_eq!(outcome(win), (LOCAL_BUILDER, true, gwei(1), "local")); + } + + #[test] + fn local_wins_value_tie() { + // Neutral boost, external trusted value == local block value ⇒ local wins ties. + let win = select_payload_bid(vec![local(5, false), gossip(5, NEUTRAL_BOOST)]).unwrap(); + assert_eq!(outcome(win), (LOCAL_BUILDER, true, gwei(5), "local")); + } + + #[test] + fn external_wins_when_strictly_higher() { + let win = select_payload_bid(vec![local(4, false), gossip(5, NEUTRAL_BOOST)]).unwrap(); + assert_eq!(outcome(win), (GOSSIP_BUILDER, false, gwei(5), "gossip")); + } + + #[test] + fn direct_bid_counts_execution_payment() { + // value 2 + payment 4 = 6 ranked (neutral) ⇒ beats local 5, reported at 6. + let win = select_payload_bid(vec![local(5, false), direct(2, 4, NEUTRAL_BOOST, NO_CLAMP)]) + .unwrap(); + assert_eq!(outcome(win), (DIRECT_BUILDER, false, gwei(6), "direct")); + } + + #[test] + fn huge_payment_sums_exactly_in_wei() { + // A hostile builder can put `u64::MAX` in `execution_payment`; with the cap disabled the + // trusted value must be the exact wei sum, not a saturated u64 gwei sum (which would + // collapse to `gwei(u64::MAX)` and erase `value`). + let candidate = direct(5, u64::MAX, NEUTRAL_BOOST, NO_CLAMP); + assert_eq!( + candidate.trusted_value(), + gwei(5).saturating_add(gwei(u64::MAX)) + ); + assert!(candidate.trusted_value() > gwei(u64::MAX)); + } + + #[test] + fn max_execution_payment_clamps_ranking_but_not_reported_value() { + // Unclamped: value 1 + payment 10 = 11 ranked (neutral) ⇒ beats local 5. + let unclamped = select_payload_bid(vec![ + local(5, false), + direct(1, 10, NEUTRAL_BOOST, NO_CLAMP), + ]) + .unwrap(); + assert_eq!( + outcome(unclamped), + (DIRECT_BUILDER, false, gwei(11), "direct") + ); + + // Clamp payment to 3: ranked value = 1 + min(10, 3) = 4 < local 5 ⇒ local wins. + let clamped = + select_payload_bid(vec![local(5, false), direct(1, 10, NEUTRAL_BOOST, 3)]).unwrap(); + assert_eq!(outcome(clamped), (LOCAL_BUILDER, true, gwei(5), "local")); + + // Clamp still lets it win over local 3 (ranked 4 > 3) — but the *reported* value is the + // unclamped proposer value 11, since the clamp is a ranking-only trust bound. + let clamped_win = + select_payload_bid(vec![local(3, false), direct(1, 10, NEUTRAL_BOOST, 3)]).unwrap(); + assert_eq!( + outcome(clamped_win), + (DIRECT_BUILDER, false, gwei(11), "direct") + ); + } + + #[test] + fn boost_amplifies_external() { + // Ranked 3 < local 5 ⇒ local; boost 200 ⇒ ranked 6 > 5 ⇒ external wins, reported at 3. + let no_boost = select_payload_bid(vec![local(5, false), gossip(3, NEUTRAL_BOOST)]).unwrap(); + assert_eq!(outcome(no_boost), (LOCAL_BUILDER, true, gwei(5), "local")); + + let boosted = select_payload_bid(vec![local(5, false), gossip(3, 200)]).unwrap(); + assert_eq!(outcome(boosted), (GOSSIP_BUILDER, false, gwei(3), "gossip")); + } + + #[test] + fn always_prefer_beats_higher_local() { + // Local block value dwarfs the bid, but `u64::MAX` boost multiplies it past any realistic local. + let win = + select_payload_bid(vec![local(1000, false), direct(1, 0, u64::MAX, NO_CLAMP)]).unwrap(); + assert_eq!(outcome(win), (DIRECT_BUILDER, false, gwei(1), "direct")); + } + + #[test] + fn zero_value_always_prefer_loses_to_local() { + // A zero-value always-prefer bid (0 × MAX = 0) correctly loses to a real local build. + let win = + select_payload_bid(vec![local(1, false), direct(0, 0, u64::MAX, NO_CLAMP)]).unwrap(); + assert_eq!(outcome(win), (LOCAL_BUILDER, true, gwei(1), "local")); + } + + #[test] + fn two_always_prefer_ranked_by_value() { + let win = select_payload_bid(vec![ + direct(1, 0, u64::MAX, NO_CLAMP), + direct(2, 0, u64::MAX, NO_CLAMP), + ]) + .unwrap(); + assert_eq!(outcome(win), (DIRECT_BUILDER, false, gwei(2), "direct")); + } + + #[test] + fn ranks_highest_across_sources() { + // Gossip ranked 10 (neutral) vs direct value 4 boosted 300 ⇒ ranked 12 ⇒ direct wins. + let win = select_payload_bid(vec![gossip(10, NEUTRAL_BOOST), direct(4, 0, 300, NO_CLAMP)]) + .unwrap(); + assert_eq!(outcome(win), (DIRECT_BUILDER, false, gwei(4), "direct")); + } + + #[test] + fn direct_winner_carries_builder_url() { + let win = select_payload_bid(vec![direct(5, 0, NEUTRAL_BOOST, NO_CLAMP)]).unwrap(); + assert_eq!(win.builder_url(), Some(&direct_url())); + } + + #[test] + fn below_min_bid_loses_to_local_regardless_of_value() { + // Direct bids 20 but its floor is 100 ⇒ below floor ⇒ loses to the local build worth only 5. + let win = + select_payload_bid(vec![local(5, false), direct_min_bid(20, NO_CLAMP, 100)]).unwrap(); + assert_eq!(outcome(win), (LOCAL_BUILDER, true, gwei(5), "local")); + } + + #[test] + fn below_min_bid_wins_when_it_is_the_only_option() { + // The local build failed and the only bid is under its floor ⇒ take it rather than miss the + // slot (ranking `min_bid` rather than filtering). + let win = select_payload_bid(vec![direct_min_bid(20, NO_CLAMP, 100)]).unwrap(); + assert_eq!(outcome(win), (DIRECT_BUILDER, false, gwei(20), "direct")); + } + + #[test] + fn floor_clearing_bid_beats_below_min_bid() { + // A gossip bid of 6 clears its (zero) floor; a direct bid of 20 is under its floor 100 ⇒ the + // floor-clearing bid wins despite its lower value. + let win = select_payload_bid(vec![ + gossip(6, NEUTRAL_BOOST), + direct_min_bid(20, NO_CLAMP, 100), + ]) + .unwrap(); + assert_eq!(outcome(win), (GOSSIP_BUILDER, false, gwei(6), "gossip")); + } + + #[test] + fn min_bid_floor_uses_trusted_value() { + // Value 4 + payment 10 but cap 0 ⇒ trusted value 4, below the floor 5; the unclamped value 14 + // can't clear it. It loses to a gossip bid of 1 that clears its own (zero) floor. + let below = BidCandidate::direct( + signed_bid(DIRECT_BUILDER, 4, 10), + NEUTRAL_BOOST, + 0, // cap 0 ⇒ payment untrusted + 5, // min_bid floor + direct_url(), + ); + let win = select_payload_bid(vec![gossip(1, NEUTRAL_BOOST), below]).unwrap(); + assert_eq!(outcome(win), (GOSSIP_BUILDER, false, gwei(1), "gossip")); + + // The same bid still wins if it's the only option (its unclamped 14 is reported). + let solo = BidCandidate::direct( + signed_bid(DIRECT_BUILDER, 4, 10), + NEUTRAL_BOOST, + 0, + 5, + direct_url(), + ); + assert_eq!( + outcome(select_payload_bid(vec![solo]).unwrap()), + (DIRECT_BUILDER, false, gwei(14), "direct") + ); + } + + #[test] + fn below_min_bid_gossip_loses_to_floor_clearing_direct() { + // Gossip bids 4 under the global floor 5; a direct bid of only 1 clears its own floor ⇒ the + // floor-clearing direct wins despite its lower value. + let win = select_payload_bid(vec![ + gossip_min_bid(4, 5), + direct(1, 0, NEUTRAL_BOOST, NO_CLAMP), + ]) + .unwrap(); + assert_eq!(outcome(win), (DIRECT_BUILDER, false, gwei(1), "direct")); + } +} diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index a7ccc151e47..66fc7766d09 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -32,13 +32,21 @@ use types::{ Address, Attestation, AttestationGloas, AttesterSlashing, AttesterSlashingGloas, BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, BlobsList, BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, - ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequestsGloas, FullPayload, Graffiti, - Hash256, IndexedAttestation, KzgProofs, PayloadAttestation, ProposerSlashing, RelativeEpoch, + ExecutionPayloadEnvelope, ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, + IndexedAttestation, KzgProofs, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, - SignedExecutionPayloadEnvelope, SignedVoluntaryExit, Slot, SyncAggregate, Uint256, Withdrawal, - Withdrawals, + SignedExecutionPayloadEnvelope, SignedProposerPreferences, SignedVoluntaryExit, Slot, + SyncAggregate, Uint256, Withdrawal, Withdrawals, }; +use builder_client::BidRequestContext; +use eth2::types::BuilderConfig; +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::gossip_verified_bid::verify_bid_state_conditions; use crate::payload_bid_verification::payload_bid_cache::BidParent; use crate::pending_payload_envelopes::PendingEnvelopeData; use crate::{ @@ -68,6 +76,9 @@ type BlockProductionResult = ( ConsensusBlockValue, ExecutionPayloadValue, Option>, + // The winning builder's URL when a direct builder won, for the `Eth-Builder-Url` response header. + // Kept as a `SensitiveUrl` (redacted in logs); stringified only at the header boundary. + Option, ); pub type PreparePayloadResult = Result, BlockProductionError>; @@ -90,18 +101,11 @@ pub struct PartialBeaconBlock { bls_to_execution_changes: Vec, } -/// Data needed to construct an ExecutionPayloadEnvelope. -/// The envelope requires the beacon_block_root which can only be computed after the block exists. -pub struct ExecutionPayloadData { - pub payload: ExecutionPayloadGloas, - pub execution_requests: ExecutionRequestsGloas, - pub builder_index: BuilderIndex, - pub slot: Slot, - pub blobs_and_proofs: (types::BlobsList, types::KzgProofs), -} - /// The result of a local payload build, used to decide whether to include a builder bid /// from the gossip cache or fall back to self-build. +/// +/// [`ExecutionPayloadData`] and the selection types ([`BidCandidate`], [`BidSource`]) live in the +/// fork-agnostic [`bid_selection`](super::bid_selection) module. pub struct LocalBuildResult { pub payload_data: ExecutionPayloadData, /// EL block value (in wei) of the locally-built payload. @@ -110,16 +114,6 @@ pub struct LocalBuildResult { pub should_override_builder: bool, } -/// The outcome of local-vs-builder bid selection. -pub(crate) struct WinningBid { - pub bid: SignedExecutionPayloadBid, - /// `Some` when self-building; `None` when committing to a builder bid (the builder - /// reveals the envelope). - pub payload_data: Option>, - /// Wei value of the winning bid. - pub payload_value: ExecutionPayloadValue, -} - impl BeaconChain { pub async fn produce_block_with_verification_gloas( self: &Arc, @@ -127,7 +121,7 @@ impl BeaconChain { slot: Slot, graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, - builder_boost_factor: Option, + builder_config: BuilderConfig, ) -> Result, BlockProductionError> { metrics::inc_counter(&metrics::BLOCK_PRODUCTION_REQUESTS); let _complete_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_TIMES); @@ -163,7 +157,7 @@ impl BeaconChain { randao_reveal, graffiti_settings, verification, - builder_boost_factor, + builder_config, ) .await } @@ -180,8 +174,14 @@ impl BeaconChain { randao_reveal: Signature, graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, - builder_boost_factor: Option, + builder_config: BuilderConfig, ) -> Result, BlockProductionError> { + debug!( + slot = %produce_at_slot, + direct_builders = builder_config.builders.len(), + "Producing Gloas block" + ); + let parent_root = if state.slot() > 0 { *state .get_block_root(state.slot() - 1) @@ -239,23 +239,95 @@ impl BeaconChain { // Part 2/3 (async) // - // Produce a local execution payload bid, then select between it and any cached - // gossip-verified builder bid using `builder_boost_factor`. - // TODO(gloas) build out trustless/trusted bid paths. - let (local_signed_bid, state, local_build) = self - .clone() - .produce_execution_payload_bid( - state, - should_build_on_full, - parent_envelope, - produce_at_slot, - BID_VALUE_SELF_BUILD, - BUILDER_INDEX_SELF_BUILD, - ) - .await?; + // Resolve the FULL/EMPTY parent execution hash, acquire the external candidates (direct + // builder bids + the highest gossip bid), produce the local execution payload bid, and + // select the most profitable eligible payload bid. + + // The FULL/EMPTY parent execution hash the payload builds on. + let parent_bid = state.latest_execution_payload_bid()?; + let parent_is_pre_gloas = !self + .spec + .fork_name_at_slot::(state.latest_block_header().slot) + .gloas_enabled(); + // The payload-chain parent: the latest *executed* ancestor's payload hash — the parent + // block's own payload when building on FULL, otherwise the payload the parent built on. + // Distinct from the beacon-chain parent (`parent_root`); on the wire this becomes the + // bid's `parent_block_hash` and the builder request's `parent_hash` path parameter. + let executed_ancestor_hash = if should_build_on_full || parent_is_pre_gloas { + parent_bid.block_hash + } else { + parent_bid.parent_block_hash + }; + + // The per-proposal context addressing each `getExecutionPayloadBid`. + let proposer_pubkey = state + .get_validator(partial_beacon_block.proposer_index as usize)? + .pubkey; + let ctx = BidRequestContext { + slot: produce_at_slot, + executed_ancestor_hash, + parent_root, + proposer_pubkey, + fork_name: self.spec.fork_name_at_slot::(produce_at_slot), + }; + + // The proposer's gossip-verified preferences for this slot, needed to validate direct bids. + // Absent (the proposer never submitted any) => direct bids are skipped. + let proposal_epoch = produce_at_slot.epoch(T::EthSpec::slots_per_epoch()); + let dependent_root = state.proposer_shuffling_decision_root_at_epoch( + proposal_epoch, + parent_root, + &self.spec, + )?; + let proposer_preferences = self + .gossip_verified_proposer_preferences_cache + .get_preferences(&produce_at_slot, dependent_root); + + // Fire the direct builder fan-out concurrently with the local EL payload build: both only + // 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, + ); + let local_fut = self.clone().produce_execution_payload_bid( + &state, + parent_envelope, + produce_at_slot, + BID_VALUE_SELF_BUILD, + BUILDER_INDEX_SELF_BUILD, + executed_ancestor_hash, + ); + let (mut candidates, local_result) = tokio::join!(acquire_fut, local_fut); + + match local_result { + Ok((local_signed_bid, local_build)) => { + let LocalBuildResult { + payload_data, + payload_value, + should_override_builder, + } = local_build; + candidates.push(BidCandidate::local( + local_signed_bid, + payload_data, + payload_value, + should_override_builder, + )); + } + Err(e) => { + error!( + error = ?e, + slot = %produce_at_slot, + "Local execution payload build failed; falling back to an external bid" + ); + } + } - let winning_bid = - self.select_payload_bid(local_signed_bid, local_build, builder_boost_factor); + let winning_bid = bid_selection::select_payload_bid(candidates) + .ok_or(BlockProductionError::NoViablePayloadBid)?; // Part 3/3 (blocking) // @@ -595,27 +667,36 @@ impl BeaconChain { /// Complete a block by computing its state root, and /// /// Return `(block, post_block_state, consensus_block_value, execution_payload_value, - /// payload_contents)` where: + /// payload_contents, builder_url)` where: /// /// - `post_block_state` is the state post block application /// - `consensus_block_value` is the consensus-layer rewards for `block` /// - `execution_payload_value` is the wei value of the winning payload bid /// - `payload_contents` is the locally-built envelope, KZG proofs and blobs (`None` when /// committing to a builder bid) + /// - `builder_url` is the winning direct builder's URL (`None` for a self-build or p2p bid) #[instrument(skip_all, level = "debug")] fn complete_partial_beacon_block_gloas( &self, partial_beacon_block: PartialBeaconBlock, - winning_bid: WinningBid, + winning_bid: BidCandidate, parent_execution_requests: ExecutionRequestsGloas, mut state: BeaconState, verification: ProduceBlockVerification, ) -> Result, BlockProductionError> { - let WinningBid { - bid: signed_execution_payload_bid, - payload_data, - payload_value: execution_payload_value, + // Read the reported value and `builder_url` (`Some` only for a direct bid, becoming the + // `Eth-Builder-Url` header) before destructuring the candidate. + let execution_payload_value = winning_bid.payload_value(); + let builder_url = winning_bid.builder_url().cloned(); + let BidCandidate { + signed_bid, source, .. } = winning_bid; + let signed_execution_payload_bid = (*signed_bid).clone(); + // `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, + }; let PartialBeaconBlock { slot, @@ -794,30 +875,32 @@ impl BeaconChain { consensus_block_value, execution_payload_value, payload_contents, + builder_url, )) } - /// Produce a self-build `ExecutionPayloadBid` for some `slot` upon the given `state`. - /// This function assumes we've already advanced `state`. + /// Produce a self-build `ExecutionPayloadBid` for some `slot` upon the given `state`, building + /// on `parent_block_hash` (the FULL/EMPTY parent execution hash the caller selected). This + /// function assumes we've already advanced `state`. /// - /// Returns the signed bid, the state, and a `LocalBuildResult` carrying the payload - /// data needed to construct the `ExecutionPayloadEnvelope` after the beacon block is - /// created, plus the EL block value and `should_override_builder` flag used by the - /// caller to compare against any cached p2p builder bid. + /// Borrows `state` (rather than consuming it) so the caller retains it if the local build fails + /// and it needs to fall back to an external bid. Returns the signed bid and a `LocalBuildResult` + /// carrying the payload data needed to construct the `ExecutionPayloadEnvelope` after the beacon + /// block is created, plus the EL block value and `should_override_builder` flag used by the + /// caller to compare against external builder bids. #[allow(clippy::type_complexity, clippy::too_many_arguments)] #[instrument(level = "debug", skip_all)] pub async fn produce_execution_payload_bid( self: Arc, - state: BeaconState, - should_build_on_full: bool, + state: &BeaconState, parent_envelope: Option>>, produce_at_slot: Slot, bid_value: u64, builder_index: BuilderIndex, + executed_ancestor_hash: ExecutionBlockHash, ) -> Result< ( SignedExecutionPayloadBid, - BeaconState, LocalBuildResult, ), BlockProductionError, @@ -853,29 +936,11 @@ impl BeaconChain { .map_err(|e| BlockProductionError::BeaconChain(Box::new(e)))?, }; - let parent_bid = state.latest_execution_payload_bid()?; - - let parent_block_slot = state.latest_block_header().slot; - let parent_is_pre_gloas = !self - .spec - .fork_name_at_slot::(parent_block_slot) - .gloas_enabled(); - let parent_block_hash = if should_build_on_full || parent_is_pre_gloas { - // Build on parent bid's payload. - parent_bid.block_hash - } else { - // Skip parent bid's payload. For genesis this is the EL genesis hash. - parent_bid.parent_block_hash - }; - - // TODO(gloas) this should be BlockProductionVersion::V4 - // V3 is okay for now as long as we're not connected to a builder - // TODO(gloas) add builder boost factor let prepare_payload_handle = get_execution_payload_gloas( self.clone(), - &state, + state, parent_root, - parent_block_hash, + executed_ancestor_hash, parent_envelope, proposer_index, builder_params, @@ -898,7 +963,7 @@ impl BeaconChain { // TODO(gloas) since we are defaulting to local building, execution payment is 0 // execution payment should only be set to > 0 for trusted building. let bid = ExecutionPayloadBid:: { - parent_block_hash, + parent_block_hash: executed_ancestor_hash, parent_block_root: parent_root, block_hash: payload.block_hash, prev_randao: payload.prev_randao, @@ -927,7 +992,6 @@ impl BeaconChain { message: bid, signature: Signature::infinity().map_err(BlockProductionError::BlsError)?, }, - state, LocalBuildResult { payload_data, payload_value, @@ -936,109 +1000,191 @@ impl BeaconChain { )) } - /// Look up the highest gossip-verified bid for the `(slot, parent_block_hash, - /// parent_block_root)` of the local bid, then choose the winner. - fn select_payload_bid( - &self, - local_signed_bid: SignedExecutionPayloadBid, - local_build: LocalBuildResult, - builder_boost_factor: Option, - ) -> WinningBid { - let cached_bid = self.gossip_verified_payload_bid_cache.get_highest_bid( - local_signed_bid.message.slot, - BidParent::from_bid(&local_signed_bid.message), - ); - select_payload_bid_pure( - local_signed_bid, - local_build, - cached_bid, - builder_boost_factor, - ) - } -} - -/// Local-vs-cached selection logic, factored out for unit testing. -/// -/// Selection rule (mirrors the pre-Gloas builder/local race in `execution_layer`): -/// - `boosted_bid = (cached_bid.value / 100) * builder_boost_factor` (raw value when `None`) -/// - if `local_value_wei >= boosted_bid_wei` → keep local -/// - if the EL signaled `should_override_builder` → keep local -/// - otherwise → use the cached builder bid and drop local payload data -/// (the builder is responsible for revealing the envelope). -/// -/// `cached_bid.value` is in gwei (`u64`); `payload_value` is in wei (`Uint256`); compared in wei. -pub(crate) fn select_payload_bid_pure( - local_signed_bid: SignedExecutionPayloadBid, - local_build: LocalBuildResult, - cached_bid: Option>>, - builder_boost_factor: Option, -) -> WinningBid { - let LocalBuildResult { - payload_data, - payload_value, - should_override_builder, - } = local_build; - - let Some(cached_bid) = cached_bid else { - return WinningBid { - bid: local_signed_bid, - payload_data: Some(payload_data), - payload_value, - }; - }; + /// Acquire the external payload-bid candidates for this proposal. + /// + /// Fans `getExecutionPayloadBid` out to every configured direct builder (validating each + /// returned bid against `state` via [`verify_direct_bid`]), then reads the highest direct bid + /// and the highest gossip-verified bid from their caches and returns them as external + /// [`BidCandidate`]s for [`bid_selection::select_payload_bid`](super::bid_selection) to rank + /// against the local build. + /// + /// Direct bids are requested only when there are configured builders to contact and the proposer + /// submitted preferences to validate against (`proposer_preferences`, needed for a direct bid's + /// 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. + async fn acquire_external_bid_candidates( + self: &Arc, + ctx: BidRequestContext, + builder_config: &BuilderConfig, + proposer_preferences: Option<&SignedProposerPreferences>, + state: &BeaconState, + ) -> Vec> { + 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 { + 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" + ); + } + } - let slot = local_signed_bid.message.slot; + 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 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 + // can't outrank a viable candidate and sink the whole proposal. + match verify_bid_state_conditions(&gossip_bid.message, state, &self.spec) { + Ok(_) => { + externals.push(BidCandidate::gossip( + gossip_bid, + builder_config.builder_boost_factor, + builder_config.min_bid, + )); + } + Err(error) => { + warn!( + ?error, + "Skipping gossip bid that no longer passes state validation" + ); + } + } + } - if should_override_builder { - debug!( - %slot, - cached_bid_value = cached_bid.message.value, - "Using local payload because EL signaled shouldOverrideBuilder" - ); - return WinningBid { - bid: local_signed_bid, - payload_data: Some(payload_data), - payload_value, - }; + externals } - // Convert bid value (gwei) to wei for comparison with `payload_value` (wei). - let bid_value_wei = types::Uint256::from(cached_bid.message.value) - .saturating_mul(types::Uint256::from(1_000_000_000u64)); - let boosted_bid_wei = match builder_boost_factor { - Some(factor) => { - (bid_value_wei / types::Uint256::from(100)).saturating_mul(types::Uint256::from(factor)) - } - None => bid_value_wei, - }; + /// Request direct bids from the configured builders and return each valid one as a selection + /// candidate. + /// + /// Best-effort and never fatal: the builder service is constructed whenever the Gloas fork is + /// scheduled, so in a correctly-built node it is always present on this (Gloas) path — a missing + /// service is an unexpected construction bug. Either way it is logged and skipped rather than + /// aborting block production. Per-builder request/validation failures are handled inside + /// [`request_and_validate_bids`](builder_client::Builders::request_and_validate_bids). + async fn acquire_direct_bid_candidates( + self: &Arc, + ctx: &BidRequestContext, + builder_config: &BuilderConfig, + proposer_preferences: &SignedProposerPreferences, + state: &BeaconState, + ) -> Vec> { + let Some(builders) = self.builders.as_ref() else { + error!( + "Builder service unexpectedly absent during Gloas block production (it is built \ + whenever the Gloas fork is scheduled); skipping direct bids for this proposal" + ); + return Vec::new(); + }; - if payload_value >= boosted_bid_wei { - debug!( - %slot, - %payload_value, - cached_bid_value_gwei = cached_bid.message.value, - ?builder_boost_factor, - "Local payload is more profitable than cached builder bid" - ); - WinningBid { - bid: local_signed_bid, - payload_data: Some(payload_data), - payload_value, - } - } else { - debug!( - %slot, - %payload_value, - cached_bid_value_gwei = cached_bid.message.value, - cached_bid_builder_index = cached_bid.message.builder_index, - ?builder_boost_factor, - "Including cached builder bid" - ); - WinningBid { - bid: (*cached_bid).clone(), - payload_data: None, - payload_value: bid_value_wei, - } + let slot = ctx.slot; + let executed_ancestor_hash = ctx.executed_ancestor_hash; + let parent_root = ctx.parent_root; + + // Clone the production state once and share it across the concurrent per-builder + // verifications via `Arc`. The clone converts the `&BeaconState` borrow into an owned value + // the blocking tasks can hold (they must be `'static`, so they can't borrow this scope); + // it's a milhouse structural share (refcount bumps, not a copy of the validator set), so it's + // cheap. Each builder's task then just clones these `Arc`s. + let state = Arc::new(state.clone()); + let spec = self.spec.clone(); + let proposer_preferences = Arc::new(proposer_preferences.clone()); + let executor = self.task_executor.clone(); + // The gas limit a direct bid must adjust from is that of the execution payload at the + // selected parent, which is the right baseline under either a FULL or EMPTY parent view. + // Unknown means we can't validate any direct bid for this parent; each is then skipped. + let executed_ancestor_gas_limit = self + .observed_execution_payloads + .get_gas_limit(executed_ancestor_hash); + + // Fan `getExecutionPayloadBid` out to the configured builders, validating each returned bid + // against the production state, then turn each valid bid into a `Direct` selection candidate. + builders + .request_and_validate_bids( + ctx, + &builder_config.builders, + move |signed_bid, expected_builder_pubkeys| { + let state = state.clone(); + let spec = spec.clone(); + let proposer_preferences = proposer_preferences.clone(); + let executor = executor.clone(); + async move { + // The bid's BLS signature check is CPU-bound; run the whole verification on a + // blocking thread so it doesn't stall the async executor during the proposal + // path. Runtime-shutdown / join failures are surfaced as `InternalError`, + // which `request_and_validate_bids` logs and skips like any other bid failure. + executor + .spawn_blocking_handle( + move || { + let Some(executed_ancestor_gas_limit) = + executed_ancestor_gas_limit + else { + return Err( + PayloadBidError::ParentExecutionPayloadUnknown { + parent_block_hash: executed_ancestor_hash, + }, + ); + }; + verify_direct_bid( + &signed_bid, + slot, + executed_ancestor_hash, + parent_root, + executed_ancestor_gas_limit, + &expected_builder_pubkeys, + &proposer_preferences, + &state, + &spec, + ) + }, + "verify_direct_bid", + ) + .ok_or_else(|| { + PayloadBidError::InternalError("runtime shutting down".to_string()) + })? + .await + .map_err(|e| { + PayloadBidError::InternalError(format!( + "verify_direct_bid task failed: {e}" + )) + })? + } + }, + ) + .await + .into_iter() + .map(|direct| { + BidCandidate::direct( + direct.signed_bid, + direct.builder_boost_factor, + direct.max_execution_payment, + direct.min_bid, + direct.builder_url, + ) + }) + .collect() } } @@ -1235,7 +1381,7 @@ fn filter_voluntary_exits_for_parent_execution_requests( #[cfg(test)] mod tests { use super::*; - use ssz_types::{ProgressiveVariableList, VariableList}; + use ssz_types::ProgressiveVariableList; use types::{ConsolidationRequest, Epoch, MainnetEthSpec, VoluntaryExit, WithdrawalRequest}; type TestSpec = MainnetEthSpec; @@ -1372,123 +1518,4 @@ mod tests { assert_eq!(exits.len(), 2); } - - // ---- select_payload_bid_pure ---- - - const REMOTE_BUILDER: BuilderIndex = 999; - - fn gwei(n: u64) -> types::Uint256 { - types::Uint256::from(n).saturating_mul(types::Uint256::from(1_000_000_000u64)) - } - - fn local_bid() -> SignedExecutionPayloadBid { - SignedExecutionPayloadBid { - message: ExecutionPayloadBid { - builder_index: BUILDER_INDEX_SELF_BUILD, - ..Default::default() - }, - signature: Signature::empty(), - } - } - - fn cached_bid(value_gwei: u64) -> Arc> { - Arc::new(SignedExecutionPayloadBid { - message: ExecutionPayloadBid { - builder_index: REMOTE_BUILDER, - value: value_gwei, - ..Default::default() - }, - signature: Signature::empty(), - }) - } - - fn local_build(payload_gwei: u64, should_override_builder: bool) -> LocalBuildResult { - LocalBuildResult { - payload_data: ExecutionPayloadData { - payload: types::ExecutionPayloadGloas::default(), - execution_requests: ExecutionRequestsGloas::default(), - builder_index: BUILDER_INDEX_SELF_BUILD, - slot: Slot::new(0), - blobs_and_proofs: (VariableList::empty(), VariableList::empty()), - }, - payload_value: gwei(payload_gwei), - should_override_builder, - } - } - - const LOCAL: BuilderIndex = BUILDER_INDEX_SELF_BUILD; - const REMOTE: BuilderIndex = REMOTE_BUILDER; - - /// Run `select_payload_bid_pure` and return - /// `(winning_builder_index, has_payload_data, execution_payload_value_wei)`. - /// - /// Args (positional, mirror `select_payload_bid_pure`): - /// - `local_payload_gwei`: local payload value, in gwei. - /// - `should_override`: EL's `shouldOverrideBuilder` flag. - /// - `cached_gwei`: `Some(g)` ⇒ seed the cache with a bid of `g` gwei. - /// - `boost`: `None` = neutral, `Some(0)` = always local, `Some(>100)` = boost bid. - fn pick( - local_payload_gwei: u64, - should_override: bool, - cached_gwei: Option, - boost: Option, - ) -> (BuilderIndex, bool, ExecutionPayloadValue) { - let build = local_build(local_payload_gwei, should_override); - let cache = cached_gwei.map(cached_bid); - let winning_bid = select_payload_bid_pure::(local_bid(), build, cache, boost); - ( - winning_bid.bid.message.builder_index, - winning_bid.payload_data.is_some(), - winning_bid.payload_value, - ) - } - - #[test] - fn select_empty_cache_keeps_local() { - assert_eq!(pick(7, false, None, Some(u64::MAX)), (LOCAL, true, gwei(7))); - } - - #[test] - fn select_el_override_beats_any_cached_bid() { - // `shouldOverrideBuilder` short-circuits regardless of cache or boost. - assert_eq!( - pick(7, true, Some(u64::MAX), Some(u64::MAX)), - (LOCAL, true, gwei(7)) - ); - } - - #[test] - fn select_boost_zero_always_keeps_local() { - // boost=0 deflates the bid to 0 ⇒ local always wins. - assert_eq!( - pick(0, false, Some(u64::MAX), Some(0)), - (LOCAL, true, gwei(0)) - ); - } - - #[test] - fn select_neutral_boost_picks_higher_bid() { - // 5 gwei bid > 1 gwei local, neutral compare ⇒ bid, valued at the bid's worth. - assert_eq!(pick(1, false, Some(5), None), (REMOTE, false, gwei(5))); - } - - #[test] - fn select_local_strictly_higher_keeps_local() { - assert_eq!(pick(10, false, Some(5), None), (LOCAL, true, gwei(10))); - } - - #[test] - fn select_tie_goes_to_local() { - // `>=` ⇒ local wins ties. - assert_eq!(pick(5, false, Some(5), None), (LOCAL, true, gwei(5))); - } - - #[test] - fn select_boost_factor_amplifies_bid() { - // 5 gwei local vs 3 gwei bid: raw ⇒ local. - assert_eq!(pick(5, false, Some(3), None), (LOCAL, true, gwei(5))); - // boost=200 ⇒ bid scaled to 6 gwei ⇒ bid wins, but the reported value - // is the raw bid value, not the boosted one. - assert_eq!(pick(5, false, Some(3), Some(200)), (REMOTE, false, gwei(3))); - } } diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index e3616419ae9..6ce6aecda9c 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -11,6 +11,7 @@ use crate::{ fork_choice_signal::ForkChoiceWaitResult, metrics, }; +mod bid_selection; mod gloas; pub use gloas::PayloadEnvelopeContents; diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 097613f6f46..0f200d39943 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -22,6 +22,7 @@ use crate::{ BeaconChain, BeaconChainTypes, BeaconForkChoiceStore, BeaconSnapshot, ServerSentEventHandler, }; use bls::Signature; +use builder_client::Builders; use execution_layer::ExecutionLayer; use fixed_bytes::FixedBytesExtended; use fork_choice::{ForkChoice, PayloadStatus, ResetPayloadStatuses}; @@ -94,6 +95,7 @@ pub struct BeaconChainBuilder { op_pool: Option>, execution_layer: Option>, proof_engine: Option>, + builders: Option>, event_handler: Option>, slot_clock: Option, shutdown_sender: Option>, @@ -137,6 +139,7 @@ where op_pool: None, execution_layer: None, proof_engine: None, + builders: None, event_handler: None, slot_clock: None, shutdown_sender: None, @@ -641,6 +644,12 @@ where self } + /// Sets the `BeaconChain` builder service (the Gloas Builder API client). + pub fn builders(mut self, builders: Option>) -> Self { + self.builders = builders; + self + } + /// Sets the node custody type for data column import. pub fn node_custody_type(mut self, node_custody_type: NodeCustodyType) -> Self { self.node_custody_type = node_custody_type; @@ -1044,6 +1053,7 @@ where observed_bls_to_execution_changes: <_>::default(), execution_layer: self.execution_layer.clone(), proof_engine: self.proof_engine, + builders: self.builders, genesis_validators_root, genesis_time, canonical_head, diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index 1e82a5cba69..ad04bbc382f 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -295,6 +295,8 @@ easy_from_to!(AttestationError, BeaconChainError); pub enum BlockProductionError { UnableToGetBlockRootFromState, UnableToReadSlot, + /// No viable payload bid was available (no local build and no eligible external bid). + NoViablePayloadBid, UnableToProduceAtSlot(Slot), SlotProcessingError(SlotProcessingError), BlockProcessingError(BlockProcessingError), 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 new file mode 100644 index 00000000000..9983df131d8 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs @@ -0,0 +1,324 @@ +use crate::payload_bid_verification::{ + PayloadBidError, + gossip_verified_bid::{is_gas_limit_target_compatible, verify_bid_consistency}, +}; +use eth2::types::BuilderPubkeys; +use state_processing::signature_sets::{ + execution_payload_bid_signature_set, get_builder_pubkey_from_state, +}; +use types::{ + BeaconState, ChainSpec, EthSpec, ExecutionBlockHash, Hash256, SignedExecutionPayloadBid, + SignedProposerPreferences, Slot, +}; + +/// 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`] +/// (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 +/// FULL/EMPTY parent (`parent_block_hash` / `parent_block_root`), the state's RANDAO mix, and a +/// gas limit compatible with the parent's under the proposer's target, and +/// - a valid builder signature. +/// +/// Unlike gossip bids, direct bids may carry an execution payment; the `execution_payment == 0` +/// rule is a gossip-only check applied in the gossip verifier, not here. +/// +/// `state` must be the beacon state the block is being produced against — the parent block's +/// post-state advanced to `proposal_slot` — and `parent_block_hash` / `parent_block_root` the +/// FULL/EMPTY parent the producer selected — `executed_ancestor_hash` is that choice's payload +/// hash (the parent's payload if FULL, else the payload the parent built on), and +/// `executed_ancestor_gas_limit` is its gas limit (from `ObservedExecutionPayloads`), the right +/// baseline under either view — after an empty parent, the state's latest bid is unexecuted and +/// its gas limit is not the one the bid must adjust from. +#[allow(clippy::too_many_arguments)] +pub fn verify_direct_bid( + 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: &SignedProposerPreferences, + state: &BeaconState, + spec: &ChainSpec, +) -> Result<(), PayloadBidError> { + let bid = &signed_bid.message; + + // The bid must be for exactly the slot being produced. + if bid.slot != proposal_slot { + return Err(PayloadBidError::InvalidBidSlot { bid_slot: bid.slot }); + } + + // The bid must build on the executed ancestor the producer selected (FULL or EMPTY view). + if bid.parent_block_hash != executed_ancestor_hash { + return Err(PayloadBidError::InvalidParentBlockHash { + bid: bid.parent_block_hash, + expected: executed_ancestor_hash, + }); + } + if bid.parent_block_root != parent_block_root { + return Err(PayloadBidError::InvalidParentBlockRoot { + bid: bid.parent_block_root, + expected: parent_block_root, + }); + } + + // `prev_randao` must be the RANDAO mix from the production state. + let expected_prev_randao = *state.get_randao_mix(proposal_slot.epoch(E::slots_per_epoch()))?; + if bid.prev_randao != expected_prev_randao { + return Err(PayloadBidError::InvalidPrevRandao { slot: bid.slot }); + } + + // The gas limit must be compatible with the parent payload's, given the proposer's target. + if !is_gas_limit_target_compatible( + executed_ancestor_gas_limit, + bid.gas_limit, + proposer_preferences.message.target_gas_limit, + )? { + return Err(PayloadBidError::InvalidGasLimit); + } + + // Consensus-consistency checks shared with the gossip verifier. + verify_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` + // response filter from beacon-APIs #630; an empty list accepts any builder). + if !expected_builder_pubkeys.is_empty() { + let actual = state + .get_builder(bid.builder_index) + .map_err(|_| PayloadBidError::InvalidBuilder { + builder_index: bid.builder_index, + })? + .pubkey; + if !expected_builder_pubkeys.contains(&actual) { + return Err(PayloadBidError::UnexpectedBuilder { + builder_index: bid.builder_index, + }); + } + } + + // Verify the builder's signature. + execution_payload_bid_signature_set( + state, + |i| get_builder_pubkey_from_state(state, i), + signed_bid, + spec, + ) + .map_err(|_| PayloadBidError::BadSignature)? + .ok_or(PayloadBidError::BadSignature)? + .verify() + .then_some(()) + .ok_or(PayloadBidError::BadSignature)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bls::Signature; + use types::{Address, ExecutionPayloadBid, MinimalEthSpec, ProposerPreferences}; + + type E = MinimalEthSpec; + + /// Gas limit of the executed ancestor's payload; equal to the proposer's target in `preferences()`. + const EXECUTED_ANCESTOR_GAS_LIMIT: u64 = 30_000_000; + + fn state_and_spec() -> (BeaconState, ChainSpec) { + let spec = E::default_spec(); + let state = BeaconState::new(0, <_>::default(), &spec); + (state, spec) + } + + fn preferences() -> SignedProposerPreferences { + SignedProposerPreferences { + message: ProposerPreferences { + fee_recipient: Address::ZERO, + target_gas_limit: 30_000_000, + ..ProposerPreferences::default() + }, + signature: Signature::empty(), + } + } + + fn signed_bid( + slot: Slot, + parent_block_hash: ExecutionBlockHash, + parent_block_root: Hash256, + prev_randao: Hash256, + ) -> SignedExecutionPayloadBid { + SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + parent_block_hash, + parent_block_root, + prev_randao, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + } + } + + #[test] + fn rejects_wrong_slot() { + let (state, spec) = state_and_spec(); + let bid = signed_bid( + Slot::new(2), + ExecutionBlockHash::zero(), + Hash256::ZERO, + Hash256::ZERO, + ); + let result = verify_direct_bid( + &bid, + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); + } + + #[test] + fn rejects_wrong_executed_ancestor_hash() { + let (state, spec) = state_and_spec(); + let bid = signed_bid( + Slot::new(1), + ExecutionBlockHash::repeat_byte(9), + Hash256::ZERO, + Hash256::ZERO, + ); + let result = verify_direct_bid( + &bid, + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::InvalidParentBlockHash { .. }) + )); + } + + #[test] + fn rejects_wrong_parent_root() { + let (state, spec) = state_and_spec(); + let bid = signed_bid( + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::repeat_byte(9), + Hash256::ZERO, + ); + let result = verify_direct_bid( + &bid, + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::InvalidParentBlockRoot { .. }) + )); + } + + #[test] + fn rejects_wrong_prev_randao() { + let (state, spec) = state_and_spec(); + // The fresh state's RANDAO mix is zero, so a non-zero `prev_randao` is rejected. + let bid = signed_bid( + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + Hash256::repeat_byte(9), + ); + let result = verify_direct_bid( + &bid, + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::InvalidPrevRandao { .. }) + )); + } + + #[test] + fn rejects_gas_limit_incompatible_with_parent() { + let (state, spec) = state_and_spec(); + // Passes the slot, parent and RANDAO checks (a fresh state's mix is zero), then asks for + // double the parent's gas limit — far outside the per-block adjustment bound. + let mut bid = signed_bid( + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + Hash256::ZERO, + ); + bid.message.gas_limit = EXECUTED_ANCESTOR_GAS_LIMIT * 2; + let result = verify_direct_bid( + &bid, + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); + } + + #[test] + fn compatible_gas_limit_passes_the_gas_check() { + let (state, spec) = state_and_spec(); + // Same as above but holding the parent's gas limit, which is always compatible: the bid + // must get past the gas check and fail later, on the fresh state's missing builder. + let mut bid = signed_bid( + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + Hash256::ZERO, + ); + bid.message.gas_limit = EXECUTED_ANCESTOR_GAS_LIMIT; + let result = verify_direct_bid( + &bid, + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!( + matches!(result, Err(PayloadBidError::InvalidBuilder { .. })), + "expected to fail after the gas check, got {result:?}" + ); + } +} 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 34713d65cc3..86411f21fff 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 @@ -18,8 +18,8 @@ use state_processing::signature_sets::{ }; use tracing::debug; use types::{ - BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, SignedExecutionPayloadBid, Slot, - consts::gloas::PAYLOAD_BUILDER_VERSION, + BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, SignedExecutionPayloadBid, + SignedProposerPreferences, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; pub(crate) fn verify_bid_slot(bid_slot: Slot, current_slot: Slot) -> Result<(), PayloadBidError> { @@ -34,8 +34,6 @@ fn verify_bid_payment_and_blobs( bid: &ExecutionPayloadBid, spec: &ChainSpec, ) -> Result<(), PayloadBidError> { - let bid_slot = bid.slot; - // Execution payments are used by off protocol builders. In protocol bids // should always have this value set to zero. if bid.execution_payment != 0 { @@ -46,13 +44,20 @@ fn verify_bid_payment_and_blobs( if bid.block_hash == bid.parent_block_hash { return Err(PayloadBidError::BlockHashEqualsParentBlockHash { - slot: bid_slot, + slot: bid.slot, block_hash: bid.block_hash, }); } + verify_bid_blobs(bid, spec) +} + +fn verify_bid_blobs( + bid: &ExecutionPayloadBid, + spec: &ChainSpec, +) -> Result<(), PayloadBidError> { let max_blobs_per_block = - spec.max_blobs_per_block(bid_slot.epoch(E::slots_per_epoch())) as usize; + spec.max_blobs_per_block(bid.slot.epoch(E::slots_per_epoch())) as usize; if bid.blob_kzg_commitments.len() > max_blobs_per_block { return Err(PayloadBidError::InvalidBlobKzgCommitments { @@ -64,7 +69,36 @@ fn verify_bid_payment_and_blobs( Ok(()) } -fn verify_builder( +/// Verify that an execution payload bid is consistent with the current chain state +/// and proposer preferences. +/// +/// 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( + bid: &ExecutionPayloadBid, + current_slot: Slot, + proposer_preferences: &SignedProposerPreferences, + head_state: &BeaconState, + spec: &ChainSpec, +) -> Result<(), PayloadBidError> { + verify_bid_slot(bid.slot, current_slot)?; + + if bid.fee_recipient != proposer_preferences.message.fee_recipient { + return Err(PayloadBidError::InvalidFeeRecipient); + } + + verify_bid_blobs(bid, spec)?; + + verify_bid_state_conditions(bid, head_state, spec) +} + +/// Verify the bid conditions that depend on the beacon `state`: the builder is active, is a payload +/// builder, and can cover the bid. These are exactly the state-dependent checks +/// `process_execution_payload_bid` re-applies in `per_block_processing`, and the only bid conditions +/// that can go stale between gossip verification and block production (e.g. the builder's balance +/// dropping). Re-running them against the production state lets bid selection drop a gossip bid that +/// has since become invalid, rather than committing to it and failing the whole block. +pub(crate) fn verify_bid_state_conditions( bid: &ExecutionPayloadBid, head_state: &BeaconState, spec: &ChainSpec, @@ -343,7 +377,7 @@ impl GossipVerifiedPayloadBid { return Err(PayloadBidError::InvalidPrevRandao { slot: bid_slot }); } - verify_builder(&signed_bid.message, head_state, ctx.spec)?; + verify_bid_state_conditions(&signed_bid.message, head_state, ctx.spec)?; execution_payload_bid_signature_set( head_state, @@ -360,10 +394,7 @@ impl GossipVerifiedPayloadBid { let gossip_verified_bid = GossipVerifiedPayloadBid { signed_bid }; ctx.gossip_verified_payload_bid_cache - .insert_seen_builder_bid(&gossip_verified_bid); - - ctx.gossip_verified_payload_bid_cache - .insert_highest_bid(gossip_verified_bid.clone()); + .observe_bid(gossip_verified_bid.clone()); Ok(gossip_verified_bid) } diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs index eb4c0e09a06..7390bb0fee0 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -12,6 +12,7 @@ use types::{BeaconStateError, ExecutionBlockHash, Hash256, Slot}; +pub mod direct_verified_bid; pub mod gossip_verified_bid; pub mod payload_bid_cache; @@ -28,12 +29,29 @@ pub enum PayloadBidError { }, /// The bid does not build on the head block or on the head block's parent. BidNotCompatibleWithHead { parent_block_root: Hash256 }, + /// The bid's parent block hash does not match the parent selected for the block being produced + /// (an equality check against the producer's choice). Produced only by direct + /// (block-production) verification, never by gossip, which does parent fork-choice + /// *membership* checks instead. + InvalidParentBlockHash { + bid: ExecutionBlockHash, + expected: ExecutionBlockHash, + }, + /// The bid's parent block root does not match the parent selected for the block being produced + /// (an equality check against the producer's choice). Produced only by direct + /// (block-production) verification, never by gossip, which does parent fork-choice + /// *membership* checks instead. + InvalidParentBlockRoot { bid: Hash256, expected: Hash256 }, /// The signature is invalid. BadSignature, /// A bid for this builder at this slot has already been seen. BuilderAlreadySeen { builder_index: u64, slot: Slot }, /// Builder is not valid/active for the given epoch InvalidBuilder { builder_index: u64 }, + /// The bid was signed by a builder not in the requesting entry's `builder_pubkeys` (the + /// response filter from beacon-APIs #630). Produced only by direct (block-production) + /// verification, never by gossip, which has no requesting entry. + UnexpectedBuilder { builder_index: u64 }, /// The builder's version is not `PAYLOAD_BUILDER_VERSION`. InvalidBuilderVersion { builder_index: u64, version: u8 }, /// The bid value is lower than the currently cached bid. diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs b/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs index 060a5a965ee..27f09d9be69 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs @@ -1,6 +1,8 @@ use crate::payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid; +use educe::Educe; use parking_lot::RwLock; use std::{ + collections::hash_map, collections::{BTreeMap, HashMap, HashSet}, sync::Arc, }; @@ -25,84 +27,115 @@ impl BidParent { } } +/// The highest-value bid seen per `(slot, BidParent)`. +/// +/// Keyed first by `Slot` (in a `BTreeMap` so that stale slots can be pruned cheaply via +/// `split_off`), then by the [`BidParent`] the bid builds on. type HighestBidMap = BTreeMap>>; +/// The mutable state guarded by the cache's lock. +#[derive(Educe)] +#[educe(Default(bound = "E: EthSpec"))] +pub struct GossipBidCacheInner { + /// The current best bid for each `(slot, BidParent)`. + highest_bid: HighestBidMap, + /// The `(BidParent, BuilderIndex)` pairs for which we have already accepted a gossip-verified + /// bid, per slot. + /// + /// Used to enforce one bid per builder per parent per slot: a builder may bid once for each + /// parent view compatible with the head. + seen_builder_bids: BTreeMap>, +} + +/// A cache of gossip-verified payload bids. +/// +/// Tracks, per slot, the highest-value bid observed for each parent a bid may build on and the +/// set of `(parent, builder)` pairs that have already bid, so that duplicate and lower-value +/// gossip bids can be rejected. Stale entries are removed via [`prune`](Self::prune) as the chain +/// advances. +#[derive(Educe)] +#[educe(Default(bound = "E: EthSpec"))] pub struct GossipVerifiedPayloadBidCache { - highest_bid: RwLock>, - seen_builder_bids: RwLock>>, + inner: RwLock>, } -impl Default for GossipVerifiedPayloadBidCache { - fn default() -> Self { +impl GossipVerifiedPayloadBidCache { + /// Create a new, empty cache. + pub fn new() -> Self { Self { - highest_bid: RwLock::new(BTreeMap::new()), - seen_builder_bids: RwLock::new(BTreeMap::new()), + inner: RwLock::new(GossipBidCacheInner::default()), } } -} -impl GossipVerifiedPayloadBidCache { - /// Get the cached bid for `(slot, bid_parent)`. + /// Get the highest-value cached bid for `(slot, bid_parent)`, if one exists. pub fn get_highest_bid( &self, slot: Slot, bid_parent: BidParent, ) -> Option>> { - self.highest_bid + self.inner .read() + .highest_bid .get(&slot) .and_then(|map| map.get(&bid_parent).map(|b| b.signed_bid.clone())) } - /// Insert a bid for `(slot, bid_parent)` only if its value is higher than the - /// currently cached bid for that key. - pub fn insert_highest_bid(&self, bid: GossipVerifiedPayloadBid) { + /// Record a gossip-verified `bid` in the cache. + /// + /// This always marks the bid's `(parent, builder)` pair as seen for the bid's slot (see + /// [`seen_builder_bid_for_parent`](Self::seen_builder_bid_for_parent)). Additionally, if the + /// bid has a strictly higher value than the currently cached bid for its `(slot, BidParent)` + /// (or no bid is cached yet), it replaces the cached bid. + /// + /// Returns `true` if the bid became the new highest bid for its parent, or `false` if an + /// existing cached bid had an equal or greater value and was therefore retained. + pub fn observe_bid(&self, bid: GossipVerifiedPayloadBid) -> bool { + let slot = bid.signed_bid.message.slot; let key = BidParent::from_bid(&bid.signed_bid.message); - let mut highest_bid = self.highest_bid.write(); - let slot_map = highest_bid.entry(bid.signed_bid.message.slot).or_default(); + let mut inner = self.inner.write(); + inner + .seen_builder_bids + .entry(slot) + .or_default() + .insert((key, bid.signed_bid.message.builder_index)); - if let Some(existing) = slot_map.get(&key) - && existing.signed_bid.message.value >= bid.signed_bid.message.value - { - return; + match inner.highest_bid.entry(slot).or_default().entry(key) { + hash_map::Entry::Vacant(entry) => { + entry.insert(bid); + true + } + hash_map::Entry::Occupied(mut entry) => { + if entry.get().signed_bid.message.value >= bid.signed_bid.message.value { + return false; + } + entry.insert(bid); + true + } } - slot_map.insert(key, bid); } - /// A gossip verified bid for `BuilderIndex` already exists for `(slot, bid_parent)`. + /// Returns `true` if a gossip-verified bid from `builder_index` has already been seen for + /// `(slot, bid_parent)`. pub fn seen_builder_bid_for_parent( &self, slot: &Slot, bid_parent: BidParent, builder_index: BuilderIndex, ) -> bool { - self.seen_builder_bids + self.inner .read() + .seen_builder_bids .get(slot) .is_some_and(|seen_builders| seen_builders.contains(&(bid_parent, builder_index))) } - /// Insert a builder into the seen cache. - pub fn insert_seen_builder_bid(&self, bid: &GossipVerifiedPayloadBid) { - let mut seen_builder_bids = self.seen_builder_bids.write(); - seen_builder_bids - .entry(bid.signed_bid.message.slot) - .or_default() - .insert(( - BidParent::from_bid(&bid.signed_bid.message), - bid.signed_bid.message.builder_index, - )); - } - - /// Prune anything before `current_slot` + /// Removes all cached bids and seen-builder records for slots older than `current_slot`. + /// + /// Entries for `current_slot` and later are retained. pub fn prune(&self, current_slot: Slot) { - self.highest_bid - .write() - .retain(|&slot, _| slot >= current_slot); - - self.seen_builder_bids - .write() - .retain(|&slot, _| slot >= current_slot); + let mut inner = self.inner.write(); + inner.highest_bid = inner.highest_bid.split_off(¤t_slot); + inner.seen_builder_bids = inner.seen_builder_bids.split_off(¤t_slot); } } @@ -163,7 +196,7 @@ mod tests { parent_a.parent_block_root, 100, ); - cache.insert_seen_builder_bid(&verified); + cache.observe_bid(verified); // Seen only for the exact (slot, parent tuple, builder) combination. assert!(cache.seen_builder_bid_for_parent(&slot, parent_a, 0)); @@ -189,8 +222,8 @@ mod tests { parent_block_root: root_b, }; - cache.insert_highest_bid(make_gossip_verified(slot, 0, hash_a, root_a, 100)); - cache.insert_highest_bid(make_gossip_verified(slot, 1, hash_b, root_b, 50)); + assert!(cache.observe_bid(make_gossip_verified(slot, 0, hash_a, root_a, 100))); + assert!(cache.observe_bid(make_gossip_verified(slot, 1, hash_b, root_b, 50))); // Each parent tuple keeps its own highest bid. assert_eq!( @@ -204,7 +237,7 @@ mod tests { // A lower bid does not replace the cached bid for its tuple, and does // not touch the other tuple. - cache.insert_highest_bid(make_gossip_verified(slot, 2, hash_a, root_a, 60)); + assert!(!cache.observe_bid(make_gossip_verified(slot, 2, hash_a, root_a, 60))); assert_eq!( cache.get_highest_bid(slot, parent_a).unwrap().message.value, 100 @@ -215,7 +248,7 @@ mod tests { ); // A higher bid replaces the cached bid for its tuple. - cache.insert_highest_bid(make_gossip_verified(slot, 3, hash_b, root_b, 70)); + assert!(cache.observe_bid(make_gossip_verified(slot, 3, hash_b, root_b, 70))); let highest_b = cache.get_highest_bid(slot, parent_b).unwrap(); assert_eq!(highest_b.message.value, 70); assert_eq!(highest_b.message.builder_index, 3); @@ -233,8 +266,7 @@ mod tests { for slot in [1, 2, 3, 7, 8, 9, 10] { let verified = make_gossip_verified(Slot::new(slot), slot, hash, root, slot * 100); - cache.insert_seen_builder_bid(&verified); - cache.insert_highest_bid(verified); + cache.observe_bid(verified); } cache.prune(Slot::new(8)); diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index 24ee660701f..b0ba057ed4b 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -29,7 +29,9 @@ use crate::{ observed_execution_payloads::ObservedExecutionPayloads, payload_bid_verification::{ PayloadBidError, - gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, + gossip_verified_bid::{ + GossipVerificationContext, GossipVerifiedPayloadBid, verify_bid_state_conditions, + }, payload_bid_cache::{BidParent, GossipVerifiedPayloadBidCache}, }, proposer_preferences_verification::{ @@ -391,7 +393,7 @@ fn builder_already_seen_for_slot() { let verified = GossipVerifiedPayloadBid { signed_bid: bid.clone(), }; - ctx.bid_cache.insert_seen_builder_bid(&verified); + ctx.bid_cache.observe_bid(verified); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!( @@ -457,7 +459,7 @@ fn bid_value_below_cached() { let high_bid = GossipVerifiedPayloadBid { signed_bid: ctx.make_signed_bid(slot, 99, Address::ZERO, 30_000_000, 500, Hash256::ZERO), }; - ctx.bid_cache.insert_highest_bid(high_bid); + ctx.bid_cache.observe_bid(high_bid); let low_bid = ctx.make_signed_bid(slot, 1, Address::ZERO, 30_000_000, 100, Hash256::ZERO); let result = GossipVerifiedPayloadBid::new(low_bid, &gossip); @@ -709,6 +711,44 @@ fn builder_cant_cover_bid() { )); } +// Regression guard for stale gossip bids: `verify_bid_state_conditions` is what bid selection +// re-runs against the production state so a gossip bid whose builder can no longer cover it is +// dropped, rather than winning selection and failing the whole block at `per_block_processing`. A +// coverable bid passes; the same bid at an uncoverable value is rejected. +#[test] +fn bid_state_conditions_reject_uncoverable_bid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let slot = Slot::new(1); + let head = ctx.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + + let coverable = ctx.make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + assert!(verify_bid_state_conditions(&coverable.message, state, &ctx.spec).is_ok()); + + let uncoverable = ctx.make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + u64::MAX, + ctx.genesis_block_root, + ); + assert!(matches!( + verify_bid_state_conditions(&uncoverable.message, state, &ctx.spec), + Err(PayloadBidError::BuilderCantCoverBid { .. }) + )); +} + #[test] fn parent_block_root_unknown() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { @@ -972,7 +1012,7 @@ fn bid_equal_to_cached_value_rejected() { ctx.genesis_block_root, ), }; - ctx.bid_cache.insert_highest_bid(high_bid); + ctx.bid_cache.observe_bid(high_bid); // Submit a bid with exactly the same value — should be rejected. let equal_bid = ctx.make_signed_bid( diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 320b50720f8..65cf9f1df09 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1299,6 +1299,7 @@ where _consensus_block_value, _execution_payload_value, _payload_contents, + _builder_url, ) = self .chain .produce_block_on_state_gloas( @@ -1310,7 +1311,7 @@ where randao_reveal, graffiti_settings, ProduceBlockVerification::VerifyRandao, - None, + eth2::types::BuilderConfig::empty(), ) .await .unwrap(); diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 52c0744ce1f..18137c12af7 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -398,6 +398,7 @@ async fn prepare_payload_generic( _consensus_block_value, _execution_payload_value, payload_contents, + _builder_url, ) = harness .chain .produce_block_with_verification_gloas( @@ -405,7 +406,7 @@ async fn prepare_payload_generic( prepare_slot, graffiti_settings, ProduceBlockVerification::VerifyRandao, - None, + eth2::types::BuilderConfig::empty(), ) .await .unwrap(); @@ -726,7 +727,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { Some(GraffitiPolicy::PreserveUserGraffiti), ); - let (block, _post_state, _value, _payload_value, _payload_contents) = harness + let (block, _post_state, _value, _payload_value, _payload_contents, _builder_url) = harness .chain .produce_block_on_state_gloas( state, @@ -737,7 +738,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { randao_reveal, graffiti_settings, ProduceBlockVerification::VerifyRandao, - None, + eth2::types::BuilderConfig::empty(), ) .await .unwrap(); diff --git a/beacon_node/builder_client/src/builder_http_client.rs b/beacon_node/builder_client/src/builder_http_client.rs index 49e84b4adf0..a857d13226c 100644 --- a/beacon_node/builder_client/src/builder_http_client.rs +++ b/beacon_node/builder_client/src/builder_http_client.rs @@ -129,7 +129,7 @@ impl BuilderHttpClient { &self, builder_url: &SensitiveUrl, slot: Slot, - parent_hash: ExecutionBlockHash, + executed_ancestor_hash: ExecutionBlockHash, parent_root: Hash256, proposer_pubkey: &PublicKeyBytes, signed_request_auth: &SignedRequestAuth, @@ -144,7 +144,8 @@ impl BuilderHttpClient { .push("builder") .push("execution_payload_bid") .push(slot.to_string().as_str()) - .push(format!("{parent_hash:?}").as_str()) + // The spec names this path parameter `parent_hash`. + .push(format!("{executed_ancestor_hash:?}").as_str()) .push(format!("{parent_root:?}").as_str()) .push(proposer_pubkey.as_hex_string().as_str()); diff --git a/beacon_node/builder_client/src/builders.rs b/beacon_node/builder_client/src/builders.rs index 1342d0c3d51..28902118f16 100644 --- a/beacon_node/builder_client/src/builders.rs +++ b/beacon_node/builder_client/src/builders.rs @@ -40,7 +40,10 @@ pub struct DirectBid { #[derive(Clone)] pub struct BidRequestContext { pub slot: Slot, - pub parent_hash: ExecutionBlockHash, + /// The latest executed ancestor's payload hash (the parent's payload when building on FULL, + /// otherwise the payload the parent built on). Sent as the builder API's `parent_hash` path + /// parameter, and the hash a returned bid must name as its `parent_block_hash`. + pub executed_ancestor_hash: ExecutionBlockHash, pub parent_root: Hash256, pub proposer_pubkey: PublicKeyBytes, /// The active consensus version at `slot`, sent as the required `Eth-Consensus-Version` @@ -148,7 +151,7 @@ impl Builders { .get_execution_payload_bid::( url, ctx.slot, - ctx.parent_hash, + ctx.executed_ancestor_hash, ctx.parent_root, &ctx.proposer_pubkey, &entry.auth, @@ -305,7 +308,7 @@ mod tests { fn context() -> BidRequestContext { BidRequestContext { slot: Slot::new(1), - parent_hash: ExecutionBlockHash::zero(), + executed_ancestor_hash: ExecutionBlockHash::zero(), parent_root: Hash256::ZERO, proposer_pubkey: PublicKeyBytes::empty(), fork_name: ForkName::Gloas, diff --git a/beacon_node/client/Cargo.toml b/beacon_node/client/Cargo.toml index 66432fc7f59..fddebe26f49 100644 --- a/beacon_node/client/Cargo.toml +++ b/beacon_node/client/Cargo.toml @@ -7,6 +7,7 @@ edition = { workspace = true } [dependencies] beacon_chain = { workspace = true } beacon_processor = { workspace = true } +builder_client = { workspace = true } directory = { workspace = true } dirs = { workspace = true } environment = { workspace = true } diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index e0a93fbafe7..d47df5cf515 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -19,6 +19,7 @@ use beacon_chain::{ use beacon_chain::{Kzg, LightClientProducerEvent}; use beacon_processor::{BeaconProcessor, BeaconProcessorChannels}; use beacon_processor::{BeaconProcessorConfig, BeaconProcessorQueueLengths}; +use builder_client::{BuilderHttpClient, Builders}; use environment::RuntimeContext; use eth2::{ BeaconNodeHttpClient, Error as ApiError, Timeouts, @@ -198,6 +199,28 @@ where }) .transpose()?; + // Construct the Gloas builder handle (Builder API client) when the Gloas fork is scheduled. + // The client is stateless w.r.t. the target builder — each request carries its own URL — but + // still honors the same `--builder-user-agent` / `--builder-disable-ssz` flags as the + // pre-Gloas builder client. + let builders = if spec.gloas_fork_epoch.is_some() { + let (user_agent, disable_ssz) = config + .execution_layer + .as_ref() + .map(|el| { + ( + el.builder_user_agent.clone(), + el.disable_builder_ssz_requests, + ) + }) + .unwrap_or((None, false)); + let client = BuilderHttpClient::new(user_agent, disable_ssz) + .map_err(|e| format!("unable to start builder client: {:?}", e))?; + Some(Arc::new(Builders::new(Arc::new(client)))) + } else { + None + }; + let kzg_err_msg = |e| format!("Failed to load trusted setup: {:?}", e); let kzg = if spec.is_peer_das_scheduled() { Kzg::new_from_trusted_setup(&config.trusted_setup).map_err(kzg_err_msg)? @@ -222,6 +245,7 @@ where .event_handler(event_handler) .execution_layer(execution_layer) .proof_engine(proof_engine) + .builders(builders) .node_custody_type(config.chain.node_custody_type) .ordered_custody_column_indices(ordered_custody_column_indices) .validator_monitor_config(config.validator_monitor.clone()) diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 8894bf9ce5a..63420fbe2d0 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -73,27 +73,35 @@ pub async fn produce_block_v4( })?; let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?; - let builder_boost_factor = if query.builder_boost_factor == Some(DEFAULT_BOOST_FACTOR) { - None - } else { - query.builder_boost_factor + // 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() }; let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); - let (block, _block_state, consensus_block_value, execution_payload_value, payload_contents) = - chain - .produce_block_with_verification_gloas( - randao_reveal, - slot, - graffiti_settings, - randao_verification, - builder_boost_factor, - ) - .await - .map_err(|e| { - warp_utils::reject::custom_bad_request(format!("failed to fetch a block: {:?}", e)) - })?; + let ( + block, + _block_state, + consensus_block_value, + execution_payload_value, + payload_contents, + _builder_url, + ) = chain + .produce_block_with_verification_gloas( + randao_reveal, + slot, + graffiti_settings, + randao_verification, + builder_config, + ) + .await + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!("failed to fetch a block: {:?}", e)) + })?; let payload_contents = include_payload.then_some(payload_contents).flatten(); diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 702b46b74c2..c78f6ae913e 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4268,13 +4268,13 @@ impl NetworkBeaconProcessor { } Err( PayloadBidError::NoProposerPreferences { .. } + | PayloadBidError::InvalidFeeRecipient | PayloadBidError::BuilderAlreadySeen { .. } | PayloadBidError::BidValueBelowCached { .. } | PayloadBidError::ParentBlockRootUnknown { .. } | PayloadBidError::ParentExecutionPayloadUnknown { .. } | PayloadBidError::BidNotCompatibleWithHead { .. } | PayloadBidError::BuilderCantCoverBid { .. } - | PayloadBidError::InvalidFeeRecipient | PayloadBidError::InvalidGasLimit | PayloadBidError::BeaconStateError(_) | PayloadBidError::InternalError(_) @@ -4283,6 +4283,17 @@ impl NetworkBeaconProcessor { ) => { self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); } + // These variants are direct-only (see their docs) and are matched here only because + // `PayloadBidError` is shared. Reaching this arm is a wiring bug: log it, and ignore + // rather than penalize the peer. + Err( + PayloadBidError::InvalidParentBlockHash { .. } + | PayloadBidError::InvalidParentBlockRoot { .. } + | PayloadBidError::UnexpectedBuilder { .. }, + ) => { + error!("Direct-bid validation error from gossip payload bid verification"); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } } } diff --git a/common/builder_types/src/builder_config.rs b/common/builder_types/src/builder_config.rs index b0b0933571a..06652b69474 100644 --- a/common/builder_types/src/builder_config.rs +++ b/common/builder_types/src/builder_config.rs @@ -25,7 +25,9 @@ use tree_hash_derive::TreeHash; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] pub struct BuilderConfig { - /// Minimum total payment (Gwei) accepted from a bid that matches no entry (a p2p bid). + /// Minimum total payment (Gwei) for a bid that matches no entry (a p2p bid) to rank as + /// acceptable — a ranking floor, not a filter: a below-floor bid is ranked behind every + /// floor-clearing candidate and wins only when nothing else is viable. #[serde(with = "serde_utils::quoted_u64")] pub min_bid: u64, /// Percentage multiplier applied to a bid that matches no entry (a p2p bid). diff --git a/common/builder_types/src/builder_entry.rs b/common/builder_types/src/builder_entry.rs index 275da974cf1..75d8f64a7cc 100644 --- a/common/builder_types/src/builder_entry.rs +++ b/common/builder_types/src/builder_entry.rs @@ -48,7 +48,9 @@ pub struct BuilderEntry { /// Maximum trusted execution-layer payment (Gwei) accepted from this builder. #[serde(with = "serde_utils::quoted_u64")] pub max_execution_payment: u64, - /// Minimum total payment (Gwei) for a bid from this builder to be accepted. + /// Minimum total payment (Gwei) for this builder's bid to rank as acceptable — a ranking + /// floor, not a filter: a below-floor bid is ranked behind every floor-clearing candidate and + /// wins only when nothing else is viable. #[serde(with = "serde_utils::quoted_u64")] pub min_bid: u64, /// Percentage multiplier applied to this builder's bid when comparing against the local payload.