diff --git a/Cargo.lock b/Cargo.lock index af25c5e3520..76426650d4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1694,16 +1694,42 @@ version = "0.1.0" dependencies = [ "arbitrary", "bls", + "builder_types", "context_deserialize", "eth2", "ethereum_ssz", + "futures", "lighthouse_version", "mockito", + "parking_lot", + "pretty_reqwest_error", "reqwest", "sensitive_url", "serde", "serde_json", "tokio", + "tracing", + "types", +] + +[[package]] +name = "builder_types" +version = "0.1.0" +dependencies = [ + "arbitrary", + "bls", + "builder_types", + "context_deserialize", + "ethereum_serde_utils", + "ethereum_ssz", + "ethereum_ssz_derive", + "sensitive_url", + "serde", + "serde_json", + "ssz_types", + "tree_hash", + "tree_hash_derive", + "typenum", "types", ] @@ -3217,6 +3243,7 @@ version = "0.1.0" dependencies = [ "arbitrary", "bls", + "builder_types", "context_deserialize", "educe", "eip_3076", diff --git a/Cargo.toml b/Cargo.toml index 65cdb23c272..48dbefc8b1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "boot_node", "common/account_utils", "common/axum_utils", + "common/builder_types", "common/clap_utils", "common/deposit_contract", "common/directory", @@ -118,6 +119,8 @@ beacon_processor = { path = "beacon_node/beacon_processor" } bincode = "1" bitvec = "1" bls = { path = "crypto/bls" } +builder_client = { path = "beacon_node/builder_client" } +builder_types = { path = "common/builder_types" } byteorder = "1" bytes = "1.11.1" cargo_metadata = "0.19" diff --git a/beacon_node/builder_client/Cargo.toml b/beacon_node/builder_client/Cargo.toml index a329379160f..e6facfb8c2d 100644 --- a/beacon_node/builder_client/Cargo.toml +++ b/beacon_node/builder_client/Cargo.toml @@ -9,14 +9,19 @@ bls = { workspace = true } context_deserialize = { workspace = true } eth2 = { workspace = true } ethereum_ssz = { workspace = true } +futures = { workspace = true } lighthouse_version = { workspace = true } +parking_lot = { workspace = true } +pretty_reqwest_error = { workspace = true } reqwest = { workspace = true } sensitive_url = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tracing = { workspace = true } [dev-dependencies] arbitrary = { workspace = true } +builder_types = { workspace = true, features = ["arbitrary"] } mockito = { workspace = true } tokio = { workspace = true } types = { workspace = true, features = ["arbitrary"] } diff --git a/beacon_node/builder_client/src/builder_http_client.rs b/beacon_node/builder_client/src/builder_http_client.rs new file mode 100644 index 00000000000..49e84b4adf0 --- /dev/null +++ b/beacon_node/builder_client/src/builder_http_client.rs @@ -0,0 +1,504 @@ +use crate::{ + DEFAULT_USER_AGENT, Error, JSON_ACCEPT_VALUE, PREFERENCE_ACCEPT_VALUE, + content_type_from_header, ok_or_error, success_or_error, +}; +use bls::PublicKeyBytes; +use eth2::types::{ + BuilderPreferencesRequest, ContentType, EthSpec, ExecutionBlockHash, ForkName, + ForkVersionedResponse, Hash256, SignedBeaconBlock, SignedExecutionPayloadBid, + SignedRequestAuth, Slot, +}; +use eth2::{ + CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER, + SSZ_CONTENT_TYPE_HEADER, +}; +use reqwest::StatusCode; +use reqwest::header::{ACCEPT, HeaderMap, HeaderName, HeaderValue}; +use sensitive_url::SensitiveUrl; +use ssz::{Decode, Encode}; +use std::time::Duration; +use tracing::warn; + +/// This is a whole rabbithole.. see discussion: +/// https://discord.com/channels/595666850260713488/874767108809031740/1529125867484348577 +pub const DEFAULT_GET_EXECUTION_PAYLOAD_BID_TIMEOUT_MILLIS: u64 = 400; + +/// Default timeout for builder submit requests (preferences and signed block). +pub const DEFAULT_SUBMIT_TIMEOUT_MILLIS: u64 = 1000; + +/// Header advertising the proposer's request timeout (in milliseconds) to the builder. +const X_TIMEOUT_MS: HeaderName = HeaderName::from_static("x-timeout-ms"); +/// Header carrying the Unix send-time (in milliseconds) so the builder can measure latency. +const DATE_MILLISECONDS: HeaderName = HeaderName::from_static("date-milliseconds"); + +/// A client for the Gloas (ePBS) Builder API. +/// +/// This client is **not** bound to a single builder URL and holds **no** per-connection state: +/// every request takes the target `builder_url` as a parameter, so one instance can fan out to any +/// number of builders. SSZ negotiation is done per-request rather than cached, because in Gloas the +/// bid request and the signed-block submission are separated by a full VC round-trip +/// (produce -> sign -> publish) and so cannot share instance state. +#[derive(Clone)] +pub struct BuilderHttpClient { + client: reqwest::Client, + user_agent: String, + /// Only use json for all request/response types. + disable_ssz: bool, +} + +impl BuilderHttpClient { + pub fn new(user_agent: Option, disable_ssz: bool) -> Result { + let user_agent = user_agent.unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()); + let client = reqwest::Client::builder().user_agent(&user_agent).build()?; + Ok(Self { + client, + user_agent, + disable_ssz, + }) + } + + pub fn get_user_agent(&self) -> &str { + &self.user_agent + } + + /// Build the HTTP headers sent with a `getExecutionPayloadBid` request. + /// + /// Sets three headers: + /// - `Accept`: requests SSZ (with JSON fallback) for the response, or JSON only when + /// `disable_ssz` is set. This governs the (larger) bid response encoding only. + /// - `X-Timeout-Ms`: the proposer's request timeout, measured from `Date-Milliseconds`. The + /// builder must respond within this window; required by the builder spec. + /// - `Date-Milliseconds`: the Unix ms send time, letting the builder estimate transit delay; + /// required by the builder spec. + /// + /// The `Accept` header is best-effort (logged and skipped if it cannot be constructed). The two + /// required timing headers are built from a static timeout and the system clock, so their + /// construction cannot realistically fail. + fn compute_get_execution_payload_bid_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + + let accept_value = if self.disable_ssz { + JSON_ACCEPT_VALUE + } else { + PREFERENCE_ACCEPT_VALUE + }; + + match HeaderValue::from_str(accept_value) { + Ok(accept_header) => { + headers.insert(ACCEPT, accept_header); + } + Err(e) => { + warn!("Invalid accept value: {}", e); + } + } + + // Advertise our timeout to the builder so it can bound its own work. + headers.insert( + X_TIMEOUT_MS, + HeaderValue::from(DEFAULT_GET_EXECUTION_PAYLOAD_BID_TIMEOUT_MILLIS), + ); + + // Timestamp the request (Unix ms) so the builder can measure one-way latency. + match std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + { + Ok(now_millis) => { + headers.insert(DATE_MILLISECONDS, HeaderValue::from(now_millis)); + } + Err(e) => { + warn!("Failed to compute date header: {}", e); + } + } + + headers + } + + /// `POST /eth/v1/builder/execution_payload_bid/{slot}/{parent_hash}/{parent_root}/{proposer_pubkey}` + /// + /// Request a bid from a single builder. Returns `Ok(None)` if the builder has no bid available + /// (HTTP 204). + /// + /// The `SignedRequestAuth` body is required by the builder spec (a builder returns 400 if it + /// is missing), and `RequestAuth` is fork-versioned, so the `Eth-Consensus-Version` header is + /// required too. The body is small and always sent as JSON; SSZ is only negotiated for the + /// (larger) response via the `Accept` header, and the response is decoded according to its + /// `Content-Type`. + #[allow(clippy::too_many_arguments)] + pub async fn get_execution_payload_bid( + &self, + builder_url: &SensitiveUrl, + slot: Slot, + parent_hash: ExecutionBlockHash, + parent_root: Hash256, + proposer_pubkey: &PublicKeyBytes, + signed_request_auth: &SignedRequestAuth, + fork_name: ForkName, + ) -> Result>, Error> { + let mut path = builder_url.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(builder_url.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("execution_payload_bid") + .push(slot.to_string().as_str()) + .push(format!("{parent_hash:?}").as_str()) + .push(format!("{parent_root:?}").as_str()) + .push(proposer_pubkey.as_hex_string().as_str()); + + let timeout = Duration::from_millis(DEFAULT_GET_EXECUTION_PAYLOAD_BID_TIMEOUT_MILLIS); + let headers = self.compute_get_execution_payload_bid_headers(); + // The auth body is tiny; always send it as JSON. SSZ-encoding it buys nothing and avoids + // having to probe the builder's SSZ request-ingest support. + let request = self + .client + .post(path) + .timeout(timeout) + .headers(headers) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) + .json(signed_request_auth); + + let response = ok_or_error(request.send().await.map_err(Error::from)?).await?; + + if response.status() == StatusCode::NO_CONTENT { + return Ok(None); + } + + let response_headers = response.headers().clone(); + let response_bytes = response.bytes().await?; + + match content_type_from_header(&response_headers) { + ContentType::Ssz => { + let bid = SignedExecutionPayloadBid::::from_ssz_bytes(&response_bytes) + .map_err(Error::InvalidSsz)?; + Ok(Some(bid)) + } + ContentType::Json => { + let versioned: ForkVersionedResponse> = + serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson)?; + Ok(Some(versioned.data)) + } + } + } + + /// `POST /eth/v1/builder/builder_preferences/{validator_pubkey}` + /// + /// Submit a validator's builder preferences to a builder ahead of the bid request (typically in + /// the epoch before the proposal, so the builder has them before `getExecutionPayloadBid` + /// arrives). Success is HTTP 202. + /// + /// `BuilderPreferencesRequest` is fork-versioned, so `fork_name` is sent as the required + /// `Eth-Consensus-Version` header (builder-specs #165); the body is small and sent as JSON. + pub async fn submit_builder_preferences( + &self, + builder_url: &SensitiveUrl, + proposer_pubkey: &PublicKeyBytes, + preferences: &BuilderPreferencesRequest, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = builder_url.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(builder_url.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("builder_preferences") + .push(proposer_pubkey.as_hex_string().as_str()); + + let timeout = Duration::from_millis(DEFAULT_SUBMIT_TIMEOUT_MILLIS); + let request = self + .client + .post(path) + .timeout(timeout) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()) + .json(preferences); + + let response = success_or_error(request.send().await.map_err(Error::from)?).await?; + + if response.status() == StatusCode::ACCEPTED { + Ok(()) + } else { + // ACCEPTED is the only valid status code response + Err(Error::StatusCode(response.status())) + } + } + + /// `POST /eth/v1/builder/beacon_blocks` + /// + /// Submit the signed Gloas beacon block to the builder that won selection. On success (HTTP + /// 202) the builder becomes responsible for publishing the execution payload envelope. + /// + /// `ssz_request` selects the request-body encoding: SSZ when `true` and the client has SSZ + /// enabled, otherwise JSON. + pub async fn submit_signed_beacon_block( + &self, + builder_url: &SensitiveUrl, + block: &SignedBeaconBlock, + ssz_request: bool, + ) -> Result<(), Error> { + let mut path = builder_url.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(builder_url.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("beacon_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + let timeout = Duration::from_millis(DEFAULT_SUBMIT_TIMEOUT_MILLIS); + let request = if ssz_request && !self.disable_ssz { + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + self.client + .post(path) + .timeout(timeout) + .headers(headers) + .body(block.as_ssz_bytes()) + } else { + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + self.client + .post(path) + .timeout(timeout) + .headers(headers) + .json(block) + }; + + let response = success_or_error(request.send().await.map_err(Error::from)?).await?; + + if response.status() == StatusCode::ACCEPTED { + Ok(()) + } else { + // ACCEPTED is the only valid status code response + Err(Error::StatusCode(response.status())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arbitrary::Arbitrary; + use eth2::types::beacon_response::EmptyMetadata; + use eth2::types::{ForkName, MainnetEthSpec}; + use mockito::{Matcher, Server, ServerGuard}; + use std::str::FromStr; + + type E = MainnetEthSpec; + + fn client_for() -> BuilderHttpClient { + BuilderHttpClient::new(None, false).unwrap() + } + + fn builder_url(server: &ServerGuard) -> SensitiveUrl { + SensitiveUrl::from_str(&server.url()).unwrap() + } + + fn signed_request_auth() -> SignedRequestAuth { + let mut u = types::test_utils::test_unstructured(); + SignedRequestAuth::arbitrary(&mut u).unwrap() + } + + fn empty_bid_response() -> ForkVersionedResponse> { + ForkVersionedResponse { + version: ForkName::Gloas, + metadata: EmptyMetadata {}, + data: SignedExecutionPayloadBid::empty(), + } + } + + fn mock_bid(server: &mut ServerGuard, content_type: ContentType) { + let body = empty_bid_response(); + let mut mock = server.mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/execution_payload_bid/.+$".to_string()), + ); + mock = match content_type { + ContentType::Json => mock + .with_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) + .with_header(CONSENSUS_VERSION_HEADER, "gloas") + .with_body(serde_json::to_string(&body).unwrap()), + ContentType::Ssz => mock + .with_header(CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER) + .with_header(CONSENSUS_VERSION_HEADER, "gloas") + .with_body(body.data.as_ssz_bytes()), + }; + mock.with_status(200).create(); + } + + async fn request_bid(server: &ServerGuard) -> Option> { + client_for() + .get_execution_payload_bid::( + &builder_url(server), + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + Hash256::repeat_byte(2), + &PublicKeyBytes::empty(), + &signed_request_auth(), + ForkName::Gloas, + ) + .await + .expect("bid request should succeed") + } + + #[tokio::test] + async fn get_execution_payload_bid_json() { + let mut server = Server::new_async().await; + mock_bid(&mut server, ContentType::Json); + let bid = request_bid(&server).await.expect("should have a bid"); + assert_eq!(bid, SignedExecutionPayloadBid::empty()); + } + + #[tokio::test] + async fn get_execution_payload_bid_ssz() { + let mut server = Server::new_async().await; + mock_bid(&mut server, ContentType::Ssz); + let bid = request_bid(&server).await.expect("should have a bid"); + assert_eq!(bid, SignedExecutionPayloadBid::empty()); + } + + #[tokio::test] + async fn submit_builder_preferences_accepted() { + use arbitrary::Arbitrary; + let mut server = Server::new_async().await; + server + .mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/builder_preferences/.+$".to_string()), + ) + .with_status(202) + .create(); + + let mut u = types::test_utils::test_unstructured(); + let preferences = BuilderPreferencesRequest::arbitrary(&mut u).unwrap(); + + client_for() + .submit_builder_preferences( + &builder_url(&server), + &PublicKeyBytes::empty(), + &preferences, + ForkName::Gloas, + ) + .await + .expect("preferences should be accepted"); + } + + /// The bid request must carry the spec-required headers: `Eth-Consensus-Version` (fork of the + /// auth body), `Date-Milliseconds` + `X-Timeout-Ms` (timing), a JSON `Content-Type` for the + /// auth body, and an `Accept` preferring SSZ for the response. + #[tokio::test] + async fn bid_request_sends_expected_headers() { + let mut server = Server::new_async().await; + let mock = server + .mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/execution_payload_bid/.+$".to_string()), + ) + .match_header("accept", PREFERENCE_ACCEPT_VALUE) + .match_header(CONSENSUS_VERSION_HEADER, "gloas") + .match_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) + .match_header( + X_TIMEOUT_MS.as_str(), + DEFAULT_GET_EXECUTION_PAYLOAD_BID_TIMEOUT_MILLIS + .to_string() + .as_str(), + ) + .match_header( + DATE_MILLISECONDS.as_str(), + Matcher::Regex(r"^\d+$".to_string()), + ) + .match_header("user-agent", DEFAULT_USER_AGENT) + .with_status(204) + .create(); + + assert!(request_bid(&server).await.is_none()); + mock.assert_async().await; + } + + /// With SSZ responses disabled, the bid request's `Accept` asks for JSON only. + #[tokio::test] + async fn bid_request_accepts_json_only_when_ssz_disabled() { + let mut server = Server::new_async().await; + let mock = server + .mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/execution_payload_bid/.+$".to_string()), + ) + .match_header("accept", JSON_ACCEPT_VALUE) + .with_status(204) + .create(); + + BuilderHttpClient::new(None, true) + .unwrap() + .get_execution_payload_bid::( + &builder_url(&server), + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + Hash256::repeat_byte(2), + &PublicKeyBytes::empty(), + &signed_request_auth(), + ForkName::Gloas, + ) + .await + .expect("bid request should succeed"); + mock.assert_async().await; + } + + /// The preferences submission must carry `Eth-Consensus-Version` (the body is fork-versioned) + /// and a JSON `Content-Type`. + #[tokio::test] + async fn submit_builder_preferences_sends_expected_headers() { + let mut server = Server::new_async().await; + let mock = server + .mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/builder_preferences/.+$".to_string()), + ) + .match_header(CONSENSUS_VERSION_HEADER, "gloas") + .match_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) + .with_status(202) + .create(); + + let mut u = types::test_utils::test_unstructured(); + let preferences = BuilderPreferencesRequest::arbitrary(&mut u).unwrap(); + client_for() + .submit_builder_preferences( + &builder_url(&server), + &PublicKeyBytes::empty(), + &preferences, + ForkName::Gloas, + ) + .await + .expect("preferences should be accepted"); + mock.assert_async().await; + } + + #[tokio::test] + async fn get_execution_payload_bid_no_content() { + let mut server = Server::new_async().await; + server + .mock( + "POST", + Matcher::Regex(r"^/eth/v1/builder/execution_payload_bid/.+$".to_string()), + ) + .with_status(204) + .create(); + assert!(request_bid(&server).await.is_none()); + } +} diff --git a/beacon_node/builder_client/src/builders.rs b/beacon_node/builder_client/src/builders.rs new file mode 100644 index 00000000000..1342d0c3d51 --- /dev/null +++ b/beacon_node/builder_client/src/builders.rs @@ -0,0 +1,410 @@ +use crate::{BuilderHttpClient, Error as BuilderClientError}; +use bls::PublicKeyBytes; +use eth2::types::{ + BuilderEntry, BuilderPreferenceEntry, BuilderPreferences, BuilderPreferencesRequest, + BuilderPubkeys, EthSpec, ExecutionBlockHash, ForkName, Hash256, SignedBeaconBlock, + SignedExecutionPayloadBid, Slot, +}; +use futures::future::join_all; +use sensitive_url::SensitiveUrl; +use std::fmt::Display; +use std::future::Future; +use std::sync::Arc; +use tracing::{debug, warn}; + +/// A validated direct builder bid, with the provenance and per-builder policy needed to turn it into +/// a selection candidate. +/// +/// The per-builder `min_bid` / `max_execution_payment` / `builder_boost_factor` are carried up as-is; +/// this crate applies no bid math (the `min_bid` floor and the boost are proposer policy resolved on +/// the beacon-chain side). +#[derive(Clone)] +pub struct DirectBid { + /// The signed bid returned by the builder. + pub signed_bid: Arc>, + /// URL of the builder that returned this bid, so a winning block can be forwarded to it via + /// `submitSignedBeaconBlock` (echoed to the beacon node as `Eth-Builder-Url`). + pub builder_url: SensitiveUrl, + /// The proposer's `max_execution_payment` cap for this builder, from its `BuilderEntry`. + pub max_execution_payment: u64, + /// The proposer's `builder_boost_factor` for this builder, from its `BuilderEntry`. + pub builder_boost_factor: u64, + /// The proposer's `min_bid` acceptance floor (gwei) for this builder, from its `BuilderEntry`. + pub min_bid: u64, +} + +/// The per-proposal parameters used to address each `getExecutionPayloadBid` request. +/// +/// Validation of returned bids is performed entirely by the caller's `validate` callback (which has +/// the beacon-chain state), so this only carries what's needed to build the request. +#[derive(Clone)] +pub struct BidRequestContext { + pub slot: Slot, + pub parent_hash: ExecutionBlockHash, + pub parent_root: Hash256, + pub proposer_pubkey: PublicKeyBytes, + /// The active consensus version at `slot`, sent as the required `Eth-Consensus-Version` + /// header on each bid request. + pub fork_name: ForkName, +} + +/// Orchestrates direct builder bid requests. +/// +/// Fans `getExecutionPayloadBid` out to the builders a proposer configured and returns the validated +/// bids for the block producer to rank against the local and gossip payloads. Stateless — it holds +/// no bids between requests. +pub struct Builders { + client: Arc, +} + +/// A single failed builder-preference submission, identified by its position in the submitted list. +pub struct SubmissionFailure { + /// Index of the failing entry in the submitted list. + pub index: usize, + /// Why the submission failed. + pub error: BuilderClientError, +} + +impl Builders { + pub fn new(client: Arc) -> Self { + Self { client } + } + + /// Forward a signed beacon block to the builder that won this slot's bid, via + /// `submitSignedBeaconBlock`. + /// + /// Submitted as JSON: the builder's SSZ preference from bid time isn't carried across the + /// `Eth-Builder-Url` header round-trip, and builders must accept JSON. + pub async fn forward_signed_block( + &self, + builder_url: &SensitiveUrl, + block: &SignedBeaconBlock, + ) -> Result<(), BuilderClientError> { + self.client + .submit_signed_beacon_block(builder_url, block, false) + .await + } + + /// Request bids from every builder in `entries` concurrently, validate them, and return the + /// valid ones. + /// + /// Every entry is a bid request to its `url`, which beacon-APIs #630 requires (a zero-length url + /// is invalid); an entry whose `url` is empty, malformed, or not http(s) can't be requested and + /// is skipped. One request is made **per entry** — several entries MAY share a `url` with + /// different `auth`, so requests are not de-duplicated by URL (#630 forbids two entries sharing + /// both a `url` and their `auth`'s `data`). + /// + /// Each builder runs in its own pipeline — request, then the producer-supplied `validate` + /// callback, which performs *all* bid validation against the block producer's advanced beacon + /// state (consensus consistency, builder eligibility, collateral, and the BLS signature). The + /// entry's `builder_pubkeys` filter (empty accepts any builder) is passed to `validate` so it + /// can enforce that the bid is signed by one of the expected builders — the state and signing + /// domain that check needs live on the producer side, not here. The per-builder `min_bid` floor + /// is likewise a proposer policy applied by the caller (see `DirectBid::min_bid`), not here. These + /// pipelines run + /// **concurrently across builders**, so a slow builder or an expensive validation for one bid + /// does not hold up the others. A failure, timeout, empty (204) response, or validation error + /// for one builder is isolated: it is logged and that bid is skipped. + /// + /// Returns every bid that passed validation; the block producer turns each into a selection + /// candidate and ranks them. + pub async fn request_and_validate_bids( + &self, + ctx: &BidRequestContext, + entries: &[BuilderEntry], + validate: F, + ) -> Vec> + where + F: Fn(Arc>, BuilderPubkeys) -> Fut, + Fut: Future>, + Err: Display, + { + // Resolve each entry to a `(resolved_url, entry)` target. Every entry must carry a valid url + // (#630); one that's empty, malformed, or non-http(s) can't be requested and is skipped. One + // request is made per entry (no URL de-duplication). + let mut targets = Vec::new(); + for entry in entries { + let url = match entry.url.to_sensitive_url() { + Ok(url) => url, + Err(e) => { + warn!(error = ?e, "Skipping builder entry with a malformed URL"); + continue; + } + }; + if !matches!(url.expose_full().scheme(), "http" | "https") { + warn!(url = ?url, "Skipping builder entry with an unsupported URL scheme"); + continue; + } + targets.push((url, entry)); + } + + // Run one pipeline per builder — request, then the producer's `validate` callback — and let + // them run concurrently across builders. Each request carries its own timeout, so a slow + // builder cannot delay the others. + let client = &self.client; + let validate = &validate; + let pipelines = targets.iter().map(|(url, entry)| async move { + let response = client + .get_execution_payload_bid::( + url, + ctx.slot, + ctx.parent_hash, + ctx.parent_root, + &ctx.proposer_pubkey, + &entry.auth, + ctx.fork_name, + ) + .await; + + match response { + Ok(Some(bid)) => { + let direct_bid = DirectBid { + signed_bid: Arc::new(bid), + builder_url: url.clone(), + max_execution_payment: entry.max_execution_payment, + builder_boost_factor: entry.builder_boost_factor, + min_bid: entry.min_bid, + }; + + if let Err(error) = + validate(direct_bid.signed_bid.clone(), entry.builder_pubkeys.clone()).await + { + warn!(url = ?url, %error, "Builder bid failed validation"); + return None; + } + Some(direct_bid) + } + Ok(None) => { + debug!(url = ?url, "Builder returned no bid"); + None + } + Err(error) => { + warn!(url = ?url, error = %error, "Builder bid request failed"); + None + } + } + }); + + join_all(pipelines).await.into_iter().flatten().collect() + } + + /// Submit a proposer's builder preferences to each entry's builder, concurrently and + /// best-effort. + /// + /// One submission is made per entry — entries are **not** de-duplicated by URL, since + /// beacon-APIs #630 allows several entries to share a `url`. Each submission is isolated: a + /// malformed URL or a failed request is recorded against that entry's index and never aborts the + /// others. The submissions run **concurrently**, so a slow builder cannot delay the rest. + /// + /// Returns `Ok(())` when every entry was submitted, or the per-entry [`SubmissionFailure`]s by + /// index. + pub async fn submit_builder_preferences( + &self, + entries: Vec, + fork_name: ForkName, + ) -> Result<(), Vec> { + let client = &self.client; + let submissions = entries + .into_iter() + .enumerate() + .map(|(index, entry)| async move { + let url = entry + .url + .to_sensitive_url() + .map_err(|e| SubmissionFailure { + index, + error: e.into(), + })?; + let request = BuilderPreferencesRequest::new( + BuilderPreferences { + max_execution_payment: entry.max_execution_payment, + }, + entry.auth, + ); + client + .submit_builder_preferences(&url, &entry.proposer_pubkey, &request, fork_name) + .await + .map_err(|error| SubmissionFailure { index, error }) + }); + + let failures: Vec = join_all(submissions) + .await + .into_iter() + .filter_map(Result::err) + .collect(); + + if failures.is_empty() { + Ok(()) + } else { + Err(failures) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bls::Signature; + use eth2::types::beacon_response::EmptyMetadata; + use eth2::types::{ + ExecutionPayloadBid, ForkName, ForkVersionedResponse, MainnetEthSpec, RequestAuth, + RequestAuthData, SignedExecutionPayloadBid, SignedRequestAuth, + }; + use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER}; + use mockito::{Matcher, Mock, Server, ServerGuard}; + + type E = MainnetEthSpec; + + const BID_PATH: &str = r"^/eth/v1/builder/execution_payload_bid/.+$"; + + fn entry(url: &str, max_execution_payment: u64) -> BuilderEntry { + BuilderEntry { + url: url.parse().unwrap(), + auth: SignedRequestAuth { + message: RequestAuth { + data: RequestAuthData::new(url.as_bytes().to_vec()).unwrap(), + slot: Slot::new(1), + }, + signature: Signature::empty(), + }, + builder_pubkeys: BuilderPubkeys::default(), + max_execution_payment, + min_bid: 0, + builder_boost_factor: 100, + } + } + + fn bid_body(value: u64) -> String { + let body = ForkVersionedResponse { + version: ForkName::Gloas, + metadata: EmptyMetadata {}, + data: SignedExecutionPayloadBid:: { + message: ExecutionPayloadBid { + slot: Slot::new(1), + parent_block_hash: ExecutionBlockHash::zero(), + parent_block_root: Hash256::ZERO, + value, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }, + }; + serde_json::to_string(&body).unwrap() + } + + fn mock_bid(server: &mut ServerGuard, value: u64) -> Mock { + server + .mock("POST", Matcher::Regex(BID_PATH.to_string())) + .with_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) + .with_header(CONSENSUS_VERSION_HEADER, "gloas") + .with_body(bid_body(value)) + .with_status(200) + .create() + } + + fn context() -> BidRequestContext { + BidRequestContext { + slot: Slot::new(1), + parent_hash: ExecutionBlockHash::zero(), + parent_root: Hash256::ZERO, + proposer_pubkey: PublicKeyBytes::empty(), + fork_name: ForkName::Gloas, + } + } + + fn builders() -> Builders { + Builders::new(Arc::new(BuilderHttpClient::new(None, false).unwrap())) + } + + #[tokio::test] + async fn fans_out_and_returns_all_valid_bids() { + let mut server_a = Server::new_async().await; + let mut server_b = Server::new_async().await; + mock_bid(&mut server_a, 100); + mock_bid(&mut server_b, 200); + + let builders = builders(); + let entries = vec![entry(&server_a.url(), 1000), entry(&server_b.url(), 1000)]; + + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Ok::<(), String>(()) + }) + .await; + let mut values: Vec = bids.iter().map(|b| b.signed_bid.message.value).collect(); + values.sort_unstable(); + assert_eq!(values, vec![100, 200]); + } + + #[tokio::test] + async fn skips_invalid_url_entry() { + let builders = builders(); + // #630 requires a url; an empty one is invalid and can't be requested, so it is skipped. + let entries = vec![entry("", 1000)]; + + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Ok::<(), String>(()) + }) + .await; + assert!(bids.is_empty()); + } + + #[tokio::test] + async fn requests_each_entry_even_when_url_is_shared() { + let mut server = Server::new_async().await; + // Two entries share a URL but carry different `auth`, so both are requested (one per entry). + let mock = mock_bid(&mut server, 100).expect(2); + + let builders = builders(); + let entry_a = entry(&server.url(), 1000); + let mut entry_b = entry(&server.url(), 1000); + entry_b.auth.message.slot = Slot::new(2); + let entries = vec![entry_a, entry_b]; + + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Ok::<(), String>(()) + }) + .await; + assert_eq!(bids.len(), 2); + mock.assert(); + } + + #[tokio::test] + async fn returns_bid_carrying_min_bid_for_the_caller() { + // The transport layer does not enforce the `min_bid` floor: it returns the bid carrying its + // entry's `min_bid` for the beacon-chain-side caller to enforce. + let mut server = Server::new_async().await; + mock_bid(&mut server, 100); + + let builders = builders(); + let mut entry = entry(&server.url(), 1000); + entry.min_bid = 500; + let entries = vec![entry]; + + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Ok::<(), String>(()) + }) + .await; + assert_eq!(bids.len(), 1); + assert_eq!(bids[0].min_bid, 500); + } + + #[tokio::test] + async fn rejects_bid_failing_producer_validation() { + let mut server = Server::new_async().await; + mock_bid(&mut server, 100); + + let builders = builders(); + let entries = vec![entry(&server.url(), 1000)]; + // The producer callback rejects the bid (e.g. a failed signature or ineligible builder). + let bids: Vec> = builders + .request_and_validate_bids(&context(), &entries, |_bid, _expected| async { + Err::<(), String>("rejected by producer".to_string()) + }) + .await; + assert!(bids.is_empty()); + } +} diff --git a/beacon_node/builder_client/src/error.rs b/beacon_node/builder_client/src/error.rs new file mode 100644 index 00000000000..1fe4af0fa43 --- /dev/null +++ b/beacon_node/builder_client/src/error.rs @@ -0,0 +1,94 @@ +//! The error type for the Gloas [`BuilderHttpClient`](crate::BuilderHttpClient), aligned with the +//! Builder API spec (`builder-specs`). +//! +//! This is deliberately separate from `eth2::Error` (the beacon-node API client's error), which +//! carries beacon-node concerns irrelevant to a builder — API tokens, impostor-signature headers, +//! server-sent events — and collapses every builder-spec status (204 no-bid, 401 auth failed, +//! 406/415 negotiation) into an opaque status code. The pre-Gloas builder client still uses +//! `eth2::Error`. + +use eth2::types::{BuilderUrlError, ErrorMessage}; +use pretty_reqwest_error::PrettyReqwestError; +use reqwest::{Response, StatusCode}; +use sensitive_url::SensitiveUrl; +use std::fmt; + +#[derive(Debug)] +pub enum Error { + /// A transport-level failure sending the request or reading the response. + Reqwest(PrettyReqwestError), + /// A builder URL could not be turned into a request URL. + InvalidUrl(SensitiveUrl), + /// A `BuilderUrl` supplied in config did not parse as a URL. + InvalidBuilderUrl(BuilderUrlError), + /// The builder returned an error response with a parseable `{code, message}` body (the + /// builder-specs `ErrorMessage`), e.g. 400 invalid request or 401 authentication failed. + ServerMessage(ErrorMessage), + /// The builder returned a non-success status whose body could not be parsed. + StatusCode(StatusCode), + /// The builder's JSON response could not be decoded. + InvalidJson(serde_json::Error), + /// The builder's SSZ response could not be decoded. + InvalidSsz(ssz::DecodeError), + /// Request headers could not be constructed. + InvalidHeaders(String), +} + +impl From for Error { + fn from(error: reqwest::Error) -> Self { + Error::Reqwest(error.into()) + } +} + +impl From for Error { + fn from(error: BuilderUrlError) -> Self { + Error::InvalidBuilderUrl(error) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Reqwest(e) => write!(f, "HTTP transport error: {e}"), + Error::InvalidUrl(url) => write!(f, "invalid builder URL: {url:?}"), + Error::InvalidBuilderUrl(e) => write!(f, "invalid builder URL: {e:?}"), + Error::ServerMessage(m) => { + write!(f, "builder returned error {}: {}", m.code, m.message) + } + Error::StatusCode(status) => write!(f, "builder returned unexpected status {status}"), + Error::InvalidJson(e) => write!(f, "invalid JSON response: {e}"), + Error::InvalidSsz(e) => write!(f, "invalid SSZ response: {e:?}"), + Error::InvalidHeaders(e) => write!(f, "invalid response headers: {e}"), + } + } +} + +impl std::error::Error for Error {} + +/// Returns `Ok(response)` for a builder success status (200/202/204), otherwise parses the body +/// into an [`Error`]. Mirrors `eth2::ok_or_error` but produces the builder-spec [`Error`]. +pub async fn ok_or_error(response: Response) -> Result { + let status = response.status(); + if matches!( + status, + StatusCode::OK | StatusCode::ACCEPTED | StatusCode::NO_CONTENT + ) { + Ok(response) + } else if let Ok(message) = response.json::().await { + Err(Error::ServerMessage(message)) + } else { + Err(Error::StatusCode(status)) + } +} + +/// Like [`ok_or_error`] but accepts any 2xx status as success. +pub async fn success_or_error(response: Response) -> Result { + let status = response.status(); + if status.is_success() { + Ok(response) + } else if let Ok(message) = response.json::().await { + Err(Error::ServerMessage(message)) + } else { + Err(Error::StatusCode(status)) + } +} diff --git a/beacon_node/builder_client/src/lib.rs b/beacon_node/builder_client/src/lib.rs index bd064ca8bf9..d0f7dafde80 100644 --- a/beacon_node/builder_client/src/lib.rs +++ b/beacon_node/builder_client/src/lib.rs @@ -1,32 +1,17 @@ -use bls::PublicKeyBytes; -use context_deserialize::ContextDeserialize; -pub use eth2::Error; -use eth2::types::beacon_response::EmptyMetadata; -use eth2::types::builder::SignedBuilderBid; -use eth2::types::{ - ContentType, EthSpec, ExecutionBlockHash, ForkName, ForkVersionDecode, ForkVersionedResponse, - SignedValidatorRegistrationData, Slot, -}; -use eth2::types::{FullPayloadContents, SignedBlindedBeaconBlock}; -use eth2::{ - CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER, - SSZ_CONTENT_TYPE_HEADER, ok_or_error, success_or_error, -}; -use reqwest::header::{ACCEPT, HeaderMap, HeaderValue}; -use reqwest::{IntoUrl, Response, StatusCode}; -use sensitive_url::SensitiveUrl; -use serde::Serialize; -use serde::de::DeserializeOwned; -use ssz::Encode; +use eth2::types::{ContentType, ForkName}; +use eth2::{CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; +use reqwest::header::HeaderMap; use std::str::FromStr; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; -pub const DEFAULT_TIMEOUT_MILLIS: u64 = 15000; +pub mod builder_http_client; +pub mod builders; +pub mod error; +pub mod pre_gloas_builder_http_client; -/// This timeout is in accordance with v0.2.0 of the [builder specs](https://github.com/flashbots/mev-boost/pull/20). -pub const DEFAULT_GET_HEADER_TIMEOUT_MILLIS: u64 = 1000; +pub use builder_http_client::BuilderHttpClient; +pub use builders::{BidRequestContext, Builders, DirectBid, SubmissionFailure}; +pub use error::{Error, ok_or_error, success_or_error}; +pub use pre_gloas_builder_http_client::PreGloasBuilderHttpClient; /// Default user agent for HTTP requests. pub const DEFAULT_USER_AGENT: &str = lighthouse_version::VERSION; @@ -36,667 +21,28 @@ pub const PREFERENCE_ACCEPT_VALUE: &str = "application/octet-stream;q=1.0,applic /// Only accept json responses. pub const JSON_ACCEPT_VALUE: &str = "application/json"; -#[derive(Clone)] -pub struct Timeouts { - get_header: Duration, - post_validators: Duration, - post_blinded_blocks: Duration, - get_builder_status: Duration, -} - -impl Timeouts { - fn new(get_header_timeout: Option) -> Self { - let get_header = - get_header_timeout.unwrap_or(Duration::from_millis(DEFAULT_GET_HEADER_TIMEOUT_MILLIS)); - - Self { - get_header, - post_validators: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), - post_blinded_blocks: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), - get_builder_status: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), - } - } -} - -#[derive(Clone)] -pub struct BuilderHttpClient { - client: reqwest::Client, - server: SensitiveUrl, - timeouts: Timeouts, - user_agent: String, - /// Only use json for all requests/responses types. - disable_ssz: bool, - /// Indicates that the `get_header` response had content-type ssz - /// so we can set content-type header to ssz to make the `submit_blinded_blocks` - /// request. - ssz_available: Arc, -} - -impl BuilderHttpClient { - pub fn new( - server: SensitiveUrl, - user_agent: Option, - builder_header_timeout: Option, - disable_ssz: bool, - ) -> Result { - let user_agent = user_agent.unwrap_or(DEFAULT_USER_AGENT.to_string()); - let client = reqwest::Client::builder().user_agent(&user_agent).build()?; - Ok(Self { - client, - server, - timeouts: Timeouts::new(builder_header_timeout), - user_agent, - disable_ssz, - ssz_available: Arc::new(false.into()), +/// Parse the `Eth-Consensus-Version` response header into a `ForkName`, if present. +pub fn fork_name_from_header(headers: &HeaderMap) -> Result, String> { + headers + .get(CONSENSUS_VERSION_HEADER) + .map(|fork_name| { + fork_name + .to_str() + .map_err(|e| e.to_string()) + .and_then(ForkName::from_str) }) - } - - pub fn get_user_agent(&self) -> &str { - &self.user_agent - } - - fn fork_name_from_header(&self, headers: &HeaderMap) -> Result, String> { - headers - .get(CONSENSUS_VERSION_HEADER) - .map(|fork_name| { - fork_name - .to_str() - .map_err(|e| e.to_string()) - .and_then(ForkName::from_str) - }) - .transpose() - } - - fn content_type_from_header(&self, headers: &HeaderMap) -> ContentType { - let Some(content_type) = headers.get(CONTENT_TYPE_HEADER).map(|content_type| { - let content_type = content_type.to_str(); - match content_type { - Ok(SSZ_CONTENT_TYPE_HEADER) => ContentType::Ssz, - _ => ContentType::Json, - } - }) else { - return ContentType::Json; - }; - content_type - } - - async fn get_with_header< - T: DeserializeOwned + ForkVersionDecode + for<'de> ContextDeserialize<'de, ForkName>, - U: IntoUrl, - >( - &self, - url: U, - timeout: Duration, - headers: HeaderMap, - ) -> Result, Error> { - let response = self - .get_response_with_header(url, Some(timeout), headers) - .await?; - - let headers = response.headers().clone(); - let response_bytes = response.bytes().await?; - - let Ok(Some(fork_name)) = self.fork_name_from_header(&headers) else { - // if no fork version specified, attempt to fallback to JSON - self.ssz_available.store(false, Ordering::SeqCst); - return serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson); - }; - - let content_type = self.content_type_from_header(&headers); - - match content_type { - ContentType::Ssz => { - self.ssz_available.store(true, Ordering::SeqCst); - T::from_ssz_bytes_by_fork(&response_bytes, fork_name) - .map(|data| ForkVersionedResponse { - version: fork_name, - metadata: EmptyMetadata {}, - data, - }) - .map_err(Error::InvalidSsz) - } - ContentType::Json => { - self.ssz_available.store(false, Ordering::SeqCst); - serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson) - } - } - } - - /// Return `true` if the most recently received response from the builder had SSZ Content-Type. - /// Return `false` otherwise. - /// Also returns `false` if we have explicitly disabled ssz. - pub fn is_ssz_available(&self) -> bool { - !self.disable_ssz && self.ssz_available.load(Ordering::SeqCst) - } - - async fn get_with_timeout( - &self, - url: U, - timeout: Duration, - ) -> Result { - self.get_response_with_timeout(url, Some(timeout)) - .await? - .json() - .await - .map_err(Into::into) - } - - /// Perform a HTTP GET request, returning the `Response` for further processing. - async fn get_response_with_header( - &self, - url: U, - timeout: Option, - headers: HeaderMap, - ) -> Result { - let mut builder = self.client.get(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - let response = builder.headers(headers).send().await.map_err(Error::from)?; - ok_or_error(response).await - } - - /// Perform a HTTP GET request, returning the `Response` for further processing. - async fn get_response_with_timeout( - &self, - url: U, - timeout: Option, - ) -> Result { - let mut builder = self.client.get(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - let response = builder.send().await.map_err(Error::from)?; - ok_or_error(response).await - } - - /// Generic POST function supporting arbitrary responses and timeouts. - async fn post_generic( - &self, - url: U, - body: &T, - timeout: Option, - ) -> Result { - let mut builder = self.client.post(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - let response = builder.json(body).send().await?; - ok_or_error(response).await - } - - async fn post_ssz_with_raw_response( - &self, - url: U, - ssz_body: Vec, - headers: HeaderMap, - timeout: Option, - ) -> Result { - let mut builder = self.client.post(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - - let response = builder - .headers(headers) - .body(ssz_body) - .send() - .await - .map_err(Error::from)?; - success_or_error(response).await - } - - async fn post_with_raw_response( - &self, - url: U, - body: &T, - headers: HeaderMap, - timeout: Option, - ) -> Result { - let mut builder = self.client.post(url); - if let Some(timeout) = timeout { - builder = builder.timeout(timeout); - } - - let response = builder - .headers(headers) - .json(body) - .send() - .await - .map_err(Error::from)?; - success_or_error(response).await - } - - /// `POST /eth/v1/builder/validators` - pub async fn post_builder_validators( - &self, - validator: &[SignedValidatorRegistrationData], - ) -> Result<(), Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("validators"); - - self.post_generic(path, &validator, Some(self.timeouts.post_validators)) - .await?; - Ok(()) - } - - /// `POST /eth/v1/builder/blinded_blocks` with SSZ serialized request body - pub async fn post_builder_blinded_blocks_v1_ssz( - &self, - blinded_block: &SignedBlindedBeaconBlock, - ) -> Result, Error> { - let mut path = self.server.expose_full().clone(); - - let body = blinded_block.as_ssz_bytes(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("blinded_blocks"); - - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - CONTENT_TYPE_HEADER, - HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - ACCEPT, - HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - - let result = self - .post_ssz_with_raw_response( - path, - body, - headers, - Some(self.timeouts.post_blinded_blocks), - ) - .await? - .bytes() - .await?; - - FullPayloadContents::from_ssz_bytes_by_fork(&result, blinded_block.fork_name_unchecked()) - .map_err(Error::InvalidSsz) - } - - /// `POST /eth/v2/builder/blinded_blocks` with SSZ serialized request body - pub async fn post_builder_blinded_blocks_v2_ssz( - &self, - blinded_block: &SignedBlindedBeaconBlock, - ) -> Result<(), Error> { - let mut path = self.server.expose_full().clone(); - - let body = blinded_block.as_ssz_bytes(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v2") - .push("builder") - .push("blinded_blocks"); - - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - CONTENT_TYPE_HEADER, - HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - ACCEPT, - HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - - let result = self - .post_ssz_with_raw_response( - path, - body, - headers, - Some(self.timeouts.post_blinded_blocks), - ) - .await?; - - if result.status() == StatusCode::ACCEPTED { - Ok(()) - } else { - // ACCEPTED is the only valid status code response - Err(Error::StatusCode(result.status())) - } - } - - /// `POST /eth/v1/builder/blinded_blocks` - pub async fn post_builder_blinded_blocks_v1( - &self, - blinded_block: &SignedBlindedBeaconBlock, - ) -> Result>, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("blinded_blocks"); - - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - CONTENT_TYPE_HEADER, - HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - ACCEPT, - HeaderValue::from_str(JSON_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - - Ok(self - .post_with_raw_response( - path, - &blinded_block, - headers, - Some(self.timeouts.post_blinded_blocks), - ) - .await? - .json() - .await?) - } - - /// `POST /eth/v2/builder/blinded_blocks` - pub async fn post_builder_blinded_blocks_v2( - &self, - blinded_block: &SignedBlindedBeaconBlock, - ) -> Result<(), Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v2") - .push("builder") - .push("blinded_blocks"); - - let mut headers = HeaderMap::new(); - headers.insert( - CONSENSUS_VERSION_HEADER, - HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - CONTENT_TYPE_HEADER, - HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - headers.insert( - ACCEPT, - HeaderValue::from_str(JSON_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - - let result = self - .post_with_raw_response( - path, - &blinded_block, - headers, - Some(self.timeouts.post_blinded_blocks), - ) - .await?; - - if result.status() == StatusCode::ACCEPTED { - Ok(()) - } else { - // ACCEPTED is the only valid status code response - Err(Error::StatusCode(result.status())) - } - } - - /// `GET /eth/v1/builder/header` - pub async fn get_builder_header( - &self, - slot: Slot, - parent_hash: ExecutionBlockHash, - pubkey: &PublicKeyBytes, - ) -> Result>>, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("header") - .push(slot.to_string().as_str()) - .push(format!("{parent_hash:?}").as_str()) - .push(pubkey.as_hex_string().as_str()); - - let mut headers = HeaderMap::new(); - if self.disable_ssz { - headers.insert( - ACCEPT, - HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - } else { - // Indicate preference for ssz response in the accept header - headers.insert( - ACCEPT, - HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) - .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, - ); - } - - let resp = self - .get_with_header(path, self.timeouts.get_header, headers) - .await; - - if matches!(resp, Err(Error::StatusCode(StatusCode::NO_CONTENT))) { - Ok(None) - } else { - resp.map(Some) - } - } - - /// `GET /eth/v1/builder/status` - pub async fn get_builder_status(&self) -> Result<(), Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("eth") - .push("v1") - .push("builder") - .push("status"); - - self.get_with_timeout(path, self.timeouts.get_builder_status) - .await - } + .transpose() } -#[cfg(test)] -mod tests { - use super::*; - use arbitrary::Arbitrary; - use bls::Signature; - use eth2::types::MainnetEthSpec; - use eth2::types::builder::{BuilderBid, BuilderBidFulu}; - use mockito::{Matcher, Server, ServerGuard}; - - type E = MainnetEthSpec; - - #[test] - fn test_headers_no_panic() { - for fork in ForkName::list_all() { - assert!(HeaderValue::from_str(&fork.to_string()).is_ok()); - } - assert!(HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE).is_ok()); - assert!(HeaderValue::from_str(JSON_ACCEPT_VALUE).is_ok()); - assert!(HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER).is_ok()); - } - - #[tokio::test] - async fn test_get_builder_header_ssz_response() { - // Set up mock server - let mut server = Server::new_async().await; - let mock_response_body = fulu_signed_builder_bid(); - mock_get_header_response( - &mut server, - Some("fulu"), - ContentType::Ssz, - mock_response_body.clone(), - ); - - let builder_client = BuilderHttpClient::new( - SensitiveUrl::from_str(&server.url()).unwrap(), - None, - None, - false, - ) - .unwrap(); - - let response = builder_client - .get_builder_header( - Slot::new(1), - ExecutionBlockHash::repeat_byte(1), - &PublicKeyBytes::empty(), - ) - .await - .expect("should succeed in get_builder_header") - .expect("should have response body"); - - assert_eq!(response, mock_response_body); - } - - #[tokio::test] - async fn test_get_builder_header_json_response() { - // Set up mock server - let mut server = Server::new_async().await; - let mock_response_body = fulu_signed_builder_bid(); - mock_get_header_response( - &mut server, - None, - ContentType::Json, - mock_response_body.clone(), - ); - - let builder_client = BuilderHttpClient::new( - SensitiveUrl::from_str(&server.url()).unwrap(), - None, - None, - false, - ) - .unwrap(); - - let response = builder_client - .get_builder_header( - Slot::new(1), - ExecutionBlockHash::repeat_byte(1), - &PublicKeyBytes::empty(), - ) - .await - .expect("should succeed in get_builder_header") - .expect("should have response body"); - - assert_eq!(response, mock_response_body); - } - - #[tokio::test] - async fn test_get_builder_header_no_version_header_fallback_json() { - // Set up mock server - let mut server = Server::new_async().await; - let mock_response_body = fulu_signed_builder_bid(); - mock_get_header_response( - &mut server, - Some("fulu"), - ContentType::Json, - mock_response_body.clone(), - ); - - let builder_client = BuilderHttpClient::new( - SensitiveUrl::from_str(&server.url()).unwrap(), - None, - None, - false, - ) - .unwrap(); - - let response = builder_client - .get_builder_header( - Slot::new(1), - ExecutionBlockHash::repeat_byte(1), - &PublicKeyBytes::empty(), - ) - .await - .expect("should succeed in get_builder_header") - .expect("should have response body"); - - assert_eq!(response, mock_response_body); - } - - fn mock_get_header_response( - server: &mut ServerGuard, - header_version_opt: Option<&str>, - content_type: ContentType, - response_body: ForkVersionedResponse>, - ) { - let mut mock = server.mock( - "GET", - Matcher::Regex(r"^/eth/v1/builder/header/\d+/.+/.+$".to_string()), - ); - - if let Some(version) = header_version_opt { - mock = mock.with_header(CONSENSUS_VERSION_HEADER, version); - } - - match content_type { - ContentType::Json => { - mock = mock - .with_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) - .with_body(serde_json::to_string(&response_body).unwrap()); - } - ContentType::Ssz => { - mock = mock - .with_header(CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER) - .with_body(response_body.data.as_ssz_bytes()); - } - } - - mock.with_status(200).create(); - } - - fn fulu_signed_builder_bid() -> ForkVersionedResponse> { - let mut u = types::test_utils::test_unstructured(); - ForkVersionedResponse { - version: ForkName::Fulu, - metadata: EmptyMetadata {}, - data: SignedBuilderBid { - message: BuilderBid::Fulu(BuilderBidFulu::arbitrary(&mut u).unwrap()), - signature: Signature::empty(), - }, - } +/// Determine the `ContentType` of a response from its `Content-Type` header. +/// +/// Defaults to JSON when the header is absent or unrecognized. +pub fn content_type_from_header(headers: &HeaderMap) -> ContentType { + match headers + .get(CONTENT_TYPE_HEADER) + .and_then(|content_type| content_type.to_str().ok()) + { + Some(SSZ_CONTENT_TYPE_HEADER) => ContentType::Ssz, + _ => ContentType::Json, } } diff --git a/beacon_node/builder_client/src/pre_gloas_builder_http_client.rs b/beacon_node/builder_client/src/pre_gloas_builder_http_client.rs new file mode 100644 index 00000000000..75428a0247b --- /dev/null +++ b/beacon_node/builder_client/src/pre_gloas_builder_http_client.rs @@ -0,0 +1,676 @@ +use crate::{ + DEFAULT_USER_AGENT, JSON_ACCEPT_VALUE, PREFERENCE_ACCEPT_VALUE, content_type_from_header, + fork_name_from_header, +}; +use bls::PublicKeyBytes; +// The pre-Gloas builder client keeps the beacon-node API client's error type, unlike the Gloas +// `BuilderHttpClient` which has its own builder-spec-aligned `crate::Error`. +use context_deserialize::ContextDeserialize; +use eth2::Error; +use eth2::types::beacon_response::EmptyMetadata; +use eth2::types::builder::SignedBuilderBid; +use eth2::types::{ + ContentType, EthSpec, ExecutionBlockHash, ForkName, ForkVersionDecode, ForkVersionedResponse, + SignedValidatorRegistrationData, Slot, +}; +use eth2::types::{FullPayloadContents, SignedBlindedBeaconBlock}; +use eth2::{ + CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER, + SSZ_CONTENT_TYPE_HEADER, ok_or_error, success_or_error, +}; +use reqwest::header::{ACCEPT, HeaderMap, HeaderValue}; +use reqwest::{IntoUrl, Response, StatusCode}; +use sensitive_url::SensitiveUrl; +use serde::Serialize; +use serde::de::DeserializeOwned; +use ssz::Encode; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +/// Default timeout for builder requests without a more specific timeout. +pub const DEFAULT_TIMEOUT_MILLIS: u64 = 15000; + +/// This timeout is in accordance with v0.2.0 of the [builder specs](https://github.com/flashbots/mev-boost/pull/20). +pub const DEFAULT_GET_HEADER_TIMEOUT_MILLIS: u64 = 1000; + +#[derive(Clone)] +pub struct Timeouts { + get_header: Duration, + post_validators: Duration, + post_blinded_blocks: Duration, + get_builder_status: Duration, +} + +impl Timeouts { + fn new(get_header_timeout: Option) -> Self { + let get_header = + get_header_timeout.unwrap_or(Duration::from_millis(DEFAULT_GET_HEADER_TIMEOUT_MILLIS)); + + Self { + get_header, + post_validators: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), + post_blinded_blocks: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), + get_builder_status: Duration::from_millis(DEFAULT_TIMEOUT_MILLIS), + } + } +} + +#[derive(Clone)] +pub struct PreGloasBuilderHttpClient { + client: reqwest::Client, + server: SensitiveUrl, + timeouts: Timeouts, + user_agent: String, + /// Only use json for all requests/responses types. + disable_ssz: bool, + /// Indicates that the `get_header` response had content-type ssz + /// so we can set content-type header to ssz to make the `submit_blinded_blocks` + /// request. + ssz_available: Arc, +} + +impl PreGloasBuilderHttpClient { + pub fn new( + server: SensitiveUrl, + user_agent: Option, + builder_header_timeout: Option, + disable_ssz: bool, + ) -> Result { + let user_agent = user_agent.unwrap_or(DEFAULT_USER_AGENT.to_string()); + let client = reqwest::Client::builder().user_agent(&user_agent).build()?; + Ok(Self { + client, + server, + timeouts: Timeouts::new(builder_header_timeout), + user_agent, + disable_ssz, + ssz_available: Arc::new(false.into()), + }) + } + + pub fn get_user_agent(&self) -> &str { + &self.user_agent + } + + async fn get_with_header< + T: DeserializeOwned + ForkVersionDecode + for<'de> ContextDeserialize<'de, ForkName>, + U: IntoUrl, + >( + &self, + url: U, + timeout: Duration, + headers: HeaderMap, + ) -> Result, Error> { + let response = self + .get_response_with_header(url, Some(timeout), headers) + .await?; + + let headers = response.headers().clone(); + let response_bytes = response.bytes().await?; + + let Ok(Some(fork_name)) = fork_name_from_header(&headers) else { + // if no fork version specified, attempt to fallback to JSON + self.ssz_available.store(false, Ordering::SeqCst); + return serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson); + }; + + let content_type = content_type_from_header(&headers); + + match content_type { + ContentType::Ssz => { + self.ssz_available.store(true, Ordering::SeqCst); + T::from_ssz_bytes_by_fork(&response_bytes, fork_name) + .map(|data| ForkVersionedResponse { + version: fork_name, + metadata: EmptyMetadata {}, + data, + }) + .map_err(Error::InvalidSsz) + } + ContentType::Json => { + self.ssz_available.store(false, Ordering::SeqCst); + serde_json::from_slice(&response_bytes).map_err(Error::InvalidJson) + } + } + } + + /// Return `true` if the most recently received response from the builder had SSZ Content-Type. + /// Return `false` otherwise. + /// Also returns `false` if we have explicitly disabled ssz. + pub fn is_ssz_available(&self) -> bool { + !self.disable_ssz && self.ssz_available.load(Ordering::SeqCst) + } + + async fn get_with_timeout( + &self, + url: U, + timeout: Duration, + ) -> Result { + self.get_response_with_timeout(url, Some(timeout)) + .await? + .json() + .await + .map_err(Into::into) + } + + /// Perform a HTTP GET request, returning the `Response` for further processing. + async fn get_response_with_header( + &self, + url: U, + timeout: Option, + headers: HeaderMap, + ) -> Result { + let mut builder = self.client.get(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + let response = builder.headers(headers).send().await.map_err(Error::from)?; + ok_or_error(response).await + } + + /// Perform a HTTP GET request, returning the `Response` for further processing. + async fn get_response_with_timeout( + &self, + url: U, + timeout: Option, + ) -> Result { + let mut builder = self.client.get(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + let response = builder.send().await.map_err(Error::from)?; + ok_or_error(response).await + } + + /// Generic POST function supporting arbitrary responses and timeouts. + async fn post_generic( + &self, + url: U, + body: &T, + timeout: Option, + ) -> Result { + let mut builder = self.client.post(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + let response = builder.json(body).send().await?; + ok_or_error(response).await + } + + async fn post_ssz_with_raw_response( + &self, + url: U, + ssz_body: Vec, + headers: HeaderMap, + timeout: Option, + ) -> Result { + let mut builder = self.client.post(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + + let response = builder + .headers(headers) + .body(ssz_body) + .send() + .await + .map_err(Error::from)?; + success_or_error(response).await + } + + async fn post_with_raw_response( + &self, + url: U, + body: &T, + headers: HeaderMap, + timeout: Option, + ) -> Result { + let mut builder = self.client.post(url); + if let Some(timeout) = timeout { + builder = builder.timeout(timeout); + } + + let response = builder + .headers(headers) + .json(body) + .send() + .await + .map_err(Error::from)?; + success_or_error(response).await + } + + /// `POST /eth/v1/builder/validators` + pub async fn post_builder_validators( + &self, + validator: &[SignedValidatorRegistrationData], + ) -> Result<(), Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("validators"); + + self.post_generic(path, &validator, Some(self.timeouts.post_validators)) + .await?; + Ok(()) + } + + /// `POST /eth/v1/builder/blinded_blocks` with SSZ serialized request body + pub async fn post_builder_blinded_blocks_v1_ssz( + &self, + blinded_block: &SignedBlindedBeaconBlock, + ) -> Result, Error> { + let mut path = self.server.expose_full().clone(); + + let body = blinded_block.as_ssz_bytes(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("blinded_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + ACCEPT, + HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + let result = self + .post_ssz_with_raw_response( + path, + body, + headers, + Some(self.timeouts.post_blinded_blocks), + ) + .await? + .bytes() + .await?; + + FullPayloadContents::from_ssz_bytes_by_fork(&result, blinded_block.fork_name_unchecked()) + .map_err(Error::InvalidSsz) + } + + /// `POST /eth/v2/builder/blinded_blocks` with SSZ serialized request body + pub async fn post_builder_blinded_blocks_v2_ssz( + &self, + blinded_block: &SignedBlindedBeaconBlock, + ) -> Result<(), Error> { + let mut path = self.server.expose_full().clone(); + + let body = blinded_block.as_ssz_bytes(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v2") + .push("builder") + .push("blinded_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(SSZ_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + ACCEPT, + HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + let result = self + .post_ssz_with_raw_response( + path, + body, + headers, + Some(self.timeouts.post_blinded_blocks), + ) + .await?; + + if result.status() == StatusCode::ACCEPTED { + Ok(()) + } else { + // ACCEPTED is the only valid status code response + Err(Error::StatusCode(result.status())) + } + } + + /// `POST /eth/v1/builder/blinded_blocks` + pub async fn post_builder_blinded_blocks_v1( + &self, + blinded_block: &SignedBlindedBeaconBlock, + ) -> Result>, Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("blinded_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + ACCEPT, + HeaderValue::from_str(JSON_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + Ok(self + .post_with_raw_response( + path, + &blinded_block, + headers, + Some(self.timeouts.post_blinded_blocks), + ) + .await? + .json() + .await?) + } + + /// `POST /eth/v2/builder/blinded_blocks` + pub async fn post_builder_blinded_blocks_v2( + &self, + blinded_block: &SignedBlindedBeaconBlock, + ) -> Result<(), Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v2") + .push("builder") + .push("blinded_blocks"); + + let mut headers = HeaderMap::new(); + headers.insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&blinded_block.fork_name_unchecked().to_string()) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + CONTENT_TYPE_HEADER, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + headers.insert( + ACCEPT, + HeaderValue::from_str(JSON_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + + let result = self + .post_with_raw_response( + path, + &blinded_block, + headers, + Some(self.timeouts.post_blinded_blocks), + ) + .await?; + + if result.status() == StatusCode::ACCEPTED { + Ok(()) + } else { + // ACCEPTED is the only valid status code response + Err(Error::StatusCode(result.status())) + } + } + + /// `GET /eth/v1/builder/header` + pub async fn get_builder_header( + &self, + slot: Slot, + parent_hash: ExecutionBlockHash, + pubkey: &PublicKeyBytes, + ) -> Result>>, Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("header") + .push(slot.to_string().as_str()) + .push(format!("{parent_hash:?}").as_str()) + .push(pubkey.as_hex_string().as_str()); + + let mut headers = HeaderMap::new(); + if self.disable_ssz { + headers.insert( + ACCEPT, + HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + } else { + // Indicate preference for ssz response in the accept header + headers.insert( + ACCEPT, + HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE) + .map_err(|e| Error::InvalidHeaders(format!("{}", e)))?, + ); + } + + let resp = self + .get_with_header(path, self.timeouts.get_header, headers) + .await; + + if matches!(resp, Err(Error::StatusCode(StatusCode::NO_CONTENT))) { + Ok(None) + } else { + resp.map(Some) + } + } + + /// `GET /eth/v1/builder/status` + pub async fn get_builder_status(&self) -> Result<(), Error> { + let mut path = self.server.expose_full().clone(); + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("eth") + .push("v1") + .push("builder") + .push("status"); + + self.get_with_timeout(path, self.timeouts.get_builder_status) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arbitrary::Arbitrary; + use bls::Signature; + use eth2::types::MainnetEthSpec; + use eth2::types::builder::{BuilderBid, BuilderBidFulu}; + use mockito::{Matcher, Server, ServerGuard}; + use std::str::FromStr; + + type E = MainnetEthSpec; + + #[test] + fn test_headers_no_panic() { + for fork in ForkName::list_all() { + assert!(HeaderValue::from_str(&fork.to_string()).is_ok()); + } + assert!(HeaderValue::from_str(PREFERENCE_ACCEPT_VALUE).is_ok()); + assert!(HeaderValue::from_str(JSON_ACCEPT_VALUE).is_ok()); + assert!(HeaderValue::from_str(JSON_CONTENT_TYPE_HEADER).is_ok()); + } + + #[tokio::test] + async fn test_get_builder_header_ssz_response() { + // Set up mock server + let mut server = Server::new_async().await; + let mock_response_body = fulu_signed_builder_bid(); + mock_get_header_response( + &mut server, + Some("fulu"), + ContentType::Ssz, + mock_response_body.clone(), + ); + + let builder_client = PreGloasBuilderHttpClient::new( + SensitiveUrl::from_str(&server.url()).unwrap(), + None, + None, + false, + ) + .unwrap(); + + let response = builder_client + .get_builder_header( + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + &PublicKeyBytes::empty(), + ) + .await + .expect("should succeed in get_builder_header") + .expect("should have response body"); + + assert_eq!(response, mock_response_body); + } + + #[tokio::test] + async fn test_get_builder_header_json_response() { + // Set up mock server + let mut server = Server::new_async().await; + let mock_response_body = fulu_signed_builder_bid(); + mock_get_header_response( + &mut server, + None, + ContentType::Json, + mock_response_body.clone(), + ); + + let builder_client = PreGloasBuilderHttpClient::new( + SensitiveUrl::from_str(&server.url()).unwrap(), + None, + None, + false, + ) + .unwrap(); + + let response = builder_client + .get_builder_header( + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + &PublicKeyBytes::empty(), + ) + .await + .expect("should succeed in get_builder_header") + .expect("should have response body"); + + assert_eq!(response, mock_response_body); + } + + #[tokio::test] + async fn test_get_builder_header_no_version_header_fallback_json() { + // Set up mock server + let mut server = Server::new_async().await; + let mock_response_body = fulu_signed_builder_bid(); + mock_get_header_response( + &mut server, + Some("fulu"), + ContentType::Json, + mock_response_body.clone(), + ); + + let builder_client = PreGloasBuilderHttpClient::new( + SensitiveUrl::from_str(&server.url()).unwrap(), + None, + None, + false, + ) + .unwrap(); + + let response = builder_client + .get_builder_header( + Slot::new(1), + ExecutionBlockHash::repeat_byte(1), + &PublicKeyBytes::empty(), + ) + .await + .expect("should succeed in get_builder_header") + .expect("should have response body"); + + assert_eq!(response, mock_response_body); + } + + fn mock_get_header_response( + server: &mut ServerGuard, + header_version_opt: Option<&str>, + content_type: ContentType, + response_body: ForkVersionedResponse>, + ) { + let mut mock = server.mock( + "GET", + Matcher::Regex(r"^/eth/v1/builder/header/\d+/.+/.+$".to_string()), + ); + + if let Some(version) = header_version_opt { + mock = mock.with_header(CONSENSUS_VERSION_HEADER, version); + } + + match content_type { + ContentType::Json => { + mock = mock + .with_header(CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER) + .with_body(serde_json::to_string(&response_body).unwrap()); + } + ContentType::Ssz => { + mock = mock + .with_header(CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER) + .with_body(response_body.data.as_ssz_bytes()); + } + } + + mock.with_status(200).create(); + } + + fn fulu_signed_builder_bid() -> ForkVersionedResponse> { + let mut u = types::test_utils::test_unstructured(); + ForkVersionedResponse { + version: ForkName::Fulu, + metadata: EmptyMetadata {}, + data: SignedBuilderBid { + message: BuilderBid::Fulu(BuilderBidFulu::arbitrary(&mut u).unwrap()), + signature: Signature::empty(), + }, + } + } +} diff --git a/beacon_node/execution_layer/Cargo.toml b/beacon_node/execution_layer/Cargo.toml index 0d90cdaf2f2..a04e065aa9f 100644 --- a/beacon_node/execution_layer/Cargo.toml +++ b/beacon_node/execution_layer/Cargo.toml @@ -11,7 +11,7 @@ alloy-rlp = { workspace = true } alloy-rpc-types-eth = { workspace = true } arc-swap = "1.6.0" bls = { workspace = true } -builder_client = { path = "../builder_client" } +builder_client = { workspace = true } bytes = { workspace = true } eth2 = { workspace = true, features = ["events", "lighthouse", "network"] } ethereum_serde_utils = { workspace = true } diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 3aff96c9b15..048e232d567 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -65,7 +65,6 @@ pub enum Error { DeserializeWithdrawals(ssz_types::Error), DeserializeDepositRequests(ssz_types::Error), DeserializeWithdrawalRequests(ssz_types::Error), - BuilderApi(builder_client::Error), IncorrectStateVariant, RequiredMethodUnsupported(&'static str), UnsupportedForkVariant(String), @@ -98,12 +97,6 @@ impl From for Error { } } -impl From for Error { - fn from(e: builder_client::Error) -> Self { - Error::BuilderApi(e) - } -} - impl From for Error { fn from(e: ssz_types::Error) -> Self { Error::SszError(e) diff --git a/beacon_node/execution_layer/src/engines.rs b/beacon_node/execution_layer/src/engines.rs index aac170d48c1..bc1516a4b89 100644 --- a/beacon_node/execution_layer/src/engines.rs +++ b/beacon_node/execution_layer/src/engines.rs @@ -115,7 +115,6 @@ struct PayloadIdCacheKey { pub enum EngineError { Offline, Api { error: EngineApiError }, - BuilderApi { error: EngineApiError }, Auth, } diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 239ffdb4e60..5c94a5fd65a 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -10,7 +10,7 @@ use arc_swap::ArcSwapOption; use auth::{Auth, JwtKey, strip_prefix}; pub use block_hash::calculate_execution_block_hash; use bls::{PublicKeyBytes, Signature}; -use builder_client::BuilderHttpClient; +use builder_client::PreGloasBuilderHttpClient; pub use engine_api::EngineCapabilities; use engine_api::Error as ApiError; pub use engine_api::*; @@ -138,7 +138,8 @@ pub enum Error { NoEngine, NoPayloadBuilder, ApiError(ApiError), - Builder(builder_client::Error), + // The pre-Gloas builder client uses the beacon-node API client's error type. + Builder(eth2::Error), NoHeaderFromBuilder, CannotProduceHeader, EngineError(Box), @@ -464,7 +465,7 @@ type PayloadContentsRefTuple<'a, E> = (ExecutionPayloadRef<'a, E>, Option<&'a Bl struct Inner { engine: Arc, - builder: ArcSwapOption, + builder: ArcSwapOption, execution_engine_forkchoice_lock: Mutex<()>, suggested_fee_recipient: Option
, proposer_preparation_data: Mutex>, @@ -603,7 +604,7 @@ impl ExecutionLayer { &self.inner.engine } - pub fn builder(&self) -> Option> { + pub fn builder(&self) -> Option> { self.inner.builder.load_full() } @@ -618,7 +619,7 @@ impl ExecutionLayer { builder_header_timeout: Option, disable_ssz: bool, ) -> Result<(), Error> { - let builder_client = BuilderHttpClient::new( + let builder_client = PreGloasBuilderHttpClient::new( builder_url.clone(), builder_user_agent, builder_header_timeout, @@ -1045,11 +1046,11 @@ impl ExecutionLayer { /// Fetches local and builder paylaods concurrently, Logs and returns results. async fn fetch_builder_and_local_payloads( &self, - builder: &BuilderHttpClient, + builder: &PreGloasBuilderHttpClient, builder_params: &BuilderParams, payload_parameters: PayloadParameters<'_>, ) -> ( - Result>>, builder_client::Error>, + Result>>, eth2::Error>, Result, Error>, ) { let slot = builder_params.slot; diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index f84a998923e..8894bf9ce5a 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -172,6 +172,7 @@ pub fn build_response_v4( consensus_block_value: consensus_block_value_wei, execution_payload_value, execution_payload_included, + builder_url: None, }; let add_v4_headers = |res: Response| { diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 6d80344e943..4d2be52a0d5 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -76,7 +76,11 @@ pub async fn gossip_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -140,7 +144,11 @@ pub async fn gossip_partial_pass() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert_eq!(response.unwrap().status(), StatusCode::ACCEPTED); } @@ -180,6 +188,7 @@ pub async fn gossip_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -228,7 +237,7 @@ pub async fn gossip_full_pass_ssz() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_contents, validation_level) + .post_beacon_blocks_v2_ssz(&block_contents, validation_level, None) .await; assert!(response.is_ok()); @@ -277,7 +286,11 @@ pub async fn consensus_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -339,7 +352,11 @@ pub async fn consensus_gossip() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -463,6 +480,7 @@ pub async fn consensus_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -514,7 +532,11 @@ pub async fn equivocation_invalid() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -587,7 +609,8 @@ pub async fn equivocation_consensus_early_equivocation() { .client .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), blobs_a), - validation_level + validation_level, + None ) .await .is_ok() @@ -605,6 +628,7 @@ pub async fn equivocation_consensus_early_equivocation() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_b.clone(), blobs_b), validation_level, + None, ) .await; assert!(response.is_err()); @@ -656,7 +680,11 @@ pub async fn equivocation_gossip() { let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&PublishBlockRequest::new(block, blobs), validation_level) + .post_beacon_blocks_v2_ssz( + &PublishBlockRequest::new(block, blobs), + validation_level, + None, + ) .await; assert!(response.is_err()); @@ -786,6 +814,7 @@ pub async fn equivocation_full_pass() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), blobs), validation_level, + None, ) .await; @@ -1631,6 +1660,7 @@ pub async fn block_seen_on_gossip_without_blobs_or_columns() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some(blobs)), validation_level, + None, ) .await; @@ -1716,6 +1746,7 @@ pub async fn block_seen_on_gossip_with_columns() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some(blobs)), validation_level, + None, ) .await; @@ -1787,6 +1818,7 @@ pub async fn columns_seen_on_gossip_without_block() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block.clone(), Some((kzg_proofs, blobs))), validation_level, + None, ) .await; @@ -1861,6 +1893,7 @@ async fn columns_seen_on_gossip_without_block_and_no_http_columns() { Some((Default::default(), Default::default())), ), validation_level, + None, ) .await; @@ -1931,6 +1964,7 @@ async fn slashable_columns_seen_on_gossip_cause_failure() { .post_beacon_blocks_v2_ssz( &PublishBlockRequest::new(block_a.clone(), Some((kzg_proofs_a, blobs_a))), validation_level, + None, ) .await; @@ -2001,7 +2035,7 @@ pub async fn duplicate_block_status_code() { let block_request = PublishBlockRequest::new(block.clone(), Some((kzg_proofs, blobs))); let response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_request, validation_level) + .post_beacon_blocks_v2_ssz(&block_request, validation_level, None) .await; // This should result in the block being fully imported. @@ -2016,7 +2050,7 @@ pub async fn duplicate_block_status_code() { // Post again. let duplicate_response: Result = tester .client - .post_beacon_blocks_v2_ssz(&block_request, validation_level) + .post_beacon_blocks_v2_ssz(&block_request, validation_level, None) .await; let err = duplicate_response.unwrap_err(); assert_eq!(err.status().unwrap(), duplicate_block_status_code); diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index ae54f110140..38bcd05a77c 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -1987,7 +1987,7 @@ impl ApiTester { let next_block = &self.next_block; self.client - .post_beacon_blocks_v2_ssz(next_block, None) + .post_beacon_blocks_v2_ssz(next_block, None, None) .await .unwrap(); @@ -2085,7 +2085,7 @@ impl ApiTester { .await .unwrap(), self.client - .post_beacon_blocks_v2_ssz(&block_contents, None) + .post_beacon_blocks_v2_ssz(&block_contents, None, None) .await .unwrap(), self.client @@ -4544,7 +4544,7 @@ impl ApiTester { block_contents.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); self.client - .post_beacon_blocks_v2_ssz(&signed_block_contents, None) + .post_beacon_blocks_v2_ssz(&signed_block_contents, None, None) .await .unwrap(); @@ -4666,7 +4666,7 @@ impl ApiTester { block_contents.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); self.client - .post_beacon_blocks_v2_ssz(&signed_block_contents, None) + .post_beacon_blocks_v2_ssz(&signed_block_contents, None, None) .await .unwrap(); @@ -4976,14 +4976,13 @@ impl ApiTester { let mut url = self .client - .get_validator_blocks_v4_path( + .post_validator_blocks_v4_path( slot, &randao_reveal, None, SkipRandaoVerification::No, false, None, - None, ) .await .unwrap(); @@ -5272,7 +5271,7 @@ impl ApiTester { let signed_block_request = PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); self.client - .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .post_beacon_blocks_v2_ssz(&signed_block_request, None, None) .await .unwrap(); assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); @@ -5347,7 +5346,7 @@ impl ApiTester { PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); if ssz { self.client - .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .post_beacon_blocks_v2_ssz(&signed_block_request, None, None) .await .unwrap(); } else { @@ -9217,23 +9216,21 @@ impl ApiTester { self } - async fn get_validator_blocks_v4_path_graffiti_policy(self) -> Self { + async fn post_validator_blocks_v4_path_graffiti_policy(self) -> Self { let slot = self.chain.slot().unwrap(); let epoch = self.chain.epoch().unwrap(); let (_, randao_reveal) = self.get_test_randao(slot, epoch).await; let graffiti = Some(Graffiti::from([0; GRAFFITI_BYTES_LEN])); - let builder_boost_factor = None; // When GraffitiPolicy is None let no_graffiti_policy_path = self .client - .get_validator_blocks_v4_path( + .post_validator_blocks_v4_path( slot, &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, None, ) .await @@ -9242,13 +9239,12 @@ impl ApiTester { // Default case where GraffitiPolicy is AppendClientVersions let default_path = self .client - .get_validator_blocks_v4_path( + .post_validator_blocks_v4_path( slot, &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, Some(GraffitiPolicy::AppendClientVersions), ) .await @@ -9267,13 +9263,12 @@ impl ApiTester { let preserve_path = self .client - .get_validator_blocks_v4_path( + .post_validator_blocks_v4_path( slot, &randao_reveal, graffiti.as_ref(), SkipRandaoVerification::Yes, false, - builder_boost_factor, Some(GraffitiPolicy::PreserveUserGraffiti), ) .await @@ -10884,7 +10879,7 @@ async fn get_validator_blocks_http_api_path() { .await .get_validator_blocks_v3_path_graffiti_policy() .await - .get_validator_blocks_v4_path_graffiti_policy() + .post_validator_blocks_v4_path_graffiti_policy() .await; } diff --git a/common/builder_types/Cargo.toml b/common/builder_types/Cargo.toml new file mode 100644 index 00000000000..ee7c5476e5e --- /dev/null +++ b/common/builder_types/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "builder_types" +version = "0.1.0" +authors = ["Sigma Prime "] +edition = { workspace = true } + +# The `arbitrary`-enabled self dependency below is only used to turn the feature on for the +# SSZ/tree-hash test macros, so `cargo-udeps` can't see it being used. +[package.metadata.cargo-udeps.ignore] +development = ["builder_types"] + +[features] +default = [] +arbitrary = [ + "dep:arbitrary", + "types/arbitrary", + "bls/arbitrary", + "ethereum_ssz/arbitrary", + "ssz_types/arbitrary", +] + +[dependencies] +arbitrary = { workspace = true, features = ["derive"], optional = true } +bls = { workspace = true } +context_deserialize = { workspace = true } +ethereum_serde_utils = { workspace = true } +ethereum_ssz = { workspace = true } +ethereum_ssz_derive = { workspace = true } +sensitive_url = { workspace = true } +serde = { workspace = true } +ssz_types = { workspace = true } +tree_hash = { workspace = true } +tree_hash_derive = { workspace = true } +typenum = { workspace = true } +types = { workspace = true } + +[dev-dependencies] +# Self-dependency with the `arbitrary` feature enabled so the SSZ/tree-hash test macros (which build +# instances via `types::test_utils::test_arbitrary_instance`) work in unit tests. Mirrors the pattern +# in `consensus/types`. +builder_types = { path = ".", features = ["arbitrary"] } +serde_json = { workspace = true } diff --git a/common/builder_types/src/builder_config.rs b/common/builder_types/src/builder_config.rs new file mode 100644 index 00000000000..b0b0933571a --- /dev/null +++ b/common/builder_types/src/builder_config.rs @@ -0,0 +1,78 @@ +use crate::{BuilderEntry, MaxBuilderEntries}; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; + +/// The builder config the validator client sends on a block-production request, per +/// [beacon-APIs #630](https://github.com/ethereum/beacon-APIs/pull/630). +/// +/// `builders` are the direct bid requests. Every field of every entry carries a concrete value on +/// the wire: any defaulting the validator client offers in its own configuration (such as +/// per-builder overrides inheriting file-level defaults, or auth data defaulting to the builder's +/// URL) is applied client-side before this type is built, and the entry's request auth is already +/// signed. In particular, the top-level `min_bid` and `builder_boost_factor` are *not* defaults +/// for the entries: they govern only a bid that matches no entry — in practice, a bid received +/// over p2p. +/// +/// SSZ container (field order per the spec — SSZ and tree-hash depend on it): +/// ```text +/// class BuilderConfig(Container): +/// min_bid: Gwei +/// builder_boost_factor: uint64 +/// builders: List[BuilderEntry, MAX_BUILDER_ENTRIES] +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderConfig { + /// Minimum total payment (Gwei) accepted from a bid that matches no entry (a p2p bid). + #[serde(with = "serde_utils::quoted_u64")] + pub min_bid: u64, + /// Percentage multiplier applied to a bid that matches no entry (a p2p bid). + #[serde(with = "serde_utils::quoted_u64")] + pub builder_boost_factor: u64, + /// The builders to request bids from directly. Empty means only p2p bids are considered. + pub builders: VariableList, +} + +impl BuilderConfig { + /// An empty config: no direct builders, with the documented compatibility defaults for the + /// p2p bid policy (`min_bid = 0`, `builder_boost_factor = 100`). + /// + /// Sent when the validator has no builder support configured, so a Gloas proposal still falls + /// back to local and p2p payloads. + pub fn empty() -> Self { + Self { + min_bid: 0, + builder_boost_factor: 100, + builders: VariableList::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderConfig); + + #[test] + fn json_shape() { + let config = BuilderConfig { + min_bid: 5, + builder_boost_factor: 100, + builders: VariableList::default(), + }; + let json = serde_json::to_value(&config).unwrap(); + let obj = json.as_object().unwrap(); + // `builders` is a JSON array; the Gwei/uint64 fields are quoted strings. + assert!(obj["builders"].is_array()); + assert_eq!(obj["min_bid"], "5"); + assert_eq!(obj["builder_boost_factor"], "100"); + + assert_eq!( + serde_json::from_value::(json).unwrap(), + config + ); + } +} diff --git a/common/builder_types/src/builder_entry.rs b/common/builder_types/src/builder_entry.rs new file mode 100644 index 00000000000..275da974cf1 --- /dev/null +++ b/common/builder_types/src/builder_entry.rs @@ -0,0 +1,130 @@ +use crate::{BuilderUrl, SignedRequestAuth}; +use bls::PublicKeyBytes; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::{VariableList, typenum}; +use tree_hash_derive::TreeHash; + +/// `MAX_BUILDER_PUBKEYS` (beacon-APIs #630) as a typenum, bounding a [`BuilderEntry`]'s +/// `builder_pubkeys` list. +pub type MaxBuilderPubkeys = typenum::U64; + +/// The builder pubkeys a [`BuilderEntry`] accepts bids from. Empty accepts any builder. +pub type BuilderPubkeys = VariableList; + +/// A per-builder bid request the validator client supplies on a block-production request, per +/// [beacon-APIs #630](https://github.com/ethereum/beacon-APIs/pull/630). +/// +/// Each entry is a direct bid request: the beacon node calls `getExecutionPayloadBid` at `url`, +/// authenticated by `auth`. One request is made per entry, so several entries MAY share a `url` +/// with different `auth`. `min_bid`/`builder_boost_factor`/`max_execution_payment` are this +/// builder's per-request selection policy; p2p bids are governed by the global values on the +/// enclosing config, not here. +/// +/// `builder_pubkeys` filters the response: an empty list accepts any builder, and a bid not signed +/// by one of a non-empty list MUST NOT be accepted. +/// +/// Field order matches the SSZ `BuilderEntry` container: +/// ```text +/// class BuilderEntry(Container): +/// url: ByteList[MAX_BUILDER_URL_SIZE] +/// auth: SignedRequestAuth +/// builder_pubkeys: List[BLSPubkey, MAX_BUILDER_PUBKEYS] +/// max_execution_payment: Gwei +/// min_bid: Gwei +/// builder_boost_factor: uint64 +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderEntry { + /// Where this entry's bid request is sent. Required and non-empty: beacon-APIs #630 treats a + /// zero-length url as invalid. p2p bid policy is carried by the top-level `BuilderConfig`. + pub url: BuilderUrl, + /// Authenticates this entry's bid request. + pub auth: SignedRequestAuth, + /// The builder pubkeys this entry accepts bids from. Empty accepts any builder; otherwise a + /// bid not signed by one of them MUST NOT be accepted. + pub builder_pubkeys: BuilderPubkeys, + /// Maximum trusted execution-layer payment (Gwei) accepted from this builder. + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, + /// Minimum total payment (Gwei) for a bid from this builder to be accepted. + #[serde(with = "serde_utils::quoted_u64")] + pub min_bid: u64, + /// Percentage multiplier applied to this builder's bid when comparing against the local payload. + #[serde(with = "serde_utils::quoted_u64")] + pub builder_boost_factor: u64, +} + +impl BuilderEntry { + /// Enforce the wire-validity rules from beacon-APIs #630: in either encoding, a zero-length + /// `url` and a zero-length `auth.message.data` are invalid. A body containing such an entry is + /// an invalid request (400), unlike per-entry failures (unreachable builder, rejected bid), + /// which are isolated and never fail the request. + pub fn validate(&self) -> Result<(), &'static str> { + if self.url.as_bytes().is_empty() { + return Err("zero-length builder url"); + } + if self.auth.message.data.is_empty() { + return Err("zero-length auth data"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderEntry); + + fn test_auth() -> SignedRequestAuth { + SignedRequestAuth { + message: crate::RequestAuth { + data: crate::RequestAuthData::new(b"http://builder.example.com".to_vec()).unwrap(), + slot: types::Slot::new(0), + }, + signature: bls::Signature::empty(), + } + } + + fn entry() -> BuilderEntry { + BuilderEntry { + url: "http://builder.example.com".parse().unwrap(), + auth: test_auth(), + builder_pubkeys: BuilderPubkeys::default(), + max_execution_payment: 1, + min_bid: 2, + builder_boost_factor: 100, + } + } + + #[test] + fn json_requires_builder_pubkeys() { + // `builder_pubkeys` is a required field (beacon-APIs #630): an empty list is serialized as + // `[]`, never omitted, and a body missing the field is rejected. + let entry = entry(); + let json = serde_json::to_value(&entry).unwrap(); + assert_eq!(json.get("builder_pubkeys"), Some(&serde_json::json!([]))); + + let mut without_field = json.clone(); + without_field + .as_object_mut() + .unwrap() + .remove("builder_pubkeys"); + assert!(serde_json::from_value::(without_field).is_err()); + + assert_eq!(serde_json::from_value::(json).unwrap(), entry); + } + + #[test] + fn json_round_trips_builder_pubkeys() { + let mut entry = entry(); + entry.builder_pubkeys = + BuilderPubkeys::new(vec![PublicKeyBytes::deserialize(&[1u8; 48]).unwrap()]).unwrap(); + let json = serde_json::to_value(&entry).unwrap(); + assert_eq!(json["builder_pubkeys"].as_array().unwrap().len(), 1); + + assert_eq!(serde_json::from_value::(json).unwrap(), entry); + } +} diff --git a/common/builder_types/src/builder_preference_entry.rs b/common/builder_types/src/builder_preference_entry.rs new file mode 100644 index 00000000000..6dd01b7e63c --- /dev/null +++ b/common/builder_types/src/builder_preference_entry.rs @@ -0,0 +1,99 @@ +use crate::{BuilderEntry, BuilderUrl, SignedRequestAuth}; +use bls::PublicKeyBytes; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::typenum; +use tree_hash_derive::TreeHash; + +/// `MAX_BUILDER_ENTRIES * (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH` (with mainnet +/// `SLOTS_PER_EPOCH`), the bound on the entry list of a single `submitBuilderPreferences` +/// beacon-API submission, per beacon-APIs #630. A fixed wire constant, not preset-derived. +pub type MaxSubmittedBuilderPreferences = typenum::U4096; + +/// [`MaxSubmittedBuilderPreferences`] as a `usize` (derived, so the two cannot drift), for runtime +/// bounds checks. +pub const MAX_SUBMITTED_BUILDER_PREFERENCES: usize = + ::USIZE; + +/// The bounded entry list of a single `submitBuilderPreferences` beacon-API submission +/// (SSZ `List[BuilderPreferencesEntry, 4096]`, per beacon-APIs #630). +pub type SubmittedBuilderPreferences = + ssz_types::VariableList; + +/// A per-builder preference a validator asks the beacon node to submit ahead of the bid request, +/// one entry per `submitBuilderPreferences` builder-API call the beacon node will make. +/// +/// This is the beacon-API (validator -> beacon node) type from beacon-APIs #630. Each entry names +/// its `proposer_pubkey`, so one flat request can carry preferences for several proposers. Unlike +/// the block-production `BuilderEntry`, it carries only what a builder is allowed to see: the routing +/// `url`, the forwarded `auth`, and the `max_execution_payment` cap. The proposer's private +/// bid-filtering knobs (`min_bid`, `builder_boost_factor`) are never sent to a builder. +/// +/// SSZ container (field order per the spec — SSZ and tree-hash depend on it): +/// ```text +/// class BuilderPreferenceEntry(Container): +/// proposer_pubkey: BLSPubkey +/// url: ByteList[MAX_BUILDER_URL_SIZE] +/// auth: SignedRequestAuth +/// max_execution_payment: Gwei +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +pub struct BuilderPreferenceEntry { + /// The proposer these preferences belong to. + pub proposer_pubkey: PublicKeyBytes, + /// The URL the beacon node submits these preferences to. Unsigned routing metadata. + pub url: BuilderUrl, + /// Authenticates the submission to the builder; forwarded byte-for-byte unchanged. + pub auth: SignedRequestAuth, + /// Maximum trusted execution-layer payment (Gwei) the proposer will accept from this builder. + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, +} + +impl BuilderPreferenceEntry { + /// Enforce the wire-validity rules from beacon-APIs #630: in either encoding, a zero-length + /// `url` and a zero-length `auth.message.data` are invalid, making the containing body an + /// invalid request (400). + pub fn validate(&self) -> Result<(), &'static str> { + if self.url.as_bytes().is_empty() { + return Err("zero-length builder url"); + } + if self.auth.message.data.is_empty() { + return Err("zero-length auth data"); + } + Ok(()) + } + + pub fn new( + proposer_pubkey: PublicKeyBytes, + url: BuilderUrl, + auth: SignedRequestAuth, + max_execution_payment: u64, + ) -> Self { + Self { + proposer_pubkey, + url, + auth, + max_execution_payment, + } + } + + /// Narrow a proposer's block-production [`BuilderEntry`] to the beacon-API preference entry, + /// dropping the builder-only fields (`min_bid`, `builder_boost_factor`, `builder_pubkeys`). + pub fn from_builder_entry(proposer_pubkey: PublicKeyBytes, entry: BuilderEntry) -> Self { + Self { + proposer_pubkey, + url: entry.url, + auth: entry.auth, + max_execution_payment: entry.max_execution_payment, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferenceEntry); +} diff --git a/common/builder_types/src/builder_preferences.rs b/common/builder_types/src/builder_preferences.rs new file mode 100644 index 00000000000..e90e5037a56 --- /dev/null +++ b/common/builder_types/src/builder_preferences.rs @@ -0,0 +1,20 @@ +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::ForkName; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct BuilderPreferences { + #[serde(with = "serde_utils::quoted_u64")] + pub max_execution_payment: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferences); +} diff --git a/common/builder_types/src/builder_preferences_request.rs b/common/builder_types/src/builder_preferences_request.rs new file mode 100644 index 00000000000..2defa12a3fd --- /dev/null +++ b/common/builder_types/src/builder_preferences_request.rs @@ -0,0 +1,35 @@ +use crate::{BuilderPreferences, SignedRequestAuth}; +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::ForkName; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct BuilderPreferencesRequest { + preferences: BuilderPreferences, + auth: SignedRequestAuth, +} + +impl BuilderPreferencesRequest { + pub fn new(preferences: BuilderPreferences, auth: SignedRequestAuth) -> Self { + Self { preferences, auth } + } + + pub fn preferences(&self) -> &BuilderPreferences { + &self.preferences + } + + pub fn auth(&self) -> &SignedRequestAuth { + &self.auth + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderPreferencesRequest); +} diff --git a/common/builder_types/src/builder_url.rs b/common/builder_types/src/builder_url.rs new file mode 100644 index 00000000000..e72cb910576 --- /dev/null +++ b/common/builder_types/src/builder_url.rs @@ -0,0 +1,178 @@ +use crate::RequestAuthData; +use sensitive_url::SensitiveUrl; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use std::fmt; +use std::str::FromStr; +use tree_hash::{PackedEncoding, TreeHash}; + +/// Maximum length (in bytes) of a builder URL on the wire (`MAX_BUILDER_URL_SIZE`), per +/// beacon-APIs #630. +pub type MaxBuilderUrlSize = typenum::U2048; + +/// Maximum number of builder entries a validator may supply on a single request, per +/// beacon-APIs #630. Used as the SSZ `List` bound on `BuilderConfig.builders`. +pub type MaxBuilderEntries = typenum::U64; + +/// [`MaxBuilderEntries`] as a `usize` (derived, so the two cannot drift), for runtime bounds checks. +pub const MAX_BUILDER_ENTRIES: usize = ::USIZE; + +// `to_default_auth_data` is infallible only while every possible URL fits within the auth `data` +// bound; enforce that at compile time so growing `MaxBuilderUrlSize` past `MaxDataSize` cannot +// silently turn the default into (wire-invalid) zero-length auth data. +const _: () = assert!( + ::USIZE + <= ::USIZE +); + +/// A builder URL as it travels on the beacon-API wire. +/// +/// Held as the UTF-8 bytes of the URL so it can serialize two ways, matching the `ByteList` / +/// `string` duality in beacon-APIs #630: an SSZ `ByteList[MAX_BUILDER_URL_SIZE]` (a bare byte list, +/// via the transparent struct behaviour) and a plain string in JSON. +/// +/// This is unsigned routing metadata. On the validator side the URL is held as a `SensitiveUrl` +/// (for redaction/ergonomics) and converted into a `BuilderUrl` only when building a request. +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Encode, Decode)] +#[ssz(struct_behaviour = "transparent")] +pub struct BuilderUrl { + bytes: VariableList, +} + +/// An error constructing or converting a [`BuilderUrl`]. +#[derive(Debug)] +pub enum BuilderUrlError { + /// The URL exceeds `MaxBuilderUrlSize` bytes. + TooLong, + /// The bytes are not a valid URL (invalid UTF-8 or unparseable). + InvalidUrl, +} + +impl BuilderUrl { + /// The URL's raw UTF-8 bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// The URL as a string slice, if it is valid UTF-8 (it always is when constructed through the + /// public API). + pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> { + std::str::from_utf8(&self.bytes) + } + + /// Parse this URL into a [`SensitiveUrl`], for making requests or redacted logging. + /// + /// Fails if the bytes are not valid UTF-8 or do not parse as a URL. `BuilderUrl` itself is just + /// opaque bytes on the wire, so this is the conversion point where URL validity is checked. + pub fn to_sensitive_url(&self) -> Result { + let url = self.as_str().map_err(|_| BuilderUrlError::InvalidUrl)?; + SensitiveUrl::parse(url).map_err(|_| BuilderUrlError::InvalidUrl) + } + + /// The default opaque auth `data` to sign for this builder when no custom auth data is provided. + /// + /// Infallible: a `BuilderUrl` is at most `MaxBuilderUrlSize` (2048) bytes, well within + /// `MaxDataSize` (4096), so building the default from the URL cannot overflow. + pub fn to_default_auth_data(&self) -> RequestAuthData { + RequestAuthData::new(self.as_bytes().to_vec()).unwrap_or_default() + } +} + +impl TryFrom<&SensitiveUrl> for BuilderUrl { + type Error = BuilderUrlError; + + fn try_from(url: &SensitiveUrl) -> Result { + // Error rather than silently truncating to an (invalid) empty url if the URL string somehow + // exceeds `MaxBuilderUrlSize`. + let bytes = VariableList::new(url.expose_full().as_str().as_bytes().to_vec()) + .map_err(|_| BuilderUrlError::TooLong)?; + Ok(Self { bytes }) + } +} + +impl FromStr for BuilderUrl { + type Err = BuilderUrlError; + + fn from_str(s: &str) -> Result { + let bytes = + VariableList::new(s.as_bytes().to_vec()).map_err(|_| BuilderUrlError::TooLong)?; + Ok(Self { bytes }) + } +} + +impl fmt::Display for BuilderUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&String::from_utf8_lossy(&self.bytes)) + } +} + +impl Serialize for BuilderUrl { + fn serialize(&self, serializer: S) -> Result { + let s = std::str::from_utf8(&self.bytes).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(s) + } +} + +impl<'de> Deserialize<'de> for BuilderUrl { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + BuilderUrl::from_str(&s).map_err(|e| de::Error::custom(format!("{e:?}"))) + } +} + +impl TreeHash for BuilderUrl { + fn tree_hash_type() -> tree_hash::TreeHashType { + as TreeHash>::tree_hash_type() + } + + fn tree_hash_packed_encoding(&self) -> PackedEncoding { + self.bytes.tree_hash_packed_encoding() + } + + fn tree_hash_packing_factor() -> usize { + as TreeHash>::tree_hash_packing_factor() + } + + fn tree_hash_root(&self) -> tree_hash::Hash256 { + self.bytes.tree_hash_root() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(BuilderUrl); + + #[test] + fn json_is_a_string() { + let url = BuilderUrl::from_str("https://builder.example.com").unwrap(); + let json = serde_json::to_string(&url).unwrap(); + assert_eq!(json, "\"https://builder.example.com\""); + + let decoded: BuilderUrl = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, url); + } + + #[test] + fn default_auth_data_cannot_fail_even_at_max_url_size() { + use ssz_types::typenum::Unsigned; + + // The `MaxBuilderUrlSize <= MaxDataSize` invariant is asserted at compile time at module + // level; exercise the largest possible URL to confirm the default is the URL bytes and + // never the empty fallback. + let scheme = "https://"; + let url_string = format!( + "{scheme}{}", + "a".repeat(MaxBuilderUrlSize::USIZE - scheme.len()) + ); + let url = BuilderUrl::from_str(&url_string).unwrap(); + assert_eq!(url.as_bytes().len(), MaxBuilderUrlSize::USIZE); + + let data = url.to_default_auth_data(); + assert!(!data.is_empty(), "default auth data fell back to empty"); + assert_eq!(&*data, url.as_bytes()); + } +} diff --git a/common/builder_types/src/lib.rs b/common/builder_types/src/lib.rs new file mode 100644 index 00000000000..1bd1edd9271 --- /dev/null +++ b/common/builder_types/src/lib.rs @@ -0,0 +1,56 @@ +//! Types for the Gloas builder flow that are defined by the Builder API and beacon-APIs specs +//! (builder-specs, beacon-APIs) rather than the consensus-specs. +//! +//! These are wire/request types — they never participate in the state transition — so they live +//! above `consensus/types` rather than in it. Consensus-spec builder containers (`Builder`, +//! `BuilderPendingPayment`, `SignedExecutionPayloadBid`, `ProposerPreferences`, ...) remain in +//! `types`. + +/// Local equivalent of the `ssz_and_tree_hash_tests!` macro in `consensus/types`, which cannot be +/// reused here because it is `#![cfg(test)]`-gated to that crate. Builds an arbitrary instance via +/// `types::test_utils::test_arbitrary_instance` (available with the `arbitrary` feature) and checks +/// SSZ round-trips and tree hashing does not panic. +#[cfg(test)] +#[macro_use] +mod test_macros { + macro_rules! ssz_and_tree_hash_tests { + ($type:ty) => { + #[test] + fn ssz_round_trip() { + let original: $type = types::test_utils::test_arbitrary_instance(); + let bytes = ssz::ssz_encode(&original); + let decoded = <$type as ssz::Decode>::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn tree_hash_root_does_not_panic() { + let original: $type = types::test_utils::test_arbitrary_instance(); + let _ = tree_hash::TreeHash::tree_hash_root(&original); + } + }; + } +} + +mod builder_config; +mod builder_entry; +mod builder_preference_entry; +mod builder_preferences; +mod builder_preferences_request; +mod builder_url; +mod request_auth; +mod signed_request_auth; + +pub use builder_config::BuilderConfig; +pub use builder_entry::{BuilderEntry, BuilderPubkeys, MaxBuilderPubkeys}; +pub use builder_preference_entry::{ + BuilderPreferenceEntry, MAX_SUBMITTED_BUILDER_PREFERENCES, MaxSubmittedBuilderPreferences, + SubmittedBuilderPreferences, +}; +pub use builder_preferences::BuilderPreferences; +pub use builder_preferences_request::BuilderPreferencesRequest; +pub use builder_url::{ + BuilderUrl, BuilderUrlError, MAX_BUILDER_ENTRIES, MaxBuilderEntries, MaxBuilderUrlSize, +}; +pub use request_auth::{MaxDataSize, RequestAuth, RequestAuthData}; +pub use signed_request_auth::SignedRequestAuth; diff --git a/common/builder_types/src/request_auth.rs b/common/builder_types/src/request_auth.rs new file mode 100644 index 00000000000..81977ae612d --- /dev/null +++ b/common/builder_types/src/request_auth.rs @@ -0,0 +1,39 @@ +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; +use types::{ForkName, SignedRoot, Slot}; + +// I would like to avoid defining this on the EthSpec if we can get away with it. +// Since it's outside the consensus-spec and is generically named.. +pub type MaxDataSize = typenum::U4096; + +pub type RequestAuthData = VariableList; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct RequestAuth { + /// Opaque authentication data unique to the builder, agreed upon out of band. The meaning of + /// the up to `MaxDataSize` (4096) bytes is left to the proposer and builder; the builder checks + /// the exact bytes when it verifies. When no value has been agreed out of band, implementations + /// SHOULD default to the UTF-8 bytes of the builder's own advertised URL, exactly as advertised, + /// so proposers with no prior relationship can construct an identical `data` deterministically. + /// A zero-length `data` is invalid. + /// + /// Serialized as a `0x`-prefixed hex string (builder-specs #165 `format: hex`). + #[serde(with = "ssz_types::serde_utils::hex_var_list")] + pub data: RequestAuthData, + /// The proposal slot this request is authorized for. + pub slot: Slot, +} + +impl SignedRoot for RequestAuth {} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(RequestAuth); +} diff --git a/common/builder_types/src/signed_request_auth.rs b/common/builder_types/src/signed_request_auth.rs new file mode 100644 index 00000000000..8c820c92888 --- /dev/null +++ b/common/builder_types/src/signed_request_auth.rs @@ -0,0 +1,22 @@ +use crate::RequestAuth; +use bls::Signature; +use context_deserialize::context_deserialize; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; +use tree_hash_derive::TreeHash; +use types::ForkName; + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] +#[context_deserialize(ForkName)] +pub struct SignedRequestAuth { + pub message: RequestAuth, + pub signature: Signature, +} + +#[cfg(test)] +mod tests { + use super::*; + + ssz_and_tree_hash_tests!(SignedRequestAuth); +} diff --git a/common/eth2/Cargo.toml b/common/eth2/Cargo.toml index 700ef9b4c27..db4a764c6d7 100644 --- a/common/eth2/Cargo.toml +++ b/common/eth2/Cargo.toml @@ -12,6 +12,7 @@ network = ["libp2p-identity", "enr", "multiaddr"] [dependencies] bls = { workspace = true } +builder_types = { workspace = true } context_deserialize = { workspace = true } educe = { workspace = true } eip_3076 = { workspace = true, optional = true } @@ -40,5 +41,6 @@ zeroize = { workspace = true, optional = true } [dev-dependencies] arbitrary = { workspace = true } +builder_types = { workspace = true, features = ["arbitrary"] } tokio = { workspace = true } types = { workspace = true, features = ["arbitrary"] } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index a48374a8538..a02b948a909 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -62,6 +62,7 @@ pub const EXECUTION_PAYLOAD_VALUE_HEADER: &str = "Eth-Execution-Payload-Value"; pub const EXECUTION_PAYLOAD_INCLUDED_HEADER: &str = "Eth-Execution-Payload-Included"; pub const CONSENSUS_BLOCK_VALUE_HEADER: &str = "Eth-Consensus-Block-Value"; pub const BLOB_DATA_INCLUDED_HEADER: &str = "Eth-Blob-Data-Included"; +pub const BUILDER_URL_HEADER: &str = "Eth-Builder-Url"; pub const CONTENT_TYPE_HEADER: &str = "Content-Type"; pub const SSZ_CONTENT_TYPE_HEADER: &str = "application/octet-stream"; @@ -367,6 +368,46 @@ impl BeaconNodeHttpClient { } } + /// Perform a HTTP POST request, using an `accept` header for the response and exposing the + /// response headers to `parser`. Returns `None` on a 404 error. + /// + /// `fork_name` is sent as the `Eth-Consensus-Version` request header (required on every + /// endpoint this helper serves, per beacon-APIs #630). `build_body` attaches the request body + /// (and its content-type) to the request builder, so the caller controls whether the body is + /// sent as JSON or SSZ. + pub async fn post_response_with_response_headers( + &self, + url: U, + accept_header: Accept, + fork_name: ForkName, + timeout: Duration, + build_body: impl FnOnce(RequestBuilder) -> RequestBuilder, + parser: impl FnOnce(Response, HeaderMap) -> F, + ) -> Result, Error> + where + F: Future>, + { + let request = build_body( + self.client + .post(url) + .timeout(timeout) + .accept(accept_header) + .header(CONSENSUS_VERSION_HEADER, fork_name.to_string()), + ); + let response = request.send().await?; + + let opt_response = ok_or_error(response).await.optional()?; + + match opt_response { + Some(resp) => { + let response_headers = resp.headers().clone(); + let parsed_response = parser(resp, response_headers).await?; + Ok(Some(parsed_response)) + } + None => Ok(None), + } + } + /// Perform a HTTP POST request. async fn post(&self, url: U, body: &T) -> Result<(), Error> { self.post_generic(url, body, None).await?; @@ -517,6 +558,7 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, blob_data_included: Option, + builder_url: Option<&str>, ) -> Result { let mut builder = self .client @@ -527,6 +569,11 @@ impl BeaconNodeHttpClient { if let Some(blob_data_included) = blob_data_included { builder = builder.header(BLOB_DATA_INCLUDED_HEADER, blob_data_included.to_string()); } + // Echo the winning builder's URL (beacon-APIs #630) so the beacon node forwards the block to + // that builder; only set on a block published after a direct-builder bid won. + if let Some(builder_url) = builder_url { + builder = builder.header(BUILDER_URL_HEADER, builder_url); + } let response = builder.body(body).send().await?; success_or_error(response).await } @@ -556,7 +603,7 @@ impl BeaconNodeHttpClient { timeout: Option, fork: ForkName, ) -> Result { - self.post_generic_with_envelope_headers_and_ssz_body(url, body, timeout, fork, None) + self.post_generic_with_envelope_headers_and_ssz_body(url, body, timeout, fork, None, None) .await } @@ -1424,17 +1471,23 @@ impl BeaconNodeHttpClient { } /// `POST v2/beacon/blocks` + /// `builder_url` echoes the `Eth-Builder-Url` from `produceBlockV4` (beacon-APIs #630) so the + /// beacon node forwards the block to the builder that won selection; `None` for a self-built or + /// p2p-won block. pub async fn post_beacon_blocks_v2_ssz( &self, block_contents: &PublishBlockRequest, validation_level: Option, + builder_url: Option<&str>, ) -> Result { let response = self - .post_generic_with_consensus_version_and_ssz_body( + .post_generic_with_envelope_headers_and_ssz_body( self.post_beacon_blocks_v2_path(validation_level)?, block_contents.as_ssz_bytes(), Some(self.timeouts.proposal), block_contents.signed_block().message().body().fork_name(), + None, + builder_url, ) .await?; @@ -2176,6 +2229,54 @@ impl BeaconNodeHttpClient { Ok(()) } + /// `POST validator/builder_preferences` + /// + /// Ask the beacon node to submit builder preferences ahead of the bid request (beacon-APIs #630). + /// The body is a flat list of entries, each naming its own `proposer_pubkey`, so one request may + /// cover several proposers. `fork_name` is sent as the required `Eth-Consensus-Version` header. + pub async fn post_validator_builder_preferences( + &self, + entries: &SubmittedBuilderPreferences, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("builder_preferences"); + + self.post_with_timeout_and_consensus_header( + path, + &entries, + self.timeouts.default, + fork_name, + ) + .await?; + + Ok(()) + } + + /// `POST validator/builder_preferences` (SSZ) + pub async fn post_validator_builder_preferences_ssz( + &self, + entries: &SubmittedBuilderPreferences, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("builder_preferences"); + + let ssz_body = entries.as_ssz_bytes(); + self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) + .await?; + + Ok(()) + } + /// `GET config/fork_schedule` pub async fn get_config_fork_schedule(&self) -> Result>, Error> { let mut path = self.eth_path(V1)?; @@ -2714,16 +2815,15 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } - /// returns `GET v4/validator/blocks/{slot}` URL path + /// returns the `POST v4/validator/blocks/{slot}` URL path #[allow(clippy::too_many_arguments)] - pub async fn get_validator_blocks_v4_path( + pub async fn post_validator_blocks_v4_path( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, include_payload: bool, - builder_booster_factor: Option, graffiti_policy: Option, ) -> Result { let mut path = self.eth_path(V4)?; @@ -2750,11 +2850,6 @@ impl BeaconNodeHttpClient { path.query_pairs_mut() .append_pair("include_payload", &include_payload.to_string()); - if let Some(builder_booster_factor) = builder_booster_factor { - path.query_pairs_mut() - .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); - } - // Only append the HTTP URL request if the graffiti_policy is PreserveUserGraffiti // If AppendClientVersions (default), then we do not modify the HTTP URL request // so that the default case is compliant to the spec @@ -2766,6 +2861,197 @@ impl BeaconNodeHttpClient { Ok(path) } + /// `POST v4/validator/blocks/{slot}` + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + include_payload: bool, + builder_config: &BuilderConfig, + graffiti_policy: Option, + fork_name: ForkName, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + self.post_validator_blocks_v4_modular( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + include_payload, + builder_config, + graffiti_policy, + fork_name, + ) + .await + } + + /// `POST v4/validator/blocks/{slot}` + /// + /// `fork_name` is sent as the required `Eth-Consensus-Version` request header (beacon-APIs + /// #630): the active consensus version the request belongs to. + /// + /// Returns either a bare block or the full [`BlockAndEnvelope`] (block + execution payload + /// envelope + blobs + KZG proofs) depending on the `Eth-Execution-Payload-Included` response + /// header. Note that a builder bid yields a bare block even when `include_payload=true`. + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4_modular( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + include_payload: bool, + builder_config: &BuilderConfig, + graffiti_policy: Option, + fork_name: ForkName, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + let path = self + .post_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + include_payload, + graffiti_policy, + ) + .await?; + + let opt_result = self + .post_response_with_response_headers( + path, + Accept::Json, + fork_name, + self.timeouts.get_validator_block, + |request| request.json(builder_config), + |response, headers| async move { + let metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let block_response = if metadata.execution_payload_included { + ProduceBlockV4Response::BlockAndEnvelope( + response + .json::, + ProduceBlockV4Metadata, + >>() + .await? + .data, + ) + } else { + ProduceBlockV4Response::BlockOnly( + response + .json::, + ProduceBlockV4Metadata, + >>() + .await? + .data, + ) + }; + Ok((block_response, metadata)) + }, + ) + .await?; + + opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + /// `POST v4/validator/blocks/{slot}` in ssz format + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4_ssz( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + include_payload: bool, + builder_config: &BuilderConfig, + graffiti_policy: Option, + fork_name: ForkName, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + self.post_validator_blocks_v4_modular_ssz::( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + include_payload, + builder_config, + graffiti_policy, + fork_name, + ) + .await + } + + /// `POST v4/validator/blocks/{slot}` in ssz format + /// + /// See [`Self::post_validator_blocks_v4_modular`] for the response semantics. + #[allow(clippy::too_many_arguments)] + pub async fn post_validator_blocks_v4_modular_ssz( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + include_payload: bool, + builder_config: &BuilderConfig, + graffiti_policy: Option, + fork_name: ForkName, + ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { + let path = self + .post_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + include_payload, + graffiti_policy, + ) + .await?; + + let opt_response = self + .post_response_with_response_headers( + path, + Accept::Ssz, + fork_name, + self.timeouts.get_validator_block, + |request| { + request + .header("Content-Type", "application/octet-stream") + .body(builder_config.as_ssz_bytes()) + }, + |response, headers| async move { + let metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let response_bytes = response.bytes().await?; + let block_response = if metadata.execution_payload_included { + ProduceBlockV4Response::BlockAndEnvelope( + BlockAndEnvelope::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?, + ) + } else { + ProduceBlockV4Response::BlockOnly( + BeaconBlock::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?, + ) + }; + + Ok((block_response, metadata)) + }, + ) + .await?; + + opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + // The legacy `GET v4/validator/blocks/{slot}` client methods below are kept alongside the new + // POST variants until the validator client migrates to POST (later in this PR stack), at which + // point they are removed. + /// `GET v4/validator/blocks/{slot}` pub async fn get_validator_blocks_v4( &self, @@ -2804,18 +3090,22 @@ impl BeaconNodeHttpClient { builder_booster_factor: Option, graffiti_policy: Option, ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let path = self - .get_validator_blocks_v4_path( + let mut path = self + .post_validator_blocks_v4_path( slot, randao_reveal, graffiti, skip_randao_verification, include_payload, - builder_booster_factor, graffiti_policy, ) .await?; + if let Some(builder_booster_factor) = builder_booster_factor { + path.query_pairs_mut() + .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); + } + let opt_result = self .get_response_with_response_headers( path, @@ -2889,18 +3179,22 @@ impl BeaconNodeHttpClient { builder_booster_factor: Option, graffiti_policy: Option, ) -> Result<(ProduceBlockV4Response, ProduceBlockV4Metadata), Error> { - let path = self - .get_validator_blocks_v4_path( + let mut path = self + .post_validator_blocks_v4_path( slot, randao_reveal, graffiti, skip_randao_verification, include_payload, - builder_booster_factor, graffiti_policy, ) .await?; + if let Some(builder_booster_factor) = builder_booster_factor { + path.query_pairs_mut() + .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); + } + let opt_response = self .get_response_with_response_headers( path, @@ -3040,6 +3334,7 @@ impl BeaconNodeHttpClient { Some(self.timeouts.proposal), fork_name, Some(false), + None, ) .await?; @@ -3087,6 +3382,7 @@ impl BeaconNodeHttpClient { Some(self.timeouts.proposal), fork_name, Some(true), + None, ) .await?; diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 086fc0ce63f..08dc2e00c5f 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1,11 +1,13 @@ //! This module exposes a superset of the `types` crate. It adds additional types that are only //! required for the HTTP API. +pub use builder_types::*; pub use types::*; use crate::{ - CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, EXECUTION_PAYLOAD_BLINDED_HEADER, - EXECUTION_PAYLOAD_INCLUDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, Error as ServerError, + BUILDER_URL_HEADER, CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, + EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, + EXECUTION_PAYLOAD_VALUE_HEADER, Error as ServerError, }; use bls::{PublicKeyBytes, SecretKey, Signature, SignatureBytes}; use context_deserialize::{ContextDeserialize, context_deserialize}; @@ -1959,6 +1961,11 @@ pub struct ProduceBlockV4Metadata { #[serde(with = "serde_utils::u256_dec")] pub execution_payload_value: Uint256, pub execution_payload_included: bool, + /// The URL of the winning builder when the payload bid came through the builder-API channel + /// (the `Eth-Builder-Url` response header). `None` for a self-built block or a p2p bid. Carried + /// only in the header, never the JSON body, so it's skipped by serde. + #[serde(skip_serializing, skip_deserializing, default)] + pub builder_url: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Encode)] @@ -2244,12 +2251,19 @@ impl TryFrom<&HeaderMap> for ProduceBlockV4Metadata { s.parse::() .map_err(|e| format!("invalid {EXECUTION_PAYLOAD_INCLUDED_HEADER}: {e:?}")) })?; + // Optional; an empty or absent value means the block was self-built or a p2p bid won. + let builder_url = headers + .get(BUILDER_URL_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + .filter(|s| !s.is_empty()); Ok(ProduceBlockV4Metadata { consensus_version, consensus_block_value, execution_payload_value, execution_payload_included, + builder_url, }) } } diff --git a/consensus/types/src/core/application_domain.rs b/consensus/types/src/core/application_domain.rs index ff55a910341..0e0f2788705 100644 --- a/consensus/types/src/core/application_domain.rs +++ b/consensus/types/src/core/application_domain.rs @@ -2,16 +2,23 @@ /// Little endian hex: 0x00000001, Binary: 1000000000000000000000000 pub const APPLICATION_DOMAIN_BUILDER: u32 = 16777216; +/// `DOMAIN_REQUEST_AUTH` from builder-specs #165, for Gloas builder-API request authentication. +/// Little endian hex: 0x0B000001 (i.e. `APPLICATION_DOMAIN_BUILDER` with a `0x0B` first byte). +pub const APPLICATION_DOMAIN_REQUEST_AUTH: u32 = 16777227; + #[derive(Debug, PartialEq, Clone, Copy)] pub enum ApplicationDomain { /// NOTE: This domain is only used for out-of-protocol block building, DO NOT use it for Gloas/ePBS. Builder, + /// Authenticates a Gloas builder-API request (`SignedRequestAuth`), per builder-specs #165. + RequestAuth, } impl ApplicationDomain { pub fn get_domain_constant(&self) -> u32 { match self { ApplicationDomain::Builder => APPLICATION_DOMAIN_BUILDER, + ApplicationDomain::RequestAuth => APPLICATION_DOMAIN_REQUEST_AUTH, } } } diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index ddbf73e73af..6aed6a9d502 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -615,6 +615,19 @@ impl ChainSpec { ) } + /// The signing domain for a Gloas builder-API `SignedRequestAuth`. + /// + /// Per builder-specs #165 this is `compute_domain(DOMAIN_REQUEST_AUTH)`: the genesis fork version + /// and a zero genesis-validators-root, matching `get_builder_application_domain`'s out-of-protocol + /// computation but with the `DOMAIN_REQUEST_AUTH` (0x0B000001) domain type. + pub fn get_request_auth_domain(&self) -> Hash256 { + self.compute_domain( + Domain::ApplicationMask(ApplicationDomain::RequestAuth), + self.genesis_fork_version, + Hash256::zero(), + ) + } + /// Return the 32-byte fork data root for the `current_version` and `genesis_validators_root`. /// /// This is used primarily in signature domains to avoid collisions across forks/chains. @@ -3268,6 +3281,19 @@ mod tests { ); } + #[test] + fn test_request_auth_domain() { + let spec = ChainSpec::mainnet(); + let domain = spec.get_request_auth_domain(); + // DOMAIN_REQUEST_AUTH = 0x0B000001 (builder-specs #165), little-endian in the first 4 bytes. + assert_eq!(&domain.as_slice()[0..4], &[0x0B, 0x00, 0x00, 0x01]); + // Same out-of-protocol computation as the builder application domain (genesis fork version, + // zero root), so only the domain-type prefix differs. + let builder = spec.get_builder_application_domain(); + assert_eq!(&domain.as_slice()[4..], &builder.as_slice()[4..]); + assert_ne!(&domain.as_slice()[0..4], &builder.as_slice()[0..4]); + } + fn apply_bit_mask(domain_bytes: [u8; 4], spec: &ChainSpec) -> u32 { let mut domain = [0; 4]; let mask_bytes = int_to_bytes4(spec.domain_application_mask); diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index a26b557adf2..7531543a187 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -750,7 +750,7 @@ impl BlockService { &[validator_metrics::BEACON_BLOCK_HTTP_POST], ); beacon_node - .post_beacon_blocks_v2_ssz(signed_block, None) + .post_beacon_blocks_v2_ssz(signed_block, None, None) .await .map(|_| ()) .or_else(|e| {