From eb375950cecd0f0d251f947667ca0c00660528a0 Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:48:14 -0500 Subject: [PATCH 01/11] 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); + } } } From 99bddcfade6603aea7ea5513fa385addb3f919c2 Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:53:50 -0500 Subject: [PATCH 02/11] Convert produceBlockV4 to POST and round-trip Eth-Builder-Url (Gloas builder API 4/5) Fourth PR of the Gloas builder API stack (beacon-APIs #630): - convert `/eth/v4/validator/blocks/{slot}` to POST with an optional `BuilderConfig` body (min_bid, builder_boost_factor, direct builders) - add `POST /eth/v1/validator/builder_preferences` for forwarding signed builder preferences - set `Eth-Builder-Url` on produceBlockV4 responses when a direct-builder bid wins, accept it on `POST /eth/v2/beacon/blocks`, and forward the signed block to that builder The validator client still uses the legacy GET methods at this point; it migrates in the final PR of this stack. Change-Id: I0ad30b8f36ad9b588ea1a0398220f92c9597bb95 --- beacon_node/http_api/src/lib.rs | 40 +++- beacon_node/http_api/src/produce_block.rs | 49 ++-- beacon_node/http_api/src/publish_blocks.rs | 70 ++++++ beacon_node/http_api/src/validator/mod.rs | 217 +++++++++++++++++- beacon_node/http_api/src/version.rs | 13 +- .../tests/broadcast_validation_tests.rs | 4 +- .../http_api/tests/gloas_reorg_tests.rs | 12 +- .../http_api/tests/interactive_tests.rs | 10 +- beacon_node/http_api/tests/tests.rs | 203 +++++++++++++++- 9 files changed, 577 insertions(+), 41 deletions(-) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 4f41c2a1a1c..e73cf06c00a 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -68,7 +68,9 @@ use eth2::types::{ self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceExtraData, ForkChoiceNode, LightClientUpdatesQuery, PublishBlockRequest, ValidatorId, }; -use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; +use eth2::{ + BUILDER_URL_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER, +}; use health_metrics::observe::Observe; use lighthouse_network::Enr; use lighthouse_network::NetworkGlobals; @@ -106,7 +108,7 @@ use types::{ }; use validator::execution_payload_envelopes::get_validator_execution_payload_envelopes; use version::{ - ResponseIncludesVersion, V1, V2, add_consensus_version_header, add_ssz_content_type_header, + ResponseIncludesVersion, V1, V2, V4, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, inconsistent_fork_rejection, unsupported_version_rejection, }; @@ -384,6 +386,7 @@ pub async fn serve( let eth_v1 = single_version(any_version.clone(), V1); let eth_v2 = single_version(any_version.clone(), V2); + let eth_v4 = single_version(any_version.clone(), V4); // Create a `warp` filter that provides access to the network globals. let inner_network_globals = ctx.network_globals.clone(); @@ -819,6 +822,9 @@ pub async fn serve( */ let consensus_version_header_filter = warp::header::header::(CONSENSUS_VERSION_HEADER).boxed(); + // The winning builder's URL echoed by the VC on a Gloas block publish (beacon-APIs #630), so the + // node forwards the block to that builder. Optional: absent for self-build / p2p-won blocks. + let builder_url_header_filter = warp::header::optional::(BUILDER_URL_HEADER).boxed(); let optional_consensus_version_header_filter = warp::header::optional::(CONSENSUS_VERSION_HEADER).boxed(); @@ -855,6 +861,8 @@ pub async fn serve( &network_tx, BroadcastValidation::default(), duplicate_block_status_code, + // Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas). + None, ) .await }) @@ -892,6 +900,8 @@ pub async fn serve( &network_tx, BroadcastValidation::default(), duplicate_block_status_code, + // Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas). + None, ) .await }) @@ -909,13 +919,15 @@ pub async fn serve( .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) + .and(builder_url_header_filter.clone()) .then( move |validation_level: api_types::BroadcastValidationQuery, value: serde_json::Value, consensus_version: ForkName, task_spawner: TaskSpawner, chain: Arc>, - network_tx: UnboundedSender>| { + network_tx: UnboundedSender>, + builder_url: Option| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { let request = PublishBlockRequest::::context_deserialize( &value, @@ -932,6 +944,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -949,13 +962,15 @@ pub async fn serve( .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) + .and(builder_url_header_filter.clone()) .then( move |validation_level: api_types::BroadcastValidationQuery, block_bytes: Bytes, consensus_version: ForkName, task_spawner: TaskSpawner, chain: Arc>, - network_tx: UnboundedSender>| { + network_tx: UnboundedSender>, + builder_url: Option| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { let block_contents = PublishBlockRequest::::from_ssz_bytes( &block_bytes, @@ -971,6 +986,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -2570,6 +2586,14 @@ pub async fn serve( task_spawner_filter.clone(), ); + // POST v4/validator/blocks/{slot} + let post_validator_blocks_v4 = post_validator_blocks_v4( + eth_v4.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // GET validator/blinded_blocks/{slot} let get_validator_blinded_blocks = get_validator_blinded_blocks( eth_v1.clone(), @@ -2683,6 +2707,12 @@ pub async fn serve( chain_filter.clone(), task_spawner_filter.clone(), ); + // POST validator/builder_preferences + let post_validator_builder_preferences = post_validator_builder_preferences( + eth_v1.clone(), + chain_filter.clone(), + task_spawner_filter.clone(), + ); // POST validator/sync_committee_subscriptions let post_validator_sync_committee_subscriptions = post_validator_sync_committee_subscriptions( eth_v1.clone(), @@ -3496,6 +3526,8 @@ pub async fn serve( .uor(post_validator_sync_committee_subscriptions) .uor(post_validator_prepare_beacon_proposer) .uor(post_validator_register_validator) + .uor(post_validator_builder_preferences) + .uor(post_validator_blocks_v4) .uor(post_validator_liveness_epoch) .uor(post_lighthouse_liveness) .uor(post_lighthouse_database_reconstruct) diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 63420fbe2d0..49315790da0 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -1,10 +1,10 @@ use crate::{ build_block_contents, version::{ - ResponseIncludesVersion, add_consensus_block_value_header, add_consensus_version_header, - add_execution_payload_blinded_header, add_execution_payload_included_header, - add_execution_payload_value_header, add_ssz_content_type_header, beacon_response, - inconsistent_fork_rejection, + ResponseIncludesVersion, add_builder_url_header, add_consensus_block_value_header, + add_consensus_version_header, add_execution_payload_blinded_header, + add_execution_payload_included_header, add_execution_payload_value_header, + add_ssz_content_type_header, beacon_response, inconsistent_fork_rejection, }, }; use beacon_chain::graffiti_calculator::GraffitiSettings; @@ -17,9 +17,10 @@ use eth2::{ beacon_response::ForkVersionedResponse, types::{BlockAndEnvelope, ProduceBlockV4Metadata}, }; +use sensitive_url::SensitiveUrl; use ssz::Encode; use std::sync::Arc; -use tracing::instrument; +use tracing::{debug, instrument}; use types::{execution::BlockProductionVersion, *}; use warp::{ http::response::Builder, @@ -58,13 +59,30 @@ pub async fn produce_block_v4( chain: Arc>, slot: Slot, query: api_types::ValidatorBlocksQuery, + builder_config: api_types::BuilderConfig, ) -> Result { + // `produceBlockV4` is the Gloas block-production endpoint. + let fork_name = chain.spec.fork_name_at_slot::(slot); + if !fork_name.gloas_enabled() { + return Err(warp_utils::reject::custom_bad_request( + "produceBlockV4 is only valid for Gloas and later".to_string(), + )); + } + let include_payload = query.include_payload.ok_or_else(|| { warp_utils::reject::custom_bad_request( "include_payload query parameter is required".to_string(), ) })?; + // The resolved builder config is threaded into block production, where it drives direct-builder + // bid requests and the gossip/direct bid policy (see `produce_block_on_state_gloas`). + debug!( + %slot, + builders = builder_config.builders.len(), + "Received produceBlockV4 request" + ); + let randao_reveal = query.randao_reveal.decompress().map_err(|e| { warp_utils::reject::custom_bad_request(format!( "randao reveal is not a valid BLS signature: {:?}", @@ -73,14 +91,9 @@ pub async fn produce_block_v4( })?; let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?; - // The GET route carries only a boost factor; direct builders arrive with the `BuilderConfig` - // body once this route is converted to POST (later in this PR stack). Until then the winning - // bid's builder URL is unused (`Eth-Builder-Url` also lands with the POST conversion). - let builder_config = api_types::BuilderConfig { - builder_boost_factor: query.builder_boost_factor.unwrap_or(DEFAULT_BOOST_FACTOR), - ..api_types::BuilderConfig::empty() - }; + // Gloas takes its bid boost policy from `builder_config` (global for gossip, per-builder for + // direct), so the V3-style `builder_boost_factor` query param is not used on this path. let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); let ( @@ -89,7 +102,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, - _builder_url, + builder_url, ) = chain .produce_block_with_verification_gloas( randao_reveal, @@ -110,6 +123,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, + builder_url, accept_header, &chain.spec, ) @@ -164,9 +178,13 @@ pub fn build_response_v4( consensus_block_value: u64, execution_payload_value: Uint256, payload_contents: Option>, + builder_url: Option, accept_header: Option, spec: &ChainSpec, ) -> Result { + // Stringify the winning builder's URL only here, at the `Eth-Builder-Url` header boundary; it is + // kept as a redacted `SensitiveUrl` everywhere upstream. + let builder_url = builder_url.map(|url| url.expose_full().to_string()); let fork_name = block .to_ref() .fork_name(spec) @@ -180,14 +198,15 @@ pub fn build_response_v4( consensus_block_value: consensus_block_value_wei, execution_payload_value, execution_payload_included, - builder_url: None, + builder_url: builder_url.clone(), }; let add_v4_headers = |res: Response| { let res = add_consensus_version_header(res, fork_name); let res = add_consensus_block_value_header(res, consensus_block_value_wei); let res = add_execution_payload_value_header(res, execution_payload_value); - add_execution_payload_included_header(res, execution_payload_included) + let res = add_execution_payload_included_header(res, execution_payload_included); + add_builder_url_header(res, builder_url.as_deref()) }; // When the payload is included, bundle the block with the execution payload envelope, blobs and diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index a7336f2f6eb..5279a25b3be 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -19,6 +19,7 @@ use logging::crit; use network::NetworkMessage; use rand::prelude::SliceRandom; use reqwest::StatusCode; +use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; use std::marker::PhantomData; use std::sync::Arc; @@ -73,6 +74,62 @@ impl ProvenancedBlock> } } +/// If a direct builder won this block's payload bid, forward the signed block to that builder via +/// `submitSignedBeaconBlock` so it reveals the execution payload envelope. +/// +/// The builder's URL is the `Eth-Builder-Url` request header the VC echoed on publish (beacon-APIs +/// #630), so this works even on a beacon node that did not produce the block. `None` (self-built or +/// p2p-won), no configured builders, or a malformed URL are all no-ops. +/// +/// Fire-and-forget: the submission runs in a detached task; a failure is logged at high severity +/// (the validator has already signed the commitment) but never blocks the publish response. Runs +/// only once per block since it hangs off the single p2p-publish point. +fn forward_signed_block_to_winning_builder( + chain: &Arc>, + block: Arc>, + builder_url: Option<&str>, +) { + // The VC echoes the winning builder's URL in the `Eth-Builder-Url` request header (beacon-APIs + // #630); absent for a self-built block or a p2p-won bid, in which case there's nothing to forward. + let Some(builder_url) = builder_url else { + return; + }; + let Some(builders) = chain.builders.as_ref() else { + return; + }; + let url = match SensitiveUrl::parse(builder_url) { + Ok(url) => url, + Err(e) => { + warn!(error = ?e, "Ignoring malformed Eth-Builder-Url header"); + return; + } + }; + + let builders = builders.clone(); + let slot = block.slot(); + let block_root = block.canonical_root(); + + chain.task_executor.spawn( + async move { + match builders.forward_signed_block(&url, &block).await { + Ok(()) => info!( + %slot, + %block_root, + "Forwarded signed block to winning builder" + ), + Err(e) => error!( + %slot, + %block_root, + builder_url = ?url, + error = ?e, + "Failed to forward signed block to winning builder" + ), + } + }, + "forward_signed_block_to_builder", + ); +} + /// Handles a request from the HTTP API for full blocks. #[allow(clippy::too_many_arguments)] #[instrument( @@ -88,6 +145,9 @@ pub async fn publish_block>( network_tx: &UnboundedSender>, validation_level: BroadcastValidation, duplicate_status_code: StatusCode, + // The `Eth-Builder-Url` request header (beacon-APIs #630): when a direct builder won the block's + // payload bid, its URL, so the block is forwarded there for envelope reveal. + builder_url: Option, ) -> Result { let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let block_publishing_delay_for_testing = chain.config.block_publishing_delay; @@ -141,6 +201,14 @@ pub async fn publish_block>( BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) })?; + // If a direct builder won this block's payload bid, forward the signed block to it so it + // reveals the execution payload envelope. + forward_signed_block_to_winning_builder( + &publish_chain, + block.clone(), + builder_url.as_deref(), + ); + Ok(()) }; @@ -570,6 +638,8 @@ pub async fn publish_blinded_block( network_tx, validation_level, duplicate_status_code, + // Blinded (mev-boost) publish predates the Gloas builder-URL round-trip. + None, ) .await } else { diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 7b904260b8e..f07273003fb 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -6,7 +6,7 @@ use crate::utils::{ AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; -use crate::version::{V1, V2, V3, V4, add_ssz_content_type_header, unsupported_version_rejection}; +use crate::version::{V1, V2, V3, add_ssz_content_type_header, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; @@ -14,12 +14,13 @@ use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainT use bls::PublicKeyBytes; use bytes::Bytes; use context_deserialize::ContextDeserialize; -use eth2::CONSENSUS_VERSION_HEADER; use eth2::types::{ - Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, - StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, - ValidatorAttestationDataQuery, ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, + Accept, BeaconCommitteeSubscription, BuilderConfig, BuilderPreferenceEntry, EndpointVersion, + Failure, GenericResponse, MAX_SUBMITTED_BUILDER_PREFERENCES, StandardLivenessResponseData, + StateId as CoreStateId, ValidatorAggregateAttestationQuery, ValidatorAttestationDataQuery, + ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, }; +use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; use reqwest::StatusCode; @@ -483,8 +484,12 @@ pub fn get_validator_blocks( not_synced_filter?; - if endpoint_version == V4 { - produce_block_v4(accept_header, chain, slot, query).await + // Gloas block production is served via `POST v4/validator/blocks`. + let fork_name = chain.spec.fork_name_at_slot::(slot); + if fork_name.gloas_enabled() { + Err(warp_utils::reject::custom_bad_request( + "Gloas block production requires POST v4/validator/blocks".to_string(), + )) } else if endpoint_version == V3 { produce_block_v3(accept_header, chain, slot, query).await } else { @@ -496,6 +501,84 @@ pub fn get_validator_blocks( .boxed() } +// POST v4/validator/blocks/{slot} +// +// The Gloas block-production endpoint. Carries the validator's resolved `BuilderConfig` as the +// request body, accepted as either JSON or SSZ (selected by `Content-Type`; `application/octet-stream` +// => SSZ). The `Eth-Consensus-Version` request header is required (per beacon-APIs #630); the body +// is not fork-versioned, so like the builder-preferences endpoint the header is validated but only +// logged. +pub fn post_validator_blocks_v4( + eth_v4: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v4 + .and(warp::path("validator")) + .and(warp::path("blocks")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid slot".to_string(), + )) + })) + .and(warp::path::end()) + .and(warp::header::optional::("accept")) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(not_while_syncing_filter) + .and(warp::query::()) + .and( + warp::header::optional::(CONTENT_TYPE_HEADER) + .and(warp::body::bytes()) + .and_then(|content_type: Option, body: Bytes| async move { + let builder_config: BuilderConfig = if content_type.as_deref() + == Some(SSZ_CONTENT_TYPE_HEADER) + { + BuilderConfig::from_ssz_bytes(&body).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })? + } else { + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + // A zero-length `url` or auth `data` makes the body itself invalid (beacon-APIs + // #630) — a 400, unlike per-entry bid failures, which are isolated. + for entry in builder_config.builders.iter() { + entry.validate().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid builder entry: {e}" + )) + })?; + } + Ok::<_, Rejection>(builder_config) + }), + ) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + accept_header: Option, + consensus_version: ForkName, + not_synced_filter: Result<(), Rejection>, + query: ValidatorBlocksQuery, + builder_config: BuilderConfig, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + debug!( + ?slot, + %consensus_version, + "Block production request from HTTP API (v4)" + ); + not_synced_filter?; + produce_block_v4(accept_header, chain, slot, query, builder_config).await + }) + }, + ) + .boxed() +} + // POST validator/liveness/{epoch} pub fn post_validator_liveness_epoch( eth_v1: EthV1Filter, @@ -770,6 +853,126 @@ pub fn post_validator_register_validator( .boxed() } +// POST validator/builder_preferences +// +// Accepts the `BuilderPreferenceEntry` list as either JSON or SSZ, selected by the request's +// `Content-Type` (`application/octet-stream` => SSZ, otherwise JSON). A required +// `Eth-Consensus-Version` header carries the consensus version the preferences belong to (per +// beacon-APIs #630); it is not needed to decode the (currently single-fork) body, so it is only +// logged. +pub fn post_validator_builder_preferences( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("builder_preferences")) + .and(warp::path::end()) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .and( + warp::header::optional::(CONTENT_TYPE_HEADER) + .and(warp::body::bytes()) + .and_then(|content_type: Option, body: Bytes| async move { + let entries: Vec = if content_type.as_deref() + == Some(SSZ_CONTENT_TYPE_HEADER) + { + Vec::from_ssz_bytes(&body).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })? + } else { + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + // The submission list is bounded (SSZ `List[BuilderPreferencesEntry, 4096]`, + // JSON `maxItems: 4096`, per beacon-APIs #630); a longer body is invalid. + if entries.len() > MAX_SUBMITTED_BUILDER_PREFERENCES { + return Err(warp_utils::reject::custom_bad_request(format!( + "too many builder preference entries: {} exceeds the limit of {}", + entries.len(), + MAX_SUBMITTED_BUILDER_PREFERENCES + ))); + } + // A zero-length `url` or auth `data` makes the body itself invalid (beacon-APIs + // #630) — a 400, unlike per-entry submission failures, which are isolated. + for entry in &entries { + entry.validate().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid builder preference entry: {e}" + )) + })?; + } + Ok::<_, Rejection>(entries) + }), + ) + .then( + |consensus_version: ForkName, + task_spawner: TaskSpawner, + chain: Arc>, + entries: Vec| async move { + let (tx, rx) = oneshot::channel(); + + let initial_result = task_spawner + .spawn_async_with_rejection_no_conversion(Priority::P0, async move { + // The builder service is only present when the Gloas fork is scheduled. + let builders = chain + .builders + .as_ref() + .ok_or(BeaconChainError::BuilderMissing) + .map_err(warp_utils::reject::unhandled_error)? + .clone(); + + debug!( + count = entries.len(), + %consensus_version, + "Received submit builder preferences request" + ); + + // Submitting to a builder can be slow (they frequently time out), so the + // fan-out runs in a detached task rather than holding a `BeaconProcessor` + // worker. The service submits each entry independently and best-effort, + // returning the failures by index (per beacon-APIs #630). + tokio::task::spawn(async move { + let response = match builders + .submit_builder_preferences(entries, consensus_version) + .await + { + Ok(()) => Ok(warp::reply::reply().into_response()), + Err(failures) => Err(warp_utils::reject::indexed_bad_request( + "error submitting builder preferences".to_string(), + failures + .into_iter() + .map(|f| Failure::new(f.index, f.error.to_string())) + .collect(), + )), + }; + let _ = tx.send(response); + }); + + Ok(warp::reply::reply().into_response()) + }) + .await; + + if initial_result.is_err() { + return convert_rejection(initial_result).await; + } + + convert_rejection(rx.await.unwrap_or_else(|_| { + Ok(warp::reply::with_status( + warp::reply::json(&"No response from channel"), + warp::http::StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response()) + })) + .await + }, + ) + .boxed() +} + // POST validator/prepare_beacon_proposer pub fn post_validator_prepare_beacon_proposer( eth_v1: EthV1Filter, diff --git a/beacon_node/http_api/src/version.rs b/beacon_node/http_api/src/version.rs index 6f441636b49..63914feb049 100644 --- a/beacon_node/http_api/src/version.rs +++ b/beacon_node/http_api/src/version.rs @@ -4,8 +4,8 @@ use eth2::beacon_response::{ ExecutionOptimisticFinalizedMetadata, ForkVersionedResponse, UnversionedResponse, }; use eth2::{ - CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, - EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, + BUILDER_URL_HEADER, CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, + CONTENT_TYPE_HEADER, EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, SSZ_CONTENT_TYPE_HEADER, }; use serde::Serialize; @@ -116,6 +116,15 @@ pub fn add_execution_payload_value_header( .into_response() } +/// Add the `Eth-Builder-Url` header (the winning builder's URL) to a response, when present. +/// Absent for a self-built block or a block won by a p2p bid. +pub fn add_builder_url_header(reply: T, builder_url: Option<&str>) -> Response { + match builder_url { + Some(url) => reply::with_header(reply, BUILDER_URL_HEADER, url).into_response(), + None => reply.into_response(), + } +} + /// Add the `Eth-Consensus-Block-Value` header to a response. pub fn add_consensus_block_value_header( reply: T, diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 4d2be52a0d5..5db04d7d136 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -433,6 +433,7 @@ pub async fn consensus_partial_pass_only_consensus() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; @@ -610,7 +611,7 @@ pub async fn equivocation_consensus_early_equivocation() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), blobs_a), validation_level, - None + None, ) .await .is_ok() @@ -763,6 +764,7 @@ pub async fn equivocation_consensus_late_equivocation() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index 4cd76c03dc2..dd60201fcb8 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -27,7 +27,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, Hash256, MinimalEthSpec, + Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, ForkName, Hash256, MinimalEthSpec, ProposerPreparationData, Slot, }; @@ -727,7 +727,15 @@ pub async fn proposer_boost_re_org_test( let (block_c, block_c_blobs) = { let (response, _) = tester .client - .get_validator_blocks_v4::(slot_c, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot_c, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); ( diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index a020c633c82..807aa2c1040 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -810,7 +810,15 @@ pub async fn fork_choice_before_proposal() { let block_d = if harness.spec.fork_name_at_slot::(slot_d).gloas_enabled() { tester .client - .get_validator_blocks_v4::(slot_d, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot_d, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() .0 diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 38bcd05a77c..94d7f15f44b 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4812,7 +4812,15 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5004,6 +5012,124 @@ impl ApiTester { self } + pub async fn test_block_production_v4_missing_consensus_version_header_returns_400( + self, + ) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + let Some((slot, epoch, _fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (_sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + ) + .await + .unwrap(); + + // A valid body, but no `Eth-Consensus-Version` header: the header is required + // (beacon-APIs #630), so the request must fail with a 400. + let response = reqwest::Client::new() + .post(url) + .json(ð2::types::BuilderConfig::empty()) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + + pub async fn test_block_production_v4_zero_length_entry_fields_return_400(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + let Some((slot, epoch, _fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (_sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + ) + .await + .unwrap(); + + let valid_auth = eth2::types::SignedRequestAuth { + message: eth2::types::RequestAuth { + data: eth2::types::RequestAuthData::new(b"http://builder.example.com".to_vec()) + .unwrap(), + slot, + }, + signature: Signature::empty(), + }; + let entry = |url: &str, auth: eth2::types::SignedRequestAuth| eth2::types::BuilderEntry { + url: url.parse().unwrap(), + auth, + builder_pubkeys: <_>::default(), + max_execution_payment: 0, + min_bid: 0, + builder_boost_factor: 100, + }; + + // A zero-length `url` and a zero-length auth `data` each make the body invalid + // (beacon-APIs #630), so the request must fail with a 400. + let empty_url_entry = entry("", valid_auth.clone()); + let mut empty_data_auth = valid_auth; + empty_data_auth.message.data = eth2::types::RequestAuthData::default(); + let empty_data_entry = entry("http://builder.example.com", empty_data_auth); + + for bad_entry in [empty_url_entry, empty_data_entry] { + let config = serde_json::json!({ + "min_bid": "0", + "builder_boost_factor": "100", + "builders": [bad_entry], + }); + let response = reqwest::Client::new() + .post(url.clone()) + .header(eth2::CONSENSUS_VERSION_HEADER, "gloas") + .json(&config) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + pub async fn test_envelope_post_when_syncing_returns_503(mut self) -> Self { if !self.chain.spec.is_gloas_scheduled() { return self; @@ -5177,7 +5303,15 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5252,7 +5386,15 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4_ssz::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5324,12 +5466,28 @@ impl ApiTester { let (response, metadata) = if ssz { self.client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, true, None, None) + .post_validator_blocks_v4_ssz::( + slot, + &randao_reveal, + None, + true, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() } else { self.client - .get_validator_blocks_v4::(slot, &randao_reveal, None, true, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + true, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() }; @@ -5870,7 +6028,15 @@ impl ApiTester { // Produce and publish a block. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5953,7 +6119,15 @@ impl ApiTester { // Produce and publish a block, but withhold its envelope. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -8915,7 +9089,15 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -9221,7 +9403,6 @@ impl ApiTester { let epoch = self.chain.epoch().unwrap(); let (_, randao_reveal) = self.get_test_randao(slot, epoch).await; let graffiti = Some(Graffiti::from([0; GRAFFITI_BYTES_LEN])); - // When GraffitiPolicy is None let no_graffiti_policy_path = self .client @@ -10093,6 +10274,10 @@ async fn envelope_api() { .await .test_block_production_v4_missing_include_payload_returns_400() .await + .test_block_production_v4_missing_consensus_version_header_returns_400() + .await + .test_block_production_v4_zero_length_entry_fields_return_400() + .await .test_envelope_post_consensus_invalid_returns_400_no_broadcast() .await .test_envelope_post_gossip_partial_pass_returns_202() From 44f442479d628ee4dcdcbd53db04791ec368057c Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:55:54 -0500 Subject: [PATCH 03/11] Migrate the validator client to the Gloas builder API (Gloas builder API 5/5) Final PR of the Gloas builder API stack: - sign builder request-auth and preferences (REQUEST_AUTH signing domain, web3signer message type) - add the builder configuration store (`builder_definitions.yml`) and book documentation - add the builder-preferences service and switch block production to `POST` produceBlockV4 with a `BuilderConfig` body, threading the `Eth-Builder-Url` header through block publication - remove the now-unused legacy `GET` produceBlockV4 client methods Change-Id: Iaeeaf6205a024e4fb9fd11aae6ac6a75978a8320 --- Cargo.lock | 25 ++ Cargo.toml | 2 + book/src/SUMMARY.md | 1 + book/src/gloas_builder_config.md | 90 +++++ common/eth2/src/lib.rs | 182 ----------- .../src/mock_beacon_node.rs | 18 +- .../src/mock_validator_store.rs | 9 + validator_client/Cargo.toml | 1 + validator_client/builder_store/Cargo.toml | 26 ++ .../builder_store/src/builder_definitions.rs | 292 +++++++++++++++++ validator_client/builder_store/src/lib.rs | 140 ++++++++ .../lighthouse_validator_store/Cargo.toml | 1 + .../lighthouse_validator_store/src/lib.rs | 25 ++ validator_client/signing_method/Cargo.toml | 1 + validator_client/signing_method/src/lib.rs | 4 + .../signing_method/src/web3signer.rs | 4 + validator_client/src/lib.rs | 29 +- .../validator_services/Cargo.toml | 2 + .../validator_services/src/block_service.rs | 122 +++++-- .../src/builder_preferences_service.rs | 308 ++++++++++++++++++ .../validator_services/src/lib.rs | 2 + .../src/request_auth_cache.rs | 106 ++++++ validator_client/validator_store/Cargo.toml | 1 + validator_client/validator_store/src/lib.rs | 7 + wordlist.txt | 2 + 25 files changed, 1186 insertions(+), 214 deletions(-) create mode 100644 book/src/gloas_builder_config.md create mode 100644 validator_client/builder_store/Cargo.toml create mode 100644 validator_client/builder_store/src/builder_definitions.rs create mode 100644 validator_client/builder_store/src/lib.rs create mode 100644 validator_client/validator_services/src/builder_preferences_service.rs create mode 100644 validator_client/validator_services/src/request_auth_cache.rs diff --git a/Cargo.lock b/Cargo.lock index 262a2cf5c32..7e8adb51eb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1713,6 +1713,25 @@ dependencies = [ "types", ] +[[package]] +name = "builder_store" +version = "0.1.0" +dependencies = [ + "account_utils", + "bls", + "builder_types", + "filesystem", + "futures", + "hex", + "parking_lot", + "serde", + "ssz_types", + "tempfile", + "tracing", + "types", + "yaml_serde", +] + [[package]] name = "builder_types" version = "0.1.0" @@ -5666,6 +5685,7 @@ dependencies = [ "account_utils", "beacon_node_fallback", "bls", + "builder_types", "doppelganger_service", "either", "environment", @@ -8445,6 +8465,7 @@ name = "signing_method" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2_keystore", "ethereum_serde_utils", "lockfile", @@ -9809,6 +9830,7 @@ version = "8.2.2" dependencies = [ "account_utils", "beacon_node_fallback", + "builder_store", "clap", "clap_utils", "directory", @@ -9974,6 +9996,8 @@ version = "0.1.0" dependencies = [ "beacon_node_fallback", "bls", + "builder_store", + "builder_types", "either", "eth2", "futures", @@ -9998,6 +10022,7 @@ name = "validator_store" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2", "futures", "slashing_protection", diff --git a/Cargo.toml b/Cargo.toml index 48dbefc8b1a..eb0a3c172a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ members = [ "testing/web3signer_tests", "validator_client", "validator_client/beacon_node_fallback", + "validator_client/builder_store", "validator_client/doppelganger_service", "validator_client/graffiti_file", "validator_client/http_api", @@ -120,6 +121,7 @@ bincode = "1" bitvec = "1" bls = { path = "crypto/bls" } builder_client = { path = "beacon_node/builder_client" } +builder_store = { path = "validator_client/builder_store" } builder_types = { path = "common/builder_types" } byteorder = "1" bytes = "1.11.1" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index feecd4b6894..15e6be2010a 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -49,6 +49,7 @@ * [Redundancy](./advanced_redundancy.md) * [Release Candidates](./advanced_release_candidates.md) * [MEV](./advanced_builders.md) + * [Gloas Builder Configuration](./gloas_builder_config.md) * [Late Block Re-orgs](./advanced_re-orgs.md) * [Blobs](./advanced_blobs.md) * [Command Line Reference (CLI)](./help_general.md) diff --git a/book/src/gloas_builder_config.md b/book/src/gloas_builder_config.md new file mode 100644 index 00000000000..4709885b7e1 --- /dev/null +++ b/book/src/gloas_builder_config.md @@ -0,0 +1,90 @@ +# Builder Configuration + +> This applies from the **Gloas** fork onwards. It configures how the validator client sources +> execution-payload bids from external builders under ePBS. + +The validator client reads its external-builder settings from a YAML file named +`builder_definitions.yml` in the validator directory +(`/validators/builder_definitions.yml`). The file holds two things: + +- **A global bid policy** — `min_bid` and `builder_boost_factor`, applied to bids received over p2p + (gossip) and used as the default for any builder that does not set its own. +- **A list of builders** to request bids from directly, each with optional per-builder overrides of + the global policy. + +## Example + +```yaml +# Global bid policy: applies to p2p (gossip) bids, and is the default for any +# builder below that omits the corresponding field. +min_bid: 0 # gwei — bids below this rank last; one wins only if nothing else is viable +builder_boost_factor: 100 # percent — 100 = neutral, >100 favors builders, 0 = prefer local + +builders: + # Minimal builder — inherits the global policy. + - enabled: true + url: "https://builder-a.example.com" + max_execution_payment: 1000000000 # gwei — cap on the trusted execution payment + + # Builder overriding the globals and pinning the expected builder key. + - enabled: true + url: "https://builder-b.example.com" + max_execution_payment: 1000000000 + min_bid: 500000000 # override the global for this builder + builder_boost_factor: 120 # override the global for this builder + builder_pubkeys: # optional — reject a bid not signed by one of these keys + - "0xa1b2c3d4..." + # auth_data: "0x68747470..." # optional — defaults to the UTF-8 bytes of `url` +``` + +> **Comments are not preserved.** The validator client rewrites this file when builders are added or +> removed (for example via the keymanager API), which strips YAML comments. Keep an annotated copy +> elsewhere if you rely on inline notes. + +## Fields + +### Top level (global bid policy) + +| Field | Required | Default | Meaning | +| ------- | ---------- | --------- | --------- | +| `min_bid` | no | `0` | Minimum total payment, in gwei, for a p2p bid. A bid below the floor is ranked behind any floor-clearing candidate (including the local block) and only wins when nothing else is viable. Also the default `min_bid` for any builder that omits it. | +| `builder_boost_factor` | no | `100` | Percentage multiplier applied to p2p bids when comparing against the local block. Also the default for any builder that omits it. | +| `builders` | no | `[]` | The list of builders to request bids from directly. | + +### Per builder (each entry under `builders`) + +| Field | Required | Default | Meaning | +| ------- | ---------- | --------- | --------- | +| `enabled` | **yes** | — | Whether this builder is used. Disabled builders are ignored. | +| `url` | **yes** | — | The builder's `http`/`https` URL. Bids are requested from here at block-production time. | +| `max_execution_payment` | **yes** | — | Cap, in gwei, on the *trusted* execution payment accepted from this builder. | +| `min_bid` | no | *(global)* | Override the global minimum bid for this builder. | +| `builder_boost_factor` | no | *(global)* | Override the global boost factor for this builder. | +| `builder_pubkeys` | no | *(empty)* | The builder's BLS public keys, hex-encoded. If non-empty, a returned bid **not** signed by one of them is rejected. | +| `auth_data` | no | *(UTF-8 of `url`)* | Opaque authentication data, hex-encoded, agreed with the builder out of band. Signed into the request. Must be non-empty when set. Defaults to the UTF-8 bytes of `url`. | + +All byte fields (`builder_pubkeys` entries, `auth_data`) are `0x`-prefixed hex strings. All payment values +(`min_bid`, `max_execution_payment`) are in gwei. + +## How bids are selected + +At block-production time the validator client requests a bid from each enabled builder with a `url`, +and also considers bids seen over p2p. For each candidate bid: + +- **`min_bid`** — a bid whose total value is below the applicable `min_bid` is ranked behind any + floor-clearing candidate (including the local block) rather than dropped, so it wins only when + nothing else is viable (e.g. the local build failed). Direct builders use their own (or the + inherited global) value; p2p bids use the global value. +- **`builder_boost_factor`** — the surviving bid's value is scaled by its boost factor + (`boost × value ÷ 100`) before being compared against the locally-built block. A factor below + `100` favors the local block; above `100` favors the builder; `0` always prefers local; + `2^64 − 1` strongly favors the builder. The factor is a multiplier, not an absolute override, so a + zero-value bid still ranks `0` and loses to any non-zero local block. +- **`max_execution_payment`** — bounds how much of a builder's (off-chain) execution payment counts + toward its bid value. This applies only to direct builders; p2p bids carry no trusted execution + payment. +- **`builder_pubkeys`** — for a direct builder, if non-empty, the returned bid must be signed by + one of these keys or it is discarded. + +The highest-value bid after these rules wins. Per-builder `min_bid`/`builder_boost_factor` apply +only to bids requested directly by URL; p2p bids are governed by the global values. diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index a02b948a909..8d94c774eda 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3048,188 +3048,6 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } - // The legacy `GET v4/validator/blocks/{slot}` client methods below are kept alongside the new - // POST variants until the validator client migrates to POST (later in this PR stack), at which - // point they are removed. - - /// `GET v4/validator/blocks/{slot}` - pub async fn get_validator_blocks_v4( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular( - slot, - randao_reveal, - graffiti, - SkipRandaoVerification::No, - include_payload, - builder_booster_factor, - graffiti_policy, - ) - .await - } - - /// `GET v4/validator/blocks/{slot}` - /// - /// Returns either a bare block or the full [`BlockAndEnvelope`] (block + execution payload - /// envelope + blobs + KZG proofs) depending on the `Eth-Execution-Payload-Included` response - /// header. Note that a builder bid yields a bare block even when `include_payload=true`. - #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - skip_randao_verification: SkipRandaoVerification, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let mut path = self - .post_validator_blocks_v4_path( - slot, - randao_reveal, - graffiti, - skip_randao_verification, - include_payload, - graffiti_policy, - ) - .await?; - - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - - let opt_result = self - .get_response_with_response_headers( - path, - Accept::Json, - self.timeouts.get_validator_block, - |response, headers| async move { - let metadata = ProduceBlockV4Metadata::try_from(&headers) - .map_err(Error::InvalidHeaders)?; - let block_response = if metadata.execution_payload_included { - ProduceBlockV4Response::BlockAndEnvelope( - response - .json::, - ProduceBlockV4Metadata, - >>() - .await? - .data, - ) - } else { - ProduceBlockV4Response::BlockOnly( - response - .json::, - ProduceBlockV4Metadata, - >>() - .await? - .data, - ) - }; - Ok((block_response, metadata)) - }, - ) - .await?; - - opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) - } - - /// `GET v4/validator/blocks/{slot}` in ssz format - pub async fn get_validator_blocks_v4_ssz( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular_ssz::( - slot, - randao_reveal, - graffiti, - SkipRandaoVerification::No, - include_payload, - builder_booster_factor, - graffiti_policy, - ) - .await - } - - /// `GET v4/validator/blocks/{slot}` in ssz format - /// - /// See [`Self::get_validator_blocks_v4_modular`] for the response semantics. - #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular_ssz( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - skip_randao_verification: SkipRandaoVerification, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let mut path = self - .post_validator_blocks_v4_path( - slot, - randao_reveal, - graffiti, - skip_randao_verification, - include_payload, - graffiti_policy, - ) - .await?; - - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - - let opt_response = self - .get_response_with_response_headers( - path, - Accept::Ssz, - self.timeouts.get_validator_block, - |response, headers| async move { - let metadata = ProduceBlockV4Metadata::try_from(&headers) - .map_err(Error::InvalidHeaders)?; - let response_bytes = response.bytes().await?; - let block_response = if metadata.execution_payload_included { - ProduceBlockV4Response::BlockAndEnvelope( - BlockAndEnvelope::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?, - ) - } else { - ProduceBlockV4Response::BlockOnly( - BeaconBlock::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?, - ) - }; - - Ok((block_response, metadata)) - }, - ) - .await?; - - opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) - } - /// `GET v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}` pub async fn get_validator_execution_payload_envelopes( &self, diff --git a/testing/validator_test_rig/src/mock_beacon_node.rs b/testing/validator_test_rig/src/mock_beacon_node.rs index d01905c0c7e..2cdc87f560a 100644 --- a/testing/validator_test_rig/src/mock_beacon_node.rs +++ b/testing/validator_test_rig/src/mock_beacon_node.rs @@ -102,8 +102,8 @@ impl MockBeaconNode { .create(); } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` - pub fn mock_get_validator_blocks_v4( + /// Mocks `POST /eth/v4/validator/blocks/{slot}` + pub fn mock_post_validator_blocks_v4( &mut self, block: &BeaconBlock, fork_name: ForkName, @@ -121,7 +121,7 @@ impl MockBeaconNode { }); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), @@ -136,8 +136,8 @@ impl MockBeaconNode { .create() } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` (SSZ) - pub fn mock_get_validator_blocks_v4_ssz( + /// Mocks `POST /eth/v4/validator/blocks/{slot}` (SSZ) + pub fn mock_post_validator_blocks_v4_ssz( &mut self, block: &BeaconBlock, fork_name: ForkName, @@ -149,7 +149,7 @@ impl MockBeaconNode { let ssz_bytes = block.as_ssz_bytes(); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), @@ -165,13 +165,13 @@ impl MockBeaconNode { .create() } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` (SSZ) returning error - pub fn mock_get_validator_blocks_v4_ssz_error(&mut self, slot: Slot) -> Mock { + /// Mocks `POST /eth/v4/validator/blocks/{slot}` (SSZ) returning error + pub fn mock_post_validator_blocks_v4_ssz_error(&mut self, slot: Slot) -> Mock { let path_pattern = Regex::new(&format!(r"^/eth/v4/validator/blocks/{}", slot.as_u64())).unwrap(); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), diff --git a/testing/validator_test_rig/src/mock_validator_store.rs b/testing/validator_test_rig/src/mock_validator_store.rs index e4ce9772647..9fb75ae6b8b 100644 --- a/testing/validator_test_rig/src/mock_validator_store.rs +++ b/testing/validator_test_rig/src/mock_validator_store.rs @@ -1,4 +1,5 @@ use bls::{PublicKeyBytes, Signature}; +use eth2::types::{RequestAuth, SignedRequestAuth}; use futures::future::{BoxFuture, FutureExt}; use futures::{Stream, stream}; use std::future::Future; @@ -184,6 +185,14 @@ impl ValidatorStore for MockValidatorStore { panic!("MockValidatorStore::sign_proposer_preferences called without a hook") } + async fn sign_request_auth_v1( + &self, + _validator_pubkey: PublicKeyBytes, + _request_auth_v1: RequestAuth, + ) -> Result> { + panic!("MockValidatorStore::sign_request_auth_v1 called without a hook") + } + fn proposal_data(&self, _pubkey: &PublicKeyBytes) -> Option { panic!("MockValidatorStore::proposal_data called without a hook") } diff --git a/validator_client/Cargo.toml b/validator_client/Cargo.toml index 6990a2f61a7..ab5a85e8b0e 100644 --- a/validator_client/Cargo.toml +++ b/validator_client/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } +builder_store = { workspace = true } clap = { workspace = true } clap_utils = { workspace = true } directory = { workspace = true } diff --git a/validator_client/builder_store/Cargo.toml b/validator_client/builder_store/Cargo.toml new file mode 100644 index 00000000000..cad40ec39fc --- /dev/null +++ b/validator_client/builder_store/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "builder_store" +version = "0.1.0" +edition = { workspace = true } +authors = ["Sigma Prime "] + +[lib] +name = "builder_store" +path = "src/lib.rs" + +[dependencies] +account_utils = { workspace = true } +bls = { workspace = true } +builder_types = { workspace = true } +filesystem = { workspace = true } +futures = { workspace = true } +hex = { workspace = true } +parking_lot = { workspace = true } +serde = { workspace = true } +ssz_types = { workspace = true } +tracing = { workspace = true } +types = { workspace = true } +yaml_serde = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs new file mode 100644 index 00000000000..928f6d3ec14 --- /dev/null +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -0,0 +1,292 @@ +use account_utils::write_file_via_temporary; +use bls::PublicKeyBytes; +use builder_types::{BuilderUrl, MAX_BUILDER_ENTRIES, RequestAuthData}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{File, create_dir_all}; +use std::io; +use std::path::{Path, PathBuf}; + +/// The file name for the serialized `BuilderConfigFile` struct. +pub const BUILDERS_FILENAME: &str = "builder_definitions.yml"; +/// The temporary file name for the serialized `BuilderConfigFile` struct. +/// +/// This is used to achieve an atomic update of the contents on disk, without truncation. +pub const BUILDERS_TEMP_FILENAME: &str = ".builder_definitions.yml.tmp"; + +#[derive(Debug)] +pub enum Error { + /// The config file could not be opened. + UnableToOpenFile(io::Error), + /// The config file could not be parsed as YAML. + UnableToParseFile(yaml_serde::Error), + /// The builders file could not be serialized as YAML. + UnableToEncodeFile(yaml_serde::Error), + /// The builders file or temp file could not be written to the filesystem. + UnableToWriteFile(filesystem::Error), + /// The validator directory could not be created. + UnableToCreateValidatorDir(PathBuf), + /// A builder with the given URL already exists. + DuplicateBuilderAuth(BuilderUrl), + /// A builder URL could not be parsed as a URL. + InvalidBuilderUrl(BuilderUrl), + /// A builder URL does not use an `http`/`https` scheme. + UnsupportedUrlScheme(BuilderUrl), + /// More than `MAX_BUILDER_ENTRIES` builders are enabled, exceeding what fits in a + /// `BuilderConfig`. + TooManyEnabledBuilders { enabled: usize, max: usize }, +} + +/// A single builder in the config file: a direct bid request, with optional per-builder overrides +/// of the global bid policy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuilderDefinition { + /// Indicates whether this definition is enabled or disabled. + pub enabled: bool, + /// The URL the beacon node uses to contact this builder. Routing metadata; never signed. + pub url: BuilderUrl, + /// Opaque authentication data signed into `RequestAuth.data`, agreed with the builder out of + /// band, as a `0x`-prefixed hex string. When unset, it defaults to the UTF-8 bytes of `url` + /// (the builder-specs #165 default). Must be non-empty when set: a zero-length `data` is + /// invalid on the wire. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_option_auth_data" + )] + pub auth_data: Option, + /// The builder BLS public keys this builder's bids may be signed by, hex-encoded. Empty (or + /// omitted) accepts any builder; otherwise a bid not signed by one of them is rejected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub builder_pubkeys: Vec, + /// The maximum execution payment, in gwei, that we're willing to accept from this builder. + pub max_execution_payment: u64, + /// Per-builder override of the global minimum total payment (gwei). Inherits the global when + /// unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + /// Per-builder override of the global boost factor. Inherits the global when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, +} + +fn default_builder_boost_factor() -> u64 { + 100 +} + +/// Serde helper: represent `Option` as a `0x`-prefixed hex string in the config +/// file (matching how other byte fields are encoded), omitting it entirely when `None`. +mod serde_option_auth_data { + use super::RequestAuthData; + use serde::{Deserialize, Deserializer, Serializer, de}; + + pub fn serialize( + value: &Option, + serializer: S, + ) -> Result { + match value { + Some(data) => serializer.serialize_some(&format!("0x{}", hex::encode(&data[..]))), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let Some(s) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let stripped = s.strip_prefix("0x").unwrap_or(&s); + let bytes = hex::decode(stripped).map_err(de::Error::custom)?; + let data = RequestAuthData::new(bytes) + .map_err(|_| de::Error::custom("auth_data exceeds the maximum size"))?; + Ok(Some(data)) + } +} + +/// The validator client's builder configuration file. +/// +/// Holds the global bid-policy defaults plus the list of builders to request bids from directly. It +/// resolves into the wire `BuilderConfig` at block-production time: the globals govern p2p bids and +/// fill in any builder that omits `min_bid`/`builder_boost_factor`. +#[derive(Clone, Serialize, Deserialize)] +pub struct BuilderConfigFile { + /// Global minimum total payment (gwei). Applies to p2p bids and is inherited by any builder that + /// omits its own `min_bid`. + #[serde(default)] + pub min_bid: u64, + /// Global boost factor. Applies to p2p bids and is inherited by any builder that omits its own + /// `builder_boost_factor`. + #[serde(default = "default_builder_boost_factor")] + pub builder_boost_factor: u64, + /// The builders to request bids from directly. + #[serde(default)] + pub builders: Vec, +} + +impl Default for BuilderConfigFile { + fn default() -> Self { + Self { + min_bid: 0, + builder_boost_factor: default_builder_boost_factor(), + builders: Vec::new(), + } + } +} + +impl BuilderConfigFile { + /// Open an existing file or create a new, empty one if it does not exist. + pub fn open_or_create>(validators_dir: P) -> Result { + create_dir_all(validators_dir.as_ref()).map_err(|_| { + Error::UnableToCreateValidatorDir(PathBuf::from(validators_dir.as_ref())) + })?; + let builders_file_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + if !builders_file_path.exists() { + let this = Self::default(); + this.save(&validators_dir)?; + } + Self::open(validators_dir) + } + + /// Open an existing file, returning an error if the file does not exist. + pub fn open>(validators_dir: P) -> Result { + let config_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + let file = File::options() + .write(true) + .read(true) + .create_new(false) + .open(config_path) + .map_err(Error::UnableToOpenFile)?; + let config: Self = yaml_serde::from_reader(file).map_err(Error::UnableToParseFile)?; + config.validate()?; + Ok(config) + } + + /// Encodes `self` as a YAML string and atomically writes it to the `CONFIG_FILENAME` file in + /// the `validators_dir` directory. + /// + /// Will create a new file if it does not exist or overwrite any existing file. + pub fn save>(&self, validators_dir: P) -> Result<(), Error> { + let config_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + let temp_path = validators_dir.as_ref().join(BUILDERS_TEMP_FILENAME); + let mut bytes = vec![]; + yaml_serde::to_writer(&mut bytes, self).map_err(Error::UnableToEncodeFile)?; + + write_file_via_temporary(&config_path, &temp_path, &bytes) + .map_err(Error::UnableToWriteFile)?; + + Ok(()) + } + + pub fn as_slice(&self) -> &[BuilderDefinition] { + &self.builders + } + + pub fn push(&mut self, definition: BuilderDefinition) { + self.builders.push(definition); + } + + pub fn validate(&self) -> Result<(), Error> { + // The enabled builders must fit in a `BuilderConfig`'s bounded list, so + // `BuilderStore::builder_config` cannot overflow when constructing it. + let enabled = self.builders.iter().filter(|d| d.enabled).count(); + if enabled > MAX_BUILDER_ENTRIES { + return Err(Error::TooManyEnabledBuilders { + enabled, + max: MAX_BUILDER_ENTRIES, + }); + } + + let mut seen_auth_urls = HashSet::new(); + + for definition in &self.builders { + if !definition.enabled { + // ignore disabled builders + continue; + } + let url = &definition.url; + // Reject malformed or non-http(s) builder URLs here, at config load, rather than + // silently skipping them during block proposal. + let sensitive_url = url + .to_sensitive_url() + .map_err(|_| Error::InvalidBuilderUrl(url.clone()))?; + if !matches!(sensitive_url.expose_full().scheme(), "http" | "https") { + return Err(Error::UnsupportedUrlScheme(url.clone())); + } + + let auth = definition + .auth_data + .clone() + .unwrap_or_else(|| url.to_default_auth_data()); + // two entries cannot contain the same url and auth data + let key = (url.clone(), auth); + if !seen_auth_urls.insert(key) { + return Err(Error::DuplicateBuilderAuth(url.clone())); + } + } + + Ok(()) + } +} + +impl<'a> IntoIterator for &'a BuilderConfigFile { + type Item = &'a BuilderDefinition; + type IntoIter = std::slice::Iter<'a, BuilderDefinition>; + + fn into_iter(self) -> Self::IntoIter { + self.builders.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_data_round_trips_as_hex() { + let definition = BuilderDefinition { + enabled: true, + url: "http://builder.example.com".parse().unwrap(), + auth_data: Some(RequestAuthData::new(b"hello".to_vec()).unwrap()), + builder_pubkeys: vec![], + max_execution_payment: 1, + min_bid: None, + builder_boost_factor: None, + }; + + let yaml = yaml_serde::to_string(&definition).unwrap(); + // "hello" is 0x68656c6c6f, a hex string — not a YAML sequence of byte values. + assert!( + yaml.contains("0x68656c6c6f"), + "auth_data not hex-encoded:\n{yaml}" + ); + + let decoded: BuilderDefinition = yaml_serde::from_str(&yaml).unwrap(); + assert_eq!(decoded, definition); + } + + #[test] + fn omits_none_optional_fields() { + let definition = BuilderDefinition { + enabled: true, + url: "http://builder.example.com".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: 1, + min_bid: None, + builder_boost_factor: None, + }; + let yaml = yaml_serde::to_string(&definition).unwrap(); + for field in [ + "auth_data", + "builder_pubkeys", + "min_bid", + "builder_boost_factor", + ] { + assert!( + !yaml.contains(field), + "unset `{field}` should be omitted:\n{yaml}" + ); + } + } +} diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs new file mode 100644 index 00000000000..d17c3c73260 --- /dev/null +++ b/validator_client/builder_store/src/lib.rs @@ -0,0 +1,140 @@ +mod builder_definitions; +use builder_definitions::BuilderConfigFile; +pub use builder_definitions::{BuilderDefinition, Error}; +use builder_types::{ + BuilderConfig, BuilderEntry, BuilderPubkeys, RequestAuthData, SignedRequestAuth, +}; +use parking_lot::RwLock; +use ssz_types::VariableList; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tracing::error; + +#[derive(Clone)] +pub struct BuilderStore { + config: Arc>, + validators_dir: PathBuf, +} + +impl BuilderStore { + pub fn open_or_create>(validators_dir: P) -> Result { + let validators_dir = validators_dir.as_ref().to_path_buf(); + + Ok(Self { + config: Arc::new(RwLock::new(BuilderConfigFile::open_or_create( + &validators_dir, + )?)), + validators_dir, + }) + } + + /// Resolve the enabled builders into a wire [`BuilderConfig`], signing each builder's request + /// auth via `sign`. + /// + /// Per-builder `min_bid`/`builder_boost_factor` inherit the global defaults when unset, and each + /// builder's `auth_data` defaults to the UTF-8 bytes of its URL when unset. `sign` receives a + /// builder's opaque auth `data` and returns the corresponding `SignedRequestAuth` — in + /// practice signed for the current proposer/slot and cached. + /// + /// Signing is per-builder: a builder whose auth `sign` fails to produce is logged (with the + /// returned error) and omitted, so one unsignable builder cannot drop the rest. The returned + /// config always carries the global policy; its `builders` list holds only the successfully + /// signed builders, and is empty when no builders are enabled or every one failed to sign. + pub async fn builder_config(&self, sign: F) -> BuilderConfig + where + F: Fn(RequestAuthData) -> Fut, + Fut: Future>, + E: std::fmt::Debug, + { + // Snapshot the enabled builders and the global policy under the lock, then sign outside it, + // so the lock is never held across an `.await`. + let (definitions, min_bid, builder_boost_factor) = { + let config = self.config.read(); + let definitions: Vec = config + .as_slice() + .iter() + .filter(|d| d.enabled) + .cloned() + .collect(); + (definitions, config.min_bid, config.builder_boost_factor) + }; + + // Sign every builder's request auth concurrently. With a remote signer each `sign` is a + // network round trip, and the signatures are independent, so signing in sequence would put + // up to `MaxBuilderEntries` serial round trips on the block-production critical path. + let signed = futures::future::join_all(definitions.into_iter().filter_map(|definition| { + let auth_data = definition + .auth_data + .clone() + .unwrap_or_else(|| definition.url.to_default_auth_data()); + // A zero-length auth `data` is invalid on the wire (beacon-specs #165 / beacon-APIs + // #630); the beacon node would reject the whole request body, so drop the builder here. + if auth_data.is_empty() { + error!( + builder_url = %definition.url, + "Zero-length auth_data is invalid; omitting builder from config" + ); + return None; + } + let signing = sign(auth_data); + Some(async move { (definition, signing.await) }) + })) + .await; + + // `join_all` preserves input order, so `builders` keeps the configured order. Omit any + // builder we cannot sign for, logging the error, rather than failing the whole config. + let mut builders = Vec::with_capacity(signed.len()); + for (definition, result) in signed { + let auth = match result { + Ok(auth) => auth, + Err(e) => { + error!( + error = ?e, + builder_url = %definition.url, + "Failed to sign builder request auth; omitting builder from config" + ); + continue; + } + }; + let Ok(builder_pubkeys) = BuilderPubkeys::new(definition.builder_pubkeys) else { + error!( + builder_url = %definition.url, + "Too many builder pubkeys; omitting builder from config" + ); + continue; + }; + builders.push(BuilderEntry { + url: definition.url, + auth, + builder_pubkeys, + max_execution_payment: definition.max_execution_payment, + min_bid: definition.min_bid.unwrap_or(min_bid), + builder_boost_factor: definition + .builder_boost_factor + .unwrap_or(builder_boost_factor), + }); + } + + BuilderConfig { + // The number of builders is bounded by `MaxBuilderEntries` at config load, so this + // cannot overflow. + builders: VariableList::new(builders) + .expect("builder count is bounded by MaxBuilderEntries at config load"), + min_bid, + builder_boost_factor, + } + } + + pub fn insert(&self, builder: BuilderDefinition) -> Result<(), Error> { + let mut config = self.config.write(); + // Validate a candidate copy before committing, so a bad insert leaves the config unchanged + // (and the global bid-policy defaults are preserved). + let mut candidate = config.clone(); + candidate.push(builder); + candidate.validate()?; + + *config = candidate; + config.save(&self.validators_dir) + } +} diff --git a/validator_client/lighthouse_validator_store/Cargo.toml b/validator_client/lighthouse_validator_store/Cargo.toml index 55d5f1cf32e..2020280e0bc 100644 --- a/validator_client/lighthouse_validator_store/Cargo.toml +++ b/validator_client/lighthouse_validator_store/Cargo.toml @@ -8,6 +8,7 @@ authors = ["Sigma Prime "] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_types = { workspace = true } doppelganger_service = { workspace = true } either = { workspace = true } environment = { workspace = true } diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index ce2b85f3af5..d98e80cf454 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -1,5 +1,6 @@ use account_utils::validator_definitions::{PasswordStorage, ValidatorDefinition}; use bls::{AggregateSignature, PublicKeyBytes, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use doppelganger_service::DoppelgangerService; use eth2::types::PublishBlockRequest; use futures::{Stream, future::join_all, stream}; @@ -1502,4 +1503,28 @@ impl ValidatorStore for LighthouseValidatorS signature, }) } + + async fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> Result { + let domain_hash = self.spec.get_request_auth_domain(); + let signing_root = request_auth_v1.signing_root(domain_hash); + + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + let signature = signing_method + .get_signature_from_root::>( + SignableMessage::RequestAuth(&request_auth_v1), + signing_root, + &self.task_executor, + None, + ) + .await?; + + Ok(SignedRequestAuth { + message: request_auth_v1, + signature, + }) + } } diff --git a/validator_client/signing_method/Cargo.toml b/validator_client/signing_method/Cargo.toml index cb321c2d498..2a33382d5e8 100644 --- a/validator_client/signing_method/Cargo.toml +++ b/validator_client/signing_method/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } eth2_keystore = { workspace = true } ethereum_serde_utils = { workspace = true } lockfile = { workspace = true } diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index 0dfde989464..f877afeaa7e 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -4,6 +4,7 @@ //! - Via a remote signer (Web3Signer) use bls::{Keypair, PublicKey, Signature}; +use builder_types::RequestAuth; use eth2_keystore::Keystore; use lockfile::Lockfile; use parking_lot::Mutex; @@ -52,6 +53,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + RequestAuth(&'a RequestAuth), } impl> SignableMessage<'_, E, Payload> { @@ -76,6 +78,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), SignableMessage::PayloadAttestationData(d) => d.signing_root(domain), SignableMessage::ProposerPreferences(p) => p.signing_root(domain), + SignableMessage::RequestAuth(r) => r.signing_root(domain), } } } @@ -248,6 +251,7 @@ impl SigningMethod { SignableMessage::ProposerPreferences(p) => { Web3SignerObject::ProposerPreferences(p) } + SignableMessage::RequestAuth(r) => Web3SignerObject::RequestAuth(r), }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index 8548a933e66..505147a46d4 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -2,6 +2,7 @@ use super::Error; use bls::{PublicKeyBytes, Signature}; +use builder_types::RequestAuth; use serde::{Deserialize, Serialize}; use types::*; @@ -23,6 +24,7 @@ pub enum MessageType { ExecutionPayloadEnvelope, PayloadAttestation, ProposerPreferences, + RequestAuth, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -83,6 +85,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + RequestAuth(&'a RequestAuth), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -156,6 +159,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation, Web3SignerObject::ProposerPreferences(_) => MessageType::ProposerPreferences, + Web3SignerObject::RequestAuth(_) => MessageType::RequestAuth, } } } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 88844918431..7697a08c45b 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; use crate::cli::ValidatorClient; use crate::duties_service::SelectionProofConfig; +use builder_store::BuilderStore; pub use config::Config; use initialized_validators::InitializedValidators; use metrics::set_gauge; @@ -43,11 +44,13 @@ use validator_services::notifier_service::spawn_notifier; use validator_services::{ attestation_service::{AttestationService, AttestationServiceBuilder}, block_service::{BlockService, BlockServiceBuilder}, + builder_preferences_service::BuilderPreferencesService, duties_service::{self, DutiesService, DutiesServiceBuilder}, latency_service, payload_attestation_service::PayloadAttestationService, preparation_service::{PreparationService, PreparationServiceBuilder}, proposer_preferences_service::ProposerPreferencesService, + request_auth_cache::RequestAuthCache, sync_committee_service::SyncCommitteeService, }; use validator_store::ValidatorStore as ValidatorStoreTrait; @@ -91,6 +94,7 @@ pub struct ProductionValidatorClient { doppelganger_service: Option>, preparation_service: PreparationService, SystemTimeSlotClock>, validator_store: Arc>, + builder_preferences_service: BuilderPreferencesService, SystemTimeSlotClock>, slot_clock: SystemTimeSlotClock, http_api_listen_addr: Option, config: Config, @@ -513,6 +517,10 @@ impl ProductionValidatorClient { ctx.shared.write().duties_service = Some(duties_service.clone()); } + let configured_builders = BuilderStore::open_or_create(&config.validator_dir) + .map_err(|e| format!("Unable to open or create builder definitions: {:?}", e))?; + let request_auth_cache = RequestAuthCache::default(); + let mut block_service_builder = BlockServiceBuilder::new() .slot_clock(slot_clock.clone()) .validator_store(validator_store.clone()) @@ -521,7 +529,9 @@ impl ProductionValidatorClient { .chain_spec(context.eth2_config.spec.clone()) .graffiti(config.graffiti) .graffiti_file(config.graffiti_file.clone()) - .graffiti_policy(config.graffiti_policy); + .graffiti_policy(config.graffiti_policy) + .configured_builders(configured_builders.clone()) + .request_auth_cache(request_auth_cache.clone()); // If we have proposer nodes, add them to the block service builder. if proposer_nodes_num > 0 { @@ -577,6 +587,17 @@ impl ProductionValidatorClient { context.eth2_config.spec.clone(), ); + let builder_preferences_service = BuilderPreferencesService::new( + duties_service.clone(), + validator_store.clone(), + slot_clock.clone(), + beacon_nodes.clone(), + configured_builders.clone(), + request_auth_cache.clone(), + context.executor.clone(), + context.eth2_config.spec.clone(), + ); + Ok(Self { context, duties_service, @@ -588,6 +609,7 @@ impl ProductionValidatorClient { doppelganger_service, preparation_service, validator_store, + builder_preferences_service, config, slot_clock, http_api_listen_addr: None, @@ -667,6 +689,11 @@ impl ProductionValidatorClient { .clone() .start_update_service() .map_err(|e| format!("Unable to start proposer preferences service: {}", e))?; + + self.builder_preferences_service + .clone() + .start_update_service() + .map_err(|e| format!("Unable to start builder preferences service: {}", e))?; } self.preparation_service diff --git a/validator_client/validator_services/Cargo.toml b/validator_client/validator_services/Cargo.toml index 625eee85bdb..5fb3137057e 100644 --- a/validator_client/validator_services/Cargo.toml +++ b/validator_client/validator_services/Cargo.toml @@ -7,6 +7,8 @@ authors = ["Sigma Prime "] [dependencies] beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_store = { workspace = true } +builder_types = { workspace = true } either = { workspace = true } eth2 = { workspace = true } futures = { workspace = true } diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 7531543a187..b2cfa138e88 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -1,5 +1,7 @@ +use crate::request_auth_cache::RequestAuthCache; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, Error as FallbackError, Errors}; use bls::PublicKeyBytes; +use builder_store::BuilderStore; use eth2::BeaconNodeHttpClient; use eth2::types::GraffitiPolicy; use graffiti_file::{GraffitiFile, determine_graffiti}; @@ -53,6 +55,8 @@ pub struct BlockServiceBuilder { graffiti: Option, graffiti_file: Option, graffiti_policy: Option, + configured_builders: Option, + request_auth_cache: Option, } impl BlockServiceBuilder { @@ -67,6 +71,8 @@ impl BlockServiceBuilder { graffiti: None, graffiti_file: None, graffiti_policy: None, + configured_builders: None, + request_auth_cache: None, } } @@ -115,6 +121,16 @@ impl BlockServiceBuilder { self } + pub fn configured_builders(mut self, configured_builders: BuilderStore) -> Self { + self.configured_builders = Some(configured_builders); + self + } + + pub fn request_auth_cache(mut self, request_auth_cache: RequestAuthCache) -> Self { + self.request_auth_cache = Some(request_auth_cache); + self + } + pub fn build(self) -> Result, String> { Ok(BlockService { inner: Arc::new(Inner { @@ -137,6 +153,12 @@ impl BlockServiceBuilder { graffiti: self.graffiti, graffiti_file: self.graffiti_file, graffiti_policy: self.graffiti_policy, + configured_builders: self + .configured_builders + .ok_or("Cannot build BlockService without configured_builders")?, + request_auth_cache: self + .request_auth_cache + .ok_or("Cannot build BlockService without request_auth_cache")?, }), }) } @@ -203,6 +225,11 @@ pub struct Inner { graffiti: Option, graffiti_file: Option, graffiti_policy: Option, + /// The configured builders to resolve into a `BuilderConfig` when producing a Gloas block. + configured_builders: BuilderStore, + /// Caches the per-(slot, proposer, auth_data) request-auth signatures reused when resolving the + /// builder config. + request_auth_cache: RequestAuthCache, } /// Attempts to produce attestations for any block producer(s) at the start of the epoch. @@ -339,6 +366,7 @@ impl BlockService { graffiti: Option, validator_pubkey: &PublicKeyBytes, unsigned_block: UnsignedBlock, + builder_url: Option, ) -> Result<(), BlockError> { let signing_timer = validator_metrics::start_timer(&validator_metrics::BLOCK_SIGNING_TIMES); @@ -383,9 +411,10 @@ impl BlockService { // Try the proposer nodes first, since we've likely gone to efforts to // protect them from DoS attacks and they're most likely to successfully // publish a block. + let builder_url_ref = builder_url.as_deref(); proposer_fallback .request_proposers_first(|beacon_node| async { - self.publish_signed_block_contents(&signed_block, beacon_node) + self.publish_signed_block_contents(&signed_block, beacon_node, builder_url_ref) .await }) .await?; @@ -463,7 +492,35 @@ impl BlockService { // Check if Gloas fork is active at this slot let fork_name = self_ref.chain_spec.fork_name_at_slot::(slot); - let (block_proposer, unsigned_block) = if fork_name.gloas_enabled() { + let (block_proposer, unsigned_block, builder_url) = if fork_name.gloas_enabled() { + // Resolve the validator's builder config for this proposal, signing each builder's + // request auth via the cache. Sent in the POST `produceBlockV4` body below (the same + // body is reused on the SSZ-to-JSON fallback and on every proposer-fallback BN). With + // no builders configured this resolves to an empty list, so the proposal still falls + // back to a local or p2p payload. Per-builder sign failures are logged and omitted + // inside `builder_config`, so this never fails the proposal. + let builder_config = self_ref + .configured_builders + .builder_config(|auth_data| { + self_ref.request_auth_cache.get_or_sign( + slot, + validator_pubkey, + auth_data, + |request_auth_v1| { + self_ref + .validator_store + .sign_request_auth_v1(validator_pubkey, request_auth_v1) + }, + ) + }) + .await; + debug!( + slot = slot.as_u64(), + builders = builder_config.builders.len(), + "Resolved builder config for block production" + ); + let builder_config_ref = &builder_config; + // Use V4 block production for Gloas // Request an SSZ block from all beacon nodes in order, returning on the first successful response. // If all nodes fail, run a second pass falling back to JSON. @@ -474,20 +531,25 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_GET], ); beacon_node - .get_validator_blocks_v4_ssz::( + .post_validator_blocks_v4_ssz::( slot, randao_reveal_ref, graffiti.as_ref(), false, - builder_boost_factor, + builder_config_ref, self_ref.graffiti_policy, + fork_name, ) .await }) .await; - let block_response = match ssz_block_response { - Ok((ssz_block_response, _metadata)) => ssz_block_response.into_block(), + // `builder_url` is the `Eth-Builder-Url` from the winning beacon node — echoed on publish + // so it forwards the block to the builder that won selection. + let (block_response, builder_url) = match ssz_block_response { + Ok((ssz_block_response, metadata)) => { + (ssz_block_response.into_block(), metadata.builder_url) + } Err(e) => { warn!( slot = slot.as_u64(), @@ -501,14 +563,15 @@ impl BlockService { &validator_metrics::BLOCK_SERVICE_TIMES, &[validator_metrics::BEACON_BLOCK_HTTP_GET], ); - let (json_block_response, _metadata) = beacon_node - .get_validator_blocks_v4::( + let (json_block_response, metadata) = beacon_node + .post_validator_blocks_v4::( slot, randao_reveal_ref, graffiti.as_ref(), false, - builder_boost_factor, + builder_config_ref, self_ref.graffiti_policy, + fork_name, ) .await .map_err(|e| { @@ -518,7 +581,7 @@ impl BlockService { )) })?; - Ok(json_block_response.into_block()) + Ok((json_block_response.into_block(), metadata.builder_url)) }) .await .map_err(BlockError::from)? @@ -530,6 +593,7 @@ impl BlockService { ( block_contents.block().proposer_index(), UnsignedBlock::Full(block_contents), + builder_url, ) } else { // Use V3 block production for pre-Gloas forks @@ -594,12 +658,16 @@ impl BlockService { } }; + // Pre-Gloas has no builder-URL provenance (the V3 mev-boost path handles builder + // forwarding itself), so there's nothing to echo on publish. match block_response { - eth2::types::ProduceBlockV3Response::Full(block) => { - (block.block().proposer_index(), UnsignedBlock::Full(block)) - } + eth2::types::ProduceBlockV3Response::Full(block) => ( + block.block().proposer_index(), + UnsignedBlock::Full(block), + None, + ), eth2::types::ProduceBlockV3Response::Blinded(block) => { - (block.proposer_index(), UnsignedBlock::Blinded(block)) + (block.proposer_index(), UnsignedBlock::Blinded(block), None) } } }; @@ -623,6 +691,7 @@ impl BlockService { graffiti, &validator_pubkey, unsigned_block, + builder_url, ) .await?; @@ -742,6 +811,7 @@ impl BlockService { &self, signed_block: &SignedBlock, beacon_node: BeaconNodeHttpClient, + builder_url: Option<&str>, ) -> Result<(), BlockError> { match signed_block { SignedBlock::Full(signed_block) => { @@ -750,7 +820,7 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_POST], ); beacon_node - .post_beacon_blocks_v2_ssz(signed_block, None, None) + .post_beacon_blocks_v2_ssz(signed_block, None, builder_url) .await .map(|_| ()) .or_else(|e| { @@ -854,6 +924,10 @@ mod tests { .beacon_nodes(harness.beacon_nodes.clone()) .executor(harness.test_runtime.task_executor.clone()) .chain_spec(harness.spec.clone()) + .request_auth_cache(RequestAuthCache::default()) + .configured_builders( + BuilderStore::open_or_create(harness._validator_dir.path()).unwrap(), + ) .build() .unwrap(); @@ -880,7 +954,11 @@ mod tests { let mock_different_slot = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, different_notification_slot); + .mock_post_validator_blocks_v4_ssz( + &block, + ForkName::Gloas, + different_notification_slot, + ); test_harness .service @@ -902,7 +980,7 @@ mod tests { let mock_same_slot = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, same_notification_slot); + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, same_notification_slot); test_harness .service @@ -937,7 +1015,7 @@ mod tests { test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); let mock_post_block = test_harness .harness .mock_beacon_node_1 @@ -1000,11 +1078,11 @@ mod tests { let mock_bn_1 = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_bn_2 = test_harness .harness .mock_beacon_node_2 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_post_block = test_harness .harness @@ -1048,11 +1126,11 @@ mod tests { let mock_ssz = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_json = test_harness .harness .mock_beacon_node_2 - .mock_get_validator_blocks_v4(&block, ForkName::Gloas, slot); + .mock_post_validator_blocks_v4(&block, ForkName::Gloas, slot); let _result = test_harness .service diff --git a/validator_client/validator_services/src/builder_preferences_service.rs b/validator_client/validator_services/src/builder_preferences_service.rs new file mode 100644 index 00000000000..203c236588b --- /dev/null +++ b/validator_client/validator_services/src/builder_preferences_service.rs @@ -0,0 +1,308 @@ +use crate::duties_service::DutiesService; +use crate::request_auth_cache::RequestAuthCache; +use beacon_node_fallback::BeaconNodeFallback; +use bls::PublicKeyBytes; +use builder_store::BuilderStore; +use builder_types::{BuilderEntry, BuilderUrl, RequestAuthData}; +use eth2::types::{ + BuilderPreferenceEntry, MAX_SUBMITTED_BUILDER_PREFERENCES, SubmittedBuilderPreferences, +}; +use slot_clock::SlotClock; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; +use task_executor::TaskExecutor; +use tokio::time::sleep; +use tracing::{debug, error, info}; +use types::{ChainSpec, EthSpec, Slot}; +use validator_store::ValidatorStore; + +/// The non-slot part of a published entry's identity: the proposer pubkey plus the decomposed +/// `BuilderPreferenceEntry` with its `slot` factored out to the enclosing map's key. +/// - `pubkey`: the proposer the entry was submitted for +/// - `url`: `entry.url` +/// - `auth_data`: `entry.auth.message.data` +/// - `max_execution_payment`: `entry.max_execution_payment` +/// +/// See [`PublishedBuilderPreferencesCache`] for how `entry.auth` decomposes into `auth_data` here +/// and `slot` at the map level, and why the `auth` signature is dropped. +#[derive(PartialEq, Eq, Hash)] +struct InnerPreferencesKey { + pubkey: PublicKeyBytes, + url: BuilderUrl, + auth_data: RequestAuthData, + max_execution_payment: u64, +} + +/// De-duplicates the `BuilderPreferenceEntry`s we've already published, so we don't re-send one. +/// +/// The identity of a published entry is `(proposer_pubkey, decompose(entry))`. That decomposition is +/// split across the two levels of this map: +/// - `entry.auth.message.slot` becomes the outer `BTreeMap` key; +/// - the rest — `proposer_pubkey`, `entry.url`, `entry.auth.message.data`, and +/// `entry.max_execution_payment` — forms the [`InnerPreferencesKey`] held in the per-slot set. +/// +/// So `entry.auth` decomposes into its `slot` (the map key) and its `data`/`auth_data` (in the inner +/// key); the `auth` signature is dropped, as it is a deterministic function of the proposer, the +/// `auth_data`, and the slot and so adds no identity. +/// +/// Operators may change their builder config at any time. Because this identity captures every entry +/// field that reaches a builder, any edit yields a new key that won't match a previously-sent entry, +/// so the updated preference is published again. +#[derive(Default)] +struct PublishedBuilderPreferencesCache { + cache: BTreeMap>, +} + +impl PublishedBuilderPreferencesCache { + pub fn new() -> Self { + Self::default() + } + + pub fn contains( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + builder_entry: &BuilderEntry, + ) -> bool { + self.cache.get(&slot).is_some_and(|set| { + set.contains(&InnerPreferencesKey { + pubkey, + url: builder_entry.url.clone(), + auth_data: builder_entry.auth.message.data.clone(), + max_execution_payment: builder_entry.max_execution_payment, + }) + }) + } + + pub fn mark_sent( + &mut self, + pubkey: PublicKeyBytes, + builder_preferences_entry: BuilderPreferenceEntry, + ) { + let slot = builder_preferences_entry.auth.message.slot; + let inner_key = InnerPreferencesKey { + pubkey, + url: builder_preferences_entry.url, + auth_data: builder_preferences_entry.auth.message.data, + max_execution_payment: builder_preferences_entry.max_execution_payment, + }; + self.cache.entry(slot).or_default().insert(inner_key); + } + + pub fn prune(&mut self, current_slot: Slot) { + self.cache = self.cache.split_off(¤t_slot); + } +} + +// Minimizes `Arc` usage +struct Inner { + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + configured_builders: BuilderStore, + request_auth_cache: RequestAuthCache, + executor: TaskExecutor, + chain_spec: Arc, +} + +pub struct BuilderPreferencesService { + inner: Arc>, +} + +// Generic clone implementation is too dumb to do this +impl Clone for BuilderPreferencesService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl BuilderPreferencesService { + #[allow(clippy::too_many_arguments)] + pub fn new( + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + configured_builders: BuilderStore, + request_auth_cache: RequestAuthCache, + executor: TaskExecutor, + chain_spec: Arc, + ) -> Self { + Self { + inner: Arc::new(Inner { + duties_service, + validator_store, + slot_clock, + beacon_nodes, + configured_builders, + request_auth_cache, + executor, + chain_spec, + }), + } + } + + pub fn start_update_service(self) -> Result<(), String> { + let slot_duration = self.inner.chain_spec.get_slot_duration(); + info!("Builder preferences service started"); + + let executor = self.inner.executor.clone(); + + let interval_fut = async move { + let mut published_preferences = PublishedBuilderPreferencesCache::new(); + + loop { + let Some(current_slot) = self.inner.slot_clock.now() else { + error!("Failed to read slot clock"); + sleep(slot_duration).await; + continue; + }; + + self.poll_and_publish_preferences(current_slot, &mut published_preferences) + .await; + + published_preferences.prune(current_slot); + self.inner.request_auth_cache.prune(current_slot); + + let duration_to_next_slot = self + .inner + .slot_clock + .duration_to_next_slot() + .unwrap_or(slot_duration); + sleep(duration_to_next_slot).await; + } + }; + + executor.spawn(interval_fut, "builder_preferences_service"); + Ok(()) + } + + /// Publish builder preferences for `current_epoch` and `current_epoch + 1`. + /// Will only publish a given `(proposer, builder, max_execution_payment)` preference once. + async fn poll_and_publish_preferences( + &self, + current_slot: Slot, + published_preferences: &mut PublishedBuilderPreferencesCache, + ) { + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + // One flat request whose body spans both epochs, each entry naming its own proposer + // (beacon-APIs #630, whose body is sized for several epochs of entries). The single + // `Eth-Consensus-Version` is the version active now, at submission time. + let current_fork = self.inner.chain_spec.fork_name_at_epoch(current_epoch); + let mut pending_entries: Vec = Vec::new(); + + for (epoch, fork_name) in [ + ( + current_epoch, + self.inner.chain_spec.fork_name_at_epoch(current_epoch), + ), + ( + current_epoch + 1, + self.inner.chain_spec.fork_name_at_epoch(current_epoch + 1), + ), + ] { + if !fork_name.gloas_enabled() { + continue; + } + + let proposers = match self.inner.duties_service.proposers.read().get(&epoch) { + Some((_, proposers)) => proposers.clone(), + None => continue, + }; + + for proposer_data in &proposers { + let slot = proposer_data.slot; + let pubkey = proposer_data.pubkey; + + // Resolve and sign the whole builder config for this proposer/slot. Auths are + // cached, so builders already published for this slot cost only a cache hit. + // Per-builder sign failures are logged and omitted inside `builder_config`, so a + // fully-failed set just yields an empty `builders` list (nothing to publish). + let config = self + .inner + .configured_builders + .builder_config(|auth_data| { + self.inner.request_auth_cache.get_or_sign( + slot, + pubkey, + auth_data, + |request_auth_v1| { + self.inner + .validator_store + .sign_request_auth_v1(pubkey, request_auth_v1) + }, + ) + }) + .await; + + // A `BuilderPreferenceEntry` is a `BuilderEntry` narrowed to what a builder may see: + // its private `min_bid`/`builder_boost_factor`/`builder_pubkeys` are dropped. + for entry in config.builders.iter() { + if published_preferences.contains(slot, pubkey, entry) { + // already published, skip + continue; + } + pending_entries.push(BuilderPreferenceEntry::from_builder_entry( + pubkey, + entry.clone(), + )); + } + } + } + + if pending_entries.is_empty() { + return; + } + + // One submission carries at most `MAX_SUBMITTED_BUILDER_PREFERENCES` entries (beacon-APIs + // #630), so submit in bounded chunks. Each chunk is best-effort: a failed chunk is logged + // and does not stop the rest. + for chunk in pending_entries.chunks(MAX_SUBMITTED_BUILDER_PREFERENCES) { + let Ok(entries) = SubmittedBuilderPreferences::new(chunk.to_vec()) else { + // Unreachable: `chunks()` bounds each chunk by the list limit. + continue; + }; + let entries_ref = &entries; + + // Try SSZ first, falling back to JSON. `first_success` is okay here because later + // we'll be resending the auths when we publish the beacon block. + let ssz_result = self + .inner + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_builder_preferences_ssz(entries_ref, current_fork) + .await + }) + .await; + + let result = match ssz_result { + Ok(()) => Ok(()), + Err(ssz_err) => { + debug!(error = %ssz_err, "SSZ builder preferences publish failed, falling back to JSON"); + self.inner + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_builder_preferences(entries_ref, current_fork) + .await + }) + .await + } + }; + + match result { + Ok(()) => { + for entry in entries.iter().cloned() { + let pubkey = entry.proposer_pubkey; + published_preferences.mark_sent(pubkey, entry); + } + } + Err(e) => error!(error = %e, "Failed to publish builder preferences"), + } + } + } +} diff --git a/validator_client/validator_services/src/lib.rs b/validator_client/validator_services/src/lib.rs index c39ef4499b7..3db106ac692 100644 --- a/validator_client/validator_services/src/lib.rs +++ b/validator_client/validator_services/src/lib.rs @@ -1,10 +1,12 @@ pub mod attestation_service; pub mod block_service; +pub mod builder_preferences_service; pub mod duties_service; pub mod latency_service; pub mod notifier_service; pub mod payload_attestation_service; pub mod preparation_service; pub mod proposer_preferences_service; +pub mod request_auth_cache; pub mod sync; pub mod sync_committee_service; diff --git a/validator_client/validator_services/src/request_auth_cache.rs b/validator_client/validator_services/src/request_auth_cache.rs new file mode 100644 index 00000000000..4d9e553d8be --- /dev/null +++ b/validator_client/validator_services/src/request_auth_cache.rs @@ -0,0 +1,106 @@ +use bls::PublicKeyBytes; +use builder_types::{RequestAuth, RequestAuthData, SignedRequestAuth}; +use parking_lot::RwLock; +use std::collections::{BTreeMap, HashMap}; +use std::future::Future; +use std::sync::Arc; +use types::Slot; + +/// Caches signed `RequestAuth` objects so a given proposer/auth-data/slot combination is only +/// signed once. +/// +/// The signed authorization is a pure function of the proposer pubkey, the opaque `auth_data`, and +/// the proposal `slot`, so those form the cache key. The builder URL is deliberately *not* part of +/// the key: two builders configured with the same `auth_data` share one signature. +#[derive(Hash, PartialEq, Eq)] +struct RequestAuthInnerKey { + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, +} + +#[derive(Default)] +struct Inner { + entries: BTreeMap>, +} + +#[derive(Clone)] +pub struct RequestAuthCache { + inner: Arc>, +} + +impl Default for RequestAuthCache { + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(Inner::default())), + } + } +} + +impl RequestAuthCache { + pub fn get( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: &RequestAuthData, + ) -> Option { + self.inner.read().entries.get(&slot).and_then(|entries| { + let key = RequestAuthInnerKey { + pubkey, + auth_data: auth_data.clone(), + }; + entries.get(&key).cloned() + }) + } + + pub fn insert( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, + signed_request_auth: SignedRequestAuth, + ) { + let key = RequestAuthInnerKey { pubkey, auth_data }; + + self.inner + .write() + .entries + .entry(slot) + .or_default() + .insert(key, signed_request_auth); + } + + /// Return the cached signature for `(slot, pubkey, auth_data)`, or produce it via `sign` (and + /// cache the result) on a miss. + /// + /// The signature is a pure function of the proposer, `auth_data`, and slot, so a hit returns + /// immediately without invoking `sign`. `sign` receives the fully-formed `RequestAuth` to + /// sign — in practice `ValidatorStore::sign_request_auth_v1`. + pub async fn get_or_sign( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, + sign: F, + ) -> Result + where + F: FnOnce(RequestAuth) -> Fut, + Fut: Future>, + { + if let Some(signed) = self.get(slot, pubkey, &auth_data) { + return Ok(signed); + } + + let signed = sign(RequestAuth { + data: auth_data.clone(), + slot, + }) + .await?; + self.insert(slot, pubkey, auth_data, signed.clone()); + Ok(signed) + } + + pub fn prune(&self, current_slot: Slot) { + let mut guard = self.inner.write(); + guard.entries = guard.entries.split_off(¤t_slot); + } +} diff --git a/validator_client/validator_store/Cargo.toml b/validator_client/validator_store/Cargo.toml index 2c6a68d4949..092f927589f 100644 --- a/validator_client/validator_store/Cargo.toml +++ b/validator_client/validator_store/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } eth2 = { workspace = true } futures = { workspace = true } slashing_protection = { workspace = true } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index dde82a2a5bb..6b2257af198 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -1,4 +1,5 @@ use bls::{PublicKeyBytes, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use eth2::types::{FullBlockContents, PublishBlockRequest}; use futures::Stream; use slashing_protection::NotSafe; @@ -213,6 +214,12 @@ pub trait ValidatorStore: Send + Sync { preferences: ProposerPreferences, ) -> impl Future>> + Send; + fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> impl Future>> + Send; + /// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`. /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. diff --git a/wordlist.txt b/wordlist.txt index f0076e63322..1fd0e7603d9 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -108,6 +108,7 @@ UI Uncached UPnP USD +UTF UX Validator VC @@ -150,6 +151,7 @@ doppelgänger dropdown else's env +ePBS eth ethdo ethereum From 9121df2ccf85e5ef618a0e6cc3bb1ec0c7b509e7 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Mon, 31 Aug 2026 04:42:17 +0000 Subject: [PATCH 04/11] Skip envelope fetch for external builder blocks Changes: Skip validator payload-envelope handling after external-builder block publication. Cover self-build and external-builder envelope behaviour. --- .../validator_services/src/block_service.rs | 71 ++++++++++++++++--- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index b2cfa138e88..4def2287d1a 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -16,7 +16,9 @@ use std::time::Duration; use task_executor::TaskExecutor; use tokio::sync::mpsc; use tracing::{Instrument, debug, error, info, info_span, instrument, trace, warn}; -use types::{BlockType, ChainSpec, EthSpec, Graffiti, Hash256, Slot}; +use types::{ + BlockType, ChainSpec, EthSpec, Graffiti, Hash256, Slot, consts::gloas::BUILDER_INDEX_SELF_BUILD, +}; use validator_store::{Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore}; #[derive(Debug)] @@ -492,6 +494,7 @@ impl BlockService { // Check if Gloas fork is active at this slot let fork_name = self_ref.chain_spec.fork_name_at_slot::(slot); + let mut publish_envelope = false; let (block_proposer, unsigned_block, builder_url) = if fork_name.gloas_enabled() { // Resolve the validator's builder config for this proposal, signing each builder's // request auth via the cache. Sent in the POST `produceBlockV4` body below (the same @@ -588,6 +591,14 @@ impl BlockService { } }; + publish_envelope = block_response + .body() + .signed_execution_payload_bid() + // Preserve the existing envelope error path for malformed Gloas responses. + .map_or(true, |bid| { + bid.message.builder_index == BUILDER_INDEX_SELF_BUILD + }); + // Gloas blocks don't have blobs (they're in the execution layer) let block_contents = eth2::types::FullBlockContents::Block(block_response); ( @@ -680,9 +691,7 @@ impl BlockService { } // Capture before `sign_and_publish_block` moves `unsigned_block`. - let produced_block_root = fork_name - .gloas_enabled() - .then(|| unsigned_block.block_root()); + let produced_block_root = publish_envelope.then(|| unsigned_block.block_root()); self_ref .sign_and_publish_block( @@ -695,9 +704,6 @@ impl BlockService { ) .await?; - // TODO(gloas) we only need to fetch, sign and publish the envelope in the local building case. - // Right now we always default to local building. Once we implement trustless/trusted builder logic - // we should check the bid for index == BUILDER_INDEX_SELF_BUILD if let Some(beacon_block_root) = produced_block_root { self_ref .fetch_sign_and_publish_payload_envelope( @@ -901,7 +907,10 @@ mod tests { use super::*; use slot_clock::ManualSlotClock; use std::time::Duration; - use types::{BeaconBlock, ExecutionPayloadEnvelope, ForkName, Slot}; + use types::{ + BeaconBlock, ExecutionPayloadEnvelope, ForkName, Slot, + consts::gloas::BUILDER_INDEX_SELF_BUILD, + }; use validator_test_rig::validator_client_harness::{S, ValidatorClientHarness}; struct TestHarness { @@ -1009,7 +1018,15 @@ mod tests { let slot = Slot::new(1); let validator_pubkey = test_harness.harness.pubkeys[0]; - let block = BeaconBlock::empty(&test_harness.harness.spec); + let mut block = BeaconBlock::empty(&test_harness.harness.spec); + let BeaconBlock::Gloas(gloas_block) = &mut block else { + panic!("expected Gloas block"); + }; + gloas_block + .body + .signed_execution_payload_bid + .message + .builder_index = BUILDER_INDEX_SELF_BUILD; let envelope = ExecutionPayloadEnvelope::empty(); test_harness @@ -1066,6 +1083,42 @@ mod tests { assert_eq!(received_envelopes.len(), 1, "Expected one envelope"); } + #[tokio::test] + async fn external_builder_block_does_not_fetch_payload_envelope() { + let mut test_harness = TestHarness::new_with_validators(1).await; + + let slot = Slot::new(1); + let validator_pubkey = test_harness.harness.pubkeys[0]; + let block = BeaconBlock::empty(&test_harness.harness.spec); + + test_harness + .harness + .mock_beacon_node_1 + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); + let mock_post_block = test_harness + .harness + .mock_beacon_node_1 + .mock_post_beacon_blocks_v2_ssz(ForkName::Gloas); + let mock_post_envelope = test_harness + .harness + .mock_beacon_node_1 + .mock_post_beacon_execution_payload_envelope_ssz(); + + let result = test_harness + .service + .clone() + .get_validator_block_and_publish_block(slot, validator_pubkey, None) + .await; + + assert!( + result.is_ok(), + "Block production failed: {:?}", + result.err() + ); + mock_post_block.expect(1).assert(); + mock_post_envelope.expect(0).assert(); + } + #[tokio::test] async fn get_validator_block_and_publish_block_fails() { let mut test_harness = TestHarness::new_with_validators(1).await; From cce904bf560d9c88be01c8e7bc7893c88d307573 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Mon, 31 Aug 2026 05:28:50 +0000 Subject: [PATCH 05/11] Clarify payload envelope handling --- .../validator_services/src/block_service.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 4def2287d1a..7162362cf9d 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -594,7 +594,8 @@ impl BlockService { publish_envelope = block_response .body() .signed_execution_payload_bid() - // Preserve the existing envelope error path for malformed Gloas responses. + // If the response is not a Gloas block, keep envelope handling enabled so the + // invalid response does not silently succeed. .map_or(true, |bid| { bid.message.builder_index == BUILDER_INDEX_SELF_BUILD }); @@ -691,7 +692,11 @@ impl BlockService { } // Capture before `sign_and_publish_block` moves `unsigned_block`. - let produced_block_root = publish_envelope.then(|| unsigned_block.block_root()); + let payload_envelope_block_root = if publish_envelope { + Some(unsigned_block.block_root()) + } else { + None + }; self_ref .sign_and_publish_block( @@ -704,7 +709,7 @@ impl BlockService { ) .await?; - if let Some(beacon_block_root) = produced_block_root { + if let Some(beacon_block_root) = payload_envelope_block_root { self_ref .fetch_sign_and_publish_payload_envelope( &proposer_fallback, From dc6e301a2de35867918f97f12616dfc33d95272d Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Mon, 31 Aug 2026 06:17:46 +0000 Subject: [PATCH 06/11] Reject non-Gloas block responses --- .../validator_services/src/block_service.rs | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 7162362cf9d..8aafd252750 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -591,14 +591,16 @@ impl BlockService { } }; - publish_envelope = block_response + let signed_execution_payload_bid = block_response .body() .signed_execution_payload_bid() - // If the response is not a Gloas block, keep envelope handling enabled so the - // invalid response does not silently succeed. - .map_or(true, |bid| { - bid.message.builder_index == BUILDER_INDEX_SELF_BUILD - }); + .map_err(|e| { + BlockError::Recoverable(format!( + "Gloas block response is missing its execution payload bid: {e:?}" + )) + })?; + publish_envelope = + signed_execution_payload_bid.message.builder_index == BUILDER_INDEX_SELF_BUILD; // Gloas blocks don't have blobs (they're in the execution layer) let block_contents = eth2::types::FullBlockContents::Block(block_response); @@ -913,7 +915,7 @@ mod tests { use slot_clock::ManualSlotClock; use std::time::Duration; use types::{ - BeaconBlock, ExecutionPayloadEnvelope, ForkName, Slot, + BeaconBlock, ExecutionPayloadEnvelope, ForkName, MainnetEthSpec, Slot, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; use validator_test_rig::validator_client_harness::{S, ValidatorClientHarness}; @@ -1124,6 +1126,40 @@ mod tests { mock_post_envelope.expect(0).assert(); } + #[tokio::test] + async fn non_gloas_block_response_is_not_published() { + let mut test_harness = TestHarness::new_with_validators(1).await; + + let slot = Slot::new(1); + let validator_pubkey = test_harness.harness.pubkeys[0]; + let mut pre_gloas_spec = test_harness.harness.spec.as_ref().clone(); + pre_gloas_spec.gloas_fork_epoch = None; + let block = BeaconBlock::empty(&pre_gloas_spec); + let response_fork = pre_gloas_spec.fork_name_at_slot::(slot); + assert!(block.body().signed_execution_payload_bid().is_err()); + + test_harness + .harness + .mock_beacon_node_1 + .mock_post_validator_blocks_v4_ssz(&block, response_fork, slot); + let mock_post_block = test_harness + .harness + .mock_beacon_node_1 + .mock_post_beacon_blocks_v2_ssz(ForkName::Gloas); + + let result = test_harness + .service + .clone() + .get_validator_block_and_publish_block(slot, validator_pubkey, None) + .await; + + let Err(BlockError::Recoverable(msg)) = result else { + panic!("Expected Recoverable error, got: {result:?}"); + }; + assert!(msg.contains("Gloas block response is missing its execution payload bid")); + mock_post_block.expect(0).assert(); + } + #[tokio::test] async fn get_validator_block_and_publish_block_fails() { let mut test_harness = TestHarness::new_with_validators(1).await; From 74a86e2908a2a312b0053ca7835fe66e6c1f573f Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:48:14 -0500 Subject: [PATCH 07/11] 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. From c9970dbd6f03e8b60862027281e105e1a468f537 Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:53:50 -0500 Subject: [PATCH 08/11] Convert produceBlockV4 to POST and round-trip Eth-Builder-Url (Gloas builder API 4/5) Fourth PR of the Gloas builder API stack (beacon-APIs #630): - convert `/eth/v4/validator/blocks/{slot}` to POST with an optional `BuilderConfig` body (min_bid, builder_boost_factor, direct builders) - add `POST /eth/v1/validator/builder_preferences` for forwarding signed builder preferences - set `Eth-Builder-Url` on produceBlockV4 responses when a direct-builder bid wins, accept it on `POST /eth/v2/beacon/blocks`, and forward the signed block to that builder The validator client still uses the legacy GET methods at this point; it migrates in the final PR of this stack. Change-Id: I0ad30b8f36ad9b588ea1a0398220f92c9597bb95 --- beacon_node/http_api/src/lib.rs | 40 +++- beacon_node/http_api/src/produce_block.rs | 49 ++-- beacon_node/http_api/src/publish_blocks.rs | 70 ++++++ beacon_node/http_api/src/validator/mod.rs | 217 +++++++++++++++++- beacon_node/http_api/src/version.rs | 13 +- .../tests/broadcast_validation_tests.rs | 4 +- .../http_api/tests/gloas_reorg_tests.rs | 12 +- .../http_api/tests/interactive_tests.rs | 10 +- beacon_node/http_api/tests/tests.rs | 203 +++++++++++++++- 9 files changed, 577 insertions(+), 41 deletions(-) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 4f41c2a1a1c..e73cf06c00a 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -68,7 +68,9 @@ use eth2::types::{ self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceExtraData, ForkChoiceNode, LightClientUpdatesQuery, PublishBlockRequest, ValidatorId, }; -use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; +use eth2::{ + BUILDER_URL_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER, +}; use health_metrics::observe::Observe; use lighthouse_network::Enr; use lighthouse_network::NetworkGlobals; @@ -106,7 +108,7 @@ use types::{ }; use validator::execution_payload_envelopes::get_validator_execution_payload_envelopes; use version::{ - ResponseIncludesVersion, V1, V2, add_consensus_version_header, add_ssz_content_type_header, + ResponseIncludesVersion, V1, V2, V4, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, inconsistent_fork_rejection, unsupported_version_rejection, }; @@ -384,6 +386,7 @@ pub async fn serve( let eth_v1 = single_version(any_version.clone(), V1); let eth_v2 = single_version(any_version.clone(), V2); + let eth_v4 = single_version(any_version.clone(), V4); // Create a `warp` filter that provides access to the network globals. let inner_network_globals = ctx.network_globals.clone(); @@ -819,6 +822,9 @@ pub async fn serve( */ let consensus_version_header_filter = warp::header::header::(CONSENSUS_VERSION_HEADER).boxed(); + // The winning builder's URL echoed by the VC on a Gloas block publish (beacon-APIs #630), so the + // node forwards the block to that builder. Optional: absent for self-build / p2p-won blocks. + let builder_url_header_filter = warp::header::optional::(BUILDER_URL_HEADER).boxed(); let optional_consensus_version_header_filter = warp::header::optional::(CONSENSUS_VERSION_HEADER).boxed(); @@ -855,6 +861,8 @@ pub async fn serve( &network_tx, BroadcastValidation::default(), duplicate_block_status_code, + // Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas). + None, ) .await }) @@ -892,6 +900,8 @@ pub async fn serve( &network_tx, BroadcastValidation::default(), duplicate_block_status_code, + // Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas). + None, ) .await }) @@ -909,13 +919,15 @@ pub async fn serve( .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) + .and(builder_url_header_filter.clone()) .then( move |validation_level: api_types::BroadcastValidationQuery, value: serde_json::Value, consensus_version: ForkName, task_spawner: TaskSpawner, chain: Arc>, - network_tx: UnboundedSender>| { + network_tx: UnboundedSender>, + builder_url: Option| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { let request = PublishBlockRequest::::context_deserialize( &value, @@ -932,6 +944,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -949,13 +962,15 @@ pub async fn serve( .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) + .and(builder_url_header_filter.clone()) .then( move |validation_level: api_types::BroadcastValidationQuery, block_bytes: Bytes, consensus_version: ForkName, task_spawner: TaskSpawner, chain: Arc>, - network_tx: UnboundedSender>| { + network_tx: UnboundedSender>, + builder_url: Option| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { let block_contents = PublishBlockRequest::::from_ssz_bytes( &block_bytes, @@ -971,6 +986,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -2570,6 +2586,14 @@ pub async fn serve( task_spawner_filter.clone(), ); + // POST v4/validator/blocks/{slot} + let post_validator_blocks_v4 = post_validator_blocks_v4( + eth_v4.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // GET validator/blinded_blocks/{slot} let get_validator_blinded_blocks = get_validator_blinded_blocks( eth_v1.clone(), @@ -2683,6 +2707,12 @@ pub async fn serve( chain_filter.clone(), task_spawner_filter.clone(), ); + // POST validator/builder_preferences + let post_validator_builder_preferences = post_validator_builder_preferences( + eth_v1.clone(), + chain_filter.clone(), + task_spawner_filter.clone(), + ); // POST validator/sync_committee_subscriptions let post_validator_sync_committee_subscriptions = post_validator_sync_committee_subscriptions( eth_v1.clone(), @@ -3496,6 +3526,8 @@ pub async fn serve( .uor(post_validator_sync_committee_subscriptions) .uor(post_validator_prepare_beacon_proposer) .uor(post_validator_register_validator) + .uor(post_validator_builder_preferences) + .uor(post_validator_blocks_v4) .uor(post_validator_liveness_epoch) .uor(post_lighthouse_liveness) .uor(post_lighthouse_database_reconstruct) diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 63420fbe2d0..49315790da0 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -1,10 +1,10 @@ use crate::{ build_block_contents, version::{ - ResponseIncludesVersion, add_consensus_block_value_header, add_consensus_version_header, - add_execution_payload_blinded_header, add_execution_payload_included_header, - add_execution_payload_value_header, add_ssz_content_type_header, beacon_response, - inconsistent_fork_rejection, + ResponseIncludesVersion, add_builder_url_header, add_consensus_block_value_header, + add_consensus_version_header, add_execution_payload_blinded_header, + add_execution_payload_included_header, add_execution_payload_value_header, + add_ssz_content_type_header, beacon_response, inconsistent_fork_rejection, }, }; use beacon_chain::graffiti_calculator::GraffitiSettings; @@ -17,9 +17,10 @@ use eth2::{ beacon_response::ForkVersionedResponse, types::{BlockAndEnvelope, ProduceBlockV4Metadata}, }; +use sensitive_url::SensitiveUrl; use ssz::Encode; use std::sync::Arc; -use tracing::instrument; +use tracing::{debug, instrument}; use types::{execution::BlockProductionVersion, *}; use warp::{ http::response::Builder, @@ -58,13 +59,30 @@ pub async fn produce_block_v4( chain: Arc>, slot: Slot, query: api_types::ValidatorBlocksQuery, + builder_config: api_types::BuilderConfig, ) -> Result { + // `produceBlockV4` is the Gloas block-production endpoint. + let fork_name = chain.spec.fork_name_at_slot::(slot); + if !fork_name.gloas_enabled() { + return Err(warp_utils::reject::custom_bad_request( + "produceBlockV4 is only valid for Gloas and later".to_string(), + )); + } + let include_payload = query.include_payload.ok_or_else(|| { warp_utils::reject::custom_bad_request( "include_payload query parameter is required".to_string(), ) })?; + // The resolved builder config is threaded into block production, where it drives direct-builder + // bid requests and the gossip/direct bid policy (see `produce_block_on_state_gloas`). + debug!( + %slot, + builders = builder_config.builders.len(), + "Received produceBlockV4 request" + ); + let randao_reveal = query.randao_reveal.decompress().map_err(|e| { warp_utils::reject::custom_bad_request(format!( "randao reveal is not a valid BLS signature: {:?}", @@ -73,14 +91,9 @@ pub async fn produce_block_v4( })?; let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?; - // The GET route carries only a boost factor; direct builders arrive with the `BuilderConfig` - // body once this route is converted to POST (later in this PR stack). Until then the winning - // bid's builder URL is unused (`Eth-Builder-Url` also lands with the POST conversion). - let builder_config = api_types::BuilderConfig { - builder_boost_factor: query.builder_boost_factor.unwrap_or(DEFAULT_BOOST_FACTOR), - ..api_types::BuilderConfig::empty() - }; + // Gloas takes its bid boost policy from `builder_config` (global for gossip, per-builder for + // direct), so the V3-style `builder_boost_factor` query param is not used on this path. let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); let ( @@ -89,7 +102,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, - _builder_url, + builder_url, ) = chain .produce_block_with_verification_gloas( randao_reveal, @@ -110,6 +123,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, + builder_url, accept_header, &chain.spec, ) @@ -164,9 +178,13 @@ pub fn build_response_v4( consensus_block_value: u64, execution_payload_value: Uint256, payload_contents: Option>, + builder_url: Option, accept_header: Option, spec: &ChainSpec, ) -> Result { + // Stringify the winning builder's URL only here, at the `Eth-Builder-Url` header boundary; it is + // kept as a redacted `SensitiveUrl` everywhere upstream. + let builder_url = builder_url.map(|url| url.expose_full().to_string()); let fork_name = block .to_ref() .fork_name(spec) @@ -180,14 +198,15 @@ pub fn build_response_v4( consensus_block_value: consensus_block_value_wei, execution_payload_value, execution_payload_included, - builder_url: None, + builder_url: builder_url.clone(), }; let add_v4_headers = |res: Response| { let res = add_consensus_version_header(res, fork_name); let res = add_consensus_block_value_header(res, consensus_block_value_wei); let res = add_execution_payload_value_header(res, execution_payload_value); - add_execution_payload_included_header(res, execution_payload_included) + let res = add_execution_payload_included_header(res, execution_payload_included); + add_builder_url_header(res, builder_url.as_deref()) }; // When the payload is included, bundle the block with the execution payload envelope, blobs and diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index a7336f2f6eb..5279a25b3be 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -19,6 +19,7 @@ use logging::crit; use network::NetworkMessage; use rand::prelude::SliceRandom; use reqwest::StatusCode; +use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; use std::marker::PhantomData; use std::sync::Arc; @@ -73,6 +74,62 @@ impl ProvenancedBlock> } } +/// If a direct builder won this block's payload bid, forward the signed block to that builder via +/// `submitSignedBeaconBlock` so it reveals the execution payload envelope. +/// +/// The builder's URL is the `Eth-Builder-Url` request header the VC echoed on publish (beacon-APIs +/// #630), so this works even on a beacon node that did not produce the block. `None` (self-built or +/// p2p-won), no configured builders, or a malformed URL are all no-ops. +/// +/// Fire-and-forget: the submission runs in a detached task; a failure is logged at high severity +/// (the validator has already signed the commitment) but never blocks the publish response. Runs +/// only once per block since it hangs off the single p2p-publish point. +fn forward_signed_block_to_winning_builder( + chain: &Arc>, + block: Arc>, + builder_url: Option<&str>, +) { + // The VC echoes the winning builder's URL in the `Eth-Builder-Url` request header (beacon-APIs + // #630); absent for a self-built block or a p2p-won bid, in which case there's nothing to forward. + let Some(builder_url) = builder_url else { + return; + }; + let Some(builders) = chain.builders.as_ref() else { + return; + }; + let url = match SensitiveUrl::parse(builder_url) { + Ok(url) => url, + Err(e) => { + warn!(error = ?e, "Ignoring malformed Eth-Builder-Url header"); + return; + } + }; + + let builders = builders.clone(); + let slot = block.slot(); + let block_root = block.canonical_root(); + + chain.task_executor.spawn( + async move { + match builders.forward_signed_block(&url, &block).await { + Ok(()) => info!( + %slot, + %block_root, + "Forwarded signed block to winning builder" + ), + Err(e) => error!( + %slot, + %block_root, + builder_url = ?url, + error = ?e, + "Failed to forward signed block to winning builder" + ), + } + }, + "forward_signed_block_to_builder", + ); +} + /// Handles a request from the HTTP API for full blocks. #[allow(clippy::too_many_arguments)] #[instrument( @@ -88,6 +145,9 @@ pub async fn publish_block>( network_tx: &UnboundedSender>, validation_level: BroadcastValidation, duplicate_status_code: StatusCode, + // The `Eth-Builder-Url` request header (beacon-APIs #630): when a direct builder won the block's + // payload bid, its URL, so the block is forwarded there for envelope reveal. + builder_url: Option, ) -> Result { let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let block_publishing_delay_for_testing = chain.config.block_publishing_delay; @@ -141,6 +201,14 @@ pub async fn publish_block>( BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) })?; + // If a direct builder won this block's payload bid, forward the signed block to it so it + // reveals the execution payload envelope. + forward_signed_block_to_winning_builder( + &publish_chain, + block.clone(), + builder_url.as_deref(), + ); + Ok(()) }; @@ -570,6 +638,8 @@ pub async fn publish_blinded_block( network_tx, validation_level, duplicate_status_code, + // Blinded (mev-boost) publish predates the Gloas builder-URL round-trip. + None, ) .await } else { diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 7b904260b8e..f07273003fb 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -6,7 +6,7 @@ use crate::utils::{ AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; -use crate::version::{V1, V2, V3, V4, add_ssz_content_type_header, unsupported_version_rejection}; +use crate::version::{V1, V2, V3, add_ssz_content_type_header, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; @@ -14,12 +14,13 @@ use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainT use bls::PublicKeyBytes; use bytes::Bytes; use context_deserialize::ContextDeserialize; -use eth2::CONSENSUS_VERSION_HEADER; use eth2::types::{ - Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, - StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, - ValidatorAttestationDataQuery, ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, + Accept, BeaconCommitteeSubscription, BuilderConfig, BuilderPreferenceEntry, EndpointVersion, + Failure, GenericResponse, MAX_SUBMITTED_BUILDER_PREFERENCES, StandardLivenessResponseData, + StateId as CoreStateId, ValidatorAggregateAttestationQuery, ValidatorAttestationDataQuery, + ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, }; +use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; use reqwest::StatusCode; @@ -483,8 +484,12 @@ pub fn get_validator_blocks( not_synced_filter?; - if endpoint_version == V4 { - produce_block_v4(accept_header, chain, slot, query).await + // Gloas block production is served via `POST v4/validator/blocks`. + let fork_name = chain.spec.fork_name_at_slot::(slot); + if fork_name.gloas_enabled() { + Err(warp_utils::reject::custom_bad_request( + "Gloas block production requires POST v4/validator/blocks".to_string(), + )) } else if endpoint_version == V3 { produce_block_v3(accept_header, chain, slot, query).await } else { @@ -496,6 +501,84 @@ pub fn get_validator_blocks( .boxed() } +// POST v4/validator/blocks/{slot} +// +// The Gloas block-production endpoint. Carries the validator's resolved `BuilderConfig` as the +// request body, accepted as either JSON or SSZ (selected by `Content-Type`; `application/octet-stream` +// => SSZ). The `Eth-Consensus-Version` request header is required (per beacon-APIs #630); the body +// is not fork-versioned, so like the builder-preferences endpoint the header is validated but only +// logged. +pub fn post_validator_blocks_v4( + eth_v4: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v4 + .and(warp::path("validator")) + .and(warp::path("blocks")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid slot".to_string(), + )) + })) + .and(warp::path::end()) + .and(warp::header::optional::("accept")) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(not_while_syncing_filter) + .and(warp::query::()) + .and( + warp::header::optional::(CONTENT_TYPE_HEADER) + .and(warp::body::bytes()) + .and_then(|content_type: Option, body: Bytes| async move { + let builder_config: BuilderConfig = if content_type.as_deref() + == Some(SSZ_CONTENT_TYPE_HEADER) + { + BuilderConfig::from_ssz_bytes(&body).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })? + } else { + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + // A zero-length `url` or auth `data` makes the body itself invalid (beacon-APIs + // #630) — a 400, unlike per-entry bid failures, which are isolated. + for entry in builder_config.builders.iter() { + entry.validate().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid builder entry: {e}" + )) + })?; + } + Ok::<_, Rejection>(builder_config) + }), + ) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + accept_header: Option, + consensus_version: ForkName, + not_synced_filter: Result<(), Rejection>, + query: ValidatorBlocksQuery, + builder_config: BuilderConfig, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + debug!( + ?slot, + %consensus_version, + "Block production request from HTTP API (v4)" + ); + not_synced_filter?; + produce_block_v4(accept_header, chain, slot, query, builder_config).await + }) + }, + ) + .boxed() +} + // POST validator/liveness/{epoch} pub fn post_validator_liveness_epoch( eth_v1: EthV1Filter, @@ -770,6 +853,126 @@ pub fn post_validator_register_validator( .boxed() } +// POST validator/builder_preferences +// +// Accepts the `BuilderPreferenceEntry` list as either JSON or SSZ, selected by the request's +// `Content-Type` (`application/octet-stream` => SSZ, otherwise JSON). A required +// `Eth-Consensus-Version` header carries the consensus version the preferences belong to (per +// beacon-APIs #630); it is not needed to decode the (currently single-fork) body, so it is only +// logged. +pub fn post_validator_builder_preferences( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("builder_preferences")) + .and(warp::path::end()) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .and( + warp::header::optional::(CONTENT_TYPE_HEADER) + .and(warp::body::bytes()) + .and_then(|content_type: Option, body: Bytes| async move { + let entries: Vec = if content_type.as_deref() + == Some(SSZ_CONTENT_TYPE_HEADER) + { + Vec::from_ssz_bytes(&body).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })? + } else { + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + // The submission list is bounded (SSZ `List[BuilderPreferencesEntry, 4096]`, + // JSON `maxItems: 4096`, per beacon-APIs #630); a longer body is invalid. + if entries.len() > MAX_SUBMITTED_BUILDER_PREFERENCES { + return Err(warp_utils::reject::custom_bad_request(format!( + "too many builder preference entries: {} exceeds the limit of {}", + entries.len(), + MAX_SUBMITTED_BUILDER_PREFERENCES + ))); + } + // A zero-length `url` or auth `data` makes the body itself invalid (beacon-APIs + // #630) — a 400, unlike per-entry submission failures, which are isolated. + for entry in &entries { + entry.validate().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid builder preference entry: {e}" + )) + })?; + } + Ok::<_, Rejection>(entries) + }), + ) + .then( + |consensus_version: ForkName, + task_spawner: TaskSpawner, + chain: Arc>, + entries: Vec| async move { + let (tx, rx) = oneshot::channel(); + + let initial_result = task_spawner + .spawn_async_with_rejection_no_conversion(Priority::P0, async move { + // The builder service is only present when the Gloas fork is scheduled. + let builders = chain + .builders + .as_ref() + .ok_or(BeaconChainError::BuilderMissing) + .map_err(warp_utils::reject::unhandled_error)? + .clone(); + + debug!( + count = entries.len(), + %consensus_version, + "Received submit builder preferences request" + ); + + // Submitting to a builder can be slow (they frequently time out), so the + // fan-out runs in a detached task rather than holding a `BeaconProcessor` + // worker. The service submits each entry independently and best-effort, + // returning the failures by index (per beacon-APIs #630). + tokio::task::spawn(async move { + let response = match builders + .submit_builder_preferences(entries, consensus_version) + .await + { + Ok(()) => Ok(warp::reply::reply().into_response()), + Err(failures) => Err(warp_utils::reject::indexed_bad_request( + "error submitting builder preferences".to_string(), + failures + .into_iter() + .map(|f| Failure::new(f.index, f.error.to_string())) + .collect(), + )), + }; + let _ = tx.send(response); + }); + + Ok(warp::reply::reply().into_response()) + }) + .await; + + if initial_result.is_err() { + return convert_rejection(initial_result).await; + } + + convert_rejection(rx.await.unwrap_or_else(|_| { + Ok(warp::reply::with_status( + warp::reply::json(&"No response from channel"), + warp::http::StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response()) + })) + .await + }, + ) + .boxed() +} + // POST validator/prepare_beacon_proposer pub fn post_validator_prepare_beacon_proposer( eth_v1: EthV1Filter, diff --git a/beacon_node/http_api/src/version.rs b/beacon_node/http_api/src/version.rs index 6f441636b49..63914feb049 100644 --- a/beacon_node/http_api/src/version.rs +++ b/beacon_node/http_api/src/version.rs @@ -4,8 +4,8 @@ use eth2::beacon_response::{ ExecutionOptimisticFinalizedMetadata, ForkVersionedResponse, UnversionedResponse, }; use eth2::{ - CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, - EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, + BUILDER_URL_HEADER, CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, + CONTENT_TYPE_HEADER, EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, SSZ_CONTENT_TYPE_HEADER, }; use serde::Serialize; @@ -116,6 +116,15 @@ pub fn add_execution_payload_value_header( .into_response() } +/// Add the `Eth-Builder-Url` header (the winning builder's URL) to a response, when present. +/// Absent for a self-built block or a block won by a p2p bid. +pub fn add_builder_url_header(reply: T, builder_url: Option<&str>) -> Response { + match builder_url { + Some(url) => reply::with_header(reply, BUILDER_URL_HEADER, url).into_response(), + None => reply.into_response(), + } +} + /// Add the `Eth-Consensus-Block-Value` header to a response. pub fn add_consensus_block_value_header( reply: T, diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 4d2be52a0d5..5db04d7d136 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -433,6 +433,7 @@ pub async fn consensus_partial_pass_only_consensus() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; @@ -610,7 +611,7 @@ pub async fn equivocation_consensus_early_equivocation() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), blobs_a), validation_level, - None + None, ) .await .is_ok() @@ -763,6 +764,7 @@ pub async fn equivocation_consensus_late_equivocation() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index f1a4ed02bb4..f31746ddf70 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -27,7 +27,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, Hash256, MinimalEthSpec, + Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, ForkName, Hash256, MinimalEthSpec, ProposerPreparationData, Slot, }; @@ -727,7 +727,15 @@ pub async fn proposer_boost_re_org_test( let (block_c, block_c_blobs) = { let (response, _) = tester .client - .get_validator_blocks_v4::(slot_c, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot_c, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); ( diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 90c7f37b02c..df4fdaf0904 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -817,7 +817,15 @@ pub async fn fork_choice_before_proposal() { let block_d = if harness.spec.fork_name_at_slot::(slot_d).gloas_enabled() { tester .client - .get_validator_blocks_v4::(slot_d, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot_d, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() .0 diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 8f9dcac5a04..dab0f6878ac 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4813,7 +4813,15 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5005,6 +5013,124 @@ impl ApiTester { self } + pub async fn test_block_production_v4_missing_consensus_version_header_returns_400( + self, + ) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + let Some((slot, epoch, _fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (_sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + ) + .await + .unwrap(); + + // A valid body, but no `Eth-Consensus-Version` header: the header is required + // (beacon-APIs #630), so the request must fail with a 400. + let response = reqwest::Client::new() + .post(url) + .json(ð2::types::BuilderConfig::empty()) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + + pub async fn test_block_production_v4_zero_length_entry_fields_return_400(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + let Some((slot, epoch, _fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (_sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + ) + .await + .unwrap(); + + let valid_auth = eth2::types::SignedRequestAuth { + message: eth2::types::RequestAuth { + data: eth2::types::RequestAuthData::new(b"http://builder.example.com".to_vec()) + .unwrap(), + slot, + }, + signature: Signature::empty(), + }; + let entry = |url: &str, auth: eth2::types::SignedRequestAuth| eth2::types::BuilderEntry { + url: url.parse().unwrap(), + auth, + builder_pubkeys: <_>::default(), + max_execution_payment: 0, + min_bid: 0, + builder_boost_factor: 100, + }; + + // A zero-length `url` and a zero-length auth `data` each make the body invalid + // (beacon-APIs #630), so the request must fail with a 400. + let empty_url_entry = entry("", valid_auth.clone()); + let mut empty_data_auth = valid_auth; + empty_data_auth.message.data = eth2::types::RequestAuthData::default(); + let empty_data_entry = entry("http://builder.example.com", empty_data_auth); + + for bad_entry in [empty_url_entry, empty_data_entry] { + let config = serde_json::json!({ + "min_bid": "0", + "builder_boost_factor": "100", + "builders": [bad_entry], + }); + let response = reqwest::Client::new() + .post(url.clone()) + .header(eth2::CONSENSUS_VERSION_HEADER, "gloas") + .json(&config) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + pub async fn test_envelope_post_when_syncing_returns_503(mut self) -> Self { if !self.chain.spec.is_gloas_scheduled() { return self; @@ -5178,7 +5304,15 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5253,7 +5387,15 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4_ssz::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5325,12 +5467,28 @@ impl ApiTester { let (response, metadata) = if ssz { self.client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, true, None, None) + .post_validator_blocks_v4_ssz::( + slot, + &randao_reveal, + None, + true, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() } else { self.client - .get_validator_blocks_v4::(slot, &randao_reveal, None, true, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + true, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() }; @@ -5871,7 +6029,15 @@ impl ApiTester { // Produce and publish a block. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5954,7 +6120,15 @@ impl ApiTester { // Produce and publish a block, but withhold its envelope. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -8923,7 +9097,15 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -9229,7 +9411,6 @@ impl ApiTester { let epoch = self.chain.epoch().unwrap(); let (_, randao_reveal) = self.get_test_randao(slot, epoch).await; let graffiti = Some(Graffiti::from([0; GRAFFITI_BYTES_LEN])); - // When GraffitiPolicy is None let no_graffiti_policy_path = self .client @@ -10101,6 +10282,10 @@ async fn envelope_api() { .await .test_block_production_v4_missing_include_payload_returns_400() .await + .test_block_production_v4_missing_consensus_version_header_returns_400() + .await + .test_block_production_v4_zero_length_entry_fields_return_400() + .await .test_envelope_post_consensus_invalid_returns_400_no_broadcast() .await .test_envelope_post_gossip_partial_pass_returns_202() From 9abe9a1a5a33e28f05c1c5f366733dcf9f63bc09 Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:55:54 -0500 Subject: [PATCH 09/11] Migrate the validator client to the Gloas builder API (Gloas builder API 5/5) Final PR of the Gloas builder API stack: - sign builder request-auth and preferences (REQUEST_AUTH signing domain, web3signer message type) - add the builder configuration store (`builder_definitions.yml`) and book documentation - add the builder-preferences service and switch block production to `POST` produceBlockV4 with a `BuilderConfig` body, threading the `Eth-Builder-Url` header through block publication - remove the now-unused legacy `GET` produceBlockV4 client methods Change-Id: Iaeeaf6205a024e4fb9fd11aae6ac6a75978a8320 --- Cargo.lock | 25 ++ Cargo.toml | 2 + book/src/SUMMARY.md | 1 + book/src/gloas_builder_config.md | 90 +++++ common/eth2/src/lib.rs | 182 ----------- .../src/mock_beacon_node.rs | 18 +- .../src/mock_validator_store.rs | 9 + validator_client/Cargo.toml | 1 + validator_client/builder_store/Cargo.toml | 26 ++ .../builder_store/src/builder_definitions.rs | 292 +++++++++++++++++ validator_client/builder_store/src/lib.rs | 140 ++++++++ .../lighthouse_validator_store/Cargo.toml | 1 + .../lighthouse_validator_store/src/lib.rs | 25 ++ validator_client/signing_method/Cargo.toml | 1 + validator_client/signing_method/src/lib.rs | 4 + .../signing_method/src/web3signer.rs | 4 + validator_client/src/lib.rs | 29 +- .../validator_services/Cargo.toml | 2 + .../validator_services/src/block_service.rs | 122 +++++-- .../src/builder_preferences_service.rs | 308 ++++++++++++++++++ .../validator_services/src/lib.rs | 2 + .../src/request_auth_cache.rs | 106 ++++++ validator_client/validator_store/Cargo.toml | 1 + validator_client/validator_store/src/lib.rs | 7 + wordlist.txt | 2 + 25 files changed, 1186 insertions(+), 214 deletions(-) create mode 100644 book/src/gloas_builder_config.md create mode 100644 validator_client/builder_store/Cargo.toml create mode 100644 validator_client/builder_store/src/builder_definitions.rs create mode 100644 validator_client/builder_store/src/lib.rs create mode 100644 validator_client/validator_services/src/builder_preferences_service.rs create mode 100644 validator_client/validator_services/src/request_auth_cache.rs diff --git a/Cargo.lock b/Cargo.lock index 3e9744e92c5..4b04d951ca1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1713,6 +1713,25 @@ dependencies = [ "types", ] +[[package]] +name = "builder_store" +version = "0.1.0" +dependencies = [ + "account_utils", + "bls", + "builder_types", + "filesystem", + "futures", + "hex", + "parking_lot", + "serde", + "ssz_types", + "tempfile", + "tracing", + "types", + "yaml_serde", +] + [[package]] name = "builder_types" version = "0.1.0" @@ -5666,6 +5685,7 @@ dependencies = [ "account_utils", "beacon_node_fallback", "bls", + "builder_types", "doppelganger_service", "either", "environment", @@ -8445,6 +8465,7 @@ name = "signing_method" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2_keystore", "ethereum_serde_utils", "lockfile", @@ -9811,6 +9832,7 @@ version = "8.2.2" dependencies = [ "account_utils", "beacon_node_fallback", + "builder_store", "clap", "clap_utils", "directory", @@ -9976,6 +9998,8 @@ version = "0.1.0" dependencies = [ "beacon_node_fallback", "bls", + "builder_store", + "builder_types", "either", "eth2", "futures", @@ -10000,6 +10024,7 @@ name = "validator_store" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2", "futures", "slashing_protection", diff --git a/Cargo.toml b/Cargo.toml index ba9a5654b2f..6098e71dc0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ members = [ "testing/web3signer_tests", "validator_client", "validator_client/beacon_node_fallback", + "validator_client/builder_store", "validator_client/doppelganger_service", "validator_client/graffiti_file", "validator_client/http_api", @@ -120,6 +121,7 @@ bincode = "1" bitvec = "1" bls = { path = "crypto/bls" } builder_client = { path = "beacon_node/builder_client" } +builder_store = { path = "validator_client/builder_store" } builder_types = { path = "common/builder_types" } byteorder = "1" bytes = "1.11.1" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index feecd4b6894..15e6be2010a 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -49,6 +49,7 @@ * [Redundancy](./advanced_redundancy.md) * [Release Candidates](./advanced_release_candidates.md) * [MEV](./advanced_builders.md) + * [Gloas Builder Configuration](./gloas_builder_config.md) * [Late Block Re-orgs](./advanced_re-orgs.md) * [Blobs](./advanced_blobs.md) * [Command Line Reference (CLI)](./help_general.md) diff --git a/book/src/gloas_builder_config.md b/book/src/gloas_builder_config.md new file mode 100644 index 00000000000..4709885b7e1 --- /dev/null +++ b/book/src/gloas_builder_config.md @@ -0,0 +1,90 @@ +# Builder Configuration + +> This applies from the **Gloas** fork onwards. It configures how the validator client sources +> execution-payload bids from external builders under ePBS. + +The validator client reads its external-builder settings from a YAML file named +`builder_definitions.yml` in the validator directory +(`/validators/builder_definitions.yml`). The file holds two things: + +- **A global bid policy** — `min_bid` and `builder_boost_factor`, applied to bids received over p2p + (gossip) and used as the default for any builder that does not set its own. +- **A list of builders** to request bids from directly, each with optional per-builder overrides of + the global policy. + +## Example + +```yaml +# Global bid policy: applies to p2p (gossip) bids, and is the default for any +# builder below that omits the corresponding field. +min_bid: 0 # gwei — bids below this rank last; one wins only if nothing else is viable +builder_boost_factor: 100 # percent — 100 = neutral, >100 favors builders, 0 = prefer local + +builders: + # Minimal builder — inherits the global policy. + - enabled: true + url: "https://builder-a.example.com" + max_execution_payment: 1000000000 # gwei — cap on the trusted execution payment + + # Builder overriding the globals and pinning the expected builder key. + - enabled: true + url: "https://builder-b.example.com" + max_execution_payment: 1000000000 + min_bid: 500000000 # override the global for this builder + builder_boost_factor: 120 # override the global for this builder + builder_pubkeys: # optional — reject a bid not signed by one of these keys + - "0xa1b2c3d4..." + # auth_data: "0x68747470..." # optional — defaults to the UTF-8 bytes of `url` +``` + +> **Comments are not preserved.** The validator client rewrites this file when builders are added or +> removed (for example via the keymanager API), which strips YAML comments. Keep an annotated copy +> elsewhere if you rely on inline notes. + +## Fields + +### Top level (global bid policy) + +| Field | Required | Default | Meaning | +| ------- | ---------- | --------- | --------- | +| `min_bid` | no | `0` | Minimum total payment, in gwei, for a p2p bid. A bid below the floor is ranked behind any floor-clearing candidate (including the local block) and only wins when nothing else is viable. Also the default `min_bid` for any builder that omits it. | +| `builder_boost_factor` | no | `100` | Percentage multiplier applied to p2p bids when comparing against the local block. Also the default for any builder that omits it. | +| `builders` | no | `[]` | The list of builders to request bids from directly. | + +### Per builder (each entry under `builders`) + +| Field | Required | Default | Meaning | +| ------- | ---------- | --------- | --------- | +| `enabled` | **yes** | — | Whether this builder is used. Disabled builders are ignored. | +| `url` | **yes** | — | The builder's `http`/`https` URL. Bids are requested from here at block-production time. | +| `max_execution_payment` | **yes** | — | Cap, in gwei, on the *trusted* execution payment accepted from this builder. | +| `min_bid` | no | *(global)* | Override the global minimum bid for this builder. | +| `builder_boost_factor` | no | *(global)* | Override the global boost factor for this builder. | +| `builder_pubkeys` | no | *(empty)* | The builder's BLS public keys, hex-encoded. If non-empty, a returned bid **not** signed by one of them is rejected. | +| `auth_data` | no | *(UTF-8 of `url`)* | Opaque authentication data, hex-encoded, agreed with the builder out of band. Signed into the request. Must be non-empty when set. Defaults to the UTF-8 bytes of `url`. | + +All byte fields (`builder_pubkeys` entries, `auth_data`) are `0x`-prefixed hex strings. All payment values +(`min_bid`, `max_execution_payment`) are in gwei. + +## How bids are selected + +At block-production time the validator client requests a bid from each enabled builder with a `url`, +and also considers bids seen over p2p. For each candidate bid: + +- **`min_bid`** — a bid whose total value is below the applicable `min_bid` is ranked behind any + floor-clearing candidate (including the local block) rather than dropped, so it wins only when + nothing else is viable (e.g. the local build failed). Direct builders use their own (or the + inherited global) value; p2p bids use the global value. +- **`builder_boost_factor`** — the surviving bid's value is scaled by its boost factor + (`boost × value ÷ 100`) before being compared against the locally-built block. A factor below + `100` favors the local block; above `100` favors the builder; `0` always prefers local; + `2^64 − 1` strongly favors the builder. The factor is a multiplier, not an absolute override, so a + zero-value bid still ranks `0` and loses to any non-zero local block. +- **`max_execution_payment`** — bounds how much of a builder's (off-chain) execution payment counts + toward its bid value. This applies only to direct builders; p2p bids carry no trusted execution + payment. +- **`builder_pubkeys`** — for a direct builder, if non-empty, the returned bid must be signed by + one of these keys or it is discarded. + +The highest-value bid after these rules wins. Per-builder `min_bid`/`builder_boost_factor` apply +only to bids requested directly by URL; p2p bids are governed by the global values. diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index a02b948a909..8d94c774eda 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3048,188 +3048,6 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } - // The legacy `GET v4/validator/blocks/{slot}` client methods below are kept alongside the new - // POST variants until the validator client migrates to POST (later in this PR stack), at which - // point they are removed. - - /// `GET v4/validator/blocks/{slot}` - pub async fn get_validator_blocks_v4( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular( - slot, - randao_reveal, - graffiti, - SkipRandaoVerification::No, - include_payload, - builder_booster_factor, - graffiti_policy, - ) - .await - } - - /// `GET v4/validator/blocks/{slot}` - /// - /// Returns either a bare block or the full [`BlockAndEnvelope`] (block + execution payload - /// envelope + blobs + KZG proofs) depending on the `Eth-Execution-Payload-Included` response - /// header. Note that a builder bid yields a bare block even when `include_payload=true`. - #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - skip_randao_verification: SkipRandaoVerification, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let mut path = self - .post_validator_blocks_v4_path( - slot, - randao_reveal, - graffiti, - skip_randao_verification, - include_payload, - graffiti_policy, - ) - .await?; - - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - - let opt_result = self - .get_response_with_response_headers( - path, - Accept::Json, - self.timeouts.get_validator_block, - |response, headers| async move { - let metadata = ProduceBlockV4Metadata::try_from(&headers) - .map_err(Error::InvalidHeaders)?; - let block_response = if metadata.execution_payload_included { - ProduceBlockV4Response::BlockAndEnvelope( - response - .json::, - ProduceBlockV4Metadata, - >>() - .await? - .data, - ) - } else { - ProduceBlockV4Response::BlockOnly( - response - .json::, - ProduceBlockV4Metadata, - >>() - .await? - .data, - ) - }; - Ok((block_response, metadata)) - }, - ) - .await?; - - opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) - } - - /// `GET v4/validator/blocks/{slot}` in ssz format - pub async fn get_validator_blocks_v4_ssz( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular_ssz::( - slot, - randao_reveal, - graffiti, - SkipRandaoVerification::No, - include_payload, - builder_booster_factor, - graffiti_policy, - ) - .await - } - - /// `GET v4/validator/blocks/{slot}` in ssz format - /// - /// See [`Self::get_validator_blocks_v4_modular`] for the response semantics. - #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular_ssz( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - skip_randao_verification: SkipRandaoVerification, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let mut path = self - .post_validator_blocks_v4_path( - slot, - randao_reveal, - graffiti, - skip_randao_verification, - include_payload, - graffiti_policy, - ) - .await?; - - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - - let opt_response = self - .get_response_with_response_headers( - path, - Accept::Ssz, - self.timeouts.get_validator_block, - |response, headers| async move { - let metadata = ProduceBlockV4Metadata::try_from(&headers) - .map_err(Error::InvalidHeaders)?; - let response_bytes = response.bytes().await?; - let block_response = if metadata.execution_payload_included { - ProduceBlockV4Response::BlockAndEnvelope( - BlockAndEnvelope::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?, - ) - } else { - ProduceBlockV4Response::BlockOnly( - BeaconBlock::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?, - ) - }; - - Ok((block_response, metadata)) - }, - ) - .await?; - - opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) - } - /// `GET v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}` pub async fn get_validator_execution_payload_envelopes( &self, diff --git a/testing/validator_test_rig/src/mock_beacon_node.rs b/testing/validator_test_rig/src/mock_beacon_node.rs index d01905c0c7e..2cdc87f560a 100644 --- a/testing/validator_test_rig/src/mock_beacon_node.rs +++ b/testing/validator_test_rig/src/mock_beacon_node.rs @@ -102,8 +102,8 @@ impl MockBeaconNode { .create(); } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` - pub fn mock_get_validator_blocks_v4( + /// Mocks `POST /eth/v4/validator/blocks/{slot}` + pub fn mock_post_validator_blocks_v4( &mut self, block: &BeaconBlock, fork_name: ForkName, @@ -121,7 +121,7 @@ impl MockBeaconNode { }); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), @@ -136,8 +136,8 @@ impl MockBeaconNode { .create() } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` (SSZ) - pub fn mock_get_validator_blocks_v4_ssz( + /// Mocks `POST /eth/v4/validator/blocks/{slot}` (SSZ) + pub fn mock_post_validator_blocks_v4_ssz( &mut self, block: &BeaconBlock, fork_name: ForkName, @@ -149,7 +149,7 @@ impl MockBeaconNode { let ssz_bytes = block.as_ssz_bytes(); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), @@ -165,13 +165,13 @@ impl MockBeaconNode { .create() } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` (SSZ) returning error - pub fn mock_get_validator_blocks_v4_ssz_error(&mut self, slot: Slot) -> Mock { + /// Mocks `POST /eth/v4/validator/blocks/{slot}` (SSZ) returning error + pub fn mock_post_validator_blocks_v4_ssz_error(&mut self, slot: Slot) -> Mock { let path_pattern = Regex::new(&format!(r"^/eth/v4/validator/blocks/{}", slot.as_u64())).unwrap(); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), diff --git a/testing/validator_test_rig/src/mock_validator_store.rs b/testing/validator_test_rig/src/mock_validator_store.rs index e4ce9772647..9fb75ae6b8b 100644 --- a/testing/validator_test_rig/src/mock_validator_store.rs +++ b/testing/validator_test_rig/src/mock_validator_store.rs @@ -1,4 +1,5 @@ use bls::{PublicKeyBytes, Signature}; +use eth2::types::{RequestAuth, SignedRequestAuth}; use futures::future::{BoxFuture, FutureExt}; use futures::{Stream, stream}; use std::future::Future; @@ -184,6 +185,14 @@ impl ValidatorStore for MockValidatorStore { panic!("MockValidatorStore::sign_proposer_preferences called without a hook") } + async fn sign_request_auth_v1( + &self, + _validator_pubkey: PublicKeyBytes, + _request_auth_v1: RequestAuth, + ) -> Result> { + panic!("MockValidatorStore::sign_request_auth_v1 called without a hook") + } + fn proposal_data(&self, _pubkey: &PublicKeyBytes) -> Option { panic!("MockValidatorStore::proposal_data called without a hook") } diff --git a/validator_client/Cargo.toml b/validator_client/Cargo.toml index 6990a2f61a7..ab5a85e8b0e 100644 --- a/validator_client/Cargo.toml +++ b/validator_client/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } +builder_store = { workspace = true } clap = { workspace = true } clap_utils = { workspace = true } directory = { workspace = true } diff --git a/validator_client/builder_store/Cargo.toml b/validator_client/builder_store/Cargo.toml new file mode 100644 index 00000000000..cad40ec39fc --- /dev/null +++ b/validator_client/builder_store/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "builder_store" +version = "0.1.0" +edition = { workspace = true } +authors = ["Sigma Prime "] + +[lib] +name = "builder_store" +path = "src/lib.rs" + +[dependencies] +account_utils = { workspace = true } +bls = { workspace = true } +builder_types = { workspace = true } +filesystem = { workspace = true } +futures = { workspace = true } +hex = { workspace = true } +parking_lot = { workspace = true } +serde = { workspace = true } +ssz_types = { workspace = true } +tracing = { workspace = true } +types = { workspace = true } +yaml_serde = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs new file mode 100644 index 00000000000..928f6d3ec14 --- /dev/null +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -0,0 +1,292 @@ +use account_utils::write_file_via_temporary; +use bls::PublicKeyBytes; +use builder_types::{BuilderUrl, MAX_BUILDER_ENTRIES, RequestAuthData}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{File, create_dir_all}; +use std::io; +use std::path::{Path, PathBuf}; + +/// The file name for the serialized `BuilderConfigFile` struct. +pub const BUILDERS_FILENAME: &str = "builder_definitions.yml"; +/// The temporary file name for the serialized `BuilderConfigFile` struct. +/// +/// This is used to achieve an atomic update of the contents on disk, without truncation. +pub const BUILDERS_TEMP_FILENAME: &str = ".builder_definitions.yml.tmp"; + +#[derive(Debug)] +pub enum Error { + /// The config file could not be opened. + UnableToOpenFile(io::Error), + /// The config file could not be parsed as YAML. + UnableToParseFile(yaml_serde::Error), + /// The builders file could not be serialized as YAML. + UnableToEncodeFile(yaml_serde::Error), + /// The builders file or temp file could not be written to the filesystem. + UnableToWriteFile(filesystem::Error), + /// The validator directory could not be created. + UnableToCreateValidatorDir(PathBuf), + /// A builder with the given URL already exists. + DuplicateBuilderAuth(BuilderUrl), + /// A builder URL could not be parsed as a URL. + InvalidBuilderUrl(BuilderUrl), + /// A builder URL does not use an `http`/`https` scheme. + UnsupportedUrlScheme(BuilderUrl), + /// More than `MAX_BUILDER_ENTRIES` builders are enabled, exceeding what fits in a + /// `BuilderConfig`. + TooManyEnabledBuilders { enabled: usize, max: usize }, +} + +/// A single builder in the config file: a direct bid request, with optional per-builder overrides +/// of the global bid policy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuilderDefinition { + /// Indicates whether this definition is enabled or disabled. + pub enabled: bool, + /// The URL the beacon node uses to contact this builder. Routing metadata; never signed. + pub url: BuilderUrl, + /// Opaque authentication data signed into `RequestAuth.data`, agreed with the builder out of + /// band, as a `0x`-prefixed hex string. When unset, it defaults to the UTF-8 bytes of `url` + /// (the builder-specs #165 default). Must be non-empty when set: a zero-length `data` is + /// invalid on the wire. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_option_auth_data" + )] + pub auth_data: Option, + /// The builder BLS public keys this builder's bids may be signed by, hex-encoded. Empty (or + /// omitted) accepts any builder; otherwise a bid not signed by one of them is rejected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub builder_pubkeys: Vec, + /// The maximum execution payment, in gwei, that we're willing to accept from this builder. + pub max_execution_payment: u64, + /// Per-builder override of the global minimum total payment (gwei). Inherits the global when + /// unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + /// Per-builder override of the global boost factor. Inherits the global when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, +} + +fn default_builder_boost_factor() -> u64 { + 100 +} + +/// Serde helper: represent `Option` as a `0x`-prefixed hex string in the config +/// file (matching how other byte fields are encoded), omitting it entirely when `None`. +mod serde_option_auth_data { + use super::RequestAuthData; + use serde::{Deserialize, Deserializer, Serializer, de}; + + pub fn serialize( + value: &Option, + serializer: S, + ) -> Result { + match value { + Some(data) => serializer.serialize_some(&format!("0x{}", hex::encode(&data[..]))), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let Some(s) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let stripped = s.strip_prefix("0x").unwrap_or(&s); + let bytes = hex::decode(stripped).map_err(de::Error::custom)?; + let data = RequestAuthData::new(bytes) + .map_err(|_| de::Error::custom("auth_data exceeds the maximum size"))?; + Ok(Some(data)) + } +} + +/// The validator client's builder configuration file. +/// +/// Holds the global bid-policy defaults plus the list of builders to request bids from directly. It +/// resolves into the wire `BuilderConfig` at block-production time: the globals govern p2p bids and +/// fill in any builder that omits `min_bid`/`builder_boost_factor`. +#[derive(Clone, Serialize, Deserialize)] +pub struct BuilderConfigFile { + /// Global minimum total payment (gwei). Applies to p2p bids and is inherited by any builder that + /// omits its own `min_bid`. + #[serde(default)] + pub min_bid: u64, + /// Global boost factor. Applies to p2p bids and is inherited by any builder that omits its own + /// `builder_boost_factor`. + #[serde(default = "default_builder_boost_factor")] + pub builder_boost_factor: u64, + /// The builders to request bids from directly. + #[serde(default)] + pub builders: Vec, +} + +impl Default for BuilderConfigFile { + fn default() -> Self { + Self { + min_bid: 0, + builder_boost_factor: default_builder_boost_factor(), + builders: Vec::new(), + } + } +} + +impl BuilderConfigFile { + /// Open an existing file or create a new, empty one if it does not exist. + pub fn open_or_create>(validators_dir: P) -> Result { + create_dir_all(validators_dir.as_ref()).map_err(|_| { + Error::UnableToCreateValidatorDir(PathBuf::from(validators_dir.as_ref())) + })?; + let builders_file_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + if !builders_file_path.exists() { + let this = Self::default(); + this.save(&validators_dir)?; + } + Self::open(validators_dir) + } + + /// Open an existing file, returning an error if the file does not exist. + pub fn open>(validators_dir: P) -> Result { + let config_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + let file = File::options() + .write(true) + .read(true) + .create_new(false) + .open(config_path) + .map_err(Error::UnableToOpenFile)?; + let config: Self = yaml_serde::from_reader(file).map_err(Error::UnableToParseFile)?; + config.validate()?; + Ok(config) + } + + /// Encodes `self` as a YAML string and atomically writes it to the `CONFIG_FILENAME` file in + /// the `validators_dir` directory. + /// + /// Will create a new file if it does not exist or overwrite any existing file. + pub fn save>(&self, validators_dir: P) -> Result<(), Error> { + let config_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + let temp_path = validators_dir.as_ref().join(BUILDERS_TEMP_FILENAME); + let mut bytes = vec![]; + yaml_serde::to_writer(&mut bytes, self).map_err(Error::UnableToEncodeFile)?; + + write_file_via_temporary(&config_path, &temp_path, &bytes) + .map_err(Error::UnableToWriteFile)?; + + Ok(()) + } + + pub fn as_slice(&self) -> &[BuilderDefinition] { + &self.builders + } + + pub fn push(&mut self, definition: BuilderDefinition) { + self.builders.push(definition); + } + + pub fn validate(&self) -> Result<(), Error> { + // The enabled builders must fit in a `BuilderConfig`'s bounded list, so + // `BuilderStore::builder_config` cannot overflow when constructing it. + let enabled = self.builders.iter().filter(|d| d.enabled).count(); + if enabled > MAX_BUILDER_ENTRIES { + return Err(Error::TooManyEnabledBuilders { + enabled, + max: MAX_BUILDER_ENTRIES, + }); + } + + let mut seen_auth_urls = HashSet::new(); + + for definition in &self.builders { + if !definition.enabled { + // ignore disabled builders + continue; + } + let url = &definition.url; + // Reject malformed or non-http(s) builder URLs here, at config load, rather than + // silently skipping them during block proposal. + let sensitive_url = url + .to_sensitive_url() + .map_err(|_| Error::InvalidBuilderUrl(url.clone()))?; + if !matches!(sensitive_url.expose_full().scheme(), "http" | "https") { + return Err(Error::UnsupportedUrlScheme(url.clone())); + } + + let auth = definition + .auth_data + .clone() + .unwrap_or_else(|| url.to_default_auth_data()); + // two entries cannot contain the same url and auth data + let key = (url.clone(), auth); + if !seen_auth_urls.insert(key) { + return Err(Error::DuplicateBuilderAuth(url.clone())); + } + } + + Ok(()) + } +} + +impl<'a> IntoIterator for &'a BuilderConfigFile { + type Item = &'a BuilderDefinition; + type IntoIter = std::slice::Iter<'a, BuilderDefinition>; + + fn into_iter(self) -> Self::IntoIter { + self.builders.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_data_round_trips_as_hex() { + let definition = BuilderDefinition { + enabled: true, + url: "http://builder.example.com".parse().unwrap(), + auth_data: Some(RequestAuthData::new(b"hello".to_vec()).unwrap()), + builder_pubkeys: vec![], + max_execution_payment: 1, + min_bid: None, + builder_boost_factor: None, + }; + + let yaml = yaml_serde::to_string(&definition).unwrap(); + // "hello" is 0x68656c6c6f, a hex string — not a YAML sequence of byte values. + assert!( + yaml.contains("0x68656c6c6f"), + "auth_data not hex-encoded:\n{yaml}" + ); + + let decoded: BuilderDefinition = yaml_serde::from_str(&yaml).unwrap(); + assert_eq!(decoded, definition); + } + + #[test] + fn omits_none_optional_fields() { + let definition = BuilderDefinition { + enabled: true, + url: "http://builder.example.com".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: 1, + min_bid: None, + builder_boost_factor: None, + }; + let yaml = yaml_serde::to_string(&definition).unwrap(); + for field in [ + "auth_data", + "builder_pubkeys", + "min_bid", + "builder_boost_factor", + ] { + assert!( + !yaml.contains(field), + "unset `{field}` should be omitted:\n{yaml}" + ); + } + } +} diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs new file mode 100644 index 00000000000..d17c3c73260 --- /dev/null +++ b/validator_client/builder_store/src/lib.rs @@ -0,0 +1,140 @@ +mod builder_definitions; +use builder_definitions::BuilderConfigFile; +pub use builder_definitions::{BuilderDefinition, Error}; +use builder_types::{ + BuilderConfig, BuilderEntry, BuilderPubkeys, RequestAuthData, SignedRequestAuth, +}; +use parking_lot::RwLock; +use ssz_types::VariableList; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tracing::error; + +#[derive(Clone)] +pub struct BuilderStore { + config: Arc>, + validators_dir: PathBuf, +} + +impl BuilderStore { + pub fn open_or_create>(validators_dir: P) -> Result { + let validators_dir = validators_dir.as_ref().to_path_buf(); + + Ok(Self { + config: Arc::new(RwLock::new(BuilderConfigFile::open_or_create( + &validators_dir, + )?)), + validators_dir, + }) + } + + /// Resolve the enabled builders into a wire [`BuilderConfig`], signing each builder's request + /// auth via `sign`. + /// + /// Per-builder `min_bid`/`builder_boost_factor` inherit the global defaults when unset, and each + /// builder's `auth_data` defaults to the UTF-8 bytes of its URL when unset. `sign` receives a + /// builder's opaque auth `data` and returns the corresponding `SignedRequestAuth` — in + /// practice signed for the current proposer/slot and cached. + /// + /// Signing is per-builder: a builder whose auth `sign` fails to produce is logged (with the + /// returned error) and omitted, so one unsignable builder cannot drop the rest. The returned + /// config always carries the global policy; its `builders` list holds only the successfully + /// signed builders, and is empty when no builders are enabled or every one failed to sign. + pub async fn builder_config(&self, sign: F) -> BuilderConfig + where + F: Fn(RequestAuthData) -> Fut, + Fut: Future>, + E: std::fmt::Debug, + { + // Snapshot the enabled builders and the global policy under the lock, then sign outside it, + // so the lock is never held across an `.await`. + let (definitions, min_bid, builder_boost_factor) = { + let config = self.config.read(); + let definitions: Vec = config + .as_slice() + .iter() + .filter(|d| d.enabled) + .cloned() + .collect(); + (definitions, config.min_bid, config.builder_boost_factor) + }; + + // Sign every builder's request auth concurrently. With a remote signer each `sign` is a + // network round trip, and the signatures are independent, so signing in sequence would put + // up to `MaxBuilderEntries` serial round trips on the block-production critical path. + let signed = futures::future::join_all(definitions.into_iter().filter_map(|definition| { + let auth_data = definition + .auth_data + .clone() + .unwrap_or_else(|| definition.url.to_default_auth_data()); + // A zero-length auth `data` is invalid on the wire (beacon-specs #165 / beacon-APIs + // #630); the beacon node would reject the whole request body, so drop the builder here. + if auth_data.is_empty() { + error!( + builder_url = %definition.url, + "Zero-length auth_data is invalid; omitting builder from config" + ); + return None; + } + let signing = sign(auth_data); + Some(async move { (definition, signing.await) }) + })) + .await; + + // `join_all` preserves input order, so `builders` keeps the configured order. Omit any + // builder we cannot sign for, logging the error, rather than failing the whole config. + let mut builders = Vec::with_capacity(signed.len()); + for (definition, result) in signed { + let auth = match result { + Ok(auth) => auth, + Err(e) => { + error!( + error = ?e, + builder_url = %definition.url, + "Failed to sign builder request auth; omitting builder from config" + ); + continue; + } + }; + let Ok(builder_pubkeys) = BuilderPubkeys::new(definition.builder_pubkeys) else { + error!( + builder_url = %definition.url, + "Too many builder pubkeys; omitting builder from config" + ); + continue; + }; + builders.push(BuilderEntry { + url: definition.url, + auth, + builder_pubkeys, + max_execution_payment: definition.max_execution_payment, + min_bid: definition.min_bid.unwrap_or(min_bid), + builder_boost_factor: definition + .builder_boost_factor + .unwrap_or(builder_boost_factor), + }); + } + + BuilderConfig { + // The number of builders is bounded by `MaxBuilderEntries` at config load, so this + // cannot overflow. + builders: VariableList::new(builders) + .expect("builder count is bounded by MaxBuilderEntries at config load"), + min_bid, + builder_boost_factor, + } + } + + pub fn insert(&self, builder: BuilderDefinition) -> Result<(), Error> { + let mut config = self.config.write(); + // Validate a candidate copy before committing, so a bad insert leaves the config unchanged + // (and the global bid-policy defaults are preserved). + let mut candidate = config.clone(); + candidate.push(builder); + candidate.validate()?; + + *config = candidate; + config.save(&self.validators_dir) + } +} diff --git a/validator_client/lighthouse_validator_store/Cargo.toml b/validator_client/lighthouse_validator_store/Cargo.toml index 55d5f1cf32e..2020280e0bc 100644 --- a/validator_client/lighthouse_validator_store/Cargo.toml +++ b/validator_client/lighthouse_validator_store/Cargo.toml @@ -8,6 +8,7 @@ authors = ["Sigma Prime "] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_types = { workspace = true } doppelganger_service = { workspace = true } either = { workspace = true } environment = { workspace = true } diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index ce2b85f3af5..d98e80cf454 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -1,5 +1,6 @@ use account_utils::validator_definitions::{PasswordStorage, ValidatorDefinition}; use bls::{AggregateSignature, PublicKeyBytes, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use doppelganger_service::DoppelgangerService; use eth2::types::PublishBlockRequest; use futures::{Stream, future::join_all, stream}; @@ -1502,4 +1503,28 @@ impl ValidatorStore for LighthouseValidatorS signature, }) } + + async fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> Result { + let domain_hash = self.spec.get_request_auth_domain(); + let signing_root = request_auth_v1.signing_root(domain_hash); + + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + let signature = signing_method + .get_signature_from_root::>( + SignableMessage::RequestAuth(&request_auth_v1), + signing_root, + &self.task_executor, + None, + ) + .await?; + + Ok(SignedRequestAuth { + message: request_auth_v1, + signature, + }) + } } diff --git a/validator_client/signing_method/Cargo.toml b/validator_client/signing_method/Cargo.toml index cb321c2d498..2a33382d5e8 100644 --- a/validator_client/signing_method/Cargo.toml +++ b/validator_client/signing_method/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } eth2_keystore = { workspace = true } ethereum_serde_utils = { workspace = true } lockfile = { workspace = true } diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index 0dfde989464..f877afeaa7e 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -4,6 +4,7 @@ //! - Via a remote signer (Web3Signer) use bls::{Keypair, PublicKey, Signature}; +use builder_types::RequestAuth; use eth2_keystore::Keystore; use lockfile::Lockfile; use parking_lot::Mutex; @@ -52,6 +53,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + RequestAuth(&'a RequestAuth), } impl> SignableMessage<'_, E, Payload> { @@ -76,6 +78,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), SignableMessage::PayloadAttestationData(d) => d.signing_root(domain), SignableMessage::ProposerPreferences(p) => p.signing_root(domain), + SignableMessage::RequestAuth(r) => r.signing_root(domain), } } } @@ -248,6 +251,7 @@ impl SigningMethod { SignableMessage::ProposerPreferences(p) => { Web3SignerObject::ProposerPreferences(p) } + SignableMessage::RequestAuth(r) => Web3SignerObject::RequestAuth(r), }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index 8548a933e66..505147a46d4 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -2,6 +2,7 @@ use super::Error; use bls::{PublicKeyBytes, Signature}; +use builder_types::RequestAuth; use serde::{Deserialize, Serialize}; use types::*; @@ -23,6 +24,7 @@ pub enum MessageType { ExecutionPayloadEnvelope, PayloadAttestation, ProposerPreferences, + RequestAuth, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -83,6 +85,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + RequestAuth(&'a RequestAuth), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -156,6 +159,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation, Web3SignerObject::ProposerPreferences(_) => MessageType::ProposerPreferences, + Web3SignerObject::RequestAuth(_) => MessageType::RequestAuth, } } } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 88844918431..7697a08c45b 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; use crate::cli::ValidatorClient; use crate::duties_service::SelectionProofConfig; +use builder_store::BuilderStore; pub use config::Config; use initialized_validators::InitializedValidators; use metrics::set_gauge; @@ -43,11 +44,13 @@ use validator_services::notifier_service::spawn_notifier; use validator_services::{ attestation_service::{AttestationService, AttestationServiceBuilder}, block_service::{BlockService, BlockServiceBuilder}, + builder_preferences_service::BuilderPreferencesService, duties_service::{self, DutiesService, DutiesServiceBuilder}, latency_service, payload_attestation_service::PayloadAttestationService, preparation_service::{PreparationService, PreparationServiceBuilder}, proposer_preferences_service::ProposerPreferencesService, + request_auth_cache::RequestAuthCache, sync_committee_service::SyncCommitteeService, }; use validator_store::ValidatorStore as ValidatorStoreTrait; @@ -91,6 +94,7 @@ pub struct ProductionValidatorClient { doppelganger_service: Option>, preparation_service: PreparationService, SystemTimeSlotClock>, validator_store: Arc>, + builder_preferences_service: BuilderPreferencesService, SystemTimeSlotClock>, slot_clock: SystemTimeSlotClock, http_api_listen_addr: Option, config: Config, @@ -513,6 +517,10 @@ impl ProductionValidatorClient { ctx.shared.write().duties_service = Some(duties_service.clone()); } + let configured_builders = BuilderStore::open_or_create(&config.validator_dir) + .map_err(|e| format!("Unable to open or create builder definitions: {:?}", e))?; + let request_auth_cache = RequestAuthCache::default(); + let mut block_service_builder = BlockServiceBuilder::new() .slot_clock(slot_clock.clone()) .validator_store(validator_store.clone()) @@ -521,7 +529,9 @@ impl ProductionValidatorClient { .chain_spec(context.eth2_config.spec.clone()) .graffiti(config.graffiti) .graffiti_file(config.graffiti_file.clone()) - .graffiti_policy(config.graffiti_policy); + .graffiti_policy(config.graffiti_policy) + .configured_builders(configured_builders.clone()) + .request_auth_cache(request_auth_cache.clone()); // If we have proposer nodes, add them to the block service builder. if proposer_nodes_num > 0 { @@ -577,6 +587,17 @@ impl ProductionValidatorClient { context.eth2_config.spec.clone(), ); + let builder_preferences_service = BuilderPreferencesService::new( + duties_service.clone(), + validator_store.clone(), + slot_clock.clone(), + beacon_nodes.clone(), + configured_builders.clone(), + request_auth_cache.clone(), + context.executor.clone(), + context.eth2_config.spec.clone(), + ); + Ok(Self { context, duties_service, @@ -588,6 +609,7 @@ impl ProductionValidatorClient { doppelganger_service, preparation_service, validator_store, + builder_preferences_service, config, slot_clock, http_api_listen_addr: None, @@ -667,6 +689,11 @@ impl ProductionValidatorClient { .clone() .start_update_service() .map_err(|e| format!("Unable to start proposer preferences service: {}", e))?; + + self.builder_preferences_service + .clone() + .start_update_service() + .map_err(|e| format!("Unable to start builder preferences service: {}", e))?; } self.preparation_service diff --git a/validator_client/validator_services/Cargo.toml b/validator_client/validator_services/Cargo.toml index 625eee85bdb..5fb3137057e 100644 --- a/validator_client/validator_services/Cargo.toml +++ b/validator_client/validator_services/Cargo.toml @@ -7,6 +7,8 @@ authors = ["Sigma Prime "] [dependencies] beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_store = { workspace = true } +builder_types = { workspace = true } either = { workspace = true } eth2 = { workspace = true } futures = { workspace = true } diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 7531543a187..b2cfa138e88 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -1,5 +1,7 @@ +use crate::request_auth_cache::RequestAuthCache; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, Error as FallbackError, Errors}; use bls::PublicKeyBytes; +use builder_store::BuilderStore; use eth2::BeaconNodeHttpClient; use eth2::types::GraffitiPolicy; use graffiti_file::{GraffitiFile, determine_graffiti}; @@ -53,6 +55,8 @@ pub struct BlockServiceBuilder { graffiti: Option, graffiti_file: Option, graffiti_policy: Option, + configured_builders: Option, + request_auth_cache: Option, } impl BlockServiceBuilder { @@ -67,6 +71,8 @@ impl BlockServiceBuilder { graffiti: None, graffiti_file: None, graffiti_policy: None, + configured_builders: None, + request_auth_cache: None, } } @@ -115,6 +121,16 @@ impl BlockServiceBuilder { self } + pub fn configured_builders(mut self, configured_builders: BuilderStore) -> Self { + self.configured_builders = Some(configured_builders); + self + } + + pub fn request_auth_cache(mut self, request_auth_cache: RequestAuthCache) -> Self { + self.request_auth_cache = Some(request_auth_cache); + self + } + pub fn build(self) -> Result, String> { Ok(BlockService { inner: Arc::new(Inner { @@ -137,6 +153,12 @@ impl BlockServiceBuilder { graffiti: self.graffiti, graffiti_file: self.graffiti_file, graffiti_policy: self.graffiti_policy, + configured_builders: self + .configured_builders + .ok_or("Cannot build BlockService without configured_builders")?, + request_auth_cache: self + .request_auth_cache + .ok_or("Cannot build BlockService without request_auth_cache")?, }), }) } @@ -203,6 +225,11 @@ pub struct Inner { graffiti: Option, graffiti_file: Option, graffiti_policy: Option, + /// The configured builders to resolve into a `BuilderConfig` when producing a Gloas block. + configured_builders: BuilderStore, + /// Caches the per-(slot, proposer, auth_data) request-auth signatures reused when resolving the + /// builder config. + request_auth_cache: RequestAuthCache, } /// Attempts to produce attestations for any block producer(s) at the start of the epoch. @@ -339,6 +366,7 @@ impl BlockService { graffiti: Option, validator_pubkey: &PublicKeyBytes, unsigned_block: UnsignedBlock, + builder_url: Option, ) -> Result<(), BlockError> { let signing_timer = validator_metrics::start_timer(&validator_metrics::BLOCK_SIGNING_TIMES); @@ -383,9 +411,10 @@ impl BlockService { // Try the proposer nodes first, since we've likely gone to efforts to // protect them from DoS attacks and they're most likely to successfully // publish a block. + let builder_url_ref = builder_url.as_deref(); proposer_fallback .request_proposers_first(|beacon_node| async { - self.publish_signed_block_contents(&signed_block, beacon_node) + self.publish_signed_block_contents(&signed_block, beacon_node, builder_url_ref) .await }) .await?; @@ -463,7 +492,35 @@ impl BlockService { // Check if Gloas fork is active at this slot let fork_name = self_ref.chain_spec.fork_name_at_slot::(slot); - let (block_proposer, unsigned_block) = if fork_name.gloas_enabled() { + let (block_proposer, unsigned_block, builder_url) = if fork_name.gloas_enabled() { + // Resolve the validator's builder config for this proposal, signing each builder's + // request auth via the cache. Sent in the POST `produceBlockV4` body below (the same + // body is reused on the SSZ-to-JSON fallback and on every proposer-fallback BN). With + // no builders configured this resolves to an empty list, so the proposal still falls + // back to a local or p2p payload. Per-builder sign failures are logged and omitted + // inside `builder_config`, so this never fails the proposal. + let builder_config = self_ref + .configured_builders + .builder_config(|auth_data| { + self_ref.request_auth_cache.get_or_sign( + slot, + validator_pubkey, + auth_data, + |request_auth_v1| { + self_ref + .validator_store + .sign_request_auth_v1(validator_pubkey, request_auth_v1) + }, + ) + }) + .await; + debug!( + slot = slot.as_u64(), + builders = builder_config.builders.len(), + "Resolved builder config for block production" + ); + let builder_config_ref = &builder_config; + // Use V4 block production for Gloas // Request an SSZ block from all beacon nodes in order, returning on the first successful response. // If all nodes fail, run a second pass falling back to JSON. @@ -474,20 +531,25 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_GET], ); beacon_node - .get_validator_blocks_v4_ssz::( + .post_validator_blocks_v4_ssz::( slot, randao_reveal_ref, graffiti.as_ref(), false, - builder_boost_factor, + builder_config_ref, self_ref.graffiti_policy, + fork_name, ) .await }) .await; - let block_response = match ssz_block_response { - Ok((ssz_block_response, _metadata)) => ssz_block_response.into_block(), + // `builder_url` is the `Eth-Builder-Url` from the winning beacon node — echoed on publish + // so it forwards the block to the builder that won selection. + let (block_response, builder_url) = match ssz_block_response { + Ok((ssz_block_response, metadata)) => { + (ssz_block_response.into_block(), metadata.builder_url) + } Err(e) => { warn!( slot = slot.as_u64(), @@ -501,14 +563,15 @@ impl BlockService { &validator_metrics::BLOCK_SERVICE_TIMES, &[validator_metrics::BEACON_BLOCK_HTTP_GET], ); - let (json_block_response, _metadata) = beacon_node - .get_validator_blocks_v4::( + let (json_block_response, metadata) = beacon_node + .post_validator_blocks_v4::( slot, randao_reveal_ref, graffiti.as_ref(), false, - builder_boost_factor, + builder_config_ref, self_ref.graffiti_policy, + fork_name, ) .await .map_err(|e| { @@ -518,7 +581,7 @@ impl BlockService { )) })?; - Ok(json_block_response.into_block()) + Ok((json_block_response.into_block(), metadata.builder_url)) }) .await .map_err(BlockError::from)? @@ -530,6 +593,7 @@ impl BlockService { ( block_contents.block().proposer_index(), UnsignedBlock::Full(block_contents), + builder_url, ) } else { // Use V3 block production for pre-Gloas forks @@ -594,12 +658,16 @@ impl BlockService { } }; + // Pre-Gloas has no builder-URL provenance (the V3 mev-boost path handles builder + // forwarding itself), so there's nothing to echo on publish. match block_response { - eth2::types::ProduceBlockV3Response::Full(block) => { - (block.block().proposer_index(), UnsignedBlock::Full(block)) - } + eth2::types::ProduceBlockV3Response::Full(block) => ( + block.block().proposer_index(), + UnsignedBlock::Full(block), + None, + ), eth2::types::ProduceBlockV3Response::Blinded(block) => { - (block.proposer_index(), UnsignedBlock::Blinded(block)) + (block.proposer_index(), UnsignedBlock::Blinded(block), None) } } }; @@ -623,6 +691,7 @@ impl BlockService { graffiti, &validator_pubkey, unsigned_block, + builder_url, ) .await?; @@ -742,6 +811,7 @@ impl BlockService { &self, signed_block: &SignedBlock, beacon_node: BeaconNodeHttpClient, + builder_url: Option<&str>, ) -> Result<(), BlockError> { match signed_block { SignedBlock::Full(signed_block) => { @@ -750,7 +820,7 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_POST], ); beacon_node - .post_beacon_blocks_v2_ssz(signed_block, None, None) + .post_beacon_blocks_v2_ssz(signed_block, None, builder_url) .await .map(|_| ()) .or_else(|e| { @@ -854,6 +924,10 @@ mod tests { .beacon_nodes(harness.beacon_nodes.clone()) .executor(harness.test_runtime.task_executor.clone()) .chain_spec(harness.spec.clone()) + .request_auth_cache(RequestAuthCache::default()) + .configured_builders( + BuilderStore::open_or_create(harness._validator_dir.path()).unwrap(), + ) .build() .unwrap(); @@ -880,7 +954,11 @@ mod tests { let mock_different_slot = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, different_notification_slot); + .mock_post_validator_blocks_v4_ssz( + &block, + ForkName::Gloas, + different_notification_slot, + ); test_harness .service @@ -902,7 +980,7 @@ mod tests { let mock_same_slot = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, same_notification_slot); + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, same_notification_slot); test_harness .service @@ -937,7 +1015,7 @@ mod tests { test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); let mock_post_block = test_harness .harness .mock_beacon_node_1 @@ -1000,11 +1078,11 @@ mod tests { let mock_bn_1 = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_bn_2 = test_harness .harness .mock_beacon_node_2 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_post_block = test_harness .harness @@ -1048,11 +1126,11 @@ mod tests { let mock_ssz = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_json = test_harness .harness .mock_beacon_node_2 - .mock_get_validator_blocks_v4(&block, ForkName::Gloas, slot); + .mock_post_validator_blocks_v4(&block, ForkName::Gloas, slot); let _result = test_harness .service diff --git a/validator_client/validator_services/src/builder_preferences_service.rs b/validator_client/validator_services/src/builder_preferences_service.rs new file mode 100644 index 00000000000..203c236588b --- /dev/null +++ b/validator_client/validator_services/src/builder_preferences_service.rs @@ -0,0 +1,308 @@ +use crate::duties_service::DutiesService; +use crate::request_auth_cache::RequestAuthCache; +use beacon_node_fallback::BeaconNodeFallback; +use bls::PublicKeyBytes; +use builder_store::BuilderStore; +use builder_types::{BuilderEntry, BuilderUrl, RequestAuthData}; +use eth2::types::{ + BuilderPreferenceEntry, MAX_SUBMITTED_BUILDER_PREFERENCES, SubmittedBuilderPreferences, +}; +use slot_clock::SlotClock; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; +use task_executor::TaskExecutor; +use tokio::time::sleep; +use tracing::{debug, error, info}; +use types::{ChainSpec, EthSpec, Slot}; +use validator_store::ValidatorStore; + +/// The non-slot part of a published entry's identity: the proposer pubkey plus the decomposed +/// `BuilderPreferenceEntry` with its `slot` factored out to the enclosing map's key. +/// - `pubkey`: the proposer the entry was submitted for +/// - `url`: `entry.url` +/// - `auth_data`: `entry.auth.message.data` +/// - `max_execution_payment`: `entry.max_execution_payment` +/// +/// See [`PublishedBuilderPreferencesCache`] for how `entry.auth` decomposes into `auth_data` here +/// and `slot` at the map level, and why the `auth` signature is dropped. +#[derive(PartialEq, Eq, Hash)] +struct InnerPreferencesKey { + pubkey: PublicKeyBytes, + url: BuilderUrl, + auth_data: RequestAuthData, + max_execution_payment: u64, +} + +/// De-duplicates the `BuilderPreferenceEntry`s we've already published, so we don't re-send one. +/// +/// The identity of a published entry is `(proposer_pubkey, decompose(entry))`. That decomposition is +/// split across the two levels of this map: +/// - `entry.auth.message.slot` becomes the outer `BTreeMap` key; +/// - the rest — `proposer_pubkey`, `entry.url`, `entry.auth.message.data`, and +/// `entry.max_execution_payment` — forms the [`InnerPreferencesKey`] held in the per-slot set. +/// +/// So `entry.auth` decomposes into its `slot` (the map key) and its `data`/`auth_data` (in the inner +/// key); the `auth` signature is dropped, as it is a deterministic function of the proposer, the +/// `auth_data`, and the slot and so adds no identity. +/// +/// Operators may change their builder config at any time. Because this identity captures every entry +/// field that reaches a builder, any edit yields a new key that won't match a previously-sent entry, +/// so the updated preference is published again. +#[derive(Default)] +struct PublishedBuilderPreferencesCache { + cache: BTreeMap>, +} + +impl PublishedBuilderPreferencesCache { + pub fn new() -> Self { + Self::default() + } + + pub fn contains( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + builder_entry: &BuilderEntry, + ) -> bool { + self.cache.get(&slot).is_some_and(|set| { + set.contains(&InnerPreferencesKey { + pubkey, + url: builder_entry.url.clone(), + auth_data: builder_entry.auth.message.data.clone(), + max_execution_payment: builder_entry.max_execution_payment, + }) + }) + } + + pub fn mark_sent( + &mut self, + pubkey: PublicKeyBytes, + builder_preferences_entry: BuilderPreferenceEntry, + ) { + let slot = builder_preferences_entry.auth.message.slot; + let inner_key = InnerPreferencesKey { + pubkey, + url: builder_preferences_entry.url, + auth_data: builder_preferences_entry.auth.message.data, + max_execution_payment: builder_preferences_entry.max_execution_payment, + }; + self.cache.entry(slot).or_default().insert(inner_key); + } + + pub fn prune(&mut self, current_slot: Slot) { + self.cache = self.cache.split_off(¤t_slot); + } +} + +// Minimizes `Arc` usage +struct Inner { + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + configured_builders: BuilderStore, + request_auth_cache: RequestAuthCache, + executor: TaskExecutor, + chain_spec: Arc, +} + +pub struct BuilderPreferencesService { + inner: Arc>, +} + +// Generic clone implementation is too dumb to do this +impl Clone for BuilderPreferencesService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl BuilderPreferencesService { + #[allow(clippy::too_many_arguments)] + pub fn new( + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + configured_builders: BuilderStore, + request_auth_cache: RequestAuthCache, + executor: TaskExecutor, + chain_spec: Arc, + ) -> Self { + Self { + inner: Arc::new(Inner { + duties_service, + validator_store, + slot_clock, + beacon_nodes, + configured_builders, + request_auth_cache, + executor, + chain_spec, + }), + } + } + + pub fn start_update_service(self) -> Result<(), String> { + let slot_duration = self.inner.chain_spec.get_slot_duration(); + info!("Builder preferences service started"); + + let executor = self.inner.executor.clone(); + + let interval_fut = async move { + let mut published_preferences = PublishedBuilderPreferencesCache::new(); + + loop { + let Some(current_slot) = self.inner.slot_clock.now() else { + error!("Failed to read slot clock"); + sleep(slot_duration).await; + continue; + }; + + self.poll_and_publish_preferences(current_slot, &mut published_preferences) + .await; + + published_preferences.prune(current_slot); + self.inner.request_auth_cache.prune(current_slot); + + let duration_to_next_slot = self + .inner + .slot_clock + .duration_to_next_slot() + .unwrap_or(slot_duration); + sleep(duration_to_next_slot).await; + } + }; + + executor.spawn(interval_fut, "builder_preferences_service"); + Ok(()) + } + + /// Publish builder preferences for `current_epoch` and `current_epoch + 1`. + /// Will only publish a given `(proposer, builder, max_execution_payment)` preference once. + async fn poll_and_publish_preferences( + &self, + current_slot: Slot, + published_preferences: &mut PublishedBuilderPreferencesCache, + ) { + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + // One flat request whose body spans both epochs, each entry naming its own proposer + // (beacon-APIs #630, whose body is sized for several epochs of entries). The single + // `Eth-Consensus-Version` is the version active now, at submission time. + let current_fork = self.inner.chain_spec.fork_name_at_epoch(current_epoch); + let mut pending_entries: Vec = Vec::new(); + + for (epoch, fork_name) in [ + ( + current_epoch, + self.inner.chain_spec.fork_name_at_epoch(current_epoch), + ), + ( + current_epoch + 1, + self.inner.chain_spec.fork_name_at_epoch(current_epoch + 1), + ), + ] { + if !fork_name.gloas_enabled() { + continue; + } + + let proposers = match self.inner.duties_service.proposers.read().get(&epoch) { + Some((_, proposers)) => proposers.clone(), + None => continue, + }; + + for proposer_data in &proposers { + let slot = proposer_data.slot; + let pubkey = proposer_data.pubkey; + + // Resolve and sign the whole builder config for this proposer/slot. Auths are + // cached, so builders already published for this slot cost only a cache hit. + // Per-builder sign failures are logged and omitted inside `builder_config`, so a + // fully-failed set just yields an empty `builders` list (nothing to publish). + let config = self + .inner + .configured_builders + .builder_config(|auth_data| { + self.inner.request_auth_cache.get_or_sign( + slot, + pubkey, + auth_data, + |request_auth_v1| { + self.inner + .validator_store + .sign_request_auth_v1(pubkey, request_auth_v1) + }, + ) + }) + .await; + + // A `BuilderPreferenceEntry` is a `BuilderEntry` narrowed to what a builder may see: + // its private `min_bid`/`builder_boost_factor`/`builder_pubkeys` are dropped. + for entry in config.builders.iter() { + if published_preferences.contains(slot, pubkey, entry) { + // already published, skip + continue; + } + pending_entries.push(BuilderPreferenceEntry::from_builder_entry( + pubkey, + entry.clone(), + )); + } + } + } + + if pending_entries.is_empty() { + return; + } + + // One submission carries at most `MAX_SUBMITTED_BUILDER_PREFERENCES` entries (beacon-APIs + // #630), so submit in bounded chunks. Each chunk is best-effort: a failed chunk is logged + // and does not stop the rest. + for chunk in pending_entries.chunks(MAX_SUBMITTED_BUILDER_PREFERENCES) { + let Ok(entries) = SubmittedBuilderPreferences::new(chunk.to_vec()) else { + // Unreachable: `chunks()` bounds each chunk by the list limit. + continue; + }; + let entries_ref = &entries; + + // Try SSZ first, falling back to JSON. `first_success` is okay here because later + // we'll be resending the auths when we publish the beacon block. + let ssz_result = self + .inner + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_builder_preferences_ssz(entries_ref, current_fork) + .await + }) + .await; + + let result = match ssz_result { + Ok(()) => Ok(()), + Err(ssz_err) => { + debug!(error = %ssz_err, "SSZ builder preferences publish failed, falling back to JSON"); + self.inner + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_builder_preferences(entries_ref, current_fork) + .await + }) + .await + } + }; + + match result { + Ok(()) => { + for entry in entries.iter().cloned() { + let pubkey = entry.proposer_pubkey; + published_preferences.mark_sent(pubkey, entry); + } + } + Err(e) => error!(error = %e, "Failed to publish builder preferences"), + } + } + } +} diff --git a/validator_client/validator_services/src/lib.rs b/validator_client/validator_services/src/lib.rs index c39ef4499b7..3db106ac692 100644 --- a/validator_client/validator_services/src/lib.rs +++ b/validator_client/validator_services/src/lib.rs @@ -1,10 +1,12 @@ pub mod attestation_service; pub mod block_service; +pub mod builder_preferences_service; pub mod duties_service; pub mod latency_service; pub mod notifier_service; pub mod payload_attestation_service; pub mod preparation_service; pub mod proposer_preferences_service; +pub mod request_auth_cache; pub mod sync; pub mod sync_committee_service; diff --git a/validator_client/validator_services/src/request_auth_cache.rs b/validator_client/validator_services/src/request_auth_cache.rs new file mode 100644 index 00000000000..4d9e553d8be --- /dev/null +++ b/validator_client/validator_services/src/request_auth_cache.rs @@ -0,0 +1,106 @@ +use bls::PublicKeyBytes; +use builder_types::{RequestAuth, RequestAuthData, SignedRequestAuth}; +use parking_lot::RwLock; +use std::collections::{BTreeMap, HashMap}; +use std::future::Future; +use std::sync::Arc; +use types::Slot; + +/// Caches signed `RequestAuth` objects so a given proposer/auth-data/slot combination is only +/// signed once. +/// +/// The signed authorization is a pure function of the proposer pubkey, the opaque `auth_data`, and +/// the proposal `slot`, so those form the cache key. The builder URL is deliberately *not* part of +/// the key: two builders configured with the same `auth_data` share one signature. +#[derive(Hash, PartialEq, Eq)] +struct RequestAuthInnerKey { + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, +} + +#[derive(Default)] +struct Inner { + entries: BTreeMap>, +} + +#[derive(Clone)] +pub struct RequestAuthCache { + inner: Arc>, +} + +impl Default for RequestAuthCache { + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(Inner::default())), + } + } +} + +impl RequestAuthCache { + pub fn get( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: &RequestAuthData, + ) -> Option { + self.inner.read().entries.get(&slot).and_then(|entries| { + let key = RequestAuthInnerKey { + pubkey, + auth_data: auth_data.clone(), + }; + entries.get(&key).cloned() + }) + } + + pub fn insert( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, + signed_request_auth: SignedRequestAuth, + ) { + let key = RequestAuthInnerKey { pubkey, auth_data }; + + self.inner + .write() + .entries + .entry(slot) + .or_default() + .insert(key, signed_request_auth); + } + + /// Return the cached signature for `(slot, pubkey, auth_data)`, or produce it via `sign` (and + /// cache the result) on a miss. + /// + /// The signature is a pure function of the proposer, `auth_data`, and slot, so a hit returns + /// immediately without invoking `sign`. `sign` receives the fully-formed `RequestAuth` to + /// sign — in practice `ValidatorStore::sign_request_auth_v1`. + pub async fn get_or_sign( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, + sign: F, + ) -> Result + where + F: FnOnce(RequestAuth) -> Fut, + Fut: Future>, + { + if let Some(signed) = self.get(slot, pubkey, &auth_data) { + return Ok(signed); + } + + let signed = sign(RequestAuth { + data: auth_data.clone(), + slot, + }) + .await?; + self.insert(slot, pubkey, auth_data, signed.clone()); + Ok(signed) + } + + pub fn prune(&self, current_slot: Slot) { + let mut guard = self.inner.write(); + guard.entries = guard.entries.split_off(¤t_slot); + } +} diff --git a/validator_client/validator_store/Cargo.toml b/validator_client/validator_store/Cargo.toml index 2c6a68d4949..092f927589f 100644 --- a/validator_client/validator_store/Cargo.toml +++ b/validator_client/validator_store/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } eth2 = { workspace = true } futures = { workspace = true } slashing_protection = { workspace = true } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index dde82a2a5bb..6b2257af198 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -1,4 +1,5 @@ use bls::{PublicKeyBytes, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use eth2::types::{FullBlockContents, PublishBlockRequest}; use futures::Stream; use slashing_protection::NotSafe; @@ -213,6 +214,12 @@ pub trait ValidatorStore: Send + Sync { preferences: ProposerPreferences, ) -> impl Future>> + Send; + fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> impl Future>> + Send; + /// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`. /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. diff --git a/wordlist.txt b/wordlist.txt index f0076e63322..1fd0e7603d9 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -108,6 +108,7 @@ UI Uncached UPnP USD +UTF UX Validator VC @@ -150,6 +151,7 @@ doppelgänger dropdown else's env +ePBS eth ethdo ethereum From a03f18662879142178dd29f5a29f62357551a150 Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:53:50 -0500 Subject: [PATCH 10/11] Convert produceBlockV4 to POST and round-trip Eth-Builder-Url (Gloas builder API 4/5) Fourth PR of the Gloas builder API stack (beacon-APIs #630): - convert `/eth/v4/validator/blocks/{slot}` to POST with an optional `BuilderConfig` body (min_bid, builder_boost_factor, direct builders) - add `POST /eth/v1/validator/builder_preferences` for forwarding signed builder preferences - set `Eth-Builder-Url` on produceBlockV4 responses when a direct-builder bid wins, accept it on `POST /eth/v2/beacon/blocks`, and forward the signed block to that builder The validator client still uses the legacy GET methods at this point; it migrates in the final PR of this stack. Change-Id: I0ad30b8f36ad9b588ea1a0398220f92c9597bb95 --- .../direct_verified_bid.rs | 42 +++- .../gossip_verified_bid.rs | 36 ++- .../builder_client/src/builder_http_client.rs | 18 +- beacon_node/http_api/src/lib.rs | 40 +++- beacon_node/http_api/src/produce_block.rs | 49 ++-- beacon_node/http_api/src/publish_blocks.rs | 70 ++++++ beacon_node/http_api/src/validator/mod.rs | 216 +++++++++++++++++- beacon_node/http_api/src/version.rs | 13 +- .../tests/broadcast_validation_tests.rs | 4 +- .../http_api/tests/gloas_reorg_tests.rs | 12 +- .../http_api/tests/interactive_tests.rs | 10 +- beacon_node/http_api/tests/tests.rs | 203 +++++++++++++++- 12 files changed, 660 insertions(+), 53 deletions(-) diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs index 9983df131d8..f57d5628695 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs @@ -1,6 +1,6 @@ use crate::payload_bid_verification::{ PayloadBidError, - gossip_verified_bid::{is_gas_limit_target_compatible, verify_bid_consistency}, + gossip_verified_bid::{is_gas_limit_target_compatible, verify_direct_bid_consistency}, }; use eth2::types::BuilderPubkeys; use state_processing::signature_sets::{ @@ -14,7 +14,8 @@ use types::{ /// Fully validate a bid fetched directly from a builder, for inclusion in a block being produced. /// /// This performs all validation a direct builder bid must pass before it can be selected: -/// - the consensus-consistency checks shared with the gossip verifier via [`verify_bid_consistency`] +/// - the consensus-consistency checks shared with the gossip verifier, bundled for this path in +/// [`verify_direct_bid_consistency`] /// (fee recipient, blob count, builder eligibility/version, and that the builder's collateral /// covers the bid value), /// - that the bid matches the block being produced — the exact `proposal_slot`, the selected @@ -81,7 +82,7 @@ pub fn verify_direct_bid( } // Consensus-consistency checks shared with the gossip verifier. - verify_bid_consistency(bid, proposal_slot, proposer_preferences, state, spec)?; + verify_direct_bid_consistency(bid, proposal_slot, proposer_preferences, state, spec)?; // If the requesting `BuilderEntry` named builder pubkeys, the bid must come from one of them: // the builder at `bid.builder_index` must have one of those pubkeys (the `builder_pubkeys` @@ -267,6 +268,38 @@ mod tests { )); } + #[test] + fn rejects_block_hash_equal_to_parent_block_hash() { + let (state, spec) = state_and_spec(); + // Passes every earlier check (slot, ancestor hash, parent root, RANDAO, gas limit), then + // claims a `block_hash` equal to its `parent_block_hash` — the consensus assert from + // `process_execution_payload_bid` that must be front-run before selection. + let executed_ancestor = ExecutionBlockHash::repeat_byte(7); + let mut bid = signed_bid( + Slot::new(1), + executed_ancestor, + Hash256::ZERO, + Hash256::ZERO, + ); + bid.message.block_hash = executed_ancestor; + bid.message.gas_limit = EXECUTED_ANCESTOR_GAS_LIMIT; + let result = verify_direct_bid( + &bid, + Slot::new(1), + executed_ancestor, + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::BlockHashEqualsParentBlockHash { .. }) + )); + } + #[test] fn rejects_gas_limit_incompatible_with_parent() { let (state, spec) = state_and_spec(); @@ -305,6 +338,9 @@ mod tests { Hash256::ZERO, ); bid.message.gas_limit = EXECUTED_ANCESTOR_GAS_LIMIT; + // A default (zero) `block_hash` would equal the zero parent hash and trip the + // block-hash-equals-parent rejection before the checks this test targets. + bid.message.block_hash = ExecutionBlockHash::repeat_byte(1); let result = verify_direct_bid( &bid, Slot::new(1), diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs index 86411f21fff..b46819aa595 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 @@ -42,14 +42,26 @@ fn verify_bid_payment_and_blobs( }); } + verify_bid_block_hash_not_parent(bid)?; + + verify_bid_blobs(bid, spec) +} + +/// Reject a bid whose `block_hash` equals its `parent_block_hash`. +/// +/// `process_execution_payload_bid` enforces this in `per_block_processing`, so every bid intake — +/// gossip *and* direct (builder-API) — must front-run it: a bid that fails only at block +/// processing has already won selection and costs the proposer the slot. +pub(crate) fn verify_bid_block_hash_not_parent( + bid: &ExecutionPayloadBid, +) -> Result<(), PayloadBidError> { if bid.block_hash == bid.parent_block_hash { return Err(PayloadBidError::BlockHashEqualsParentBlockHash { slot: bid.slot, block_hash: bid.block_hash, }); } - - verify_bid_blobs(bid, spec) + Ok(()) } fn verify_bid_blobs( @@ -69,12 +81,17 @@ fn verify_bid_blobs( Ok(()) } -/// Verify that an execution payload bid is consistent with the current chain state -/// and proposer preferences. +/// Verify that a direct (builder-API) bid is consistent with the current chain state +/// and proposer preferences: the direct path's bundle of the shared bid checks. /// -/// These checks are shared by gossip and direct bids. Source-specific checks (e.g. the gossip-only -/// requirement that `execution_payment == 0`) are applied by the caller. -pub(crate) fn verify_bid_consistency( +/// The individual checks are shared with gossip, but this bundle's only caller is +/// [`verify_direct_bid`](crate::payload_bid_verification::direct_verified_bid::verify_direct_bid): +/// the gossip verifier applies the same helpers (`verify_bid_slot`, `verify_bid_blobs`, +/// `verify_bid_block_hash_not_parent`, `verify_bid_state_conditions`) piecewise, in gossip-spec +/// order, interleaved with gossip-only work (cache checks, the preferences lookup, fork-choice +/// rules). A check that must cover both intakes belongs in one of those shared helpers — adding +/// it only here leaves gossip uncovered. +pub(crate) fn verify_direct_bid_consistency( bid: &ExecutionPayloadBid, current_slot: Slot, proposer_preferences: &SignedProposerPreferences, @@ -87,6 +104,11 @@ pub(crate) fn verify_bid_consistency( return Err(PayloadBidError::InvalidFeeRecipient); } + // Mirrors the consensus assert in `process_execution_payload_bid`. The gossip path applies + // this earlier (via `verify_bid_payment_and_blobs`); repeating it here keeps the direct path + // covered without depending on the gossip caller's composition. + verify_bid_block_hash_not_parent(bid)?; + verify_bid_blobs(bid, spec)?; verify_bid_state_conditions(bid, head_state, spec) diff --git a/beacon_node/builder_client/src/builder_http_client.rs b/beacon_node/builder_client/src/builder_http_client.rs index a857d13226c..12241036b3f 100644 --- a/beacon_node/builder_client/src/builder_http_client.rs +++ b/beacon_node/builder_client/src/builder_http_client.rs @@ -41,6 +41,11 @@ const DATE_MILLISECONDS: HeaderName = HeaderName::from_static("date-milliseconds #[derive(Clone)] pub struct BuilderHttpClient { client: reqwest::Client, + /// Client for `submitSignedBeaconBlock` only. The target URL arrives over the wire (the + /// `Eth-Builder-Url` request header echoed by the VC) and is an SSRF risk, so beacon-APIs + /// `publishBlock` requires that the forwarding request "MUST NOT follow redirects" — reqwest's + /// redirect policy is client-wide, hence a dedicated client with redirects disabled. + no_redirect_client: reqwest::Client, user_agent: String, /// Only use json for all request/response types. disable_ssz: bool, @@ -50,8 +55,13 @@ impl BuilderHttpClient { pub fn new(user_agent: Option, disable_ssz: bool) -> Result { let user_agent = user_agent.unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); let client = reqwest::Client::builder().user_agent(&user_agent).build()?; + let no_redirect_client = reqwest::Client::builder() + .user_agent(&user_agent) + .redirect(reqwest::redirect::Policy::none()) + .build()?; Ok(Self { client, + no_redirect_client, user_agent, disable_ssz, }) @@ -234,6 +244,10 @@ impl BuilderHttpClient { /// /// `ssz_request` selects the request-body encoding: SSZ when `true` and the client has SSZ /// enabled, otherwise JSON. + /// + /// Sent via [`Self::no_redirect_client`]: `builder_url` is wire input (`Eth-Builder-Url`), and + /// the spec forbids following redirects on this request. A redirect response surfaces as + /// [`Error::StatusCode`] like any other non-202. pub async fn submit_signed_beacon_block( &self, builder_url: &SensitiveUrl, @@ -263,7 +277,7 @@ impl BuilderHttpClient { HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, ); - self.client + self.no_redirect_client .post(path) .timeout(timeout) .headers(headers) @@ -274,7 +288,7 @@ impl BuilderHttpClient { HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, ); - self.client + self.no_redirect_client .post(path) .timeout(timeout) .headers(headers) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 4f41c2a1a1c..e73cf06c00a 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -68,7 +68,9 @@ use eth2::types::{ self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceExtraData, ForkChoiceNode, LightClientUpdatesQuery, PublishBlockRequest, ValidatorId, }; -use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; +use eth2::{ + BUILDER_URL_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER, +}; use health_metrics::observe::Observe; use lighthouse_network::Enr; use lighthouse_network::NetworkGlobals; @@ -106,7 +108,7 @@ use types::{ }; use validator::execution_payload_envelopes::get_validator_execution_payload_envelopes; use version::{ - ResponseIncludesVersion, V1, V2, add_consensus_version_header, add_ssz_content_type_header, + ResponseIncludesVersion, V1, V2, V4, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, inconsistent_fork_rejection, unsupported_version_rejection, }; @@ -384,6 +386,7 @@ pub async fn serve( let eth_v1 = single_version(any_version.clone(), V1); let eth_v2 = single_version(any_version.clone(), V2); + let eth_v4 = single_version(any_version.clone(), V4); // Create a `warp` filter that provides access to the network globals. let inner_network_globals = ctx.network_globals.clone(); @@ -819,6 +822,9 @@ pub async fn serve( */ let consensus_version_header_filter = warp::header::header::(CONSENSUS_VERSION_HEADER).boxed(); + // The winning builder's URL echoed by the VC on a Gloas block publish (beacon-APIs #630), so the + // node forwards the block to that builder. Optional: absent for self-build / p2p-won blocks. + let builder_url_header_filter = warp::header::optional::(BUILDER_URL_HEADER).boxed(); let optional_consensus_version_header_filter = warp::header::optional::(CONSENSUS_VERSION_HEADER).boxed(); @@ -855,6 +861,8 @@ pub async fn serve( &network_tx, BroadcastValidation::default(), duplicate_block_status_code, + // Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas). + None, ) .await }) @@ -892,6 +900,8 @@ pub async fn serve( &network_tx, BroadcastValidation::default(), duplicate_block_status_code, + // Legacy v1 publish: no builder-URL provenance (VC uses v2 for Gloas). + None, ) .await }) @@ -909,13 +919,15 @@ pub async fn serve( .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) + .and(builder_url_header_filter.clone()) .then( move |validation_level: api_types::BroadcastValidationQuery, value: serde_json::Value, consensus_version: ForkName, task_spawner: TaskSpawner, chain: Arc>, - network_tx: UnboundedSender>| { + network_tx: UnboundedSender>, + builder_url: Option| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { let request = PublishBlockRequest::::context_deserialize( &value, @@ -932,6 +944,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -949,13 +962,15 @@ pub async fn serve( .and(task_spawner_filter.clone()) .and(chain_filter.clone()) .and(network_tx_filter.clone()) + .and(builder_url_header_filter.clone()) .then( move |validation_level: api_types::BroadcastValidationQuery, block_bytes: Bytes, consensus_version: ForkName, task_spawner: TaskSpawner, chain: Arc>, - network_tx: UnboundedSender>| { + network_tx: UnboundedSender>, + builder_url: Option| { task_spawner.spawn_async_with_rejection(Priority::P0, async move { let block_contents = PublishBlockRequest::::from_ssz_bytes( &block_bytes, @@ -971,6 +986,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -2570,6 +2586,14 @@ pub async fn serve( task_spawner_filter.clone(), ); + // POST v4/validator/blocks/{slot} + let post_validator_blocks_v4 = post_validator_blocks_v4( + eth_v4.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // GET validator/blinded_blocks/{slot} let get_validator_blinded_blocks = get_validator_blinded_blocks( eth_v1.clone(), @@ -2683,6 +2707,12 @@ pub async fn serve( chain_filter.clone(), task_spawner_filter.clone(), ); + // POST validator/builder_preferences + let post_validator_builder_preferences = post_validator_builder_preferences( + eth_v1.clone(), + chain_filter.clone(), + task_spawner_filter.clone(), + ); // POST validator/sync_committee_subscriptions let post_validator_sync_committee_subscriptions = post_validator_sync_committee_subscriptions( eth_v1.clone(), @@ -3496,6 +3526,8 @@ pub async fn serve( .uor(post_validator_sync_committee_subscriptions) .uor(post_validator_prepare_beacon_proposer) .uor(post_validator_register_validator) + .uor(post_validator_builder_preferences) + .uor(post_validator_blocks_v4) .uor(post_validator_liveness_epoch) .uor(post_lighthouse_liveness) .uor(post_lighthouse_database_reconstruct) diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 63420fbe2d0..49315790da0 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -1,10 +1,10 @@ use crate::{ build_block_contents, version::{ - ResponseIncludesVersion, add_consensus_block_value_header, add_consensus_version_header, - add_execution_payload_blinded_header, add_execution_payload_included_header, - add_execution_payload_value_header, add_ssz_content_type_header, beacon_response, - inconsistent_fork_rejection, + ResponseIncludesVersion, add_builder_url_header, add_consensus_block_value_header, + add_consensus_version_header, add_execution_payload_blinded_header, + add_execution_payload_included_header, add_execution_payload_value_header, + add_ssz_content_type_header, beacon_response, inconsistent_fork_rejection, }, }; use beacon_chain::graffiti_calculator::GraffitiSettings; @@ -17,9 +17,10 @@ use eth2::{ beacon_response::ForkVersionedResponse, types::{BlockAndEnvelope, ProduceBlockV4Metadata}, }; +use sensitive_url::SensitiveUrl; use ssz::Encode; use std::sync::Arc; -use tracing::instrument; +use tracing::{debug, instrument}; use types::{execution::BlockProductionVersion, *}; use warp::{ http::response::Builder, @@ -58,13 +59,30 @@ pub async fn produce_block_v4( chain: Arc>, slot: Slot, query: api_types::ValidatorBlocksQuery, + builder_config: api_types::BuilderConfig, ) -> Result { + // `produceBlockV4` is the Gloas block-production endpoint. + let fork_name = chain.spec.fork_name_at_slot::(slot); + if !fork_name.gloas_enabled() { + return Err(warp_utils::reject::custom_bad_request( + "produceBlockV4 is only valid for Gloas and later".to_string(), + )); + } + let include_payload = query.include_payload.ok_or_else(|| { warp_utils::reject::custom_bad_request( "include_payload query parameter is required".to_string(), ) })?; + // The resolved builder config is threaded into block production, where it drives direct-builder + // bid requests and the gossip/direct bid policy (see `produce_block_on_state_gloas`). + debug!( + %slot, + builders = builder_config.builders.len(), + "Received produceBlockV4 request" + ); + let randao_reveal = query.randao_reveal.decompress().map_err(|e| { warp_utils::reject::custom_bad_request(format!( "randao reveal is not a valid BLS signature: {:?}", @@ -73,14 +91,9 @@ pub async fn produce_block_v4( })?; let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?; - // The GET route carries only a boost factor; direct builders arrive with the `BuilderConfig` - // body once this route is converted to POST (later in this PR stack). Until then the winning - // bid's builder URL is unused (`Eth-Builder-Url` also lands with the POST conversion). - let builder_config = api_types::BuilderConfig { - builder_boost_factor: query.builder_boost_factor.unwrap_or(DEFAULT_BOOST_FACTOR), - ..api_types::BuilderConfig::empty() - }; + // Gloas takes its bid boost policy from `builder_config` (global for gossip, per-builder for + // direct), so the V3-style `builder_boost_factor` query param is not used on this path. let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); let ( @@ -89,7 +102,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, - _builder_url, + builder_url, ) = chain .produce_block_with_verification_gloas( randao_reveal, @@ -110,6 +123,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, + builder_url, accept_header, &chain.spec, ) @@ -164,9 +178,13 @@ pub fn build_response_v4( consensus_block_value: u64, execution_payload_value: Uint256, payload_contents: Option>, + builder_url: Option, accept_header: Option, spec: &ChainSpec, ) -> Result { + // Stringify the winning builder's URL only here, at the `Eth-Builder-Url` header boundary; it is + // kept as a redacted `SensitiveUrl` everywhere upstream. + let builder_url = builder_url.map(|url| url.expose_full().to_string()); let fork_name = block .to_ref() .fork_name(spec) @@ -180,14 +198,15 @@ pub fn build_response_v4( consensus_block_value: consensus_block_value_wei, execution_payload_value, execution_payload_included, - builder_url: None, + builder_url: builder_url.clone(), }; let add_v4_headers = |res: Response| { let res = add_consensus_version_header(res, fork_name); let res = add_consensus_block_value_header(res, consensus_block_value_wei); let res = add_execution_payload_value_header(res, execution_payload_value); - add_execution_payload_included_header(res, execution_payload_included) + let res = add_execution_payload_included_header(res, execution_payload_included); + add_builder_url_header(res, builder_url.as_deref()) }; // When the payload is included, bundle the block with the execution payload envelope, blobs and diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index a7336f2f6eb..5279a25b3be 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -19,6 +19,7 @@ use logging::crit; use network::NetworkMessage; use rand::prelude::SliceRandom; use reqwest::StatusCode; +use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; use std::marker::PhantomData; use std::sync::Arc; @@ -73,6 +74,62 @@ impl ProvenancedBlock> } } +/// If a direct builder won this block's payload bid, forward the signed block to that builder via +/// `submitSignedBeaconBlock` so it reveals the execution payload envelope. +/// +/// The builder's URL is the `Eth-Builder-Url` request header the VC echoed on publish (beacon-APIs +/// #630), so this works even on a beacon node that did not produce the block. `None` (self-built or +/// p2p-won), no configured builders, or a malformed URL are all no-ops. +/// +/// Fire-and-forget: the submission runs in a detached task; a failure is logged at high severity +/// (the validator has already signed the commitment) but never blocks the publish response. Runs +/// only once per block since it hangs off the single p2p-publish point. +fn forward_signed_block_to_winning_builder( + chain: &Arc>, + block: Arc>, + builder_url: Option<&str>, +) { + // The VC echoes the winning builder's URL in the `Eth-Builder-Url` request header (beacon-APIs + // #630); absent for a self-built block or a p2p-won bid, in which case there's nothing to forward. + let Some(builder_url) = builder_url else { + return; + }; + let Some(builders) = chain.builders.as_ref() else { + return; + }; + let url = match SensitiveUrl::parse(builder_url) { + Ok(url) => url, + Err(e) => { + warn!(error = ?e, "Ignoring malformed Eth-Builder-Url header"); + return; + } + }; + + let builders = builders.clone(); + let slot = block.slot(); + let block_root = block.canonical_root(); + + chain.task_executor.spawn( + async move { + match builders.forward_signed_block(&url, &block).await { + Ok(()) => info!( + %slot, + %block_root, + "Forwarded signed block to winning builder" + ), + Err(e) => error!( + %slot, + %block_root, + builder_url = ?url, + error = ?e, + "Failed to forward signed block to winning builder" + ), + } + }, + "forward_signed_block_to_builder", + ); +} + /// Handles a request from the HTTP API for full blocks. #[allow(clippy::too_many_arguments)] #[instrument( @@ -88,6 +145,9 @@ pub async fn publish_block>( network_tx: &UnboundedSender>, validation_level: BroadcastValidation, duplicate_status_code: StatusCode, + // The `Eth-Builder-Url` request header (beacon-APIs #630): when a direct builder won the block's + // payload bid, its URL, so the block is forwarded there for envelope reveal. + builder_url: Option, ) -> Result { let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let block_publishing_delay_for_testing = chain.config.block_publishing_delay; @@ -141,6 +201,14 @@ pub async fn publish_block>( BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) })?; + // If a direct builder won this block's payload bid, forward the signed block to it so it + // reveals the execution payload envelope. + forward_signed_block_to_winning_builder( + &publish_chain, + block.clone(), + builder_url.as_deref(), + ); + Ok(()) }; @@ -570,6 +638,8 @@ pub async fn publish_blinded_block( network_tx, validation_level, duplicate_status_code, + // Blinded (mev-boost) publish predates the Gloas builder-URL round-trip. + None, ) .await } else { diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 7b904260b8e..ca2df59a088 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -6,7 +6,7 @@ use crate::utils::{ AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; -use crate::version::{V1, V2, V3, V4, add_ssz_content_type_header, unsupported_version_rejection}; +use crate::version::{V1, V2, V3, add_ssz_content_type_header, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; @@ -14,12 +14,13 @@ use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainT use bls::PublicKeyBytes; use bytes::Bytes; use context_deserialize::ContextDeserialize; -use eth2::CONSENSUS_VERSION_HEADER; use eth2::types::{ - Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, - StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, - ValidatorAttestationDataQuery, ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, + Accept, BeaconCommitteeSubscription, BuilderConfig, BuilderPreferenceEntry, EndpointVersion, + Failure, GenericResponse, MAX_SUBMITTED_BUILDER_PREFERENCES, StandardLivenessResponseData, + StateId as CoreStateId, ValidatorAggregateAttestationQuery, ValidatorAttestationDataQuery, + ValidatorBlocksQuery, ValidatorIndexData, ValidatorStatus, }; +use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; use reqwest::StatusCode; @@ -483,8 +484,12 @@ pub fn get_validator_blocks( not_synced_filter?; - if endpoint_version == V4 { - produce_block_v4(accept_header, chain, slot, query).await + // Gloas block production is served via `POST v4/validator/blocks`. + let fork_name = chain.spec.fork_name_at_slot::(slot); + if fork_name.gloas_enabled() { + Err(warp_utils::reject::custom_bad_request( + "Gloas block production requires POST v4/validator/blocks".to_string(), + )) } else if endpoint_version == V3 { produce_block_v3(accept_header, chain, slot, query).await } else { @@ -496,6 +501,84 @@ pub fn get_validator_blocks( .boxed() } +// POST v4/validator/blocks/{slot} +// +// The Gloas block-production endpoint. Carries the validator's resolved `BuilderConfig` as the +// request body, accepted as either JSON or SSZ (selected by `Content-Type`; `application/octet-stream` +// => SSZ). The `Eth-Consensus-Version` request header is required (per beacon-APIs #630); the body +// is not fork-versioned, so like the builder-preferences endpoint the header is validated but only +// logged. +pub fn post_validator_blocks_v4( + eth_v4: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v4 + .and(warp::path("validator")) + .and(warp::path("blocks")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid slot".to_string(), + )) + })) + .and(warp::path::end()) + .and(warp::header::optional::("accept")) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(not_while_syncing_filter) + .and(warp::query::()) + .and( + warp::header::optional::(CONTENT_TYPE_HEADER) + .and(warp::body::bytes()) + .and_then(|content_type: Option, body: Bytes| async move { + let builder_config: BuilderConfig = if content_type.as_deref() + == Some(SSZ_CONTENT_TYPE_HEADER) + { + BuilderConfig::from_ssz_bytes(&body).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })? + } else { + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + // A zero-length `url` or auth `data` makes the body itself invalid (beacon-APIs + // #630) — a 400, unlike per-entry bid failures, which are isolated. + for entry in builder_config.builders.iter() { + entry.validate().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid builder entry: {e}" + )) + })?; + } + Ok::<_, Rejection>(builder_config) + }), + ) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + accept_header: Option, + consensus_version: ForkName, + not_synced_filter: Result<(), Rejection>, + query: ValidatorBlocksQuery, + builder_config: BuilderConfig, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + debug!( + ?slot, + %consensus_version, + "Block production request from HTTP API (v4)" + ); + not_synced_filter?; + produce_block_v4(accept_header, chain, slot, query, builder_config).await + }) + }, + ) + .boxed() +} + // POST validator/liveness/{epoch} pub fn post_validator_liveness_epoch( eth_v1: EthV1Filter, @@ -770,6 +853,125 @@ pub fn post_validator_register_validator( .boxed() } +// POST validator/builder_preferences +// +// Accepts the `BuilderPreferenceEntry` list as either JSON or SSZ. A required +// `Eth-Consensus-Version` header carries the consensus version the preferences belong to (per +// beacon-APIs #630); it is not needed to decode the (currently single-fork) body, so it is only +// logged. +pub fn post_validator_builder_preferences( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("builder_preferences")) + .and(warp::path::end()) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .and( + warp::header::optional::(CONTENT_TYPE_HEADER) + .and(warp::body::bytes()) + .and_then(|content_type: Option, body: Bytes| async move { + let entries: Vec = if content_type.as_deref() + == Some(SSZ_CONTENT_TYPE_HEADER) + { + Vec::from_ssz_bytes(&body).map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })? + } else { + serde_json::from_slice(&body).map_err(|e| { + warp_utils::reject::custom_deserialize_error(format!("{e:?}")) + })? + }; + // The submission list is bounded (SSZ `List[BuilderPreferencesEntry, 4096]`, + // JSON `maxItems: 4096`, per beacon-APIs #630); a longer body is invalid. + if entries.len() > MAX_SUBMITTED_BUILDER_PREFERENCES { + return Err(warp_utils::reject::custom_bad_request(format!( + "too many builder preference entries: {} exceeds the limit of {}", + entries.len(), + MAX_SUBMITTED_BUILDER_PREFERENCES + ))); + } + // A zero-length `url` or auth `data` makes the body itself invalid (beacon-APIs + // #630) — a 400, unlike per-entry submission failures, which are isolated. + for entry in &entries { + entry.validate().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid builder preference entry: {e}" + )) + })?; + } + Ok::<_, Rejection>(entries) + }), + ) + .then( + |consensus_version: ForkName, + task_spawner: TaskSpawner, + chain: Arc>, + entries: Vec| async move { + let (tx, rx) = oneshot::channel(); + + let initial_result = task_spawner + .spawn_async_with_rejection_no_conversion(Priority::P0, async move { + // The builder service is only present when the Gloas fork is scheduled. + let builders = chain + .builders + .as_ref() + .ok_or(BeaconChainError::BuilderMissing) + .map_err(warp_utils::reject::unhandled_error)? + .clone(); + + debug!( + count = entries.len(), + %consensus_version, + "Received submit builder preferences request" + ); + + // Submitting to a builder can be slow (they frequently time out), so the + // fan-out runs in a detached task rather than holding a `BeaconProcessor` + // worker. The service submits each entry independently and best-effort, + // returning the failures by index (per beacon-APIs #630). + tokio::task::spawn(async move { + let response = match builders + .submit_builder_preferences(entries, consensus_version) + .await + { + Ok(()) => Ok(warp::reply::reply().into_response()), + Err(failures) => Err(warp_utils::reject::indexed_bad_request( + "error submitting builder preferences".to_string(), + failures + .into_iter() + .map(|f| Failure::new(f.index, f.error.to_string())) + .collect(), + )), + }; + let _ = tx.send(response); + }); + + Ok(warp::reply::reply().into_response()) + }) + .await; + + if initial_result.is_err() { + return convert_rejection(initial_result).await; + } + + convert_rejection(rx.await.unwrap_or_else(|_| { + Ok(warp::reply::with_status( + warp::reply::json(&"No response from channel"), + warp::http::StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response()) + })) + .await + }, + ) + .boxed() +} + // POST validator/prepare_beacon_proposer pub fn post_validator_prepare_beacon_proposer( eth_v1: EthV1Filter, diff --git a/beacon_node/http_api/src/version.rs b/beacon_node/http_api/src/version.rs index 6f441636b49..63914feb049 100644 --- a/beacon_node/http_api/src/version.rs +++ b/beacon_node/http_api/src/version.rs @@ -4,8 +4,8 @@ use eth2::beacon_response::{ ExecutionOptimisticFinalizedMetadata, ForkVersionedResponse, UnversionedResponse, }; use eth2::{ - CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, - EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, + BUILDER_URL_HEADER, CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, + CONTENT_TYPE_HEADER, EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, SSZ_CONTENT_TYPE_HEADER, }; use serde::Serialize; @@ -116,6 +116,15 @@ pub fn add_execution_payload_value_header( .into_response() } +/// Add the `Eth-Builder-Url` header (the winning builder's URL) to a response, when present. +/// Absent for a self-built block or a block won by a p2p bid. +pub fn add_builder_url_header(reply: T, builder_url: Option<&str>) -> Response { + match builder_url { + Some(url) => reply::with_header(reply, BUILDER_URL_HEADER, url).into_response(), + None => reply.into_response(), + } +} + /// Add the `Eth-Consensus-Block-Value` header to a response. pub fn add_consensus_block_value_header( reply: T, diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 4d2be52a0d5..5db04d7d136 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -433,6 +433,7 @@ pub async fn consensus_partial_pass_only_consensus() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; @@ -610,7 +611,7 @@ pub async fn equivocation_consensus_early_equivocation() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), blobs_a), validation_level, - None + None, ) .await .is_ok() @@ -763,6 +764,7 @@ pub async fn equivocation_consensus_late_equivocation() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index f1a4ed02bb4..f31746ddf70 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -27,7 +27,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use types::{ - Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, Hash256, MinimalEthSpec, + Address, BeaconBlockRef, EthSpec, ExecutionBlockHash, ForkName, Hash256, MinimalEthSpec, ProposerPreparationData, Slot, }; @@ -727,7 +727,15 @@ pub async fn proposer_boost_re_org_test( let (block_c, block_c_blobs) = { let (response, _) = tester .client - .get_validator_blocks_v4::(slot_c, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot_c, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); ( diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 90c7f37b02c..df4fdaf0904 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -817,7 +817,15 @@ pub async fn fork_choice_before_proposal() { let block_d = if harness.spec.fork_name_at_slot::(slot_d).gloas_enabled() { tester .client - .get_validator_blocks_v4::(slot_d, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot_d, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() .0 diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 8f9dcac5a04..dab0f6878ac 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4813,7 +4813,15 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5005,6 +5013,124 @@ impl ApiTester { self } + pub async fn test_block_production_v4_missing_consensus_version_header_returns_400( + self, + ) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + let Some((slot, epoch, _fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (_sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + ) + .await + .unwrap(); + + // A valid body, but no `Eth-Consensus-Version` header: the header is required + // (beacon-APIs #630), so the request must fail with a 400. + let response = reqwest::Client::new() + .post(url) + .json(ð2::types::BuilderConfig::empty()) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + + pub async fn test_block_production_v4_zero_length_entry_fields_return_400(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + let Some((slot, epoch, _fork_name)) = self.advance_to_gloas_slot() else { + return self; + }; + + let (_sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let url = self + .client + .post_validator_blocks_v4_path( + slot, + &randao_reveal, + None, + SkipRandaoVerification::No, + false, + None, + ) + .await + .unwrap(); + + let valid_auth = eth2::types::SignedRequestAuth { + message: eth2::types::RequestAuth { + data: eth2::types::RequestAuthData::new(b"http://builder.example.com".to_vec()) + .unwrap(), + slot, + }, + signature: Signature::empty(), + }; + let entry = |url: &str, auth: eth2::types::SignedRequestAuth| eth2::types::BuilderEntry { + url: url.parse().unwrap(), + auth, + builder_pubkeys: <_>::default(), + max_execution_payment: 0, + min_bid: 0, + builder_boost_factor: 100, + }; + + // A zero-length `url` and a zero-length auth `data` each make the body invalid + // (beacon-APIs #630), so the request must fail with a 400. + let empty_url_entry = entry("", valid_auth.clone()); + let mut empty_data_auth = valid_auth; + empty_data_auth.message.data = eth2::types::RequestAuthData::default(); + let empty_data_entry = entry("http://builder.example.com", empty_data_auth); + + for bad_entry in [empty_url_entry, empty_data_entry] { + let config = serde_json::json!({ + "min_bid": "0", + "builder_boost_factor": "100", + "builders": [bad_entry], + }); + let response = reqwest::Client::new() + .post(url.clone()) + .header(eth2::CONSENSUS_VERSION_HEADER, "gloas") + .json(&config) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + + self + } + pub async fn test_envelope_post_when_syncing_returns_503(mut self) -> Self { if !self.chain.spec.is_gloas_scheduled() { return self; @@ -5178,7 +5304,15 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5253,7 +5387,15 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4_ssz::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5325,12 +5467,28 @@ impl ApiTester { let (response, metadata) = if ssz { self.client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, true, None, None) + .post_validator_blocks_v4_ssz::( + slot, + &randao_reveal, + None, + true, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() } else { self.client - .get_validator_blocks_v4::(slot, &randao_reveal, None, true, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + true, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap() }; @@ -5871,7 +6029,15 @@ impl ApiTester { // Produce and publish a block. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -5954,7 +6120,15 @@ impl ApiTester { // Produce and publish a block, but withhold its envelope. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -8923,7 +9097,15 @@ impl ApiTester { let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, false, None, None) + .post_validator_blocks_v4::( + slot, + &randao_reveal, + None, + false, + ð2::types::BuilderConfig::empty(), + None, + ForkName::Gloas, + ) .await .unwrap(); let block = response.into_block(); @@ -9229,7 +9411,6 @@ impl ApiTester { let epoch = self.chain.epoch().unwrap(); let (_, randao_reveal) = self.get_test_randao(slot, epoch).await; let graffiti = Some(Graffiti::from([0; GRAFFITI_BYTES_LEN])); - // When GraffitiPolicy is None let no_graffiti_policy_path = self .client @@ -10101,6 +10282,10 @@ async fn envelope_api() { .await .test_block_production_v4_missing_include_payload_returns_400() .await + .test_block_production_v4_missing_consensus_version_header_returns_400() + .await + .test_block_production_v4_zero_length_entry_fields_return_400() + .await .test_envelope_post_consensus_invalid_returns_400_no_broadcast() .await .test_envelope_post_gossip_partial_pass_returns_202() From 2a6fc09ba605d3a2415388f135e7b33069221c68 Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:55:54 -0500 Subject: [PATCH 11/11] Migrate the validator client to the Gloas builder API (Gloas builder API 5/5) Final PR of the Gloas builder API stack: - sign builder request-auth and preferences (REQUEST_AUTH signing domain, web3signer message type) - add the builder configuration store (`builder_definitions.yml`) and book documentation - add the builder-preferences service and switch block production to `POST` produceBlockV4 with a `BuilderConfig` body, threading the `Eth-Builder-Url` header through block publication - remove the now-unused legacy `GET` produceBlockV4 client methods Change-Id: Iaeeaf6205a024e4fb9fd11aae6ac6a75978a8320 --- Cargo.lock | 25 ++ Cargo.toml | 2 + book/src/SUMMARY.md | 1 + book/src/gloas_builder_config.md | 90 +++++ common/eth2/src/lib.rs | 182 ----------- .../src/mock_beacon_node.rs | 18 +- .../src/mock_validator_store.rs | 9 + validator_client/Cargo.toml | 1 + validator_client/builder_store/Cargo.toml | 26 ++ .../builder_store/src/builder_definitions.rs | 292 +++++++++++++++++ validator_client/builder_store/src/lib.rs | 140 ++++++++ .../lighthouse_validator_store/Cargo.toml | 1 + .../lighthouse_validator_store/src/lib.rs | 25 ++ validator_client/signing_method/Cargo.toml | 1 + validator_client/signing_method/src/lib.rs | 4 + .../signing_method/src/web3signer.rs | 4 + validator_client/src/lib.rs | 29 +- .../validator_services/Cargo.toml | 2 + .../validator_services/src/block_service.rs | 122 +++++-- .../src/builder_preferences_service.rs | 308 ++++++++++++++++++ .../validator_services/src/lib.rs | 2 + .../src/request_auth_cache.rs | 106 ++++++ validator_client/validator_store/Cargo.toml | 1 + validator_client/validator_store/src/lib.rs | 7 + wordlist.txt | 2 + 25 files changed, 1186 insertions(+), 214 deletions(-) create mode 100644 book/src/gloas_builder_config.md create mode 100644 validator_client/builder_store/Cargo.toml create mode 100644 validator_client/builder_store/src/builder_definitions.rs create mode 100644 validator_client/builder_store/src/lib.rs create mode 100644 validator_client/validator_services/src/builder_preferences_service.rs create mode 100644 validator_client/validator_services/src/request_auth_cache.rs diff --git a/Cargo.lock b/Cargo.lock index 3e9744e92c5..4b04d951ca1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1713,6 +1713,25 @@ dependencies = [ "types", ] +[[package]] +name = "builder_store" +version = "0.1.0" +dependencies = [ + "account_utils", + "bls", + "builder_types", + "filesystem", + "futures", + "hex", + "parking_lot", + "serde", + "ssz_types", + "tempfile", + "tracing", + "types", + "yaml_serde", +] + [[package]] name = "builder_types" version = "0.1.0" @@ -5666,6 +5685,7 @@ dependencies = [ "account_utils", "beacon_node_fallback", "bls", + "builder_types", "doppelganger_service", "either", "environment", @@ -8445,6 +8465,7 @@ name = "signing_method" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2_keystore", "ethereum_serde_utils", "lockfile", @@ -9811,6 +9832,7 @@ version = "8.2.2" dependencies = [ "account_utils", "beacon_node_fallback", + "builder_store", "clap", "clap_utils", "directory", @@ -9976,6 +9998,8 @@ version = "0.1.0" dependencies = [ "beacon_node_fallback", "bls", + "builder_store", + "builder_types", "either", "eth2", "futures", @@ -10000,6 +10024,7 @@ name = "validator_store" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2", "futures", "slashing_protection", diff --git a/Cargo.toml b/Cargo.toml index ba9a5654b2f..6098e71dc0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ members = [ "testing/web3signer_tests", "validator_client", "validator_client/beacon_node_fallback", + "validator_client/builder_store", "validator_client/doppelganger_service", "validator_client/graffiti_file", "validator_client/http_api", @@ -120,6 +121,7 @@ bincode = "1" bitvec = "1" bls = { path = "crypto/bls" } builder_client = { path = "beacon_node/builder_client" } +builder_store = { path = "validator_client/builder_store" } builder_types = { path = "common/builder_types" } byteorder = "1" bytes = "1.11.1" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index feecd4b6894..15e6be2010a 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -49,6 +49,7 @@ * [Redundancy](./advanced_redundancy.md) * [Release Candidates](./advanced_release_candidates.md) * [MEV](./advanced_builders.md) + * [Gloas Builder Configuration](./gloas_builder_config.md) * [Late Block Re-orgs](./advanced_re-orgs.md) * [Blobs](./advanced_blobs.md) * [Command Line Reference (CLI)](./help_general.md) diff --git a/book/src/gloas_builder_config.md b/book/src/gloas_builder_config.md new file mode 100644 index 00000000000..4709885b7e1 --- /dev/null +++ b/book/src/gloas_builder_config.md @@ -0,0 +1,90 @@ +# Builder Configuration + +> This applies from the **Gloas** fork onwards. It configures how the validator client sources +> execution-payload bids from external builders under ePBS. + +The validator client reads its external-builder settings from a YAML file named +`builder_definitions.yml` in the validator directory +(`/validators/builder_definitions.yml`). The file holds two things: + +- **A global bid policy** — `min_bid` and `builder_boost_factor`, applied to bids received over p2p + (gossip) and used as the default for any builder that does not set its own. +- **A list of builders** to request bids from directly, each with optional per-builder overrides of + the global policy. + +## Example + +```yaml +# Global bid policy: applies to p2p (gossip) bids, and is the default for any +# builder below that omits the corresponding field. +min_bid: 0 # gwei — bids below this rank last; one wins only if nothing else is viable +builder_boost_factor: 100 # percent — 100 = neutral, >100 favors builders, 0 = prefer local + +builders: + # Minimal builder — inherits the global policy. + - enabled: true + url: "https://builder-a.example.com" + max_execution_payment: 1000000000 # gwei — cap on the trusted execution payment + + # Builder overriding the globals and pinning the expected builder key. + - enabled: true + url: "https://builder-b.example.com" + max_execution_payment: 1000000000 + min_bid: 500000000 # override the global for this builder + builder_boost_factor: 120 # override the global for this builder + builder_pubkeys: # optional — reject a bid not signed by one of these keys + - "0xa1b2c3d4..." + # auth_data: "0x68747470..." # optional — defaults to the UTF-8 bytes of `url` +``` + +> **Comments are not preserved.** The validator client rewrites this file when builders are added or +> removed (for example via the keymanager API), which strips YAML comments. Keep an annotated copy +> elsewhere if you rely on inline notes. + +## Fields + +### Top level (global bid policy) + +| Field | Required | Default | Meaning | +| ------- | ---------- | --------- | --------- | +| `min_bid` | no | `0` | Minimum total payment, in gwei, for a p2p bid. A bid below the floor is ranked behind any floor-clearing candidate (including the local block) and only wins when nothing else is viable. Also the default `min_bid` for any builder that omits it. | +| `builder_boost_factor` | no | `100` | Percentage multiplier applied to p2p bids when comparing against the local block. Also the default for any builder that omits it. | +| `builders` | no | `[]` | The list of builders to request bids from directly. | + +### Per builder (each entry under `builders`) + +| Field | Required | Default | Meaning | +| ------- | ---------- | --------- | --------- | +| `enabled` | **yes** | — | Whether this builder is used. Disabled builders are ignored. | +| `url` | **yes** | — | The builder's `http`/`https` URL. Bids are requested from here at block-production time. | +| `max_execution_payment` | **yes** | — | Cap, in gwei, on the *trusted* execution payment accepted from this builder. | +| `min_bid` | no | *(global)* | Override the global minimum bid for this builder. | +| `builder_boost_factor` | no | *(global)* | Override the global boost factor for this builder. | +| `builder_pubkeys` | no | *(empty)* | The builder's BLS public keys, hex-encoded. If non-empty, a returned bid **not** signed by one of them is rejected. | +| `auth_data` | no | *(UTF-8 of `url`)* | Opaque authentication data, hex-encoded, agreed with the builder out of band. Signed into the request. Must be non-empty when set. Defaults to the UTF-8 bytes of `url`. | + +All byte fields (`builder_pubkeys` entries, `auth_data`) are `0x`-prefixed hex strings. All payment values +(`min_bid`, `max_execution_payment`) are in gwei. + +## How bids are selected + +At block-production time the validator client requests a bid from each enabled builder with a `url`, +and also considers bids seen over p2p. For each candidate bid: + +- **`min_bid`** — a bid whose total value is below the applicable `min_bid` is ranked behind any + floor-clearing candidate (including the local block) rather than dropped, so it wins only when + nothing else is viable (e.g. the local build failed). Direct builders use their own (or the + inherited global) value; p2p bids use the global value. +- **`builder_boost_factor`** — the surviving bid's value is scaled by its boost factor + (`boost × value ÷ 100`) before being compared against the locally-built block. A factor below + `100` favors the local block; above `100` favors the builder; `0` always prefers local; + `2^64 − 1` strongly favors the builder. The factor is a multiplier, not an absolute override, so a + zero-value bid still ranks `0` and loses to any non-zero local block. +- **`max_execution_payment`** — bounds how much of a builder's (off-chain) execution payment counts + toward its bid value. This applies only to direct builders; p2p bids carry no trusted execution + payment. +- **`builder_pubkeys`** — for a direct builder, if non-empty, the returned bid must be signed by + one of these keys or it is discarded. + +The highest-value bid after these rules wins. Per-builder `min_bid`/`builder_boost_factor` apply +only to bids requested directly by URL; p2p bids are governed by the global values. diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index a02b948a909..8d94c774eda 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3048,188 +3048,6 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } - // The legacy `GET v4/validator/blocks/{slot}` client methods below are kept alongside the new - // POST variants until the validator client migrates to POST (later in this PR stack), at which - // point they are removed. - - /// `GET v4/validator/blocks/{slot}` - pub async fn get_validator_blocks_v4( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular( - slot, - randao_reveal, - graffiti, - SkipRandaoVerification::No, - include_payload, - builder_booster_factor, - graffiti_policy, - ) - .await - } - - /// `GET v4/validator/blocks/{slot}` - /// - /// Returns either a bare block or the full [`BlockAndEnvelope`] (block + execution payload - /// envelope + blobs + KZG proofs) depending on the `Eth-Execution-Payload-Included` response - /// header. Note that a builder bid yields a bare block even when `include_payload=true`. - #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - skip_randao_verification: SkipRandaoVerification, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let mut path = self - .post_validator_blocks_v4_path( - slot, - randao_reveal, - graffiti, - skip_randao_verification, - include_payload, - graffiti_policy, - ) - .await?; - - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - - let opt_result = self - .get_response_with_response_headers( - path, - Accept::Json, - self.timeouts.get_validator_block, - |response, headers| async move { - let metadata = ProduceBlockV4Metadata::try_from(&headers) - .map_err(Error::InvalidHeaders)?; - let block_response = if metadata.execution_payload_included { - ProduceBlockV4Response::BlockAndEnvelope( - response - .json::, - ProduceBlockV4Metadata, - >>() - .await? - .data, - ) - } else { - ProduceBlockV4Response::BlockOnly( - response - .json::, - ProduceBlockV4Metadata, - >>() - .await? - .data, - ) - }; - Ok((block_response, metadata)) - }, - ) - .await?; - - opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) - } - - /// `GET v4/validator/blocks/{slot}` in ssz format - pub async fn get_validator_blocks_v4_ssz( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular_ssz::( - slot, - randao_reveal, - graffiti, - SkipRandaoVerification::No, - include_payload, - builder_booster_factor, - graffiti_policy, - ) - .await - } - - /// `GET v4/validator/blocks/{slot}` in ssz format - /// - /// See [`Self::get_validator_blocks_v4_modular`] for the response semantics. - #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular_ssz( - &self, - slot: Slot, - randao_reveal: &SignatureBytes, - graffiti: Option<&Graffiti>, - skip_randao_verification: SkipRandaoVerification, - include_payload: bool, - builder_booster_factor: Option, - graffiti_policy: Option, - ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let mut path = self - .post_validator_blocks_v4_path( - slot, - randao_reveal, - graffiti, - skip_randao_verification, - include_payload, - graffiti_policy, - ) - .await?; - - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - - let opt_response = self - .get_response_with_response_headers( - path, - Accept::Ssz, - self.timeouts.get_validator_block, - |response, headers| async move { - let metadata = ProduceBlockV4Metadata::try_from(&headers) - .map_err(Error::InvalidHeaders)?; - let response_bytes = response.bytes().await?; - let block_response = if metadata.execution_payload_included { - ProduceBlockV4Response::BlockAndEnvelope( - BlockAndEnvelope::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?, - ) - } else { - ProduceBlockV4Response::BlockOnly( - BeaconBlock::from_ssz_bytes_for_fork( - &response_bytes, - metadata.consensus_version, - ) - .map_err(Error::InvalidSsz)?, - ) - }; - - Ok((block_response, metadata)) - }, - ) - .await?; - - opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) - } - /// `GET v1/validator/execution_payload_envelopes/{slot}/{beacon_block_root}` pub async fn get_validator_execution_payload_envelopes( &self, diff --git a/testing/validator_test_rig/src/mock_beacon_node.rs b/testing/validator_test_rig/src/mock_beacon_node.rs index d01905c0c7e..2cdc87f560a 100644 --- a/testing/validator_test_rig/src/mock_beacon_node.rs +++ b/testing/validator_test_rig/src/mock_beacon_node.rs @@ -102,8 +102,8 @@ impl MockBeaconNode { .create(); } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` - pub fn mock_get_validator_blocks_v4( + /// Mocks `POST /eth/v4/validator/blocks/{slot}` + pub fn mock_post_validator_blocks_v4( &mut self, block: &BeaconBlock, fork_name: ForkName, @@ -121,7 +121,7 @@ impl MockBeaconNode { }); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), @@ -136,8 +136,8 @@ impl MockBeaconNode { .create() } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` (SSZ) - pub fn mock_get_validator_blocks_v4_ssz( + /// Mocks `POST /eth/v4/validator/blocks/{slot}` (SSZ) + pub fn mock_post_validator_blocks_v4_ssz( &mut self, block: &BeaconBlock, fork_name: ForkName, @@ -149,7 +149,7 @@ impl MockBeaconNode { let ssz_bytes = block.as_ssz_bytes(); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), @@ -165,13 +165,13 @@ impl MockBeaconNode { .create() } - /// Mocks `GET /eth/v4/validator/blocks/{slot}` (SSZ) returning error - pub fn mock_get_validator_blocks_v4_ssz_error(&mut self, slot: Slot) -> Mock { + /// Mocks `POST /eth/v4/validator/blocks/{slot}` (SSZ) returning error + pub fn mock_post_validator_blocks_v4_ssz_error(&mut self, slot: Slot) -> Mock { let path_pattern = Regex::new(&format!(r"^/eth/v4/validator/blocks/{}", slot.as_u64())).unwrap(); self.server - .mock("GET", Matcher::Regex(path_pattern.to_string())) + .mock("POST", Matcher::Regex(path_pattern.to_string())) .match_query(Matcher::UrlEncoded( "include_payload".into(), "false".into(), diff --git a/testing/validator_test_rig/src/mock_validator_store.rs b/testing/validator_test_rig/src/mock_validator_store.rs index e4ce9772647..9fb75ae6b8b 100644 --- a/testing/validator_test_rig/src/mock_validator_store.rs +++ b/testing/validator_test_rig/src/mock_validator_store.rs @@ -1,4 +1,5 @@ use bls::{PublicKeyBytes, Signature}; +use eth2::types::{RequestAuth, SignedRequestAuth}; use futures::future::{BoxFuture, FutureExt}; use futures::{Stream, stream}; use std::future::Future; @@ -184,6 +185,14 @@ impl ValidatorStore for MockValidatorStore { panic!("MockValidatorStore::sign_proposer_preferences called without a hook") } + async fn sign_request_auth_v1( + &self, + _validator_pubkey: PublicKeyBytes, + _request_auth_v1: RequestAuth, + ) -> Result> { + panic!("MockValidatorStore::sign_request_auth_v1 called without a hook") + } + fn proposal_data(&self, _pubkey: &PublicKeyBytes) -> Option { panic!("MockValidatorStore::proposal_data called without a hook") } diff --git a/validator_client/Cargo.toml b/validator_client/Cargo.toml index 6990a2f61a7..ab5a85e8b0e 100644 --- a/validator_client/Cargo.toml +++ b/validator_client/Cargo.toml @@ -11,6 +11,7 @@ path = "src/lib.rs" [dependencies] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } +builder_store = { workspace = true } clap = { workspace = true } clap_utils = { workspace = true } directory = { workspace = true } diff --git a/validator_client/builder_store/Cargo.toml b/validator_client/builder_store/Cargo.toml new file mode 100644 index 00000000000..cad40ec39fc --- /dev/null +++ b/validator_client/builder_store/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "builder_store" +version = "0.1.0" +edition = { workspace = true } +authors = ["Sigma Prime "] + +[lib] +name = "builder_store" +path = "src/lib.rs" + +[dependencies] +account_utils = { workspace = true } +bls = { workspace = true } +builder_types = { workspace = true } +filesystem = { workspace = true } +futures = { workspace = true } +hex = { workspace = true } +parking_lot = { workspace = true } +serde = { workspace = true } +ssz_types = { workspace = true } +tracing = { workspace = true } +types = { workspace = true } +yaml_serde = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs new file mode 100644 index 00000000000..928f6d3ec14 --- /dev/null +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -0,0 +1,292 @@ +use account_utils::write_file_via_temporary; +use bls::PublicKeyBytes; +use builder_types::{BuilderUrl, MAX_BUILDER_ENTRIES, RequestAuthData}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{File, create_dir_all}; +use std::io; +use std::path::{Path, PathBuf}; + +/// The file name for the serialized `BuilderConfigFile` struct. +pub const BUILDERS_FILENAME: &str = "builder_definitions.yml"; +/// The temporary file name for the serialized `BuilderConfigFile` struct. +/// +/// This is used to achieve an atomic update of the contents on disk, without truncation. +pub const BUILDERS_TEMP_FILENAME: &str = ".builder_definitions.yml.tmp"; + +#[derive(Debug)] +pub enum Error { + /// The config file could not be opened. + UnableToOpenFile(io::Error), + /// The config file could not be parsed as YAML. + UnableToParseFile(yaml_serde::Error), + /// The builders file could not be serialized as YAML. + UnableToEncodeFile(yaml_serde::Error), + /// The builders file or temp file could not be written to the filesystem. + UnableToWriteFile(filesystem::Error), + /// The validator directory could not be created. + UnableToCreateValidatorDir(PathBuf), + /// A builder with the given URL already exists. + DuplicateBuilderAuth(BuilderUrl), + /// A builder URL could not be parsed as a URL. + InvalidBuilderUrl(BuilderUrl), + /// A builder URL does not use an `http`/`https` scheme. + UnsupportedUrlScheme(BuilderUrl), + /// More than `MAX_BUILDER_ENTRIES` builders are enabled, exceeding what fits in a + /// `BuilderConfig`. + TooManyEnabledBuilders { enabled: usize, max: usize }, +} + +/// A single builder in the config file: a direct bid request, with optional per-builder overrides +/// of the global bid policy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BuilderDefinition { + /// Indicates whether this definition is enabled or disabled. + pub enabled: bool, + /// The URL the beacon node uses to contact this builder. Routing metadata; never signed. + pub url: BuilderUrl, + /// Opaque authentication data signed into `RequestAuth.data`, agreed with the builder out of + /// band, as a `0x`-prefixed hex string. When unset, it defaults to the UTF-8 bytes of `url` + /// (the builder-specs #165 default). Must be non-empty when set: a zero-length `data` is + /// invalid on the wire. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_option_auth_data" + )] + pub auth_data: Option, + /// The builder BLS public keys this builder's bids may be signed by, hex-encoded. Empty (or + /// omitted) accepts any builder; otherwise a bid not signed by one of them is rejected. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub builder_pubkeys: Vec, + /// The maximum execution payment, in gwei, that we're willing to accept from this builder. + pub max_execution_payment: u64, + /// Per-builder override of the global minimum total payment (gwei). Inherits the global when + /// unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + /// Per-builder override of the global boost factor. Inherits the global when unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, +} + +fn default_builder_boost_factor() -> u64 { + 100 +} + +/// Serde helper: represent `Option` as a `0x`-prefixed hex string in the config +/// file (matching how other byte fields are encoded), omitting it entirely when `None`. +mod serde_option_auth_data { + use super::RequestAuthData; + use serde::{Deserialize, Deserializer, Serializer, de}; + + pub fn serialize( + value: &Option, + serializer: S, + ) -> Result { + match value { + Some(data) => serializer.serialize_some(&format!("0x{}", hex::encode(&data[..]))), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let Some(s) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let stripped = s.strip_prefix("0x").unwrap_or(&s); + let bytes = hex::decode(stripped).map_err(de::Error::custom)?; + let data = RequestAuthData::new(bytes) + .map_err(|_| de::Error::custom("auth_data exceeds the maximum size"))?; + Ok(Some(data)) + } +} + +/// The validator client's builder configuration file. +/// +/// Holds the global bid-policy defaults plus the list of builders to request bids from directly. It +/// resolves into the wire `BuilderConfig` at block-production time: the globals govern p2p bids and +/// fill in any builder that omits `min_bid`/`builder_boost_factor`. +#[derive(Clone, Serialize, Deserialize)] +pub struct BuilderConfigFile { + /// Global minimum total payment (gwei). Applies to p2p bids and is inherited by any builder that + /// omits its own `min_bid`. + #[serde(default)] + pub min_bid: u64, + /// Global boost factor. Applies to p2p bids and is inherited by any builder that omits its own + /// `builder_boost_factor`. + #[serde(default = "default_builder_boost_factor")] + pub builder_boost_factor: u64, + /// The builders to request bids from directly. + #[serde(default)] + pub builders: Vec, +} + +impl Default for BuilderConfigFile { + fn default() -> Self { + Self { + min_bid: 0, + builder_boost_factor: default_builder_boost_factor(), + builders: Vec::new(), + } + } +} + +impl BuilderConfigFile { + /// Open an existing file or create a new, empty one if it does not exist. + pub fn open_or_create>(validators_dir: P) -> Result { + create_dir_all(validators_dir.as_ref()).map_err(|_| { + Error::UnableToCreateValidatorDir(PathBuf::from(validators_dir.as_ref())) + })?; + let builders_file_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + if !builders_file_path.exists() { + let this = Self::default(); + this.save(&validators_dir)?; + } + Self::open(validators_dir) + } + + /// Open an existing file, returning an error if the file does not exist. + pub fn open>(validators_dir: P) -> Result { + let config_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + let file = File::options() + .write(true) + .read(true) + .create_new(false) + .open(config_path) + .map_err(Error::UnableToOpenFile)?; + let config: Self = yaml_serde::from_reader(file).map_err(Error::UnableToParseFile)?; + config.validate()?; + Ok(config) + } + + /// Encodes `self` as a YAML string and atomically writes it to the `CONFIG_FILENAME` file in + /// the `validators_dir` directory. + /// + /// Will create a new file if it does not exist or overwrite any existing file. + pub fn save>(&self, validators_dir: P) -> Result<(), Error> { + let config_path = validators_dir.as_ref().join(BUILDERS_FILENAME); + let temp_path = validators_dir.as_ref().join(BUILDERS_TEMP_FILENAME); + let mut bytes = vec![]; + yaml_serde::to_writer(&mut bytes, self).map_err(Error::UnableToEncodeFile)?; + + write_file_via_temporary(&config_path, &temp_path, &bytes) + .map_err(Error::UnableToWriteFile)?; + + Ok(()) + } + + pub fn as_slice(&self) -> &[BuilderDefinition] { + &self.builders + } + + pub fn push(&mut self, definition: BuilderDefinition) { + self.builders.push(definition); + } + + pub fn validate(&self) -> Result<(), Error> { + // The enabled builders must fit in a `BuilderConfig`'s bounded list, so + // `BuilderStore::builder_config` cannot overflow when constructing it. + let enabled = self.builders.iter().filter(|d| d.enabled).count(); + if enabled > MAX_BUILDER_ENTRIES { + return Err(Error::TooManyEnabledBuilders { + enabled, + max: MAX_BUILDER_ENTRIES, + }); + } + + let mut seen_auth_urls = HashSet::new(); + + for definition in &self.builders { + if !definition.enabled { + // ignore disabled builders + continue; + } + let url = &definition.url; + // Reject malformed or non-http(s) builder URLs here, at config load, rather than + // silently skipping them during block proposal. + let sensitive_url = url + .to_sensitive_url() + .map_err(|_| Error::InvalidBuilderUrl(url.clone()))?; + if !matches!(sensitive_url.expose_full().scheme(), "http" | "https") { + return Err(Error::UnsupportedUrlScheme(url.clone())); + } + + let auth = definition + .auth_data + .clone() + .unwrap_or_else(|| url.to_default_auth_data()); + // two entries cannot contain the same url and auth data + let key = (url.clone(), auth); + if !seen_auth_urls.insert(key) { + return Err(Error::DuplicateBuilderAuth(url.clone())); + } + } + + Ok(()) + } +} + +impl<'a> IntoIterator for &'a BuilderConfigFile { + type Item = &'a BuilderDefinition; + type IntoIter = std::slice::Iter<'a, BuilderDefinition>; + + fn into_iter(self) -> Self::IntoIter { + self.builders.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_data_round_trips_as_hex() { + let definition = BuilderDefinition { + enabled: true, + url: "http://builder.example.com".parse().unwrap(), + auth_data: Some(RequestAuthData::new(b"hello".to_vec()).unwrap()), + builder_pubkeys: vec![], + max_execution_payment: 1, + min_bid: None, + builder_boost_factor: None, + }; + + let yaml = yaml_serde::to_string(&definition).unwrap(); + // "hello" is 0x68656c6c6f, a hex string — not a YAML sequence of byte values. + assert!( + yaml.contains("0x68656c6c6f"), + "auth_data not hex-encoded:\n{yaml}" + ); + + let decoded: BuilderDefinition = yaml_serde::from_str(&yaml).unwrap(); + assert_eq!(decoded, definition); + } + + #[test] + fn omits_none_optional_fields() { + let definition = BuilderDefinition { + enabled: true, + url: "http://builder.example.com".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: 1, + min_bid: None, + builder_boost_factor: None, + }; + let yaml = yaml_serde::to_string(&definition).unwrap(); + for field in [ + "auth_data", + "builder_pubkeys", + "min_bid", + "builder_boost_factor", + ] { + assert!( + !yaml.contains(field), + "unset `{field}` should be omitted:\n{yaml}" + ); + } + } +} diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs new file mode 100644 index 00000000000..d17c3c73260 --- /dev/null +++ b/validator_client/builder_store/src/lib.rs @@ -0,0 +1,140 @@ +mod builder_definitions; +use builder_definitions::BuilderConfigFile; +pub use builder_definitions::{BuilderDefinition, Error}; +use builder_types::{ + BuilderConfig, BuilderEntry, BuilderPubkeys, RequestAuthData, SignedRequestAuth, +}; +use parking_lot::RwLock; +use ssz_types::VariableList; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tracing::error; + +#[derive(Clone)] +pub struct BuilderStore { + config: Arc>, + validators_dir: PathBuf, +} + +impl BuilderStore { + pub fn open_or_create>(validators_dir: P) -> Result { + let validators_dir = validators_dir.as_ref().to_path_buf(); + + Ok(Self { + config: Arc::new(RwLock::new(BuilderConfigFile::open_or_create( + &validators_dir, + )?)), + validators_dir, + }) + } + + /// Resolve the enabled builders into a wire [`BuilderConfig`], signing each builder's request + /// auth via `sign`. + /// + /// Per-builder `min_bid`/`builder_boost_factor` inherit the global defaults when unset, and each + /// builder's `auth_data` defaults to the UTF-8 bytes of its URL when unset. `sign` receives a + /// builder's opaque auth `data` and returns the corresponding `SignedRequestAuth` — in + /// practice signed for the current proposer/slot and cached. + /// + /// Signing is per-builder: a builder whose auth `sign` fails to produce is logged (with the + /// returned error) and omitted, so one unsignable builder cannot drop the rest. The returned + /// config always carries the global policy; its `builders` list holds only the successfully + /// signed builders, and is empty when no builders are enabled or every one failed to sign. + pub async fn builder_config(&self, sign: F) -> BuilderConfig + where + F: Fn(RequestAuthData) -> Fut, + Fut: Future>, + E: std::fmt::Debug, + { + // Snapshot the enabled builders and the global policy under the lock, then sign outside it, + // so the lock is never held across an `.await`. + let (definitions, min_bid, builder_boost_factor) = { + let config = self.config.read(); + let definitions: Vec = config + .as_slice() + .iter() + .filter(|d| d.enabled) + .cloned() + .collect(); + (definitions, config.min_bid, config.builder_boost_factor) + }; + + // Sign every builder's request auth concurrently. With a remote signer each `sign` is a + // network round trip, and the signatures are independent, so signing in sequence would put + // up to `MaxBuilderEntries` serial round trips on the block-production critical path. + let signed = futures::future::join_all(definitions.into_iter().filter_map(|definition| { + let auth_data = definition + .auth_data + .clone() + .unwrap_or_else(|| definition.url.to_default_auth_data()); + // A zero-length auth `data` is invalid on the wire (beacon-specs #165 / beacon-APIs + // #630); the beacon node would reject the whole request body, so drop the builder here. + if auth_data.is_empty() { + error!( + builder_url = %definition.url, + "Zero-length auth_data is invalid; omitting builder from config" + ); + return None; + } + let signing = sign(auth_data); + Some(async move { (definition, signing.await) }) + })) + .await; + + // `join_all` preserves input order, so `builders` keeps the configured order. Omit any + // builder we cannot sign for, logging the error, rather than failing the whole config. + let mut builders = Vec::with_capacity(signed.len()); + for (definition, result) in signed { + let auth = match result { + Ok(auth) => auth, + Err(e) => { + error!( + error = ?e, + builder_url = %definition.url, + "Failed to sign builder request auth; omitting builder from config" + ); + continue; + } + }; + let Ok(builder_pubkeys) = BuilderPubkeys::new(definition.builder_pubkeys) else { + error!( + builder_url = %definition.url, + "Too many builder pubkeys; omitting builder from config" + ); + continue; + }; + builders.push(BuilderEntry { + url: definition.url, + auth, + builder_pubkeys, + max_execution_payment: definition.max_execution_payment, + min_bid: definition.min_bid.unwrap_or(min_bid), + builder_boost_factor: definition + .builder_boost_factor + .unwrap_or(builder_boost_factor), + }); + } + + BuilderConfig { + // The number of builders is bounded by `MaxBuilderEntries` at config load, so this + // cannot overflow. + builders: VariableList::new(builders) + .expect("builder count is bounded by MaxBuilderEntries at config load"), + min_bid, + builder_boost_factor, + } + } + + pub fn insert(&self, builder: BuilderDefinition) -> Result<(), Error> { + let mut config = self.config.write(); + // Validate a candidate copy before committing, so a bad insert leaves the config unchanged + // (and the global bid-policy defaults are preserved). + let mut candidate = config.clone(); + candidate.push(builder); + candidate.validate()?; + + *config = candidate; + config.save(&self.validators_dir) + } +} diff --git a/validator_client/lighthouse_validator_store/Cargo.toml b/validator_client/lighthouse_validator_store/Cargo.toml index 55d5f1cf32e..2020280e0bc 100644 --- a/validator_client/lighthouse_validator_store/Cargo.toml +++ b/validator_client/lighthouse_validator_store/Cargo.toml @@ -8,6 +8,7 @@ authors = ["Sigma Prime "] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_types = { workspace = true } doppelganger_service = { workspace = true } either = { workspace = true } environment = { workspace = true } diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index ce2b85f3af5..d98e80cf454 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -1,5 +1,6 @@ use account_utils::validator_definitions::{PasswordStorage, ValidatorDefinition}; use bls::{AggregateSignature, PublicKeyBytes, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use doppelganger_service::DoppelgangerService; use eth2::types::PublishBlockRequest; use futures::{Stream, future::join_all, stream}; @@ -1502,4 +1503,28 @@ impl ValidatorStore for LighthouseValidatorS signature, }) } + + async fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> Result { + let domain_hash = self.spec.get_request_auth_domain(); + let signing_root = request_auth_v1.signing_root(domain_hash); + + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + let signature = signing_method + .get_signature_from_root::>( + SignableMessage::RequestAuth(&request_auth_v1), + signing_root, + &self.task_executor, + None, + ) + .await?; + + Ok(SignedRequestAuth { + message: request_auth_v1, + signature, + }) + } } diff --git a/validator_client/signing_method/Cargo.toml b/validator_client/signing_method/Cargo.toml index cb321c2d498..2a33382d5e8 100644 --- a/validator_client/signing_method/Cargo.toml +++ b/validator_client/signing_method/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } eth2_keystore = { workspace = true } ethereum_serde_utils = { workspace = true } lockfile = { workspace = true } diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index 0dfde989464..f877afeaa7e 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -4,6 +4,7 @@ //! - Via a remote signer (Web3Signer) use bls::{Keypair, PublicKey, Signature}; +use builder_types::RequestAuth; use eth2_keystore::Keystore; use lockfile::Lockfile; use parking_lot::Mutex; @@ -52,6 +53,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + RequestAuth(&'a RequestAuth), } impl> SignableMessage<'_, E, Payload> { @@ -76,6 +78,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), SignableMessage::PayloadAttestationData(d) => d.signing_root(domain), SignableMessage::ProposerPreferences(p) => p.signing_root(domain), + SignableMessage::RequestAuth(r) => r.signing_root(domain), } } } @@ -248,6 +251,7 @@ impl SigningMethod { SignableMessage::ProposerPreferences(p) => { Web3SignerObject::ProposerPreferences(p) } + SignableMessage::RequestAuth(r) => Web3SignerObject::RequestAuth(r), }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index 8548a933e66..505147a46d4 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -2,6 +2,7 @@ use super::Error; use bls::{PublicKeyBytes, Signature}; +use builder_types::RequestAuth; use serde::{Deserialize, Serialize}; use types::*; @@ -23,6 +24,7 @@ pub enum MessageType { ExecutionPayloadEnvelope, PayloadAttestation, ProposerPreferences, + RequestAuth, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -83,6 +85,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), ProposerPreferences(&'a ProposerPreferences), + RequestAuth(&'a RequestAuth), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -156,6 +159,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation, Web3SignerObject::ProposerPreferences(_) => MessageType::ProposerPreferences, + Web3SignerObject::RequestAuth(_) => MessageType::RequestAuth, } } } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 88844918431..7697a08c45b 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; use crate::cli::ValidatorClient; use crate::duties_service::SelectionProofConfig; +use builder_store::BuilderStore; pub use config::Config; use initialized_validators::InitializedValidators; use metrics::set_gauge; @@ -43,11 +44,13 @@ use validator_services::notifier_service::spawn_notifier; use validator_services::{ attestation_service::{AttestationService, AttestationServiceBuilder}, block_service::{BlockService, BlockServiceBuilder}, + builder_preferences_service::BuilderPreferencesService, duties_service::{self, DutiesService, DutiesServiceBuilder}, latency_service, payload_attestation_service::PayloadAttestationService, preparation_service::{PreparationService, PreparationServiceBuilder}, proposer_preferences_service::ProposerPreferencesService, + request_auth_cache::RequestAuthCache, sync_committee_service::SyncCommitteeService, }; use validator_store::ValidatorStore as ValidatorStoreTrait; @@ -91,6 +94,7 @@ pub struct ProductionValidatorClient { doppelganger_service: Option>, preparation_service: PreparationService, SystemTimeSlotClock>, validator_store: Arc>, + builder_preferences_service: BuilderPreferencesService, SystemTimeSlotClock>, slot_clock: SystemTimeSlotClock, http_api_listen_addr: Option, config: Config, @@ -513,6 +517,10 @@ impl ProductionValidatorClient { ctx.shared.write().duties_service = Some(duties_service.clone()); } + let configured_builders = BuilderStore::open_or_create(&config.validator_dir) + .map_err(|e| format!("Unable to open or create builder definitions: {:?}", e))?; + let request_auth_cache = RequestAuthCache::default(); + let mut block_service_builder = BlockServiceBuilder::new() .slot_clock(slot_clock.clone()) .validator_store(validator_store.clone()) @@ -521,7 +529,9 @@ impl ProductionValidatorClient { .chain_spec(context.eth2_config.spec.clone()) .graffiti(config.graffiti) .graffiti_file(config.graffiti_file.clone()) - .graffiti_policy(config.graffiti_policy); + .graffiti_policy(config.graffiti_policy) + .configured_builders(configured_builders.clone()) + .request_auth_cache(request_auth_cache.clone()); // If we have proposer nodes, add them to the block service builder. if proposer_nodes_num > 0 { @@ -577,6 +587,17 @@ impl ProductionValidatorClient { context.eth2_config.spec.clone(), ); + let builder_preferences_service = BuilderPreferencesService::new( + duties_service.clone(), + validator_store.clone(), + slot_clock.clone(), + beacon_nodes.clone(), + configured_builders.clone(), + request_auth_cache.clone(), + context.executor.clone(), + context.eth2_config.spec.clone(), + ); + Ok(Self { context, duties_service, @@ -588,6 +609,7 @@ impl ProductionValidatorClient { doppelganger_service, preparation_service, validator_store, + builder_preferences_service, config, slot_clock, http_api_listen_addr: None, @@ -667,6 +689,11 @@ impl ProductionValidatorClient { .clone() .start_update_service() .map_err(|e| format!("Unable to start proposer preferences service: {}", e))?; + + self.builder_preferences_service + .clone() + .start_update_service() + .map_err(|e| format!("Unable to start builder preferences service: {}", e))?; } self.preparation_service diff --git a/validator_client/validator_services/Cargo.toml b/validator_client/validator_services/Cargo.toml index 625eee85bdb..5fb3137057e 100644 --- a/validator_client/validator_services/Cargo.toml +++ b/validator_client/validator_services/Cargo.toml @@ -7,6 +7,8 @@ authors = ["Sigma Prime "] [dependencies] beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_store = { workspace = true } +builder_types = { workspace = true } either = { workspace = true } eth2 = { workspace = true } futures = { workspace = true } diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 7531543a187..b2cfa138e88 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -1,5 +1,7 @@ +use crate::request_auth_cache::RequestAuthCache; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, Error as FallbackError, Errors}; use bls::PublicKeyBytes; +use builder_store::BuilderStore; use eth2::BeaconNodeHttpClient; use eth2::types::GraffitiPolicy; use graffiti_file::{GraffitiFile, determine_graffiti}; @@ -53,6 +55,8 @@ pub struct BlockServiceBuilder { graffiti: Option, graffiti_file: Option, graffiti_policy: Option, + configured_builders: Option, + request_auth_cache: Option, } impl BlockServiceBuilder { @@ -67,6 +71,8 @@ impl BlockServiceBuilder { graffiti: None, graffiti_file: None, graffiti_policy: None, + configured_builders: None, + request_auth_cache: None, } } @@ -115,6 +121,16 @@ impl BlockServiceBuilder { self } + pub fn configured_builders(mut self, configured_builders: BuilderStore) -> Self { + self.configured_builders = Some(configured_builders); + self + } + + pub fn request_auth_cache(mut self, request_auth_cache: RequestAuthCache) -> Self { + self.request_auth_cache = Some(request_auth_cache); + self + } + pub fn build(self) -> Result, String> { Ok(BlockService { inner: Arc::new(Inner { @@ -137,6 +153,12 @@ impl BlockServiceBuilder { graffiti: self.graffiti, graffiti_file: self.graffiti_file, graffiti_policy: self.graffiti_policy, + configured_builders: self + .configured_builders + .ok_or("Cannot build BlockService without configured_builders")?, + request_auth_cache: self + .request_auth_cache + .ok_or("Cannot build BlockService without request_auth_cache")?, }), }) } @@ -203,6 +225,11 @@ pub struct Inner { graffiti: Option, graffiti_file: Option, graffiti_policy: Option, + /// The configured builders to resolve into a `BuilderConfig` when producing a Gloas block. + configured_builders: BuilderStore, + /// Caches the per-(slot, proposer, auth_data) request-auth signatures reused when resolving the + /// builder config. + request_auth_cache: RequestAuthCache, } /// Attempts to produce attestations for any block producer(s) at the start of the epoch. @@ -339,6 +366,7 @@ impl BlockService { graffiti: Option, validator_pubkey: &PublicKeyBytes, unsigned_block: UnsignedBlock, + builder_url: Option, ) -> Result<(), BlockError> { let signing_timer = validator_metrics::start_timer(&validator_metrics::BLOCK_SIGNING_TIMES); @@ -383,9 +411,10 @@ impl BlockService { // Try the proposer nodes first, since we've likely gone to efforts to // protect them from DoS attacks and they're most likely to successfully // publish a block. + let builder_url_ref = builder_url.as_deref(); proposer_fallback .request_proposers_first(|beacon_node| async { - self.publish_signed_block_contents(&signed_block, beacon_node) + self.publish_signed_block_contents(&signed_block, beacon_node, builder_url_ref) .await }) .await?; @@ -463,7 +492,35 @@ impl BlockService { // Check if Gloas fork is active at this slot let fork_name = self_ref.chain_spec.fork_name_at_slot::(slot); - let (block_proposer, unsigned_block) = if fork_name.gloas_enabled() { + let (block_proposer, unsigned_block, builder_url) = if fork_name.gloas_enabled() { + // Resolve the validator's builder config for this proposal, signing each builder's + // request auth via the cache. Sent in the POST `produceBlockV4` body below (the same + // body is reused on the SSZ-to-JSON fallback and on every proposer-fallback BN). With + // no builders configured this resolves to an empty list, so the proposal still falls + // back to a local or p2p payload. Per-builder sign failures are logged and omitted + // inside `builder_config`, so this never fails the proposal. + let builder_config = self_ref + .configured_builders + .builder_config(|auth_data| { + self_ref.request_auth_cache.get_or_sign( + slot, + validator_pubkey, + auth_data, + |request_auth_v1| { + self_ref + .validator_store + .sign_request_auth_v1(validator_pubkey, request_auth_v1) + }, + ) + }) + .await; + debug!( + slot = slot.as_u64(), + builders = builder_config.builders.len(), + "Resolved builder config for block production" + ); + let builder_config_ref = &builder_config; + // Use V4 block production for Gloas // Request an SSZ block from all beacon nodes in order, returning on the first successful response. // If all nodes fail, run a second pass falling back to JSON. @@ -474,20 +531,25 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_GET], ); beacon_node - .get_validator_blocks_v4_ssz::( + .post_validator_blocks_v4_ssz::( slot, randao_reveal_ref, graffiti.as_ref(), false, - builder_boost_factor, + builder_config_ref, self_ref.graffiti_policy, + fork_name, ) .await }) .await; - let block_response = match ssz_block_response { - Ok((ssz_block_response, _metadata)) => ssz_block_response.into_block(), + // `builder_url` is the `Eth-Builder-Url` from the winning beacon node — echoed on publish + // so it forwards the block to the builder that won selection. + let (block_response, builder_url) = match ssz_block_response { + Ok((ssz_block_response, metadata)) => { + (ssz_block_response.into_block(), metadata.builder_url) + } Err(e) => { warn!( slot = slot.as_u64(), @@ -501,14 +563,15 @@ impl BlockService { &validator_metrics::BLOCK_SERVICE_TIMES, &[validator_metrics::BEACON_BLOCK_HTTP_GET], ); - let (json_block_response, _metadata) = beacon_node - .get_validator_blocks_v4::( + let (json_block_response, metadata) = beacon_node + .post_validator_blocks_v4::( slot, randao_reveal_ref, graffiti.as_ref(), false, - builder_boost_factor, + builder_config_ref, self_ref.graffiti_policy, + fork_name, ) .await .map_err(|e| { @@ -518,7 +581,7 @@ impl BlockService { )) })?; - Ok(json_block_response.into_block()) + Ok((json_block_response.into_block(), metadata.builder_url)) }) .await .map_err(BlockError::from)? @@ -530,6 +593,7 @@ impl BlockService { ( block_contents.block().proposer_index(), UnsignedBlock::Full(block_contents), + builder_url, ) } else { // Use V3 block production for pre-Gloas forks @@ -594,12 +658,16 @@ impl BlockService { } }; + // Pre-Gloas has no builder-URL provenance (the V3 mev-boost path handles builder + // forwarding itself), so there's nothing to echo on publish. match block_response { - eth2::types::ProduceBlockV3Response::Full(block) => { - (block.block().proposer_index(), UnsignedBlock::Full(block)) - } + eth2::types::ProduceBlockV3Response::Full(block) => ( + block.block().proposer_index(), + UnsignedBlock::Full(block), + None, + ), eth2::types::ProduceBlockV3Response::Blinded(block) => { - (block.proposer_index(), UnsignedBlock::Blinded(block)) + (block.proposer_index(), UnsignedBlock::Blinded(block), None) } } }; @@ -623,6 +691,7 @@ impl BlockService { graffiti, &validator_pubkey, unsigned_block, + builder_url, ) .await?; @@ -742,6 +811,7 @@ impl BlockService { &self, signed_block: &SignedBlock, beacon_node: BeaconNodeHttpClient, + builder_url: Option<&str>, ) -> Result<(), BlockError> { match signed_block { SignedBlock::Full(signed_block) => { @@ -750,7 +820,7 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_POST], ); beacon_node - .post_beacon_blocks_v2_ssz(signed_block, None, None) + .post_beacon_blocks_v2_ssz(signed_block, None, builder_url) .await .map(|_| ()) .or_else(|e| { @@ -854,6 +924,10 @@ mod tests { .beacon_nodes(harness.beacon_nodes.clone()) .executor(harness.test_runtime.task_executor.clone()) .chain_spec(harness.spec.clone()) + .request_auth_cache(RequestAuthCache::default()) + .configured_builders( + BuilderStore::open_or_create(harness._validator_dir.path()).unwrap(), + ) .build() .unwrap(); @@ -880,7 +954,11 @@ mod tests { let mock_different_slot = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, different_notification_slot); + .mock_post_validator_blocks_v4_ssz( + &block, + ForkName::Gloas, + different_notification_slot, + ); test_harness .service @@ -902,7 +980,7 @@ mod tests { let mock_same_slot = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, same_notification_slot); + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, same_notification_slot); test_harness .service @@ -937,7 +1015,7 @@ mod tests { test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); + .mock_post_validator_blocks_v4_ssz(&block, ForkName::Gloas, slot); let mock_post_block = test_harness .harness .mock_beacon_node_1 @@ -1000,11 +1078,11 @@ mod tests { let mock_bn_1 = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_bn_2 = test_harness .harness .mock_beacon_node_2 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_post_block = test_harness .harness @@ -1048,11 +1126,11 @@ mod tests { let mock_ssz = test_harness .harness .mock_beacon_node_1 - .mock_get_validator_blocks_v4_ssz_error(slot); + .mock_post_validator_blocks_v4_ssz_error(slot); let mock_json = test_harness .harness .mock_beacon_node_2 - .mock_get_validator_blocks_v4(&block, ForkName::Gloas, slot); + .mock_post_validator_blocks_v4(&block, ForkName::Gloas, slot); let _result = test_harness .service diff --git a/validator_client/validator_services/src/builder_preferences_service.rs b/validator_client/validator_services/src/builder_preferences_service.rs new file mode 100644 index 00000000000..203c236588b --- /dev/null +++ b/validator_client/validator_services/src/builder_preferences_service.rs @@ -0,0 +1,308 @@ +use crate::duties_service::DutiesService; +use crate::request_auth_cache::RequestAuthCache; +use beacon_node_fallback::BeaconNodeFallback; +use bls::PublicKeyBytes; +use builder_store::BuilderStore; +use builder_types::{BuilderEntry, BuilderUrl, RequestAuthData}; +use eth2::types::{ + BuilderPreferenceEntry, MAX_SUBMITTED_BUILDER_PREFERENCES, SubmittedBuilderPreferences, +}; +use slot_clock::SlotClock; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; +use task_executor::TaskExecutor; +use tokio::time::sleep; +use tracing::{debug, error, info}; +use types::{ChainSpec, EthSpec, Slot}; +use validator_store::ValidatorStore; + +/// The non-slot part of a published entry's identity: the proposer pubkey plus the decomposed +/// `BuilderPreferenceEntry` with its `slot` factored out to the enclosing map's key. +/// - `pubkey`: the proposer the entry was submitted for +/// - `url`: `entry.url` +/// - `auth_data`: `entry.auth.message.data` +/// - `max_execution_payment`: `entry.max_execution_payment` +/// +/// See [`PublishedBuilderPreferencesCache`] for how `entry.auth` decomposes into `auth_data` here +/// and `slot` at the map level, and why the `auth` signature is dropped. +#[derive(PartialEq, Eq, Hash)] +struct InnerPreferencesKey { + pubkey: PublicKeyBytes, + url: BuilderUrl, + auth_data: RequestAuthData, + max_execution_payment: u64, +} + +/// De-duplicates the `BuilderPreferenceEntry`s we've already published, so we don't re-send one. +/// +/// The identity of a published entry is `(proposer_pubkey, decompose(entry))`. That decomposition is +/// split across the two levels of this map: +/// - `entry.auth.message.slot` becomes the outer `BTreeMap` key; +/// - the rest — `proposer_pubkey`, `entry.url`, `entry.auth.message.data`, and +/// `entry.max_execution_payment` — forms the [`InnerPreferencesKey`] held in the per-slot set. +/// +/// So `entry.auth` decomposes into its `slot` (the map key) and its `data`/`auth_data` (in the inner +/// key); the `auth` signature is dropped, as it is a deterministic function of the proposer, the +/// `auth_data`, and the slot and so adds no identity. +/// +/// Operators may change their builder config at any time. Because this identity captures every entry +/// field that reaches a builder, any edit yields a new key that won't match a previously-sent entry, +/// so the updated preference is published again. +#[derive(Default)] +struct PublishedBuilderPreferencesCache { + cache: BTreeMap>, +} + +impl PublishedBuilderPreferencesCache { + pub fn new() -> Self { + Self::default() + } + + pub fn contains( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + builder_entry: &BuilderEntry, + ) -> bool { + self.cache.get(&slot).is_some_and(|set| { + set.contains(&InnerPreferencesKey { + pubkey, + url: builder_entry.url.clone(), + auth_data: builder_entry.auth.message.data.clone(), + max_execution_payment: builder_entry.max_execution_payment, + }) + }) + } + + pub fn mark_sent( + &mut self, + pubkey: PublicKeyBytes, + builder_preferences_entry: BuilderPreferenceEntry, + ) { + let slot = builder_preferences_entry.auth.message.slot; + let inner_key = InnerPreferencesKey { + pubkey, + url: builder_preferences_entry.url, + auth_data: builder_preferences_entry.auth.message.data, + max_execution_payment: builder_preferences_entry.max_execution_payment, + }; + self.cache.entry(slot).or_default().insert(inner_key); + } + + pub fn prune(&mut self, current_slot: Slot) { + self.cache = self.cache.split_off(¤t_slot); + } +} + +// Minimizes `Arc` usage +struct Inner { + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + configured_builders: BuilderStore, + request_auth_cache: RequestAuthCache, + executor: TaskExecutor, + chain_spec: Arc, +} + +pub struct BuilderPreferencesService { + inner: Arc>, +} + +// Generic clone implementation is too dumb to do this +impl Clone for BuilderPreferencesService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl BuilderPreferencesService { + #[allow(clippy::too_many_arguments)] + pub fn new( + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + configured_builders: BuilderStore, + request_auth_cache: RequestAuthCache, + executor: TaskExecutor, + chain_spec: Arc, + ) -> Self { + Self { + inner: Arc::new(Inner { + duties_service, + validator_store, + slot_clock, + beacon_nodes, + configured_builders, + request_auth_cache, + executor, + chain_spec, + }), + } + } + + pub fn start_update_service(self) -> Result<(), String> { + let slot_duration = self.inner.chain_spec.get_slot_duration(); + info!("Builder preferences service started"); + + let executor = self.inner.executor.clone(); + + let interval_fut = async move { + let mut published_preferences = PublishedBuilderPreferencesCache::new(); + + loop { + let Some(current_slot) = self.inner.slot_clock.now() else { + error!("Failed to read slot clock"); + sleep(slot_duration).await; + continue; + }; + + self.poll_and_publish_preferences(current_slot, &mut published_preferences) + .await; + + published_preferences.prune(current_slot); + self.inner.request_auth_cache.prune(current_slot); + + let duration_to_next_slot = self + .inner + .slot_clock + .duration_to_next_slot() + .unwrap_or(slot_duration); + sleep(duration_to_next_slot).await; + } + }; + + executor.spawn(interval_fut, "builder_preferences_service"); + Ok(()) + } + + /// Publish builder preferences for `current_epoch` and `current_epoch + 1`. + /// Will only publish a given `(proposer, builder, max_execution_payment)` preference once. + async fn poll_and_publish_preferences( + &self, + current_slot: Slot, + published_preferences: &mut PublishedBuilderPreferencesCache, + ) { + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + // One flat request whose body spans both epochs, each entry naming its own proposer + // (beacon-APIs #630, whose body is sized for several epochs of entries). The single + // `Eth-Consensus-Version` is the version active now, at submission time. + let current_fork = self.inner.chain_spec.fork_name_at_epoch(current_epoch); + let mut pending_entries: Vec = Vec::new(); + + for (epoch, fork_name) in [ + ( + current_epoch, + self.inner.chain_spec.fork_name_at_epoch(current_epoch), + ), + ( + current_epoch + 1, + self.inner.chain_spec.fork_name_at_epoch(current_epoch + 1), + ), + ] { + if !fork_name.gloas_enabled() { + continue; + } + + let proposers = match self.inner.duties_service.proposers.read().get(&epoch) { + Some((_, proposers)) => proposers.clone(), + None => continue, + }; + + for proposer_data in &proposers { + let slot = proposer_data.slot; + let pubkey = proposer_data.pubkey; + + // Resolve and sign the whole builder config for this proposer/slot. Auths are + // cached, so builders already published for this slot cost only a cache hit. + // Per-builder sign failures are logged and omitted inside `builder_config`, so a + // fully-failed set just yields an empty `builders` list (nothing to publish). + let config = self + .inner + .configured_builders + .builder_config(|auth_data| { + self.inner.request_auth_cache.get_or_sign( + slot, + pubkey, + auth_data, + |request_auth_v1| { + self.inner + .validator_store + .sign_request_auth_v1(pubkey, request_auth_v1) + }, + ) + }) + .await; + + // A `BuilderPreferenceEntry` is a `BuilderEntry` narrowed to what a builder may see: + // its private `min_bid`/`builder_boost_factor`/`builder_pubkeys` are dropped. + for entry in config.builders.iter() { + if published_preferences.contains(slot, pubkey, entry) { + // already published, skip + continue; + } + pending_entries.push(BuilderPreferenceEntry::from_builder_entry( + pubkey, + entry.clone(), + )); + } + } + } + + if pending_entries.is_empty() { + return; + } + + // One submission carries at most `MAX_SUBMITTED_BUILDER_PREFERENCES` entries (beacon-APIs + // #630), so submit in bounded chunks. Each chunk is best-effort: a failed chunk is logged + // and does not stop the rest. + for chunk in pending_entries.chunks(MAX_SUBMITTED_BUILDER_PREFERENCES) { + let Ok(entries) = SubmittedBuilderPreferences::new(chunk.to_vec()) else { + // Unreachable: `chunks()` bounds each chunk by the list limit. + continue; + }; + let entries_ref = &entries; + + // Try SSZ first, falling back to JSON. `first_success` is okay here because later + // we'll be resending the auths when we publish the beacon block. + let ssz_result = self + .inner + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_builder_preferences_ssz(entries_ref, current_fork) + .await + }) + .await; + + let result = match ssz_result { + Ok(()) => Ok(()), + Err(ssz_err) => { + debug!(error = %ssz_err, "SSZ builder preferences publish failed, falling back to JSON"); + self.inner + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_builder_preferences(entries_ref, current_fork) + .await + }) + .await + } + }; + + match result { + Ok(()) => { + for entry in entries.iter().cloned() { + let pubkey = entry.proposer_pubkey; + published_preferences.mark_sent(pubkey, entry); + } + } + Err(e) => error!(error = %e, "Failed to publish builder preferences"), + } + } + } +} diff --git a/validator_client/validator_services/src/lib.rs b/validator_client/validator_services/src/lib.rs index c39ef4499b7..3db106ac692 100644 --- a/validator_client/validator_services/src/lib.rs +++ b/validator_client/validator_services/src/lib.rs @@ -1,10 +1,12 @@ pub mod attestation_service; pub mod block_service; +pub mod builder_preferences_service; pub mod duties_service; pub mod latency_service; pub mod notifier_service; pub mod payload_attestation_service; pub mod preparation_service; pub mod proposer_preferences_service; +pub mod request_auth_cache; pub mod sync; pub mod sync_committee_service; diff --git a/validator_client/validator_services/src/request_auth_cache.rs b/validator_client/validator_services/src/request_auth_cache.rs new file mode 100644 index 00000000000..4d9e553d8be --- /dev/null +++ b/validator_client/validator_services/src/request_auth_cache.rs @@ -0,0 +1,106 @@ +use bls::PublicKeyBytes; +use builder_types::{RequestAuth, RequestAuthData, SignedRequestAuth}; +use parking_lot::RwLock; +use std::collections::{BTreeMap, HashMap}; +use std::future::Future; +use std::sync::Arc; +use types::Slot; + +/// Caches signed `RequestAuth` objects so a given proposer/auth-data/slot combination is only +/// signed once. +/// +/// The signed authorization is a pure function of the proposer pubkey, the opaque `auth_data`, and +/// the proposal `slot`, so those form the cache key. The builder URL is deliberately *not* part of +/// the key: two builders configured with the same `auth_data` share one signature. +#[derive(Hash, PartialEq, Eq)] +struct RequestAuthInnerKey { + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, +} + +#[derive(Default)] +struct Inner { + entries: BTreeMap>, +} + +#[derive(Clone)] +pub struct RequestAuthCache { + inner: Arc>, +} + +impl Default for RequestAuthCache { + fn default() -> Self { + Self { + inner: Arc::new(RwLock::new(Inner::default())), + } + } +} + +impl RequestAuthCache { + pub fn get( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: &RequestAuthData, + ) -> Option { + self.inner.read().entries.get(&slot).and_then(|entries| { + let key = RequestAuthInnerKey { + pubkey, + auth_data: auth_data.clone(), + }; + entries.get(&key).cloned() + }) + } + + pub fn insert( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, + signed_request_auth: SignedRequestAuth, + ) { + let key = RequestAuthInnerKey { pubkey, auth_data }; + + self.inner + .write() + .entries + .entry(slot) + .or_default() + .insert(key, signed_request_auth); + } + + /// Return the cached signature for `(slot, pubkey, auth_data)`, or produce it via `sign` (and + /// cache the result) on a miss. + /// + /// The signature is a pure function of the proposer, `auth_data`, and slot, so a hit returns + /// immediately without invoking `sign`. `sign` receives the fully-formed `RequestAuth` to + /// sign — in practice `ValidatorStore::sign_request_auth_v1`. + pub async fn get_or_sign( + &self, + slot: Slot, + pubkey: PublicKeyBytes, + auth_data: RequestAuthData, + sign: F, + ) -> Result + where + F: FnOnce(RequestAuth) -> Fut, + Fut: Future>, + { + if let Some(signed) = self.get(slot, pubkey, &auth_data) { + return Ok(signed); + } + + let signed = sign(RequestAuth { + data: auth_data.clone(), + slot, + }) + .await?; + self.insert(slot, pubkey, auth_data, signed.clone()); + Ok(signed) + } + + pub fn prune(&self, current_slot: Slot) { + let mut guard = self.inner.write(); + guard.entries = guard.entries.split_off(¤t_slot); + } +} diff --git a/validator_client/validator_store/Cargo.toml b/validator_client/validator_store/Cargo.toml index 2c6a68d4949..092f927589f 100644 --- a/validator_client/validator_store/Cargo.toml +++ b/validator_client/validator_store/Cargo.toml @@ -6,6 +6,7 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } eth2 = { workspace = true } futures = { workspace = true } slashing_protection = { workspace = true } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index dde82a2a5bb..6b2257af198 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -1,4 +1,5 @@ use bls::{PublicKeyBytes, Signature}; +use builder_types::{RequestAuth, SignedRequestAuth}; use eth2::types::{FullBlockContents, PublishBlockRequest}; use futures::Stream; use slashing_protection::NotSafe; @@ -213,6 +214,12 @@ pub trait ValidatorStore: Send + Sync { preferences: ProposerPreferences, ) -> impl Future>> + Send; + fn sign_request_auth_v1( + &self, + validator_pubkey: PublicKeyBytes, + request_auth_v1: RequestAuth, + ) -> impl Future>> + Send; + /// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`. /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. diff --git a/wordlist.txt b/wordlist.txt index f0076e63322..1fd0e7603d9 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -108,6 +108,7 @@ UI Uncached UPnP USD +UTF UX Validator VC @@ -150,6 +151,7 @@ doppelgänger dropdown else's env +ePBS eth ethdo ethereum