diff --git a/Cargo.lock b/Cargo.lock index 24f0275a6d4..a75a22e2764 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,7 @@ dependencies = [ "beacon_chain", "bitvec", "bls", + "builder_client", "criterion", "educe", "eth2", @@ -1693,16 +1694,60 @@ version = "0.1.0" dependencies = [ "arbitrary", "bls", + "builder_types", "context_deserialize", "eth2", "ethereum_ssz", + "futures", "lighthouse_version", "mockito", + "parking_lot", + "pretty_reqwest_error", "reqwest", "sensitive_url", "serde", "serde_json", "tokio", + "tracing", + "types", +] + +[[package]] +name = "builder_store" +version = "0.1.0" +dependencies = [ + "account_utils", + "bls", + "builder_types", + "filesystem", + "hex", + "parking_lot", + "serde", + "ssz_types", + "tempfile", + "tracing", + "types", + "yaml_serde", +] + +[[package]] +name = "builder_types" +version = "0.1.0" +dependencies = [ + "arbitrary", + "bls", + "builder_types", + "context_deserialize", + "ethereum_serde_utils", + "ethereum_ssz", + "ethereum_ssz_derive", + "sensitive_url", + "serde", + "serde_json", + "ssz_types", + "tree_hash", + "tree_hash_derive", + "typenum", "types", ] @@ -1989,6 +2034,7 @@ version = "0.2.0" dependencies = [ "beacon_chain", "beacon_processor", + "builder_client", "directory", "dirs", "environment", @@ -3215,6 +3261,7 @@ version = "0.1.0" dependencies = [ "arbitrary", "bls", + "builder_types", "context_deserialize", "educe", "eip_3076", @@ -5635,6 +5682,7 @@ dependencies = [ "account_utils", "beacon_node_fallback", "bls", + "builder_types", "doppelganger_service", "either", "environment", @@ -8394,6 +8442,7 @@ name = "signing_method" version = "0.1.0" dependencies = [ "bls", + "builder_types", "eth2_keystore", "ethereum_serde_utils", "lockfile", @@ -9758,6 +9807,7 @@ version = "8.2.1" dependencies = [ "account_utils", "beacon_node_fallback", + "builder_store", "clap", "clap_utils", "directory", @@ -9923,6 +9973,8 @@ version = "0.1.0" dependencies = [ "beacon_node_fallback", "bls", + "builder_store", + "builder_types", "either", "eth2", "futures", @@ -9947,6 +9999,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 0448b2771ee..a97d8b74854 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "boot_node", "common/account_utils", "common/axum_utils", + "common/builder_types", "common/clap_utils", "common/deposit_contract", "common/directory", @@ -76,6 +77,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", @@ -117,6 +119,9 @@ beacon_processor = { path = "beacon_node/beacon_processor" } 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" cargo_metadata = "0.19" diff --git a/beacon_node/beacon_chain/Cargo.toml b/beacon_node/beacon_chain/Cargo.toml index a3ac4804960..634f2060fbb 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 a0e3ac4ae2f..74df148f27e 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -88,6 +88,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, @@ -450,6 +451,9 @@ pub struct BeaconChain { Mutex>, /// Interfaces with the execution client. pub execution_layer: 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..5815ad05e96 --- /dev/null +++ b/beacon_node/beacon_chain/src/block_production/bid_selection.rs @@ -0,0 +1,540 @@ +//! 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 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: String, + 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` "always prefers" the builder. `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: String, + ) -> 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` just multiplies through), 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. + pub fn builder_url(&self) -> Option<&str> { + 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 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.to_string(), + ) + } + + 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.to_string(), + ) + } + + 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.to_string(), + ); + 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.to_string(), + ); + 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 28326bbec7d..7a4ab8d6d6a 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -32,13 +32,20 @@ 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 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::pending_payload_envelopes::PendingEnvelopeData; use crate::{ BeaconChain, BeaconChainError, BeaconChainTypes, BlockProductionError, @@ -67,6 +74,8 @@ type BlockProductionResult = ( ConsensusBlockValue, ExecutionPayloadValue, Option>, + // The winning builder's URL when a direct builder won, for the `Eth-Builder-Url` response header. + Option, ); pub type PreparePayloadResult = Result, BlockProductionError>; @@ -89,18 +98,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. @@ -109,16 +111,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, @@ -126,7 +118,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); @@ -162,7 +154,7 @@ impl BeaconChain { randao_reveal, graffiti_settings, verification, - builder_boost_factor, + builder_config, ) .await } @@ -179,8 +171,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) @@ -237,23 +235,90 @@ 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, + }; + + // 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) // @@ -571,27 +636,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().map(str::to_owned); + 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, @@ -770,30 +844,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, @@ -829,27 +905,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, @@ -903,7 +961,6 @@ impl BeaconChain { message: bid, signature: Signature::infinity().map_err(BlockProductionError::BlsError)?, }, - state, LocalBuildResult { payload_data, payload_value, @@ -912,110 +969,173 @@ 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, - local_signed_bid.message.parent_block_hash, - local_signed_bid.message.parent_block_root, - ); - 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, + ctx.parent_hash, + 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_pubkey| { + 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_pubkey, + &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.expose_full().to_string(), + ) + }) + .collect() } } @@ -1212,7 +1332,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; @@ -1349,123 +1469,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 39ac4cd0b70..640420417ce 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}; @@ -91,6 +92,7 @@ pub struct BeaconChainBuilder { >, op_pool: Option>, execution_layer: Option>, + builders: Option>, event_handler: Option>, slot_clock: Option, shutdown_sender: Option>, @@ -133,6 +135,7 @@ where fork_choice: None, op_pool: None, execution_layer: None, + builders: None, event_handler: None, slot_clock: None, shutdown_sender: None, @@ -626,6 +629,12 @@ where self } + /// Sets the `BeaconChain` builder service (the Gloas Builder API client and bid cache). + 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; @@ -1016,6 +1025,7 @@ where observed_attester_slashings: <_>::default(), observed_bls_to_execution_changes: <_>::default(), execution_layer: self.execution_layer.clone(), + 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 a37d6703f9f..2e7ab9eb952 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -294,6 +294,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..3276d53f53b --- /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 bls::PublicKeyBytes; +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_pubkey: Option, + 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 an expected builder, the bid must come from it: the + // builder at `bid.builder_index` must have that pubkey (the `builder_pubkey` response filter + // from beacon-APIs #630). + if let Some(expected) = expected_builder_pubkey { + let actual = state + .get_builder(bid.builder_index) + .map_err(|_| PayloadBidError::InvalidBuilder { + builder_index: bid.builder_index, + })? + .pubkey; + if actual != expected { + 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, + None, + &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, + None, + &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, + None, + &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, + None, + &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 1e2f779163a..edac807ad90 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 @@ -19,6 +19,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, @@ -32,14 +35,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); } @@ -54,9 +49,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 })?; @@ -64,7 +73,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, @@ -72,7 +81,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, @@ -111,6 +120,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_index(&bid_slot, signed_bid.message.builder_index) @@ -176,9 +193,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 }); } @@ -235,10 +255,7 @@ impl GossipVerifiedPayloadBid { let gossip_verified_bid = GossipVerifiedPayloadBid { signed_bid }; ctx.gossip_verified_payload_bid_cache - .insert_seen_builder(&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) } @@ -410,23 +427,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 a5453d0c5bb..eea9ebb99c2 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's parent block root is known but not on the canonical chain. ParentBlockRootNotCanonical { 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 different builder than the requesting entry's `builder_pubkey`. + 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 22e21bd57e9..11f8fd113d1 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,86 +1,121 @@ 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, }; use types::{BuilderIndex, EthSpec, ExecutionBlockHash, Hash256, SignedExecutionPayloadBid, Slot}; +/// The highest-value bid seen per `(slot, parent_block_hash, parent_block_root)` tuple. +/// +/// Keyed first by `Slot` (in a `BTreeMap` so that stale slots can be pruned cheaply via +/// `split_off`), then by the `(parent_block_hash, parent_block_root)` of the block 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, parent_block_hash, parent_block_root)` tuple. + highest_bid: HighestBidMap, + /// The set of builders from which we have already accepted a gossip-verified bid, per slot. + /// + /// Used to enforce one bid per builder per slot. + seen_builders: BTreeMap>, +} + +/// A cache of gossip-verified payload bids. +/// +/// Tracks, per slot, the highest-value bid observed for each parent block and the set of builders +/// 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: 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: RwLock::new(BTreeMap::new()), + inner: RwLock::new(GossipBidCacheInner::default()), } } -} -impl GossipVerifiedPayloadBidCache { - /// Get the cached bid for the tuple `(slot, parent_block_hash, parent_block_root)`. + /// Get the highest-value cached bid for the tuple `(slot, parent_block_hash, + /// parent_block_root)`, if one exists. pub fn get_highest_bid( &self, slot: Slot, parent_block_hash: ExecutionBlockHash, parent_block_root: Hash256, ) -> Option>> { - self.highest_bid.read().get(&slot).and_then(|map| { + self.inner.read().highest_bid.get(&slot).and_then(|map| { map.get(&(parent_block_hash, parent_block_root)) .map(|b| b.signed_bid.clone()) }) } - /// Insert a bid for the tuple `(slot, parent_block_hash, parent_block_root)` only if - /// its value is higher than the currently cached bid for that tuple. - pub fn insert_highest_bid(&self, bid: GossipVerifiedPayloadBid) { + /// Record a gossip-verified `bid` in the cache. + /// + /// This always marks the bid's builder as seen for the bid's slot (see + /// [`seen_builder_index`](Self::seen_builder_index)). Additionally, if the bid has a strictly + /// higher value than the currently cached bid for its `(slot, parent_block_hash, + /// parent_block_root)` tuple (or no bid is cached yet), it replaces the cached bid. + /// + /// Returns `true` if the bid became the new highest bid for its tuple, 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 mut inner = self.inner.write(); + inner + .seen_builders + .entry(slot) + .or_default() + .insert(bid.signed_bid.message.builder_index); + let key = ( bid.signed_bid.message.parent_block_hash, bid.signed_bid.message.parent_block_root, ); - let mut highest_bid = self.highest_bid.write(); - let slot_map = highest_bid.entry(bid.signed_bid.message.slot).or_default(); - 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 at `slot` + /// Returns `true` if a gossip-verified bid from `builder_index` has already been seen for + /// `slot`. pub fn seen_builder_index(&self, slot: &Slot, builder_index: BuilderIndex) -> bool { - self.seen_builder + self.inner .read() + .seen_builders .get(slot) .is_some_and(|seen_builders| seen_builders.contains(&builder_index)) } - /// Insert a builder into the seen cache. - pub fn insert_seen_builder(&self, bid: &GossipVerifiedPayloadBid) { - let mut seen_builder = self.seen_builder.write(); - seen_builder - .entry(bid.signed_bid.message.slot) - .or_default() - .insert(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 - .write() - .retain(|&slot, _| slot >= current_slot); + let mut inner = self.inner.write(); + inner.highest_bid = inner.highest_bid.split_off(¤t_slot); + inner.seen_builders = inner.seen_builders.split_off(¤t_slot); } } @@ -129,8 +164,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(&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 62da420f9d4..e8d647c4360 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::GossipVerifiedPayloadBidCache, }, proposer_preferences_verification::{ @@ -358,7 +360,7 @@ fn builder_already_seen_for_slot() { let verified = GossipVerifiedPayloadBid { signed_bid: bid.clone(), }; - ctx.bid_cache.insert_seen_builder(&verified); + ctx.bid_cache.observe_bid(verified); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!( @@ -383,7 +385,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); @@ -569,6 +571,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()) { @@ -817,7 +857,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 38dc5501a8e..31e8c0e1e08 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -625,7 +625,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, @@ -636,7 +636,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { randao_reveal, graffiti_settings, ProduceBlockVerification::VerifyRandao, - None, + eth2::types::BuilderConfig::empty(), ) .await .unwrap(); diff --git a/beacon_node/builder_client/Cargo.toml b/beacon_node/builder_client/Cargo.toml index a329379160f..e6facfb8c2d 100644 --- a/beacon_node/builder_client/Cargo.toml +++ b/beacon_node/builder_client/Cargo.toml @@ -9,14 +9,19 @@ bls = { workspace = true } context_deserialize = { workspace = true } eth2 = { workspace = true } ethereum_ssz = { workspace = true } +futures = { workspace = true } lighthouse_version = { workspace = true } +parking_lot = { workspace = true } +pretty_reqwest_error = { workspace = true } reqwest = { workspace = true } sensitive_url = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tracing = { workspace = true } [dev-dependencies] arbitrary = { workspace = true } +builder_types = { workspace = true, features = ["arbitrary"] } mockito = { workspace = true } tokio = { workspace = true } types = { workspace = true, features = ["arbitrary"] } diff --git a/beacon_node/builder_client/src/builder_http_client.rs b/beacon_node/builder_client/src/builder_http_client.rs new file mode 100644 index 00000000000..90afb054943 --- /dev/null +++ b/beacon_node/builder_client/src/builder_http_client.rs @@ -0,0 +1,422 @@ +use crate::{ + DEFAULT_USER_AGENT, Error, JSON_ACCEPT_VALUE, PREFERENCE_ACCEPT_VALUE, + content_type_from_header, ok_or_error, success_or_error, +}; +use bls::PublicKeyBytes; +use eth2::types::{ + BuilderPreferencesRequest, ContentType, EthSpec, ExecutionBlockHash, ForkVersionedResponse, + Hash256, SignedBeaconBlock, SignedExecutionPayloadBid, SignedRequestAuth, Slot, +}; +use eth2::{ + CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER, + SSZ_CONTENT_TYPE_HEADER, +}; +use reqwest::StatusCode; +use reqwest::header::{ACCEPT, HeaderMap, HeaderName, HeaderValue}; +use sensitive_url::SensitiveUrl; +use ssz::{Decode, Encode}; +use std::time::Duration; +use tracing::warn; + +/// This is a whole rabbithole.. see discussion: +/// https://discord.com/channels/595666850260713488/874767108809031740/1529125867484348577 +pub const DEFAULT_GET_EXECUTION_PAYLOAD_BID_TIMEOUT_MILLIS: u64 = 400; + +/// Default timeout for builder submit requests (preferences and signed block). +pub const DEFAULT_SUBMIT_TIMEOUT_MILLIS: u64 = 1000; + +/// Header advertising the proposer's request timeout (in milliseconds) to the builder. +const X_TIMEOUT_MS: HeaderName = HeaderName::from_static("x-timeout-ms"); +/// Header carrying the Unix send-time (in milliseconds) so the builder can measure latency. +const DATE_MILLISECONDS: HeaderName = HeaderName::from_static("date-milliseconds"); + +/// A client for the Gloas (ePBS) Builder API. +/// +/// This client is **not** bound to a single builder URL and holds **no** per-connection state: +/// every request takes the target `builder_url` as a parameter, so one instance can fan out to any +/// number of builders. SSZ negotiation is done per-request rather than cached, because in Gloas the +/// bid request and the signed-block submission are separated by a full VC round-trip +/// (produce -> sign -> publish) and so cannot share instance state. +#[derive(Clone)] +pub struct BuilderHttpClient { + client: reqwest::Client, + user_agent: String, + /// Only use json for all request/response types. + disable_ssz: bool, +} + +/// The successful response from a builder's `getExecutionPayloadBid` endpoint. +pub struct GloasBidResponse { + /// The signed bid returned by the builder. + pub bid: SignedExecutionPayloadBid, + /// Whether the builder served the bid encoded as SSZ. + /// + /// Carry this into the winning-builder provenance so the follow-up + /// [`submit_signed_beacon_block`](BuilderHttpClient::submit_signed_beacon_block) can reuse the + /// encoding the builder just demonstrated it supports, rather than probing again. + pub ssz_response: bool, +} + +impl BuilderHttpClient { + pub fn new(user_agent: Option, disable_ssz: bool) -> Result { + let user_agent = user_agent.unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); + let client = reqwest::Client::builder().user_agent(&user_agent).build()?; + Ok(Self { + client, + user_agent, + disable_ssz, + }) + } + + pub fn get_user_agent(&self) -> &str { + &self.user_agent + } + + /// Build the HTTP headers sent with a `getExecutionPayloadBid` request. + /// + /// Sets three headers: + /// - `Accept`: requests SSZ (with JSON fallback) for the response, or JSON only when + /// `disable_ssz` is set. This governs the (larger) bid response encoding only. + /// - `X-Timeout-Ms`: the proposer's request timeout, measured from `Date-Milliseconds`. The + /// builder must respond within this window; required by the builder spec. + /// - `Date-Milliseconds`: the Unix ms send time, letting the builder estimate transit delay; + /// required by the builder spec. + /// + /// The `Accept` header is best-effort (logged and skipped if it cannot be constructed). The two + /// required timing headers are built from a static timeout and the system clock, so their + /// construction cannot realistically fail. + fn compute_get_execution_payload_bid_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + + let accept_value = if self.disable_ssz { + JSON_ACCEPT_VALUE + } else { + PREFERENCE_ACCEPT_VALUE + }; + + match HeaderValue::from_str(accept_value) { + Ok(accept_header) => { + headers.insert(ACCEPT, accept_header); + } + Err(e) => { + warn!("Invalid accept value: {}", e); + } + } + + // Advertise our timeout to the builder so it can bound its own work. + headers.insert( + X_TIMEOUT_MS, + HeaderValue::from(DEFAULT_GET_EXECUTION_PAYLOAD_BID_TIMEOUT_MILLIS), + ); + + // Timestamp the request (Unix ms) so the builder can measure one-way latency. + match std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + { + Ok(now_millis) => { + headers.insert(DATE_MILLISECONDS, HeaderValue::from(now_millis)); + } + Err(e) => { + warn!("Failed to compute date header: {}", e); + } + } + + headers + } + + /// `POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}` + /// + /// Request a bid from a single builder. Returns `Ok(None)` if the builder has no bid available + /// (HTTP 204). + /// + /// The `SignedRequestAuth` body is required by the builder spec (a builder returns 400 if it + /// is missing). It is small and always sent as JSON; SSZ is only negotiated for the (larger) + /// response via the `Accept` header. The response's encoding is reported via + /// [`GloasBidResponse::ssz_response`]. + #[allow(clippy::too_many_arguments)] + pub async fn get_execution_payload_bid( + &self, + builder_url: &SensitiveUrl, + slot: Slot, + parent_hash: ExecutionBlockHash, + parent_root: Hash256, + proposer_pubkey: &PublicKeyBytes, + signed_request_auth: &SignedRequestAuth, + ) -> Result>, Error> { + let mut path = builder_url.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(builder_url.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("execution_payload_bid") + .push(slot.to_string().as_str()) + .push(format!("{parent_hash:?}").as_str()) + .push(format!("{parent_root:?}").as_str()) + .push(proposer_pubkey.as_hex_string().as_str()); + + let timeout = Duration::from_millis(DEFAULT_GET_EXECUTION_PAYLOAD_BID_TIMEOUT_MILLIS); + let headers = self.compute_get_execution_payload_bid_headers(); + // The auth body is tiny; always send it as JSON. SSZ-encoding it buys nothing and avoids + // having to probe the builder's SSZ request-ingest support. + let request = self + .client + .post(path) + .timeout(timeout) + .headers(headers) + .json(signed_request_auth); + + let response = ok_or_error(request.send().await.map_err(Error::from)?).await?; + + if response.status() == StatusCode::NO_CONTENT { + return Ok(None); + } + + let response_headers = response.headers().clone(); + let response_bytes = response.bytes().await?; + + match content_type_from_header(&response_headers) { + ContentType::Ssz => { + let bid = SignedExecutionPayloadBid::::from_ssz_bytes(&response_bytes) + .map_err(Error::InvalidSsz)?; + Ok(Some(GloasBidResponse { + bid, + ssz_response: true, + })) + } + ContentType::Json => { + let versioned: ForkVersionedResponse> = + serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson)?; + Ok(Some(GloasBidResponse { + bid: versioned.data, + ssz_response: false, + })) + } + } + } + + /// `POST /eth/v1/builder/builder_preferences/{validator_pubkey}` + /// + /// Submit a validator's builder preferences to a builder ahead of the bid request (typically in + /// the epoch before the proposal, so the builder has them before `getExecutionPayloadBid` + /// arrives). Success is HTTP 202. + /// + /// `BuilderPreferencesRequest` is not fork-versioned, so no `Eth-Consensus-Version` header is + /// required; the body is small and sent as JSON. + pub async fn submit_builder_preferences( + &self, + builder_url: &SensitiveUrl, + proposer_pubkey: &PublicKeyBytes, + preferences: &BuilderPreferencesRequest, + ) -> Result<(), Error> { + let mut path = builder_url.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(builder_url.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("builder_preferences") + .push(proposer_pubkey.as_hex_string().as_str()); + + let timeout = Duration::from_millis(DEFAULT_SUBMIT_TIMEOUT_MILLIS); + let request = self.client.post(path).timeout(timeout).json(preferences); + + let response = success_or_error(request.send().await.map_err(Error::from)?).await?; + + if response.status() == StatusCode::ACCEPTED { + Ok(()) + } else { + // ACCEPTED is the only valid status code response + Err(Error::StatusCode(response.status())) + } + } + + /// `POST /eth/v1/builder/beacon_blocks` + /// + /// Submit the signed Gloas beacon block to the builder that won selection. On success (HTTP + /// 202) the builder becomes responsible for publishing the execution payload envelope. + /// + /// `ssz_request` selects the request-body encoding; pass the + /// [`GloasBidResponse::ssz_response`] recorded when the winning bid was fetched. + pub async fn submit_signed_beacon_block( + &self, + builder_url: &SensitiveUrl, + block: &SignedBeaconBlock, + ssz_request: bool, + ) -> Result<(), Error> { + let mut path = builder_url.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(builder_url.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("beacon_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + let timeout = Duration::from_millis(DEFAULT_SUBMIT_TIMEOUT_MILLIS); + let request = if ssz_request && !self.disable_ssz { + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + self.client + .post(path) + .timeout(timeout) + .headers(headers) + .body(block.as_ssz_bytes()) + } else { + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + self.client + .post(path) + .timeout(timeout) + .headers(headers) + .json(block) + }; + + let response = success_or_error(request.send().await.map_err(Error::from)?).await?; + + if response.status() == StatusCode::ACCEPTED { + Ok(()) + } else { + // ACCEPTED is the only valid status code response + Err(Error::StatusCode(response.status())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arbitrary::Arbitrary; + use eth2::types::beacon_response::EmptyMetadata; + use eth2::types::{ForkName, MainnetEthSpec}; + use mockito::{Matcher, Server, ServerGuard}; + use std::str::FromStr; + + type E = MainnetEthSpec; + + fn client_for() -> BuilderHttpClient { + BuilderHttpClient::new(None, false).unwrap() + } + + fn builder_url(server: &ServerGuard) -> SensitiveUrl { + SensitiveUrl::from_str(&server.url()).unwrap() + } + + fn signed_request_auth() -> SignedRequestAuth { + let mut u = types::test_utils::test_unstructured(); + SignedRequestAuth::arbitrary(&mut u).unwrap() + } + + fn empty_bid_response() -> ForkVersionedResponse> { + ForkVersionedResponse { + version: ForkName::Gloas, + metadata: EmptyMetadata {}, + data: SignedExecutionPayloadBid::empty(), + } + } + + fn mock_bid(server: &mut ServerGuard, content_type: ContentType) { + let body = empty_bid_response(); + let mut mock = server.mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/execution_payload_bid/.+$".to_string()), + ); + mock = match content_type { + ContentType::Json => mock + .with_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) + .with_header(CONSENSUS_VERSION_HEADER, "gloas") + .with_body(serde_json::to_string(&body).unwrap()), + ContentType::Ssz => mock + .with_header(CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER) + .with_header(CONSENSUS_VERSION_HEADER, "gloas") + .with_body(body.data.as_ssz_bytes()), + }; + mock.with_status(200).create(); + } + + async fn request_bid(server: &ServerGuard) -> Option> { + client_for() + .get_execution_payload_bid::( + &builder_url(server), + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + Hash256::repeat_byte(2), + &PublicKeyBytes::empty(), + &signed_request_auth(), + ) + .await + .expect("bid request should succeed") + } + + #[tokio::test] + async fn get_execution_payload_bid_json() { + let mut server = Server::new_async().await; + mock_bid(&mut server, ContentType::Json); + let response = request_bid(&server).await.expect("should have a bid"); + assert!(!response.ssz_response); + assert_eq!(response.bid, SignedExecutionPayloadBid::empty()); + } + + #[tokio::test] + async fn get_execution_payload_bid_ssz() { + let mut server = Server::new_async().await; + mock_bid(&mut server, ContentType::Ssz); + let response = request_bid(&server).await.expect("should have a bid"); + assert!(response.ssz_response); + assert_eq!(response.bid, SignedExecutionPayloadBid::empty()); + } + + #[tokio::test] + async fn submit_builder_preferences_accepted() { + use arbitrary::Arbitrary; + let mut server = Server::new_async().await; + server + .mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/builder_preferences/.+$".to_string()), + ) + .with_status(202) + .create(); + + let mut u = types::test_utils::test_unstructured(); + let preferences = BuilderPreferencesRequest::arbitrary(&mut u).unwrap(); + + client_for() + .submit_builder_preferences( + &builder_url(&server), + &PublicKeyBytes::empty(), + &preferences, + ) + .await + .expect("preferences should be accepted"); + } + + #[tokio::test] + async fn get_execution_payload_bid_no_content() { + let mut server = Server::new_async().await; + server + .mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/execution_payload_bid/.+$".to_string()), + ) + .with_status(204) + .create(); + assert!(request_bid(&server).await.is_none()); + } +} diff --git a/beacon_node/builder_client/src/builders.rs b/beacon_node/builder_client/src/builders.rs new file mode 100644 index 00000000000..fc4e6daba03 --- /dev/null +++ b/beacon_node/builder_client/src/builders.rs @@ -0,0 +1,406 @@ +use crate::{BuilderHttpClient, Error as BuilderClientError, GloasBidResponse}; +use bls::PublicKeyBytes; +use eth2::types::{ + BuilderEntry, BuilderPreferenceEntry, BuilderPreferences, BuilderPreferencesRequest, EthSpec, + ExecutionBlockHash, Hash256, SignedBeaconBlock, SignedExecutionPayloadBid, Slot, +}; +use futures::future::join_all; +use sensitive_url::SensitiveUrl; +use std::fmt::Display; +use std::future::Future; +use std::sync::Arc; +use tracing::{debug, warn}; + +/// A validated direct builder bid, with the provenance and per-builder policy needed to turn it into +/// a selection candidate. +/// +/// The per-builder `min_bid` / `max_execution_payment` / `builder_boost_factor` are carried up as-is; +/// this crate applies no bid math (the `min_bid` floor and the boost are proposer policy resolved on +/// the beacon-chain side). +#[derive(Clone)] +pub struct DirectBid { + /// The signed bid returned by the builder. + pub signed_bid: Arc>, + /// URL of the builder that returned this bid, so a winning block can be forwarded to it via + /// `submitSignedBeaconBlock` (echoed to the beacon node as `Eth-Builder-Url`). + pub builder_url: SensitiveUrl, + /// The proposer's `max_execution_payment` cap for this builder, from its `BuilderEntry`. + pub max_execution_payment: u64, + /// The proposer's `builder_boost_factor` for this builder, from its `BuilderEntry`. + pub builder_boost_factor: u64, + /// The proposer's `min_bid` acceptance floor (gwei) for this builder, from its `BuilderEntry`. + pub min_bid: u64, +} + +/// The per-proposal parameters used to address each `getExecutionPayloadBid` request. +/// +/// Validation of returned bids is performed entirely by the caller's `validate` callback (which has +/// the beacon-chain state), so this only carries what's needed to build the request. +#[derive(Clone)] +pub struct BidRequestContext { + pub slot: Slot, + pub parent_hash: ExecutionBlockHash, + pub parent_root: Hash256, + pub proposer_pubkey: PublicKeyBytes, +} + +/// Orchestrates direct builder bid requests. +/// +/// Fans `getExecutionPayloadBid` out to the builders a proposer configured and returns the validated +/// bids for the block producer to rank against the local and gossip payloads. Stateless — it holds +/// no bids between requests. +pub struct Builders { + client: Arc, +} + +/// A single failed builder-preference submission, identified by its position in the submitted list. +pub struct SubmissionFailure { + /// Index of the failing entry in the submitted list. + pub index: usize, + /// Why the submission failed. + pub error: BuilderClientError, +} + +impl Builders { + pub fn new(client: Arc) -> Self { + Self { client } + } + + /// Forward a signed beacon block to the builder that won this slot's bid, via + /// `submitSignedBeaconBlock`. + /// + /// Submitted as JSON: the builder's SSZ preference from bid time isn't carried across the + /// `Eth-Builder-Url` header round-trip, and builders must accept JSON. + pub async fn forward_signed_block( + &self, + builder_url: &SensitiveUrl, + block: &SignedBeaconBlock, + ) -> Result<(), BuilderClientError> { + self.client + .submit_signed_beacon_block(builder_url, block, false) + .await + } + + /// Request bids from every builder in `entries` concurrently, validate them, and return the + /// valid ones. + /// + /// Every entry is a bid request to its `url`, which beacon-APIs #630 requires (a zero-length url + /// is invalid); an entry whose `url` is empty, malformed, or not http(s) can't be requested and + /// is skipped. One request is made **per entry** — several entries MAY share a `url` with + /// different `auth`, so requests are not de-duplicated by URL (#630 forbids two entries sharing + /// both a `url` and their `auth`'s `data`). + /// + /// Each builder runs in its own pipeline — request, then the producer-supplied `validate` + /// callback, which performs *all* bid validation against the block producer's advanced beacon + /// state (consensus consistency, builder eligibility, collateral, and the BLS signature). The + /// entry's expected `builder_pubkey` (`None` when the entry omits one) is passed to `validate` + /// so it can enforce that the bid is signed by the expected builder — the state and signing + /// domain that check needs live on the producer side, not here. The per-builder `min_bid` floor + /// is likewise a proposer policy applied by the caller (see `DirectBid::min_bid`), not here. These + /// pipelines run + /// **concurrently across builders**, so a slow builder or an expensive validation for one bid + /// does not hold up the others. A failure, timeout, empty (204) response, or validation error + /// for one builder is isolated: it is logged and that bid is skipped. + /// + /// Returns every bid that passed validation; the block producer turns each into a selection + /// candidate and ranks them. + pub async fn request_and_validate_bids( + &self, + ctx: &BidRequestContext, + entries: &[BuilderEntry], + validate: F, + ) -> Vec> + where + F: Fn(Arc>, Option) -> Fut, + Fut: Future>, + Err: Display, + { + // Resolve each entry to a `(resolved_url, entry)` target. Every entry must carry a valid url + // (#630); one that's empty, malformed, or non-http(s) can't be requested and is skipped. One + // request is made per entry (no URL de-duplication). + let mut targets = Vec::new(); + for entry in entries { + let url = match entry.url.to_sensitive_url() { + Ok(url) => url, + Err(e) => { + warn!(error = ?e, "Skipping builder entry with a malformed URL"); + continue; + } + }; + if !matches!(url.expose_full().scheme(), "http" | "https") { + warn!(url = ?url, "Skipping builder entry with an unsupported URL scheme"); + continue; + } + targets.push((url, entry)); + } + + // Run one pipeline per builder — request, then the producer's `validate` callback — and let + // them run concurrently across builders. Each request carries its own timeout, so a slow + // builder cannot delay the others. + let client = &self.client; + let validate = &validate; + let pipelines = targets.iter().map(|(url, entry)| async move { + let response = client + .get_execution_payload_bid::( + url, + ctx.slot, + ctx.parent_hash, + ctx.parent_root, + &ctx.proposer_pubkey, + &entry.auth, + ) + .await; + + match response { + Ok(Some(GloasBidResponse { + bid, + ssz_response: _, + })) => { + let direct_bid = DirectBid { + signed_bid: Arc::new(bid), + builder_url: url.clone(), + max_execution_payment: entry.max_execution_payment, + builder_boost_factor: entry.builder_boost_factor, + min_bid: entry.min_bid, + }; + + if let Err(error) = + validate(direct_bid.signed_bid.clone(), entry.builder_pubkey()).await + { + warn!(url = ?url, %error, "Builder bid failed validation"); + return None; + } + Some(direct_bid) + } + Ok(None) => { + debug!(url = ?url, "Builder returned no bid"); + None + } + Err(error) => { + warn!(url = ?url, error = %error, "Builder bid request failed"); + None + } + } + }); + + join_all(pipelines).await.into_iter().flatten().collect() + } + + /// Submit a proposer's builder preferences to each entry's builder, concurrently and + /// best-effort. + /// + /// One submission is made per entry — entries are **not** de-duplicated by URL, since + /// beacon-APIs #630 allows several entries to share a `url`. Each submission is isolated: a + /// malformed URL or a failed request is recorded against that entry's index and never aborts the + /// others. The submissions run **concurrently**, so a slow builder cannot delay the rest. + /// + /// Returns `Ok(())` when every entry was submitted, or the per-entry [`SubmissionFailure`]s by + /// index. + pub async fn submit_builder_preferences( + &self, + entries: Vec, + ) -> Result<(), Vec> { + let client = &self.client; + let submissions = entries + .into_iter() + .enumerate() + .map(|(index, entry)| async move { + let url = entry + .url + .to_sensitive_url() + .map_err(|e| SubmissionFailure { + index, + error: e.into(), + })?; + let request = BuilderPreferencesRequest::new( + BuilderPreferences { + max_execution_payment: entry.max_execution_payment, + }, + entry.auth, + ); + client + .submit_builder_preferences(&url, &entry.proposer_pubkey, &request) + .await + .map_err(|error| SubmissionFailure { index, error }) + }); + + let failures: Vec = join_all(submissions) + .await + .into_iter() + .filter_map(Result::err) + .collect(); + + if failures.is_empty() { + Ok(()) + } else { + Err(failures) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bls::Signature; + use eth2::types::beacon_response::EmptyMetadata; + use eth2::types::{ + ExecutionPayloadBid, ForkName, ForkVersionedResponse, MainnetEthSpec, RequestAuth, + RequestAuthData, SignedExecutionPayloadBid, SignedRequestAuth, + }; + use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER}; + use mockito::{Matcher, Mock, Server, ServerGuard}; + + type E = MainnetEthSpec; + + const BID_PATH: &str = r"^/eth/v1/builder/execution_payload_bid/.+$"; + + fn entry(url: &str, max_execution_payment: u64) -> BuilderEntry { + BuilderEntry { + url: url.parse().unwrap(), + auth: SignedRequestAuth { + message: RequestAuth { + data: RequestAuthData::default(), + slot: Slot::new(1), + }, + signature: Signature::empty(), + }, + builder_pubkey: PublicKeyBytes::empty(), + max_execution_payment, + min_bid: 0, + builder_boost_factor: 100, + } + } + + fn bid_body(value: u64) -> String { + let body = ForkVersionedResponse { + version: ForkName::Gloas, + metadata: EmptyMetadata {}, + data: SignedExecutionPayloadBid:: { + message: ExecutionPayloadBid { + slot: Slot::new(1), + parent_block_hash: ExecutionBlockHash::zero(), + parent_block_root: Hash256::ZERO, + value, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }, + }; + serde_json::to_string(&body).unwrap() + } + + fn mock_bid(server: &mut ServerGuard, value: u64) -> Mock { + server + .mock("POST", Matcher::Regex(BID_PATH.to_string())) + .with_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) + .with_header(CONSENSUS_VERSION_HEADER, "gloas") + .with_body(bid_body(value)) + .with_status(200) + .create() + } + + fn context() -> BidRequestContext { + BidRequestContext { + slot: Slot::new(1), + parent_hash: ExecutionBlockHash::zero(), + parent_root: Hash256::ZERO, + proposer_pubkey: PublicKeyBytes::empty(), + } + } + + fn builders() -> Builders { + Builders::new(Arc::new(BuilderHttpClient::new(None, false).unwrap())) + } + + #[tokio::test] + async fn fans_out_and_returns_all_valid_bids() { + let mut server_a = Server::new_async().await; + let mut server_b = Server::new_async().await; + mock_bid(&mut server_a, 100); + mock_bid(&mut server_b, 200); + + let builders = builders(); + let entries = vec![entry(&server_a.url(), 1000), entry(&server_b.url(), 1000)]; + + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Ok::<(), String>(()) + }) + .await; + let mut values: Vec = bids.iter().map(|b| b.signed_bid.message.value).collect(); + values.sort_unstable(); + assert_eq!(values, vec![100, 200]); + } + + #[tokio::test] + async fn skips_invalid_url_entry() { + let builders = builders(); + // #630 requires a url; an empty one is invalid and can't be requested, so it is skipped. + let entries = vec![entry("", 1000)]; + + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Ok::<(), String>(()) + }) + .await; + assert!(bids.is_empty()); + } + + #[tokio::test] + async fn requests_each_entry_even_when_url_is_shared() { + let mut server = Server::new_async().await; + // Two entries share a URL but carry different `auth`, so both are requested (one per entry). + let mock = mock_bid(&mut server, 100).expect(2); + + let builders = builders(); + let entry_a = entry(&server.url(), 1000); + let mut entry_b = entry(&server.url(), 1000); + entry_b.auth.message.slot = Slot::new(2); + let entries = vec![entry_a, entry_b]; + + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Ok::<(), String>(()) + }) + .await; + assert_eq!(bids.len(), 2); + mock.assert(); + } + + #[tokio::test] + async fn returns_bid_carrying_min_bid_for_the_caller() { + // The transport layer does not enforce the `min_bid` floor: it returns the bid carrying its + // entry's `min_bid` for the beacon-chain-side caller to enforce. + let mut server = Server::new_async().await; + mock_bid(&mut server, 100); + + let builders = builders(); + let mut entry = entry(&server.url(), 1000); + entry.min_bid = 500; + let entries = vec![entry]; + + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Ok::<(), String>(()) + }) + .await; + assert_eq!(bids.len(), 1); + assert_eq!(bids[0].min_bid, 500); + } + + #[tokio::test] + async fn rejects_bid_failing_producer_validation() { + let mut server = Server::new_async().await; + mock_bid(&mut server, 100); + + let builders = builders(); + let entries = vec![entry(&server.url(), 1000)]; + // The producer callback rejects the bid (e.g. a failed signature or ineligible builder). + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Err::<(), String>("rejected by producer".to_string()) + }) + .await; + assert!(bids.is_empty()); + } +} diff --git a/beacon_node/builder_client/src/error.rs b/beacon_node/builder_client/src/error.rs new file mode 100644 index 00000000000..1fe4af0fa43 --- /dev/null +++ b/beacon_node/builder_client/src/error.rs @@ -0,0 +1,94 @@ +//! The error type for the Gloas [`BuilderHttpClient`](crate::BuilderHttpClient), aligned with the +//! Builder API spec (`builder-specs`). +//! +//! This is deliberately separate from `eth2::Error` (the beacon-node API client's error), which +//! carries beacon-node concerns irrelevant to a builder — API tokens, impostor-signature headers, +//! server-sent events — and collapses every builder-spec status (204 no-bid, 401 auth failed, +//! 406/415 negotiation) into an opaque status code. The pre-Gloas builder client still uses +//! `eth2::Error`. + +use eth2::types::{BuilderUrlError, ErrorMessage}; +use pretty_reqwest_error::PrettyReqwestError; +use reqwest::{Response, StatusCode}; +use sensitive_url::SensitiveUrl; +use std::fmt; + +#[derive(Debug)] +pub enum Error { + /// A transport-level failure sending the request or reading the response. + Reqwest(PrettyReqwestError), + /// A builder URL could not be turned into a request URL. + InvalidUrl(SensitiveUrl), + /// A `BuilderUrl` supplied in config did not parse as a URL. + InvalidBuilderUrl(BuilderUrlError), + /// The builder returned an error response with a parseable `{code, message}` body (the + /// builder-specs `ErrorMessage`), e.g. 400 invalid request or 401 authentication failed. + ServerMessage(ErrorMessage), + /// The builder returned a non-success status whose body could not be parsed. + StatusCode(StatusCode), + /// The builder's JSON response could not be decoded. + InvalidJson(serde_json::Error), + /// The builder's SSZ response could not be decoded. + InvalidSsz(ssz::DecodeError), + /// Request headers could not be constructed. + InvalidHeaders(String), +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Error::Reqwest(error.into()) + } +} + +impl From for Error { + fn from(error: BuilderUrlError) -> Self { + Error::InvalidBuilderUrl(error) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Reqwest(e) => write!(f, "HTTP transport error: {e}"), + Error::InvalidUrl(url) => write!(f, "invalid builder URL: {url:?}"), + Error::InvalidBuilderUrl(e) => write!(f, "invalid builder URL: {e:?}"), + Error::ServerMessage(m) => { + write!(f, "builder returned error {}: {}", m.code, m.message) + } + Error::StatusCode(status) => write!(f, "builder returned unexpected status {status}"), + Error::InvalidJson(e) => write!(f, "invalid JSON response: {e}"), + Error::InvalidSsz(e) => write!(f, "invalid SSZ response: {e:?}"), + Error::InvalidHeaders(e) => write!(f, "invalid response headers: {e}"), + } + } +} + +impl std::error::Error for Error {} + +/// Returns `Ok(response)` for a builder success status (200/202/204), otherwise parses the body +/// into an [`Error`]. Mirrors `eth2::ok_or_error` but produces the builder-spec [`Error`]. +pub async fn ok_or_error(response: Response) -> Result { + let status = response.status(); + if matches!( + status, + StatusCode::OK | StatusCode::ACCEPTED | StatusCode::NO_CONTENT + ) { + Ok(response) + } else if let Ok(message) = response.json::().await { + Err(Error::ServerMessage(message)) + } else { + Err(Error::StatusCode(status)) + } +} + +/// Like [`ok_or_error`] but accepts any 2xx status as success. +pub async fn success_or_error(response: Response) -> Result { + let status = response.status(); + if status.is_success() { + Ok(response) + } else if let Ok(message) = response.json::().await { + Err(Error::ServerMessage(message)) + } else { + Err(Error::StatusCode(status)) + } +} diff --git a/beacon_node/builder_client/src/lib.rs b/beacon_node/builder_client/src/lib.rs index bd064ca8bf9..983b4c0c0a2 100644 --- a/beacon_node/builder_client/src/lib.rs +++ b/beacon_node/builder_client/src/lib.rs @@ -1,32 +1,20 @@ -use bls::PublicKeyBytes; -use context_deserialize::ContextDeserialize; -pub use eth2::Error; -use eth2::types::beacon_response::EmptyMetadata; -use eth2::types::builder::SignedBuilderBid; -use eth2::types::{ - ContentType, EthSpec, ExecutionBlockHash, ForkName, ForkVersionDecode, ForkVersionedResponse, - SignedValidatorRegistrationData, Slot, -}; -use eth2::types::{FullPayloadContents, SignedBlindedBeaconBlock}; -use eth2::{ - CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER, - SSZ_CONTENT_TYPE_HEADER, ok_or_error, success_or_error, -}; -use reqwest::header::{ACCEPT, HeaderMap, HeaderValue}; -use reqwest::{IntoUrl, Response, StatusCode}; -use sensitive_url::SensitiveUrl; -use serde::Serialize; -use serde::de::DeserializeOwned; -use ssz::Encode; +use eth2::types::{ContentType, ForkName}; +use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; +use reqwest::header::HeaderMap; use std::str::FromStr; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; -pub const DEFAULT_TIMEOUT_MILLIS: u64 = 15000; +pub mod builder_http_client; +pub mod builders; +pub mod error; +pub mod pre_gloas_builder_http_client; + +pub use builder_http_client::{BuilderHttpClient, GloasBidResponse}; +pub use builders::{BidRequestContext, Builders, DirectBid, SubmissionFailure}; +pub use error::{Error, ok_or_error, success_or_error}; +pub use pre_gloas_builder_http_client::PreGloasBuilderHttpClient; -/// This timeout is in accordance with v0.2.0 of the [builder specs](https://github.com/flashbots/mev-boost/pull/20). -pub const DEFAULT_GET_HEADER_TIMEOUT_MILLIS: u64 = 1000; +/// Default timeout for builder requests without a more specific timeout. +pub const DEFAULT_TIMEOUT_MILLIS: u64 = 15000; /// Default user agent for HTTP requests. pub const DEFAULT_USER_AGENT: &str = lighthouse_version::VERSION; @@ -36,667 +24,28 @@ pub const PREFERENCE_ACCEPT_VALUE: &str = "application/octet-stream;q=1.0,applic /// Only accept json responses. pub const JSON_ACCEPT_VALUE: &str = "application/json"; -#[derive(Clone)] -pub struct Timeouts { - get_header: Duration, - post_validators: Duration, - post_blinded_blocks: Duration, - get_builder_status: Duration, -} - -impl Timeouts { - fn new(get_header_timeout: Option) -> Self { - let get_header = - get_header_timeout.unwrap_or(Duration::from_millis(DEFAULT_GET_HEADER_TIMEOUT_MILLIS)); - - Self { - get_header, - post_validators: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), - post_blinded_blocks: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), - get_builder_status: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), - } - } -} - -#[derive(Clone)] -pub struct BuilderHttpClient { - client: reqwest::Client, - server: SensitiveUrl, - timeouts: Timeouts, - user_agent: String, - /// Only use json for all requests/responses types. - disable_ssz: bool, - /// Indicates that the `get_header` response had content-type ssz - /// so we can set content-type header to ssz to make the `submit_blinded_blocks` - /// request. - ssz_available: Arc, -} - -impl BuilderHttpClient { - pub fn new( - server: SensitiveUrl, - user_agent: Option, - builder_header_timeout: Option, - disable_ssz: bool, - ) -> Result { - let user_agent = user_agent.unwrap_or(DEFAULT_USER_AGENT.to_string()); - let client = reqwest::Client::builder().user_agent(&user_agent).build()?; - Ok(Self { - client, - server, - timeouts: Timeouts::new(builder_header_timeout), - user_agent, - disable_ssz, - ssz_available: Arc::new(false.into()), +/// Parse the `Eth-Consensus-Version` response header into a `ForkName`, if present. +pub fn fork_name_from_header(headers: &HeaderMap) -> Result, String> { + headers + .get(CONSENSUS_VERSION_HEADER) + .map(|fork_name| { + fork_name + .to_str() + .map_err(|e| e.to_string()) + .and_then(ForkName::from_str) }) - } - - pub fn get_user_agent(&self) -> &str { - &self.user_agent - } - - fn fork_name_from_header(&self, headers: &HeaderMap) -> Result, String> { - headers - .get(CONSENSUS_VERSION_HEADER) - .map(|fork_name| { - fork_name - .to_str() - .map_err(|e| e.to_string()) - .and_then(ForkName::from_str) - }) - .transpose() - } - - fn content_type_from_header(&self, headers: &HeaderMap) -> ContentType { - let Some(content_type) = headers.get(CONTENT_TYPE_HEADER).map(|content_type| { - let content_type = content_type.to_str(); - match content_type { - Ok(SSZ_CONTENT_TYPE_HEADER) => ContentType::Ssz, - _ => ContentType::Json, - } - }) else { - return ContentType::Json; - }; - content_type - } - - async fn get_with_header< - T: DeserializeOwned + ForkVersionDecode + for<'de> ContextDeserialize<'de, ForkName>, - U: IntoUrl, - >( - &self, - url: U, - timeout: Duration, - headers: HeaderMap, - ) -> Result, Error> { - let response = self - .get_response_with_header(url, Some(timeout), headers) - .await?; - - let headers = response.headers().clone(); - let response_bytes = response.bytes().await?; - - let Ok(Some(fork_name)) = self.fork_name_from_header(&headers) else { - // if no fork version specified, attempt to fallback to JSON - self.ssz_available.store(false, Ordering::SeqCst); - return serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson); - }; - - let content_type = self.content_type_from_header(&headers); - - match content_type { - ContentType::Ssz => { - self.ssz_available.store(true, Ordering::SeqCst); - T::from_ssz_bytes_by_fork(&response_bytes, fork_name) - .map(|data| ForkVersionedResponse { - version: fork_name, - metadata: EmptyMetadata {}, - data, - }) - .map_err(Error::InvalidSsz) - } - ContentType::Json => { - self.ssz_available.store(false, Ordering::SeqCst); - serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson) - } - } - } - - /// Return `true` if the most recently received response from the builder had SSZ Content-Type. - /// Return `false` otherwise. - /// Also returns `false` if we have explicitly disabled ssz. - pub fn is_ssz_available(&self) -> bool { - !self.disable_ssz && self.ssz_available.load(Ordering::SeqCst) - } - - async fn get_with_timeout( - &self, - url: U, - timeout: Duration, - ) -> Result { - self.get_response_with_timeout(url, Some(timeout)) - .await? - .json() - .await - .map_err(Into::into) - } - - /// Perform a HTTP GET request, returning the `Response` for further processing. - async fn get_response_with_header( - &self, - url: U, - timeout: Option, - headers: HeaderMap, - ) -> Result { - let mut builder = self.client.get(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - let response = builder.headers(headers).send().await.map_err(Error::from)?; - ok_or_error(response).await - } - - /// Perform a HTTP GET request, returning the `Response` for further processing. - async fn get_response_with_timeout( - &self, - url: U, - timeout: Option, - ) -> Result { - let mut builder = self.client.get(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - let response = builder.send().await.map_err(Error::from)?; - ok_or_error(response).await - } - - /// Generic POST function supporting arbitrary responses and timeouts. - async fn post_generic( - &self, - url: U, - body: &T, - timeout: Option, - ) -> Result { - let mut builder = self.client.post(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - let response = builder.json(body).send().await?; - ok_or_error(response).await - } - - async fn post_ssz_with_raw_response( - &self, - url: U, - ssz_body: Vec, - headers: HeaderMap, - timeout: Option, - ) -> Result { - let mut builder = self.client.post(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - - let response = builder - .headers(headers) - .body(ssz_body) - .send() - .await - .map_err(Error::from)?; - success_or_error(response).await - } - - async fn post_with_raw_response( - &self, - url: U, - body: &T, - headers: HeaderMap, - timeout: Option, - ) -> Result { - let mut builder = self.client.post(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - - let response = builder - .headers(headers) - .json(body) - .send() - .await - .map_err(Error::from)?; - success_or_error(response).await - } - - /// `POST /eth/v1/builder/validators` - pub async fn post_builder_validators( - &self, - validator: &[SignedValidatorRegistrationData], - ) -> Result<(), Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("validators"); - - self.post_generic(path, &validator, Some(self.timeouts.post_validators)) - .await?; - Ok(()) - } - - /// `POST /eth/v1/builder/blinded_blocks` with SSZ serialized request body - pub async fn post_builder_blinded_blocks_v1_ssz( - &self, - blinded_block: &SignedBlindedBeaconBlock, - ) -> Result, Error> { - let mut path = self.server.expose_full().clone(); - - let body = blinded_block.as_ssz_bytes(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("blinded_blocks"); - - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - CONTENT_TYPE_HEADER, - HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - ACCEPT, - HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - - let result = self - .post_ssz_with_raw_response( - path, - body, - headers, - Some(self.timeouts.post_blinded_blocks), - ) - .await? - .bytes() - .await?; - - FullPayloadContents::from_ssz_bytes_by_fork(&result, blinded_block.fork_name_unchecked()) - .map_err(Error::InvalidSsz) - } - - /// `POST /eth/v2/builder/blinded_blocks` with SSZ serialized request body - pub async fn post_builder_blinded_blocks_v2_ssz( - &self, - blinded_block: &SignedBlindedBeaconBlock, - ) -> Result<(), Error> { - let mut path = self.server.expose_full().clone(); - - let body = blinded_block.as_ssz_bytes(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v2") - .push("builder") - .push("blinded_blocks"); - - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - CONTENT_TYPE_HEADER, - HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - ACCEPT, - HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - - let result = self - .post_ssz_with_raw_response( - path, - body, - headers, - Some(self.timeouts.post_blinded_blocks), - ) - .await?; - - if result.status() == StatusCode::ACCEPTED { - Ok(()) - } else { - // ACCEPTED is the only valid status code response - Err(Error::StatusCode(result.status())) - } - } - - /// `POST /eth/v1/builder/blinded_blocks` - pub async fn post_builder_blinded_blocks_v1( - &self, - blinded_block: &SignedBlindedBeaconBlock, - ) -> Result>, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("blinded_blocks"); - - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - CONTENT_TYPE_HEADER, - HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - ACCEPT, - HeaderValue::from_str(JSON_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - - Ok(self - .post_with_raw_response( - path, - &blinded_block, - headers, - Some(self.timeouts.post_blinded_blocks), - ) - .await? - .json() - .await?) - } - - /// `POST /eth/v2/builder/blinded_blocks` - pub async fn post_builder_blinded_blocks_v2( - &self, - blinded_block: &SignedBlindedBeaconBlock, - ) -> Result<(), Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v2") - .push("builder") - .push("blinded_blocks"); - - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - CONTENT_TYPE_HEADER, - HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - ACCEPT, - HeaderValue::from_str(JSON_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - - let result = self - .post_with_raw_response( - path, - &blinded_block, - headers, - Some(self.timeouts.post_blinded_blocks), - ) - .await?; - - if result.status() == StatusCode::ACCEPTED { - Ok(()) - } else { - // ACCEPTED is the only valid status code response - Err(Error::StatusCode(result.status())) - } - } - - /// `GET /eth/v1/builder/header` - pub async fn get_builder_header( - &self, - slot: Slot, - parent_hash: ExecutionBlockHash, - pubkey: &PublicKeyBytes, - ) -> Result>>, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("header") - .push(slot.to_string().as_str()) - .push(format!("{parent_hash:?}").as_str()) - .push(pubkey.as_hex_string().as_str()); - - let mut headers = HeaderMap::new(); - if self.disable_ssz { - headers.insert( - ACCEPT, - HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - } else { - // Indicate preference for ssz response in the accept header - headers.insert( - ACCEPT, - HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - } - - let resp = self - .get_with_header(path, self.timeouts.get_header, headers) - .await; - - if matches!(resp, Err(Error::StatusCode(StatusCode::NO_CONTENT))) { - Ok(None) - } else { - resp.map(Some) - } - } - - /// `GET /eth/v1/builder/status` - pub async fn get_builder_status(&self) -> Result<(), Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("status"); - - self.get_with_timeout(path, self.timeouts.get_builder_status) - .await - } + .transpose() } -#[cfg(test)] -mod tests { - use super::*; - use arbitrary::Arbitrary; - use bls::Signature; - use eth2::types::MainnetEthSpec; - use eth2::types::builder::{BuilderBid, BuilderBidFulu}; - use mockito::{Matcher, Server, ServerGuard}; - - type E = MainnetEthSpec; - - #[test] - fn test_headers_no_panic() { - for fork in ForkName::list_all() { - assert!(HeaderValue::from_str(&fork.to_string()).is_ok()); - } - assert!(HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE).is_ok()); - assert!(HeaderValue::from_str(JSON_ACCEPT_VALUE).is_ok()); - assert!(HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER).is_ok()); - } - - #[tokio::test] - async fn test_get_builder_header_ssz_response() { - // Set up mock server - let mut server = Server::new_async().await; - let mock_response_body = fulu_signed_builder_bid(); - mock_get_header_response( - &mut server, - Some("fulu"), - ContentType::Ssz, - mock_response_body.clone(), - ); - - let builder_client = BuilderHttpClient::new( - SensitiveUrl::from_str(&server.url()).unwrap(), - None, - None, - false, - ) - .unwrap(); - - let response = builder_client - .get_builder_header( - Slot::new(1), - ExecutionBlockHash::repeat_byte(1), - &PublicKeyBytes::empty(), - ) - .await - .expect("should succeed in get_builder_header") - .expect("should have response body"); - - assert_eq!(response, mock_response_body); - } - - #[tokio::test] - async fn test_get_builder_header_json_response() { - // Set up mock server - let mut server = Server::new_async().await; - let mock_response_body = fulu_signed_builder_bid(); - mock_get_header_response( - &mut server, - None, - ContentType::Json, - mock_response_body.clone(), - ); - - let builder_client = BuilderHttpClient::new( - SensitiveUrl::from_str(&server.url()).unwrap(), - None, - None, - false, - ) - .unwrap(); - - let response = builder_client - .get_builder_header( - Slot::new(1), - ExecutionBlockHash::repeat_byte(1), - &PublicKeyBytes::empty(), - ) - .await - .expect("should succeed in get_builder_header") - .expect("should have response body"); - - assert_eq!(response, mock_response_body); - } - - #[tokio::test] - async fn test_get_builder_header_no_version_header_fallback_json() { - // Set up mock server - let mut server = Server::new_async().await; - let mock_response_body = fulu_signed_builder_bid(); - mock_get_header_response( - &mut server, - Some("fulu"), - ContentType::Json, - mock_response_body.clone(), - ); - - let builder_client = BuilderHttpClient::new( - SensitiveUrl::from_str(&server.url()).unwrap(), - None, - None, - false, - ) - .unwrap(); - - let response = builder_client - .get_builder_header( - Slot::new(1), - ExecutionBlockHash::repeat_byte(1), - &PublicKeyBytes::empty(), - ) - .await - .expect("should succeed in get_builder_header") - .expect("should have response body"); - - assert_eq!(response, mock_response_body); - } - - fn mock_get_header_response( - server: &mut ServerGuard, - header_version_opt: Option<&str>, - content_type: ContentType, - response_body: ForkVersionedResponse>, - ) { - let mut mock = server.mock( - "GET", - Matcher::Regex(r"^/eth/v1/builder/header/\d+/.+/.+$".to_string()), - ); - - if let Some(version) = header_version_opt { - mock = mock.with_header(CONSENSUS_VERSION_HEADER, version); - } - - match content_type { - ContentType::Json => { - mock = mock - .with_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) - .with_body(serde_json::to_string(&response_body).unwrap()); - } - ContentType::Ssz => { - mock = mock - .with_header(CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER) - .with_body(response_body.data.as_ssz_bytes()); - } - } - - mock.with_status(200).create(); - } - - fn fulu_signed_builder_bid() -> ForkVersionedResponse> { - let mut u = types::test_utils::test_unstructured(); - ForkVersionedResponse { - version: ForkName::Fulu, - metadata: EmptyMetadata {}, - data: SignedBuilderBid { - message: BuilderBid::Fulu(BuilderBidFulu::arbitrary(&mut u).unwrap()), - signature: Signature::empty(), - }, - } +/// Determine the `ContentType` of a response from its `Content-Type` header. +/// +/// Defaults to JSON when the header is absent or unrecognized. +pub fn content_type_from_header(headers: &HeaderMap) -> ContentType { + match headers + .get(CONTENT_TYPE_HEADER) + .and_then(|content_type| content_type.to_str().ok()) + { + Some(SSZ_CONTENT_TYPE_HEADER) => ContentType::Ssz, + _ => ContentType::Json, } } diff --git a/beacon_node/builder_client/src/pre_gloas_builder_http_client.rs b/beacon_node/builder_client/src/pre_gloas_builder_http_client.rs new file mode 100644 index 00000000000..1a15087d5f9 --- /dev/null +++ b/beacon_node/builder_client/src/pre_gloas_builder_http_client.rs @@ -0,0 +1,673 @@ +use crate::{ + DEFAULT_TIMEOUT_MILLIS, DEFAULT_USER_AGENT, JSON_ACCEPT_VALUE, PREFERENCE_ACCEPT_VALUE, + content_type_from_header, fork_name_from_header, +}; +use bls::PublicKeyBytes; +// The pre-Gloas builder client keeps the beacon-node API client's error type, unlike the Gloas +// `BuilderHttpClient` which has its own builder-spec-aligned `crate::Error`. +use context_deserialize::ContextDeserialize; +use eth2::Error; +use eth2::types::beacon_response::EmptyMetadata; +use eth2::types::builder::SignedBuilderBid; +use eth2::types::{ + ContentType, EthSpec, ExecutionBlockHash, ForkName, ForkVersionDecode, ForkVersionedResponse, + SignedValidatorRegistrationData, Slot, +}; +use eth2::types::{FullPayloadContents, SignedBlindedBeaconBlock}; +use eth2::{ + CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER, + SSZ_CONTENT_TYPE_HEADER, ok_or_error, success_or_error, +}; +use reqwest::header::{ACCEPT, HeaderMap, HeaderValue}; +use reqwest::{IntoUrl, Response, StatusCode}; +use sensitive_url::SensitiveUrl; +use serde::Serialize; +use serde::de::DeserializeOwned; +use ssz::Encode; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +/// This timeout is in accordance with v0.2.0 of the [builder specs](https://github.com/flashbots/mev-boost/pull/20). +pub const DEFAULT_GET_HEADER_TIMEOUT_MILLIS: u64 = 1000; + +#[derive(Clone)] +pub struct Timeouts { + get_header: Duration, + post_validators: Duration, + post_blinded_blocks: Duration, + get_builder_status: Duration, +} + +impl Timeouts { + fn new(get_header_timeout: Option) -> Self { + let get_header = + get_header_timeout.unwrap_or(Duration::from_millis(DEFAULT_GET_HEADER_TIMEOUT_MILLIS)); + + Self { + get_header, + post_validators: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), + post_blinded_blocks: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), + get_builder_status: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), + } + } +} + +#[derive(Clone)] +pub struct PreGloasBuilderHttpClient { + client: reqwest::Client, + server: SensitiveUrl, + timeouts: Timeouts, + user_agent: String, + /// Only use json for all requests/responses types. + disable_ssz: bool, + /// Indicates that the `get_header` response had content-type ssz + /// so we can set content-type header to ssz to make the `submit_blinded_blocks` + /// request. + ssz_available: Arc, +} + +impl PreGloasBuilderHttpClient { + pub fn new( + server: SensitiveUrl, + user_agent: Option, + builder_header_timeout: Option, + disable_ssz: bool, + ) -> Result { + let user_agent = user_agent.unwrap_or(DEFAULT_USER_AGENT.to_string()); + let client = reqwest::Client::builder().user_agent(&user_agent).build()?; + Ok(Self { + client, + server, + timeouts: Timeouts::new(builder_header_timeout), + user_agent, + disable_ssz, + ssz_available: Arc::new(false.into()), + }) + } + + pub fn get_user_agent(&self) -> &str { + &self.user_agent + } + + async fn get_with_header< + T: DeserializeOwned + ForkVersionDecode + for<'de> ContextDeserialize<'de, ForkName>, + U: IntoUrl, + >( + &self, + url: U, + timeout: Duration, + headers: HeaderMap, + ) -> Result, Error> { + let response = self + .get_response_with_header(url, Some(timeout), headers) + .await?; + + let headers = response.headers().clone(); + let response_bytes = response.bytes().await?; + + let Ok(Some(fork_name)) = fork_name_from_header(&headers) else { + // if no fork version specified, attempt to fallback to JSON + self.ssz_available.store(false, Ordering::SeqCst); + return serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson); + }; + + let content_type = content_type_from_header(&headers); + + match content_type { + ContentType::Ssz => { + self.ssz_available.store(true, Ordering::SeqCst); + T::from_ssz_bytes_by_fork(&response_bytes, fork_name) + .map(|data| ForkVersionedResponse { + version: fork_name, + metadata: EmptyMetadata {}, + data, + }) + .map_err(Error::InvalidSsz) + } + ContentType::Json => { + self.ssz_available.store(false, Ordering::SeqCst); + serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson) + } + } + } + + /// Return `true` if the most recently received response from the builder had SSZ Content-Type. + /// Return `false` otherwise. + /// Also returns `false` if we have explicitly disabled ssz. + pub fn is_ssz_available(&self) -> bool { + !self.disable_ssz && self.ssz_available.load(Ordering::SeqCst) + } + + async fn get_with_timeout( + &self, + url: U, + timeout: Duration, + ) -> Result { + self.get_response_with_timeout(url, Some(timeout)) + .await? + .json() + .await + .map_err(Into::into) + } + + /// Perform a HTTP GET request, returning the `Response` for further processing. + async fn get_response_with_header( + &self, + url: U, + timeout: Option, + headers: HeaderMap, + ) -> Result { + let mut builder = self.client.get(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + let response = builder.headers(headers).send().await.map_err(Error::from)?; + ok_or_error(response).await + } + + /// Perform a HTTP GET request, returning the `Response` for further processing. + async fn get_response_with_timeout( + &self, + url: U, + timeout: Option, + ) -> Result { + let mut builder = self.client.get(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + let response = builder.send().await.map_err(Error::from)?; + ok_or_error(response).await + } + + /// Generic POST function supporting arbitrary responses and timeouts. + async fn post_generic( + &self, + url: U, + body: &T, + timeout: Option, + ) -> Result { + let mut builder = self.client.post(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + let response = builder.json(body).send().await?; + ok_or_error(response).await + } + + async fn post_ssz_with_raw_response( + &self, + url: U, + ssz_body: Vec, + headers: HeaderMap, + timeout: Option, + ) -> Result { + let mut builder = self.client.post(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + + let response = builder + .headers(headers) + .body(ssz_body) + .send() + .await + .map_err(Error::from)?; + success_or_error(response).await + } + + async fn post_with_raw_response( + &self, + url: U, + body: &T, + headers: HeaderMap, + timeout: Option, + ) -> Result { + let mut builder = self.client.post(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + + let response = builder + .headers(headers) + .json(body) + .send() + .await + .map_err(Error::from)?; + success_or_error(response).await + } + + /// `POST /eth/v1/builder/validators` + pub async fn post_builder_validators( + &self, + validator: &[SignedValidatorRegistrationData], + ) -> Result<(), Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("validators"); + + self.post_generic(path, &validator, Some(self.timeouts.post_validators)) + .await?; + Ok(()) + } + + /// `POST /eth/v1/builder/blinded_blocks` with SSZ serialized request body + pub async fn post_builder_blinded_blocks_v1_ssz( + &self, + blinded_block: &SignedBlindedBeaconBlock, + ) -> Result, Error> { + let mut path = self.server.expose_full().clone(); + + let body = blinded_block.as_ssz_bytes(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("blinded_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + ACCEPT, + HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + let result = self + .post_ssz_with_raw_response( + path, + body, + headers, + Some(self.timeouts.post_blinded_blocks), + ) + .await? + .bytes() + .await?; + + FullPayloadContents::from_ssz_bytes_by_fork(&result, blinded_block.fork_name_unchecked()) + .map_err(Error::InvalidSsz) + } + + /// `POST /eth/v2/builder/blinded_blocks` with SSZ serialized request body + pub async fn post_builder_blinded_blocks_v2_ssz( + &self, + blinded_block: &SignedBlindedBeaconBlock, + ) -> Result<(), Error> { + let mut path = self.server.expose_full().clone(); + + let body = blinded_block.as_ssz_bytes(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v2") + .push("builder") + .push("blinded_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + ACCEPT, + HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + let result = self + .post_ssz_with_raw_response( + path, + body, + headers, + Some(self.timeouts.post_blinded_blocks), + ) + .await?; + + if result.status() == StatusCode::ACCEPTED { + Ok(()) + } else { + // ACCEPTED is the only valid status code response + Err(Error::StatusCode(result.status())) + } + } + + /// `POST /eth/v1/builder/blinded_blocks` + pub async fn post_builder_blinded_blocks_v1( + &self, + blinded_block: &SignedBlindedBeaconBlock, + ) -> Result>, Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("blinded_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + ACCEPT, + HeaderValue::from_str(JSON_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + Ok(self + .post_with_raw_response( + path, + &blinded_block, + headers, + Some(self.timeouts.post_blinded_blocks), + ) + .await? + .json() + .await?) + } + + /// `POST /eth/v2/builder/blinded_blocks` + pub async fn post_builder_blinded_blocks_v2( + &self, + blinded_block: &SignedBlindedBeaconBlock, + ) -> Result<(), Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v2") + .push("builder") + .push("blinded_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + ACCEPT, + HeaderValue::from_str(JSON_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + let result = self + .post_with_raw_response( + path, + &blinded_block, + headers, + Some(self.timeouts.post_blinded_blocks), + ) + .await?; + + if result.status() == StatusCode::ACCEPTED { + Ok(()) + } else { + // ACCEPTED is the only valid status code response + Err(Error::StatusCode(result.status())) + } + } + + /// `GET /eth/v1/builder/header` + pub async fn get_builder_header( + &self, + slot: Slot, + parent_hash: ExecutionBlockHash, + pubkey: &PublicKeyBytes, + ) -> Result>>, Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("header") + .push(slot.to_string().as_str()) + .push(format!("{parent_hash:?}").as_str()) + .push(pubkey.as_hex_string().as_str()); + + let mut headers = HeaderMap::new(); + if self.disable_ssz { + headers.insert( + ACCEPT, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + } else { + // Indicate preference for ssz response in the accept header + headers.insert( + ACCEPT, + HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + } + + let resp = self + .get_with_header(path, self.timeouts.get_header, headers) + .await; + + if matches!(resp, Err(Error::StatusCode(StatusCode::NO_CONTENT))) { + Ok(None) + } else { + resp.map(Some) + } + } + + /// `GET /eth/v1/builder/status` + pub async fn get_builder_status(&self) -> Result<(), Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("status"); + + self.get_with_timeout(path, self.timeouts.get_builder_status) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arbitrary::Arbitrary; + use bls::Signature; + use eth2::types::MainnetEthSpec; + use eth2::types::builder::{BuilderBid, BuilderBidFulu}; + use mockito::{Matcher, Server, ServerGuard}; + use std::str::FromStr; + + type E = MainnetEthSpec; + + #[test] + fn test_headers_no_panic() { + for fork in ForkName::list_all() { + assert!(HeaderValue::from_str(&fork.to_string()).is_ok()); + } + assert!(HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE).is_ok()); + assert!(HeaderValue::from_str(JSON_ACCEPT_VALUE).is_ok()); + assert!(HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER).is_ok()); + } + + #[tokio::test] + async fn test_get_builder_header_ssz_response() { + // Set up mock server + let mut server = Server::new_async().await; + let mock_response_body = fulu_signed_builder_bid(); + mock_get_header_response( + &mut server, + Some("fulu"), + ContentType::Ssz, + mock_response_body.clone(), + ); + + let builder_client = PreGloasBuilderHttpClient::new( + SensitiveUrl::from_str(&server.url()).unwrap(), + None, + None, + false, + ) + .unwrap(); + + let response = builder_client + .get_builder_header( + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + &PublicKeyBytes::empty(), + ) + .await + .expect("should succeed in get_builder_header") + .expect("should have response body"); + + assert_eq!(response, mock_response_body); + } + + #[tokio::test] + async fn test_get_builder_header_json_response() { + // Set up mock server + let mut server = Server::new_async().await; + let mock_response_body = fulu_signed_builder_bid(); + mock_get_header_response( + &mut server, + None, + ContentType::Json, + mock_response_body.clone(), + ); + + let builder_client = PreGloasBuilderHttpClient::new( + SensitiveUrl::from_str(&server.url()).unwrap(), + None, + None, + false, + ) + .unwrap(); + + let response = builder_client + .get_builder_header( + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + &PublicKeyBytes::empty(), + ) + .await + .expect("should succeed in get_builder_header") + .expect("should have response body"); + + assert_eq!(response, mock_response_body); + } + + #[tokio::test] + async fn test_get_builder_header_no_version_header_fallback_json() { + // Set up mock server + let mut server = Server::new_async().await; + let mock_response_body = fulu_signed_builder_bid(); + mock_get_header_response( + &mut server, + Some("fulu"), + ContentType::Json, + mock_response_body.clone(), + ); + + let builder_client = PreGloasBuilderHttpClient::new( + SensitiveUrl::from_str(&server.url()).unwrap(), + None, + None, + false, + ) + .unwrap(); + + let response = builder_client + .get_builder_header( + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + &PublicKeyBytes::empty(), + ) + .await + .expect("should succeed in get_builder_header") + .expect("should have response body"); + + assert_eq!(response, mock_response_body); + } + + fn mock_get_header_response( + server: &mut ServerGuard, + header_version_opt: Option<&str>, + content_type: ContentType, + response_body: ForkVersionedResponse>, + ) { + let mut mock = server.mock( + "GET", + Matcher::Regex(r"^/eth/v1/builder/header/\d+/.+/.+$".to_string()), + ); + + if let Some(version) = header_version_opt { + mock = mock.with_header(CONSENSUS_VERSION_HEADER, version); + } + + match content_type { + ContentType::Json => { + mock = mock + .with_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) + .with_body(serde_json::to_string(&response_body).unwrap()); + } + ContentType::Ssz => { + mock = mock + .with_header(CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER) + .with_body(response_body.data.as_ssz_bytes()); + } + } + + mock.with_status(200).create(); + } + + fn fulu_signed_builder_bid() -> ForkVersionedResponse> { + let mut u = types::test_utils::test_unstructured(); + ForkVersionedResponse { + version: ForkName::Fulu, + metadata: EmptyMetadata {}, + data: SignedBuilderBid { + message: BuilderBid::Fulu(BuilderBidFulu::arbitrary(&mut u).unwrap()), + signature: Signature::empty(), + }, + } + } +} diff --git a/beacon_node/client/Cargo.toml b/beacon_node/client/Cargo.toml index 50d76e8f199..db2dcbe5fd1 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 003694eb3bb..edee1c46901 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, @@ -187,6 +188,28 @@ where None }; + // 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)? @@ -210,6 +233,7 @@ where .beacon_graffiti(beacon_graffiti) .event_handler(event_handler) .execution_layer(execution_layer) + .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/execution_layer/Cargo.toml b/beacon_node/execution_layer/Cargo.toml index 0d90cdaf2f2..a04e065aa9f 100644 --- a/beacon_node/execution_layer/Cargo.toml +++ b/beacon_node/execution_layer/Cargo.toml @@ -11,7 +11,7 @@ alloy-rlp = { workspace = true } alloy-rpc-types-eth = { workspace = true } arc-swap = "1.6.0" bls = { workspace = true } -builder_client = { path = "../builder_client" } +builder_client = { workspace = true } bytes = { workspace = true } eth2 = { workspace = true, features = ["events", "lighthouse", "network"] } ethereum_serde_utils = { workspace = true } diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 3aff96c9b15..048e232d567 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -65,7 +65,6 @@ pub enum Error { DeserializeWithdrawals(ssz_types::Error), DeserializeDepositRequests(ssz_types::Error), DeserializeWithdrawalRequests(ssz_types::Error), - BuilderApi(builder_client::Error), IncorrectStateVariant, RequiredMethodUnsupported(&'static str), UnsupportedForkVariant(String), @@ -98,12 +97,6 @@ impl From for Error { } } -impl From for Error { - fn from(e: builder_client::Error) -> Self { - Error::BuilderApi(e) - } -} - impl From for Error { fn from(e: ssz_types::Error) -> Self { Error::SszError(e) diff --git a/beacon_node/execution_layer/src/engines.rs b/beacon_node/execution_layer/src/engines.rs index aac170d48c1..bc1516a4b89 100644 --- a/beacon_node/execution_layer/src/engines.rs +++ b/beacon_node/execution_layer/src/engines.rs @@ -115,7 +115,6 @@ struct PayloadIdCacheKey { pub enum EngineError { Offline, Api { error: EngineApiError }, - BuilderApi { error: EngineApiError }, Auth, } diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 239ffdb4e60..5c94a5fd65a 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -10,7 +10,7 @@ use arc_swap::ArcSwapOption; use auth::{Auth, JwtKey, strip_prefix}; pub use block_hash::calculate_execution_block_hash; use bls::{PublicKeyBytes, Signature}; -use builder_client::BuilderHttpClient; +use builder_client::PreGloasBuilderHttpClient; pub use engine_api::EngineCapabilities; use engine_api::Error as ApiError; pub use engine_api::*; @@ -138,7 +138,8 @@ pub enum Error { NoEngine, NoPayloadBuilder, ApiError(ApiError), - Builder(builder_client::Error), + // The pre-Gloas builder client uses the beacon-node API client's error type. + Builder(eth2::Error), NoHeaderFromBuilder, CannotProduceHeader, EngineError(Box), @@ -464,7 +465,7 @@ type PayloadContentsRefTuple<'a, E> = (ExecutionPayloadRef<'a, E>, Option<&'a Bl struct Inner { engine: Arc, - builder: ArcSwapOption, + builder: ArcSwapOption, execution_engine_forkchoice_lock: Mutex<()>, suggested_fee_recipient: Option
, proposer_preparation_data: Mutex>, @@ -603,7 +604,7 @@ impl ExecutionLayer { &self.inner.engine } - pub fn builder(&self) -> Option> { + pub fn builder(&self) -> Option> { self.inner.builder.load_full() } @@ -618,7 +619,7 @@ impl ExecutionLayer { builder_header_timeout: Option, disable_ssz: bool, ) -> Result<(), Error> { - let builder_client = BuilderHttpClient::new( + let builder_client = PreGloasBuilderHttpClient::new( builder_url.clone(), builder_user_agent, builder_header_timeout, @@ -1045,11 +1046,11 @@ impl ExecutionLayer { /// Fetches local and builder paylaods concurrently, Logs and returns results. async fn fetch_builder_and_local_payloads( &self, - builder: &BuilderHttpClient, + builder: &PreGloasBuilderHttpClient, builder_params: &BuilderParams, payload_parameters: PayloadParameters<'_>, ) -> ( - Result>>, builder_client::Error>, + Result>>, eth2::Error>, Result, Error>, ) { let slot = builder_params.slot; diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 03d0450627f..e94f2a11ddd 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, }; @@ -383,6 +385,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(); @@ -818,6 +821,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(); @@ -854,6 +860,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 }) @@ -891,6 +899,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 }) @@ -908,13 +918,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, @@ -931,6 +943,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -948,13 +961,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, @@ -970,6 +985,7 @@ pub async fn serve( &network_tx, validation_level.broadcast_validation, duplicate_block_status_code, + builder_url, ) .await }) @@ -2569,6 +2585,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(), @@ -2682,6 +2706,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(), @@ -3498,6 +3528,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 f84a998923e..42f28bb8538 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; @@ -19,7 +19,7 @@ use eth2::{ }; 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 +58,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,27 +90,30 @@ 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 - }; + // 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 (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(); @@ -102,6 +122,7 @@ pub async fn produce_block_v4( consensus_block_value, execution_payload_value, payload_contents, + builder_url, accept_header, &chain.spec, ) @@ -156,6 +177,7 @@ 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 { @@ -172,13 +194,15 @@ pub fn build_response_v4( consensus_block_value: consensus_block_value_wei, execution_payload_value, execution_payload_included, + 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 0368a874934..d2a704e0ee4 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(()) }; @@ -572,6 +640,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 5287a1a3974..e210cbbfde1 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, unsupported_version_rejection}; +use crate::version::{V1, V2, V3, 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, 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; @@ -462,8 +463,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 { @@ -475,6 +480,68 @@ 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 body is not fork-versioned, so no `Eth-Consensus-Version` header is used (per +// beacon-APIs #630). +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(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:?}")) + })? + }; + Ok::<_, Rejection>(builder_config) + }), + ) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + accept_header: Option, + 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, "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, @@ -749,6 +816,104 @@ 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). The body is not +// fork-versioned, so no `Eth-Consensus-Version` header is used (per beacon-APIs #630). +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:?}")) + })? + }; + 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).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 6d80344e943..5db04d7d136 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -76,7 +76,11 @@ pub async fn gossip_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -140,7 +144,11 @@ pub async fn gossip_partial_pass() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert_eq!(response.unwrap().status(), StatusCode::ACCEPTED); } @@ -180,6 +188,7 @@ pub async fn gossip_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -228,7 +237,7 @@ pub async fn gossip_full_pass_ssz() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_contents, validation_level) + .post_beacon_blocks_v2_ssz(&block_contents, validation_level, None) .await; assert!(response.is_ok()); @@ -277,7 +286,11 @@ pub async fn consensus_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -339,7 +352,11 @@ pub async fn consensus_gossip() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -416,6 +433,7 @@ pub async fn consensus_partial_pass_only_consensus() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; @@ -463,6 +481,7 @@ pub async fn consensus_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -514,7 +533,11 @@ pub async fn equivocation_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -587,7 +610,8 @@ pub async fn equivocation_consensus_early_equivocation() { .client .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), blobs_a), - validation_level + validation_level, + None, ) .await .is_ok() @@ -605,6 +629,7 @@ pub async fn equivocation_consensus_early_equivocation() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_b.clone(), blobs_b), validation_level, + None, ) .await; assert!(response.is_err()); @@ -656,7 +681,11 @@ pub async fn equivocation_gossip() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -735,6 +764,7 @@ pub async fn equivocation_consensus_late_equivocation() { &channel.0, validation_level, StatusCode::ACCEPTED, + None, ) .await; @@ -786,6 +816,7 @@ pub async fn equivocation_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -1631,6 +1662,7 @@ pub async fn block_seen_on_gossip_without_blobs_or_columns() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some(blobs)), validation_level, + None, ) .await; @@ -1716,6 +1748,7 @@ pub async fn block_seen_on_gossip_with_columns() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some(blobs)), validation_level, + None, ) .await; @@ -1787,6 +1820,7 @@ pub async fn columns_seen_on_gossip_without_block() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some((kzg_proofs, blobs))), validation_level, + None, ) .await; @@ -1861,6 +1895,7 @@ async fn columns_seen_on_gossip_without_block_and_no_http_columns() { Some((Default::default(), Default::default())), ), validation_level, + None, ) .await; @@ -1931,6 +1966,7 @@ async fn slashable_columns_seen_on_gossip_cause_failure() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), Some((kzg_proofs_a, blobs_a))), validation_level, + None, ) .await; @@ -2001,7 +2037,7 @@ pub async fn duplicate_block_status_code() { let block_request = PublishBlockRequest::new(block.clone(), Some((kzg_proofs, blobs))); let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_request, validation_level) + .post_beacon_blocks_v2_ssz(&block_request, validation_level, None) .await; // This should result in the block being fully imported. @@ -2016,7 +2052,7 @@ pub async fn duplicate_block_status_code() { // Post again. let duplicate_response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_request, validation_level) + .post_beacon_blocks_v2_ssz(&block_request, validation_level, None) .await; let err = duplicate_response.unwrap_err(); assert_eq!(err.status().unwrap(), duplicate_block_status_code); diff --git a/beacon_node/http_api/tests/gloas_reorg_tests.rs b/beacon_node/http_api/tests/gloas_reorg_tests.rs index e9480eba92e..8698ce32053 100644 --- a/beacon_node/http_api/tests/gloas_reorg_tests.rs +++ b/beacon_node/http_api/tests/gloas_reorg_tests.rs @@ -727,7 +727,14 @@ 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, + ) .await .unwrap(); ( diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index a020c633c82..b0c004f0ff8 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -810,7 +810,14 @@ 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, + ) .await .unwrap() .0 diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 6d71a1cdddc..f8cf189572e 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -1799,7 +1799,7 @@ impl ApiTester { let next_block = &self.next_block; self.client - .post_beacon_blocks_v2_ssz(next_block, None) + .post_beacon_blocks_v2_ssz(next_block, None, None) .await .unwrap(); @@ -1897,7 +1897,7 @@ impl ApiTester { .await .unwrap(), self.client - .post_beacon_blocks_v2_ssz(&block_contents, None) + .post_beacon_blocks_v2_ssz(&block_contents, None, None) .await .unwrap(), self.client @@ -4329,7 +4329,7 @@ impl ApiTester { block_contents.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); self.client - .post_beacon_blocks_v2_ssz(&signed_block_contents, None) + .post_beacon_blocks_v2_ssz(&signed_block_contents, None, None) .await .unwrap(); @@ -4451,7 +4451,7 @@ impl ApiTester { block_contents.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); self.client - .post_beacon_blocks_v2_ssz(&signed_block_contents, None) + .post_beacon_blocks_v2_ssz(&signed_block_contents, None, None) .await .unwrap(); @@ -4597,7 +4597,14 @@ 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, + ) .await .unwrap(); let block = response.into_block(); @@ -4768,7 +4775,6 @@ impl ApiTester { SkipRandaoVerification::No, false, None, - None, ) .await .unwrap(); @@ -4963,7 +4969,14 @@ 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, + ) .await .unwrap(); let block = response.into_block(); @@ -5038,7 +5051,14 @@ 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, + ) .await .unwrap(); let block = response.into_block(); @@ -5057,7 +5077,7 @@ impl ApiTester { let signed_block_request = PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); self.client - .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .post_beacon_blocks_v2_ssz(&signed_block_request, None, None) .await .unwrap(); assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); @@ -5110,12 +5130,26 @@ 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, + ) .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, + ) .await .unwrap() }; @@ -5132,7 +5166,7 @@ impl ApiTester { PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); if ssz { self.client - .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .post_beacon_blocks_v2_ssz(&signed_block_request, None, None) .await .unwrap(); } else { @@ -5630,7 +5664,14 @@ 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, + ) .await .unwrap(); let block = response.into_block(); @@ -5713,7 +5754,14 @@ 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, + ) .await .unwrap(); let block = response.into_block(); @@ -8645,7 +8693,14 @@ 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, + ) .await .unwrap(); let block = response.into_block(); @@ -8951,8 +9006,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])); - let builder_boost_factor = None; - // When GraffitiPolicy is None let no_graffiti_policy_path = self .client @@ -8962,7 +9015,6 @@ impl ApiTester { graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, None, ) .await @@ -8977,7 +9029,6 @@ impl ApiTester { graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, Some(GraffitiPolicy::AppendClientVersions), ) .await @@ -9002,7 +9053,6 @@ impl ApiTester { graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, Some(GraffitiPolicy::PreserveUserGraffiti), ) .await 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 39efe6c0e90..c6ea475497e 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4101,12 +4101,12 @@ impl NetworkBeaconProcessor { } Err( PayloadBidError::NoProposerPreferences { .. } + | PayloadBidError::InvalidFeeRecipient | PayloadBidError::BuilderAlreadySeen { .. } | PayloadBidError::BidValueBelowCached { .. } | PayloadBidError::ParentBlockRootUnknown { .. } | PayloadBidError::ParentBlockRootNotCanonical { .. } | PayloadBidError::BuilderCantCoverBid { .. } - | PayloadBidError::InvalidFeeRecipient | PayloadBidError::InvalidGasLimit | PayloadBidError::BeaconStateError(_) | PayloadBidError::InternalError(_) @@ -4115,6 +4115,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_pubkey` 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); + } } } 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..a593a23e563 --- /dev/null +++ b/book/src/gloas_builder_config.md @@ -0,0 +1,86 @@ +# 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 — reject any bid whose total payment is below this +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_pubkey: "0xa1b2c3d4..." # optional — reject a bid not signed by this key + # 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 to be accepted. 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_pubkey` | no | *(none)* | The builder's BLS public key, hex-encoded. If set, a returned bid **not** signed by it 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. Defaults to the UTF-8 bytes of `url`. | + +All byte fields (`builder_pubkey`, `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 rejected. 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` always prefers the builder. +- **`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_pubkey`** — for a direct builder, if set, the returned bid must be signed by this key + 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/builder_types/Cargo.toml b/common/builder_types/Cargo.toml new file mode 100644 index 00000000000..ee7c5476e5e --- /dev/null +++ b/common/builder_types/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "builder_types" +version = "0.1.0" +authors = ["Sigma Prime "] +edition = { workspace = true } + +# The `arbitrary`-enabled self dependency below is only used to turn the feature on for the +# SSZ/tree-hash test macros, so `cargo-udeps` can't see it being used. +[package.metadata.cargo-udeps.ignore] +development = ["builder_types"] + +[features] +default = [] +arbitrary = [ + "dep:arbitrary", + "types/arbitrary", + "bls/arbitrary", + "ethereum_ssz/arbitrary", + "ssz_types/arbitrary", +] + +[dependencies] +arbitrary = { workspace = true, features = ["derive"], optional = true } +bls = { workspace = true } +context_deserialize = { workspace = true } +ethereum_serde_utils = { workspace = true } +ethereum_ssz = { workspace = true } +ethereum_ssz_derive = { workspace = true } +sensitive_url = { workspace = true } +serde = { workspace = true } +ssz_types = { workspace = true } +tree_hash = { workspace = true } +tree_hash_derive = { workspace = true } +typenum = { workspace = true } +types = { workspace = true } + +[dev-dependencies] +# Self-dependency with the `arbitrary` feature enabled so the SSZ/tree-hash test macros (which build +# instances via `types::test_utils::test_arbitrary_instance`) work in unit tests. Mirrors the pattern +# in `consensus/types`. +builder_types = { path = ".", features = ["arbitrary"] } +serde_json = { workspace = true } diff --git a/common/builder_types/src/builder_config.rs b/common/builder_types/src/builder_config.rs new file mode 100644 index 00000000000..526f47ce6e0 --- /dev/null +++ b/common/builder_types/src/builder_config.rs @@ -0,0 +1,74 @@ +use crate::{BuilderEntry, MaxBuilderEntries}; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; + +/// The resolved builder config the validator client sends on a block-production request, per +/// [beacon-APIs #630](https://github.com/ethereum/beacon-APIs/pull/630). +/// +/// `builders` are the direct bid requests, each fully resolved. The top-level `min_bid` and +/// `builder_boost_factor` govern any bid that matches no entry — in practice, a bid received over +/// p2p. +/// +/// SSZ container (field order per the spec — SSZ and tree-hash depend on it): +/// ```text +/// class BuilderConfig(Container): +/// min_bid: Gwei +/// builder_boost_factor: uint64 +/// builders: List[BuilderEntry, MAX_BUILDER_ENTRIES] +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderConfig { + /// Minimum total payment (Gwei) accepted from a bid that matches no entry (a p2p bid). + #[serde(with = "serde_utils::quoted_u64")] + pub min_bid: u64, + /// Percentage multiplier applied to a bid that matches no entry (a p2p bid). + #[serde(with = "serde_utils::quoted_u64")] + pub builder_boost_factor: u64, + /// The builders to request bids from directly. Empty means only p2p bids are considered. + pub builders: VariableList, +} + +impl BuilderConfig { + /// An empty config: no direct builders, with the documented compatibility defaults for the + /// p2p bid policy (`min_bid = 0`, `builder_boost_factor = 100`). + /// + /// Sent when the validator has no builder support configured, so a Gloas proposal still falls + /// back to local and p2p payloads. + pub fn empty() -> Self { + Self { + min_bid: 0, + builder_boost_factor: 100, + builders: VariableList::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderConfig); + + #[test] + fn json_shape() { + let config = BuilderConfig { + min_bid: 5, + builder_boost_factor: 100, + builders: VariableList::default(), + }; + let json = serde_json::to_value(&config).unwrap(); + let obj = json.as_object().unwrap(); + // `builders` is a JSON array; the Gwei/uint64 fields are quoted strings. + assert!(obj["builders"].is_array()); + assert_eq!(obj["min_bid"], "5"); + assert_eq!(obj["builder_boost_factor"], "100"); + + assert_eq!( + serde_json::from_value::(json).unwrap(), + config + ); + } +} diff --git a/common/builder_types/src/builder_entry.rs b/common/builder_types/src/builder_entry.rs new file mode 100644 index 00000000000..bb942d3ce24 --- /dev/null +++ b/common/builder_types/src/builder_entry.rs @@ -0,0 +1,110 @@ +use crate::{BuilderUrl, SignedRequestAuth}; +use bls::PublicKeyBytes; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; + +/// A per-builder bid request the validator client supplies on a block-production request, per +/// [beacon-APIs #630](https://github.com/ethereum/beacon-APIs/pull/630). +/// +/// Each entry is a direct bid request: the beacon node calls `getExecutionPayloadBid` at `url`, +/// authenticated by `auth`. One request is made per entry, so several entries MAY share a `url` +/// with different `auth`. `min_bid`/`builder_boost_factor`/`max_execution_payment` are this +/// builder's per-request selection policy; p2p bids are governed by the global values on the +/// enclosing config, not here. +/// +/// `builder_pubkey` is optional (its all-zero value means unset — resolve it via the +/// [`builder_pubkey`](Self::builder_pubkey) accessor). When set, it filters the response: a bid not +/// signed by it MUST NOT be accepted. SSZ cannot express absence, hence the sentinel. +/// +/// Field order matches the SSZ `BuilderEntry` container: +/// ```text +/// class BuilderEntry(Container): +/// url: ByteList[MAX_BUILDER_URL_SIZE] +/// auth: SignedRequestAuth +/// builder_pubkey: BLSPubkey +/// max_execution_payment: Gwei +/// min_bid: Gwei +/// builder_boost_factor: uint64 +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderEntry { + /// Where this entry's bid request is sent. Required and non-empty: beacon-APIs #630 treats a + /// zero-length url as invalid. p2p bid policy is carried by the top-level `BuilderConfig`. + pub url: BuilderUrl, + /// Authenticates this entry's bid request. + pub auth: SignedRequestAuth, + /// If set, the returned bid must be signed by this key or it MUST NOT be accepted. Unset is + /// all-zero. + #[serde( + default = "PublicKeyBytes::empty", + skip_serializing_if = "pubkey_is_unset" + )] + pub builder_pubkey: PublicKeyBytes, + /// Maximum trusted execution-layer payment (Gwei) accepted from this builder. + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, + /// Minimum total payment (Gwei) for a bid from this builder to be accepted. + #[serde(with = "serde_utils::quoted_u64")] + pub min_bid: u64, + /// Percentage multiplier applied to this builder's bid when comparing against the local payload. + #[serde(with = "serde_utils::quoted_u64")] + pub builder_boost_factor: u64, +} + +impl BuilderEntry { + /// The builder pubkey the bid response must be signed by, or `None` when unset. + pub fn builder_pubkey(&self) -> Option { + (!pubkey_is_unset(&self.builder_pubkey)).then_some(self.builder_pubkey) + } +} + +/// Whether a `builder_pubkey` is unset (all-zero). A free function, used for the field's +/// `skip_serializing_if`, because `PublicKeyBytes` is a foreign type without an `is_empty` method +/// (unlike `BuilderUrl`/`SignedRequestAuth`, which carry their own predicates). +fn pubkey_is_unset(pubkey: &PublicKeyBytes) -> bool { + *pubkey == PublicKeyBytes::empty() +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderEntry); + + fn entry() -> BuilderEntry { + BuilderEntry { + url: "http://builder.example.com".parse().unwrap(), + auth: SignedRequestAuth::unset(), + builder_pubkey: PublicKeyBytes::empty(), + max_execution_payment: 1, + min_bid: 2, + builder_boost_factor: 100, + } + } + + #[test] + fn json_omits_unset_builder_pubkey() { + // `url` and `auth` are always present; only an unset `builder_pubkey` is omitted. + let entry = entry(); + let json = serde_json::to_value(&entry).unwrap(); + let obj = json.as_object().unwrap(); + assert!(obj.contains_key("url")); + assert!(obj.contains_key("auth")); + assert!(!obj.contains_key("builder_pubkey")); + + // The omitted `builder_pubkey` deserializes back to its unset sentinel. + assert_eq!(serde_json::from_value::(json).unwrap(), entry); + } + + #[test] + fn json_includes_set_builder_pubkey() { + let mut entry = entry(); + entry.builder_pubkey = PublicKeyBytes::deserialize(&[1u8; 48]).unwrap(); + let json = serde_json::to_value(&entry).unwrap(); + assert!(json.as_object().unwrap().contains_key("builder_pubkey")); + + assert_eq!(serde_json::from_value::(json).unwrap(), entry); + } +} diff --git a/common/builder_types/src/builder_preference_entry.rs b/common/builder_types/src/builder_preference_entry.rs new file mode 100644 index 00000000000..a0822c15fc5 --- /dev/null +++ b/common/builder_types/src/builder_preference_entry.rs @@ -0,0 +1,70 @@ +use crate::{BuilderEntry, BuilderUrl, SignedRequestAuth}; +use bls::PublicKeyBytes; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; + +/// A per-builder preference a validator asks the beacon node to submit ahead of the bid request, +/// one entry per `submitBuilderPreferences` builder-API call the beacon node will make. +/// +/// This is the beacon-API (validator -> beacon node) type from beacon-APIs #630. Each entry names +/// its `proposer_pubkey`, so one flat request can carry preferences for several proposers. Unlike +/// the block-production `BuilderEntry`, it carries only what a builder is allowed to see: the routing +/// `url`, the forwarded `auth`, and the `max_execution_payment` cap. The proposer's private +/// bid-filtering knobs (`min_bid`, `builder_boost_factor`) are never sent to a builder. +/// +/// SSZ container (field order per the spec — SSZ and tree-hash depend on it): +/// ```text +/// class BuilderPreferenceEntry(Container): +/// proposer_pubkey: BLSPubkey +/// url: ByteList[MAX_BUILDER_URL_SIZE] +/// auth: SignedRequestAuth +/// max_execution_payment: Gwei +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderPreferenceEntry { + /// The proposer these preferences belong to. + pub proposer_pubkey: PublicKeyBytes, + /// The URL the beacon node submits these preferences to. Unsigned routing metadata. + pub url: BuilderUrl, + /// Authenticates the submission to the builder; forwarded byte-for-byte unchanged. + pub auth: SignedRequestAuth, + /// Maximum trusted execution-layer payment (Gwei) the proposer will accept from this builder. + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, +} + +impl BuilderPreferenceEntry { + pub fn new( + proposer_pubkey: PublicKeyBytes, + url: BuilderUrl, + auth: SignedRequestAuth, + max_execution_payment: u64, + ) -> Self { + Self { + proposer_pubkey, + url, + auth, + max_execution_payment, + } + } + + /// Narrow a proposer's block-production [`BuilderEntry`] to the beacon-API preference entry, + /// dropping the builder-only fields (`min_bid`, `builder_boost_factor`, `builder_pubkey`). + pub fn from_builder_entry(proposer_pubkey: PublicKeyBytes, entry: BuilderEntry) -> Self { + Self { + proposer_pubkey, + url: entry.url, + auth: entry.auth, + max_execution_payment: entry.max_execution_payment, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferenceEntry); +} diff --git a/common/builder_types/src/builder_preferences.rs b/common/builder_types/src/builder_preferences.rs new file mode 100644 index 00000000000..e90e5037a56 --- /dev/null +++ b/common/builder_types/src/builder_preferences.rs @@ -0,0 +1,20 @@ +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::ForkName; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct BuilderPreferences { + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferences); +} diff --git a/common/builder_types/src/builder_preferences_request.rs b/common/builder_types/src/builder_preferences_request.rs new file mode 100644 index 00000000000..2defa12a3fd --- /dev/null +++ b/common/builder_types/src/builder_preferences_request.rs @@ -0,0 +1,35 @@ +use crate::{BuilderPreferences, SignedRequestAuth}; +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::ForkName; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct BuilderPreferencesRequest { + preferences: BuilderPreferences, + auth: SignedRequestAuth, +} + +impl BuilderPreferencesRequest { + pub fn new(preferences: BuilderPreferences, auth: SignedRequestAuth) -> Self { + Self { preferences, auth } + } + + pub fn preferences(&self) -> &BuilderPreferences { + &self.preferences + } + + pub fn auth(&self) -> &SignedRequestAuth { + &self.auth + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferencesRequest); +} diff --git a/common/builder_types/src/builder_url.rs b/common/builder_types/src/builder_url.rs new file mode 100644 index 00000000000..71f64144c4b --- /dev/null +++ b/common/builder_types/src/builder_url.rs @@ -0,0 +1,150 @@ +use crate::RequestAuthData; +use sensitive_url::SensitiveUrl; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use std::fmt; +use std::str::FromStr; +use tree_hash::{PackedEncoding, TreeHash}; + +/// Maximum length (in bytes) of a builder URL on the wire (`MAX_BUILDER_URL_SIZE`), per +/// beacon-APIs #630. +pub type MaxBuilderUrlSize = typenum::U2048; + +/// Maximum number of builder entries a validator may supply on a single request, per +/// beacon-APIs #630. Used as the SSZ `List` bound on `BuilderConfig.builders`. +pub type MaxBuilderEntries = typenum::U64; + +/// [`MaxBuilderEntries`] as a `usize` (derived, so the two cannot drift), for runtime bounds checks. +pub const MAX_BUILDER_ENTRIES: usize = ::USIZE; + +/// A builder URL as it travels on the beacon-API wire. +/// +/// Held as the UTF-8 bytes of the URL so it can serialize two ways, matching the `ByteList` / +/// `string` duality in beacon-APIs #630: an SSZ `ByteList[MAX_BUILDER_URL_SIZE]` (a bare byte list, +/// via the transparent struct behaviour) and a plain string in JSON. +/// +/// This is unsigned routing metadata. On the validator side the URL is held as a `SensitiveUrl` +/// (for redaction/ergonomics) and converted into a `BuilderUrl` only when building a request. +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Encode, Decode)] +#[ssz(struct_behaviour = "transparent")] +pub struct BuilderUrl { + bytes: VariableList, +} + +/// An error constructing or converting a [`BuilderUrl`]. +#[derive(Debug)] +pub enum BuilderUrlError { + /// The URL exceeds `MaxBuilderUrlSize` bytes. + TooLong, + /// The bytes are not a valid URL (invalid UTF-8 or unparseable). + InvalidUrl, +} + +impl BuilderUrl { + /// The URL's raw UTF-8 bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// The URL as a string slice, if it is valid UTF-8 (it always is when constructed through the + /// public API). + pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> { + std::str::from_utf8(&self.bytes) + } + + /// Parse this URL into a [`SensitiveUrl`], for making requests or redacted logging. + /// + /// Fails if the bytes are not valid UTF-8 or do not parse as a URL. `BuilderUrl` itself is just + /// opaque bytes on the wire, so this is the conversion point where URL validity is checked. + pub fn to_sensitive_url(&self) -> Result { + let url = self.as_str().map_err(|_| BuilderUrlError::InvalidUrl)?; + SensitiveUrl::parse(url).map_err(|_| BuilderUrlError::InvalidUrl) + } + + /// The default opaque auth `data` to sign for this builder when no custom auth data is provided. + /// + /// Infallible: a `BuilderUrl` is at most `MaxBuilderUrlSize` (2048) bytes, well within + /// `MaxDataSize` (4096), so building the default from the URL cannot overflow. + pub fn to_default_auth_data(&self) -> RequestAuthData { + RequestAuthData::new(self.as_bytes().to_vec()).unwrap_or_default() + } +} + +impl TryFrom<&SensitiveUrl> for BuilderUrl { + type Error = BuilderUrlError; + + fn try_from(url: &SensitiveUrl) -> Result { + // Error rather than silently truncating to an (invalid) empty url if the URL string somehow + // exceeds `MaxBuilderUrlSize`. + let bytes = VariableList::new(url.expose_full().as_str().as_bytes().to_vec()) + .map_err(|_| BuilderUrlError::TooLong)?; + Ok(Self { bytes }) + } +} + +impl FromStr for BuilderUrl { + type Err = BuilderUrlError; + + fn from_str(s: &str) -> Result { + let bytes = + VariableList::new(s.as_bytes().to_vec()).map_err(|_| BuilderUrlError::TooLong)?; + Ok(Self { bytes }) + } +} + +impl fmt::Display for BuilderUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&String::from_utf8_lossy(&self.bytes)) + } +} + +impl Serialize for BuilderUrl { + fn serialize(&self, serializer: S) -> Result { + let s = std::str::from_utf8(&self.bytes).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(s) + } +} + +impl<'de> Deserialize<'de> for BuilderUrl { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + BuilderUrl::from_str(&s).map_err(|e| de::Error::custom(format!("{e:?}"))) + } +} + +impl TreeHash for BuilderUrl { + fn tree_hash_type() -> tree_hash::TreeHashType { + as TreeHash>::tree_hash_type() + } + + fn tree_hash_packed_encoding(&self) -> PackedEncoding { + self.bytes.tree_hash_packed_encoding() + } + + fn tree_hash_packing_factor() -> usize { + as TreeHash>::tree_hash_packing_factor() + } + + fn tree_hash_root(&self) -> tree_hash::Hash256 { + self.bytes.tree_hash_root() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderUrl); + + #[test] + fn json_is_a_string() { + let url = BuilderUrl::from_str("https://builder.example.com").unwrap(); + let json = serde_json::to_string(&url).unwrap(); + assert_eq!(json, "\"https://builder.example.com\""); + + let decoded: BuilderUrl = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, url); + } +} diff --git a/common/builder_types/src/lib.rs b/common/builder_types/src/lib.rs new file mode 100644 index 00000000000..a74cee9ecb6 --- /dev/null +++ b/common/builder_types/src/lib.rs @@ -0,0 +1,53 @@ +//! Types for the Gloas builder flow that are defined by the Builder API and beacon-APIs specs +//! (builder-specs, beacon-APIs) rather than the consensus-specs. +//! +//! These are wire/request types — they never participate in the state transition — so they live +//! above `consensus/types` rather than in it. Consensus-spec builder containers (`Builder`, +//! `BuilderPendingPayment`, `SignedExecutionPayloadBid`, `ProposerPreferences`, ...) remain in +//! `types`. + +/// Local equivalent of the `ssz_and_tree_hash_tests!` macro in `consensus/types`, which cannot be +/// reused here because it is `#![cfg(test)]`-gated to that crate. Builds an arbitrary instance via +/// `types::test_utils::test_arbitrary_instance` (available with the `arbitrary` feature) and checks +/// SSZ round-trips and tree hashing does not panic. +#[cfg(test)] +#[macro_use] +mod test_macros { + macro_rules! ssz_and_tree_hash_tests { + ($type:ty) => { + #[test] + fn ssz_round_trip() { + let original: $type = types::test_utils::test_arbitrary_instance(); + let bytes = ssz::ssz_encode(&original); + let decoded = <$type as ssz::Decode>::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn tree_hash_root_does_not_panic() { + let original: $type = types::test_utils::test_arbitrary_instance(); + let _ = tree_hash::TreeHash::tree_hash_root(&original); + } + }; + } +} + +mod builder_config; +mod builder_entry; +mod builder_preference_entry; +mod builder_preferences; +mod builder_preferences_request; +mod builder_url; +mod request_auth; +mod signed_request_auth; + +pub use builder_config::BuilderConfig; +pub use builder_entry::BuilderEntry; +pub use builder_preference_entry::BuilderPreferenceEntry; +pub use builder_preferences::BuilderPreferences; +pub use builder_preferences_request::BuilderPreferencesRequest; +pub use builder_url::{ + BuilderUrl, BuilderUrlError, MAX_BUILDER_ENTRIES, MaxBuilderEntries, MaxBuilderUrlSize, +}; +pub use request_auth::{MaxDataSize, RequestAuth, RequestAuthData}; +pub use signed_request_auth::SignedRequestAuth; diff --git a/common/builder_types/src/request_auth.rs b/common/builder_types/src/request_auth.rs new file mode 100644 index 00000000000..69a98e2345d --- /dev/null +++ b/common/builder_types/src/request_auth.rs @@ -0,0 +1,38 @@ +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; +use types::{ForkName, SignedRoot, Slot}; + +// I would like to avoid defining this on the EthSpec if we can get away with it. +// Since it's outside the consensus-spec and is generically named.. +pub type MaxDataSize = typenum::U4096; + +pub type RequestAuthData = VariableList; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct RequestAuth { + /// Opaque authentication data unique to the builder, agreed upon out of band. The meaning of + /// the up to `MaxDataSize` (4096) bytes is left to the proposer and builder; the builder checks + /// the exact bytes when it verifies. When no value has been agreed out of band, implementations + /// SHOULD default to the UTF-8 bytes of the builder's own advertised URL, exactly as advertised, + /// so proposers with no prior relationship can construct an identical `data` deterministically. + /// + /// Serialized as a `0x`-prefixed hex string (builder-specs #165 `format: hex`). + #[serde(with = "ssz_types::serde_utils::hex_var_list")] + pub data: RequestAuthData, + /// The proposal slot this request is authorized for. + pub slot: Slot, +} + +impl SignedRoot for RequestAuth {} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(RequestAuth); +} diff --git a/common/builder_types/src/signed_request_auth.rs b/common/builder_types/src/signed_request_auth.rs new file mode 100644 index 00000000000..952aeefd7a6 --- /dev/null +++ b/common/builder_types/src/signed_request_auth.rs @@ -0,0 +1,35 @@ +use crate::{RequestAuth, RequestAuthData}; +use bls::Signature; +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::{ForkName, Slot}; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct SignedRequestAuth { + pub message: RequestAuth, + pub signature: Signature, +} + +impl SignedRequestAuth { + /// An auth with zero-length `data`, slot `0`, and an all-zero signature. + pub fn unset() -> Self { + Self { + message: RequestAuth { + data: RequestAuthData::default(), + slot: Slot::new(0), + }, + signature: Signature::empty(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(SignedRequestAuth); +} diff --git a/common/eth2/Cargo.toml b/common/eth2/Cargo.toml index 700ef9b4c27..db4a764c6d7 100644 --- a/common/eth2/Cargo.toml +++ b/common/eth2/Cargo.toml @@ -12,6 +12,7 @@ network = ["libp2p-identity", "enr", "multiaddr"] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } context_deserialize = { workspace = true } educe = { workspace = true } eip_3076 = { workspace = true, optional = true } @@ -40,5 +41,6 @@ zeroize = { workspace = true, optional = true } [dev-dependencies] arbitrary = { workspace = true } +builder_types = { workspace = true, features = ["arbitrary"] } tokio = { workspace = true } types = { workspace = true, features = ["arbitrary"] } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index b216362b389..19ea65f2ccc 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -62,6 +62,7 @@ pub const EXECUTION_PAYLOAD_VALUE_HEADER: &str = "Eth-Execution-Payload-Value"; pub const EXECUTION_PAYLOAD_INCLUDED_HEADER: &str = "Eth-Execution-Payload-Included"; pub const CONSENSUS_BLOCK_VALUE_HEADER: &str = "Eth-Consensus-Block-Value"; pub const BLOB_DATA_INCLUDED_HEADER: &str = "Eth-Blob-Data-Included"; +pub const BUILDER_URL_HEADER: &str = "Eth-Builder-Url"; pub const CONTENT_TYPE_HEADER: &str = "Content-Type"; pub const SSZ_CONTENT_TYPE_HEADER: &str = "application/octet-stream"; @@ -367,6 +368,37 @@ impl BeaconNodeHttpClient { } } + /// Perform a HTTP POST request, using an `accept` header for the response and exposing the + /// response headers to `parser`. Returns `None` on a 404 error. + /// + /// `build_body` attaches the request body (and its content-type) to the request builder, so the + /// caller controls whether the body is sent as JSON or SSZ. + pub async fn post_response_with_response_headers( + &self, + url: U, + accept_header: Accept, + timeout: Duration, + build_body: impl FnOnce(RequestBuilder) -> RequestBuilder, + parser: impl FnOnce(Response, HeaderMap) -> F, + ) -> Result, Error> + where + F: Future>, + { + let request = build_body(self.client.post(url).timeout(timeout).accept(accept_header)); + let response = request.send().await?; + + let opt_response = ok_or_error(response).await.optional()?; + + match opt_response { + Some(resp) => { + let response_headers = resp.headers().clone(); + let parsed_response = parser(resp, response_headers).await?; + Ok(Some(parsed_response)) + } + None => Ok(None), + } + } + /// Perform a HTTP POST request. async fn post(&self, url: U, body: &T) -> Result<(), Error> { self.post_generic(url, body, None).await?; @@ -494,6 +526,7 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, blob_data_included: Option, + builder_url: Option<&str>, ) -> Result { let mut builder = self .client @@ -504,6 +537,11 @@ impl BeaconNodeHttpClient { if let Some(blob_data_included) = blob_data_included { builder = builder.header(BLOB_DATA_INCLUDED_HEADER, blob_data_included.to_string()); } + // Echo the winning builder's URL (beacon-APIs #630) so the beacon node forwards the block to + // that builder; only set on a block published after a direct-builder bid won. + if let Some(builder_url) = builder_url { + builder = builder.header(BUILDER_URL_HEADER, builder_url); + } let response = builder.body(body).send().await?; success_or_error(response).await } @@ -533,7 +571,7 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, ) -> Result { - self.post_generic_with_envelope_headers_and_ssz_body(url, body, timeout, fork, None) + self.post_generic_with_envelope_headers_and_ssz_body(url, body, timeout, fork, None, None) .await } @@ -1318,17 +1356,23 @@ impl BeaconNodeHttpClient { } /// `POST v2/beacon/blocks` + /// `builder_url` echoes the `Eth-Builder-Url` from `produceBlockV4` (beacon-APIs #630) so the + /// beacon node forwards the block to the builder that won selection; `None` for a self-built or + /// p2p-won block. pub async fn post_beacon_blocks_v2_ssz( &self, block_contents: &PublishBlockRequest, validation_level: Option, + builder_url: Option<&str>, ) -> Result { let response = self - .post_generic_with_consensus_version_and_ssz_body( + .post_generic_with_envelope_headers_and_ssz_body( self.post_beacon_blocks_v2_path(validation_level)?, block_contents.as_ssz_bytes(), Some(self.timeouts.proposal), block_contents.signed_block().message().body().fork_name(), + None, + builder_url, ) .await?; @@ -2070,6 +2114,54 @@ impl BeaconNodeHttpClient { Ok(()) } + /// `POST validator/builder_preferences` + /// + /// Ask the beacon node to submit builder preferences ahead of the bid request (beacon-APIs #630). + /// The body is a flat list of entries, each naming its own `proposer_pubkey`, so one request may + /// cover several proposers. `fork_name` is sent as the required `Eth-Consensus-Version` header. + pub async fn post_validator_builder_preferences( + &self, + entries: &[BuilderPreferenceEntry], + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("builder_preferences"); + + self.post_with_timeout_and_consensus_header( + path, + &entries, + self.timeouts.default, + fork_name, + ) + .await?; + + Ok(()) + } + + /// `POST validator/builder_preferences` (SSZ) + pub async fn post_validator_builder_preferences_ssz( + &self, + entries: &[BuilderPreferenceEntry], + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("builder_preferences"); + + let ssz_body = entries.to_vec().as_ssz_bytes(); + self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) + .await?; + + Ok(()) + } + /// `GET config/fork_schedule` pub async fn get_config_fork_schedule(&self) -> Result>, Error> { let mut path = self.eth_path(V1)?; @@ -2608,7 +2700,7 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } - /// returns `GET v4/validator/blocks/{slot}` URL path + /// returns the `POST v4/validator/blocks/{slot}` URL path #[allow(clippy::too_many_arguments)] pub async fn get_validator_blocks_v4_path( &self, @@ -2617,7 +2709,6 @@ impl BeaconNodeHttpClient { graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, include_payload: bool, - builder_booster_factor: Option, graffiti_policy: Option, ) -> Result { let mut path = self.eth_path(V4)?; @@ -2644,11 +2735,6 @@ impl BeaconNodeHttpClient { path.query_pairs_mut() .append_pair("include_payload", &include_payload.to_string()); - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - // Only append the HTTP URL request if the graffiti_policy is PreserveUserGraffiti // If AppendClientVersions (default), then we do not modify the HTTP URL request // so that the default case is compliant to the spec @@ -2660,42 +2746,43 @@ impl BeaconNodeHttpClient { Ok(path) } - /// `GET v4/validator/blocks/{slot}` - pub async fn get_validator_blocks_v4( + /// `POST v4/validator/blocks/{slot}` + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, include_payload: bool, - builder_booster_factor: Option, + builder_config: &BuilderConfig, graffiti_policy: Option, ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular( + self.post_validator_blocks_v4_modular( slot, randao_reveal, graffiti, SkipRandaoVerification::No, include_payload, - builder_booster_factor, + builder_config, graffiti_policy, ) .await } - /// `GET v4/validator/blocks/{slot}` + /// `POST 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( + pub async fn post_validator_blocks_v4_modular( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, include_payload: bool, - builder_booster_factor: Option, + builder_config: &BuilderConfig, graffiti_policy: Option, ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { let path = self @@ -2705,16 +2792,16 @@ impl BeaconNodeHttpClient { graffiti, skip_randao_verification, include_payload, - builder_booster_factor, graffiti_policy, ) .await?; let opt_result = self - .get_response_with_response_headers( + .post_response_with_response_headers( path, Accept::Json, self.timeouts.get_validator_block, + |request| request.json(builder_config), |response, headers| async move { let metadata = ProduceBlockV4Metadata::try_from(&headers) .map_err(Error::InvalidHeaders)?; @@ -2747,40 +2834,41 @@ impl BeaconNodeHttpClient { 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( + /// `POST v4/validator/blocks/{slot}` in ssz format + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4_ssz( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, include_payload: bool, - builder_booster_factor: Option, + builder_config: &BuilderConfig, graffiti_policy: Option, ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - self.get_validator_blocks_v4_modular_ssz::( + self.post_validator_blocks_v4_modular_ssz::( slot, randao_reveal, graffiti, SkipRandaoVerification::No, include_payload, - builder_booster_factor, + builder_config, graffiti_policy, ) .await } - /// `GET v4/validator/blocks/{slot}` in ssz format + /// `POST v4/validator/blocks/{slot}` in ssz format /// - /// See [`Self::get_validator_blocks_v4_modular`] for the response semantics. + /// See [`Self::post_validator_blocks_v4_modular`] for the response semantics. #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_modular_ssz( + pub async fn post_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, + builder_config: &BuilderConfig, graffiti_policy: Option, ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { let path = self @@ -2790,16 +2878,20 @@ impl BeaconNodeHttpClient { graffiti, skip_randao_verification, include_payload, - builder_booster_factor, graffiti_policy, ) .await?; let opt_response = self - .get_response_with_response_headers( + .post_response_with_response_headers( path, Accept::Ssz, self.timeouts.get_validator_block, + |request| { + request + .header("Content-Type", "application/octet-stream") + .body(builder_config.as_ssz_bytes()) + }, |response, headers| async move { let metadata = ProduceBlockV4Metadata::try_from(&headers) .map_err(Error::InvalidHeaders)?; @@ -2934,6 +3026,7 @@ impl BeaconNodeHttpClient { Some(self.timeouts.proposal), fork_name, Some(false), + None, ) .await?; @@ -2981,6 +3074,7 @@ impl BeaconNodeHttpClient { Some(self.timeouts.proposal), fork_name, Some(true), + None, ) .await?; diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index f941857d28f..aaac5140da7 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1,11 +1,13 @@ //! This module exposes a superset of the `types` crate. It adds additional types that are only //! required for the HTTP API. +pub use builder_types::*; pub use types::*; use crate::{ - CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, EXECUTION_PAYLOAD_BLINDED_HEADER, - EXECUTION_PAYLOAD_INCLUDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, Error as ServerError, + BUILDER_URL_HEADER, CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, + EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, + EXECUTION_PAYLOAD_VALUE_HEADER, Error as ServerError, }; use bls::{PublicKeyBytes, SecretKey, Signature, SignatureBytes}; use context_deserialize::{ContextDeserialize, context_deserialize}; @@ -1983,6 +1985,11 @@ pub struct ProduceBlockV4Metadata { #[serde(with = "serde_utils::u256_dec")] pub execution_payload_value: Uint256, pub execution_payload_included: bool, + /// The URL of the winning builder when the payload bid came through the builder-API channel + /// (the `Eth-Builder-Url` response header). `None` for a self-built block or a p2p bid. Carried + /// only in the header, never the JSON body, so it's skipped by serde. + #[serde(skip_serializing, skip_deserializing, default)] + pub builder_url: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Encode)] @@ -2268,12 +2275,19 @@ impl TryFrom<&HeaderMap> for ProduceBlockV4Metadata { s.parse::() .map_err(|e| format!("invalid {EXECUTION_PAYLOAD_INCLUDED_HEADER}: {e:?}")) })?; + // Optional; an empty or absent value means the block was self-built or a p2p bid won. + let builder_url = headers + .get(BUILDER_URL_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .filter(|s| !s.is_empty()); Ok(ProduceBlockV4Metadata { consensus_version, consensus_block_value, execution_payload_value, execution_payload_included, + builder_url, }) } } diff --git a/consensus/types/src/core/application_domain.rs b/consensus/types/src/core/application_domain.rs index ff55a910341..0e0f2788705 100644 --- a/consensus/types/src/core/application_domain.rs +++ b/consensus/types/src/core/application_domain.rs @@ -2,16 +2,23 @@ /// Little endian hex: 0x00000001, Binary: 1000000000000000000000000 pub const APPLICATION_DOMAIN_BUILDER: u32 = 16777216; +/// `DOMAIN_REQUEST_AUTH` from builder-specs #165, for Gloas builder-API request authentication. +/// Little endian hex: 0x0B000001 (i.e. `APPLICATION_DOMAIN_BUILDER` with a `0x0B` first byte). +pub const APPLICATION_DOMAIN_REQUEST_AUTH: u32 = 16777227; + #[derive(Debug, PartialEq, Clone, Copy)] pub enum ApplicationDomain { /// NOTE: This domain is only used for out-of-protocol block building, DO NOT use it for Gloas/ePBS. Builder, + /// Authenticates a Gloas builder-API request (`SignedRequestAuth`), per builder-specs #165. + RequestAuth, } impl ApplicationDomain { pub fn get_domain_constant(&self) -> u32 { match self { ApplicationDomain::Builder => APPLICATION_DOMAIN_BUILDER, + ApplicationDomain::RequestAuth => APPLICATION_DOMAIN_REQUEST_AUTH, } } } diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index fd3def843fd..3a32e0d1bde 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -606,6 +606,19 @@ impl ChainSpec { ) } + /// The signing domain for a Gloas builder-API `SignedRequestAuth`. + /// + /// Per builder-specs #165 this is `compute_domain(DOMAIN_REQUEST_AUTH)`: the genesis fork version + /// and a zero genesis-validators-root, matching `get_builder_application_domain`'s out-of-protocol + /// computation but with the `DOMAIN_REQUEST_AUTH` (0x0B000001) domain type. + pub fn get_request_auth_domain(&self) -> Hash256 { + self.compute_domain( + Domain::ApplicationMask(ApplicationDomain::RequestAuth), + self.genesis_fork_version, + Hash256::zero(), + ) + } + /// Return the 32-byte fork data root for the `current_version` and `genesis_validators_root`. /// /// This is used primarily in signature domains to avoid collisions across forks/chains. @@ -3130,6 +3143,19 @@ mod tests { ); } + #[test] + fn test_request_auth_domain() { + let spec = ChainSpec::mainnet(); + let domain = spec.get_request_auth_domain(); + // DOMAIN_REQUEST_AUTH = 0x0B000001 (builder-specs #165), little-endian in the first 4 bytes. + assert_eq!(&domain.as_slice()[0..4], &[0x0B, 0x00, 0x00, 0x01]); + // Same out-of-protocol computation as the builder application domain (genesis fork version, + // zero root), so only the domain-type prefix differs. + let builder = spec.get_builder_application_domain(); + assert_eq!(&domain.as_slice()[4..], &builder.as_slice()[4..]); + assert_ne!(&domain.as_slice()[0..4], &builder.as_slice()[0..4]); + } + fn apply_bit_mask(domain_bytes: [u8; 4], spec: &ChainSpec) -> u32 { let mut domain = [0; 4]; let mask_bytes = int_to_bytes4(spec.domain_application_mask); 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/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..2128dc34e94 --- /dev/null +++ b/validator_client/builder_store/Cargo.toml @@ -0,0 +1,25 @@ +[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 } +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..d33f86bcc2e --- /dev/null +++ b/validator_client/builder_store/src/builder_definitions.rs @@ -0,0 +1,290 @@ +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). + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "serde_option_auth_data" + )] + pub auth_data: Option, + /// The builder's BLS public key. If set, a bid not signed by it is rejected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub builder_pubkey: Option, + /// 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_pubkey: None, + 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_pubkey: None, + 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_pubkey", + "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..af175b22404 --- /dev/null +++ b/validator_client/builder_store/src/lib.rs @@ -0,0 +1,115 @@ +mod builder_definitions; +use bls::PublicKeyBytes; +use builder_definitions::BuilderConfigFile; +pub use builder_definitions::{BuilderDefinition, Error}; +use builder_types::{BuilderConfig, BuilderEntry, 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) + }; + + let mut builders = Vec::with_capacity(definitions.len()); + for definition in definitions { + let auth_data = definition + .auth_data + .unwrap_or_else(|| definition.url.to_default_auth_data()); + // Omit any builder we cannot sign for, logging the error, rather than failing the + // whole config. + let auth = match sign(auth_data).await { + Ok(auth) => auth, + Err(e) => { + error!( + error = ?e, + builder_url = %definition.url, + "Failed to sign builder request auth; omitting builder from config" + ); + continue; + } + }; + builders.push(BuilderEntry { + url: definition.url, + auth, + builder_pubkey: definition + .builder_pubkey + .unwrap_or_else(PublicKeyBytes::empty), + 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 a26b557adf2..c4903fb26c1 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,24 @@ 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, ) .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,13 +562,13 @@ 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, ) .await @@ -518,7 +579,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 +591,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 +656,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 +689,7 @@ impl BlockService { graffiti, &validator_pubkey, unsigned_block, + builder_url, ) .await?; @@ -742,6 +809,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 +818,7 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_POST], ); beacon_node - .post_beacon_blocks_v2_ssz(signed_block, None) + .post_beacon_blocks_v2_ssz(signed_block, None, builder_url) .await .map(|_| ()) .or_else(|e| { @@ -854,6 +922,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 +952,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 +978,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 +1013,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 +1076,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 +1124,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..4bd665e1e0e --- /dev/null +++ b/validator_client/validator_services/src/builder_preferences_service.rs @@ -0,0 +1,296 @@ +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; +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_pubkey` 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; + } + let entries_ref = pending_entries.as_slice(); + + // 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 pending_entries { + 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