From eb375950cecd0f0d251f947667ca0c00660528a0 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 | 552 ++++++++++++++++ .../src/block_production/gloas.rs | 599 +++++++++--------- .../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 | 259 ++++++++ .../gossip_verified_bid.rs | 68 +- .../src/payload_bid_verification/mod.rs | 12 +- .../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 +- 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 | 17 +- 19 files changed, 1376 insertions(+), 406 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 76426650d4a..262a2cf5c32 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 cca74d918c4..0ae694dd7fe 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -90,6 +90,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, @@ -457,6 +458,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..69c73cdb3ab --- /dev/null +++ b/beacon_node/beacon_chain/src/block_production/bid_selection.rs @@ -0,0 +1,552 @@ +//! 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, + /// which is the proposer's own block and is never gated. + 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. + 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(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. Local is never gated + /// (`min_bid` is `0`), so it always qualifies. + 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 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 fe12c525aef..4a75168883b 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,91 @@ 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(); + let parent_block_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, + parent_hash: parent_block_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, + parent_block_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) // @@ -589,27 +657,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, @@ -788,30 +865,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, + parent_block_hash: ExecutionBlockHash, ) -> Result< ( SignedExecutionPayloadBid, - BeaconState, LocalBuildResult, ), BlockProductionError, @@ -847,27 +926,9 @@ 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, parent_envelope, @@ -921,7 +982,6 @@ impl BeaconChain { message: bid, signature: Signature::infinity().map_err(BlockProductionError::BlsError)?, }, - state, LocalBuildResult { payload_data, payload_value, @@ -930,109 +990,175 @@ 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.parent_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 parent_hash = ctx.parent_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(); + + // 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 || { + verify_direct_bid( + &signed_bid, + slot, + parent_hash, + parent_root, + &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() } } @@ -1229,7 +1355,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; @@ -1366,123 +1492,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 74e39b09654..df09958982b 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 6d3dd7b9447..ef5883aa5db 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}; @@ -93,6 +94,7 @@ pub struct BeaconChainBuilder { op_pool: Option>, execution_layer: Option>, proof_engine: Option>, + builders: Option>, event_handler: Option>, slot_clock: Option, shutdown_sender: Option>, @@ -136,6 +138,7 @@ where op_pool: None, execution_layer: None, proof_engine: None, + builders: None, event_handler: None, slot_clock: None, shutdown_sender: None, @@ -635,6 +638,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; @@ -1026,6 +1035,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..cc2a932ffed --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs @@ -0,0 +1,259 @@ +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. +#[allow(clippy::too_many_arguments)] +pub fn verify_direct_bid( + signed_bid: &SignedExecutionPayloadBid, + proposal_slot: Slot, + parent_block_hash: ExecutionBlockHash, + parent_block_root: Hash256, + 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 same parent the producer selected (FULL or EMPTY). + if bid.parent_block_hash != parent_block_hash { + return Err(PayloadBidError::InvalidParentBlockHash { + bid: bid.parent_block_hash, + expected: parent_block_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's, given the proposer's target. + if let Ok(parent_bid) = state.latest_execution_payload_bid() + && !is_gas_limit_target_compatible( + parent_bid.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; + + 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, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); + } + + #[test] + fn rejects_wrong_parent_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, + &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, + &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, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::InvalidPrevRandao { .. }) + )); + } +} 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 00168a58c9f..8ab77d20e40 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 @@ -23,6 +23,9 @@ use types::{ /// 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, @@ -36,14 +39,6 @@ pub(crate) fn verify_bid_consistency( return Err(PayloadBidError::InvalidBidSlot { 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 { - return Err(PayloadBidError::ExecutionPaymentNonZero { - execution_payment: bid.execution_payment, - }); - } - if bid.fee_recipient != proposer_preferences.message.fee_recipient { return Err(PayloadBidError::InvalidFeeRecipient); } @@ -58,9 +53,23 @@ pub(crate) fn verify_bid_consistency( }); } + 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, + state: &BeaconState, + spec: &ChainSpec, +) -> Result<(), PayloadBidError> { let builder_index = bid.builder_index; - let is_active_builder = head_state + let is_active_builder = state .is_active_builder(builder_index, spec) .map_err(|_| PayloadBidError::InvalidBuilder { builder_index })?; @@ -68,7 +77,7 @@ pub(crate) fn verify_bid_consistency( return Err(PayloadBidError::InvalidBuilder { builder_index }); } - let builder_version = head_state.get_builder(builder_index)?.version; + let builder_version = state.get_builder(builder_index)?.version; if builder_version != PAYLOAD_BUILDER_VERSION { return Err(PayloadBidError::InvalidBuilderVersion { builder_index, @@ -76,7 +85,7 @@ pub(crate) fn verify_bid_consistency( }); } - if !head_state.can_builder_cover_bid(builder_index, bid.value, spec)? { + if !state.can_builder_cover_bid(builder_index, bid.value, spec)? { return Err(PayloadBidError::BuilderCantCoverBid { builder_index, builder_bid: bid.value, @@ -187,6 +196,14 @@ impl GossipVerifiedPayloadBid { let bid_parent_block_root = signed_bid.message.parent_block_root; let bid_value = signed_bid.message.value; + // Execution payments are used by off-protocol builders. In-protocol (gossip) bids should + // always have this value set to zero. + if signed_bid.message.execution_payment != 0 { + return Err(PayloadBidError::ExecutionPaymentNonZero { + execution_payment: signed_bid.message.execution_payment, + }); + } + if ctx .gossip_verified_payload_bid_cache .seen_builder_bid_for_parent(&bid_slot, bid_parent, signed_bid.message.builder_index) @@ -287,9 +304,12 @@ impl GossipVerifiedPayloadBid { } // [REJECT] `bid.prev_randao` is the correct RANDAO mix -- i.e. validate that - // `bid.prev_randao == get_randao_mix(parent_state, get_current_epoch(parent_state))` + // `bid.prev_randao == get_randao_mix(parent_state, get_current_epoch(parent_state))`. + // Query the mix at the state's own current epoch (`head_state` stands in for the parent + // post-state); using the wall-clock epoch instead would be out of bounds during the first + // slot(s) of an epoch, before a block advances the head into it. if signed_bid.message.prev_randao - != *head_state.get_randao_mix(current_slot.epoch(E::slots_per_epoch()))? + != *head_state.get_randao_mix(head_state.current_epoch())? { return Err(PayloadBidError::InvalidPrevRandao { slot: bid_slot }); } @@ -346,10 +366,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) } @@ -522,23 +539,6 @@ mod tests { )); } - #[test] - fn test_execution_payment_nonzero() { - let (state, spec) = state_and_spec(); - let current_slot = Slot::new(10); - let mut bid = make_bid(current_slot, Address::ZERO, 30_000_000); - bid.execution_payment = 42; - let prefs = make_preferences(Address::ZERO, 30_000_000); - - let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); - assert!(matches!( - result, - Err(PayloadBidError::ExecutionPaymentNonZero { - execution_payment: 42 - }) - )); - } - #[test] fn test_fee_recipient_mismatch() { let (state, spec) = state_and_spec(); 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 afa9805fad1..e954b80a976 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -10,8 +10,9 @@ //! GossipVerifiedPayloadBid -------> Insert into GossipVerifiedPayloadBidCache //! ``` -use types::{BeaconStateError, Hash256, Slot}; +use types::{BeaconStateError, ExecutionBlockHash, Hash256, Slot}; +pub mod direct_verified_bid; pub mod gossip_verified_bid; pub mod payload_bid_cache; @@ -24,12 +25,21 @@ pub enum PayloadBidError { ParentBlockRootUnknown { parent_block_root: Hash256 }, /// 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. + InvalidParentBlockHash { + bid: ExecutionBlockHash, + expected: ExecutionBlockHash, + }, + /// The bid's parent block root does not match the parent selected for the block being produced. + 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`. + 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 d76138bf76c..ebb1728b1ec 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -28,7 +28,9 @@ use crate::{ chain_config::FastConfirmationMode, 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::{ @@ -365,7 +367,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!( @@ -430,7 +432,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); @@ -616,6 +618,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()) { @@ -877,7 +917,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 313437d6bf4..e743526e125 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 96e94cfc17c..a7c4dd7e498 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/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 d804ccefa9d..97bc6a0e08e 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4239,12 +4239,12 @@ impl NetworkBeaconProcessor { } Err( PayloadBidError::NoProposerPreferences { .. } + | PayloadBidError::InvalidFeeRecipient | PayloadBidError::BuilderAlreadySeen { .. } | PayloadBidError::BidValueBelowCached { .. } | PayloadBidError::ParentBlockRootUnknown { .. } | PayloadBidError::BidNotCompatibleWithHead { .. } | PayloadBidError::BuilderCantCoverBid { .. } - | PayloadBidError::InvalidFeeRecipient | PayloadBidError::InvalidGasLimit | PayloadBidError::BeaconStateError(_) | PayloadBidError::InternalError(_) @@ -4253,6 +4253,21 @@ impl NetworkBeaconProcessor { ) => { self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); } + // `InvalidParentBlockHash` / `InvalidParentBlockRoot` are equality checks against the + // producer's selected parent, and `UnexpectedBuilder` is the `BuilderEntry` + // `builder_pubkeys` response filter — all produced only by direct (block-production) + // verification, never by gossip, which instead does parent fork-choice *membership* + // checks (the `ParentBlockRoot*` variants above) and has no requesting entry. They're + // handled here only because `PayloadBidError` is shared; reaching this arm indicates a + // wiring bug, so 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); + } } }