From eb375950cecd0f0d251f947667ca0c00660528a0 Mon Sep 17 00:00:00 2001 From: Mark Mackey Date: Thu, 13 Aug 2026 14:48:14 -0500 Subject: [PATCH 01/14] 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/14] 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/14] 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 309a1e1423bd8a1f4dfaee5e542a2a8c4d546748 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 01:46:18 +0000 Subject: [PATCH 04/14] Add per-validator builder configuration API --- Cargo.lock | 2 + book/src/advanced_builders.md | 20 +- book/src/api_vc.md | 6 +- book/src/api_vc_endpoints.md | 13 +- book/src/gloas_builder_config.md | 18 + common/eth2/src/lighthouse_vc/http_client.rs | 39 ++ common/eth2/src/lighthouse_vc/std_types.rs | 210 +++++++++++ common/warp_utils/src/reject.rs | 3 + .../builder_store/src/builder_definitions.rs | 228 +++++++++++- validator_client/builder_store/src/lib.rs | 346 +++++++++++++++++- validator_client/http_api/Cargo.toml | 2 + validator_client/http_api/src/lib.rs | 199 +++++++++- validator_client/http_api/src/test_utils.rs | 5 + validator_client/http_api/src/tests.rs | 220 ++++++++++- validator_client/src/lib.rs | 3 + .../validator_services/src/block_service.rs | 2 +- .../src/builder_preferences_service.rs | 2 +- 17 files changed, 1268 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e8adb51eb7..37c4de33393 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9887,6 +9887,7 @@ dependencies = [ "axum_utils", "beacon_node_fallback", "bls", + "builder_store", "deposit_contract", "directory", "dirs", @@ -9906,6 +9907,7 @@ dependencies = [ "logging", "parking_lot", "rand 0.9.2", + "reqwest", "sensitive_url", "serde", "serde_json", diff --git a/book/src/advanced_builders.md b/book/src/advanced_builders.md index 7202bd7bb9d..e8d0fccf044 100644 --- a/book/src/advanced_builders.md +++ b/book/src/advanced_builders.md @@ -61,10 +61,8 @@ relays, run one of the following services and configure lighthouse to use it wit In the validator client you can configure gas limit and fee recipient on a per-validator basis. If no gas limit is configured, Lighthouse will use a default gas limit of 60,000,000, which is the current default value used in execution -engines. You can also enable or disable use of external builders on a per-validator basis rather than using -`--builder-proposals`, `--builder-boost-factor` or `--prefer-builder-proposals`, which apply builder related preferences for all validators. -In order to manage these configurations per-validator, you can either make updates to the `validator_definitions.yml` file -or you can use the HTTP requests described below. +engines. You can also configure the external builders for one validator without changing the global builder settings. +Use the standard keymanager API requests described below for per-validator builder configuration. Both the gas limit and fee recipient will be passed along as suggestions to connected builders. If there is a discrepancy in either, it will *not* keep you from proposing a block with the builder. This is because the bounds on gas limit are @@ -85,10 +83,18 @@ To update gas limit per-validator you can use the [standard key manager API][gas Alternatively, you can use the [lighthouse API](api_vc_endpoints.md). See below for an example. -### Enable/Disable builder proposals via HTTP +### Configure external builders via HTTP -Use the [lighthouse API](api_vc_endpoints.md) to enable/disable use of the builder API on a per-validator basis. -You can also update the configured gas limit with these requests. +Use the standard keymanager API to manage the external builders for one validator: + +- `GET /eth/v1/validator/{pubkey}/builder_config` returns the fully resolved configuration. +- `POST /eth/v1/validator/{pubkey}/builder_config` replaces the per-validator configuration. +- `DELETE /eth/v1/validator/{pubkey}/builder_config` removes the per-validator configuration. + +An omitted field inherits the global configuration. `builders: []` explicitly disables direct builder +requests for that validator. `POST {}` stores no overrides and therefore has the same behavior as +the global configuration. `DELETE` restores inheritance after any override. Payment and boost values +use quoted decimal strings in JSON, and `auth_data` uses `0x`-prefixed hex. #### `PATCH /lighthouse/validators/:voting_pubkey` diff --git a/book/src/api_vc.md b/book/src/api_vc.md index f5df5df76c3..6338d29ddea 100644 --- a/book/src/api_vc.md +++ b/book/src/api_vc.md @@ -3,9 +3,9 @@ Lighthouse implements a JSON HTTP API for the validator client which enables programmatic management of validators and keys. -The API includes all of the endpoints from the [standard keymanager -API](https://ethereum.github.io/keymanager-APIs/) that is implemented by other clients and remote -signers. It also includes some Lighthouse-specific endpoints which are described in +The API includes the standard keymanager endpoints implemented by Lighthouse, including per-validator +fee recipient, gas limit, graffiti, keystore, remote-key, and external-builder configuration. It also +includes Lighthouse-specific endpoints which are described in [Endpoints](./api_vc_endpoints.md). > Note: All requests to the HTTP server must supply an diff --git a/book/src/api_vc_endpoints.md b/book/src/api_vc_endpoints.md index cc9dd362f80..0fe86edc433 100644 --- a/book/src/api_vc_endpoints.md +++ b/book/src/api_vc_endpoints.md @@ -20,10 +20,21 @@ | [`GET /lighthouse/logs`](#get-lighthouselogs) | Get logs | | [`GET /lighthouse/beacon/health`](#get-lighthousebeaconhealth) | Get health information for each connected beacon node. | | [`POST /lighthouse/beacon/update`](#post-lighthousebeaconupdate) | Update the `--beacon-nodes` list. | +| `GET /eth/v1/validator/:voting_pubkey/builder_config` | Get the resolved external-builder configuration for a validator. | +| `POST /eth/v1/validator/:voting_pubkey/builder_config` | Replace the external-builder configuration for a validator. | +| `DELETE /eth/v1/validator/:voting_pubkey/builder_config` | Remove the external-builder configuration and restore inheritance. | The query to Lighthouse API endpoints requires authorization, see [Authorization Header](./api_vc_auth_header.md). -In addition to the above endpoints Lighthouse also supports all of the [standard keymanager APIs](https://ethereum.github.io/keymanager-APIs/). +In addition to the above endpoints Lighthouse supports the standard keymanager endpoints listed in +the [keymanager API specification](https://ethereum.github.io/keymanager-APIs/). + +The builder configuration endpoints use the path +`/eth/v1/validator/{voting_pubkey}/builder_config`. `GET` returns resolved values. `POST` replaces the +per-validator configuration and accepts an empty object. Omitted fields inherit the global builder +configuration, while `builders: []` explicitly disables direct builders. `DELETE` removes the +per-validator configuration and restores global inheritance. Payment and boost values are quoted +decimal strings, and `auth_data` is `0x`-prefixed hex. ## `GET /lighthouse/version` diff --git a/book/src/gloas_builder_config.md b/book/src/gloas_builder_config.md index 4709885b7e1..7fc45d646b1 100644 --- a/book/src/gloas_builder_config.md +++ b/book/src/gloas_builder_config.md @@ -11,6 +11,8 @@ The validator client reads its external-builder settings from a YAML file named (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. +- **Per-validator configurations** under `validator_configs`, managed through the standard keymanager + API. Each map key is a validator public key. ## Example @@ -35,6 +37,12 @@ builders: 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` + +# Optional per-validator replacement or override. +# validator_configs: +# "0x": +# min_bid: 500000000 +# builders: [] # explicitly disable direct builders for this validator ``` > **Comments are not preserved.** The validator client rewrites this file when builders are added or @@ -50,6 +58,7 @@ builders: | `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. | +| `validator_configs` | no | `{}` | Per-validator configurations. An entry replaces only the fields it contains; omitted fields inherit the global configuration. | ### Per builder (each entry under `builders`) @@ -66,6 +75,15 @@ builders: All byte fields (`builder_pubkeys` entries, `auth_data`) are `0x`-prefixed hex strings. All payment values (`min_bid`, `max_execution_payment`) are in gwei. +## Per-validator configuration + +The standard keymanager API manages entries under `validator_configs` without a validator-client +restart. `GET /eth/v1/validator/{pubkey}/builder_config` returns all values after inheritance is resolved. +`POST` replaces the complete per-validator configuration, while `DELETE` removes it and restores the +global configuration. An omitted `builders` field inherits the global builder list; `builders: []` +means that the validator has no direct builders. An entry without `max_execution_payment` inherits +the value from a matching global builder URL and authentication value. + ## How bids are selected At block-production time the validator client requests a bid from each enabled builder with a `url`, diff --git a/common/eth2/src/lighthouse_vc/http_client.rs b/common/eth2/src/lighthouse_vc/http_client.rs index 3c850fcb052..297635c2f75 100644 --- a/common/eth2/src/lighthouse_vc/http_client.rs +++ b/common/eth2/src/lighthouse_vc/http_client.rs @@ -494,6 +494,18 @@ impl ValidatorClientHttpClient { Ok(url) } + fn make_builder_config_url(&self, pubkey: &PublicKeyBytes) -> Result { + let mut url = self.server.expose_full().clone(); + url.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("validator") + .push(&pubkey.to_string()) + .push("builder_config"); + Ok(url) + } + fn make_graffiti_url(&self, pubkey: &PublicKeyBytes) -> Result { let mut url = self.server.expose_full().clone(); url.path_segments_mut() @@ -603,6 +615,33 @@ impl ValidatorClientHttpClient { self.delete_with_raw_response(url, &()).await } + /// `GET /eth/v1/validator/{pubkey}/builder_config` + pub async fn get_builder_config( + &self, + pubkey: &PublicKeyBytes, + ) -> Result { + let url = self.make_builder_config_url(pubkey)?; + self.get(url) + .await + .map(|generic: GenericResponse| generic.data) + } + + /// `POST /eth/v1/validator/{pubkey}/builder_config` + pub async fn post_builder_config( + &self, + pubkey: &PublicKeyBytes, + request: &BuilderConfig, + ) -> Result { + let url = self.make_builder_config_url(pubkey)?; + self.post_with_raw_response(url, request).await + } + + /// `DELETE /eth/v1/validator/{pubkey}/builder_config` + pub async fn delete_builder_config(&self, pubkey: &PublicKeyBytes) -> Result { + let url = self.make_builder_config_url(pubkey)?; + self.delete_with_raw_response(url, &()).await + } + /// `GET /eth/v1/validator/{pubkey}/gas_limit` pub async fn get_gas_limit( &self, diff --git a/common/eth2/src/lighthouse_vc/std_types.rs b/common/eth2/src/lighthouse_vc/std_types.rs index c54252b9e33..3d3a9b283a3 100644 --- a/common/eth2/src/lighthouse_vc/std_types.rs +++ b/common/eth2/src/lighthouse_vc/std_types.rs @@ -1,9 +1,97 @@ use bls::PublicKeyBytes; +pub use builder_types::{BuilderUrl, RequestAuthData}; use eth2_keystore::Keystore; use serde::{Deserialize, Serialize}; +use serde_utils::quoted_u64::Quoted; use types::{Address, Graffiti}; use zeroize::Zeroizing; +mod optional_auth_data { + use super::RequestAuthData; + use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + + #[derive(Deserialize, Serialize)] + #[serde(transparent)] + struct JsonRequestAuthData( + #[serde(with = "ssz_types::serde_utils::hex_var_list")] RequestAuthData, + ); + + pub fn serialize( + value: &Option, + serializer: S, + ) -> Result { + value + .as_ref() + .map(|data| JsonRequestAuthData(data.clone())) + .serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Option::::deserialize(deserializer)? + .map(|data| Some(data.0)) + .ok_or_else(|| de::Error::custom("null is not allowed")) + } +} + +mod optional_quoted_u64 { + use super::Quoted; + use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + + pub fn serialize(value: &Option, serializer: S) -> Result { + value.map(|value| Quoted { value }).serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + Option::>::deserialize(deserializer)? + .map(|value| Some(value.value)) + .ok_or_else(|| de::Error::custom("null is not allowed")) + } +} + +mod optional_builder_pubkeys { + use bls::PublicKeyBytes; + use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + + pub fn serialize( + value: &Option>, + serializer: S, + ) -> Result { + value.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result>, D::Error> { + Option::>::deserialize(deserializer)? + .map(Some) + .ok_or_else(|| de::Error::custom("null is not allowed")) + } +} + +mod optional_builder_entries { + use super::BuilderEntry; + use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + + pub fn serialize( + value: &Option>, + serializer: S, + ) -> Result { + value.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result>, D::Error> { + Option::>::deserialize(deserializer)? + .map(Some) + .ok_or_else(|| de::Error::custom("null is not allowed")) + } +} + pub use eip_3076::Interchange; #[derive(Debug, Deserialize, Serialize, PartialEq)] @@ -20,6 +108,128 @@ pub struct GetGasLimitResponse { pub gas_limit: u64, } +/// Per-validator external-builder configuration from the standard keymanager API. +/// +/// A missing field inherits the validator client's global configuration. The GET endpoint returns +/// all fields resolved, while POST accepts an omitted `builders` field and an explicitly empty +/// list as distinct values. +#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] +pub struct BuilderConfig { + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_quoted_u64" + )] + pub min_bid: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_quoted_u64" + )] + pub builder_boost_factor: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_builder_entries" + )] + pub builders: Option>, +} + +/// An external-builder entry from the standard keymanager API. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct BuilderEntry { + pub url: BuilderUrl, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_auth_data" + )] + pub auth_data: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_builder_pubkeys" + )] + pub builder_pubkeys: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_quoted_u64" + )] + pub max_execution_payment: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_quoted_u64" + )] + pub min_bid: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_quoted_u64" + )] + pub builder_boost_factor: Option, +} + +#[cfg(test)] +mod builder_config_tests { + use super::*; + + #[test] + fn builder_config_uses_keymanager_json_encoding() { + let config = BuilderConfig { + min_bid: Some(3), + builder_boost_factor: Some(110), + builders: Some(vec![BuilderEntry { + url: "https://builder.example".parse().unwrap(), + auth_data: Some(RequestAuthData::new(vec![1, 2]).unwrap()), + builder_pubkeys: Some(vec![]), + max_execution_payment: Some(8), + min_bid: None, + builder_boost_factor: None, + }]), + }; + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["min_bid"], "3"); + assert_eq!(json["builder_boost_factor"], "110"); + assert_eq!(json["builders"][0]["auth_data"], "0x0102"); + assert_eq!(json["builders"][0]["max_execution_payment"], "8"); + assert_eq!( + json["builders"][0]["builder_pubkeys"], + serde_json::json!([]) + ); + assert_eq!( + serde_json::from_value::(json).unwrap(), + config + ); + } + + #[test] + fn empty_post_serializes_without_overrides() { + assert_eq!( + serde_json::to_value(BuilderConfig::default()).unwrap(), + serde_json::json!({}) + ); + } + + #[test] + fn null_optional_fields_are_rejected() { + for field in ["builders", "min_bid", "builder_boost_factor"] { + let json = serde_json::json!({(field): null}); + assert!( + serde_json::from_value::(json).is_err(), + "field {field} unexpectedly accepts null" + ); + } + + let json = serde_json::json!({ + "builders": [{"url": "https://builder.example", "builder_pubkeys": null}] + }); + assert!(serde_json::from_value::(json).is_err()); + } +} + #[derive(Debug, Deserialize, Serialize, PartialEq)] pub struct AuthResponse { pub token_path: String, diff --git a/common/warp_utils/src/reject.rs b/common/warp_utils/src/reject.rs index b88fd79b23f..a4c6f53599b 100644 --- a/common/warp_utils/src/reject.rs +++ b/common/warp_utils/src/reject.rs @@ -176,6 +176,9 @@ pub async fn handle_rejection(err: warp::Rejection) -> Result().is_some() { code = StatusCode::UNSUPPORTED_MEDIA_TYPE; message = "UNSUPPORTED_MEDIA_TYPE".to_string(); + } else if err.find::().is_some() { + code = StatusCode::PAYLOAD_TOO_LARGE; + message = "PAYLOAD_TOO_LARGE".to_string(); } else if let Some(e) = err.find::() { message = format!("BAD_REQUEST: body deserialize error: {}", e.0); code = StatusCode::BAD_REQUEST; diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs index 928f6d3ec14..5a33710f02d 100644 --- a/validator_client/builder_store/src/builder_definitions.rs +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -1,8 +1,8 @@ use account_utils::write_file_via_temporary; use bls::PublicKeyBytes; -use builder_types::{BuilderUrl, MAX_BUILDER_ENTRIES, RequestAuthData}; +use builder_types::{BuilderPubkeys, BuilderUrl, MAX_BUILDER_ENTRIES, RequestAuthData}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::fs::{File, create_dir_all}; use std::io; use std::path::{Path, PathBuf}; @@ -35,6 +35,10 @@ pub enum Error { /// More than `MAX_BUILDER_ENTRIES` builders are enabled, exceeding what fits in a /// `BuilderConfig`. TooManyEnabledBuilders { enabled: usize, max: usize }, + /// A builder entry contains more public keys than fits in a `BuilderEntry`. + TooManyBuilderPubkeys(BuilderUrl), + /// A builder entry contains an explicitly empty authentication value. + EmptyAuthData(BuilderUrl), } /// A single builder in the config file: a direct bid request, with optional per-builder overrides @@ -76,7 +80,7 @@ fn default_builder_boost_factor() -> u64 { /// 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 { +pub(crate) mod serde_option_auth_data { use super::RequestAuthData; use serde::{Deserialize, Deserializer, Serializer, de}; @@ -104,6 +108,79 @@ mod serde_option_auth_data { } } +/// A per-validator builder configuration as submitted through the keymanager API. +/// +/// Every field is optional so that an omitted value can inherit from the global configuration. +/// `builders: Some(vec![])` is intentionally different from `builders: None`: the former disables +/// direct builder requests for this validator, while the latter follows the global builder list. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ValidatorBuilderConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builders: Option>, +} + +/// A builder entry in a per-validator configuration. +/// +/// Unlike [`BuilderDefinition`], `max_execution_payment` is optional because the keymanager API +/// allows it to inherit from the validator client's matching global builder definition. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ValidatorBuilderDefinition { + pub url: BuilderUrl, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_option_auth_data" + )] + pub auth_data: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub builder_pubkeys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_execution_payment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_bid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_boost_factor: Option, +} + +/// A fully resolved builder configuration used by the validator client and the HTTP API. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedBuilderConfig { + pub min_bid: u64, + pub builder_boost_factor: u64, + pub builders: Vec, +} + +impl ValidatorBuilderConfig { + pub(crate) fn validate(&self) -> Result<(), Error> { + let Some(builders) = &self.builders else { + return Ok(()); + }; + + if builders.len() > MAX_BUILDER_ENTRIES { + return Err(Error::TooManyEnabledBuilders { + enabled: builders.len(), + max: MAX_BUILDER_ENTRIES, + }); + } + + let mut seen_auth_urls = HashSet::new(); + for builder in builders { + validate_builder_definition( + &builder.url, + &builder.auth_data, + &builder.builder_pubkeys, + &mut seen_auth_urls, + )?; + } + + Ok(()) + } +} + /// The validator client's builder configuration file. /// /// Holds the global bid-policy defaults plus the list of builders to request bids from directly. It @@ -122,6 +199,9 @@ pub struct BuilderConfigFile { /// The builders to request bids from directly. #[serde(default)] pub builders: Vec, + /// Per-validator overrides. The key is the compressed validator public key in hex form. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub validator_configs: BTreeMap, } impl Default for BuilderConfigFile { @@ -130,6 +210,7 @@ impl Default for BuilderConfigFile { min_bid: 0, builder_boost_factor: default_builder_boost_factor(), builders: Vec::new(), + validator_configs: BTreeMap::new(), } } } @@ -205,28 +286,135 @@ impl BuilderConfigFile { 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())); - } + validate_builder_definition( + url, + &definition.auth_data, + &definition.builder_pubkeys, + &mut seen_auth_urls, + )?; + } - 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())); - } + for config in self.validator_configs.values() { + config.validate()?; } Ok(()) } + + /// Resolve the configuration that applies to `validator_pubkey`. + pub fn resolved_for(&self, validator_pubkey: &PublicKeyBytes) -> ResolvedBuilderConfig { + let validator_config = self.validator_configs.get(&validator_pubkey.to_string()); + let min_bid = validator_config + .and_then(|config| config.min_bid) + .unwrap_or(self.min_bid); + let builder_boost_factor = validator_config + .and_then(|config| config.builder_boost_factor) + .unwrap_or(self.builder_boost_factor); + + let builders = match validator_config.and_then(|config| config.builders.as_ref()) { + Some(builders) => builders + .iter() + .map(|builder| { + self.resolve_validator_builder(builder, min_bid, builder_boost_factor) + }) + .collect(), + None => self + .builders + .iter() + .filter(|builder| builder.enabled) + .map(|builder| { + let mut builder = builder.clone(); + builder.min_bid = Some(builder.min_bid.unwrap_or(min_bid)); + builder.builder_boost_factor = + Some(builder.builder_boost_factor.unwrap_or(builder_boost_factor)); + builder + }) + .collect(), + }; + + ResolvedBuilderConfig { + min_bid, + builder_boost_factor, + builders, + } + } + + fn resolve_validator_builder( + &self, + builder: &ValidatorBuilderDefinition, + min_bid: u64, + builder_boost_factor: u64, + ) -> BuilderDefinition { + let max_execution_payment = builder + .max_execution_payment + .or_else(|| self.global_max_execution_payment(builder)) + .unwrap_or_default(); + + BuilderDefinition { + enabled: true, + url: builder.url.clone(), + auth_data: builder.auth_data.clone(), + builder_pubkeys: builder.builder_pubkeys.clone(), + max_execution_payment, + min_bid: Some(builder.min_bid.unwrap_or(min_bid)), + builder_boost_factor: Some( + builder.builder_boost_factor.unwrap_or(builder_boost_factor), + ), + } + } + + fn global_max_execution_payment(&self, builder: &ValidatorBuilderDefinition) -> Option { + let auth_data = builder + .auth_data + .clone() + .unwrap_or_else(|| builder.url.to_default_auth_data()); + self.builders + .iter() + .filter(|global| global.enabled) + .find(|global| { + global.url == builder.url + && global + .auth_data + .clone() + .unwrap_or_else(|| global.url.to_default_auth_data()) + == auth_data + }) + .map(|global| global.max_execution_payment) + } +} + +fn validate_builder_definition( + url: &BuilderUrl, + auth_data: &Option, + builder_pubkeys: &[PublicKeyBytes], + seen_auth_urls: &mut HashSet<(BuilderUrl, RequestAuthData)>, +) -> Result<(), Error> { + // 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())); + } + + if BuilderPubkeys::new(builder_pubkeys.to_vec()).is_err() { + return Err(Error::TooManyBuilderPubkeys(url.clone())); + } + + let auth = auth_data + .clone() + .unwrap_or_else(|| url.to_default_auth_data()); + if auth.is_empty() { + return Err(Error::EmptyAuthData(url.clone())); + } + + // Two entries cannot contain the same URL and auth data. + if !seen_auth_urls.insert((url.clone(), auth)) { + return Err(Error::DuplicateBuilderAuth(url.clone())); + } + + Ok(()) } impl<'a> IntoIterator for &'a BuilderConfigFile { diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs index d17c3c73260..aaa7985a4f7 100644 --- a/validator_client/builder_store/src/lib.rs +++ b/validator_client/builder_store/src/lib.rs @@ -1,6 +1,9 @@ mod builder_definitions; use builder_definitions::BuilderConfigFile; -pub use builder_definitions::{BuilderDefinition, Error}; +pub use builder_definitions::{ + BuilderDefinition, Error, ResolvedBuilderConfig, ValidatorBuilderConfig, + ValidatorBuilderDefinition, +}; use builder_types::{ BuilderConfig, BuilderEntry, BuilderPubkeys, RequestAuthData, SignedRequestAuth, }; @@ -39,25 +42,29 @@ impl BuilderStore { /// /// 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 + /// config always carries the validator's resolved 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, + validator_pubkey: &bls::PublicKeyBytes, + 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`. + // Snapshot the validator's resolved builders and 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) + let resolved = config.resolved_for(validator_pubkey); + ( + resolved.builders, + resolved.min_bid, + resolved.builder_boost_factor, + ) }; // Sign every builder's request auth concurrently. With a remote signer each `sign` is a @@ -134,7 +141,318 @@ impl BuilderStore { candidate.push(builder); candidate.validate()?; + candidate.save(&self.validators_dir)?; *config = candidate; - config.save(&self.validators_dir) + Ok(()) + } + + /// Return the fully resolved configuration for a validator without signing builder auth data. + pub fn validator_config( + &self, + validator_pubkey: &bls::PublicKeyBytes, + ) -> ResolvedBuilderConfig { + self.config.read().resolved_for(validator_pubkey) + } + + /// Replace the per-validator configuration and persist it atomically. + pub fn set_validator_config( + &self, + validator_pubkey: &bls::PublicKeyBytes, + validator_config: ValidatorBuilderConfig, + ) -> Result<(), Error> { + validator_config.validate()?; + + let mut config = self.config.write(); + let mut candidate = config.clone(); + candidate + .validator_configs + .insert(validator_pubkey.to_string(), validator_config); + candidate.validate()?; + candidate.save(&self.validators_dir)?; + *config = candidate; + Ok(()) + } + + /// Remove a validator's override and persist the inherited global configuration atomically. + pub fn delete_validator_config( + &self, + validator_pubkey: &bls::PublicKeyBytes, + ) -> Result<(), Error> { + let mut config = self.config.write(); + let mut candidate = config.clone(); + if candidate + .validator_configs + .remove(&validator_pubkey.to_string()) + .is_none() + { + return Ok(()); + } + candidate.validate()?; + candidate.save(&self.validators_dir)?; + *config = candidate; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bls::Keypair; + use builder_types::RequestAuth; + use tempfile::tempdir; + use types::Slot; + + fn global_builder(url: &str, max_execution_payment: u64) -> BuilderDefinition { + BuilderDefinition { + enabled: true, + url: url.parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment, + min_bid: None, + builder_boost_factor: None, + } + } + + fn signed_auth(data: RequestAuthData) -> SignedRequestAuth { + SignedRequestAuth { + message: RequestAuth { + data, + slot: Slot::new(0), + }, + signature: bls::Signature::empty(), + } + } + + #[test] + fn validator_config_inherits_global_and_distinguishes_empty_builders() { + let directory = tempdir().unwrap(); + let store = BuilderStore::open_or_create(directory.path()).unwrap(); + let validator = Keypair::random().pk.compress(); + + store + .insert(global_builder("https://global-builder.example", 7)) + .unwrap(); + store + .set_validator_config( + &validator, + ValidatorBuilderConfig { + min_bid: Some(5), + builder_boost_factor: Some(125), + builders: None, + }, + ) + .unwrap(); + + let inherited = store.validator_config(&validator); + assert_eq!(inherited.min_bid, 5); + assert_eq!(inherited.builder_boost_factor, 125); + assert_eq!(inherited.builders.len(), 1); + assert_eq!(inherited.builders[0].max_execution_payment, 7); + assert_eq!(inherited.builders[0].min_bid, Some(5)); + assert_eq!(inherited.builders[0].builder_boost_factor, Some(125)); + + store + .set_validator_config( + &validator, + ValidatorBuilderConfig { + min_bid: None, + builder_boost_factor: None, + builders: Some(vec![]), + }, + ) + .unwrap(); + assert!(store.validator_config(&validator).builders.is_empty()); + + store.delete_validator_config(&validator).unwrap(); + let restored = store.validator_config(&validator); + assert_eq!(restored.min_bid, 0); + assert_eq!(restored.builder_boost_factor, 100); + assert_eq!(restored.builders.len(), 1); + } + + #[test] + fn validator_config_persists_across_store_restart_and_post_empty_is_stored() { + let directory = tempdir().unwrap(); + let store = BuilderStore::open_or_create(directory.path()).unwrap(); + let validator = Keypair::random().pk.compress(); + + store + .set_validator_config(&validator, ValidatorBuilderConfig::default()) + .unwrap(); + + let file = builder_definitions::BuilderConfigFile::open(directory.path()).unwrap(); + assert!(file.validator_configs.contains_key(&validator.to_string())); + + let restarted = BuilderStore::open_or_create(directory.path()).unwrap(); + assert_eq!( + restarted.validator_config(&validator), + store.validator_config(&validator) + ); + + restarted.delete_validator_config(&validator).unwrap(); + let file = builder_definitions::BuilderConfigFile::open(directory.path()).unwrap(); + assert!(!file.validator_configs.contains_key(&validator.to_string())); + + restarted.delete_validator_config(&validator).unwrap(); + let file = builder_definitions::BuilderConfigFile::open(directory.path()).unwrap(); + assert!(!file.validator_configs.contains_key(&validator.to_string())); + } + + #[test] + fn validator_updates_are_serialized_without_losing_each_other() { + let directory = tempdir().unwrap(); + let store = Arc::new(BuilderStore::open_or_create(directory.path()).unwrap()); + let first = Keypair::random().pk.compress(); + let second = Keypair::random().pk.compress(); + + let first_store = store.clone(); + let first_handle = std::thread::spawn(move || { + first_store.set_validator_config( + &first, + ValidatorBuilderConfig { + min_bid: Some(11), + ..Default::default() + }, + ) + }); + let second_store = store.clone(); + let second_handle = std::thread::spawn(move || { + second_store.set_validator_config( + &second, + ValidatorBuilderConfig { + min_bid: Some(22), + ..Default::default() + }, + ) + }); + + first_handle.join().unwrap().unwrap(); + second_handle.join().unwrap().unwrap(); + assert_eq!(store.validator_config(&first).min_bid, 11); + assert_eq!(store.validator_config(&second).min_bid, 22); + + let restarted = BuilderStore::open_or_create(directory.path()).unwrap(); + assert_eq!(restarted.validator_config(&first).min_bid, 11); + assert_eq!(restarted.validator_config(&second).min_bid, 22); + } + + #[test] + fn builder_consumer_observes_runtime_updates_immediately() { + let directory = tempdir().unwrap(); + let store = BuilderStore::open_or_create(directory.path()).unwrap(); + let validator = Keypair::random().pk.compress(); + store + .insert(global_builder("https://global-builder.example", 7)) + .unwrap(); + + store + .set_validator_config( + &validator, + ValidatorBuilderConfig { + builders: Some(vec![]), + ..Default::default() + }, + ) + .unwrap(); + let empty = + futures::executor::block_on(store.builder_config(&validator, |data| async move { + Ok::<_, ()>(signed_auth(data)) + })); + assert!(empty.builders.is_empty()); + + store.delete_validator_config(&validator).unwrap(); + let inherited = + futures::executor::block_on(store.builder_config(&validator, |data| async move { + Ok::<_, ()>(signed_auth(data)) + })); + assert_eq!(inherited.builders.len(), 1); + assert_eq!(inherited.builders[0].max_execution_payment, 7); + } + + #[test] + fn custom_builder_inherits_matching_payment_limit() { + let directory = tempdir().unwrap(); + let store = BuilderStore::open_or_create(directory.path()).unwrap(); + let validator = Keypair::random().pk.compress(); + store + .insert(global_builder("https://global-builder.example", 9)) + .unwrap(); + + store + .set_validator_config( + &validator, + ValidatorBuilderConfig { + builders: Some(vec![ValidatorBuilderDefinition { + url: "https://global-builder.example".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + }]), + ..Default::default() + }, + ) + .unwrap(); + + let resolved = store.validator_config(&validator); + assert_eq!(resolved.builders[0].max_execution_payment, 9); + } + + #[test] + fn custom_builder_does_not_inherit_disabled_payment_limit() { + let directory = tempdir().unwrap(); + let store = BuilderStore::open_or_create(directory.path()).unwrap(); + let validator = Keypair::random().pk.compress(); + store + .insert(BuilderDefinition { + enabled: false, + url: "https://disabled-builder.example".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: 9, + min_bid: None, + builder_boost_factor: None, + }) + .unwrap(); + + store + .set_validator_config( + &validator, + ValidatorBuilderConfig { + builders: Some(vec![ValidatorBuilderDefinition { + url: "https://disabled-builder.example".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + }]), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!( + store.validator_config(&validator).builders[0].max_execution_payment, + 0 + ); + } + + #[test] + fn invalid_builder_url_is_rejected() { + let config = ValidatorBuilderConfig { + builders: Some(vec![ValidatorBuilderDefinition { + url: "ftp://builder.example".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: Some(1), + min_bid: None, + builder_boost_factor: None, + }]), + ..Default::default() + }; + assert!(config.validate().is_err()); } } diff --git a/validator_client/http_api/Cargo.toml b/validator_client/http_api/Cargo.toml index e03ce2f9ebc..cf1aed18eb5 100644 --- a/validator_client/http_api/Cargo.toml +++ b/validator_client/http_api/Cargo.toml @@ -17,6 +17,7 @@ axum = { workspace = true } axum_utils = { workspace = true } beacon_node_fallback = { workspace = true } bls = { workspace = true } +builder_store = { workspace = true } deposit_contract = { workspace = true, optional = true } directory = { workspace = true } dirs = { workspace = true } @@ -64,5 +65,6 @@ doppelganger_service = { workspace = true } futures = { workspace = true } itertools = { workspace = true } rand = { workspace = true, features = ["small_rng"] } +reqwest = { workspace = true } ssz_types = { workspace = true } tempfile = { workspace = true } diff --git a/validator_client/http_api/src/lib.rs b/validator_client/http_api/src/lib.rs index 22e9eb2fa7e..e1a3096c3ee 100644 --- a/validator_client/http_api/src/lib.rs +++ b/validator_client/http_api/src/lib.rs @@ -19,6 +19,7 @@ use axum::Router; use axum_utils::server::Server; use beacon_node_fallback::CandidateInfo; use bls::{PublicKey, PublicKeyBytes}; +use builder_store::{ResolvedBuilderConfig, ValidatorBuilderConfig, ValidatorBuilderDefinition}; use core::convert::Infallible; use create_signed_voluntary_exit::create_signed_voluntary_exit; use create_validator::{ @@ -59,7 +60,7 @@ use validator_services::block_service::BlockService; use validator_store::ValidatorStore; use warp::{Filter, reply::Response, sse::Event}; use warp_utils::reject::convert_rejection; -use warp_utils::task::blocking_json_task; +use warp_utils::task::{blocking_json_task, blocking_response_task}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -79,6 +80,78 @@ impl From for Error { } } +fn into_store_builder_config(config: api_types::BuilderConfig) -> ValidatorBuilderConfig { + ValidatorBuilderConfig { + min_bid: config.min_bid, + builder_boost_factor: config.builder_boost_factor, + builders: config.builders.map(|builders| { + builders + .into_iter() + .map(|builder| ValidatorBuilderDefinition { + url: builder.url, + auth_data: builder.auth_data, + builder_pubkeys: builder.builder_pubkeys.unwrap_or_default(), + max_execution_payment: builder.max_execution_payment, + min_bid: builder.min_bid, + builder_boost_factor: builder.builder_boost_factor, + }) + .collect() + }), + } +} + +fn into_api_builder_config(config: ResolvedBuilderConfig) -> api_types::BuilderConfig { + let ResolvedBuilderConfig { + min_bid, + builder_boost_factor, + builders, + } = config; + let builders = builders + .into_iter() + .map(|builder| { + let auth_data = Some( + builder + .auth_data + .unwrap_or_else(|| builder.url.to_default_auth_data()), + ); + api_types::BuilderEntry { + url: builder.url, + auth_data, + builder_pubkeys: Some(builder.builder_pubkeys), + max_execution_payment: Some(builder.max_execution_payment), + min_bid: Some(builder.min_bid.unwrap_or(min_bid)), + builder_boost_factor: Some( + builder.builder_boost_factor.unwrap_or(builder_boost_factor), + ), + } + }) + .collect(); + + api_types::BuilderConfig { + min_bid: Some(min_bid), + builder_boost_factor: Some(builder_boost_factor), + builders: Some(builders), + } +} + +fn builder_store_rejection(error: builder_store::Error) -> warp::Rejection { + let message = format!("builder configuration error: {error:?}"); + match error { + builder_store::Error::DuplicateBuilderAuth(_) + | builder_store::Error::InvalidBuilderUrl(_) + | builder_store::Error::UnsupportedUrlScheme(_) + | builder_store::Error::TooManyEnabledBuilders { .. } + | builder_store::Error::TooManyBuilderPubkeys(_) + | builder_store::Error::EmptyAuthData(_) => warp_utils::reject::custom_bad_request(message), + _ => warp_utils::reject::custom_server_error(message), + } +} + +// The API bounds each configuration at 64 entries, each with bounded URLs, auth data, and +// builder public keys. Keep body decoding bounded as well, so malformed authenticated requests +// cannot allocate without limit before those per-field checks run. +const MAX_BUILDER_CONFIG_BODY_SIZE: u64 = 2 * 1024 * 1024; + /// A wrapper around all the items required to spawn the HTTP server. /// /// The server will gracefully handle the case where any fields are `None`. @@ -88,6 +161,7 @@ pub struct Context { pub block_service: Option, T>>, pub validator_store: Option>>, pub validator_dir: Option, + pub configured_builders: builder_store::BuilderStore, pub secrets_dir: Option, pub graffiti_file: Option, pub graffiti_flag: Option, @@ -213,6 +287,9 @@ pub async fn serve( }) }); + let inner_configured_builders = ctx.configured_builders.clone(); + let configured_builders_filter = warp::any().map(move || inner_configured_builders.clone()); + let inner_task_executor = ctx.task_executor.clone(); let task_executor_filter = warp::any().map(move || inner_task_executor.clone()); @@ -1022,6 +1099,123 @@ pub async fn serve( ) .map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::NO_CONTENT)); + // GET /eth/v1/validator/{pubkey}/builder_config + let get_builder_config = eth_v1 + .and(warp::path("validator")) + .and(warp::path::param::()) + .and(warp::path("builder_config")) + .and(warp::path::end()) + .and(validator_store_filter.clone()) + .and(configured_builders_filter.clone()) + .then( + |validator_pubkey: PublicKey, + validator_store: Arc>, + configured_builders: builder_store::BuilderStore| { + blocking_json_task(move || { + if validator_store + .initialized_validators() + .read() + .is_enabled(&validator_pubkey) + .is_none() + { + return Err(warp_utils::reject::custom_not_found(format!( + "no validator found with pubkey {:?}", + validator_pubkey + ))); + } + + Ok(GenericResponse::from(into_api_builder_config( + configured_builders + .validator_config(&PublicKeyBytes::from(&validator_pubkey)), + ))) + }) + }, + ); + + // POST /eth/v1/validator/{pubkey}/builder_config + let post_builder_config = eth_v1 + .and(warp::path("validator")) + .and(warp::path::param::()) + .and(warp::path("builder_config")) + .and(warp::body::content_length_limit( + MAX_BUILDER_CONFIG_BODY_SIZE, + )) + .and(warp::body::json()) + .and(warp::path::end()) + .and(validator_store_filter.clone()) + .and(configured_builders_filter.clone()) + .and_then( + |validator_pubkey: PublicKey, + request: api_types::BuilderConfig, + validator_store: Arc>, + configured_builders: builder_store::BuilderStore| { + blocking_response_task(move || { + if validator_store + .initialized_validators() + .read() + .is_enabled(&validator_pubkey) + .is_none() + { + return Err(warp_utils::reject::custom_not_found(format!( + "no validator found with pubkey {:?}", + validator_pubkey + ))); + } + + configured_builders + .set_validator_config( + &PublicKeyBytes::from(&validator_pubkey), + into_store_builder_config(request), + ) + .map(|_| { + warp::reply::with_status( + warp::reply(), + warp::http::StatusCode::ACCEPTED, + ) + }) + .map_err(builder_store_rejection) + }) + }, + ); + + // DELETE /eth/v1/validator/{pubkey}/builder_config + let delete_builder_config = eth_v1 + .and(warp::path("validator")) + .and(warp::path::param::()) + .and(warp::path("builder_config")) + .and(warp::path::end()) + .and(validator_store_filter.clone()) + .and(configured_builders_filter.clone()) + .and_then( + |validator_pubkey: PublicKey, + validator_store: Arc>, + configured_builders: builder_store::BuilderStore| { + blocking_response_task(move || { + if validator_store + .initialized_validators() + .read() + .is_enabled(&validator_pubkey) + .is_none() + { + return Err(warp_utils::reject::custom_not_found(format!( + "no validator found with pubkey {:?}", + validator_pubkey + ))); + } + + configured_builders + .delete_validator_config(&PublicKeyBytes::from(&validator_pubkey)) + .map(|_| { + warp::reply::with_status( + warp::reply(), + warp::http::StatusCode::NO_CONTENT, + ) + }) + .map_err(builder_store_rejection) + }) + }, + ); + // GET /eth/v1/validator/{pubkey}/gas_limit let get_gas_limit = eth_v1 .and(warp::path("validator")) @@ -1364,6 +1558,7 @@ pub async fn serve( .or(get_lighthouse_ui_graffiti) .or(get_lighthouse_beacon_health) .or(get_fee_recipient) + .or(get_builder_config) .or(get_gas_limit) .or(get_graffiti) .or(get_std_keystores) @@ -1377,6 +1572,7 @@ pub async fn serve( .or(post_validators_web3signer) .or(post_validators_voluntary_exits) .or(post_fee_recipient) + .or(post_builder_config) .or(post_gas_limit) .or(post_std_keystores) .or(post_std_remotekeys) @@ -1389,6 +1585,7 @@ pub async fn serve( .or(warp::delete().and( delete_lighthouse_keystores .or(delete_fee_recipient) + .or(delete_builder_config) .or(delete_gas_limit) .or(delete_std_keystores) .or(delete_std_remotekeys) diff --git a/validator_client/http_api/src/test_utils.rs b/validator_client/http_api/src/test_utils.rs index 2c9bf79895a..0be91a6369c 100644 --- a/validator_client/http_api/src/test_utils.rs +++ b/validator_client/http_api/src/test_utils.rs @@ -5,6 +5,7 @@ use account_utils::{ eth2_wallet::WalletBuilder, mnemonic_from_phrase, random_mnemonic, random_password, }; use bls::Keypair; +use builder_store::BuilderStore; use deposit_contract::decode_eth1_tx_data; use doppelganger_service::DoppelgangerService; use eth2::{ @@ -57,6 +58,7 @@ pub struct ApiTester { pub client: ValidatorClientHttpClient, pub initialized_validators: Arc>, pub validator_store: Arc>, + pub configured_builders: BuilderStore, pub url: SensitiveUrl, pub api_token: String, pub test_runtime: TestRuntime, @@ -88,6 +90,7 @@ impl ApiTester { let validator_dir = tempdir().unwrap(); let secrets_dir = tempdir().unwrap(); let token_path = tempdir().unwrap().path().join(PK_FILENAME); + let configured_builders = BuilderStore::open_or_create(validator_dir.path()).unwrap(); let validator_defs = ValidatorDefinitions::open_or_create(validator_dir.path()).unwrap(); @@ -134,6 +137,7 @@ impl ApiTester { api_secret, block_service: None::, _>>, validator_dir: Some(validator_dir.path().into()), + configured_builders: configured_builders.clone(), secrets_dir: Some(secrets_dir.path().into()), validator_store: Some(validator_store.clone()), graffiti_file: None, @@ -166,6 +170,7 @@ impl ApiTester { client, initialized_validators, validator_store, + configured_builders, url, api_token: api_pubkey, test_runtime, diff --git a/validator_client/http_api/src/tests.rs b/validator_client/http_api/src/tests.rs index 723d2175ee5..d9b18f7d657 100644 --- a/validator_client/http_api/src/tests.rs +++ b/validator_client/http_api/src/tests.rs @@ -6,16 +6,20 @@ mod keystores; use doppelganger_service::DoppelgangerService; use initialized_validators::{Config as InitializedValidatorsConfig, InitializedValidators}; -use crate::{ApiSecret, Config as HttpConfig, Context}; +use crate::{ApiSecret, Config as HttpConfig, Context, MAX_BUILDER_CONFIG_BODY_SIZE}; use account_utils::{ eth2_wallet::WalletBuilder, mnemonic_from_phrase, random_mnemonic, random_password, random_password_string, validator_definitions::ValidatorDefinitions, }; use bls::{Keypair, PublicKeyBytes}; +use builder_store::{BuilderDefinition, BuilderStore}; use deposit_contract::decode_eth1_tx_data; use eth2::{ Error as ApiError, - lighthouse_vc::{http_client::ValidatorClientHttpClient, types::*}, + lighthouse_vc::{ + http_client::{StatusCode, ValidatorClientHttpClient}, + types::*, + }, types::ErrorMessage as ApiErrorMessage, }; use eth2_keystore::KeystoreBuilder; @@ -44,6 +48,7 @@ struct ApiTester { client: ValidatorClientHttpClient, initialized_validators: Arc>, validator_store: Arc>, + configured_builders: BuilderStore, url: SensitiveUrl, slot_clock: TestingSlotClock, spec: Arc, @@ -65,6 +70,7 @@ impl ApiTester { let validator_dir = tempdir().unwrap(); let secrets_dir = tempdir().unwrap(); let token_path = tempdir().unwrap().path().join("api-token.txt"); + let configured_builders = BuilderStore::open_or_create(validator_dir.path()).unwrap(); let validator_defs = ValidatorDefinitions::open_or_create(validator_dir.path()).unwrap(); @@ -115,6 +121,7 @@ impl ApiTester { api_secret, block_service: None, validator_dir: Some(validator_dir.path().into()), + configured_builders: configured_builders.clone(), secrets_dir: Some(secrets_dir.path().into()), validator_store: Some(validator_store.clone()), graffiti_file: None, @@ -154,6 +161,7 @@ impl ApiTester { client, initialized_validators, validator_store, + configured_builders, url, slot_clock, spec, @@ -941,6 +949,20 @@ async fn routes_with_invalid_auth() { .await }) .await + .test_with_invalid_auth(|client| async move { + client.get_builder_config(&PublicKeyBytes::empty()).await + }) + .await + .test_with_invalid_auth(|client| async move { + client + .post_builder_config(&PublicKeyBytes::empty(), &BuilderConfig::default()) + .await + }) + .await + .test_with_invalid_auth(|client| async move { + client.delete_builder_config(&PublicKeyBytes::empty()).await + }) + .await .test_with_invalid_auth(|client| async move { client.get_keystores().await }) .await .test_with_invalid_auth(|client| async move { @@ -985,6 +1007,200 @@ async fn routes_with_invalid_auth() { .await; } +#[tokio::test] +async fn validator_builder_configuration_endpoints() { + let tester = ApiTester::new() + .await + .create_hd_validators(HdValidatorScenario { + count: 1, + specify_mnemonic: false, + key_derivation_path_offset: 0, + disabled: vec![], + }) + .await; + tester + .configured_builders + .insert(BuilderDefinition { + enabled: true, + url: "https://global-builder.example".parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: 7, + min_bid: None, + builder_boost_factor: None, + }) + .unwrap(); + let validator = tester + .client + .get_lighthouse_validators() + .await + .unwrap() + .data[0] + .voting_pubkey; + + let inherited = tester.client.get_builder_config(&validator).await.unwrap(); + assert_eq!(inherited.min_bid, Some(0)); + assert_eq!(inherited.builder_boost_factor, Some(100)); + assert_eq!(inherited.builders.as_ref().unwrap().len(), 1); + assert_eq!( + inherited.builders.as_ref().unwrap()[0].max_execution_payment, + Some(7) + ); + + let empty_post = tester + .client + .post_builder_config(&validator, &BuilderConfig::default()) + .await + .unwrap(); + assert_eq!(empty_post.status(), StatusCode::ACCEPTED); + assert_eq!( + tester.client.get_builder_config(&validator).await.unwrap(), + inherited + ); + + let custom = BuilderConfig { + min_bid: Some(3), + builder_boost_factor: Some(115), + builders: Some(vec![BuilderEntry { + url: "https://builder.example".parse().unwrap(), + auth_data: Some(RequestAuthData::new(b"validator-auth".to_vec()).unwrap()), + builder_pubkeys: Some(vec![]), + max_execution_payment: Some(8), + min_bid: None, + builder_boost_factor: None, + }]), + }; + let custom_post = tester + .client + .post_builder_config(&validator, &custom) + .await + .unwrap(); + assert_eq!(custom_post.status(), StatusCode::ACCEPTED); + let resolved = tester.client.get_builder_config(&validator).await.unwrap(); + assert_eq!(resolved.min_bid, Some(3)); + assert_eq!(resolved.builder_boost_factor, Some(115)); + let entry = &resolved.builders.as_ref().unwrap()[0]; + assert_eq!(entry.url.to_string(), "https://builder.example"); + assert_eq!(&entry.auth_data.as_ref().unwrap()[..], b"validator-auth"); + assert_eq!(entry.max_execution_payment, Some(8)); + assert_eq!(entry.min_bid, Some(3)); + assert_eq!(entry.builder_boost_factor, Some(115)); + + let restarted = BuilderStore::open_or_create(tester._validator_dir.path()).unwrap(); + let restarted_config = restarted.validator_config(&validator); + assert_eq!(restarted_config.min_bid, 3); + assert_eq!(restarted_config.builders[0].max_execution_payment, 8); + + let empty_list = BuilderConfig { + min_bid: Some(9), + builder_boost_factor: Some(130), + builders: Some(vec![]), + }; + tester + .client + .post_builder_config(&validator, &empty_list) + .await + .unwrap(); + let explicitly_empty = tester.client.get_builder_config(&validator).await.unwrap(); + assert_eq!(explicitly_empty.min_bid, Some(9)); + assert_eq!(explicitly_empty.builder_boost_factor, Some(130)); + assert_eq!(explicitly_empty.builders, Some(vec![])); + + let delete = tester + .client + .delete_builder_config(&validator) + .await + .unwrap(); + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + assert_eq!( + tester.client.get_builder_config(&validator).await.unwrap(), + inherited + ); + let delete_again = tester + .client + .delete_builder_config(&validator) + .await + .unwrap(); + assert_eq!(delete_again.status(), StatusCode::NO_CONTENT); + + let unknown = Keypair::random().pk.compress(); + match tester.client.get_builder_config(&unknown).await { + Err(ApiError::ServerMessage(ApiErrorMessage { code: 404, .. })) => (), + other => panic!("expected unknown validator to return 404, got {other:?}"), + } + match tester + .client + .post_builder_config(&unknown, &BuilderConfig::default()) + .await + { + Err(ApiError::ServerMessage(ApiErrorMessage { code: 404, .. })) => (), + other => panic!("expected unknown validator POST to return 404, got {other:?}"), + } + match tester.client.delete_builder_config(&unknown).await { + Err(ApiError::ServerMessage(ApiErrorMessage { code: 404, .. })) => (), + other => panic!("expected unknown validator DELETE to return 404, got {other:?}"), + } + + let invalid = BuilderConfig { + builders: Some(vec![BuilderEntry { + url: "ftp://builder.example".parse().unwrap(), + auth_data: None, + builder_pubkeys: Some(vec![]), + max_execution_payment: Some(1), + min_bid: None, + builder_boost_factor: None, + }]), + ..Default::default() + }; + match tester + .client + .post_builder_config(&validator, &invalid) + .await + { + Err(ApiError::ServerMessage(ApiErrorMessage { code: 400, .. })) => (), + other => panic!("expected invalid builder input to return 400, got {other:?}"), + } + + let empty_auth = BuilderConfig { + builders: Some(vec![BuilderEntry { + url: "https://builder.example".parse().unwrap(), + auth_data: Some(RequestAuthData::default()), + builder_pubkeys: Some(vec![]), + max_execution_payment: Some(1), + min_bid: None, + builder_boost_factor: None, + }]), + ..Default::default() + }; + match tester + .client + .post_builder_config(&validator, &empty_auth) + .await + { + Err(ApiError::ServerMessage(ApiErrorMessage { code: 400, .. })) => (), + other => panic!("expected empty auth input to return 400, got {other:?}"), + } + + let oversized_body = vec![b' '; (MAX_BUILDER_CONFIG_BODY_SIZE + 1) as usize]; + let oversized_url = tester + .url + .expose_full() + .join(&format!("eth/v1/validator/{validator}/builder_config")) + .unwrap(); + let oversized_response = reqwest::Client::new() + .post(oversized_url) + .header( + "Authorization", + format!("Bearer {}", tester.client.api_token().unwrap().as_str()), + ) + .header("Content-Type", "application/json") + .body(oversized_body) + .send() + .await + .unwrap(); + assert_eq!(oversized_response.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + #[tokio::test] async fn simple_getters() { ApiTester::new() diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index 7697a08c45b..6621adbcdf7 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -94,6 +94,7 @@ pub struct ProductionValidatorClient { doppelganger_service: Option>, preparation_service: PreparationService, SystemTimeSlotClock>, validator_store: Arc>, + configured_builders: BuilderStore, builder_preferences_service: BuilderPreferencesService, SystemTimeSlotClock>, slot_clock: SystemTimeSlotClock, http_api_listen_addr: Option, @@ -609,6 +610,7 @@ impl ProductionValidatorClient { doppelganger_service, preparation_service, validator_store, + configured_builders: configured_builders.clone(), builder_preferences_service, config, slot_clock, @@ -633,6 +635,7 @@ impl ProductionValidatorClient { block_service: Some(self.block_service.clone()), validator_store: Some(self.validator_store.clone()), validator_dir: Some(self.config.validator_dir.clone()), + configured_builders: self.configured_builders.clone(), secrets_dir: Some(self.config.secrets_dir.clone()), graffiti_file: self.config.graffiti_file.clone(), graffiti_flag: self.config.graffiti, diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index b2cfa138e88..1e157b3ae95 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -501,7 +501,7 @@ impl BlockService { // inside `builder_config`, so this never fails the proposal. let builder_config = self_ref .configured_builders - .builder_config(|auth_data| { + .builder_config(&validator_pubkey, |auth_data| { self_ref.request_auth_cache.get_or_sign( slot, validator_pubkey, diff --git a/validator_client/validator_services/src/builder_preferences_service.rs b/validator_client/validator_services/src/builder_preferences_service.rs index 203c236588b..e47c9e086bd 100644 --- a/validator_client/validator_services/src/builder_preferences_service.rs +++ b/validator_client/validator_services/src/builder_preferences_service.rs @@ -224,7 +224,7 @@ impl BuilderPreferencesServ let config = self .inner .configured_builders - .builder_config(|auth_data| { + .builder_config(&pubkey, |auth_data| { self.inner.request_auth_cache.get_or_sign( slot, pubkey, From 570836c7f35ae3a77ce91f6ebea514eff8d7bc3e Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 04:16:05 +0000 Subject: [PATCH 05/14] Refine per-validator builder configuration API --- Cargo.lock | 1 - book/src/advanced_builders.md | 20 +- book/src/api_vc.md | 6 +- book/src/api_vc_endpoints.md | 13 +- book/src/gloas_builder_config.md | 20 +- common/eth2/src/lighthouse_vc/std_types.rs | 169 +++++-------- common/warp_utils/src/reject.rs | 15 +- .../builder_store/src/builder_definitions.rs | 92 +++++-- validator_client/builder_store/src/lib.rs | 146 ++++------- validator_client/http_api/Cargo.toml | 1 - validator_client/http_api/src/lib.rs | 50 ++-- validator_client/http_api/src/test_utils.rs | 2 - validator_client/http_api/src/tests.rs | 233 ++++++++++-------- 13 files changed, 369 insertions(+), 399 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37c4de33393..5e4351d228d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9907,7 +9907,6 @@ dependencies = [ "logging", "parking_lot", "rand 0.9.2", - "reqwest", "sensitive_url", "serde", "serde_json", diff --git a/book/src/advanced_builders.md b/book/src/advanced_builders.md index e8d0fccf044..7202bd7bb9d 100644 --- a/book/src/advanced_builders.md +++ b/book/src/advanced_builders.md @@ -61,8 +61,10 @@ relays, run one of the following services and configure lighthouse to use it wit In the validator client you can configure gas limit and fee recipient on a per-validator basis. If no gas limit is configured, Lighthouse will use a default gas limit of 60,000,000, which is the current default value used in execution -engines. You can also configure the external builders for one validator without changing the global builder settings. -Use the standard keymanager API requests described below for per-validator builder configuration. +engines. You can also enable or disable use of external builders on a per-validator basis rather than using +`--builder-proposals`, `--builder-boost-factor` or `--prefer-builder-proposals`, which apply builder related preferences for all validators. +In order to manage these configurations per-validator, you can either make updates to the `validator_definitions.yml` file +or you can use the HTTP requests described below. Both the gas limit and fee recipient will be passed along as suggestions to connected builders. If there is a discrepancy in either, it will *not* keep you from proposing a block with the builder. This is because the bounds on gas limit are @@ -83,18 +85,10 @@ To update gas limit per-validator you can use the [standard key manager API][gas Alternatively, you can use the [lighthouse API](api_vc_endpoints.md). See below for an example. -### Configure external builders via HTTP +### Enable/Disable builder proposals via HTTP -Use the standard keymanager API to manage the external builders for one validator: - -- `GET /eth/v1/validator/{pubkey}/builder_config` returns the fully resolved configuration. -- `POST /eth/v1/validator/{pubkey}/builder_config` replaces the per-validator configuration. -- `DELETE /eth/v1/validator/{pubkey}/builder_config` removes the per-validator configuration. - -An omitted field inherits the global configuration. `builders: []` explicitly disables direct builder -requests for that validator. `POST {}` stores no overrides and therefore has the same behavior as -the global configuration. `DELETE` restores inheritance after any override. Payment and boost values -use quoted decimal strings in JSON, and `auth_data` uses `0x`-prefixed hex. +Use the [lighthouse API](api_vc_endpoints.md) to enable/disable use of the builder API on a per-validator basis. +You can also update the configured gas limit with these requests. #### `PATCH /lighthouse/validators/:voting_pubkey` diff --git a/book/src/api_vc.md b/book/src/api_vc.md index 6338d29ddea..f5df5df76c3 100644 --- a/book/src/api_vc.md +++ b/book/src/api_vc.md @@ -3,9 +3,9 @@ Lighthouse implements a JSON HTTP API for the validator client which enables programmatic management of validators and keys. -The API includes the standard keymanager endpoints implemented by Lighthouse, including per-validator -fee recipient, gas limit, graffiti, keystore, remote-key, and external-builder configuration. It also -includes Lighthouse-specific endpoints which are described in +The API includes all of the endpoints from the [standard keymanager +API](https://ethereum.github.io/keymanager-APIs/) that is implemented by other clients and remote +signers. It also includes some Lighthouse-specific endpoints which are described in [Endpoints](./api_vc_endpoints.md). > Note: All requests to the HTTP server must supply an diff --git a/book/src/api_vc_endpoints.md b/book/src/api_vc_endpoints.md index 0fe86edc433..cc9dd362f80 100644 --- a/book/src/api_vc_endpoints.md +++ b/book/src/api_vc_endpoints.md @@ -20,21 +20,10 @@ | [`GET /lighthouse/logs`](#get-lighthouselogs) | Get logs | | [`GET /lighthouse/beacon/health`](#get-lighthousebeaconhealth) | Get health information for each connected beacon node. | | [`POST /lighthouse/beacon/update`](#post-lighthousebeaconupdate) | Update the `--beacon-nodes` list. | -| `GET /eth/v1/validator/:voting_pubkey/builder_config` | Get the resolved external-builder configuration for a validator. | -| `POST /eth/v1/validator/:voting_pubkey/builder_config` | Replace the external-builder configuration for a validator. | -| `DELETE /eth/v1/validator/:voting_pubkey/builder_config` | Remove the external-builder configuration and restore inheritance. | The query to Lighthouse API endpoints requires authorization, see [Authorization Header](./api_vc_auth_header.md). -In addition to the above endpoints Lighthouse supports the standard keymanager endpoints listed in -the [keymanager API specification](https://ethereum.github.io/keymanager-APIs/). - -The builder configuration endpoints use the path -`/eth/v1/validator/{voting_pubkey}/builder_config`. `GET` returns resolved values. `POST` replaces the -per-validator configuration and accepts an empty object. Omitted fields inherit the global builder -configuration, while `builders: []` explicitly disables direct builders. `DELETE` removes the -per-validator configuration and restores global inheritance. Payment and boost values are quoted -decimal strings, and `auth_data` is `0x`-prefixed hex. +In addition to the above endpoints Lighthouse also supports all of the [standard keymanager APIs](https://ethereum.github.io/keymanager-APIs/). ## `GET /lighthouse/version` diff --git a/book/src/gloas_builder_config.md b/book/src/gloas_builder_config.md index 7fc45d646b1..605ffdf1f73 100644 --- a/book/src/gloas_builder_config.md +++ b/book/src/gloas_builder_config.md @@ -5,7 +5,7 @@ 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: +(`/validators/builder_definitions.yml`). The file contains: - **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. @@ -38,16 +38,15 @@ builders: - "0xa1b2c3d4..." # auth_data: "0x68747470..." # optional — defaults to the UTF-8 bytes of `url` -# Optional per-validator replacement or override. +# Optional per-validator configuration. # validator_configs: # "0x": # min_bid: 500000000 # builders: [] # explicitly disable direct builders for this validator ``` -> **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. +> **Comments are not preserved.** The validator client rewrites this file when builder settings +> change through the keymanager API. Keep an annotated copy elsewhere if you rely on inline notes. ## Fields @@ -58,7 +57,7 @@ builders: | `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. | -| `validator_configs` | no | `{}` | Per-validator configurations. An entry replaces only the fields it contains; omitted fields inherit the global configuration. | +| `validator_configs` | no | `{}` | Builder settings for individual validators. Omitted fields use global values. An empty `builders` list uses no direct builders. | ### Per builder (each entry under `builders`) @@ -75,15 +74,6 @@ builders: All byte fields (`builder_pubkeys` entries, `auth_data`) are `0x`-prefixed hex strings. All payment values (`min_bid`, `max_execution_payment`) are in gwei. -## Per-validator configuration - -The standard keymanager API manages entries under `validator_configs` without a validator-client -restart. `GET /eth/v1/validator/{pubkey}/builder_config` returns all values after inheritance is resolved. -`POST` replaces the complete per-validator configuration, while `DELETE` removes it and restores the -global configuration. An omitted `builders` field inherits the global builder list; `builders: []` -means that the validator has no direct builders. An entry without `max_execution_payment` inherits -the value from a matching global builder URL and authentication value. - ## How bids are selected At block-production time the validator client requests a bid from each enabled builder with a `url`, diff --git a/common/eth2/src/lighthouse_vc/std_types.rs b/common/eth2/src/lighthouse_vc/std_types.rs index 3d3a9b283a3..987556760d3 100644 --- a/common/eth2/src/lighthouse_vc/std_types.rs +++ b/common/eth2/src/lighthouse_vc/std_types.rs @@ -1,95 +1,37 @@ use bls::PublicKeyBytes; pub use builder_types::{BuilderUrl, RequestAuthData}; use eth2_keystore::Keystore; -use serde::{Deserialize, Serialize}; -use serde_utils::quoted_u64::Quoted; +use serde::{Deserialize, Deserializer, Serialize, de}; +pub use serde_utils::quoted_u64::Quoted; use types::{Address, Graffiti}; use zeroize::Zeroizing; -mod optional_auth_data { - use super::RequestAuthData; - use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; - - #[derive(Deserialize, Serialize)] - #[serde(transparent)] - struct JsonRequestAuthData( - #[serde(with = "ssz_types::serde_utils::hex_var_list")] RequestAuthData, - ); - - pub fn serialize( - value: &Option, - serializer: S, - ) -> Result { - value - .as_ref() - .map(|data| JsonRequestAuthData(data.clone())) - .serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - Option::::deserialize(deserializer)? - .map(|data| Some(data.0)) - .ok_or_else(|| de::Error::custom("null is not allowed")) - } -} - -mod optional_quoted_u64 { - use super::Quoted; - use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; - - pub fn serialize(value: &Option, serializer: S) -> Result { - value.map(|value| Quoted { value }).serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - Option::>::deserialize(deserializer)? - .map(|value| Some(value.value)) - .ok_or_else(|| de::Error::custom("null is not allowed")) - } -} - -mod optional_builder_pubkeys { - use bls::PublicKeyBytes; - use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; - - pub fn serialize( - value: &Option>, - serializer: S, - ) -> Result { - value.serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result>, D::Error> { - Option::>::deserialize(deserializer)? - .map(Some) - .ok_or_else(|| de::Error::custom("null is not allowed")) - } +fn deserialize_present<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer)? + .map(Some) + .ok_or_else(|| de::Error::custom("null is not allowed")) } -mod optional_builder_entries { - use super::BuilderEntry; - use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; - - pub fn serialize( - value: &Option>, - serializer: S, - ) -> Result { - value.serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result>, D::Error> { - Option::>::deserialize(deserializer)? - .map(Some) - .ok_or_else(|| de::Error::custom("null is not allowed")) +fn deserialize_keymanager_u64<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + let is_canonical = value == "0" + || (value.len() <= 20 + && value.as_bytes().first().is_some_and(|byte| *byte >= b'1') + && value.as_bytes().iter().all(u8::is_ascii_digit)); + if !is_canonical { + return Err(de::Error::custom("invalid quoted uint64")); } + value + .parse() + .map(|value| Some(Quoted { value })) + .map_err(de::Error::custom) } pub use eip_3076::Interchange; @@ -118,23 +60,30 @@ pub struct BuilderConfig { #[serde( default, skip_serializing_if = "Option::is_none", - with = "optional_quoted_u64" + deserialize_with = "deserialize_keymanager_u64" )] - pub min_bid: Option, + pub min_bid: Option>, #[serde( default, skip_serializing_if = "Option::is_none", - with = "optional_quoted_u64" + deserialize_with = "deserialize_keymanager_u64" )] - pub builder_boost_factor: Option, + pub builder_boost_factor: Option>, #[serde( default, skip_serializing_if = "Option::is_none", - with = "optional_builder_entries" + deserialize_with = "deserialize_present" )] pub builders: Option>, } +/// Request authentication data encoded as `0x`-prefixed hex in the keymanager API. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(transparent)] +pub struct HexRequestAuthData( + #[serde(with = "ssz_types::serde_utils::hex_var_list")] pub RequestAuthData, +); + /// An external-builder entry from the standard keymanager API. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct BuilderEntry { @@ -142,33 +91,33 @@ pub struct BuilderEntry { #[serde( default, skip_serializing_if = "Option::is_none", - with = "optional_auth_data" + deserialize_with = "deserialize_present" )] - pub auth_data: Option, + pub auth_data: Option, #[serde( default, skip_serializing_if = "Option::is_none", - with = "optional_builder_pubkeys" + deserialize_with = "deserialize_present" )] pub builder_pubkeys: Option>, #[serde( default, skip_serializing_if = "Option::is_none", - with = "optional_quoted_u64" + deserialize_with = "deserialize_keymanager_u64" )] - pub max_execution_payment: Option, + pub max_execution_payment: Option>, #[serde( default, skip_serializing_if = "Option::is_none", - with = "optional_quoted_u64" + deserialize_with = "deserialize_keymanager_u64" )] - pub min_bid: Option, + pub min_bid: Option>, #[serde( default, skip_serializing_if = "Option::is_none", - with = "optional_quoted_u64" + deserialize_with = "deserialize_keymanager_u64" )] - pub builder_boost_factor: Option, + pub builder_boost_factor: Option>, } #[cfg(test)] @@ -178,13 +127,15 @@ mod builder_config_tests { #[test] fn builder_config_uses_keymanager_json_encoding() { let config = BuilderConfig { - min_bid: Some(3), - builder_boost_factor: Some(110), + min_bid: Some(Quoted { value: 3 }), + builder_boost_factor: Some(Quoted { value: 110 }), builders: Some(vec![BuilderEntry { url: "https://builder.example".parse().unwrap(), - auth_data: Some(RequestAuthData::new(vec![1, 2]).unwrap()), + auth_data: Some(HexRequestAuthData( + RequestAuthData::new(vec![1, 2]).unwrap(), + )), builder_pubkeys: Some(vec![]), - max_execution_payment: Some(8), + max_execution_payment: Some(Quoted { value: 8 }), min_bid: None, builder_boost_factor: None, }]), @@ -203,18 +154,20 @@ mod builder_config_tests { serde_json::from_value::(json).unwrap(), config ); - } - - #[test] - fn empty_post_serializes_without_overrides() { assert_eq!( serde_json::to_value(BuilderConfig::default()).unwrap(), serde_json::json!({}) ); - } + assert!( + serde_json::from_value::(serde_json::json!({"min_bid": 3})).is_err() + ); + for value in ["01", "+1"] { + assert!( + serde_json::from_value::(serde_json::json!({"min_bid": value})) + .is_err() + ); + } - #[test] - fn null_optional_fields_are_rejected() { for field in ["builders", "min_bid", "builder_boost_factor"] { let json = serde_json::json!({(field): null}); assert!( diff --git a/common/warp_utils/src/reject.rs b/common/warp_utils/src/reject.rs index a4c6f53599b..408551bb9b2 100644 --- a/common/warp_utils/src/reject.rs +++ b/common/warp_utils/src/reject.rs @@ -65,6 +65,15 @@ pub fn custom_bad_request(msg: String) -> warp::reject::Rejection { warp::reject::custom(CustomBadRequest(msg)) } +#[derive(Debug)] +pub struct CustomForbidden(pub String); + +impl Reject for CustomForbidden {} + +pub fn custom_forbidden(msg: String) -> warp::reject::Rejection { + warp::reject::custom(CustomForbidden(msg)) +} + #[derive(Debug)] pub struct CustomDeserializeError(pub String); @@ -176,9 +185,6 @@ pub async fn handle_rejection(err: warp::Rejection) -> Result().is_some() { code = StatusCode::UNSUPPORTED_MEDIA_TYPE; message = "UNSUPPORTED_MEDIA_TYPE".to_string(); - } else if err.find::().is_some() { - code = StatusCode::PAYLOAD_TOO_LARGE; - message = "PAYLOAD_TOO_LARGE".to_string(); } else if let Some(e) = err.find::() { message = format!("BAD_REQUEST: body deserialize error: {}", e.0); code = StatusCode::BAD_REQUEST; @@ -197,6 +203,9 @@ pub async fn handle_rejection(err: warp::Rejection) -> Result() { code = StatusCode::BAD_REQUEST; message = format!("BAD_REQUEST: {}", e.0); + } else if let Some(e) = err.find::() { + code = StatusCode::FORBIDDEN; + message = format!("FORBIDDEN: {}", e.0); } else if let Some(e) = err.find::() { code = StatusCode::INTERNAL_SERVER_ERROR; message = format!("INTERNAL_SERVER_ERROR: {}", e.0); diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs index 5a33710f02d..4976a976cb5 100644 --- a/validator_client/builder_store/src/builder_definitions.rs +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -1,11 +1,12 @@ use account_utils::write_file_via_temporary; use bls::PublicKeyBytes; use builder_types::{BuilderPubkeys, BuilderUrl, MAX_BUILDER_ENTRIES, RequestAuthData}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::collections::{BTreeMap, HashSet}; use std::fs::{File, create_dir_all}; use std::io; use std::path::{Path, PathBuf}; +use std::str::FromStr; /// The file name for the serialized `BuilderConfigFile` struct. pub const BUILDERS_FILENAME: &str = "builder_definitions.yml"; @@ -80,7 +81,7 @@ fn default_builder_boost_factor() -> u64 { /// 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`. -pub(crate) mod serde_option_auth_data { +mod serde_option_auth_data { use super::RequestAuthData; use serde::{Deserialize, Deserializer, Serializer, de}; @@ -169,12 +170,17 @@ impl ValidatorBuilderConfig { let mut seen_auth_urls = HashSet::new(); for builder in builders { - validate_builder_definition( - &builder.url, - &builder.auth_data, - &builder.builder_pubkeys, - &mut seen_auth_urls, - )?; + validate_builder_definition(&builder.url, &builder.auth_data, &mut seen_auth_urls)?; + if BuilderPubkeys::new(builder.builder_pubkeys.clone()).is_err() { + return Err(Error::TooManyBuilderPubkeys(builder.url.clone())); + } + if builder + .auth_data + .as_ref() + .is_some_and(|data| data.is_empty()) + { + return Err(Error::EmptyAuthData(builder.url.clone())); + } } Ok(()) @@ -200,10 +206,39 @@ pub struct BuilderConfigFile { #[serde(default)] pub builders: Vec, /// Per-validator overrides. The key is the compressed validator public key in hex form. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde( + default, + skip_serializing_if = "BTreeMap::is_empty", + with = "serde_validator_configs" + )] pub validator_configs: BTreeMap, } +mod serde_validator_configs { + use super::*; + + pub fn serialize( + configs: &BTreeMap, + serializer: S, + ) -> Result { + configs.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let configs = BTreeMap::::deserialize(deserializer)?; + let mut canonical = BTreeMap::new(); + for (key, config) in configs { + let public_key = PublicKeyBytes::from_str(&key).map_err(de::Error::custom)?; + if canonical.insert(public_key.to_string(), config).is_some() { + return Err(de::Error::custom("duplicate validator public key")); + } + } + Ok(canonical) + } +} + impl Default for BuilderConfigFile { fn default() -> Self { Self { @@ -286,12 +321,7 @@ impl BuilderConfigFile { continue; } let url = &definition.url; - validate_builder_definition( - url, - &definition.auth_data, - &definition.builder_pubkeys, - &mut seen_auth_urls, - )?; + validate_builder_definition(url, &definition.auth_data, &mut seen_auth_urls)?; } for config in self.validator_configs.values() { @@ -321,7 +351,14 @@ impl BuilderConfigFile { None => self .builders .iter() - .filter(|builder| builder.enabled) + .filter(|builder| { + builder.enabled + && BuilderPubkeys::new(builder.builder_pubkeys.clone()).is_ok() + && !builder + .auth_data + .as_ref() + .is_some_and(|auth_data| auth_data.is_empty()) + }) .map(|builder| { let mut builder = builder.clone(); builder.min_bid = Some(builder.min_bid.unwrap_or(min_bid)); @@ -386,7 +423,6 @@ impl BuilderConfigFile { fn validate_builder_definition( url: &BuilderUrl, auth_data: &Option, - builder_pubkeys: &[PublicKeyBytes], seen_auth_urls: &mut HashSet<(BuilderUrl, RequestAuthData)>, ) -> Result<(), Error> { // Reject malformed or non-http(s) builder URLs here, at config load, rather than silently @@ -398,17 +434,9 @@ fn validate_builder_definition( return Err(Error::UnsupportedUrlScheme(url.clone())); } - if BuilderPubkeys::new(builder_pubkeys.to_vec()).is_err() { - return Err(Error::TooManyBuilderPubkeys(url.clone())); - } - let auth = auth_data .clone() .unwrap_or_else(|| url.to_default_auth_data()); - if auth.is_empty() { - return Err(Error::EmptyAuthData(url.clone())); - } - // Two entries cannot contain the same URL and auth data. if !seen_auth_urls.insert((url.clone(), auth)) { return Err(Error::DuplicateBuilderAuth(url.clone())); @@ -477,4 +505,18 @@ mod tests { ); } } + + #[test] + fn validator_config_keys_are_canonicalized() { + let public_key = bls::Keypair::random().pk.compress(); + let encoded = public_key.to_string(); + let uppercase = format!("0x{}", encoded[2..].to_uppercase()); + let yaml = format!("validator_configs:\n {uppercase}: {{}}\n"); + + let config: BuilderConfigFile = yaml_serde::from_str(&yaml).unwrap(); + assert!(config.validator_configs.contains_key(&encoded)); + + let invalid = "validator_configs:\n not-a-public-key: {}\n"; + assert!(yaml_serde::from_str::(invalid).is_err()); + } } diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs index aaa7985a4f7..2001e0ddb23 100644 --- a/validator_client/builder_store/src/lib.rs +++ b/validator_client/builder_store/src/lib.rs @@ -7,7 +7,7 @@ pub use builder_definitions::{ use builder_types::{ BuilderConfig, BuilderEntry, BuilderPubkeys, RequestAuthData, SignedRequestAuth, }; -use parking_lot::RwLock; +use parking_lot::{Mutex, RwLock}; use ssz_types::VariableList; use std::future::Future; use std::path::{Path, PathBuf}; @@ -17,6 +17,7 @@ use tracing::error; #[derive(Clone)] pub struct BuilderStore { config: Arc>, + update_lock: Arc>, validators_dir: PathBuf, } @@ -28,6 +29,7 @@ impl BuilderStore { config: Arc::new(RwLock::new(BuilderConfigFile::open_or_create( &validators_dir, )?)), + update_lock: Arc::new(Mutex::new(())), validators_dir, }) } @@ -134,6 +136,7 @@ impl BuilderStore { } pub fn insert(&self, builder: BuilderDefinition) -> Result<(), Error> { + let _update_guard = self.update_lock.lock(); 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). @@ -141,9 +144,8 @@ impl BuilderStore { candidate.push(builder); candidate.validate()?; - candidate.save(&self.validators_dir)?; *config = candidate; - Ok(()) + config.save(&self.validators_dir) } /// Return the fully resolved configuration for a validator without signing builder auth data. @@ -162,14 +164,13 @@ impl BuilderStore { ) -> Result<(), Error> { validator_config.validate()?; - let mut config = self.config.write(); - let mut candidate = config.clone(); + let _update_guard = self.update_lock.lock(); + let mut candidate = self.config.read().clone(); candidate .validator_configs .insert(validator_pubkey.to_string(), validator_config); - candidate.validate()?; candidate.save(&self.validators_dir)?; - *config = candidate; + *self.config.write() = candidate; Ok(()) } @@ -178,8 +179,8 @@ impl BuilderStore { &self, validator_pubkey: &bls::PublicKeyBytes, ) -> Result<(), Error> { - let mut config = self.config.write(); - let mut candidate = config.clone(); + let _update_guard = self.update_lock.lock(); + let mut candidate = self.config.read().clone(); if candidate .validator_configs .remove(&validator_pubkey.to_string()) @@ -187,9 +188,8 @@ impl BuilderStore { { return Ok(()); } - candidate.validate()?; candidate.save(&self.validators_dir)?; - *config = candidate; + *self.config.write() = candidate; Ok(()) } } @@ -214,6 +214,17 @@ mod tests { } } + fn validator_builder(url: &str) -> ValidatorBuilderDefinition { + ValidatorBuilderDefinition { + url: url.parse().unwrap(), + auth_data: None, + builder_pubkeys: vec![], + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + } + } + fn signed_auth(data: RequestAuthData) -> SignedRequestAuth { SignedRequestAuth { message: RequestAuth { @@ -233,6 +244,13 @@ mod tests { store .insert(global_builder("https://global-builder.example", 7)) .unwrap(); + let mut empty_auth = global_builder("https://empty-auth.example", 7); + empty_auth.auth_data = Some(RequestAuthData::default()); + store.insert(empty_auth).unwrap(); + let mut excessive_pubkeys = global_builder("https://too-many-pubkeys.example", 7); + excessive_pubkeys.builder_pubkeys = + (0..65).map(|_| Keypair::random().pk.compress()).collect(); + store.insert(excessive_pubkeys).unwrap(); store .set_validator_config( &validator, @@ -272,7 +290,7 @@ mod tests { } #[test] - fn validator_config_persists_across_store_restart_and_post_empty_is_stored() { + fn empty_validator_config_persists_across_store_restart() { let directory = tempdir().unwrap(); let store = BuilderStore::open_or_create(directory.path()).unwrap(); let validator = Keypair::random().pk.compress(); @@ -293,10 +311,6 @@ mod tests { restarted.delete_validator_config(&validator).unwrap(); let file = builder_definitions::BuilderConfigFile::open(directory.path()).unwrap(); assert!(!file.validator_configs.contains_key(&validator.to_string())); - - restarted.delete_validator_config(&validator).unwrap(); - let file = builder_definitions::BuilderConfigFile::open(directory.path()).unwrap(); - assert!(!file.validator_configs.contains_key(&validator.to_string())); } #[test] @@ -306,29 +320,21 @@ mod tests { let first = Keypair::random().pk.compress(); let second = Keypair::random().pk.compress(); - let first_store = store.clone(); - let first_handle = std::thread::spawn(move || { - first_store.set_validator_config( - &first, - ValidatorBuilderConfig { - min_bid: Some(11), - ..Default::default() - }, - ) - }); - let second_store = store.clone(); - let second_handle = std::thread::spawn(move || { - second_store.set_validator_config( - &second, - ValidatorBuilderConfig { - min_bid: Some(22), - ..Default::default() - }, - ) + let handles = [(first, 11), (second, 22)].map(|(validator, min_bid)| { + let store = store.clone(); + std::thread::spawn(move || { + store.set_validator_config( + &validator, + ValidatorBuilderConfig { + min_bid: Some(min_bid), + ..Default::default() + }, + ) + }) }); - - first_handle.join().unwrap().unwrap(); - second_handle.join().unwrap().unwrap(); + for handle in handles { + handle.join().unwrap().unwrap(); + } assert_eq!(store.validator_config(&first).min_bid, 11); assert_eq!(store.validator_config(&second).min_bid, 22); @@ -371,40 +377,13 @@ mod tests { } #[test] - fn custom_builder_inherits_matching_payment_limit() { + fn custom_builders_resolve_payment_limits_from_enabled_globals() { let directory = tempdir().unwrap(); let store = BuilderStore::open_or_create(directory.path()).unwrap(); let validator = Keypair::random().pk.compress(); store .insert(global_builder("https://global-builder.example", 9)) .unwrap(); - - store - .set_validator_config( - &validator, - ValidatorBuilderConfig { - builders: Some(vec![ValidatorBuilderDefinition { - url: "https://global-builder.example".parse().unwrap(), - auth_data: None, - builder_pubkeys: vec![], - max_execution_payment: None, - min_bid: None, - builder_boost_factor: None, - }]), - ..Default::default() - }, - ) - .unwrap(); - - let resolved = store.validator_config(&validator); - assert_eq!(resolved.builders[0].max_execution_payment, 9); - } - - #[test] - fn custom_builder_does_not_inherit_disabled_payment_limit() { - let directory = tempdir().unwrap(); - let store = BuilderStore::open_or_create(directory.path()).unwrap(); - let validator = Keypair::random().pk.compress(); store .insert(BuilderDefinition { enabled: false, @@ -421,38 +400,17 @@ mod tests { .set_validator_config( &validator, ValidatorBuilderConfig { - builders: Some(vec![ValidatorBuilderDefinition { - url: "https://disabled-builder.example".parse().unwrap(), - auth_data: None, - builder_pubkeys: vec![], - max_execution_payment: None, - min_bid: None, - builder_boost_factor: None, - }]), + builders: Some(vec![ + validator_builder("https://global-builder.example"), + validator_builder("https://disabled-builder.example"), + ]), ..Default::default() }, ) .unwrap(); - assert_eq!( - store.validator_config(&validator).builders[0].max_execution_payment, - 0 - ); - } - - #[test] - fn invalid_builder_url_is_rejected() { - let config = ValidatorBuilderConfig { - builders: Some(vec![ValidatorBuilderDefinition { - url: "ftp://builder.example".parse().unwrap(), - auth_data: None, - builder_pubkeys: vec![], - max_execution_payment: Some(1), - min_bid: None, - builder_boost_factor: None, - }]), - ..Default::default() - }; - assert!(config.validate().is_err()); + let resolved = store.validator_config(&validator); + assert_eq!(resolved.builders[0].max_execution_payment, 9); + assert_eq!(resolved.builders[1].max_execution_payment, 0); } } diff --git a/validator_client/http_api/Cargo.toml b/validator_client/http_api/Cargo.toml index cf1aed18eb5..3148ff1f10d 100644 --- a/validator_client/http_api/Cargo.toml +++ b/validator_client/http_api/Cargo.toml @@ -65,6 +65,5 @@ doppelganger_service = { workspace = true } futures = { workspace = true } itertools = { workspace = true } rand = { workspace = true, features = ["small_rng"] } -reqwest = { workspace = true } ssz_types = { workspace = true } tempfile = { workspace = true } diff --git a/validator_client/http_api/src/lib.rs b/validator_client/http_api/src/lib.rs index e1a3096c3ee..9d2a10c4e4b 100644 --- a/validator_client/http_api/src/lib.rs +++ b/validator_client/http_api/src/lib.rs @@ -82,18 +82,18 @@ impl From for Error { fn into_store_builder_config(config: api_types::BuilderConfig) -> ValidatorBuilderConfig { ValidatorBuilderConfig { - min_bid: config.min_bid, - builder_boost_factor: config.builder_boost_factor, + min_bid: config.min_bid.map(|value| value.value), + builder_boost_factor: config.builder_boost_factor.map(|value| value.value), builders: config.builders.map(|builders| { builders .into_iter() .map(|builder| ValidatorBuilderDefinition { url: builder.url, - auth_data: builder.auth_data, + auth_data: builder.auth_data.map(|data| data.0), builder_pubkeys: builder.builder_pubkeys.unwrap_or_default(), - max_execution_payment: builder.max_execution_payment, - min_bid: builder.min_bid, - builder_boost_factor: builder.builder_boost_factor, + max_execution_payment: builder.max_execution_payment.map(|value| value.value), + min_bid: builder.min_bid.map(|value| value.value), + builder_boost_factor: builder.builder_boost_factor.map(|value| value.value), }) .collect() }), @@ -109,27 +109,33 @@ fn into_api_builder_config(config: ResolvedBuilderConfig) -> api_types::BuilderC let builders = builders .into_iter() .map(|builder| { - let auth_data = Some( + let auth_data = Some(api_types::HexRequestAuthData( builder .auth_data .unwrap_or_else(|| builder.url.to_default_auth_data()), - ); + )); api_types::BuilderEntry { url: builder.url, auth_data, builder_pubkeys: Some(builder.builder_pubkeys), - max_execution_payment: Some(builder.max_execution_payment), - min_bid: Some(builder.min_bid.unwrap_or(min_bid)), - builder_boost_factor: Some( - builder.builder_boost_factor.unwrap_or(builder_boost_factor), - ), + max_execution_payment: Some(api_types::Quoted { + value: builder.max_execution_payment, + }), + min_bid: Some(api_types::Quoted { + value: builder.min_bid.unwrap_or(min_bid), + }), + builder_boost_factor: Some(api_types::Quoted { + value: builder.builder_boost_factor.unwrap_or(builder_boost_factor), + }), } }) .collect(); api_types::BuilderConfig { - min_bid: Some(min_bid), - builder_boost_factor: Some(builder_boost_factor), + min_bid: Some(api_types::Quoted { value: min_bid }), + builder_boost_factor: Some(api_types::Quoted { + value: builder_boost_factor, + }), builders: Some(builders), } } @@ -147,10 +153,11 @@ fn builder_store_rejection(error: builder_store::Error) -> warp::Rejection { } } -// The API bounds each configuration at 64 entries, each with bounded URLs, auth data, and -// builder public keys. Keep body decoding bounded as well, so malformed authenticated requests -// cannot allocate without limit before those per-field checks run. -const MAX_BUILDER_CONFIG_BODY_SIZE: u64 = 2 * 1024 * 1024; +fn builder_store_delete_rejection(error: builder_store::Error) -> warp::Rejection { + warp_utils::reject::custom_forbidden(format!( + "builder configuration could not be removed: {error:?}" + )) +} /// A wrapper around all the items required to spawn the HTTP server. /// @@ -1137,9 +1144,6 @@ pub async fn serve( .and(warp::path("validator")) .and(warp::path::param::()) .and(warp::path("builder_config")) - .and(warp::body::content_length_limit( - MAX_BUILDER_CONFIG_BODY_SIZE, - )) .and(warp::body::json()) .and(warp::path::end()) .and(validator_store_filter.clone()) @@ -1211,7 +1215,7 @@ pub async fn serve( warp::http::StatusCode::NO_CONTENT, ) }) - .map_err(builder_store_rejection) + .map_err(builder_store_delete_rejection) }) }, ); diff --git a/validator_client/http_api/src/test_utils.rs b/validator_client/http_api/src/test_utils.rs index 0be91a6369c..2f7db82f150 100644 --- a/validator_client/http_api/src/test_utils.rs +++ b/validator_client/http_api/src/test_utils.rs @@ -58,7 +58,6 @@ pub struct ApiTester { pub client: ValidatorClientHttpClient, pub initialized_validators: Arc>, pub validator_store: Arc>, - pub configured_builders: BuilderStore, pub url: SensitiveUrl, pub api_token: String, pub test_runtime: TestRuntime, @@ -170,7 +169,6 @@ impl ApiTester { client, initialized_validators, validator_store, - configured_builders, url, api_token: api_pubkey, test_runtime, diff --git a/validator_client/http_api/src/tests.rs b/validator_client/http_api/src/tests.rs index d9b18f7d657..985b8710ea5 100644 --- a/validator_client/http_api/src/tests.rs +++ b/validator_client/http_api/src/tests.rs @@ -6,7 +6,7 @@ mod keystores; use doppelganger_service::DoppelgangerService; use initialized_validators::{Config as InitializedValidatorsConfig, InitializedValidators}; -use crate::{ApiSecret, Config as HttpConfig, Context, MAX_BUILDER_CONFIG_BODY_SIZE}; +use crate::{ApiSecret, Config as HttpConfig, Context}; use account_utils::{ eth2_wallet::WalletBuilder, mnemonic_from_phrase, random_mnemonic, random_password, random_password_string, validator_definitions::ValidatorDefinitions, @@ -1007,8 +1007,7 @@ async fn routes_with_invalid_auth() { .await; } -#[tokio::test] -async fn validator_builder_configuration_endpoints() { +async fn builder_configuration_tester() -> (ApiTester, PublicKeyBytes) { let tester = ApiTester::new() .await .create_hd_validators(HdValidatorScenario { @@ -1018,6 +1017,19 @@ async fn validator_builder_configuration_endpoints() { disabled: vec![], }) .await; + let validator = tester + .client + .get_lighthouse_validators() + .await + .unwrap() + .data[0] + .voting_pubkey; + (tester, validator) +} + +#[tokio::test] +async fn validator_builder_configuration_endpoints() { + let (tester, validator) = builder_configuration_tester().await; tester .configured_builders .insert(BuilderDefinition { @@ -1030,21 +1042,17 @@ async fn validator_builder_configuration_endpoints() { builder_boost_factor: None, }) .unwrap(); - let validator = tester - .client - .get_lighthouse_validators() - .await - .unwrap() - .data[0] - .voting_pubkey; let inherited = tester.client.get_builder_config(&validator).await.unwrap(); - assert_eq!(inherited.min_bid, Some(0)); - assert_eq!(inherited.builder_boost_factor, Some(100)); + assert_eq!(inherited.min_bid.unwrap().value, 0); + assert_eq!(inherited.builder_boost_factor.unwrap().value, 100); assert_eq!(inherited.builders.as_ref().unwrap().len(), 1); assert_eq!( - inherited.builders.as_ref().unwrap()[0].max_execution_payment, - Some(7) + inherited.builders.as_ref().unwrap()[0] + .max_execution_payment + .unwrap() + .value, + 7 ); let empty_post = tester @@ -1059,52 +1067,59 @@ async fn validator_builder_configuration_endpoints() { ); let custom = BuilderConfig { - min_bid: Some(3), - builder_boost_factor: Some(115), + min_bid: Some(Quoted { value: 3 }), + builder_boost_factor: Some(Quoted { value: 115 }), builders: Some(vec![BuilderEntry { url: "https://builder.example".parse().unwrap(), - auth_data: Some(RequestAuthData::new(b"validator-auth".to_vec()).unwrap()), + auth_data: Some(HexRequestAuthData( + RequestAuthData::new(b"validator-auth".to_vec()).unwrap(), + )), builder_pubkeys: Some(vec![]), - max_execution_payment: Some(8), + max_execution_payment: Some(Quoted { value: 8 }), min_bid: None, builder_boost_factor: None, }]), }; - let custom_post = tester + tester .client .post_builder_config(&validator, &custom) .await .unwrap(); - assert_eq!(custom_post.status(), StatusCode::ACCEPTED); - let resolved = tester.client.get_builder_config(&validator).await.unwrap(); - assert_eq!(resolved.min_bid, Some(3)); - assert_eq!(resolved.builder_boost_factor, Some(115)); - let entry = &resolved.builders.as_ref().unwrap()[0]; - assert_eq!(entry.url.to_string(), "https://builder.example"); - assert_eq!(&entry.auth_data.as_ref().unwrap()[..], b"validator-auth"); - assert_eq!(entry.max_execution_payment, Some(8)); - assert_eq!(entry.min_bid, Some(3)); - assert_eq!(entry.builder_boost_factor, Some(115)); - - let restarted = BuilderStore::open_or_create(tester._validator_dir.path()).unwrap(); - let restarted_config = restarted.validator_config(&validator); - assert_eq!(restarted_config.min_bid, 3); - assert_eq!(restarted_config.builders[0].max_execution_payment, 8); + assert_eq!( + tester.client.get_builder_config(&validator).await.unwrap(), + BuilderConfig { + min_bid: Some(Quoted { value: 3 }), + builder_boost_factor: Some(Quoted { value: 115 }), + builders: Some(vec![BuilderEntry { + url: "https://builder.example".parse().unwrap(), + auth_data: Some(HexRequestAuthData( + RequestAuthData::new(b"validator-auth".to_vec()).unwrap(), + )), + builder_pubkeys: Some(vec![]), + max_execution_payment: Some(Quoted { value: 8 }), + min_bid: Some(Quoted { value: 3 }), + builder_boost_factor: Some(Quoted { value: 115 }), + }]), + } + ); let empty_list = BuilderConfig { - min_bid: Some(9), - builder_boost_factor: Some(130), builders: Some(vec![]), + ..Default::default() }; tester .client .post_builder_config(&validator, &empty_list) .await .unwrap(); - let explicitly_empty = tester.client.get_builder_config(&validator).await.unwrap(); - assert_eq!(explicitly_empty.min_bid, Some(9)); - assert_eq!(explicitly_empty.builder_boost_factor, Some(130)); - assert_eq!(explicitly_empty.builders, Some(vec![])); + assert_eq!( + tester.client.get_builder_config(&validator).await.unwrap(), + BuilderConfig { + min_bid: Some(Quoted { value: 0 }), + builder_boost_factor: Some(Quoted { value: 100 }), + builders: Some(vec![]), + } + ); let delete = tester .client @@ -1116,89 +1131,109 @@ async fn validator_builder_configuration_endpoints() { tester.client.get_builder_config(&validator).await.unwrap(), inherited ); - let delete_again = tester - .client - .delete_builder_config(&validator) - .await - .unwrap(); - assert_eq!(delete_again.status(), StatusCode::NO_CONTENT); +} +#[tokio::test] +async fn builder_configuration_unknown_validator() { + let tester = ApiTester::new().await; let unknown = Keypair::random().pk.compress(); - match tester.client.get_builder_config(&unknown).await { - Err(ApiError::ServerMessage(ApiErrorMessage { code: 404, .. })) => (), - other => panic!("expected unknown validator to return 404, got {other:?}"), - } - match tester - .client - .post_builder_config(&unknown, &BuilderConfig::default()) - .await - { - Err(ApiError::ServerMessage(ApiErrorMessage { code: 404, .. })) => (), - other => panic!("expected unknown validator POST to return 404, got {other:?}"), - } - match tester.client.delete_builder_config(&unknown).await { - Err(ApiError::ServerMessage(ApiErrorMessage { code: 404, .. })) => (), - other => panic!("expected unknown validator DELETE to return 404, got {other:?}"), - } + assert_server_error_code(tester.client.get_builder_config(&unknown).await, 404); + assert_server_error_code( + tester + .client + .post_builder_config(&unknown, &BuilderConfig::default()) + .await, + 404, + ); + assert_server_error_code(tester.client.delete_builder_config(&unknown).await, 404); +} +#[tokio::test] +async fn builder_configuration_rejects_invalid_input() { + let (tester, validator) = builder_configuration_tester().await; let invalid = BuilderConfig { builders: Some(vec![BuilderEntry { url: "ftp://builder.example".parse().unwrap(), auth_data: None, builder_pubkeys: Some(vec![]), - max_execution_payment: Some(1), + max_execution_payment: Some(Quoted { value: 1 }), min_bid: None, builder_boost_factor: None, }]), ..Default::default() }; - match tester - .client - .post_builder_config(&validator, &invalid) - .await - { - Err(ApiError::ServerMessage(ApiErrorMessage { code: 400, .. })) => (), - other => panic!("expected invalid builder input to return 400, got {other:?}"), - } + assert_server_error_code( + tester + .client + .post_builder_config(&validator, &invalid) + .await, + 400, + ); let empty_auth = BuilderConfig { builders: Some(vec![BuilderEntry { url: "https://builder.example".parse().unwrap(), - auth_data: Some(RequestAuthData::default()), + auth_data: Some(HexRequestAuthData(RequestAuthData::default())), builder_pubkeys: Some(vec![]), - max_execution_payment: Some(1), + max_execution_payment: Some(Quoted { value: 1 }), min_bid: None, builder_boost_factor: None, }]), ..Default::default() }; - match tester - .client - .post_builder_config(&validator, &empty_auth) - .await - { - Err(ApiError::ServerMessage(ApiErrorMessage { code: 400, .. })) => (), - other => panic!("expected empty auth input to return 400, got {other:?}"), - } + assert_server_error_code( + tester + .client + .post_builder_config(&validator, &empty_auth) + .await, + 400, + ); - let oversized_body = vec![b' '; (MAX_BUILDER_CONFIG_BODY_SIZE + 1) as usize]; - let oversized_url = tester - .url - .expose_full() - .join(&format!("eth/v1/validator/{validator}/builder_config")) - .unwrap(); - let oversized_response = reqwest::Client::new() - .post(oversized_url) - .header( - "Authorization", - format!("Bearer {}", tester.client.api_token().unwrap().as_str()), - ) - .header("Content-Type", "application/json") - .body(oversized_body) - .send() - .await - .unwrap(); - assert_eq!(oversized_response.status(), StatusCode::PAYLOAD_TOO_LARGE); + let before = tester.client.get_builder_config(&validator).await.unwrap(); + let duplicate = BuilderEntry { + url: "https://duplicate-builder.example".parse().unwrap(), + auth_data: None, + builder_pubkeys: None, + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + }; + assert_server_error_code( + tester + .client + .post_builder_config( + &validator, + &BuilderConfig { + builders: Some(vec![duplicate.clone(), duplicate]), + ..Default::default() + }, + ) + .await, + 400, + ); + assert_eq!( + tester.client.get_builder_config(&validator).await.unwrap(), + before + ); +} + +#[test] +fn builder_delete_errors_are_forbidden() { + let rejection = crate::builder_store_delete_rejection(builder_store::Error::UnableToOpenFile( + std::io::Error::other("test"), + )); + assert!( + rejection + .find::() + .is_some() + ); +} + +fn assert_server_error_code(result: Result, expected: u16) { + match result { + Err(ApiError::ServerMessage(ApiErrorMessage { code, .. })) if code == expected => (), + other => panic!("expected HTTP {expected}, got {other:?}"), + } } #[tokio::test] From 07d558148c9acd70e14287ea6926d7b91d90e357 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 04:47:06 +0000 Subject: [PATCH 06/14] Simplify builder configuration request types --- common/eth2/src/lighthouse_vc/std_types.rs | 124 ++++++--------------- validator_client/http_api/src/lib.rs | 6 +- validator_client/http_api/src/tests.rs | 10 +- 3 files changed, 39 insertions(+), 101 deletions(-) diff --git a/common/eth2/src/lighthouse_vc/std_types.rs b/common/eth2/src/lighthouse_vc/std_types.rs index 987556760d3..b6c1b1e48a0 100644 --- a/common/eth2/src/lighthouse_vc/std_types.rs +++ b/common/eth2/src/lighthouse_vc/std_types.rs @@ -1,37 +1,35 @@ use bls::PublicKeyBytes; pub use builder_types::{BuilderUrl, RequestAuthData}; use eth2_keystore::Keystore; -use serde::{Deserialize, Deserializer, Serialize, de}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::value::StringDeserializer}; pub use serde_utils::quoted_u64::Quoted; use types::{Address, Graffiti}; use zeroize::Zeroizing; -fn deserialize_present<'de, D, T>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, - T: Deserialize<'de>, -{ - Option::::deserialize(deserializer)? - .map(Some) - .ok_or_else(|| de::Error::custom("null is not allowed")) -} +mod serde_option_auth_data { + use super::*; -fn deserialize_keymanager_u64<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - let value = String::deserialize(deserializer)?; - let is_canonical = value == "0" - || (value.len() <= 20 - && value.as_bytes().first().is_some_and(|byte| *byte >= b'1') - && value.as_bytes().iter().all(u8::is_ascii_digit)); - if !is_canonical { - return Err(de::Error::custom("invalid quoted uint64")); + pub fn serialize( + value: &Option, + serializer: S, + ) -> Result { + match value { + Some(data) => ssz_types::serde_utils::hex_var_list::serialize(data, serializer), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + ssz_types::serde_utils::hex_var_list::deserialize(StringDeserializer::::new( + value, + )) + .map(Some) } - value - .parse() - .map(|value| Some(Quoted { value })) - .map_err(de::Error::custom) } pub use eip_3076::Interchange; @@ -57,33 +55,14 @@ pub struct GetGasLimitResponse { /// list as distinct values. #[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] pub struct BuilderConfig { - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_keymanager_u64" - )] + #[serde(default, skip_serializing_if = "Option::is_none")] pub min_bid: Option>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_keymanager_u64" - )] + #[serde(default, skip_serializing_if = "Option::is_none")] pub builder_boost_factor: Option>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_present" - )] + #[serde(default, skip_serializing_if = "Option::is_none")] pub builders: Option>, } -/// Request authentication data encoded as `0x`-prefixed hex in the keymanager API. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[serde(transparent)] -pub struct HexRequestAuthData( - #[serde(with = "ssz_types::serde_utils::hex_var_list")] pub RequestAuthData, -); - /// An external-builder entry from the standard keymanager API. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] pub struct BuilderEntry { @@ -91,32 +70,16 @@ pub struct BuilderEntry { #[serde( default, skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_present" - )] - pub auth_data: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_present" + with = "serde_option_auth_data" )] + pub auth_data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub builder_pubkeys: Option>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_keymanager_u64" - )] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_execution_payment: Option>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_keymanager_u64" - )] + #[serde(default, skip_serializing_if = "Option::is_none")] pub min_bid: Option>, - #[serde( - default, - skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_keymanager_u64" - )] + #[serde(default, skip_serializing_if = "Option::is_none")] pub builder_boost_factor: Option>, } @@ -131,9 +94,7 @@ mod builder_config_tests { builder_boost_factor: Some(Quoted { value: 110 }), builders: Some(vec![BuilderEntry { url: "https://builder.example".parse().unwrap(), - auth_data: Some(HexRequestAuthData( - RequestAuthData::new(vec![1, 2]).unwrap(), - )), + auth_data: Some(RequestAuthData::new(vec![1, 2]).unwrap()), builder_pubkeys: Some(vec![]), max_execution_payment: Some(Quoted { value: 8 }), min_bid: None, @@ -161,25 +122,6 @@ mod builder_config_tests { assert!( serde_json::from_value::(serde_json::json!({"min_bid": 3})).is_err() ); - for value in ["01", "+1"] { - assert!( - serde_json::from_value::(serde_json::json!({"min_bid": value})) - .is_err() - ); - } - - for field in ["builders", "min_bid", "builder_boost_factor"] { - let json = serde_json::json!({(field): null}); - assert!( - serde_json::from_value::(json).is_err(), - "field {field} unexpectedly accepts null" - ); - } - - let json = serde_json::json!({ - "builders": [{"url": "https://builder.example", "builder_pubkeys": null}] - }); - assert!(serde_json::from_value::(json).is_err()); } } diff --git a/validator_client/http_api/src/lib.rs b/validator_client/http_api/src/lib.rs index 9d2a10c4e4b..daedc7769e6 100644 --- a/validator_client/http_api/src/lib.rs +++ b/validator_client/http_api/src/lib.rs @@ -89,7 +89,7 @@ fn into_store_builder_config(config: api_types::BuilderConfig) -> ValidatorBuild .into_iter() .map(|builder| ValidatorBuilderDefinition { url: builder.url, - auth_data: builder.auth_data.map(|data| data.0), + auth_data: builder.auth_data, builder_pubkeys: builder.builder_pubkeys.unwrap_or_default(), max_execution_payment: builder.max_execution_payment.map(|value| value.value), min_bid: builder.min_bid.map(|value| value.value), @@ -109,11 +109,11 @@ fn into_api_builder_config(config: ResolvedBuilderConfig) -> api_types::BuilderC let builders = builders .into_iter() .map(|builder| { - let auth_data = Some(api_types::HexRequestAuthData( + let auth_data = Some( builder .auth_data .unwrap_or_else(|| builder.url.to_default_auth_data()), - )); + ); api_types::BuilderEntry { url: builder.url, auth_data, diff --git a/validator_client/http_api/src/tests.rs b/validator_client/http_api/src/tests.rs index 985b8710ea5..8de26467e33 100644 --- a/validator_client/http_api/src/tests.rs +++ b/validator_client/http_api/src/tests.rs @@ -1071,9 +1071,7 @@ async fn validator_builder_configuration_endpoints() { builder_boost_factor: Some(Quoted { value: 115 }), builders: Some(vec![BuilderEntry { url: "https://builder.example".parse().unwrap(), - auth_data: Some(HexRequestAuthData( - RequestAuthData::new(b"validator-auth".to_vec()).unwrap(), - )), + auth_data: Some(RequestAuthData::new(b"validator-auth".to_vec()).unwrap()), builder_pubkeys: Some(vec![]), max_execution_payment: Some(Quoted { value: 8 }), min_bid: None, @@ -1092,9 +1090,7 @@ async fn validator_builder_configuration_endpoints() { builder_boost_factor: Some(Quoted { value: 115 }), builders: Some(vec![BuilderEntry { url: "https://builder.example".parse().unwrap(), - auth_data: Some(HexRequestAuthData( - RequestAuthData::new(b"validator-auth".to_vec()).unwrap(), - )), + auth_data: Some(RequestAuthData::new(b"validator-auth".to_vec()).unwrap()), builder_pubkeys: Some(vec![]), max_execution_payment: Some(Quoted { value: 8 }), min_bid: Some(Quoted { value: 3 }), @@ -1173,7 +1169,7 @@ async fn builder_configuration_rejects_invalid_input() { let empty_auth = BuilderConfig { builders: Some(vec![BuilderEntry { url: "https://builder.example".parse().unwrap(), - auth_data: Some(HexRequestAuthData(RequestAuthData::default())), + auth_data: Some(RequestAuthData::default()), builder_pubkeys: Some(vec![]), max_execution_payment: Some(Quoted { value: 1 }), min_bid: None, From f5c71745fa83c0c9a8ac478cc84c8a4a97610d85 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 05:04:15 +0000 Subject: [PATCH 07/14] Tighten builder configuration request handling --- common/eth2/src/lighthouse_vc/std_types.rs | 127 +++++++++++++----- .../builder_store/src/builder_definitions.rs | 35 ++--- validator_client/http_api/src/tests.rs | 85 +++++------- 3 files changed, 138 insertions(+), 109 deletions(-) diff --git a/common/eth2/src/lighthouse_vc/std_types.rs b/common/eth2/src/lighthouse_vc/std_types.rs index b6c1b1e48a0..1cf0f13bac6 100644 --- a/common/eth2/src/lighthouse_vc/std_types.rs +++ b/common/eth2/src/lighthouse_vc/std_types.rs @@ -1,11 +1,42 @@ use bls::PublicKeyBytes; pub use builder_types::{BuilderUrl, RequestAuthData}; use eth2_keystore::Keystore; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de::value::StringDeserializer}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{self, value::StringDeserializer}, +}; pub use serde_utils::quoted_u64::Quoted; use types::{Address, Graffiti}; use zeroize::Zeroizing; +fn deserialize_non_null<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +fn deserialize_canonical_quoted_u64<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + let is_canonical = value == "0" + || (value.len() <= 20 + && value.as_bytes().first().is_some_and(|byte| *byte >= b'1') + && value.as_bytes().iter().all(u8::is_ascii_digit)); + if !is_canonical { + return Err(de::Error::custom("invalid quoted uint64")); + } + value + .parse() + .map(|value| Some(Quoted { value })) + .map_err(de::Error::custom) +} + mod serde_option_auth_data { use super::*; @@ -22,9 +53,7 @@ mod serde_option_auth_data { pub fn deserialize<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { - let Some(value) = Option::::deserialize(deserializer)? else { - return Ok(None); - }; + let value = String::deserialize(deserializer)?; ssz_types::serde_utils::hex_var_list::deserialize(StringDeserializer::::new( value, )) @@ -55,11 +84,23 @@ pub struct GetGasLimitResponse { /// list as distinct values. #[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)] pub struct BuilderConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_canonical_quoted_u64" + )] pub min_bid: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_canonical_quoted_u64" + )] pub builder_boost_factor: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_non_null" + )] pub builders: Option>, } @@ -73,13 +114,29 @@ pub struct BuilderEntry { with = "serde_option_auth_data" )] pub auth_data: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_non_null" + )] pub builder_pubkeys: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_canonical_quoted_u64" + )] pub max_execution_payment: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_canonical_quoted_u64" + )] pub min_bid: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_canonical_quoted_u64" + )] pub builder_boost_factor: Option>, } @@ -89,32 +146,18 @@ mod builder_config_tests { #[test] fn builder_config_uses_keymanager_json_encoding() { - let config = BuilderConfig { - min_bid: Some(Quoted { value: 3 }), - builder_boost_factor: Some(Quoted { value: 110 }), - builders: Some(vec![BuilderEntry { - url: "https://builder.example".parse().unwrap(), - auth_data: Some(RequestAuthData::new(vec![1, 2]).unwrap()), - builder_pubkeys: Some(vec![]), - max_execution_payment: Some(Quoted { value: 8 }), - min_bid: None, - builder_boost_factor: None, - }]), - }; - - let json = serde_json::to_value(&config).unwrap(); - assert_eq!(json["min_bid"], "3"); - assert_eq!(json["builder_boost_factor"], "110"); - assert_eq!(json["builders"][0]["auth_data"], "0x0102"); - assert_eq!(json["builders"][0]["max_execution_payment"], "8"); - assert_eq!( - json["builders"][0]["builder_pubkeys"], - serde_json::json!([]) - ); - assert_eq!( - serde_json::from_value::(json).unwrap(), - config - ); + let json = serde_json::json!({ + "min_bid": "3", + "builder_boost_factor": "110", + "builders": [{ + "url": "https://builder.example", + "auth_data": "0x0102", + "builder_pubkeys": [], + "max_execution_payment": "8" + }] + }); + let config = serde_json::from_value::(json.clone()).unwrap(); + assert_eq!(serde_json::to_value(config).unwrap(), json); assert_eq!( serde_json::to_value(BuilderConfig::default()).unwrap(), serde_json::json!({}) @@ -122,6 +165,16 @@ mod builder_config_tests { assert!( serde_json::from_value::(serde_json::json!({"min_bid": 3})).is_err() ); + for invalid in [ + serde_json::json!({"min_bid": null}), + serde_json::json!({"min_bid": "01"}), + serde_json::json!({"min_bid": "+1"}), + serde_json::json!({"builders": null}), + serde_json::json!({"builders": [{"url": "https://builder.example", "auth_data": null}]}), + serde_json::json!({"builders": [{"url": "https://builder.example", "builder_pubkeys": null}]}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } } } diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs index 4976a976cb5..3d977af9547 100644 --- a/validator_client/builder_store/src/builder_definitions.rs +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -1,7 +1,7 @@ use account_utils::write_file_via_temporary; use bls::PublicKeyBytes; use builder_types::{BuilderPubkeys, BuilderUrl, MAX_BUILDER_ENTRIES, RequestAuthData}; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use serde::{Deserialize, Deserializer, Serialize, de}; use std::collections::{BTreeMap, HashSet}; use std::fs::{File, create_dir_all}; use std::io; @@ -209,34 +209,23 @@ pub struct BuilderConfigFile { #[serde( default, skip_serializing_if = "BTreeMap::is_empty", - with = "serde_validator_configs" + deserialize_with = "deserialize_canonical_validator_configs" )] pub validator_configs: BTreeMap, } -mod serde_validator_configs { - use super::*; - - pub fn serialize( - configs: &BTreeMap, - serializer: S, - ) -> Result { - configs.serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - let configs = BTreeMap::::deserialize(deserializer)?; - let mut canonical = BTreeMap::new(); - for (key, config) in configs { - let public_key = PublicKeyBytes::from_str(&key).map_err(de::Error::custom)?; - if canonical.insert(public_key.to_string(), config).is_some() { - return Err(de::Error::custom("duplicate validator public key")); - } +fn deserialize_canonical_validator_configs<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let configs = BTreeMap::::deserialize(deserializer)?; + let mut canonical = BTreeMap::new(); + for (key, config) in configs { + let public_key = PublicKeyBytes::from_str(&key).map_err(de::Error::custom)?; + if canonical.insert(public_key.to_string(), config).is_some() { + return Err(de::Error::custom("duplicate validator public key")); } - Ok(canonical) } + Ok(canonical) } impl Default for BuilderConfigFile { diff --git a/validator_client/http_api/src/tests.rs b/validator_client/http_api/src/tests.rs index 8de26467e33..3a374b029a4 100644 --- a/validator_client/http_api/src/tests.rs +++ b/validator_client/http_api/src/tests.rs @@ -37,6 +37,7 @@ use task_executor::test_utils::TestRuntime; use tempfile::{TempDir, tempdir}; use types::graffiti::GraffitiString; use validator_store::ValidatorStore; +use warp::Reply; use zeroize::Zeroizing; const PASSWORD_BYTES: &[u8] = &[42, 50, 37]; @@ -1027,8 +1028,19 @@ async fn builder_configuration_tester() -> (ApiTester, PublicKeyBytes) { (tester, validator) } +fn builder_entry(url: &str) -> BuilderEntry { + BuilderEntry { + url: url.parse().unwrap(), + auth_data: None, + builder_pubkeys: None, + max_execution_payment: None, + min_bid: None, + builder_boost_factor: None, + } +} + #[tokio::test] -async fn validator_builder_configuration_endpoints() { +async fn builder_configuration_lifecycle() { let (tester, validator) = builder_configuration_tester().await; tester .configured_builders @@ -1046,12 +1058,10 @@ async fn validator_builder_configuration_endpoints() { let inherited = tester.client.get_builder_config(&validator).await.unwrap(); assert_eq!(inherited.min_bid.unwrap().value, 0); assert_eq!(inherited.builder_boost_factor.unwrap().value, 100); - assert_eq!(inherited.builders.as_ref().unwrap().len(), 1); + let inherited_builders = inherited.builders.as_ref().unwrap(); + assert_eq!(inherited_builders.len(), 1); assert_eq!( - inherited.builders.as_ref().unwrap()[0] - .max_execution_payment - .unwrap() - .value, + inherited_builders[0].max_execution_payment.unwrap().value, 7 ); @@ -1070,12 +1080,10 @@ async fn validator_builder_configuration_endpoints() { min_bid: Some(Quoted { value: 3 }), builder_boost_factor: Some(Quoted { value: 115 }), builders: Some(vec![BuilderEntry { - url: "https://builder.example".parse().unwrap(), auth_data: Some(RequestAuthData::new(b"validator-auth".to_vec()).unwrap()), builder_pubkeys: Some(vec![]), max_execution_payment: Some(Quoted { value: 8 }), - min_bid: None, - builder_boost_factor: None, + ..builder_entry("https://builder.example") }]), }; tester @@ -1083,20 +1091,13 @@ async fn validator_builder_configuration_endpoints() { .post_builder_config(&validator, &custom) .await .unwrap(); + let mut expected = custom.clone(); + let expected_builder = &mut expected.builders.as_mut().unwrap()[0]; + expected_builder.min_bid = custom.min_bid; + expected_builder.builder_boost_factor = custom.builder_boost_factor; assert_eq!( tester.client.get_builder_config(&validator).await.unwrap(), - BuilderConfig { - min_bid: Some(Quoted { value: 3 }), - builder_boost_factor: Some(Quoted { value: 115 }), - builders: Some(vec![BuilderEntry { - url: "https://builder.example".parse().unwrap(), - auth_data: Some(RequestAuthData::new(b"validator-auth".to_vec()).unwrap()), - builder_pubkeys: Some(vec![]), - max_execution_payment: Some(Quoted { value: 8 }), - min_bid: Some(Quoted { value: 3 }), - builder_boost_factor: Some(Quoted { value: 115 }), - }]), - } + expected ); let empty_list = BuilderConfig { @@ -1108,14 +1109,13 @@ async fn validator_builder_configuration_endpoints() { .post_builder_config(&validator, &empty_list) .await .unwrap(); + let resolved_empty = tester.client.get_builder_config(&validator).await.unwrap(); + assert_eq!(resolved_empty.min_bid, inherited.min_bid); assert_eq!( - tester.client.get_builder_config(&validator).await.unwrap(), - BuilderConfig { - min_bid: Some(Quoted { value: 0 }), - builder_boost_factor: Some(Quoted { value: 100 }), - builders: Some(vec![]), - } + resolved_empty.builder_boost_factor, + inherited.builder_boost_factor ); + assert_eq!(resolved_empty.builders, Some(vec![])); let delete = tester .client @@ -1149,12 +1149,9 @@ async fn builder_configuration_rejects_invalid_input() { let (tester, validator) = builder_configuration_tester().await; let invalid = BuilderConfig { builders: Some(vec![BuilderEntry { - url: "ftp://builder.example".parse().unwrap(), - auth_data: None, builder_pubkeys: Some(vec![]), max_execution_payment: Some(Quoted { value: 1 }), - min_bid: None, - builder_boost_factor: None, + ..builder_entry("ftp://builder.example") }]), ..Default::default() }; @@ -1168,12 +1165,10 @@ async fn builder_configuration_rejects_invalid_input() { let empty_auth = BuilderConfig { builders: Some(vec![BuilderEntry { - url: "https://builder.example".parse().unwrap(), auth_data: Some(RequestAuthData::default()), builder_pubkeys: Some(vec![]), max_execution_payment: Some(Quoted { value: 1 }), - min_bid: None, - builder_boost_factor: None, + ..builder_entry("https://builder.example") }]), ..Default::default() }; @@ -1186,14 +1181,7 @@ async fn builder_configuration_rejects_invalid_input() { ); let before = tester.client.get_builder_config(&validator).await.unwrap(); - let duplicate = BuilderEntry { - url: "https://duplicate-builder.example".parse().unwrap(), - auth_data: None, - builder_pubkeys: None, - max_execution_payment: None, - min_bid: None, - builder_boost_factor: None, - }; + let duplicate = builder_entry("https://duplicate-builder.example"); assert_server_error_code( tester .client @@ -1213,16 +1201,15 @@ async fn builder_configuration_rejects_invalid_input() { ); } -#[test] -fn builder_delete_errors_are_forbidden() { +#[tokio::test] +async fn builder_delete_errors_are_forbidden() { let rejection = crate::builder_store_delete_rejection(builder_store::Error::UnableToOpenFile( std::io::Error::other("test"), )); - assert!( - rejection - .find::() - .is_some() - ); + let reply = warp_utils::reject::handle_rejection(rejection) + .await + .unwrap(); + assert_eq!(reply.into_response().status(), StatusCode::FORBIDDEN); } fn assert_server_error_code(result: Result, expected: u16) { From 21ba0a492ca1d23585d82d2c75955d78c25452c0 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 05:10:13 +0000 Subject: [PATCH 08/14] Clarify builder uint64 deserializer name --- common/eth2/src/lighthouse_vc/std_types.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/common/eth2/src/lighthouse_vc/std_types.rs b/common/eth2/src/lighthouse_vc/std_types.rs index 1cf0f13bac6..a11adb3c28b 100644 --- a/common/eth2/src/lighthouse_vc/std_types.rs +++ b/common/eth2/src/lighthouse_vc/std_types.rs @@ -17,7 +17,7 @@ where T::deserialize(deserializer).map(Some) } -fn deserialize_canonical_quoted_u64<'de, D>( +fn deserialize_strict_uint64_string<'de, D>( deserializer: D, ) -> Result>, D::Error> where @@ -87,13 +87,13 @@ pub struct BuilderConfig { #[serde( default, skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_canonical_quoted_u64" + deserialize_with = "deserialize_strict_uint64_string" )] pub min_bid: Option>, #[serde( default, skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_canonical_quoted_u64" + deserialize_with = "deserialize_strict_uint64_string" )] pub builder_boost_factor: Option>, #[serde( @@ -123,19 +123,19 @@ pub struct BuilderEntry { #[serde( default, skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_canonical_quoted_u64" + deserialize_with = "deserialize_strict_uint64_string" )] pub max_execution_payment: Option>, #[serde( default, skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_canonical_quoted_u64" + deserialize_with = "deserialize_strict_uint64_string" )] pub min_bid: Option>, #[serde( default, skip_serializing_if = "Option::is_none", - deserialize_with = "deserialize_canonical_quoted_u64" + deserialize_with = "deserialize_strict_uint64_string" )] pub builder_boost_factor: Option>, } From 19f9f787739b6420215735b529ccf23a0babb5ca Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 05:12:37 +0000 Subject: [PATCH 09/14] Document strict keymanager uint64 parsing --- common/eth2/src/lighthouse_vc/std_types.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/eth2/src/lighthouse_vc/std_types.rs b/common/eth2/src/lighthouse_vc/std_types.rs index a11adb3c28b..2b2e3f2d875 100644 --- a/common/eth2/src/lighthouse_vc/std_types.rs +++ b/common/eth2/src/lighthouse_vc/std_types.rs @@ -17,6 +17,9 @@ where T::deserialize(deserializer).map(Some) } +/// Deserialize the keymanager `Uint64` format: a quoted decimal value without a sign or leading +/// zeros. `Quoted` accepts strings such as `"+1"` and `"01"`, which the API schema rejects. +/// The `Option` allows omission through `#[serde(default)]`; deserializing a `String` rejects null. fn deserialize_strict_uint64_string<'de, D>( deserializer: D, ) -> Result>, D::Error> From a5d33a7e47f70cf440b1e5c5c20770241b8cf806 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 05:30:43 +0000 Subject: [PATCH 10/14] Simplify builder configuration updates --- .../builder_store/src/builder_definitions.rs | 15 +- validator_client/builder_store/src/lib.rs | 88 ++++++---- .../http_api/src/builder_config.rs | 156 ++++++++++++++++++ validator_client/http_api/src/lib.rs | 148 ++--------------- validator_client/http_api/src/tests.rs | 12 -- 5 files changed, 237 insertions(+), 182 deletions(-) create mode 100644 validator_client/http_api/src/builder_config.rs diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs index 3d977af9547..7368886c154 100644 --- a/validator_client/builder_store/src/builder_definitions.rs +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -209,23 +209,26 @@ pub struct BuilderConfigFile { #[serde( default, skip_serializing_if = "BTreeMap::is_empty", - deserialize_with = "deserialize_canonical_validator_configs" + deserialize_with = "deserialize_validator_configs_and_standardize_pubkeys" )] pub validator_configs: BTreeMap, } -fn deserialize_canonical_validator_configs<'de, D: Deserializer<'de>>( +fn deserialize_validator_configs_and_standardize_pubkeys<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { let configs = BTreeMap::::deserialize(deserializer)?; - let mut canonical = BTreeMap::new(); + let mut standardized = BTreeMap::new(); for (key, config) in configs { let public_key = PublicKeyBytes::from_str(&key).map_err(de::Error::custom)?; - if canonical.insert(public_key.to_string(), config).is_some() { + if standardized + .insert(public_key.to_string(), config) + .is_some() + { return Err(de::Error::custom("duplicate validator public key")); } } - Ok(canonical) + Ok(standardized) } impl Default for BuilderConfigFile { @@ -496,7 +499,7 @@ mod tests { } #[test] - fn validator_config_keys_are_canonicalized() { + fn validator_config_pubkey_keys_use_standard_format() { let public_key = bls::Keypair::random().pk.compress(); let encoded = public_key.to_string(); let uppercase = format!("0x{}", encoded[2..].to_uppercase()); diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs index 2001e0ddb23..84b3015b260 100644 --- a/validator_client/builder_store/src/lib.rs +++ b/validator_client/builder_store/src/lib.rs @@ -7,7 +7,7 @@ pub use builder_definitions::{ use builder_types::{ BuilderConfig, BuilderEntry, BuilderPubkeys, RequestAuthData, SignedRequestAuth, }; -use parking_lot::{Mutex, RwLock}; +use parking_lot::{RwLock, RwLockUpgradableReadGuard}; use ssz_types::VariableList; use std::future::Future; use std::path::{Path, PathBuf}; @@ -17,7 +17,6 @@ use tracing::error; #[derive(Clone)] pub struct BuilderStore { config: Arc>, - update_lock: Arc>, validators_dir: PathBuf, } @@ -29,7 +28,6 @@ impl BuilderStore { config: Arc::new(RwLock::new(BuilderConfigFile::open_or_create( &validators_dir, )?)), - update_lock: Arc::new(Mutex::new(())), validators_dir, }) } @@ -136,16 +134,11 @@ impl BuilderStore { } pub fn insert(&self, builder: BuilderDefinition) -> Result<(), Error> { - let _update_guard = self.update_lock.lock(); - 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) + self.persist_update(|candidate| { + candidate.push(builder); + candidate.validate()?; + Ok(true) + }) } /// Return the fully resolved configuration for a validator without signing builder auth data. @@ -164,14 +157,12 @@ impl BuilderStore { ) -> Result<(), Error> { validator_config.validate()?; - let _update_guard = self.update_lock.lock(); - let mut candidate = self.config.read().clone(); - candidate - .validator_configs - .insert(validator_pubkey.to_string(), validator_config); - candidate.save(&self.validators_dir)?; - *self.config.write() = candidate; - Ok(()) + self.persist_update(|candidate| { + candidate + .validator_configs + .insert(validator_pubkey.to_string(), validator_config); + Ok(true) + }) } /// Remove a validator's override and persist the inherited global configuration atomically. @@ -179,17 +170,36 @@ impl BuilderStore { &self, validator_pubkey: &bls::PublicKeyBytes, ) -> Result<(), Error> { - let _update_guard = self.update_lock.lock(); - let mut candidate = self.config.read().clone(); - if candidate - .validator_configs - .remove(&validator_pubkey.to_string()) - .is_none() - { + self.persist_update(|candidate| { + Ok(candidate + .validator_configs + .remove(&validator_pubkey.to_string()) + .is_some()) + }) + } + + /// Apply and persist one update while ordinary readers continue using the current config. + /// + /// The update returns `false` when it made no change and no save is required. + /// The upgradable read guard serializes writers from snapshot through save. The brief upgrade + /// publishes only a successfully persisted candidate. The replaced config is dropped after the + /// write guard is released because it can contain a large per-validator map. + fn persist_update( + &self, + update: impl FnOnce(&mut BuilderConfigFile) -> Result, + ) -> Result<(), Error> { + let config = self.config.upgradable_read(); + let mut candidate = config.clone(); + if !update(&mut candidate)? { return Ok(()); } candidate.save(&self.validators_dir)?; - *self.config.write() = candidate; + + let previous = { + let mut config = RwLockUpgradableReadGuard::upgrade(config); + std::mem::replace(&mut *config, candidate) + }; + drop(previous); Ok(()) } } @@ -313,6 +323,26 @@ mod tests { assert!(!file.validator_configs.contains_key(&validator.to_string())); } + #[test] + fn failed_insert_is_not_published() { + let directory = tempdir().unwrap(); + let store = BuilderStore::open_or_create(directory.path()).unwrap(); + let validator = Keypair::random().pk.compress(); + std::fs::create_dir( + directory + .path() + .join(builder_definitions::BUILDERS_TEMP_FILENAME), + ) + .unwrap(); + + assert!( + store + .insert(global_builder("https://global-builder.example", 7)) + .is_err() + ); + assert!(store.validator_config(&validator).builders.is_empty()); + } + #[test] fn validator_updates_are_serialized_without_losing_each_other() { let directory = tempdir().unwrap(); diff --git a/validator_client/http_api/src/builder_config.rs b/validator_client/http_api/src/builder_config.rs new file mode 100644 index 00000000000..1d3e5b210fc --- /dev/null +++ b/validator_client/http_api/src/builder_config.rs @@ -0,0 +1,156 @@ +use bls::{PublicKey, PublicKeyBytes}; +use builder_store::{ + BuilderStore, ResolvedBuilderConfig, ValidatorBuilderConfig, ValidatorBuilderDefinition, +}; +use eth2::lighthouse_vc::types as api_types; +use lighthouse_validator_store::LighthouseValidatorStore; +use slot_clock::SlotClock; +use std::sync::Arc; +use types::EthSpec; + +pub fn get( + validator_pubkey: PublicKey, + validator_store: Arc>, + configured_builders: BuilderStore, +) -> Result { + let validator_pubkey = require_validator(&validator_pubkey, &validator_store)?; + Ok(into_api_builder_config( + configured_builders.validator_config(&validator_pubkey), + )) +} + +pub fn set( + validator_pubkey: PublicKey, + request: api_types::BuilderConfig, + validator_store: Arc>, + configured_builders: BuilderStore, +) -> Result<(), warp::Rejection> { + let validator_pubkey = require_validator(&validator_pubkey, &validator_store)?; + configured_builders + .set_validator_config(&validator_pubkey, into_store_builder_config(request)) + .map_err(builder_store_rejection) +} + +pub fn delete( + validator_pubkey: PublicKey, + validator_store: Arc>, + configured_builders: BuilderStore, +) -> Result<(), warp::Rejection> { + let validator_pubkey = require_validator(&validator_pubkey, &validator_store)?; + configured_builders + .delete_validator_config(&validator_pubkey) + .map_err(builder_store_delete_rejection) +} + +fn require_validator( + validator_pubkey: &PublicKey, + validator_store: &LighthouseValidatorStore, +) -> Result { + if validator_store + .initialized_validators() + .read() + .is_enabled(validator_pubkey) + .is_none() + { + return Err(warp_utils::reject::custom_not_found(format!( + "no validator found with pubkey {validator_pubkey:?}" + ))); + } + Ok(PublicKeyBytes::from(validator_pubkey)) +} + +fn into_store_builder_config(config: api_types::BuilderConfig) -> ValidatorBuilderConfig { + ValidatorBuilderConfig { + min_bid: config.min_bid.map(|value| value.value), + builder_boost_factor: config.builder_boost_factor.map(|value| value.value), + builders: config.builders.map(|builders| { + builders + .into_iter() + .map(|builder| ValidatorBuilderDefinition { + url: builder.url, + auth_data: builder.auth_data, + builder_pubkeys: builder.builder_pubkeys.unwrap_or_default(), + max_execution_payment: builder.max_execution_payment.map(|value| value.value), + min_bid: builder.min_bid.map(|value| value.value), + builder_boost_factor: builder.builder_boost_factor.map(|value| value.value), + }) + .collect() + }), + } +} + +fn into_api_builder_config(config: ResolvedBuilderConfig) -> api_types::BuilderConfig { + let ResolvedBuilderConfig { + min_bid, + builder_boost_factor, + builders, + } = config; + let builders = builders + .into_iter() + .map(|builder| api_types::BuilderEntry { + auth_data: Some( + builder + .auth_data + .unwrap_or_else(|| builder.url.to_default_auth_data()), + ), + builder_pubkeys: Some(builder.builder_pubkeys), + max_execution_payment: Some(api_types::Quoted { + value: builder.max_execution_payment, + }), + min_bid: Some(api_types::Quoted { + value: builder.min_bid.unwrap_or(min_bid), + }), + builder_boost_factor: Some(api_types::Quoted { + value: builder.builder_boost_factor.unwrap_or(builder_boost_factor), + }), + url: builder.url, + }) + .collect(); + + api_types::BuilderConfig { + min_bid: Some(api_types::Quoted { value: min_bid }), + builder_boost_factor: Some(api_types::Quoted { + value: builder_boost_factor, + }), + builders: Some(builders), + } +} + +fn builder_store_rejection(error: builder_store::Error) -> warp::Rejection { + let message = format!("builder configuration error: {error:?}"); + match error { + builder_store::Error::DuplicateBuilderAuth(_) + | builder_store::Error::InvalidBuilderUrl(_) + | builder_store::Error::UnsupportedUrlScheme(_) + | builder_store::Error::TooManyEnabledBuilders { .. } + | builder_store::Error::TooManyBuilderPubkeys(_) + | builder_store::Error::EmptyAuthData(_) => warp_utils::reject::custom_bad_request(message), + _ => warp_utils::reject::custom_server_error(message), + } +} + +fn builder_store_delete_rejection(error: builder_store::Error) -> warp::Rejection { + warp_utils::reject::custom_forbidden(format!( + "builder configuration could not be removed: {error:?}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use warp::Reply; + + #[tokio::test] + async fn delete_errors_are_forbidden() { + let rejection = builder_store_delete_rejection(builder_store::Error::UnableToOpenFile( + std::io::Error::other("test"), + )); + let reply = warp_utils::reject::handle_rejection(rejection) + .await + .unwrap(); + assert_eq!( + reply.into_response().status(), + warp::http::StatusCode::FORBIDDEN + ); + } +} diff --git a/validator_client/http_api/src/lib.rs b/validator_client/http_api/src/lib.rs index daedc7769e6..b5177f5918a 100644 --- a/validator_client/http_api/src/lib.rs +++ b/validator_client/http_api/src/lib.rs @@ -2,6 +2,7 @@ pub mod test_utils; mod api_secret; +mod builder_config; mod create_signed_voluntary_exit; mod create_validator; mod graffiti; @@ -19,7 +20,6 @@ use axum::Router; use axum_utils::server::Server; use beacon_node_fallback::CandidateInfo; use bls::{PublicKey, PublicKeyBytes}; -use builder_store::{ResolvedBuilderConfig, ValidatorBuilderConfig, ValidatorBuilderDefinition}; use core::convert::Infallible; use create_signed_voluntary_exit::create_signed_voluntary_exit; use create_validator::{ @@ -80,85 +80,6 @@ impl From for Error { } } -fn into_store_builder_config(config: api_types::BuilderConfig) -> ValidatorBuilderConfig { - ValidatorBuilderConfig { - min_bid: config.min_bid.map(|value| value.value), - builder_boost_factor: config.builder_boost_factor.map(|value| value.value), - builders: config.builders.map(|builders| { - builders - .into_iter() - .map(|builder| ValidatorBuilderDefinition { - url: builder.url, - auth_data: builder.auth_data, - builder_pubkeys: builder.builder_pubkeys.unwrap_or_default(), - max_execution_payment: builder.max_execution_payment.map(|value| value.value), - min_bid: builder.min_bid.map(|value| value.value), - builder_boost_factor: builder.builder_boost_factor.map(|value| value.value), - }) - .collect() - }), - } -} - -fn into_api_builder_config(config: ResolvedBuilderConfig) -> api_types::BuilderConfig { - let ResolvedBuilderConfig { - min_bid, - builder_boost_factor, - builders, - } = config; - let builders = builders - .into_iter() - .map(|builder| { - let auth_data = Some( - builder - .auth_data - .unwrap_or_else(|| builder.url.to_default_auth_data()), - ); - api_types::BuilderEntry { - url: builder.url, - auth_data, - builder_pubkeys: Some(builder.builder_pubkeys), - max_execution_payment: Some(api_types::Quoted { - value: builder.max_execution_payment, - }), - min_bid: Some(api_types::Quoted { - value: builder.min_bid.unwrap_or(min_bid), - }), - builder_boost_factor: Some(api_types::Quoted { - value: builder.builder_boost_factor.unwrap_or(builder_boost_factor), - }), - } - }) - .collect(); - - api_types::BuilderConfig { - min_bid: Some(api_types::Quoted { value: min_bid }), - builder_boost_factor: Some(api_types::Quoted { - value: builder_boost_factor, - }), - builders: Some(builders), - } -} - -fn builder_store_rejection(error: builder_store::Error) -> warp::Rejection { - let message = format!("builder configuration error: {error:?}"); - match error { - builder_store::Error::DuplicateBuilderAuth(_) - | builder_store::Error::InvalidBuilderUrl(_) - | builder_store::Error::UnsupportedUrlScheme(_) - | builder_store::Error::TooManyEnabledBuilders { .. } - | builder_store::Error::TooManyBuilderPubkeys(_) - | builder_store::Error::EmptyAuthData(_) => warp_utils::reject::custom_bad_request(message), - _ => warp_utils::reject::custom_server_error(message), - } -} - -fn builder_store_delete_rejection(error: builder_store::Error) -> warp::Rejection { - warp_utils::reject::custom_forbidden(format!( - "builder configuration could not be removed: {error:?}" - )) -} - /// A wrapper around all the items required to spawn the HTTP server. /// /// The server will gracefully handle the case where any fields are `None`. @@ -1119,22 +1040,8 @@ pub async fn serve( validator_store: Arc>, configured_builders: builder_store::BuilderStore| { blocking_json_task(move || { - if validator_store - .initialized_validators() - .read() - .is_enabled(&validator_pubkey) - .is_none() - { - return Err(warp_utils::reject::custom_not_found(format!( - "no validator found with pubkey {:?}", - validator_pubkey - ))); - } - - Ok(GenericResponse::from(into_api_builder_config( - configured_builders - .validator_config(&PublicKeyBytes::from(&validator_pubkey)), - ))) + builder_config::get(validator_pubkey, validator_store, configured_builders) + .map(GenericResponse::from) }) }, ); @@ -1154,30 +1061,15 @@ pub async fn serve( validator_store: Arc>, configured_builders: builder_store::BuilderStore| { blocking_response_task(move || { - if validator_store - .initialized_validators() - .read() - .is_enabled(&validator_pubkey) - .is_none() - { - return Err(warp_utils::reject::custom_not_found(format!( - "no validator found with pubkey {:?}", - validator_pubkey - ))); - } - - configured_builders - .set_validator_config( - &PublicKeyBytes::from(&validator_pubkey), - into_store_builder_config(request), - ) - .map(|_| { - warp::reply::with_status( - warp::reply(), - warp::http::StatusCode::ACCEPTED, - ) - }) - .map_err(builder_store_rejection) + builder_config::set( + validator_pubkey, + request, + validator_store, + configured_builders, + ) + .map(|_| { + warp::reply::with_status(warp::reply(), warp::http::StatusCode::ACCEPTED) + }) }) }, ); @@ -1195,27 +1087,13 @@ pub async fn serve( validator_store: Arc>, configured_builders: builder_store::BuilderStore| { blocking_response_task(move || { - if validator_store - .initialized_validators() - .read() - .is_enabled(&validator_pubkey) - .is_none() - { - return Err(warp_utils::reject::custom_not_found(format!( - "no validator found with pubkey {:?}", - validator_pubkey - ))); - } - - configured_builders - .delete_validator_config(&PublicKeyBytes::from(&validator_pubkey)) + builder_config::delete(validator_pubkey, validator_store, configured_builders) .map(|_| { warp::reply::with_status( warp::reply(), warp::http::StatusCode::NO_CONTENT, ) }) - .map_err(builder_store_delete_rejection) }) }, ); diff --git a/validator_client/http_api/src/tests.rs b/validator_client/http_api/src/tests.rs index 3a374b029a4..c627626a9b7 100644 --- a/validator_client/http_api/src/tests.rs +++ b/validator_client/http_api/src/tests.rs @@ -37,7 +37,6 @@ use task_executor::test_utils::TestRuntime; use tempfile::{TempDir, tempdir}; use types::graffiti::GraffitiString; use validator_store::ValidatorStore; -use warp::Reply; use zeroize::Zeroizing; const PASSWORD_BYTES: &[u8] = &[42, 50, 37]; @@ -1201,17 +1200,6 @@ async fn builder_configuration_rejects_invalid_input() { ); } -#[tokio::test] -async fn builder_delete_errors_are_forbidden() { - let rejection = crate::builder_store_delete_rejection(builder_store::Error::UnableToOpenFile( - std::io::Error::other("test"), - )); - let reply = warp_utils::reject::handle_rejection(rejection) - .await - .unwrap(); - assert_eq!(reply.into_response().status(), StatusCode::FORBIDDEN); -} - fn assert_server_error_code(result: Result, expected: u16) { match result { Err(ApiError::ServerMessage(ApiErrorMessage { code, .. })) if code == expected => (), From 302d8d72e431c7a869037905b0ae5d87f3412e46 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 05:46:51 +0000 Subject: [PATCH 11/14] Simplify builder configuration publication --- validator_client/builder_store/src/lib.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs index 84b3015b260..5de5e4a343c 100644 --- a/validator_client/builder_store/src/lib.rs +++ b/validator_client/builder_store/src/lib.rs @@ -182,8 +182,7 @@ impl BuilderStore { /// /// The update returns `false` when it made no change and no save is required. /// The upgradable read guard serializes writers from snapshot through save. The brief upgrade - /// publishes only a successfully persisted candidate. The replaced config is dropped after the - /// write guard is released because it can contain a large per-validator map. + /// publishes only a successfully persisted candidate. fn persist_update( &self, update: impl FnOnce(&mut BuilderConfigFile) -> Result, @@ -194,12 +193,7 @@ impl BuilderStore { return Ok(()); } candidate.save(&self.validators_dir)?; - - let previous = { - let mut config = RwLockUpgradableReadGuard::upgrade(config); - std::mem::replace(&mut *config, candidate) - }; - drop(previous); + *RwLockUpgradableReadGuard::upgrade(config) = candidate; Ok(()) } } From 023488e3e0f78ae49c71f7d3345d692a794a9275 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 19 Aug 2026 05:58:33 +0000 Subject: [PATCH 12/14] Clarify builder configuration updates --- book/src/gloas_builder_config.md | 3 +++ validator_client/builder_store/src/lib.rs | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/book/src/gloas_builder_config.md b/book/src/gloas_builder_config.md index 605ffdf1f73..42648ea0e46 100644 --- a/book/src/gloas_builder_config.md +++ b/book/src/gloas_builder_config.md @@ -14,6 +14,9 @@ The validator client reads its external-builder settings from a YAML file named - **Per-validator configurations** under `validator_configs`, managed through the standard keymanager API. Each map key is a validator public key. +Use `GET`, `POST`, and `DELETE` at `/eth/v1/validator/{pubkey}/builder_config`. `GET` returns the +configuration in use. `POST` replaces the stored configuration. `DELETE` restores global inheritance. + ## Example ```yaml diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs index 5de5e4a343c..3fd0e58bea6 100644 --- a/validator_client/builder_store/src/lib.rs +++ b/validator_client/builder_store/src/lib.rs @@ -189,7 +189,8 @@ impl BuilderStore { ) -> Result<(), Error> { let config = self.config.upgradable_read(); let mut candidate = config.clone(); - if !update(&mut candidate)? { + let changed = update(&mut candidate)?; + if !changed { return Ok(()); } candidate.save(&self.validators_dir)?; From 7de5c200f94e1fc99b1fdf6e6461218c7d638aa5 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Thu, 27 Aug 2026 04:16:35 +0000 Subject: [PATCH 13/14] Fix inherited builder configuration precedence --- .../builder_store/src/builder_definitions.rs | 30 ++++++++++------ validator_client/builder_store/src/lib.rs | 35 ++++++++++--------- .../http_api/src/builder_config.rs | 2 +- 3 files changed, 39 insertions(+), 28 deletions(-) diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs index 7368886c154..459bd1e8de5 100644 --- a/validator_client/builder_store/src/builder_definitions.rs +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -156,7 +156,7 @@ pub struct ResolvedBuilderConfig { } impl ValidatorBuilderConfig { - pub(crate) fn validate(&self) -> Result<(), Error> { + pub(crate) fn validate_builder_entries(&self) -> Result<(), Error> { let Some(builders) = &self.builders else { return Ok(()); }; @@ -317,7 +317,7 @@ impl BuilderConfigFile { } for config in self.validator_configs.values() { - config.validate()?; + config.validate_builder_entries()?; } Ok(()) @@ -326,12 +326,12 @@ impl BuilderConfigFile { /// Resolve the configuration that applies to `validator_pubkey`. pub fn resolved_for(&self, validator_pubkey: &PublicKeyBytes) -> ResolvedBuilderConfig { let validator_config = self.validator_configs.get(&validator_pubkey.to_string()); - let min_bid = validator_config - .and_then(|config| config.min_bid) - .unwrap_or(self.min_bid); - let builder_boost_factor = validator_config - .and_then(|config| config.builder_boost_factor) - .unwrap_or(self.builder_boost_factor); + let validator_min_bid = validator_config.and_then(|config| config.min_bid); + let validator_builder_boost_factor = + validator_config.and_then(|config| config.builder_boost_factor); + let min_bid = validator_min_bid.unwrap_or(self.min_bid); + let builder_boost_factor = + validator_builder_boost_factor.unwrap_or(self.builder_boost_factor); let builders = match validator_config.and_then(|config| config.builders.as_ref()) { Some(builders) => builders @@ -353,9 +353,17 @@ impl BuilderConfigFile { }) .map(|builder| { let mut builder = builder.clone(); - builder.min_bid = Some(builder.min_bid.unwrap_or(min_bid)); - builder.builder_boost_factor = - Some(builder.builder_boost_factor.unwrap_or(builder_boost_factor)); + // Validator defaults override values on inherited global builders. + builder.min_bid = Some( + validator_min_bid + .or(builder.min_bid) + .unwrap_or(self.min_bid), + ); + builder.builder_boost_factor = Some( + validator_builder_boost_factor + .or(builder.builder_boost_factor) + .unwrap_or(self.builder_boost_factor), + ); builder }) .collect(), diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs index 3fd0e58bea6..9409260a1ff 100644 --- a/validator_client/builder_store/src/lib.rs +++ b/validator_client/builder_store/src/lib.rs @@ -142,7 +142,7 @@ impl BuilderStore { } /// Return the fully resolved configuration for a validator without signing builder auth data. - pub fn validator_config( + pub fn get_validator_config( &self, validator_pubkey: &bls::PublicKeyBytes, ) -> ResolvedBuilderConfig { @@ -155,7 +155,7 @@ impl BuilderStore { validator_pubkey: &bls::PublicKeyBytes, validator_config: ValidatorBuilderConfig, ) -> Result<(), Error> { - validator_config.validate()?; + validator_config.validate_builder_entries()?; self.persist_update(|candidate| { candidate @@ -246,9 +246,10 @@ mod tests { let store = BuilderStore::open_or_create(directory.path()).unwrap(); let validator = Keypair::random().pk.compress(); - store - .insert(global_builder("https://global-builder.example", 7)) - .unwrap(); + let mut configured_global_builder = global_builder("https://global-builder.example", 7); + configured_global_builder.min_bid = Some(100); + configured_global_builder.builder_boost_factor = Some(200); + store.insert(configured_global_builder).unwrap(); let mut empty_auth = global_builder("https://empty-auth.example", 7); empty_auth.auth_data = Some(RequestAuthData::default()); store.insert(empty_auth).unwrap(); @@ -267,7 +268,7 @@ mod tests { ) .unwrap(); - let inherited = store.validator_config(&validator); + let inherited = store.get_validator_config(&validator); assert_eq!(inherited.min_bid, 5); assert_eq!(inherited.builder_boost_factor, 125); assert_eq!(inherited.builders.len(), 1); @@ -285,13 +286,15 @@ mod tests { }, ) .unwrap(); - assert!(store.validator_config(&validator).builders.is_empty()); + assert!(store.get_validator_config(&validator).builders.is_empty()); store.delete_validator_config(&validator).unwrap(); - let restored = store.validator_config(&validator); + let restored = store.get_validator_config(&validator); assert_eq!(restored.min_bid, 0); assert_eq!(restored.builder_boost_factor, 100); assert_eq!(restored.builders.len(), 1); + assert_eq!(restored.builders[0].min_bid, Some(100)); + assert_eq!(restored.builders[0].builder_boost_factor, Some(200)); } #[test] @@ -309,8 +312,8 @@ mod tests { let restarted = BuilderStore::open_or_create(directory.path()).unwrap(); assert_eq!( - restarted.validator_config(&validator), - store.validator_config(&validator) + restarted.get_validator_config(&validator), + store.get_validator_config(&validator) ); restarted.delete_validator_config(&validator).unwrap(); @@ -335,7 +338,7 @@ mod tests { .insert(global_builder("https://global-builder.example", 7)) .is_err() ); - assert!(store.validator_config(&validator).builders.is_empty()); + assert!(store.get_validator_config(&validator).builders.is_empty()); } #[test] @@ -360,12 +363,12 @@ mod tests { for handle in handles { handle.join().unwrap().unwrap(); } - assert_eq!(store.validator_config(&first).min_bid, 11); - assert_eq!(store.validator_config(&second).min_bid, 22); + assert_eq!(store.get_validator_config(&first).min_bid, 11); + assert_eq!(store.get_validator_config(&second).min_bid, 22); let restarted = BuilderStore::open_or_create(directory.path()).unwrap(); - assert_eq!(restarted.validator_config(&first).min_bid, 11); - assert_eq!(restarted.validator_config(&second).min_bid, 22); + assert_eq!(restarted.get_validator_config(&first).min_bid, 11); + assert_eq!(restarted.get_validator_config(&second).min_bid, 22); } #[test] @@ -434,7 +437,7 @@ mod tests { ) .unwrap(); - let resolved = store.validator_config(&validator); + let resolved = store.get_validator_config(&validator); assert_eq!(resolved.builders[0].max_execution_payment, 9); assert_eq!(resolved.builders[1].max_execution_payment, 0); } diff --git a/validator_client/http_api/src/builder_config.rs b/validator_client/http_api/src/builder_config.rs index 1d3e5b210fc..fff37faf57b 100644 --- a/validator_client/http_api/src/builder_config.rs +++ b/validator_client/http_api/src/builder_config.rs @@ -15,7 +15,7 @@ pub fn get( ) -> Result { let validator_pubkey = require_validator(&validator_pubkey, &validator_store)?; Ok(into_api_builder_config( - configured_builders.validator_config(&validator_pubkey), + configured_builders.get_validator_config(&validator_pubkey), )) } From 64ea090490d0a5858fb6715ca343d5b5fd382a34 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Thu, 27 Aug 2026 05:08:33 +0000 Subject: [PATCH 14/14] Restore concise builder config validation name --- validator_client/builder_store/src/builder_definitions.rs | 4 ++-- validator_client/builder_store/src/lib.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/validator_client/builder_store/src/builder_definitions.rs b/validator_client/builder_store/src/builder_definitions.rs index 459bd1e8de5..7709f24f03a 100644 --- a/validator_client/builder_store/src/builder_definitions.rs +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -156,7 +156,7 @@ pub struct ResolvedBuilderConfig { } impl ValidatorBuilderConfig { - pub(crate) fn validate_builder_entries(&self) -> Result<(), Error> { + pub(crate) fn validate(&self) -> Result<(), Error> { let Some(builders) = &self.builders else { return Ok(()); }; @@ -317,7 +317,7 @@ impl BuilderConfigFile { } for config in self.validator_configs.values() { - config.validate_builder_entries()?; + config.validate()?; } Ok(()) diff --git a/validator_client/builder_store/src/lib.rs b/validator_client/builder_store/src/lib.rs index 9409260a1ff..383b3703bad 100644 --- a/validator_client/builder_store/src/lib.rs +++ b/validator_client/builder_store/src/lib.rs @@ -155,7 +155,7 @@ impl BuilderStore { validator_pubkey: &bls::PublicKeyBytes, validator_config: ValidatorBuilderConfig, ) -> Result<(), Error> { - validator_config.validate_builder_entries()?; + validator_config.validate()?; self.persist_update(|candidate| { candidate