diff --git a/Cargo.lock b/Cargo.lock index fcda3ecb..e58458a7 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/common/types/src/beacon/signing.rs b/crates/common/types/src/beacon/signing.rs index 48df7046..e7630a7c 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 { diff --git a/crates/net/rpc/Cargo.toml b/crates/net/rpc/Cargo.toml index d07713af..48b1061a 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 00000000..6da14b3c --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_client/error.rs @@ -0,0 +1,98 @@ +//! What can go wrong talking to a builder. + +use std::time::Duration; + +use reqwest::StatusCode; + +use super::{read_body, truncate_error_text}; + +/// A failed builder API call. +/// +/// 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. + #[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), + /// 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 { + /// 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 { + 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, deadline: Duration) -> Self { + let status = response.status(); + 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: truncate_error_text(body), + }; + } + let message = serde_json::from_str::(&body) + .map(|error| error.message) + .unwrap_or(body); + Self::BadRequest(truncate_error_text(message)) + } + + /// How the call ended, as a metric label. + 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(_) | Self::TooLarge { .. } => "invalid_response", + Self::InvalidUrl(_) => "invalid_url", + 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 00000000..01e50834 --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_client/mock_relay.rs @@ -0,0 +1,332 @@ +//! 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`. 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; + +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, 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, 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::{SszDecode as _, 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, + 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`. +#[derive(Clone)] +struct RawAnswer { + status: StatusCode, + content_type: &'static str, + body: Vec, + consensus_version: Option<&'static str>, +} + +/// 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), + ) + .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()); + 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. + /// An SSZ `submitBlindedBlockV2` is refused too, with 415. + 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 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; + } + + /// Serve `bid` to every `getHeader`. + pub(crate) fn offer(&self, bid: VersionedBid) { + 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() + } + + 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(), + }); + 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()) + .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(); + }; + if wants_ssz && !state.ignores_accept { + let mut response = ( + [(header::CONTENT_TYPE, crate::SSZ_CONTENT_TYPE)], + bid.to_ssz(), + ) + .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() + } +} + +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() +} + +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( + 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 new file mode 100644 index 00000000..4ebf7ca8 --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_client/mod.rs @@ -0,0 +1,399 @@ +//! 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, 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. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +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 _, SszEncode as _}; +use reqwest::StatusCode; +use reqwest::header::{self, HeaderMap}; +use tracing::info; + +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"; + +/// 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"; + +/// builder-specs gives `registerValidator` no deadline. Ten seconds covers a +/// 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 { + 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 base_url = base_url.into(); + 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() + .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, + 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 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), + 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) + } + } + } + + 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)) + } + + /// `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), + } + } + + /// `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); + } + + 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 = read_body(response, deadline).await?; + + if ssz { + // 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()) + })?; + let fork = bid_fork(&version)?; + let bid = SignedBuilderBid::from_ssz_bytes(&body) + .map_err(|err| BuilderError::Decode(format!("{err:?}")))?; + return Ok(VersionedBid { fork, bid }); + } + // 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()))?; + 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 { + match ForkName::parse(version) { + Some(fork @ (ForkName::Electra | ForkName::Fulu)) => Ok(fork), + _ => Err(BuilderError::UnsupportedFork(version.to_owned())), + } +} + +/// `getHeader`'s JSON body holds its fork under `version`... +#[derive(serde::Deserialize)] +struct Version { + version: String, +} + +/// ...and the bid under `data`. +#[derive(serde::Deserialize)] +struct Data { + data: SignedBuilderBid, +} + +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)) +} + +/// 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") +} + +/// 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) + .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 00000000..59691376 --- /dev/null +++ b/crates/net/rpc/src/beacon/builder_client/tests.rs @@ -0,0 +1,395 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::http::StatusCode; +use axum::http::header::ACCEPT; +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, BlsSignature, ExecutionBlockHash, Root, Uint256, Version, +}; + +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. +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); + + // 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; every later one asks + // for JSON only. + let accepts = accept_headers(&relay); + assert_eq!(accepts.len(), 2); + assert!(accepts[0].starts_with("application/octet-stream")); + assert_eq!(accepts[1], "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(_)) + )); +} + +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_eq!(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(_)) + )); +} + +#[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(_)) + )); +} + +#[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()); +} + +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() + )); + } +} diff --git a/crates/net/rpc/src/beacon/mod.rs b/crates/net/rpc/src/beacon/mod.rs index f9ed6706..0c707791 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 9173f0ac..11ddca14 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 b07459e7..bb1529e6 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 1ea07953..48616f7b 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