From 7314157566a04312546dd295b099870a447ebbd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:26:39 -0300 Subject: [PATCH 1/7] feat(beacon): compute the builder API's signing domain Registrations and bids are signed under DOMAIN_APPLICATION_BUILDER at the genesis fork version and a zero validators root, the way deposits are. The known-answer test pins mainnet's value, so a call that used get_domain's current fork and real validators root instead would fail here rather than at every relay. --- crates/common/types/src/beacon/signing.rs | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/common/types/src/beacon/signing.rs b/crates/common/types/src/beacon/signing.rs index 48df70464..e7630a7c5 100644 --- a/crates/common/types/src/beacon/signing.rs +++ b/crates/common/types/src/beacon/signing.rs @@ -7,7 +7,7 @@ //! a message, such as a validator client, can reach them without taking on that //! crate's `blst`, `c-kzg` and RocksDB dependencies. -use crate::beacon::constants::DOMAIN_DEPOSIT; +use crate::beacon::constants::{DOMAIN_APPLICATION_BUILDER, DOMAIN_DEPOSIT}; use crate::beacon::containers::shared::{Fork, SigningData}; use crate::beacon::fork_digest::compute_fork_data_root; use crate::beacon::preset; @@ -72,10 +72,33 @@ pub fn compute_deposit_domain(genesis_fork_version: Version) -> Domain { compute_domain(DOMAIN_DEPOSIT, genesis_fork_version, Root::ZERO) } +/// The domain for the builder API's own messages: validator registrations +/// and builder bids. +/// +/// Like a deposit's, it is computed against the genesis fork version and a +/// zero validators root, so one signature stays valid across every fork of +/// its chain. This is builder-specs' `compute_domain(DOMAIN_APPLICATION_BUILDER)`, +/// whose defaults are exactly those. It is not gloas's in-protocol builder +/// domain; see [`DOMAIN_APPLICATION_BUILDER`]. +pub fn compute_builder_domain(genesis_fork_version: Version) -> Domain { + compute_domain(DOMAIN_APPLICATION_BUILDER, genesis_fork_version, Root::ZERO) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn the_mainnet_builder_domain_is_the_published_one() { + // The domain type, then the first 28 bytes of the fork data root of + // mainnet's genesis fork version (all zero) and a zero validators + // root, which is sha256 of 64 zero bytes. + let expected = + hex::decode("00000001f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9") + .unwrap(); + assert_eq!(compute_builder_domain([0, 0, 0, 0]).to_vec(), expected); + } + #[test] fn fork_version_switches_at_the_boundary() { let fork = Fork { From e81531d2ea74571e2e0f869783056509e1e5cb1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:29:17 -0300 Subject: [PATCH 2/7] feat(rpc): ask a builder for a header The first builder API call the produceBlockV3 builder path needs. One client per URL, SSZ first, and JSON for good once the URL refuses SSZ; responses are read by their own content type, since relays commonly answer JSON without refusing. An SSZ bid must name its fork, and only electra and fulu bids are accepted, as separate errors so the caller can count them. The mock relay is what the produceBlockV3 tests will drive next. --- Cargo.lock | 2 + crates/net/rpc/Cargo.toml | 2 + .../rpc/src/beacon/builder_client/error.rs | 83 +++++++ .../src/beacon/builder_client/mock_relay.rs | 166 ++++++++++++++ .../net/rpc/src/beacon/builder_client/mod.rs | 217 ++++++++++++++++++ .../rpc/src/beacon/builder_client/tests.rs | 170 ++++++++++++++ crates/net/rpc/src/beacon/mod.rs | 1 + crates/net/rpc/src/lib.rs | 2 + crates/net/rpc/src/metrics.rs | 22 +- docs/metrics.md | 1 + 10 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 crates/net/rpc/src/beacon/builder_client/error.rs create mode 100644 crates/net/rpc/src/beacon/builder_client/mock_relay.rs create mode 100644 crates/net/rpc/src/beacon/builder_client/mod.rs create mode 100644 crates/net/rpc/src/beacon/builder_client/tests.rs diff --git a/Cargo.lock b/Cargo.lock index fcda3ecb2..e58458a7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2111,9 +2111,11 @@ dependencies = [ "libssz 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=5cb14378ffdadcb51b387c003567e2630d0c7073)", "libssz-derive 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=5cb14378ffdadcb51b387c003567e2630d0c7073)", "libssz-types 0.3.0 (git+https://github.com/lambdaclass/libssz?rev=5cb14378ffdadcb51b387c003567e2630d0c7073)", + "reqwest", "serde", "serde_json", "spawned-concurrency", + "thiserror 2.0.18", "tokio", "tokio-util", "tower", diff --git a/crates/net/rpc/Cargo.toml b/crates/net/rpc/Cargo.toml index d07713af4..48b1061af 100644 --- a/crates/net/rpc/Cargo.toml +++ b/crates/net/rpc/Cargo.toml @@ -30,6 +30,8 @@ serde_json.workspace = true hex.workspace = true tracing.workspace = true jemalloc_pprof.workspace = true +reqwest.workspace = true +thiserror.workspace = true futures-util = "0.3" [dev-dependencies] diff --git a/crates/net/rpc/src/beacon/builder_client/error.rs b/crates/net/rpc/src/beacon/builder_client/error.rs new file mode 100644 index 000000000..d86338f97 --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_client/error.rs @@ -0,0 +1,83 @@ +//! What can go wrong talking to a builder. + +use std::time::Duration; + +use reqwest::StatusCode; + +/// A failed builder API call. +/// +/// A Beacon API handler answers [`BadRequest`](Self::BadRequest) with the +/// builder's own 400 and anything else with a 500. +#[derive(Debug, thiserror::Error)] +pub enum BuilderError { + /// No complete answer within the call's deadline. + #[error("builder did not answer within {0:?}")] + Timeout(Duration), + /// The request did not complete: refused, reset, DNS, TLS. + #[error("builder unreachable: {0}")] + Transport(String), + /// The builder refused the request as invalid, with its reason. + #[error("builder refused the request: {0}")] + BadRequest(String), + /// A status the call does not expect. + #[error("builder answered {status}: {body}")] + Status { status: u16, body: String }, + /// A body that does not decode as what the call returns. + #[error("builder response did not decode: {0}")] + Decode(String), + /// A response missing something it needs to be read. + #[error("builder response is incomplete: {0}")] + Incomplete(String), + /// A bid for a fork whose containers this node does not decode. + #[error("builder offered a bid for {0}")] + UnsupportedFork(String), + /// A request body this node could not encode. + #[error("request did not encode: {0}")] + Encode(String), +} + +impl BuilderError { + pub(super) fn from_reqwest(err: &reqwest::Error, deadline: Duration) -> Self { + if err.is_timeout() { + Self::Timeout(deadline) + } else { + Self::Transport(err.to_string()) + } + } + + /// The error a status other than the call's success stands for. A 400 + /// carries the builder's `message`, or its raw body if it sent no JSON. + pub(super) async fn from_status(response: reqwest::Response) -> Self { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if status != StatusCode::BAD_REQUEST { + return Self::Status { + status: status.as_u16(), + body, + }; + } + let message = serde_json::from_str::(&body) + .map(|error| error.message) + .unwrap_or(body); + Self::BadRequest(message) + } + + /// How the call ended, as a metric label. + pub(super) fn label(&self) -> &'static str { + match self { + Self::Timeout(_) => "timeout", + Self::Transport(_) => "unreachable", + Self::BadRequest(_) => "bad_request", + Self::Status { .. } => "unexpected_status", + Self::Decode(_) | Self::Incomplete(_) => "invalid_response", + Self::UnsupportedFork(_) => "unsupported_fork", + Self::Encode(_) => "encode", + } + } +} + +/// builder-specs' `ErrorMessage`, of which only the message is kept. +#[derive(serde::Deserialize)] +struct ErrorMessage { + message: String, +} diff --git a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs new file mode 100644 index 000000000..a6981c38d --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs @@ -0,0 +1,166 @@ +//! A builder API server for tests. +//! +//! It serves one bid, records what it is asked and sent, and has knobs for +//! the ways real builders differ: refusing SSZ, ignoring `Accept`, answering +//! slowly, and leaving out `Eth-Consensus-Version`. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use ethlambda_state_transition::beacon::helpers::test_state::{secret_key_for, sign_for}; +use ethlambda_types::beacon::builder::{BuilderBid, SignedBuilderBid}; +use ethlambda_types::beacon::primitives::{BlsPubkey, HashTreeRoot as _, Version}; +use ethlambda_types::beacon::signing::{compute_builder_domain, compute_signing_root}; +use libssz::SszEncode as _; + +use super::VersionedBid; + +/// The relay's own key: an index far from any test validator's. +const RELAY_KEY: usize = 1_000_000; + +type Shared = Arc>; + +#[derive(Default)] +struct RelayState { + genesis_fork_version: Version, + refuses_ssz: bool, + ignores_accept: bool, + omits_consensus_version: bool, + delay: Duration, + bid: Option, + header_requests: Vec, +} + +/// One `getHeader` the relay received. +#[derive(Debug, Clone)] +pub(crate) struct HeaderRequest { + pub(crate) slot: String, + pub(crate) parent_hash: String, + pub(crate) pubkey: String, + pub(crate) headers: HeaderMap, +} + +pub(crate) struct MockRelay { + pub(crate) url: String, + state: Shared, +} + +impl MockRelay { + /// A relay on a free local port, for the chain whose genesis fork + /// version is `genesis_fork_version`. It takes SSZ, answers at once and + /// has no bid. + pub(crate) async fn start(genesis_fork_version: Version) -> Self { + let state = Arc::new(Mutex::new(RelayState { + genesis_fork_version, + ..RelayState::default() + })); + let app = Router::new() + .route( + "/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}", + get(header), + ) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + Self { url, state } + } + + /// Refuse SSZ outright: 406 to an `Accept` that starts with SSZ, as a + /// builder that reads only the first media type does. + pub(crate) fn refuse_ssz(&self) { + self.state.lock().unwrap().refuses_ssz = true; + } + + /// Answer JSON whatever the `Accept`, without refusing. + pub(crate) fn ignore_accept(&self) { + self.state.lock().unwrap().ignores_accept = true; + } + + /// Leave `Eth-Consensus-Version` off every answer. + pub(crate) fn omit_consensus_version(&self) { + self.state.lock().unwrap().omits_consensus_version = true; + } + + /// Hold every `getHeader` answer for `delay`. + pub(crate) fn delay(&self, delay: Duration) { + self.state.lock().unwrap().delay = delay; + } + + /// Serve `bid` to every `getHeader`. + pub(crate) fn offer(&self, bid: VersionedBid) { + self.state.lock().unwrap().bid = Some(bid); + } + + pub(crate) fn header_requests(&self) -> Vec { + self.state.lock().unwrap().header_requests.clone() + } + + /// The key this relay signs its bids with. + pub(crate) fn pubkey(&self) -> BlsPubkey { + BlsPubkey(secret_key_for(RELAY_KEY).sk_to_pk().to_bytes()) + } + + /// `message` as this relay sends it: naming the relay's key and signed + /// with it under the builder domain. + pub(crate) fn sign_bid(&self, mut message: BuilderBid) -> SignedBuilderBid { + message.pubkey = self.pubkey(); + let genesis_fork_version = self.state.lock().unwrap().genesis_fork_version; + let domain = compute_builder_domain(genesis_fork_version); + let root = compute_signing_root(message.hash_tree_root(), domain); + SignedBuilderBid { + message, + signature: sign_for(RELAY_KEY, root), + } + } +} + +async fn header( + State(state): State, + Path((slot, parent_hash, pubkey)): Path<(String, String, String)>, + headers: HeaderMap, +) -> Response { + let delay = state.lock().unwrap().delay; + tokio::time::sleep(delay).await; + + let mut state = state.lock().unwrap(); + state.header_requests.push(HeaderRequest { + slot, + parent_hash, + pubkey, + headers: headers.clone(), + }); + let accept = headers + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let wants_ssz = accept.starts_with(crate::SSZ_CONTENT_TYPE); + if wants_ssz && state.refuses_ssz { + return StatusCode::NOT_ACCEPTABLE.into_response(); + } + let Some(VersionedBid { fork, bid }) = state.bid.clone() else { + return StatusCode::NO_CONTENT.into_response(); + }; + let mut response = if wants_ssz && !state.ignores_accept { + ( + [(header::CONTENT_TYPE, crate::SSZ_CONTENT_TYPE)], + bid.to_ssz(), + ) + .into_response() + } else { + let body = serde_json::json!({ "version": fork.as_str(), "data": bid }).to_string(); + ([(header::CONTENT_TYPE, "application/json")], body).into_response() + }; + if !state.omits_consensus_version { + response.headers_mut().insert( + "eth-consensus-version", + HeaderValue::from_static(fork.as_str()), + ); + } + response +} diff --git a/crates/net/rpc/src/beacon/builder_client/mod.rs b/crates/net/rpc/src/beacon/builder_client/mod.rs new file mode 100644 index 000000000..ba73f7257 --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_client/mod.rs @@ -0,0 +1,217 @@ +//! The builder API client (`ethereum/builder-specs`): how this node talks to +//! MEV-Boost, or to a relay directly, on a proposer's behalf. +//! +//! One client per URL. Requests go out in SSZ until the URL refuses it (406 +//! or 415), then in JSON for good, since a builder either takes SSZ or it +//! does not. Responses are decoded by their own `Content-Type`, not by what +//! was asked for: a builder may answer JSON to an SSZ request without +//! refusing it. +//! +//! Every call is timed into `lean_beacon_builder_request_seconds`, by call +//! and outcome. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use ethlambda_types::beacon::builder::SignedBuilderBid; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionBlockHash, Slot}; +use libssz::SszDecode as _; +use reqwest::StatusCode; +use reqwest::header::{self, HeaderMap}; +use tracing::debug; + +use crate::metrics::observe_builder_request; + +mod error; +#[cfg(test)] +pub(crate) mod mock_relay; +#[cfg(test)] +mod tests; + +pub use error::BuilderError; + +const JSON_CONTENT_TYPE: &str = "application/json"; + +/// `getHeader`'s `Accept` while the URL still takes SSZ: SSZ preferred and +/// JSON acceptable, so a JSON-only builder answers instead of refusing. +const ACCEPT_SSZ_FIRST: &str = "application/octet-stream;q=1.0,application/json;q=0.9"; + +/// A builder API endpoint: a relay, or MEV-Boost in front of several. +#[derive(Debug, Clone)] +pub struct BuilderClient { + base_url: String, + http: reqwest::Client, + header_timeout: Duration, + /// Set once the URL refuses SSZ. Clones share it, so the switch holds + /// for the URL rather than for one copy of the client. + json_only: Arc, +} + +/// A bid, with the fork its builder says it was built for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VersionedBid { + pub fork: ForkName, + pub bid: SignedBuilderBid, +} + +impl BuilderClient { + /// A client for `base_url` that identifies itself as `user_agent`. + /// + /// `header_timeout` bounds `getHeader`. It is also sent to the builder as + /// `X-Timeout-Ms`, so the builder can hold its answer for as long as the + /// proposer will wait and no longer. + pub fn new( + base_url: impl Into, + header_timeout: Duration, + user_agent: &str, + ) -> Result { + let http = reqwest::Client::builder() + .user_agent(user_agent) + .build() + .map_err(|err| BuilderError::Transport(err.to_string()))?; + let base_url = base_url.into().trim_end_matches('/').to_owned(); + Ok(Self { + base_url, + http, + header_timeout, + json_only: Arc::default(), + }) + } + + /// `getHeader`: the builder's best bid for `slot`, on top of + /// `parent_hash`, for the proposer `pubkey`. `None` when it has none + /// (204). + pub async fn get_header( + &self, + slot: Slot, + parent_hash: ExecutionBlockHash, + pubkey: &BlsPubkey, + ) -> Result, BuilderError> { + let started = Instant::now(); + let result = self.try_get_header(slot, parent_hash, pubkey).await; + let outcome = match &result { + Ok(Some(_)) => "ok", + Ok(None) => "no_bid", + Err(err) => err.label(), + }; + observe_builder_request("get_header", outcome, started.elapsed()); + result + } + + async fn try_get_header( + &self, + slot: Slot, + parent_hash: ExecutionBlockHash, + pubkey: &BlsPubkey, + ) -> Result, BuilderError> { + let path = format!( + "/eth/v1/builder/header/{slot}/0x{}/0x{}", + hex::encode(parent_hash.0), + hex::encode(pubkey.0) + ); + let mut response = self.send_get_header(&path).await?; + if response.status() == StatusCode::NOT_ACCEPTABLE + && !self.json_only.load(Ordering::Relaxed) + { + self.fall_back_to_json(); + response = self.send_get_header(&path).await?; + } + match response.status() { + StatusCode::OK => decode_bid(response, self.header_timeout).await.map(Some), + StatusCode::NO_CONTENT => Ok(None), + _ => Err(BuilderError::from_status(response).await), + } + } + + async fn send_get_header(&self, path: &str) -> Result { + let accept = if self.json_only.load(Ordering::Relaxed) { + JSON_CONTENT_TYPE + } else { + ACCEPT_SSZ_FIRST + }; + self.http + .get(self.url(path)) + .timeout(self.header_timeout) + .header(header::ACCEPT, accept) + .header("x-timeout-ms", self.header_timeout.as_millis().to_string()) + .header("date-milliseconds", unix_millis().to_string()) + .send() + .await + .map_err(|err| BuilderError::from_reqwest(&err, self.header_timeout)) + } + + fn fall_back_to_json(&self) { + debug!(url = %self.base_url, "Builder refused SSZ; using JSON from now on"); + self.json_only.store(true, Ordering::Relaxed); + } + + fn url(&self, path: &str) -> String { + format!("{}{path}", self.base_url) + } +} + +/// A bid, decoded by the response's own `Content-Type`. +async fn decode_bid( + response: reqwest::Response, + deadline: Duration, +) -> Result { + let ssz = is_ssz(response.headers()); + let named_fork = response + .headers() + .get("eth-consensus-version") + .and_then(|value| value.to_str().ok()) + .map(str::to_ascii_lowercase); + let body = response + .bytes() + .await + .map_err(|err| BuilderError::from_reqwest(&err, deadline))?; + + if ssz { + // builder-specs makes this header optional, but an SSZ body has no + // other way to name its fork, and the bid's shape depends on it. + let version = named_fork.ok_or_else(|| { + BuilderError::Incomplete("an SSZ bid without Eth-Consensus-Version".to_owned()) + })?; + let fork = bid_fork(&version)?; + let bid = SignedBuilderBid::from_ssz_bytes(&body) + .map_err(|err| BuilderError::Decode(format!("{err:?}")))?; + return Ok(VersionedBid { fork, bid }); + } + let envelope: JsonBid = + serde_json::from_slice(&body).map_err(|err| BuilderError::Decode(err.to_string()))?; + let fork = bid_fork(&envelope.version.to_ascii_lowercase())?; + let bid = serde_json::from_value(envelope.data) + .map_err(|err| BuilderError::Decode(err.to_string()))?; + Ok(VersionedBid { fork, bid }) +} + +/// The fork `version` names, if its bids have the shape [`SignedBuilderBid`] +/// decodes. Electra and fulu share it; no other fork does. +fn bid_fork(version: &str) -> Result { + match ForkName::parse(version) { + Some(fork @ (ForkName::Electra | ForkName::Fulu)) => Ok(fork), + _ => Err(BuilderError::UnsupportedFork(version.to_owned())), + } +} + +/// `getHeader`'s JSON body: the bid under `data`, its fork under `version`. +#[derive(serde::Deserialize)] +struct JsonBid { + version: String, + data: serde_json::Value, +} + +fn is_ssz(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with(crate::SSZ_CONTENT_TYPE)) +} + +fn unix_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_millis()) +} diff --git a/crates/net/rpc/src/beacon/builder_client/tests.rs b/crates/net/rpc/src/beacon/builder_client/tests.rs new file mode 100644 index 000000000..602eacf44 --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_client/tests.rs @@ -0,0 +1,170 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::http::header::ACCEPT; +use ethlambda_types::beacon::builder::BuilderBid; +use ethlambda_types::beacon::containers::deneb::{ExecutionPayloadHeader, KzgCommitments}; +use ethlambda_types::beacon::containers::electra::{self, ExecutionRequests}; +use ethlambda_types::beacon::fork::ForkName; +use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionBlockHash, Uint256, Version}; + +use super::mock_relay::MockRelay; +use super::{BuilderClient, BuilderError, VersionedBid}; + +/// Any version will do: the relay and the signers only have to agree on it. +const GENESIS_FORK_VERSION: Version = [0x10, 0x00, 0x00, 0x38]; +const HEADER_TIMEOUT: Duration = Duration::from_millis(1000); +const SLOT: u64 = 12_345; + +fn client_for(relay: &MockRelay) -> BuilderClient { + BuilderClient::new(relay.url.clone(), HEADER_TIMEOUT, "ethlambda/test").unwrap() +} + +/// A bid on the empty payload, signed by `relay`, for `fork`. +fn bid_from(relay: &MockRelay, fork: ForkName) -> VersionedBid { + let payload = electra::BeaconBlockBody::empty().execution_payload; + let message = BuilderBid { + header: ExecutionPayloadHeader::from(&payload), + blob_kzg_commitments: KzgCommitments::default(), + execution_requests: ExecutionRequests::default(), + value: Uint256::from(1_000_000_000u64), + pubkey: BlsPubkey([0; 48]), + }; + VersionedBid { + fork, + bid: relay.sign_bid(message), + } +} + +async fn get_header(client: &BuilderClient) -> Result, BuilderError> { + let parent_hash = ExecutionBlockHash::repeat_byte(0xab); + let proposer = BlsPubkey([0xcd; 48]); + client.get_header(SLOT, parent_hash, &proposer).await +} + +fn accept_headers(relay: &MockRelay) -> Vec { + relay + .header_requests() + .iter() + .map(|request| request.headers[ACCEPT].to_str().unwrap().to_owned()) + .collect() +} + +#[tokio::test] +async fn no_bid_is_none() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + assert_eq!(get_header(&client_for(&relay)).await.unwrap(), None); +} + +#[tokio::test] +async fn a_bid_arrives_in_ssz() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + let bid = bid_from(&relay, ForkName::Fulu); + relay.offer(bid.clone()); + assert_eq!(get_header(&client_for(&relay)).await.unwrap(), Some(bid)); + assert!(accept_headers(&relay)[0].starts_with("application/octet-stream")); +} + +#[tokio::test] +async fn a_relay_that_ignores_accept_still_delivers_its_bid() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.ignore_accept(); + let bid = bid_from(&relay, ForkName::Electra); + relay.offer(bid.clone()); + assert_eq!(get_header(&client_for(&relay)).await.unwrap(), Some(bid)); +} + +#[tokio::test] +async fn a_relay_that_refuses_ssz_is_asked_for_json_from_then_on() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.refuse_ssz(); + let bid = bid_from(&relay, ForkName::Fulu); + relay.offer(bid.clone()); + let client = client_for(&relay); + + assert_eq!(get_header(&client).await.unwrap(), Some(bid.clone())); + assert_eq!(get_header(&client).await.unwrap(), Some(bid)); + + // The first request asks for SSZ and is refused; its retry and every + // later request ask for JSON only. + let accepts = accept_headers(&relay); + assert_eq!(accepts.len(), 3); + assert!(accepts[0].starts_with("application/octet-stream")); + assert_eq!(accepts[1..], ["application/json", "application/json"]); +} + +#[tokio::test] +async fn get_header_names_the_slot_parent_and_proposer() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + get_header(&client_for(&relay)).await.unwrap(); + + let request = &relay.header_requests()[0]; + assert_eq!(request.slot, "12345"); + assert_eq!(request.parent_hash, format!("0x{}", "ab".repeat(32))); + assert_eq!(request.pubkey, format!("0x{}", "cd".repeat(48))); + assert_eq!(request.headers["x-timeout-ms"], "1000"); + assert_eq!(request.headers["user-agent"], "ethlambda/test"); + let sent: u128 = request.headers["date-milliseconds"] + .to_str() + .unwrap() + .parse() + .unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + assert!( + now.abs_diff(sent) < 60_000, + "Date-Milliseconds is the current time" + ); +} + +#[tokio::test] +async fn a_trailing_slash_on_the_url_is_harmless() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + let client = + BuilderClient::new(format!("{}/", relay.url), HEADER_TIMEOUT, "ethlambda/test").unwrap(); + assert_eq!(get_header(&client).await.unwrap(), None); +} + +#[tokio::test] +async fn a_slow_relay_times_out() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.delay(Duration::from_millis(500)); + let deadline = Duration::from_millis(100); + let client = BuilderClient::new(relay.url.clone(), deadline, "ethlambda/test").unwrap(); + assert!(matches!( + get_header(&client).await, + Err(BuilderError::Timeout(waited)) if waited == deadline + )); +} + +#[tokio::test] +async fn a_bid_for_another_fork_is_refused() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.offer(bid_from(&relay, ForkName::Deneb)); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::UnsupportedFork(fork)) if fork == "deneb" + )); +} + +#[tokio::test] +async fn an_ssz_bid_must_name_its_fork() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.omit_consensus_version(); + relay.offer(bid_from(&relay, ForkName::Fulu)); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::Incomplete(_)) + )); +} + +#[tokio::test] +async fn an_unreachable_relay_is_reported_as_such() { + let client = + BuilderClient::new("http://127.0.0.1:1", HEADER_TIMEOUT, "ethlambda/test").unwrap(); + assert!(matches!( + get_header(&client).await, + Err(BuilderError::Transport(_)) + )); +} diff --git a/crates/net/rpc/src/beacon/mod.rs b/crates/net/rpc/src/beacon/mod.rs index f9ed67066..0c7077911 100644 --- a/crates/net/rpc/src/beacon/mod.rs +++ b/crates/net/rpc/src/beacon/mod.rs @@ -18,6 +18,7 @@ use crate::shared::block_id::IdError; pub(crate) mod bid_selection; pub(crate) mod bids; pub(crate) mod blocks; +pub(crate) mod builder_client; pub(crate) mod builder_config; pub(crate) mod builders; pub(crate) mod config; diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 9173f0ac4..11ddca147 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -29,6 +29,8 @@ mod shared; mod spec; pub mod test_driver; +pub use beacon::builder_client::{BuilderClient, BuilderError, VersionedBid}; + pub(crate) use base::json_response; #[derive(Debug, Clone)] diff --git a/crates/net/rpc/src/metrics.rs b/crates/net/rpc/src/metrics.rs index b07459e7e..bb1529e63 100644 --- a/crates/net/rpc/src/metrics.rs +++ b/crates/net/rpc/src/metrics.rs @@ -1,5 +1,7 @@ use axum::{Router, http::HeaderValue, http::header, response::IntoResponse, routing::get}; -use ethlambda_metrics::{Histogram, gather_default_metrics, register_histogram}; +use ethlambda_metrics::{ + Histogram, HistogramVec, gather_default_metrics, register_histogram, register_histogram_vec, +}; use std::{sync::LazyLock, time::Duration}; use tracing::warn; @@ -18,6 +20,24 @@ pub(crate) fn observe_publish_data_columns(elapsed: Duration) { LEAN_BEACON_PUBLISH_DATA_COLUMNS_SECONDS.observe(elapsed.as_secs_f64()); } +/// Record one builder API call: which call, how it ended, and how long it +/// took. `getHeader` runs against the proposal's deadline, so its tail +/// matters as much as its failures. +pub(crate) fn observe_builder_request(call: &str, outcome: &str, elapsed: Duration) { + static LEAN_BEACON_BUILDER_REQUEST_SECONDS: LazyLock = LazyLock::new(|| { + register_histogram_vec!( + "lean_beacon_builder_request_seconds", + "Time a builder API call took, by call and outcome", + &["call", "outcome"], + vec![0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 5.0, 10.0] + ) + .unwrap() + }); + LEAN_BEACON_BUILDER_REQUEST_SECONDS + .with_label_values(&[call, outcome]) + .observe(elapsed.as_secs_f64()); +} + pub fn start_prometheus_metrics_api() -> Router { Router::new() .route("/metrics", get(get_metrics)) diff --git a/docs/metrics.md b/docs/metrics.md index 1ea07953f..48616f7b4 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -467,6 +467,7 @@ ethlambda-specific, not part of the leanMetrics spec. | Name | Type | Usage | Sample collection event | Labels | Buckets | |------|------|-------|-------------------------|--------|---------| | `lean_beacon_publish_data_columns_seconds` | Histogram | Time to compute and verify a published block's cells and build its data column sidecars | On every published block that carries blobs and whose cells verify | | 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5 | +| `lean_beacon_builder_request_seconds` | Histogram | Time a builder API call took | On every `getHeader`, `registerValidator` and `submitBlindedBlockV2` call to the configured builder | call=get_header,register_validators,submit_blinded_block
outcome=ok,no_bid,timeout,unreachable,bad_request,unexpected_status,invalid_response,unsupported_fork,encode | 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 2, 5, 10 | ### Beacon Committee Cache From a7ce95cc2781607c3d22ce432012572f69547127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:41:08 -0300 Subject: [PATCH 3/7] fix(rpc): bound what a builder can cost the client A 406 no longer retries getHeader, since a retry restarts the clock the proposer is waiting on; the next call asks for JSON instead. Answers are capped at 8 MiB and kept error text at 1 KiB, a malformed URL fails when the client is built, and transport errors drop the URL so credentials in it cannot reach a log. The mock relay now leaves Eth-Consensus-Version off JSON answers, as MEV-Boost does. --- .../rpc/src/beacon/builder_client/error.rs | 27 +++- .../src/beacon/builder_client/mock_relay.rs | 63 +++++++-- .../net/rpc/src/beacon/builder_client/mod.rs | 102 ++++++++++---- .../rpc/src/beacon/builder_client/tests.rs | 127 +++++++++++++++++- 4 files changed, 270 insertions(+), 49 deletions(-) diff --git a/crates/net/rpc/src/beacon/builder_client/error.rs b/crates/net/rpc/src/beacon/builder_client/error.rs index d86338f97..a26b30f3f 100644 --- a/crates/net/rpc/src/beacon/builder_client/error.rs +++ b/crates/net/rpc/src/beacon/builder_client/error.rs @@ -4,6 +4,8 @@ use std::time::Duration; use reqwest::StatusCode; +use super::{read_body, truncate_error_text}; + /// A failed builder API call. /// /// A Beacon API handler answers [`BadRequest`](Self::BadRequest) with the @@ -31,13 +33,22 @@ pub enum BuilderError { /// A bid for a fork whose containers this node does not decode. #[error("builder offered a bid for {0}")] UnsupportedFork(String), + /// An answer longer than the client reads. + #[error("builder answer larger than {limit} bytes")] + TooLarge { limit: usize }, + /// A builder URL that does not parse. + #[error("invalid builder url: {0}")] + InvalidUrl(String), /// A request body this node could not encode. #[error("request did not encode: {0}")] Encode(String), } impl BuilderError { - pub(super) fn from_reqwest(err: &reqwest::Error, deadline: Duration) -> Self { + /// The URL is dropped from the message: it may carry credentials, and + /// the error ends up in logs and response bodies. + pub(super) fn from_reqwest(err: reqwest::Error, deadline: Duration) -> Self { + let err = err.without_url(); if err.is_timeout() { Self::Timeout(deadline) } else { @@ -47,29 +58,31 @@ impl BuilderError { /// The error a status other than the call's success stands for. A 400 /// carries the builder's `message`, or its raw body if it sent no JSON. - pub(super) async fn from_status(response: reqwest::Response) -> Self { + pub(super) async fn from_status(response: reqwest::Response, deadline: Duration) -> Self { let status = response.status(); - let body = response.text().await.unwrap_or_default(); + let body = read_body(response, deadline).await.unwrap_or_default(); + let body = String::from_utf8_lossy(&body).into_owned(); if status != StatusCode::BAD_REQUEST { return Self::Status { status: status.as_u16(), - body, + body: truncate_error_text(body), }; } let message = serde_json::from_str::(&body) .map(|error| error.message) .unwrap_or(body); - Self::BadRequest(message) + Self::BadRequest(truncate_error_text(message)) } /// How the call ended, as a metric label. - pub(super) fn label(&self) -> &'static str { + pub(crate) fn label(&self) -> &'static str { match self { Self::Timeout(_) => "timeout", Self::Transport(_) => "unreachable", Self::BadRequest(_) => "bad_request", Self::Status { .. } => "unexpected_status", - Self::Decode(_) | Self::Incomplete(_) => "invalid_response", + Self::Decode(_) | Self::Incomplete(_) | Self::TooLarge { .. } => "invalid_response", + Self::InvalidUrl(_) => "invalid_url", Self::UnsupportedFork(_) => "unsupported_fork", Self::Encode(_) => "encode", } diff --git a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs index a6981c38d..ed4eaa9de 100644 --- a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs +++ b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs @@ -34,6 +34,16 @@ struct RelayState { delay: Duration, bid: Option, header_requests: Vec, + raw_answer: Option, +} + +/// A fixed answer to every `getHeader`. +#[derive(Clone)] +struct RawAnswer { + status: StatusCode, + content_type: &'static str, + body: Vec, + consensus_version: Option<&'static str>, } /// One `getHeader` the relay received. @@ -82,11 +92,28 @@ impl MockRelay { self.state.lock().unwrap().ignores_accept = true; } - /// Leave `Eth-Consensus-Version` off every answer. + /// Leave `Eth-Consensus-Version` off SSZ answers. JSON answers never carry + /// it, as MEV-Boost's do not. pub(crate) fn omit_consensus_version(&self) { self.state.lock().unwrap().omits_consensus_version = true; } + /// Answer every `getHeader` with exactly this, whatever the request. + pub(crate) fn answer_headers_with( + &self, + status: StatusCode, + content_type: &'static str, + body: Vec, + consensus_version: Option<&'static str>, + ) { + self.state.lock().unwrap().raw_answer = Some(RawAnswer { + status, + content_type, + body, + consensus_version, + }); + } + /// Hold every `getHeader` answer for `delay`. pub(crate) fn delay(&self, delay: Duration) { self.state.lock().unwrap().delay = delay; @@ -135,6 +162,20 @@ async fn header( pubkey, headers: headers.clone(), }); + if let Some(raw) = state.raw_answer.clone() { + let mut response = ( + raw.status, + [(header::CONTENT_TYPE, raw.content_type)], + raw.body, + ) + .into_response(); + if let Some(version) = raw.consensus_version { + response + .headers_mut() + .insert("eth-consensus-version", HeaderValue::from_static(version)); + } + return response; + } let accept = headers .get(header::ACCEPT) .and_then(|value| value.to_str().ok()) @@ -146,21 +187,21 @@ async fn header( let Some(VersionedBid { fork, bid }) = state.bid.clone() else { return StatusCode::NO_CONTENT.into_response(); }; - let mut response = if wants_ssz && !state.ignores_accept { - ( + if wants_ssz && !state.ignores_accept { + let mut response = ( [(header::CONTENT_TYPE, crate::SSZ_CONTENT_TYPE)], bid.to_ssz(), ) - .into_response() + .into_response(); + if !state.omits_consensus_version { + response.headers_mut().insert( + "eth-consensus-version", + HeaderValue::from_static(fork.as_str()), + ); + } + response } else { let body = serde_json::json!({ "version": fork.as_str(), "data": bid }).to_string(); ([(header::CONTENT_TYPE, "application/json")], body).into_response() - }; - if !state.omits_consensus_version { - response.headers_mut().insert( - "eth-consensus-version", - HeaderValue::from_static(fork.as_str()), - ); } - response } diff --git a/crates/net/rpc/src/beacon/builder_client/mod.rs b/crates/net/rpc/src/beacon/builder_client/mod.rs index ba73f7257..bf2e31963 100644 --- a/crates/net/rpc/src/beacon/builder_client/mod.rs +++ b/crates/net/rpc/src/beacon/builder_client/mod.rs @@ -20,7 +20,7 @@ use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionBlockHash, Slot}; use libssz::SszDecode as _; use reqwest::StatusCode; use reqwest::header::{self, HeaderMap}; -use tracing::debug; +use tracing::info; use crate::metrics::observe_builder_request; @@ -34,6 +34,14 @@ pub use error::BuilderError; const JSON_CONTENT_TYPE: &str = "application/json"; +/// The most a builder answer may hold. A fulu bid at its list limits is +/// under 2 MiB in SSZ and about twice that in JSON. +const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024; + +/// How much of an error body is kept: enough to read, not a proxy's +/// whole error page in a log line or a 400 passed back to the caller. +const MAX_ERROR_TEXT: usize = 1024; + /// `getHeader`'s `Accept` while the URL still takes SSZ: SSZ preferred and /// JSON acceptable, so a JSON-only builder answers instead of refusing. const ACCEPT_SSZ_FIRST: &str = "application/octet-stream;q=1.0,application/json;q=0.9"; @@ -67,11 +75,13 @@ impl BuilderClient { header_timeout: Duration, user_agent: &str, ) -> Result { + let base_url = base_url.into(); + reqwest::Url::parse(&base_url).map_err(|err| BuilderError::InvalidUrl(err.to_string()))?; let http = reqwest::Client::builder() .user_agent(user_agent) .build() - .map_err(|err| BuilderError::Transport(err.to_string()))?; - let base_url = base_url.into().trim_end_matches('/').to_owned(); + .map_err(|err| BuilderError::Transport(err.without_url().to_string()))?; + let base_url = base_url.trim_end_matches('/').to_owned(); Ok(Self { base_url, http, @@ -111,17 +121,20 @@ impl BuilderClient { hex::encode(parent_hash.0), hex::encode(pubkey.0) ); - let mut response = self.send_get_header(&path).await?; - if response.status() == StatusCode::NOT_ACCEPTABLE - && !self.json_only.load(Ordering::Relaxed) - { - self.fall_back_to_json(); - response = self.send_get_header(&path).await?; - } + let asked_ssz = !self.json_only.load(Ordering::Relaxed); + let response = self.send_get_header(&path).await?; match response.status() { StatusCode::OK => decode_bid(response, self.header_timeout).await.map(Some), StatusCode::NO_CONTENT => Ok(None), - _ => Err(BuilderError::from_status(response).await), + status => { + // No retry: a second request would get a fresh timeout, and + // one call could then take twice the proposer's budget. The + // next call asks for JSON instead. + if status == StatusCode::NOT_ACCEPTABLE && asked_ssz { + self.fall_back_to_json(); + } + Err(BuilderError::from_status(response, self.header_timeout).await) + } } } @@ -139,11 +152,11 @@ impl BuilderClient { .header("date-milliseconds", unix_millis().to_string()) .send() .await - .map_err(|err| BuilderError::from_reqwest(&err, self.header_timeout)) + .map_err(|err| BuilderError::from_reqwest(err, self.header_timeout)) } fn fall_back_to_json(&self) { - debug!(url = %self.base_url, "Builder refused SSZ; using JSON from now on"); + info!("Builder refused SSZ; using JSON from now on"); self.json_only.store(true, Ordering::Relaxed); } @@ -163,14 +176,11 @@ async fn decode_bid( .get("eth-consensus-version") .and_then(|value| value.to_str().ok()) .map(str::to_ascii_lowercase); - let body = response - .bytes() - .await - .map_err(|err| BuilderError::from_reqwest(&err, deadline))?; + let body = read_body(response, deadline).await?; if ssz { - // builder-specs makes this header optional, but an SSZ body has no - // other way to name its fork, and the bid's shape depends on it. + // builder-specs requires this header on an SSZ answer, since nothing + // else in an SSZ body names its fork and the bid's shape depends on it. let version = named_fork.ok_or_else(|| { BuilderError::Incomplete("an SSZ bid without Eth-Consensus-Version".to_owned()) })?; @@ -179,14 +189,49 @@ async fn decode_bid( .map_err(|err| BuilderError::Decode(format!("{err:?}")))?; return Ok(VersionedBid { fork, bid }); } - let envelope: JsonBid = + // Two typed passes over the same bytes, each ignoring the other's field, + // so the bid is never built as an intermediate `serde_json::Value`. + let Version { version } = + serde_json::from_slice(&body).map_err(|err| BuilderError::Decode(err.to_string()))?; + let fork = bid_fork(&version.to_ascii_lowercase())?; + let Data { data: bid } = serde_json::from_slice(&body).map_err(|err| BuilderError::Decode(err.to_string()))?; - let fork = bid_fork(&envelope.version.to_ascii_lowercase())?; - let bid = serde_json::from_value(envelope.data) - .map_err(|err| BuilderError::Decode(err.to_string()))?; Ok(VersionedBid { fork, bid }) } +/// The whole body, or `TooLarge` as soon as it would pass the cap. +pub(super) async fn read_body( + mut response: reqwest::Response, + deadline: Duration, +) -> Result, BuilderError> { + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|err| BuilderError::from_reqwest(err, deadline))? + { + if body.len() + chunk.len() > MAX_RESPONSE_BYTES { + return Err(BuilderError::TooLarge { + limit: MAX_RESPONSE_BYTES, + }); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +/// `text` cut to at most [`MAX_ERROR_TEXT`] bytes, at a char boundary. +pub(super) fn truncate_error_text(mut text: String) -> String { + if text.len() > MAX_ERROR_TEXT { + let mut end = MAX_ERROR_TEXT; + while !text.is_char_boundary(end) { + end -= 1; + } + text.truncate(end); + } + text +} + /// The fork `version` names, if its bids have the shape [`SignedBuilderBid`] /// decodes. Electra and fulu share it; no other fork does. fn bid_fork(version: &str) -> Result { @@ -196,11 +241,16 @@ fn bid_fork(version: &str) -> Result { } } -/// `getHeader`'s JSON body: the bid under `data`, its fork under `version`. +/// `getHeader`'s JSON body holds its fork under `version`... #[derive(serde::Deserialize)] -struct JsonBid { +struct Version { version: String, - data: serde_json::Value, +} + +/// ...and the bid under `data`. +#[derive(serde::Deserialize)] +struct Data { + data: SignedBuilderBid, } fn is_ssz(headers: &HeaderMap) -> bool { diff --git a/crates/net/rpc/src/beacon/builder_client/tests.rs b/crates/net/rpc/src/beacon/builder_client/tests.rs index 602eacf44..f0e2afdea 100644 --- a/crates/net/rpc/src/beacon/builder_client/tests.rs +++ b/crates/net/rpc/src/beacon/builder_client/tests.rs @@ -1,5 +1,6 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use axum::http::StatusCode; use axum::http::header::ACCEPT; use ethlambda_types::beacon::builder::BuilderBid; use ethlambda_types::beacon::containers::deneb::{ExecutionPayloadHeader, KzgCommitments}; @@ -81,15 +82,20 @@ async fn a_relay_that_refuses_ssz_is_asked_for_json_from_then_on() { relay.offer(bid.clone()); let client = client_for(&relay); - assert_eq!(get_header(&client).await.unwrap(), Some(bid.clone())); + // The refusal is not retried, since a retry would restart the clock the + // proposer is waiting on; it is only remembered. + assert!(matches!( + get_header(&client).await, + Err(BuilderError::Status { status: 406, .. }) + )); assert_eq!(get_header(&client).await.unwrap(), Some(bid)); - // The first request asks for SSZ and is refused; its retry and every - // later request ask for JSON only. + // The first request asks for SSZ and is refused; every later one asks + // for JSON only. let accepts = accept_headers(&relay); - assert_eq!(accepts.len(), 3); + assert_eq!(accepts.len(), 2); assert!(accepts[0].starts_with("application/octet-stream")); - assert_eq!(accepts[1..], ["application/json", "application/json"]); + assert_eq!(accepts[1], "application/json"); } #[tokio::test] @@ -168,3 +174,114 @@ async fn an_unreachable_relay_is_reported_as_such() { Err(BuilderError::Transport(_)) )); } + +const JSON: &str = "application/json"; +const TEXT: &str = "text/plain"; +const SSZ: &str = "application/octet-stream"; + +#[tokio::test] +async fn a_400_carries_the_builders_message() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + let body = br#"{"code":400,"message":"Unknown hash"}"#.to_vec(); + relay.answer_headers_with(StatusCode::BAD_REQUEST, JSON, body, None); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::BadRequest(message)) if message == "Unknown hash" + )); +} + +#[tokio::test] +async fn a_400_without_json_carries_its_body() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.answer_headers_with(StatusCode::BAD_REQUEST, TEXT, b"nope".to_vec(), None); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::BadRequest(message)) if message == "nope" + )); +} + +#[tokio::test] +async fn a_long_error_is_cut_short() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.answer_headers_with(StatusCode::BAD_REQUEST, TEXT, vec![b'x'; 5000], None); + let Err(BuilderError::BadRequest(message)) = get_header(&client_for(&relay)).await else { + panic!("expected a bad request"); + }; + assert!(message.len() <= 1024); +} + +#[tokio::test] +async fn a_server_error_is_an_unexpected_status() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.answer_headers_with( + StatusCode::INTERNAL_SERVER_ERROR, + TEXT, + b"boom".to_vec(), + None, + ); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::Status { status: 500, .. }) + )); +} + +#[tokio::test] +async fn garbage_ssz_does_not_decode() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.answer_headers_with(StatusCode::OK, SSZ, vec![1, 2, 3], Some("fulu")); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::Decode(_)) + )); +} + +#[tokio::test] +async fn malformed_json_does_not_decode() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + let body = br#"{"version":"fulu","data":"#.to_vec(); + relay.answer_headers_with(StatusCode::OK, JSON, body, None); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::Decode(_)) + )); +} + +#[tokio::test] +async fn an_uppercase_version_is_read() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + let bid = bid_from(&relay, ForkName::Fulu); + let body = serde_json::json!({ "version": "FULU", "data": bid.bid }).to_string(); + relay.answer_headers_with(StatusCode::OK, JSON, body.into_bytes(), None); + let got = get_header(&client_for(&relay)).await.unwrap().unwrap(); + assert_eq!(got.fork, ForkName::Fulu); + assert_eq!(got, bid); +} + +#[tokio::test] +async fn a_json_bid_for_another_fork_is_refused() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.ignore_accept(); + relay.offer(bid_from(&relay, ForkName::Deneb)); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::UnsupportedFork(fork)) if fork == "deneb" + )); +} + +#[tokio::test] +async fn an_oversized_answer_is_refused() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.answer_headers_with(StatusCode::OK, SSZ, vec![0; 9 * 1024 * 1024], Some("fulu")); + assert!(matches!( + get_header(&client_for(&relay)).await, + Err(BuilderError::TooLarge { .. }) + )); +} + +#[tokio::test] +async fn an_invalid_url_is_refused_up_front() { + assert!(matches!( + BuilderClient::new("not a url", HEADER_TIMEOUT, "ethlambda/test"), + Err(BuilderError::InvalidUrl(_)) + )); +} From f2fcbbcaec7ec109432fffb38814c49aa9ad5bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:43:08 -0300 Subject: [PATCH 4/7] fix(rpc): refuse a builder URL without an http scheme Url::parse reads `localhost:18550` as scheme `localhost`, so a missing `http://` would only surface as an unreachable builder at the first proposal. The truncation test now checks the cut length exactly, so an empty body would fail it. --- crates/net/rpc/src/beacon/builder_client/mod.rs | 10 +++++++++- crates/net/rpc/src/beacon/builder_client/tests.rs | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/net/rpc/src/beacon/builder_client/mod.rs b/crates/net/rpc/src/beacon/builder_client/mod.rs index bf2e31963..3a9707963 100644 --- a/crates/net/rpc/src/beacon/builder_client/mod.rs +++ b/crates/net/rpc/src/beacon/builder_client/mod.rs @@ -76,7 +76,15 @@ impl BuilderClient { user_agent: &str, ) -> Result { let base_url = base_url.into(); - reqwest::Url::parse(&base_url).map_err(|err| BuilderError::InvalidUrl(err.to_string()))?; + let url = reqwest::Url::parse(&base_url) + .map_err(|err| BuilderError::InvalidUrl(err.to_string()))?; + // `localhost:18550` parses, with `localhost` as its scheme. + if !matches!(url.scheme(), "http" | "https") { + return Err(BuilderError::InvalidUrl(format!( + "expected an http or https url, got scheme {:?}", + url.scheme() + ))); + } let http = reqwest::Client::builder() .user_agent(user_agent) .build() diff --git a/crates/net/rpc/src/beacon/builder_client/tests.rs b/crates/net/rpc/src/beacon/builder_client/tests.rs index f0e2afdea..f49b091b6 100644 --- a/crates/net/rpc/src/beacon/builder_client/tests.rs +++ b/crates/net/rpc/src/beacon/builder_client/tests.rs @@ -207,7 +207,7 @@ async fn a_long_error_is_cut_short() { let Err(BuilderError::BadRequest(message)) = get_header(&client_for(&relay)).await else { panic!("expected a bad request"); }; - assert!(message.len() <= 1024); + assert_eq!(message.len(), 1024); } #[tokio::test] @@ -285,3 +285,11 @@ async fn an_invalid_url_is_refused_up_front() { Err(BuilderError::InvalidUrl(_)) )); } + +#[tokio::test] +async fn a_url_without_a_scheme_is_refused_up_front() { + assert!(matches!( + BuilderClient::new("localhost:18550", HEADER_TIMEOUT, "ethlambda/test"), + Err(BuilderError::InvalidUrl(_)) + )); +} From faa1866ef8ce43e9dc723f00575ddc434d5983ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:44:05 -0300 Subject: [PATCH 5/7] feat(rpc): forward validator registrations to a builder registerValidator in one JSON request, keeping the builder's 400 message so the Beacon API can pass it through. JSON because MEV-Boost forwards a registration's body to every relay as is and reports success if any accepts it, so SSZ would be lost on the relays that do not take it. The mock relay verifies registration signatures under the builder domain, the same check a real relay makes. --- .../src/beacon/builder_client/mock_relay.rs | 78 ++++++++++++++++++- .../net/rpc/src/beacon/builder_client/mod.rs | 48 +++++++++++- .../rpc/src/beacon/builder_client/tests.rs | 29 ++++++- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs index ed4eaa9de..6b8e7681f 100644 --- a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs +++ b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs @@ -8,13 +8,19 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use axum::Router; +use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; -use axum::routing::get; +use axum::routing::{get, post}; +use ethlambda_state_transition::beacon::bls; use ethlambda_state_transition::beacon::helpers::test_state::{secret_key_for, sign_for}; -use ethlambda_types::beacon::builder::{BuilderBid, SignedBuilderBid}; -use ethlambda_types::beacon::primitives::{BlsPubkey, HashTreeRoot as _, Version}; +use ethlambda_types::beacon::builder::{ + BuilderBid, SignedBuilderBid, SignedValidatorRegistrationV1, ValidatorRegistrationV1, +}; +use ethlambda_types::beacon::primitives::{ + BlsPubkey, ExecutionAddress, HashTreeRoot as _, Version, +}; use ethlambda_types::beacon::signing::{compute_builder_domain, compute_signing_root}; use libssz::SszEncode as _; @@ -35,6 +41,7 @@ struct RelayState { bid: Option, header_requests: Vec, raw_answer: Option, + registrations: Vec, } /// A fixed answer to every `getHeader`. @@ -74,6 +81,7 @@ impl MockRelay { "/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}", get(header), ) + .route("/eth/v1/builder/validators", post(register)) .with_state(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); @@ -124,6 +132,10 @@ impl MockRelay { self.state.lock().unwrap().bid = Some(bid); } + pub(crate) fn registrations(&self) -> Vec { + self.state.lock().unwrap().registrations.clone() + } + pub(crate) fn header_requests(&self) -> Vec { self.state.lock().unwrap().header_requests.clone() } @@ -205,3 +217,63 @@ async fn header( ([(header::CONTENT_TYPE, "application/json")], body).into_response() } } + +async fn register(State(state): State, headers: HeaderMap, body: Bytes) -> Response { + let mut state = state.lock().unwrap(); + // The client always registers in JSON (see `register_validators`); an + // SSZ registration here is a regression. + if is_ssz_request(&headers) { + return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); + } + let Ok(registrations) = serde_json::from_slice::>(&body) + else { + return bad_request("invalid JSON"); + }; + let domain = compute_builder_domain(state.genesis_fork_version); + for registration in ®istrations { + let root = compute_signing_root(registration.message.hash_tree_root(), domain); + if !bls::verify(®istration.message.pubkey, root, ®istration.signature) { + return bad_request("invalid signature"); + } + } + state.registrations.extend(registrations); + StatusCode::OK.into_response() +} + +/// Test validator `index`'s registration, signed with its key for the chain +/// whose genesis fork version is `genesis_fork_version`. +pub(crate) fn signed_registration( + index: usize, + genesis_fork_version: Version, +) -> SignedValidatorRegistrationV1 { + let message = ValidatorRegistrationV1 { + fee_recipient: ExecutionAddress::repeat_byte(0x11), + gas_limit: 60_000_000, + timestamp: 1_700_000_000, + pubkey: BlsPubkey(secret_key_for(index).sk_to_pk().to_bytes()), + }; + let domain = compute_builder_domain(genesis_fork_version); + let root = compute_signing_root(message.hash_tree_root(), domain); + SignedValidatorRegistrationV1 { + message, + signature: sign_for(index, root), + } +} + +fn is_ssz_request(headers: &HeaderMap) -> bool { + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with(crate::SSZ_CONTENT_TYPE)) +} + +/// builder-specs' `ErrorMessage` body for a 400. +fn bad_request(message: &str) -> Response { + let body = serde_json::json!({ "code": 400, "message": message }).to_string(); + ( + StatusCode::BAD_REQUEST, + [(header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response() +} diff --git a/crates/net/rpc/src/beacon/builder_client/mod.rs b/crates/net/rpc/src/beacon/builder_client/mod.rs index 3a9707963..e5a96e3b8 100644 --- a/crates/net/rpc/src/beacon/builder_client/mod.rs +++ b/crates/net/rpc/src/beacon/builder_client/mod.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use ethlambda_types::beacon::builder::SignedBuilderBid; +use ethlambda_types::beacon::builder::{SignedBuilderBid, SignedValidatorRegistrationV1}; use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionBlockHash, Slot}; use libssz::SszDecode as _; @@ -46,6 +46,10 @@ const MAX_ERROR_TEXT: usize = 1024; /// JSON acceptable, so a JSON-only builder answers instead of refusing. const ACCEPT_SSZ_FIRST: &str = "application/octet-stream;q=1.0,application/json;q=0.9"; +/// builder-specs gives `registerValidator` no deadline. Ten seconds covers a +/// batch of a few thousand registrations. +const REGISTER_TIMEOUT: Duration = Duration::from_secs(10); + /// A builder API endpoint: a relay, or MEV-Boost in front of several. #[derive(Debug, Clone)] pub struct BuilderClient { @@ -163,6 +167,43 @@ impl BuilderClient { .map_err(|err| BuilderError::from_reqwest(err, self.header_timeout)) } + /// `registerValidator`: forwards signed registrations in one request. + /// + /// Always in JSON. MEV-Boost passes a registration's body and content + /// type to every relay unchanged and answers 200 if any one accepts it, + /// so an SSZ registration would be dropped without a word by every relay + /// that does not take SSZ. + pub async fn register_validators( + &self, + registrations: &[SignedValidatorRegistrationV1], + ) -> Result<(), BuilderError> { + let started = Instant::now(); + let result = self.try_register_validators(registrations).await; + observe_builder_request("register_validators", outcome(&result), started.elapsed()); + result + } + + async fn try_register_validators( + &self, + registrations: &[SignedValidatorRegistrationV1], + ) -> Result<(), BuilderError> { + let body = serde_json::to_vec(registrations) + .map_err(|err| BuilderError::Encode(err.to_string()))?; + let response = self + .http + .post(self.url("/eth/v1/builder/validators")) + .timeout(REGISTER_TIMEOUT) + .header(header::CONTENT_TYPE, JSON_CONTENT_TYPE) + .body(body) + .send() + .await + .map_err(|err| BuilderError::from_reqwest(err, REGISTER_TIMEOUT))?; + match response.status() { + StatusCode::OK => Ok(()), + _ => Err(BuilderError::from_status(response, REGISTER_TIMEOUT).await), + } + } + fn fall_back_to_json(&self) { info!("Builder refused SSZ; using JSON from now on"); self.json_only.store(true, Ordering::Relaxed); @@ -268,6 +309,11 @@ fn is_ssz(headers: &HeaderMap) -> bool { .is_some_and(|value| value.starts_with(crate::SSZ_CONTENT_TYPE)) } +/// How a call that returns nothing on success ended, as a metric label. +fn outcome(result: &Result) -> &'static str { + result.as_ref().map_or_else(BuilderError::label, |_| "ok") +} + fn unix_millis() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/net/rpc/src/beacon/builder_client/tests.rs b/crates/net/rpc/src/beacon/builder_client/tests.rs index f49b091b6..97ea5a04e 100644 --- a/crates/net/rpc/src/beacon/builder_client/tests.rs +++ b/crates/net/rpc/src/beacon/builder_client/tests.rs @@ -8,7 +8,7 @@ use ethlambda_types::beacon::containers::electra::{self, ExecutionRequests}; use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionBlockHash, Uint256, Version}; -use super::mock_relay::MockRelay; +use super::mock_relay::{MockRelay, signed_registration}; use super::{BuilderClient, BuilderError, VersionedBid}; /// Any version will do: the relay and the signers only have to agree on it. @@ -293,3 +293,30 @@ async fn a_url_without_a_scheme_is_refused_up_front() { Err(BuilderError::InvalidUrl(_)) )); } + +#[tokio::test] +async fn registrations_reach_the_relay_in_json() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + let registrations = vec![ + signed_registration(0, GENESIS_FORK_VERSION), + signed_registration(1, GENESIS_FORK_VERSION), + ]; + client_for(&relay) + .register_validators(®istrations) + .await + .unwrap(); + // The relay refuses SSZ registrations, so arriving at all means JSON. + assert_eq!(relay.registrations(), registrations); +} + +#[tokio::test] +async fn a_registration_the_relay_refuses_is_a_bad_request() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + let mut registration = signed_registration(0, GENESIS_FORK_VERSION); + registration.signature = signed_registration(1, GENESIS_FORK_VERSION).signature; + assert!(matches!( + client_for(&relay).register_validators(&[registration]).await, + Err(BuilderError::BadRequest(message)) if message == "invalid signature" + )); + assert!(relay.registrations().is_empty()); +} From 393f5d7d442a0263565fe6076229f1ffa4627a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:44:56 -0300 Subject: [PATCH 6/7] feat(rpc): submit a signed blinded block to a builder submitBlindedBlockV2, the last fulu call. The builder publishes the full block, so only its 202 counts as success. SSZ goes first, since MEV-Boost converts it for relays that lack SSZ; a relay used directly that refuses it gets the block again in JSON, an immediate retry that costs the proposer nothing. --- .../src/beacon/builder_client/mock_relay.rs | 54 ++++++++++++- .../net/rpc/src/beacon/builder_client/mod.rs | 75 +++++++++++++++++- .../rpc/src/beacon/builder_client/tests.rs | 77 ++++++++++++++++++- 3 files changed, 200 insertions(+), 6 deletions(-) diff --git a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs index 6b8e7681f..ee07d800f 100644 --- a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs +++ b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs @@ -16,13 +16,14 @@ use axum::routing::{get, post}; use ethlambda_state_transition::beacon::bls; use ethlambda_state_transition::beacon::helpers::test_state::{secret_key_for, sign_for}; use ethlambda_types::beacon::builder::{ - BuilderBid, SignedBuilderBid, SignedValidatorRegistrationV1, ValidatorRegistrationV1, + BuilderBid, SignedBlindedBeaconBlock, SignedBuilderBid, SignedValidatorRegistrationV1, + ValidatorRegistrationV1, }; use ethlambda_types::beacon::primitives::{ BlsPubkey, ExecutionAddress, HashTreeRoot as _, Version, }; use ethlambda_types::beacon::signing::{compute_builder_domain, compute_signing_root}; -use libssz::SszEncode as _; +use libssz::{SszDecode as _, SszEncode as _}; use super::VersionedBid; @@ -42,6 +43,11 @@ struct RelayState { header_requests: Vec, raw_answer: Option, registrations: Vec, + blinded_blocks: Vec, + /// What `submitBlindedBlockV2` answers; `None` is 202. + submit_status: Option, + /// Submissions that arrived SSZ-encoded, refused or not. + ssz_submissions: usize, } /// A fixed answer to every `getHeader`. @@ -82,6 +88,7 @@ impl MockRelay { get(header), ) .route("/eth/v1/builder/validators", post(register)) + .route("/eth/v2/builder/blinded_blocks", post(submit)) .with_state(state.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); @@ -91,6 +98,7 @@ impl MockRelay { /// Refuse SSZ outright: 406 to an `Accept` that starts with SSZ, as a /// builder that reads only the first media type does. + /// An SSZ `submitBlindedBlockV2` is refused too, with 415. pub(crate) fn refuse_ssz(&self) { self.state.lock().unwrap().refuses_ssz = true; } @@ -132,6 +140,19 @@ impl MockRelay { self.state.lock().unwrap().bid = Some(bid); } + /// Answer every `submitBlindedBlockV2` with `status` instead of 202. + pub(crate) fn answer_submissions_with(&self, status: StatusCode) { + self.state.lock().unwrap().submit_status = Some(status); + } + + pub(crate) fn blinded_blocks(&self) -> Vec { + self.state.lock().unwrap().blinded_blocks.clone() + } + + pub(crate) fn ssz_submissions(&self) -> usize { + self.state.lock().unwrap().ssz_submissions + } + pub(crate) fn registrations(&self) -> Vec { self.state.lock().unwrap().registrations.clone() } @@ -240,6 +261,35 @@ async fn register(State(state): State, headers: HeaderMap, body: Bytes) StatusCode::OK.into_response() } +async fn submit(State(state): State, headers: HeaderMap, body: Bytes) -> Response { + let mut state = state.lock().unwrap(); + let ssz = is_ssz_request(&headers); + if ssz { + state.ssz_submissions += 1; + if state.refuses_ssz { + return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); + } + // builder-specs requires the header on an SSZ submission. + if !headers.contains_key("eth-consensus-version") { + return bad_request("missing Eth-Consensus-Version"); + } + } + let block = if ssz { + SignedBlindedBeaconBlock::from_ssz_bytes(&body).ok() + } else { + serde_json::from_slice(&body).ok() + }; + let Some(block) = block else { + return bad_request("invalid block encoding"); + }; + state.blinded_blocks.push(block); + match state.submit_status { + None => StatusCode::ACCEPTED.into_response(), + Some(StatusCode::BAD_REQUEST) => bad_request("block refused"), + Some(status) => status.into_response(), + } +} + /// Test validator `index`'s registration, signed with its key for the chain /// whose genesis fork version is `genesis_fork_version`. pub(crate) fn signed_registration( diff --git a/crates/net/rpc/src/beacon/builder_client/mod.rs b/crates/net/rpc/src/beacon/builder_client/mod.rs index e5a96e3b8..e9f3f62d0 100644 --- a/crates/net/rpc/src/beacon/builder_client/mod.rs +++ b/crates/net/rpc/src/beacon/builder_client/mod.rs @@ -14,10 +14,12 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use ethlambda_types::beacon::builder::{SignedBuilderBid, SignedValidatorRegistrationV1}; +use ethlambda_types::beacon::builder::{ + SignedBlindedBeaconBlock, SignedBuilderBid, SignedValidatorRegistrationV1, +}; use ethlambda_types::beacon::fork::ForkName; use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionBlockHash, Slot}; -use libssz::SszDecode as _; +use libssz::{SszDecode as _, SszEncode as _}; use reqwest::StatusCode; use reqwest::header::{self, HeaderMap}; use tracing::info; @@ -50,6 +52,10 @@ const ACCEPT_SSZ_FIRST: &str = "application/octet-stream;q=1.0,application/json; /// batch of a few thousand registrations. const REGISTER_TIMEOUT: Duration = Duration::from_secs(10); +/// At least MEV-Boost's own default of four seconds, which it spends +/// fetching the payload from its relays before it answers. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(5); + /// A builder API endpoint: a relay, or MEV-Boost in front of several. #[derive(Debug, Clone)] pub struct BuilderClient { @@ -204,6 +210,63 @@ impl BuilderClient { } } + /// `submitBlindedBlockV2`: hands a signed blinded block to the builder, + /// which publishes the full block and its blobs itself. + /// + /// Only a 202 is success. The proposer is committed once this is sent, + /// so a caller has no local block to fall back on whatever this returns. + pub async fn submit_blinded_block( + &self, + block: &SignedBlindedBeaconBlock, + fork: ForkName, + ) -> Result<(), BuilderError> { + let started = Instant::now(); + let result = self.try_submit_blinded_block(block, fork).await; + observe_builder_request("submit_blinded_block", outcome(&result), started.elapsed()); + result + } + + async fn try_submit_blinded_block( + &self, + block: &SignedBlindedBeaconBlock, + fork: ForkName, + ) -> Result<(), BuilderError> { + let path = "/eth/v2/builder/blinded_blocks"; + if !self.json_only.load(Ordering::Relaxed) { + let response = self + .send_block(path, crate::SSZ_CONTENT_TYPE, block.to_ssz(), fork) + .await?; + // A 415 is this URL refusing SSZ, answered at once: send the + // same block again in JSON, and JSON from now on. + if response.status() != StatusCode::UNSUPPORTED_MEDIA_TYPE { + return submission_result(response).await; + } + self.fall_back_to_json(); + } + let body = + serde_json::to_vec(block).map_err(|err| BuilderError::Encode(err.to_string()))?; + let response = self.send_block(path, JSON_CONTENT_TYPE, body, fork).await?; + submission_result(response).await + } + + async fn send_block( + &self, + path: &str, + content_type: &'static str, + body: Vec, + fork: ForkName, + ) -> Result { + self.http + .post(self.url(path)) + .timeout(SUBMIT_TIMEOUT) + .header(header::CONTENT_TYPE, content_type) + .header("eth-consensus-version", fork.as_str()) + .body(body) + .send() + .await + .map_err(|err| BuilderError::from_reqwest(err, SUBMIT_TIMEOUT)) + } + fn fall_back_to_json(&self) { info!("Builder refused SSZ; using JSON from now on"); self.json_only.store(true, Ordering::Relaxed); @@ -314,6 +377,14 @@ fn outcome(result: &Result) -> &'static str { result.as_ref().map_or_else(BuilderError::label, |_| "ok") } +/// A submission's answer: only 202 is success. +async fn submission_result(response: reqwest::Response) -> Result<(), BuilderError> { + match response.status() { + StatusCode::ACCEPTED => Ok(()), + _ => Err(BuilderError::from_status(response, SUBMIT_TIMEOUT).await), + } +} + fn unix_millis() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/net/rpc/src/beacon/builder_client/tests.rs b/crates/net/rpc/src/beacon/builder_client/tests.rs index 97ea5a04e..596913767 100644 --- a/crates/net/rpc/src/beacon/builder_client/tests.rs +++ b/crates/net/rpc/src/beacon/builder_client/tests.rs @@ -2,11 +2,13 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use axum::http::StatusCode; use axum::http::header::ACCEPT; -use ethlambda_types::beacon::builder::BuilderBid; +use ethlambda_types::beacon::builder::{BlindedBeaconBlock, BuilderBid, SignedBlindedBeaconBlock}; use ethlambda_types::beacon::containers::deneb::{ExecutionPayloadHeader, KzgCommitments}; use ethlambda_types::beacon::containers::electra::{self, ExecutionRequests}; use ethlambda_types::beacon::fork::ForkName; -use ethlambda_types::beacon::primitives::{BlsPubkey, ExecutionBlockHash, Uint256, Version}; +use ethlambda_types::beacon::primitives::{ + BlsPubkey, BlsSignature, ExecutionBlockHash, Root, Uint256, Version, +}; use super::mock_relay::{MockRelay, signed_registration}; use super::{BuilderClient, BuilderError, VersionedBid}; @@ -320,3 +322,74 @@ async fn a_registration_the_relay_refuses_is_a_bad_request() { )); assert!(relay.registrations().is_empty()); } + +fn blinded_block() -> SignedBlindedBeaconBlock { + let block = electra::BeaconBlock { + slot: SLOT, + proposer_index: 3, + parent_root: Root::repeat_byte(1), + state_root: Root::repeat_byte(2), + body: electra::BeaconBlockBody::empty(), + }; + SignedBlindedBeaconBlock { + message: BlindedBeaconBlock::from(block), + signature: BlsSignature::default(), + } +} + +#[tokio::test] +async fn a_blinded_block_reaches_the_relay_in_ssz_with_its_fork() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + let block = blinded_block(); + client_for(&relay) + .submit_blinded_block(&block, ForkName::Fulu) + .await + .unwrap(); + // The relay refuses an SSZ submission that does not name its fork. + assert_eq!(relay.blinded_blocks(), [block]); + assert_eq!(relay.ssz_submissions(), 1); +} + +#[tokio::test] +async fn a_relay_that_refuses_ssz_blocks_is_sent_json_from_then_on() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.refuse_ssz(); + let client = client_for(&relay); + let block = blinded_block(); + + client + .submit_blinded_block(&block, ForkName::Fulu) + .await + .unwrap(); + client + .submit_blinded_block(&block, ForkName::Fulu) + .await + .unwrap(); + + assert_eq!(relay.blinded_blocks(), [block.clone(), block]); + // The first submission tried SSZ, was refused and went again in JSON; + // the second went straight to JSON. + assert_eq!(relay.ssz_submissions(), 1); +} + +#[tokio::test] +async fn a_relay_that_refuses_a_block_says_why() { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.answer_submissions_with(StatusCode::BAD_REQUEST); + assert!(matches!( + client_for(&relay).submit_blinded_block(&blinded_block(), ForkName::Fulu).await, + Err(BuilderError::BadRequest(message)) if message == "block refused" + )); +} + +#[tokio::test] +async fn only_202_is_a_successful_submission() { + for status in [StatusCode::OK, StatusCode::INTERNAL_SERVER_ERROR] { + let relay = MockRelay::start(GENESIS_FORK_VERSION).await; + relay.answer_submissions_with(status); + assert!(matches!( + client_for(&relay).submit_blinded_block(&blinded_block(), ForkName::Fulu).await, + Err(BuilderError::Status { status: answered, .. }) if answered == status.as_u16() + )); + } +} From 4359c29e8df3c13df1ccd7cf30afb8aa6fe43c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:49:15 -0300 Subject: [PATCH 7/7] docs(rpc): describe the builder client's encoding call by call The module doc still described one SSZ-until-refused policy, which the review fixes replaced with a choice per call. The mock relay's doc now covers registrations and submissions, and BuilderError no longer claims every error becomes a 500. --- .../rpc/src/beacon/builder_client/error.rs | 6 ++++-- .../src/beacon/builder_client/mock_relay.rs | 5 ++++- .../net/rpc/src/beacon/builder_client/mod.rs | 19 +++++++++++++------ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/net/rpc/src/beacon/builder_client/error.rs b/crates/net/rpc/src/beacon/builder_client/error.rs index a26b30f3f..6da14b3c1 100644 --- a/crates/net/rpc/src/beacon/builder_client/error.rs +++ b/crates/net/rpc/src/beacon/builder_client/error.rs @@ -8,8 +8,10 @@ use super::{read_body, truncate_error_text}; /// A failed builder API call. /// -/// A Beacon API handler answers [`BadRequest`](Self::BadRequest) with the -/// builder's own 400 and anything else with a 500. +/// A Beacon API handler for registrations or blinded blocks answers +/// [`BadRequest`](Self::BadRequest) with the builder's own 400 and anything +/// else with a 500. On `produceBlockV3` any error just means no bid, and the +/// node builds locally. #[derive(Debug, thiserror::Error)] pub enum BuilderError { /// No complete answer within the call's deadline. diff --git a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs index ee07d800f..01e50834a 100644 --- a/crates/net/rpc/src/beacon/builder_client/mock_relay.rs +++ b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs @@ -2,7 +2,10 @@ //! //! It serves one bid, records what it is asked and sent, and has knobs for //! the ways real builders differ: refusing SSZ, ignoring `Accept`, answering -//! slowly, and leaving out `Eth-Consensus-Version`. +//! slowly, and leaving out `Eth-Consensus-Version`. It also verifies +//! registration signatures and refuses SSZ registrations, records blinded +//! block submissions, and has `answer_headers_with` and +//! `answer_submissions_with` to answer a call with exactly what a test needs. use std::sync::{Arc, Mutex}; use std::time::Duration; diff --git a/crates/net/rpc/src/beacon/builder_client/mod.rs b/crates/net/rpc/src/beacon/builder_client/mod.rs index e9f3f62d0..4ebf7ca87 100644 --- a/crates/net/rpc/src/beacon/builder_client/mod.rs +++ b/crates/net/rpc/src/beacon/builder_client/mod.rs @@ -1,11 +1,18 @@ //! The builder API client (`ethereum/builder-specs`): how this node talks to //! MEV-Boost, or to a relay directly, on a proposer's behalf. //! -//! One client per URL. Requests go out in SSZ until the URL refuses it (406 -//! or 415), then in JSON for good, since a builder either takes SSZ or it -//! does not. Responses are decoded by their own `Content-Type`, not by what -//! was asked for: a builder may answer JSON to an SSZ request without -//! refusing it. +//! One client per URL, with an encoding chosen per call: +//! - `getHeader` asks SSZ first. A 406 fails that call with no retry, since a +//! retry restarts the clock the proposer is waiting on; later calls ask for +//! JSON. +//! - `registerValidator` is always JSON: MEV-Boost forwards the body +//! unchanged to every relay and answers 200 if any one accepts it. +//! - `submitBlindedBlockV2` sends SSZ, which MEV-Boost converts per relay. +//! After its own 415 it resends the block in JSON, and sends JSON from then +//! on. +//! +//! Responses are decoded by their own `Content-Type`, not by what was asked +//! for: a builder may answer JSON to an SSZ request without refusing it. //! //! Every call is timed into `lean_beacon_builder_request_seconds`, by call //! and outcome. @@ -372,7 +379,7 @@ fn is_ssz(headers: &HeaderMap) -> bool { .is_some_and(|value| value.starts_with(crate::SSZ_CONTENT_TYPE)) } -/// How a call that returns nothing on success ended, as a metric label. +/// How a call whose success is just `ok` ended, as a metric label. fn outcome(result: &Result) -> &'static str { result.as_ref().map_or_else(BuilderError::label, |_| "ok") }