From e6d68a60b0c93d12647e9b79a444faa9038f772a Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 19 May 2026 10:51:54 -0700 Subject: [PATCH 1/3] Add SSZ types, content negotiation, and encoding helpers Introduce EncodingType, AcceptedEncodings, and content negotiation primitives for SSZ support in the PBS pipeline. Includes: - EncodingType enum with FromStr/Display and MIME param tolerance - AcceptedEncodings with q-value aware Accept header parsing - SSZ bid value extraction from SignedBuilderBid by fork - deserialize_body and parse_response_encoding_and_fork helpers - Per-fork type aliases for BuilderBid and ExecutionPayloadHeader - SszValueError, PbsError::GeneralRequest, PbsClientError variants - Comprehensive unit tests for all new types and helpers - New deps: headers-accept, mediatype --- Cargo.lock | 25 +- Cargo.toml | 2 + crates/common/Cargo.toml | 2 + crates/common/src/pbs/error.rs | 25 + crates/common/src/pbs/mod.rs | 1 + crates/common/src/pbs/types/mod.rs | 12 +- crates/common/src/utils.rs | 1080 +++++++++++++++++++++++++++- crates/pbs/Cargo.toml | 4 + crates/pbs/src/error.rs | 14 + 9 files changed, 1157 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc7c61ac..a0b06233 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1777,8 +1777,10 @@ dependencies = [ "ethereum_ssz_derive 0.10.3", "eyre", "futures", + "headers-accept", "jsonwebtoken", "lazy_static", + "mediatype 0.20.0", "notify", "pbkdf2", "rand 0.9.4", @@ -1828,8 +1830,11 @@ dependencies = [ "axum-extra", "cb-common", "cb-metrics", + "ethereum_serde_utils 0.7.0", + "ethereum_ssz 0.10.3", "eyre", "futures", + "headers", "lazy_static", "notify", "parking_lot", @@ -1841,6 +1846,7 @@ dependencies = [ "tower-http", "tracing", "tree_hash 0.12.1", + "types", "url", "uuid 1.23.1", ] @@ -2903,7 +2909,7 @@ dependencies = [ "ethereum_ssz_derive 0.10.3", "futures", "futures-util", - "mediatype", + "mediatype 0.19.20", "pretty_reqwest_error", "reqwest 0.12.28", "reqwest-eventsource 0.6.0", @@ -3534,6 +3540,17 @@ dependencies = [ "sha1", ] +[[package]] +name = "headers-accept" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479bcb872e714e11f72fcc6a71afadbc86d0dbe887bc44252b04cfbc63272897" +dependencies = [ + "headers-core", + "http 1.4.0", + "mediatype 0.20.0", +] + [[package]] name = "headers-core" version = "0.3.0" @@ -4373,6 +4390,12 @@ version = "0.19.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" +[[package]] +name = "mediatype" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f490ea2ae935dd8ac89c472d4df28c7f6b87cc20767e1b21fd5ed6a16e7f61e4" + [[package]] name = "memchr" version = "2.8.0" diff --git a/Cargo.toml b/Cargo.toml index 0adabb6c..170cb38a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,9 +49,11 @@ ethereum_ssz_derive = "0.10" eyre = "0.6.12" futures = "0.3.30" headers = "0.4.0" +headers-accept = "0.2.1" indexmap = "2.2.6" jsonwebtoken = { version = "9.3.1", default-features = false } lazy_static = "1.5.0" +mediatype = "0.20.0" lh_eth2 = { package = "eth2", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3", features = ["events"] } lh_eth2_keystore = { package = "eth2_keystore", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } lh_bls = { package = "bls", git = "https://github.com/sigp/lighthouse", tag = "v8.1.3" } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 9c335cb6..eb87dd94 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -26,12 +26,14 @@ ethereum_ssz.workspace = true ethereum_ssz_derive.workspace = true eyre.workspace = true futures.workspace = true +headers-accept.workspace = true jsonwebtoken.workspace = true lazy_static.workspace = true lh_bls.workspace = true lh_eth2.workspace = true lh_eth2_keystore.workspace = true lh_types.workspace = true +mediatype.workspace = true notify.workspace = true pbkdf2.workspace = true rand.workspace = true diff --git a/crates/common/src/pbs/error.rs b/crates/common/src/pbs/error.rs index 77d942cd..16ebdc35 100644 --- a/crates/common/src/pbs/error.rs +++ b/crates/common/src/pbs/error.rs @@ -14,6 +14,9 @@ pub enum PbsError { #[error("json decode error: {err:?}, raw: {raw}")] JsonDecode { err: serde_json::Error, raw: String }, + #[error("error with request: {0}")] + GeneralRequest(String), + #[error("{0}")] ReadResponse(#[from] ResponseReadError), @@ -107,3 +110,25 @@ pub enum ValidationError { #[error("unsupported fork")] UnsupportedFork, } + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SszValueError { + #[error("invalid payload length: required {required} but payload was {actual}")] + InvalidPayloadLength { required: usize, actual: usize }, + + #[error("unsupported fork")] + UnsupportedFork { name: String }, +} + +impl From for PbsError { + fn from(err: SszValueError) -> Self { + match err { + SszValueError::InvalidPayloadLength { required, actual } => PbsError::GeneralRequest( + format!("invalid payload length: required {required} but payload was {actual}"), + ), + SszValueError::UnsupportedFork { name } => { + PbsError::GeneralRequest(format!("unsupported fork: {name}")) + } + } + } +} diff --git a/crates/common/src/pbs/mod.rs b/crates/common/src/pbs/mod.rs index af2c07b4..a1152b58 100644 --- a/crates/common/src/pbs/mod.rs +++ b/crates/common/src/pbs/mod.rs @@ -6,5 +6,6 @@ mod types; pub use builder::*; pub use constants::*; +pub use lh_types::ForkVersionDecode; pub use relay::*; pub use types::*; diff --git a/crates/common/src/pbs/types/mod.rs b/crates/common/src/pbs/types/mod.rs index b79f8f01..e01bbf44 100644 --- a/crates/common/src/pbs/types/mod.rs +++ b/crates/common/src/pbs/types/mod.rs @@ -1,5 +1,5 @@ use alloy::primitives::{B256, U256, b256}; -use lh_eth2::ForkVersionedResponse; +pub use lh_eth2::ForkVersionedResponse; pub use lh_types::ForkName; use lh_types::{BlindedPayload, ExecPayload, MainnetEthSpec}; use serde::{Deserialize, Serialize}; @@ -26,6 +26,10 @@ pub type PayloadAndBlobs = lh_eth2::types::ExecutionPayloadAndBlobs; pub type ExecutionPayloadHeader = lh_types::ExecutionPayloadHeader; +pub type ExecutionPayloadHeaderBellatrix = + lh_types::ExecutionPayloadHeaderBellatrix; +pub type ExecutionPayloadHeaderCapella = lh_types::ExecutionPayloadHeaderCapella; +pub type ExecutionPayloadHeaderDeneb = lh_types::ExecutionPayloadHeaderDeneb; pub type ExecutionPayloadHeaderElectra = lh_types::ExecutionPayloadHeaderElectra; pub type ExecutionPayloadHeaderFulu = lh_types::ExecutionPayloadHeaderFulu; pub type ExecutionPayloadHeaderRef<'a> = lh_types::ExecutionPayloadHeaderRef<'a, MainnetEthSpec>; @@ -34,7 +38,11 @@ pub type ExecutionPayloadElectra = lh_types::ExecutionPayloadElectra; pub type SignedBuilderBid = lh_types::SignedBuilderBid; pub type BuilderBid = lh_types::BuilderBid; +pub type BuilderBidBellatrix = lh_types::BuilderBidBellatrix; +pub type BuilderBidCapella = lh_types::BuilderBidCapella; +pub type BuilderBidDeneb = lh_types::BuilderBidDeneb; pub type BuilderBidElectra = lh_types::BuilderBidElectra; +pub type BuilderBidFulu = lh_types::BuilderBidFulu; /// Response object of GET /// `/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}` @@ -42,6 +50,8 @@ pub type GetHeaderResponse = ForkVersionedResponse; pub type KzgCommitments = lh_types::KzgCommitments; +pub type Uint256 = lh_types::Uint256; + /// Response params of GET /// `/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}` #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index e504e477..ca4f0258 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,7 +1,10 @@ #[cfg(feature = "testing-flags")] use std::cell::Cell; use std::{ + fmt::Display, net::Ipv4Addr, + str::FromStr, + sync::LazyLock, time::{SystemTime, UNIX_EPOCH}, }; @@ -10,13 +13,24 @@ use alloy::{ primitives::{U256, keccak256}, }; use axum::http::HeaderValue; +use bytes::Bytes; use futures::StreamExt; -use lh_types::test_utils::{SeedableRng, TestRandom, XorShiftRng}; +use headers_accept::Accept; +use lh_bls::Signature; +pub use lh_types::ForkName; +use lh_types::{ + BeaconBlock, + test_utils::{SeedableRng, TestRandom, XorShiftRng}, +}; +use mediatype::{MediaType, ReadParams}; use rand::{Rng, distr::Alphanumeric}; -use reqwest::{Response, header::HeaderMap}; +use reqwest::{ + Response, + header::{ACCEPT, CONTENT_TYPE, HeaderMap}, +}; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; -use ssz::{Decode, Encode}; +use ssz::{BYTES_PER_LENGTH_OFFSET, Decode, Encode}; use thiserror::Error; use tracing::Level; use tracing_appender::{non_blocking::WorkerGuard, rolling::Rotation}; @@ -29,11 +43,22 @@ use tracing_subscriber::{ use crate::{ config::LogsSettings, constants::SIGNER_JWT_EXPIRATION, - pbs::HEADER_VERSION_VALUE, + pbs::{ + BuilderBidBellatrix, BuilderBidCapella, BuilderBidDeneb, BuilderBidElectra, BuilderBidFulu, + ExecutionPayloadHeaderBellatrix, ExecutionPayloadHeaderCapella, + ExecutionPayloadHeaderDeneb, ExecutionPayloadHeaderElectra, ExecutionPayloadHeaderFulu, + ExecutionRequests, HEADER_VERSION_VALUE, KzgCommitments, SignedBlindedBeaconBlock, + error::SszValueError, + }, types::{BlsPublicKey, Chain, Jwt, JwtAdminClaims, JwtClaims, ModuleId}, }; +pub const APPLICATION_JSON: &str = "application/json"; +pub const APPLICATION_OCTET_STREAM: &str = "application/octet-stream"; +pub const WILDCARD: &str = "*/*"; + const MILLIS_PER_SECOND: u64 = 1_000; +pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; #[derive(Debug, Error)] pub enum ResponseReadError { @@ -517,6 +542,314 @@ pub fn get_user_agent_with_version(req_headers: &HeaderMap) -> eyre::Result, +} + +impl AcceptedEncodings { + pub const fn single(primary: EncodingType) -> Self { + Self { primary, fallback: None } + } + + pub fn contains(self, enc: EncodingType) -> bool { + self.primary == enc || self.fallback == Some(enc) + } + + /// Iterate in preference order: primary first, then fallback (if any). + pub fn iter(self) -> impl Iterator { + std::iter::once(self.primary).chain(self.fallback) + } + + /// Pick the caller's highest-preference encoding that the server supports. + /// Returns `None` if no overlap exists. + pub fn preferred(self, supported: &[EncodingType]) -> Option { + self.iter().find(|a| supported.contains(a)) + } +} + +impl IntoIterator for AcceptedEncodings { + type Item = EncodingType; + type IntoIter = + std::iter::Chain, std::option::IntoIter>; + fn into_iter(self) -> Self::IntoIter { + std::iter::once(self.primary).chain(self.fallback) + } +} + +/// Parse the ACCEPT header into a q-value ordered [`AcceptedEncodings`] +/// (highest preference first, deduplicated), defaulting to the request's +/// Content-Type when no Accept header is present. Returns an error only if +/// every media type in the header is malformed or unsupported. Supports +/// requests with multiple ACCEPT headers or headers with multiple media +/// types. `q=0` entries are treated as explicit rejections per RFC 7231 +/// §5.3.1 and are skipped. +/// +/// The returned order honors the RFC 9110 §12.5.1 precedence rules already +/// applied by `headers_accept::Accept::media_types()` (specificity, then +/// q-value, then original order). +pub fn get_accept_types(req_headers: &HeaderMap) -> eyre::Result { + // Only two supported media types, so the ordered set is at most two + // entries: primary + optional fallback. + let mut primary: Option = None; + let mut fallback: Option = None; + let mut saw_any = false; + let mut had_supported = false; + for header in req_headers.get_all(ACCEPT).iter() { + let accept = Accept::from_str(header.to_str()?) + .map_err(|e| eyre::eyre!("invalid accept header: {e}"))?; + for mt in accept.media_types() { + saw_any = true; + + // Skip q=0 entries — RFC 7231 §5.3.1: "A request without any Accept + // header field implies that the user agent will accept any media + // type in response. When a header field is present ... a value of + // 0 means 'not acceptable'." + if let Some(q) = mt.get_param(mediatype::names::Q) && + q.as_str().parse::().is_ok_and(|v| v <= 0.0) + { + continue; + } + + let parsed = match mt.essence().to_string().as_str() { + APPLICATION_OCTET_STREAM => Some(EncodingType::Ssz), + APPLICATION_JSON => Some(EncodingType::Json), + WILDCARD => Some(NO_PREFERENCE_DEFAULT), + _ => None, + }; + if let Some(enc) = parsed { + had_supported = true; + match primary { + None => primary = Some(enc), + Some(p) if p != enc && fallback.is_none() => fallback = Some(enc), + _ => {} + } + } + } + } + + if let Some(primary) = primary { + return Ok(AcceptedEncodings { primary, fallback }); + } + + if saw_any && !had_supported { + return Err(eyre::eyre!("unsupported accept type")); + } + + // No accept header (or only q=0 rejections): fall back to the request + // Content-Type, which mirrors the historical behavior. + Ok(AcceptedEncodings::single(get_content_type(req_headers))) +} + +/// Compute the q-value for the `index`-th preferred encoding when building an +/// outbound `Accept` header. The first entry gets q=1.0, each subsequent entry +/// decreases by 0.1, and the value is clamped to a minimum of 0.1 so we never +/// emit q=0 (which per RFC 7231 §5.3.1 means "not acceptable"). +fn accept_q_value_for_index(index: usize) -> f32 { + // `as i32` would silently wrap for large indices (e.g. usize::MAX → -1), + // which would invert the clamp. Saturate the cast explicitly. + let idx = i32::try_from(index).unwrap_or(i32::MAX); + let step = 10_i32.saturating_sub(idx).max(1); + step as f32 / 10.0 +} + +/// Format a single `Accept` header entry as `";q="`. +fn format_accept_entry(enc: EncodingType, q: f32) -> String { + format!("{};q={:.1}", enc.content_type(), q) +} + +/// Build an `Accept` header string that mirrors the caller's preference order +/// so the relay sees the same priority the beacon node asked us for. Each +/// subsequent entry receives a q-value 0.1 lower than the previous one, +/// starting at 1.0. +pub fn build_outbound_accept(preferred: AcceptedEncodings) -> String { + preferred + .iter() + .enumerate() + .map(|(i, enc)| format_accept_entry(enc, accept_q_value_for_index(i))) + .collect::>() + .join(",") +} + +/// Parse CONTENT TYPE header to get the encoding type of the body, defaulting +/// to JSON if missing or malformed. +pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { + EncodingType::from_str( + req_headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(APPLICATION_JSON), + ) + .unwrap_or(EncodingType::Json) +} + +/// Parse CONSENSUS_VERSION header +pub fn get_consensus_version_header(req_headers: &HeaderMap) -> Option { + ForkName::from_str( + req_headers + .get(CONSENSUS_VERSION_HEADER) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""), + ) + .ok() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EncodingType { + Json, + Ssz, +} + +impl EncodingType { + pub fn content_type(&self) -> &str { + match self { + EncodingType::Json => APPLICATION_JSON, + EncodingType::Ssz => APPLICATION_OCTET_STREAM, + } + } + + /// Pre-built `Content-Type` header for this encoding. `HeaderValue` is + /// not `const`-constructible in stable Rust, so the values are + /// lazy-initialized once per process via `LazyLock` from static ASCII + /// strings. Callers can clone the returned reference cheaply (the + /// underlying bytes are shared). + pub fn content_type_header(&self) -> &'static HeaderValue { + static JSON_HEADER: LazyLock = + LazyLock::new(|| HeaderValue::from_static(APPLICATION_JSON)); + static SSZ_HEADER: LazyLock = + LazyLock::new(|| HeaderValue::from_static(APPLICATION_OCTET_STREAM)); + match self { + EncodingType::Json => &JSON_HEADER, + EncodingType::Ssz => &SSZ_HEADER, + } + } +} + +impl std::fmt::Display for EncodingType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.content_type()) + } +} + +impl FromStr for EncodingType { + type Err = String; + fn from_str(value: &str) -> Result { + // Preserve prior behavior: empty defaults to JSON (used by + // `get_content_type` when Content-Type header is absent). + if value.is_empty() { + return Ok(EncodingType::Json); + } + // Parse as a media type so we tolerate RFC 7231 §3.1.1.1 parameters + // (e.g. `application/json; charset=utf-8`). Compare essence only. + let parsed = + MediaType::parse(value).map_err(|e| format!("invalid content type {value}: {e}"))?; + match parsed.essence().to_string().to_ascii_lowercase().as_str() { + APPLICATION_JSON => Ok(EncodingType::Json), + APPLICATION_OCTET_STREAM => Ok(EncodingType::Ssz), + _ => Err(format!("unsupported encoding type: {value}")), + } + } +} + +/// Parse the Content-Type and Eth-Consensus-Version headers from a relay +/// response, returning the encoding to use for body decoding and the +/// optional fork name. Tolerates MIME parameters per RFC 7231 §3.1.1.1 and +/// defaults to JSON when no Content-Type header is present (matching legacy +/// relay behavior). `code` is the HTTP status of the response and is echoed +/// back in any `PbsError::RelayResponse` this function produces, so callers +/// can surface the original status on decode failure. +pub fn parse_response_encoding_and_fork( + headers: &HeaderMap, + code: u16, +) -> Result<(EncodingType, Option), crate::pbs::error::PbsError> { + use crate::pbs::error::PbsError; + let content_type = match headers.get(CONTENT_TYPE) { + // No Content-Type: apply the shared no-preference default + None => NO_PREFERENCE_DEFAULT, + Some(hv) => { + let header_str = hv.to_str().map_err(|e| PbsError::RelayResponse { + error_msg: format!("cannot decode content-type header: {e}"), + code, + })?; + EncodingType::from_str(header_str) + .map_err(|msg| PbsError::RelayResponse { error_msg: msg, code })? + } + }; + Ok((content_type, get_consensus_version_header(headers))) +} + +pub enum BodyDeserializeError { + SerdeJsonError(serde_json::Error), + SszDecodeError(ssz::DecodeError), + UnsupportedMediaType, + MissingVersionHeader, +} + +impl Display for BodyDeserializeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BodyDeserializeError::SerdeJsonError(e) => write!(f, "JSON deserialization error: {e}"), + BodyDeserializeError::SszDecodeError(e) => { + write!(f, "SSZ deserialization error: {e:?}") + } + BodyDeserializeError::UnsupportedMediaType => write!(f, "unsupported media type"), + BodyDeserializeError::MissingVersionHeader => { + write!(f, "missing consensus version header") + } + } + } +} + +pub async fn deserialize_body( + headers: &HeaderMap, + body: Bytes, +) -> Result { + // Determine the encoding to decode with. Precedence: + // - Content-Type absent → NO_PREFERENCE_DEFAULT + // - Content-Type recognized → use it. + // - Content-Type present but unrecognized → UnsupportedMediaType. + let encoding = match headers.get(CONTENT_TYPE) { + None => NO_PREFERENCE_DEFAULT, + Some(hv) => { + let value = hv.to_str().map_err(|_| BodyDeserializeError::UnsupportedMediaType)?; + EncodingType::from_str(value).map_err(|_| BodyDeserializeError::UnsupportedMediaType)? + } + }; + + match encoding { + EncodingType::Json => serde_json::from_slice::(&body) + .map_err(BodyDeserializeError::SerdeJsonError), + EncodingType::Ssz => match get_consensus_version_header(headers) { + Some(version) => SignedBlindedBeaconBlock::from_ssz_bytes_with(&body, |bytes| { + BeaconBlock::from_ssz_bytes_for_fork(bytes, version) + }) + .map_err(BodyDeserializeError::SszDecodeError), + None => Err(BodyDeserializeError::MissingVersionHeader), + }, + } +} + #[cfg(unix)] pub async fn wait_for_signal() -> eyre::Result<()> { use tokio::signal::unix::{SignalKind, signal}; @@ -566,19 +899,131 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { bls_pubkey_from_hex(hex).unwrap() } +// Get the offset of the message in a SignedBuilderBid SSZ structure +fn get_ssz_value_offset_for_fork(fork: ForkName) -> Option { + match fork { + ForkName::Bellatrix => { + // Message goes header -> value -> pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len(), + ) + } + + ForkName::Capella => { + // Message goes header -> value -> pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len(), + ) + } + + ForkName::Deneb => { + // Message goes header -> blob_kzg_commitments -> value -> pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len() + + ::ssz_fixed_len(), + ) + } + + ForkName::Electra => { + // Message goes header -> blob_kzg_commitments -> execution_requests -> value -> + // pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len() + + ::ssz_fixed_len() + + ::ssz_fixed_len(), + ) + } + + ForkName::Fulu => { + // Message goes header -> blob_kzg_commitments -> execution_requests -> value -> + // pubkey + Some( + get_message_offset::() + + ::ssz_fixed_len() + + ::ssz_fixed_len() + + ::ssz_fixed_len(), + ) + } + + _ => None, + } +} + +/// Extracts the bid value from SSZ-encoded SignedBuilderBid response bytes. +pub fn get_bid_value_from_signed_builder_bid_ssz( + response_bytes: &[u8], + fork: ForkName, +) -> Result { + let value_offset = get_ssz_value_offset_for_fork(fork) + .ok_or(SszValueError::UnsupportedFork { name: fork.to_string() })?; + + // Sanity check the response length so we don't panic trying to slice it + let end_offset = value_offset + 32; // U256 is 32 bytes + if response_bytes.len() < end_offset { + return Err(SszValueError::InvalidPayloadLength { + required: end_offset, + actual: response_bytes.len(), + }); + } + + // Extract the value bytes and convert to U256 + let value_bytes = &response_bytes[value_offset..end_offset]; + let value = U256::from_le_slice(value_bytes); + Ok(value) +} + +// Get the offset where the `message` field starts in some SignedBuilderBid SSZ +// data. Requires that SignedBuilderBid always has the following structure: +// message -> signature +// where `message` is a BuilderBid type determined by the fork choice, and +// `signature` is a fixed-length Signature type. +fn get_message_offset() -> usize +where + BuilderBidType: ssz::Encode, +{ + // Since `message` is the first field, its offset is always 0 + let mut offset = 0; + + // If it's variable length, then it will be represented by a pointer to + // the actual data, so we need to get the location of where that data starts + if !BuilderBidType::is_ssz_fixed_len() { + offset += BYTES_PER_LENGTH_OFFSET + ::ssz_fixed_len(); + } + + offset +} + #[cfg(test)] mod test { use alloy::primitives::keccak256; + use axum::http::{HeaderMap, HeaderName, HeaderValue}; + use bytes::Bytes; + use reqwest::header::{ACCEPT, CONTENT_TYPE}; use super::{ - create_admin_jwt, create_jwt, decode_admin_jwt, decode_jwt, random_jwt_secret, - validate_admin_jwt, validate_jwt, + AcceptedEncodings, BodyDeserializeError, CONSENSUS_VERSION_HEADER, OUTBOUND_ACCEPT, + accept_q_value_for_index, build_outbound_accept, create_admin_jwt, create_jwt, + decode_admin_jwt, decode_jwt, deserialize_body, format_accept_entry, + get_consensus_version_header, get_content_type, parse_response_encoding_and_fork, + random_jwt_secret, validate_admin_jwt, validate_jwt, }; use crate::{ constants::SIGNER_JWT_EXPIRATION, + pbs::error::SszValueError, types::{Jwt, JwtAdminClaims, ModuleId}, + utils::{ + APPLICATION_JSON, APPLICATION_OCTET_STREAM, EncodingType, ForkName, + NO_PREFERENCE_DEFAULT, WILDCARD, get_accept_types, + get_bid_value_from_signed_builder_bid_ssz, + }, }; + const APPLICATION_TEXT: &str = "application/text"; + #[test] fn test_jwt_validation_no_payload_hash() { // Check valid JWT @@ -605,6 +1050,349 @@ mod test { assert_eq!(response.unwrap_err().to_string(), "InvalidSignature"); } + /// Make sure a missing Accept header is interpreted as JSON + #[test] + fn test_missing_accept_header() { + let headers = HeaderMap::new(); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, AcceptedEncodings::single(EncodingType::Json)); + } + + /// Test accepting JSON + #[test] + fn test_accept_header_json() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_JSON).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, AcceptedEncodings::single(EncodingType::Json)); + } + + /// Test accepting SSZ + #[test] + fn test_accept_header_ssz() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, AcceptedEncodings::single(EncodingType::Ssz)); + } + + /// Wildcard `Accept: */*` resolves to the `NO_PREFERENCE_DEFAULT` + /// policy. Separate from the explicit + /// `Accept: application/json` path to keep the two intents distinct. + #[test] + fn test_accept_header_wildcard() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(WILDCARD).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, AcceptedEncodings::single(NO_PREFERENCE_DEFAULT)); + } + + /// Test accepting one header with multiple values (order preserved, + /// first listed wins at equal q) + #[test] + fn test_accept_header_multiple_values() { + let header_string = format!("{APPLICATION_JSON}, {APPLICATION_OCTET_STREAM}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, AcceptedEncodings { + primary: EncodingType::Json, + fallback: Some(EncodingType::Ssz) + }); + } + + /// Test accepting multiple headers + #[test] + fn test_multiple_accept_headers() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_JSON).unwrap()); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert!(result.contains(EncodingType::Json)); + assert!(result.contains(EncodingType::Ssz)); + assert!(result.fallback.is_some()); + } + + /// Test accepting one header with multiple values, including a type that + /// can't be used + #[test] + fn test_accept_header_multiple_values_including_unknown() { + let header_string = + format!("{APPLICATION_JSON}, {APPLICATION_OCTET_STREAM}, {APPLICATION_TEXT}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result, AcceptedEncodings { + primary: EncodingType::Json, + fallback: Some(EncodingType::Ssz) + }); + } + + /// Test rejecting an unknown accept type + #[test] + fn test_invalid_accept_header_type() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_TEXT).unwrap()); + let result = get_accept_types(&headers); + assert!(result.is_err()); + } + + /// Test accepting one header with multiple values + #[test] + fn test_accept_header_invalid_parse() { + let header_string = format!("{APPLICATION_JSON}, a?;ef)"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers); + assert!(result.is_err()); + } + + /// q-values are honored: JSON@1.0 should outrank SSZ@0.1 regardless of + /// byte order in the header. + #[test] + fn test_accept_header_q_value_ordering() { + let mut headers = HeaderMap::new(); + headers.append( + ACCEPT, + HeaderValue::from_str("application/json;q=1.0, application/octet-stream;q=0.1") + .unwrap(), + ); + assert_eq!(get_accept_types(&headers).unwrap(), AcceptedEncodings { + primary: EncodingType::Json, + fallback: Some(EncodingType::Ssz) + }); + + let mut headers = HeaderMap::new(); + headers.append( + ACCEPT, + HeaderValue::from_str("application/octet-stream;q=0.1, application/json;q=1.0") + .unwrap(), + ); + assert_eq!(get_accept_types(&headers).unwrap(), AcceptedEncodings { + primary: EncodingType::Json, + fallback: Some(EncodingType::Ssz) + }); + } + + /// q=0 is an explicit rejection per RFC 7231 §5.3.1 and must be dropped. + #[test] + fn test_accept_header_q_zero_rejected() { + let mut headers = HeaderMap::new(); + headers.append( + ACCEPT, + HeaderValue::from_str("application/json, application/octet-stream;q=0").unwrap(), + ); + assert_eq!( + get_accept_types(&headers).unwrap(), + AcceptedEncodings::single(EncodingType::Json) + ); + } + + /// An Accept header containing only q=0 for every supported type is a + /// deliberate "I accept nothing" and must error (so the route can return + /// 406 Not Acceptable per RFC 7231 §5.3.1 and §6.5.6). + #[test] + fn test_accept_header_only_q_zero_errors() { + let mut headers = HeaderMap::new(); + headers.append( + ACCEPT, + HeaderValue::from_str("application/json;q=0, application/octet-stream;q=0").unwrap(), + ); + assert!(get_accept_types(&headers).is_err()); + } + + /// `AcceptedEncodings::preferred` picks the caller's first choice that + /// the server can actually produce. + #[test] + fn test_preferred_encoding_picks_highest_q_match() { + let accepts = + AcceptedEncodings { primary: EncodingType::Json, fallback: Some(EncodingType::Ssz) }; + let supported = [EncodingType::Ssz, EncodingType::Json]; + assert_eq!(accepts.preferred(&supported), Some(EncodingType::Json)); + + let accepts = AcceptedEncodings::single(EncodingType::Ssz); + let supported = [EncodingType::Json]; + assert_eq!(accepts.preferred(&supported), None); + } + + /// Outbound Accept should be deterministic and q-ordered to match caller + /// preference. + #[test] + fn test_build_outbound_accept_deterministic() { + let ssz_then_json = + AcceptedEncodings { primary: EncodingType::Ssz, fallback: Some(EncodingType::Json) }; + let json_then_ssz = + AcceptedEncodings { primary: EncodingType::Json, fallback: Some(EncodingType::Ssz) }; + assert_eq!( + build_outbound_accept(ssz_then_json), + "application/octet-stream;q=1.0,application/json;q=0.9" + ); + assert_eq!( + build_outbound_accept(json_then_ssz), + "application/json;q=1.0,application/octet-stream;q=0.9" + ); + + // Stable across repeats + for _ in 0..100 { + assert_eq!( + build_outbound_accept(ssz_then_json), + "application/octet-stream;q=1.0,application/json;q=0.9" + ); + } + } + + /// `AcceptedEncodings::single` produces a primary with no fallback. + #[test] + fn test_accepted_encodings_single() { + let a = AcceptedEncodings::single(EncodingType::Ssz); + assert_eq!(a.primary, EncodingType::Ssz); + assert_eq!(a.fallback, None); + } + + /// `contains` checks both primary and fallback. + #[test] + fn test_accepted_encodings_contains() { + let only_ssz = AcceptedEncodings::single(EncodingType::Ssz); + assert!(only_ssz.contains(EncodingType::Ssz)); + assert!(!only_ssz.contains(EncodingType::Json)); + + let both = + AcceptedEncodings { primary: EncodingType::Ssz, fallback: Some(EncodingType::Json) }; + assert!(both.contains(EncodingType::Ssz)); + assert!(both.contains(EncodingType::Json)); + } + + /// `iter` yields primary first, then fallback if present. Single-value + /// instances yield exactly one element. + #[test] + fn test_accepted_encodings_iter_order() { + let both = + AcceptedEncodings { primary: EncodingType::Json, fallback: Some(EncodingType::Ssz) }; + assert_eq!(both.iter().collect::>(), vec![EncodingType::Json, EncodingType::Ssz]); + + let only = AcceptedEncodings::single(EncodingType::Ssz); + assert_eq!(only.iter().collect::>(), vec![EncodingType::Ssz]); + } + + /// `IntoIterator` matches `iter`: preference order preserved, fallback + /// included only when present. + #[test] + fn test_accepted_encodings_into_iterator() { + let both = + AcceptedEncodings { primary: EncodingType::Ssz, fallback: Some(EncodingType::Json) }; + let collected: Vec<_> = both.into_iter().collect(); + assert_eq!(collected, vec![EncodingType::Ssz, EncodingType::Json]); + + let only = AcceptedEncodings::single(EncodingType::Json); + let collected: Vec<_> = only.into_iter().collect(); + assert_eq!(collected, vec![EncodingType::Json]); + } + + /// Duplicate media types in an Accept header are deduplicated — the + /// second occurrence of `primary` must not populate `fallback`. + #[test] + fn test_accept_header_duplicate_dedups() { + let header_string = format!("{APPLICATION_JSON}, {APPLICATION_JSON}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + assert_eq!( + get_accept_types(&headers).unwrap(), + AcceptedEncodings::single(EncodingType::Json) + ); + } + + /// Once primary and fallback are filled, further supported entries must + /// not overwrite fallback. (Belt-and-suspenders — only two supported + /// variants exist today, so this is mostly a guard against future + /// regressions if a third variant is added.) + #[test] + fn test_accept_header_third_supported_entry_ignored() { + // Repeat SSZ to simulate a third supported-but-duplicate entry + // landing after primary+fallback are already set. + let header_string = + format!("{APPLICATION_JSON}, {APPLICATION_OCTET_STREAM}, {APPLICATION_JSON}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + assert_eq!(get_accept_types(&headers).unwrap(), AcceptedEncodings { + primary: EncodingType::Json, + fallback: Some(EncodingType::Ssz) + }); + } + + /// Unsupported media types interleaved with supported ones must not + /// occupy the primary or fallback slots. + #[test] + fn test_accept_header_unsupported_does_not_fill_fallback() { + let header_string = format!("{APPLICATION_TEXT}, {APPLICATION_JSON}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + // `saw_any = true` and `had_supported = true`, so we return the + // supported type as primary with no fallback. + assert_eq!( + get_accept_types(&headers).unwrap(), + AcceptedEncodings::single(EncodingType::Json) + ); + } + + /// `build_outbound_accept` on a single-value `AcceptedEncodings` emits + /// exactly one entry at q=1.0 (no trailing comma, no orphan fallback). + #[test] + fn test_build_outbound_accept_single_value() { + let only_ssz = AcceptedEncodings::single(EncodingType::Ssz); + assert_eq!(build_outbound_accept(only_ssz), "application/octet-stream;q=1.0"); + + let only_json = AcceptedEncodings::single(EncodingType::Json); + assert_eq!(build_outbound_accept(only_json), "application/json;q=1.0"); + } + + /// `preferred` walks the caller's preference order and returns the + /// first supported match — not the server's first choice. + #[test] + fn test_preferred_respects_caller_order_over_server_order() { + // Caller prefers JSON first. Server lists SSZ first. Caller wins. + let accepts = + AcceptedEncodings { primary: EncodingType::Json, fallback: Some(EncodingType::Ssz) }; + assert_eq!( + accepts.preferred(&[EncodingType::Ssz, EncodingType::Json]), + Some(EncodingType::Json) + ); + } + + /// Snapshot test: constant emits exactly what we document in + /// OUTBOUND_ACCEPT. + #[test] + fn test_outbound_accept_constant_snapshot() { + assert_eq!(OUTBOUND_ACCEPT, "application/octet-stream;q=1.0,application/json;q=0.9"); + } + + /// q-value ladder: first entry is 1.0, each subsequent entry drops by 0.1. + #[test] + fn test_accept_q_value_for_index_ladder() { + assert!((accept_q_value_for_index(0) - 1.0).abs() < f32::EPSILON); + assert!((accept_q_value_for_index(1) - 0.9).abs() < f32::EPSILON); + assert!((accept_q_value_for_index(5) - 0.5).abs() < f32::EPSILON); + assert!((accept_q_value_for_index(9) - 0.1).abs() < f32::EPSILON); + } + + /// Clamp at 0.1: we never emit q=0 (which per RFC 7231 §5.3.1 would mean + /// "not acceptable"). + #[test] + fn test_accept_q_value_for_index_clamps_to_minimum() { + assert!((accept_q_value_for_index(10) - 0.1).abs() < f32::EPSILON); + assert!((accept_q_value_for_index(100) - 0.1).abs() < f32::EPSILON); + // Even an adversarial usize::MAX must not underflow or drop to zero. + assert!((accept_q_value_for_index(usize::MAX) - 0.1).abs() < f32::EPSILON); + } + + /// Entry formatter emits the spec-shaped string. + #[test] + fn test_format_accept_entry_shape() { + assert_eq!(format_accept_entry(EncodingType::Ssz, 1.0), "application/octet-stream;q=1.0"); + assert_eq!(format_accept_entry(EncodingType::Json, 0.9), "application/json;q=0.9"); + // One decimal place, even when the value has more precision. + assert_eq!(format_accept_entry(EncodingType::Json, 0.12345), "application/json;q=0.1"); + } + #[test] fn test_jwt_validation_with_payload() { // Pretend payload @@ -783,4 +1571,284 @@ mod test { // Two calls should produce distinct values with overwhelming probability. assert_ne!(secret, random_jwt_secret()); } + + // ── get_content_type ───────────────────────────────────────────────────── + + #[test] + fn test_content_type_missing_defaults_to_json() { + let headers = HeaderMap::new(); + assert_eq!(get_content_type(&headers), EncodingType::Json); + } + + #[test] + fn test_content_type_json() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str(APPLICATION_JSON).unwrap()); + assert_eq!(get_content_type(&headers), EncodingType::Json); + } + + #[test] + fn test_content_type_ssz() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + assert_eq!(get_content_type(&headers), EncodingType::Ssz); + } + + #[test] + fn test_content_type_unknown_defaults_to_json() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str("application/xml").unwrap()); + assert_eq!(get_content_type(&headers), EncodingType::Json); + } + + // ── get_consensus_version_header ───────────────────────────────────────── + + #[test] + fn test_consensus_version_header_electra() { + let mut headers = HeaderMap::new(); + let name = HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(); + headers.insert(name, HeaderValue::from_str("electra").unwrap()); + assert_eq!(get_consensus_version_header(&headers), Some(ForkName::Electra)); + } + + #[test] + fn test_consensus_version_header_missing() { + let headers = HeaderMap::new(); + assert_eq!(get_consensus_version_header(&headers), None); + } + + #[test] + fn test_consensus_version_header_invalid() { + let mut headers = HeaderMap::new(); + let name = HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(); + headers.insert(name, HeaderValue::from_str("not_a_fork").unwrap()); + assert_eq!(get_consensus_version_header(&headers), None); + } + + // ── EncodingType ───────────────────────────────────────────────────────── + + #[test] + fn test_encoding_type_from_str_variants() { + use std::str::FromStr; + assert_eq!(EncodingType::from_str(APPLICATION_JSON).unwrap(), EncodingType::Json); + assert_eq!(EncodingType::from_str(APPLICATION_OCTET_STREAM).unwrap(), EncodingType::Ssz); + // empty string defaults to JSON per the impl + assert_eq!(EncodingType::from_str("").unwrap(), EncodingType::Json); + assert!(EncodingType::from_str("application/xml").is_err()); + } + + #[test] + fn test_encoding_type_from_str_with_mime_params() { + // RFC 7231 §3.1.1.1: media-type parameters must be tolerated. + // Relays behind proxies routinely add charset= and similar. + use std::str::FromStr; + assert_eq!( + EncodingType::from_str("application/json; charset=utf-8").unwrap(), + EncodingType::Json + ); + assert_eq!( + EncodingType::from_str("application/octet-stream; boundary=x").unwrap(), + EncodingType::Ssz + ); + // Case-insensitivity per RFC 7231: type/subtype are lowercased before + // comparison. + assert_eq!(EncodingType::from_str("APPLICATION/OCTET-STREAM").unwrap(), EncodingType::Ssz); + // Extra whitespace around parameters is tolerated by the MIME parser. + assert_eq!( + EncodingType::from_str("application/json;charset=utf-8").unwrap(), + EncodingType::Json + ); + // Garbage that can't parse as a media type is an error. + assert!(EncodingType::from_str("garbage").is_err()); + // A parseable media type that isn't one we support is an error. + assert!(EncodingType::from_str("text/plain").is_err()); + } + + #[test] + fn test_parse_response_encoding_and_fork_tolerates_mime_params() { + // Full integration of the helper: missing header defaults to JSON, + // present header with params still decodes correctly. + let mut headers = HeaderMap::new(); + let (enc, fork) = parse_response_encoding_and_fork(&headers, 200).unwrap(); + assert_eq!(enc, EncodingType::Json); + assert!(fork.is_none()); + + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str("application/octet-stream; charset=binary").unwrap(), + ); + let (enc, _) = parse_response_encoding_and_fork(&headers, 200).unwrap(); + assert_eq!(enc, EncodingType::Ssz); + + headers.insert(CONTENT_TYPE, HeaderValue::from_str("application/xml").unwrap()); + let err = parse_response_encoding_and_fork(&headers, 415).unwrap_err(); + match err { + crate::pbs::error::PbsError::RelayResponse { code, .. } => assert_eq!(code, 415), + other => panic!("expected RelayResponse, got {other:?}"), + } + } + + #[test] + fn test_encoding_type_display() { + assert_eq!(EncodingType::Json.to_string(), APPLICATION_JSON); + assert_eq!(EncodingType::Ssz.to_string(), APPLICATION_OCTET_STREAM); + } + + // ── get_bid_value_from_signed_builder_bid_ssz ──────────────────────────── + + #[test] + fn test_ssz_value_extraction_unsupported_fork() { + let dummy_bytes = vec![0u8; 1000]; + let err = + get_bid_value_from_signed_builder_bid_ssz(&dummy_bytes, ForkName::Altair).unwrap_err(); + assert!(matches!(err, SszValueError::UnsupportedFork { .. })); + } + + #[test] + fn test_ssz_value_extraction_truncated_payload() { + // A payload that is far too short for any supported fork's value offset + let tiny_bytes = vec![0u8; 4]; + let err = + get_bid_value_from_signed_builder_bid_ssz(&tiny_bytes, ForkName::Electra).unwrap_err(); + assert!(matches!(err, SszValueError::InvalidPayloadLength { .. })); + } + + /// Per-fork positive tests: construct a `SignedBuilderBid` with a known + /// value for each supported fork, SSZ-encode it, and verify + /// `get_bid_value_from_signed_builder_bid_ssz` round-trips correctly. + #[test] + fn test_ssz_value_extraction_with_known_bid() { + use alloy::primitives::U256; + use ssz::Encode; + + use crate::{ + pbs::{ + BuilderBid, BuilderBidBellatrix, BuilderBidCapella, BuilderBidDeneb, + BuilderBidElectra, BuilderBidFulu, ExecutionPayloadHeaderBellatrix, + ExecutionPayloadHeaderCapella, ExecutionPayloadHeaderDeneb, + ExecutionPayloadHeaderElectra, ExecutionPayloadHeaderFulu, ExecutionRequests, + SignedBuilderBid, + }, + types::{BlsPublicKeyBytes, BlsSignature}, + utils::TestRandomSeed, + }; + + // Distinctive value — large enough that endianness bugs produce a + // different number and zero-matches are impossible. + let known_value = U256::from(0x0102_0304_0506_0708_u64); + let pubkey = BlsPublicKeyBytes::test_random(); + let sig = BlsSignature::test_random(); + + // ── Bellatrix ──────────────────────────────────────────────────────── + { + let message = BuilderBid::Bellatrix(BuilderBidBellatrix { + header: ExecutionPayloadHeaderBellatrix::test_random(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig.clone() }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Bellatrix) + .expect("Bellatrix extraction failed"); + assert_eq!(got, known_value, "Bellatrix: value mismatch"); + } + + // ── Capella ────────────────────────────────────────────────────────── + { + let message = BuilderBid::Capella(BuilderBidCapella { + header: ExecutionPayloadHeaderCapella::test_random(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig.clone() }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Capella) + .expect("Capella extraction failed"); + assert_eq!(got, known_value, "Capella: value mismatch"); + } + + // ── Deneb ──────────────────────────────────────────────────────────── + { + let message = BuilderBid::Deneb(BuilderBidDeneb { + header: ExecutionPayloadHeaderDeneb::test_random(), + blob_kzg_commitments: Default::default(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig.clone() }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Deneb) + .expect("Deneb extraction failed"); + assert_eq!(got, known_value, "Deneb: value mismatch"); + } + + // ── Electra ────────────────────────────────────────────────────────── + { + let message = BuilderBid::Electra(BuilderBidElectra { + header: ExecutionPayloadHeaderElectra::test_random(), + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig.clone() }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Electra) + .expect("Electra extraction failed"); + assert_eq!(got, known_value, "Electra: value mismatch"); + } + + // ── Fulu ───────────────────────────────────────────────────────────── + { + let message = BuilderBid::Fulu(BuilderBidFulu { + header: ExecutionPayloadHeaderFulu::test_random(), + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: known_value, + pubkey, + }); + let bid = SignedBuilderBid { message, signature: sig }; + let got = + get_bid_value_from_signed_builder_bid_ssz(&bid.as_ssz_bytes(), ForkName::Fulu) + .expect("Fulu extraction failed"); + assert_eq!(got, known_value, "Fulu: value mismatch"); + } + } + + // ── deserialize_body error paths ───────────────────────────────────────── + + /// Missing Content-Type falls back to the `NO_PREFERENCE_DEFAULT` (JSON) + /// path, matching pre-PR behavior. Garbage body reaches the JSON + /// decoder and errors as `SerdeJsonError`, proving the default kicked + /// in (vs. bailing early with `UnsupportedMediaType`). + #[tokio::test] + async fn test_deserialize_body_missing_content_type_falls_back_to_json() { + let headers = HeaderMap::new(); + let body = Bytes::from_static(b"not json"); + let err = deserialize_body(&headers, body).await.unwrap_err(); + assert!( + matches!(err, BodyDeserializeError::SerdeJsonError(_)), + "expected SerdeJsonError (JSON decode attempted), got: {err}" + ); + } + + /// Present-but-unrecognized Content-Type still bails as + /// `UnsupportedMediaType`; the fallback only covers *missing* headers. + #[tokio::test] + async fn test_deserialize_body_unrecognized_content_type() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + let body = Bytes::from_static(b"hi"); + let err = deserialize_body(&headers, body).await.unwrap_err(); + assert!(matches!(err, BodyDeserializeError::UnsupportedMediaType)); + } + + #[tokio::test] + async fn test_deserialize_body_ssz_missing_version_header() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + let body = Bytes::from_static(b"\x00\x01\x02\x03"); + let err = deserialize_body(&headers, body).await.unwrap_err(); + assert!(matches!(err, BodyDeserializeError::MissingVersionHeader)); + } } diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index a9124c06..9d9df214 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -12,9 +12,13 @@ axum.workspace = true axum-extra.workspace = true cb-common.workspace = true cb-metrics.workspace = true +ethereum_serde_utils.workspace = true +ethereum_ssz.workspace = true eyre.workspace = true futures.workspace = true +headers.workspace = true lazy_static.workspace = true +lh_types.workspace = true notify.workspace = true parking_lot.workspace = true prometheus.workspace = true diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 590c03d4..7f3b3e2a 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -1,4 +1,5 @@ use axum::{http::StatusCode, response::IntoResponse}; +use cb_common::utils::BodyDeserializeError; #[derive(Debug)] /// Errors that the PbsService returns to client @@ -6,6 +7,9 @@ pub enum PbsClientError { NoResponse, NoPayload, Internal, + DecodeError(String), + #[allow(dead_code)] + RelayError(String), } impl PbsClientError { @@ -14,16 +18,26 @@ impl PbsClientError { PbsClientError::NoResponse => StatusCode::BAD_GATEWAY, PbsClientError::NoPayload => StatusCode::BAD_GATEWAY, PbsClientError::Internal => StatusCode::INTERNAL_SERVER_ERROR, + PbsClientError::DecodeError(_) => StatusCode::BAD_REQUEST, + PbsClientError::RelayError(_) => StatusCode::FAILED_DEPENDENCY, } } } +impl From for PbsClientError { + fn from(e: BodyDeserializeError) -> Self { + PbsClientError::DecodeError(format!("failed to deserialize body: {e}")) + } +} + impl IntoResponse for PbsClientError { fn into_response(self) -> axum::response::Response { let msg = match &self { PbsClientError::NoResponse => "no response from relays".to_string(), PbsClientError::NoPayload => "no payload from relays".to_string(), PbsClientError::Internal => "internal server error".to_string(), + PbsClientError::DecodeError(e) => format!("error decoding request: {e}"), + PbsClientError::RelayError(e) => format!("error processing relay response: {e}"), }; (self.status_code(), msg).into_response() From 760221edc7b0377f702808bc5b3d02dd403005ff Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 19 May 2026 13:27:39 -0700 Subject: [PATCH 2/3] Rewrite get_header relay communication to support SSZ and JSON content negotiation. Includes: - SSZ and JSON encoding/decoding for relay get_header responses - Content-Type and Eth-Consensus-Version header parsing - Accept header forwarding to relays with q-value ordering - MIME parameter tolerance on relay response Content-Type - Fork-aware SSZ bid value extraction for all supported forks - Mock relay and validator SSZ support in test infrastructure - get_header integration tests for both encodings - Dynamic port allocation in tests via get_free_listener --- Cargo.lock | 2 + crates/pbs/src/mev_boost/get_header.rs | 518 +++++++++++++++++-------- crates/pbs/src/routes/get_header.rs | 48 ++- tests/Cargo.toml | 2 + tests/data/get_header/bellatrix.json | 26 ++ tests/data/get_header/capella.json | 27 ++ tests/data/get_header/deneb.json | 37 ++ tests/data/get_header/electra.json | 62 +++ tests/data/get_header/fulu.json | 62 +++ tests/src/mock_relay.rs | 315 +++++++++++++-- tests/src/mock_validator.rs | 39 +- tests/src/utils.rs | 13 + tests/tests/pbs_cfg_file_update.rs | 25 +- tests/tests/pbs_get_header.rs | 397 +++++++++++++++++-- tests/tests/pbs_mux.rs | 116 ++++-- tests/tests/pbs_mux_refresh.rs | 48 ++- 16 files changed, 1431 insertions(+), 306 deletions(-) create mode 100644 tests/data/get_header/bellatrix.json create mode 100644 tests/data/get_header/capella.json create mode 100644 tests/data/get_header/deneb.json create mode 100644 tests/data/get_header/electra.json create mode 100644 tests/data/get_header/fulu.json diff --git a/Cargo.lock b/Cargo.lock index a0b06233..9bce0e12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1891,6 +1891,8 @@ dependencies = [ "cb-common", "cb-pbs", "cb-signer", + "eth2", + "ethereum_ssz 0.10.3", "eyre", "jsonwebtoken", "rcgen", diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index c144e2c0..49b3d2f0 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -12,20 +12,25 @@ use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ constants::APPLICATION_BUILDER_DOMAIN, pbs::{ - EMPTY_TX_ROOT_HASH, ExecutionPayloadHeaderRef, GetHeaderInfo, GetHeaderParams, - GetHeaderResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, RelayClient, + EMPTY_TX_ROOT_HASH, ExecutionPayloadHeaderRef, ForkName, ForkVersionDecode, GetHeaderInfo, + GetHeaderParams, GetHeaderResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, + RelayClient, SignedBuilderBid, error::{PbsError, ValidationError}, }, signature::verify_signed_message, types::{BlsPublicKey, BlsPublicKeyBytes, BlsSignature, Chain}, utils::{ - get_user_agent_with_version, ms_into_slot, read_chunked_body_with_max, - timestamp_of_slot_start_sec, utcnow_ms, + EncodingType, OUTBOUND_ACCEPT, get_user_agent_with_version, ms_into_slot, + parse_response_encoding_and_fork, read_chunked_body_with_max, timestamp_of_slot_start_sec, + utcnow_ms, }, }; use futures::future::join_all; use parking_lot::RwLock; -use reqwest::{StatusCode, header::USER_AGENT}; +use reqwest::{ + StatusCode, + header::{ACCEPT, USER_AGENT}, +}; use tokio::time::sleep; use tracing::{Instrument, debug, error, info, warn}; use tree_hash::TreeHash; @@ -41,6 +46,53 @@ use crate::{ utils::check_gas_limit, }; +/// Info about an incoming get_header request. +/// Sent from get_header to each send_timed_get_header call. +#[derive(Clone)] +struct RequestInfo { + /// The blockchain parameters of the get_header request (what slot it's for, + /// which pubkey is requesting it, etc) + params: GetHeaderParams, + + /// Common baseline of headers to send with each request + headers: Arc, + + /// The chain the request is for + chain: Chain, + + /// Context for validating the header returned by the relay + validation: ValidationContext, +} + +struct GetHeaderResponseInfo { + /// ID of the relay the response came from + relay_id: Arc, + + /// The raw body of the response + response_bytes: Vec, + + /// The content type the response is encoded with + content_type: EncodingType, + + /// Which fork the response bid is for (if provided as a header, rather than + /// part of the body) + fork: Option, + + /// The status code of the response, for logging + code: StatusCode, + + /// The round-trip latency of the request + request_latency: Duration, +} + +#[derive(Clone)] +struct ValidationContext { + skip_sigverify: bool, + min_bid_wei: U256, + extra_validation_enabled: bool, + parent_block: Arc>>, +} + /// Implements https://ethereum.github.io/builder-specs/#/Builder/getHeader /// Returns 200 if at least one relay returns 200, else 204 pub async fn get_header( @@ -97,22 +149,37 @@ pub async fn get_header( let mut send_headers = HeaderMap::new(); send_headers.insert(USER_AGENT, get_user_agent_with_version(&req_headers)?); + // Create the Accept headers for requests + // Use the documented, deterministic preference: + // SSZ first (wire-efficient), JSON fallback. + let accept_types = OUTBOUND_ACCEPT.to_string(); + send_headers.insert( + ACCEPT, + HeaderValue::from_str(&accept_types) + .map_err(|e| PbsError::GeneralRequest(format!("invalid accept header value: {e}")))?, + ); + + // Send requests to all relays concurrently + let slot = params.slot as i64; + let request_info = Arc::new(RequestInfo { + params, + headers: Arc::new(send_headers), + chain: state.config.chain, + validation: ValidationContext { + skip_sigverify: state.pbs_config().skip_sigverify, + min_bid_wei: state.pbs_config().min_bid_wei, + extra_validation_enabled: state.extra_validation_enabled(), + parent_block, + }, + }); let mut handles = Vec::with_capacity(relays.len()); for relay in relays.iter() { handles.push( send_timed_get_header( - params.clone(), + request_info.clone(), relay.clone(), - state.config.chain, - send_headers.clone(), ms_into_slot, max_timeout_ms, - ValidationContext { - skip_sigverify: state.pbs_config().skip_sigverify, - min_bid_wei: state.pbs_config().min_bid_wei, - extra_validation_enabled: state.extra_validation_enabled(), - parent_block: parent_block.clone(), - }, ) .in_current_span(), ); @@ -125,7 +192,7 @@ pub async fn get_header( match res { Ok(Some(res)) => { - RELAY_LAST_SLOT.with_label_values(&[relay_id]).set(params.slot as i64); + RELAY_LAST_SLOT.with_label_values(&[relay_id]).set(slot); let value_gwei = (res.data.message.value() / U256::from(1_000_000_000)) .try_into() .unwrap_or_default(); @@ -179,15 +246,13 @@ async fn fetch_parent_block( } async fn send_timed_get_header( - params: GetHeaderParams, + request_info: Arc, relay: RelayClient, - chain: Chain, - headers: HeaderMap, ms_into_slot: u64, mut timeout_left_ms: u64, - validation: ValidationContext, ) -> Result, PbsError> { - let url = relay.get_header_url(params.slot, ¶ms.parent_hash, ¶ms.pubkey)?; + let params = &request_info.params; + let url = Arc::new(relay.get_header_url(params.slot, ¶ms.parent_hash, ¶ms.pubkey)?); if relay.config.enable_timing_games { if let Some(target_ms) = relay.config.target_first_request_ms { @@ -218,18 +283,12 @@ async fn send_timed_get_header( ); loop { - let params = params.clone(); handles.push(tokio::spawn( send_one_get_header( - params, + request_info.clone(), relay.clone(), - chain, - RequestContext { - timeout_ms: timeout_left_ms, - url: url.clone(), - headers: headers.clone(), - }, - validation.clone(), + url.clone(), + timeout_left_ms, ) .in_current_span(), )); @@ -285,54 +344,181 @@ async fn send_timed_get_header( } // if no timing games or no repeated send, just send one request - send_one_get_header( - params, - relay, - chain, - RequestContext { timeout_ms: timeout_left_ms, url, headers }, - validation, + send_one_get_header(request_info, relay, url, timeout_left_ms) + .await + .map(|(_, maybe_header)| maybe_header) +} + +/// Handles requesting a header from a relay, decoding, and validation. +/// Used by send_timed_get_header to handle each individual request. +async fn send_one_get_header( + request_info: Arc, + relay: RelayClient, + url: Arc, + timeout_left_ms: u64, +) -> Result<(u64, Option), PbsError> { + // Full processing: decode full response and validate + let (start_request_time, get_header_response) = send_get_header_full( + &relay, + url, + timeout_left_ms, + (*request_info.headers).clone(), /* Create a copy of the HeaderMap because the + * impl + * will + * modify it */ ) - .await - .map(|(_, maybe_header)| maybe_header) + .await?; + let get_header_response = match get_header_response { + None => { + // Break if there's no header + return Ok((start_request_time, None)); + } + Some(res) => res, + }; + + // Extract the basic header data needed for validation + let header_data = match &get_header_response.data.message.header() { + ExecutionPayloadHeaderRef::Bellatrix(_) | + ExecutionPayloadHeaderRef::Capella(_) | + ExecutionPayloadHeaderRef::Deneb(_) => { + Err(PbsError::Validation(ValidationError::UnsupportedFork)) + } + ExecutionPayloadHeaderRef::Electra(res) => Ok(HeaderData { + block_hash: res.block_hash.0, + parent_hash: res.parent_hash.0, + tx_root: res.transactions_root, + value: *get_header_response.value(), + timestamp: res.timestamp, + }), + ExecutionPayloadHeaderRef::Fulu(res) => Ok(HeaderData { + block_hash: res.block_hash.0, + parent_hash: res.parent_hash.0, + tx_root: res.transactions_root, + value: *get_header_response.value(), + timestamp: res.timestamp, + }), + }?; + + // Validate the header + let chain = request_info.chain; + let params = &request_info.params; + let validation = &request_info.validation; + validate_header_data( + &header_data, + chain, + params.parent_hash, + validation.min_bid_wei, + params.slot, + )?; + + // Validate the relay signature + if !validation.skip_sigverify { + validate_signature( + chain, + relay.pubkey(), + get_header_response.data.message.pubkey(), + &get_header_response.data.message, + &get_header_response.data.signature, + )?; + } + + // Validate the parent block if enabled + if validation.extra_validation_enabled { + let parent_block = validation.parent_block.read(); + if let Some(parent_block) = parent_block.as_ref() { + extra_validation(parent_block, &get_header_response)?; + } else { + warn!( + relay_id = relay.id.as_ref(), + "parent block not found, skipping extra validation" + ); + } + } + + Ok((start_request_time, Some(get_header_response))) } -struct RequestContext { - url: Url, - timeout_ms: u64, +/// Send and decode a full get_header response, with all of the fields. +async fn send_get_header_full( + relay: &RelayClient, + url: Arc, + timeout_left_ms: u64, headers: HeaderMap, +) -> Result<(u64, Option), PbsError> { + // Send the request + let (start_request_time, info) = + send_get_header_impl(relay, url, timeout_left_ms, headers).await?; + let info = match info { + Some(info) => info, + None => { + return Ok((start_request_time, None)); + } + }; + + // Decode the response + let get_header_response = decode_by_encoding(&info, decode_json_payload, decode_ssz_payload)?; + + // Log and return + info!( + relay_id = info.relay_id.as_ref(), + header_size_bytes = info.response_bytes.len(), + latency = ?info.request_latency, + version =? get_header_response.version, + value_eth = format_ether(*get_header_response.value()), + block_hash = %get_header_response.block_hash(), + content_type = ?info.content_type, + "received new header" + ); + Ok((start_request_time, Some(get_header_response))) } -#[derive(Clone)] -struct ValidationContext { - skip_sigverify: bool, - min_bid_wei: U256, - extra_validation_enabled: bool, - parent_block: Arc>>, +/// Dispatch a get_header response to the appropriate decoder based on the +/// negotiated content-type. SSZ requires a fork header; its absence is a +/// protocol violation reported as `PbsError::RelayResponse`. Callers supply +/// the format-specific decoders, keeping the encoding branch in one place. +fn decode_by_encoding( + info: &GetHeaderResponseInfo, + on_json: impl FnOnce(&[u8]) -> Result, + on_ssz: impl FnOnce(&[u8], ForkName) -> Result, +) -> Result { + match info.content_type { + EncodingType::Json => on_json(&info.response_bytes), + EncodingType::Ssz => { + let fork = info.fork.ok_or_else(|| PbsError::RelayResponse { + error_msg: "relay did not provide consensus version header for ssz payload" + .to_string(), + code: info.code.as_u16(), + })?; + on_ssz(&info.response_bytes, fork) + } + } } -async fn send_one_get_header( - params: GetHeaderParams, - relay: RelayClient, - chain: Chain, - mut req_config: RequestContext, - validation: ValidationContext, -) -> Result<(u64, Option), PbsError> { +/// Sends a get_header request to a relay, returning the response, the time the +/// request was started, and the encoding type of the response (if any). +/// Used by send_one_get_header to perform the actual request submission. +async fn send_get_header_impl( + relay: &RelayClient, + url: Arc, + timeout_left_ms: u64, + mut headers: HeaderMap, +) -> Result<(u64, Option), PbsError> { // the timestamp in the header is the consensus block time which is fixed, // use the beginning of the request as proxy to make sure we use only the // last one received let start_request_time = utcnow_ms(); - req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); + headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); // The timeout header indicating how long a relay has to respond, so they can // minimize timing games without losing the bid - req_config.headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(req_config.timeout_ms)); + headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(timeout_left_ms)); let start_request = Instant::now(); let res = match relay .client - .get(req_config.url) - .timeout(Duration::from_millis(req_config.timeout_ms)) - .headers(req_config.headers) + .get(url.as_ref().clone()) + .timeout(Duration::from_millis(timeout_left_ms)) + .headers(headers) .send() .await { @@ -353,120 +539,73 @@ async fn send_one_get_header( let code = res.status(); RELAY_STATUS_CODE.with_label_values(&[code.as_str(), GET_HEADER_ENDPOINT_TAG, &relay.id]).inc(); - let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; - let header_size_bytes = response_bytes.len(); - if !code.is_success() { - return Err(PbsError::RelayResponse { - error_msg: String::from_utf8_lossy(&response_bytes).into_owned(), - code: code.as_u16(), - }); - }; - if code == StatusCode::NO_CONTENT { - debug!( - relay_id = relay.id.as_ref(), - ?code, - latency = ?request_latency, - response = ?response_bytes, - "no header from relay" - ); - return Ok((start_request_time, None)); - } - - let get_header_response = match serde_json::from_slice::(&response_bytes) { - Ok(parsed) => parsed, - Err(err) => { - return Err(PbsError::JsonDecode { - err, - raw: String::from_utf8_lossy(&response_bytes).into_owned(), + // Per the builder spec, 200 carries a bid payload and 204 means no bid + // is available. Any other status is an error. We check before reading + // the body so that response headers are still accessible for + // Content-Type and Eth-Consensus-Version parsing on the 200 path. + if code != StatusCode::OK { + if code == StatusCode::NO_CONTENT { + let response_bytes = + read_chunked_body_with_max(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; + debug!( + relay_id = relay.id.as_ref(), + ?code, + latency = ?request_latency, + response = ?response_bytes, + "no header from relay" + ); + return Ok((start_request_time, None)); + } else { + return Err(PbsError::RelayResponse { + error_msg: format!("unexpected status code from relay: {code}"), + code: code.as_u16(), }); } - }; + } - info!( - relay_id = relay.id.as_ref(), - header_size_bytes, - latency = ?request_latency, - version =? get_header_response.version, - value_eth = format_ether(*get_header_response.value()), - block_hash = %get_header_response.block_hash(), - "received new header" - ); + // Parse Content-Type (tolerating MIME parameters per RFC 7231 §3.1.1.1) + // and Eth-Consensus-Version headers in one shot. + let (content_type, fork) = parse_response_encoding_and_fork(res.headers(), code.as_u16())?; - match &get_header_response.data.message.header() { - ExecutionPayloadHeaderRef::Bellatrix(_) | - ExecutionPayloadHeaderRef::Capella(_) | - ExecutionPayloadHeaderRef::Deneb(_) => { - return Err(PbsError::Validation(ValidationError::UnsupportedFork)) - } - ExecutionPayloadHeaderRef::Electra(res) => { - let header_data = HeaderData { - block_hash: res.block_hash.0, - parent_hash: res.parent_hash.0, - tx_root: res.transactions_root, - value: *get_header_response.value(), - timestamp: res.timestamp, - }; - - validate_header_data( - &header_data, - chain, - params.parent_hash, - validation.min_bid_wei, - params.slot, - )?; - - if !validation.skip_sigverify { - validate_signature( - chain, - relay.pubkey(), - get_header_response.data.message.pubkey(), - &get_header_response.data.message, - &get_header_response.data.signature, - )?; - } - } - ExecutionPayloadHeaderRef::Fulu(res) => { - let header_data = HeaderData { - block_hash: res.block_hash.0, - parent_hash: res.parent_hash.0, - tx_root: res.transactions_root, - value: *get_header_response.value(), - timestamp: res.timestamp, - }; - - validate_header_data( - &header_data, - chain, - params.parent_hash, - validation.min_bid_wei, - params.slot, - )?; - - if !validation.skip_sigverify { - validate_signature( - chain, - relay.pubkey(), - get_header_response.data.message.pubkey(), - &get_header_response.data.message, - &get_header_response.data.signature, - )?; - } - } - } + // Decode the body + let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; - if validation.extra_validation_enabled { - let parent_block = validation.parent_block.read(); - if let Some(parent_block) = parent_block.as_ref() { - extra_validation(parent_block, &get_header_response)?; - } else { - warn!( - relay_id = relay.id.as_ref(), - "parent block not found, skipping extra validation" - ); - } + Ok(( + start_request_time, + Some(GetHeaderResponseInfo { + relay_id: relay.id.clone(), + response_bytes, + content_type, + fork, + code, + request_latency, + }), + )) +} + +/// Decode a JSON-encoded get_header response +fn decode_json_payload(response_bytes: &[u8]) -> Result { + match serde_json::from_slice::(response_bytes) { + Ok(parsed) => Ok(parsed), + Err(err) => Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(response_bytes).into_owned(), + }), } +} - Ok((start_request_time, Some(get_header_response))) +/// Decode an SSZ-encoded get_header response +fn decode_ssz_payload( + response_bytes: &[u8], + fork: ForkName, +) -> Result { + let data = SignedBuilderBid::from_ssz_bytes_by_fork(response_bytes, fork).map_err(|e| { + PbsError::RelayResponse { + error_msg: (format!("error decoding relay payload: {e:?}")).to_string(), + code: 200, + } + })?; + Ok(GetHeaderResponse { version: fork, data, metadata: Default::default() }) } struct HeaderData { @@ -565,13 +704,18 @@ fn extra_validation( #[cfg(test)] mod tests { + use std::{fs, path::Path}; + use alloy::primitives::{B256, U256}; use cb_common::{ - pbs::{EMPTY_TX_ROOT_HASH, error::ValidationError}, + pbs::*, signature::sign_builder_message, - types::{BlsSecretKey, Chain}, - utils::{TestRandomSeed, timestamp_of_slot_start_sec}, + types::{BlsPublicKeyBytes, BlsSecretKey, BlsSignature, Chain}, + utils::{ + TestRandomSeed, get_bid_value_from_signed_builder_bid_ssz, timestamp_of_slot_start_sec, + }, }; + use ssz::Encode; use super::{validate_header_data, *}; @@ -673,4 +817,42 @@ mod tests { .is_ok() ); } + + #[test] + fn test_ssz_value_extraction() { + for fork_name in ForkName::list_all() { + match fork_name { + // Handle forks that didn't have builder bids yet + ForkName::Altair | ForkName::Base => continue, + + // Handle supported forks + ForkName::Bellatrix | + ForkName::Capella | + ForkName::Deneb | + ForkName::Electra | + ForkName::Fulu => {} + + // Skip unsupported forks + ForkName::Gloas => continue, + } + + // Load get_header JSON from test data + let fork_name_str = fork_name.to_string().to_lowercase(); + let path_str = format!("../../tests/data/get_header/{fork_name_str}.json"); + let path = Path::new(path_str.as_str()); + let json_bytes = fs::read(path).expect("file not found"); + let decoded = decode_json_payload(&json_bytes).expect("failed to decode JSON"); + + // Extract the bid value from the SSZ + let encoded = decoded.data.as_ssz_bytes(); + let bid_value = get_bid_value_from_signed_builder_bid_ssz(&encoded, fork_name) + .expect("failed to extract bid value from SSZ"); + + // Compare to the original value + println!("Testing fork: {}", fork_name); + println!("Original value: {}", decoded.value()); + println!("Extracted value: {}", bid_value); + assert_eq!(*decoded.value(), bid_value); + } + } } diff --git a/crates/pbs/src/routes/get_header.rs b/crates/pbs/src/routes/get_header.rs index 9ed312af..34a10b7c 100644 --- a/crates/pbs/src/routes/get_header.rs +++ b/crates/pbs/src/routes/get_header.rs @@ -1,14 +1,17 @@ use alloy::primitives::utils::format_ether; use axum::{ extract::{Path, State}, - http::HeaderMap, + http::{HeaderMap, HeaderValue}, response::IntoResponse, }; use cb_common::{ pbs::{GetHeaderInfo, GetHeaderParams}, - utils::{get_user_agent, ms_into_slot}, + utils::{ + CONSENSUS_VERSION_HEADER, EncodingType, get_accept_types, get_user_agent, ms_into_slot, + }, }; -use reqwest::StatusCode; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; use tracing::{error, info}; use crate::{ @@ -32,16 +35,51 @@ pub async fn handle_get_header>( let ua = get_user_agent(&req_headers); let ms_into_slot = ms_into_slot(params.slot, state.config.chain); + let accept_types = get_accept_types(&req_headers).map_err(|e| { + error!(%e, "error parsing accept header"); + PbsClientError::DecodeError(format!("error parsing accept header: {e}")) + })?; + // Honor caller q-value preference: pick the highest-priority encoding that + // we can actually produce. Server preference for tiebreaks is SSZ first. + let response_encoding = accept_types.preferred(&[EncodingType::Ssz, EncodingType::Json]); info!(ua, ms_into_slot, "new request"); match A::get_header(params, req_headers, state).await { Ok(res) => { if let Some(max_bid) = res { + BEACON_NODE_STATUS.with_label_values(&["200", GET_HEADER_ENDPOINT_TAG]).inc(); + // Respond based on requester accept types info!(value_eth = format_ether(*max_bid.data.message.value()), block_hash =% max_bid.block_hash(), "received header"); - BEACON_NODE_STATUS.with_label_values(&["200", GET_HEADER_ENDPOINT_TAG]).inc(); - Ok((StatusCode::OK, axum::Json(max_bid)).into_response()) + // Three arms: no viable encoding (unreachable in + // practice — `get_accept_types` errors earlier if + // the caller offers nothing we support), SSZ, or JSON. + match response_encoding { + None => Err(PbsClientError::DecodeError( + "no viable accept types in request".to_string(), + )), + Some(EncodingType::Ssz) => { + // ForkName::to_string() always yields valid + // ASCII, so HeaderValue::from_str cannot + // fail here. + let consensus_version_header = + HeaderValue::from_str(&max_bid.version.to_string()) + .expect("fork name is always a valid header value"); + let content_type_header = EncodingType::Ssz.content_type_header().clone(); + + let mut res = max_bid.data.as_ssz_bytes().into_response(); + res.headers_mut() + .insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + res.headers_mut().insert(CONTENT_TYPE, content_type_header); + info!("sending response as SSZ"); + Ok(res) + } + Some(EncodingType::Json) => { + info!("sending response as JSON"); + Ok((StatusCode::OK, axum::Json(max_bid)).into_response()) + } + } } else { // spec: return 204 if request is valid but no bid available info!("no header available for slot"); diff --git a/tests/Cargo.toml b/tests/Cargo.toml index c1c51f58..88b2e377 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -11,8 +11,10 @@ cb-common.workspace = true cb-pbs.workspace = true cb-signer.workspace = true eyre.workspace = true +ethereum_ssz.workspace = true jsonwebtoken.workspace = true lh_types.workspace = true +lh_eth2.workspace = true rcgen.workspace = true reqwest.workspace = true serde.workspace = true diff --git a/tests/data/get_header/bellatrix.json b/tests/data/get_header/bellatrix.json new file mode 100644 index 00000000..16dfb330 --- /dev/null +++ b/tests/data/get_header/bellatrix.json @@ -0,0 +1,26 @@ +{ + "version": "bellatrix", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0" + }, + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/data/get_header/capella.json b/tests/data/get_header/capella.json new file mode 100644 index 00000000..6cdbeb98 --- /dev/null +++ b/tests/data/get_header/capella.json @@ -0,0 +1,27 @@ +{ + "version": "capella", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0", + "withdrawals_root": "0x2daccf0e476ca3e2644afbd13b2621d55b4d515b813a3b867cdacea24bb352d1" + }, + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/data/get_header/deneb.json b/tests/data/get_header/deneb.json new file mode 100644 index 00000000..28d3426a --- /dev/null +++ b/tests/data/get_header/deneb.json @@ -0,0 +1,37 @@ +{ + "version": "deneb", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "blob_gas_used": "786432", + "excess_blob_gas": "95158272", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0", + "withdrawals_root": "0x2daccf0e476ca3e2644afbd13b2621d55b4d515b813a3b867cdacea24bb352d1" + }, + "blob_kzg_commitments": [ + "0x9559cce9cd71a3416793c8e28d3aaaae9f53732180f57e046bf725c74ab348a7b16693fd03194cac9dd2199a526461b7", + "0xabc493f754d156c7156eb8365d28eee13e5b3413767356ce4cb30cb0306fbe0ed45eaba92936a94e81ed976aa0d787c2", + "0xa5d87332b5dd391ed3153fe36dbd67775dcbc1818cbf6a68d2089a5c6015de1de02e5138f039f2375e6b3511cc94764b", + "0xa49c576627561ec9ae1ef7494e7cee7ede7fa7695d4462436c3e549cc3ce78674b407e8b5f8903b80f77a68814642d6c", + "0x83155fbeb04758d267193800fb89fa30eb13ac0e217005ae7e271733205ca8a6cd80fba08bf5c9a4a5cc0c9d463ac633", + "0xa20c71d1985996098aa63e8b5dc7b7fedb70de31478fe309dad3ac0e9b6d28d82be8e5e543021a0203dc785742e94b2f" + ], + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/data/get_header/electra.json b/tests/data/get_header/electra.json new file mode 100644 index 00000000..458018d6 --- /dev/null +++ b/tests/data/get_header/electra.json @@ -0,0 +1,62 @@ +{ + "version": "electra", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "blob_gas_used": "786432", + "excess_blob_gas": "95158272", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0", + "withdrawals_root": "0x2daccf0e476ca3e2644afbd13b2621d55b4d515b813a3b867cdacea24bb352d1" + }, + "blob_kzg_commitments": [ + "0x9559cce9cd71a3416793c8e28d3aaaae9f53732180f57e046bf725c74ab348a7b16693fd03194cac9dd2199a526461b7", + "0xabc493f754d156c7156eb8365d28eee13e5b3413767356ce4cb30cb0306fbe0ed45eaba92936a94e81ed976aa0d787c2", + "0xa5d87332b5dd391ed3153fe36dbd67775dcbc1818cbf6a68d2089a5c6015de1de02e5138f039f2375e6b3511cc94764b", + "0xa49c576627561ec9ae1ef7494e7cee7ede7fa7695d4462436c3e549cc3ce78674b407e8b5f8903b80f77a68814642d6c", + "0x83155fbeb04758d267193800fb89fa30eb13ac0e217005ae7e271733205ca8a6cd80fba08bf5c9a4a5cc0c9d463ac633", + "0xa20c71d1985996098aa63e8b5dc7b7fedb70de31478fe309dad3ac0e9b6d28d82be8e5e543021a0203dc785742e94b2f" + ], + "execution_requests": { + "deposits": [ + { + "pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "withdrawal_credentials": "0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f", + "amount": "100", + "signature": "0x8aeb4642fb2982039a43fd6a6d9cc0ebf7598dbf02343c4617d9a68d799393c162492add63f31099a25eacc2782ba27a190e977a8c58760b6636dccb503d528b3be9e885c93d5b79699e68fcca870b0c790cdb00d67604d8b4a3025ae75efa2f", + "index": "1" + } + ], + "withdrawals": [ + { + "source_address": "0x1100000000000000000000000000000000000000", + "validator_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "amount": "1" + } + ], + "consolidations": [ + { + "source_address": "0x1200000000000000000000000000000000000000", + "source_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "target_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5" + } + ] + }, + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/data/get_header/fulu.json b/tests/data/get_header/fulu.json new file mode 100644 index 00000000..b4cef51a --- /dev/null +++ b/tests/data/get_header/fulu.json @@ -0,0 +1,62 @@ +{ + "version": "fulu", + "data": { + "message": { + "header": { + "parent_hash": "0x114d1897fefa402a01a653c21a7f1f1db049d1373a5e73a2d25d7a8045dc02a1", + "fee_recipient": "0x477cc10a5b54aed5c88544c2e71ea0581cf64593", + "state_root": "0x6724be16ef8e65681cb66f9c144da67347b8983aa5e3f4662c9b5dba90ab5bc6", + "receipts_root": "0xf2f6d2fe6960e4dedad18cca0c7881e6509d551d3e04c1879a627fb8aba30272", + "logs_bloom": "0x00000400000000000000848008100000000000000000000004000000010080000000000100000400000000000000000000000000020100000000000000000000080004000000000800008008000000000000000020004000000400000000000000000000000400000000000000000000000000000010000002000010000000000000000000800000200100000000000000004000000000200002000004000000000800000000000000000000000000008000000000000000800000008000000400012002000000000000000000000000000200000000000000000000000000040000000000000000000000000000000000408000000000040000000000000000", + "prev_randao": "0x0fde820be6404bcb71d7bbeee140c16cd28b1940a40fa8a4e2c493114a08b38a", + "block_number": "1598034", + "gas_limit": "30000000", + "gas_used": "1939652", + "timestamp": "1716481836", + "extra_data": "0xd983010d0c846765746889676f312e32312e3130856c696e7578", + "base_fee_per_gas": "1266581747", + "blob_gas_used": "786432", + "excess_blob_gas": "95158272", + "block_hash": "0x0d9eccac62175d903e4242783d7252f4ab6cdd35995810646bda627b4c35adac", + "transactions_root": "0x9dca93e8c6c9a1b5fcc850990ed95cd44af96ff0a6094c87b119a34259eb64b0", + "withdrawals_root": "0x2daccf0e476ca3e2644afbd13b2621d55b4d515b813a3b867cdacea24bb352d1" + }, + "blob_kzg_commitments": [ + "0x9559cce9cd71a3416793c8e28d3aaaae9f53732180f57e046bf725c74ab348a7b16693fd03194cac9dd2199a526461b7", + "0xabc493f754d156c7156eb8365d28eee13e5b3413767356ce4cb30cb0306fbe0ed45eaba92936a94e81ed976aa0d787c2", + "0xa5d87332b5dd391ed3153fe36dbd67775dcbc1818cbf6a68d2089a5c6015de1de02e5138f039f2375e6b3511cc94764b", + "0xa49c576627561ec9ae1ef7494e7cee7ede7fa7695d4462436c3e549cc3ce78674b407e8b5f8903b80f77a68814642d6c", + "0x83155fbeb04758d267193800fb89fa30eb13ac0e217005ae7e271733205ca8a6cd80fba08bf5c9a4a5cc0c9d463ac633", + "0xa20c71d1985996098aa63e8b5dc7b7fedb70de31478fe309dad3ac0e9b6d28d82be8e5e543021a0203dc785742e94b2f" + ], + "execution_requests": { + "deposits": [ + { + "pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "withdrawal_credentials": "0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f", + "amount": "100", + "signature": "0x8aeb4642fb2982039a43fd6a6d9cc0ebf7598dbf02343c4617d9a68d799393c162492add63f31099a25eacc2782ba27a190e977a8c58760b6636dccb503d528b3be9e885c93d5b79699e68fcca870b0c790cdb00d67604d8b4a3025ae75efa2f", + "index": "1" + } + ], + "withdrawals": [ + { + "source_address": "0x1100000000000000000000000000000000000000", + "validator_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "amount": "1" + } + ], + "consolidations": [ + { + "source_address": "0x1200000000000000000000000000000000000000", + "source_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5", + "target_pubkey": "0xac0a230bd98a766b8e4156f0626ee679dd280dee5b0eedc2b9455ca3dacc4c7618da5010b9db609450a712f095c9f7a5" + } + ] + }, + "value": "1234567890", + "pubkey": "0x883827193f7627cd04e621e1e8d56498362a52b2a30c9a1c72036eb935c4278dee23d38a24d2f7dda62689886f0c39f4" + }, + "signature": "0xa9f158bca1d9d6b93a9104f48bd2d1e7689bef3fc974651fc755cc6f50d3649c5153a342a12f95cd8f9cac4f90144985189f498a7e0e1cb202ed5e7c98f3f504f371a53b9293bdd973fbb019c91242f808072d0ffcd9d17e2404baea3190fd18" + } +} \ No newline at end of file diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index 4d7f0fc1..fd8a92e9 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -1,43 +1,56 @@ use std::{ + collections::HashSet, net::SocketAddr, sync::{ Arc, RwLock, atomic::{AtomicU64, Ordering}, }, + time::Duration, }; use alloy::{primitives::U256, rpc::types::beacon::relay::ValidatorRegistration}; use axum::{ Json, Router, extract::{Path, State}, - http::StatusCode, + http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, }; use cb_common::{ pbs::{ BUILDER_V1_API_PATH, BUILDER_V2_API_PATH, BlobsBundle, BuilderBid, BuilderBidElectra, - ExecutionPayloadElectra, ExecutionPayloadHeaderElectra, ExecutionRequests, ForkName, - GET_HEADER_PATH, GET_STATUS_PATH, GetHeaderParams, GetHeaderResponse, GetPayloadInfo, - PayloadAndBlobs, REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SignedBlindedBeaconBlock, - SignedBuilderBid, SubmitBlindedBlockResponse, + BuilderBidFulu, ExecutionPayloadElectra, ExecutionPayloadHeaderElectra, + ExecutionPayloadHeaderFulu, ExecutionRequests, ForkName, GET_HEADER_PATH, GET_STATUS_PATH, + GetHeaderParams, GetHeaderResponse, GetPayloadInfo, PayloadAndBlobs, + REGISTER_VALIDATOR_PATH, SUBMIT_BLOCK_PATH, SignedBuilderBid, SubmitBlindedBlockResponse, }, signature::sign_builder_root, types::{BlsSecretKey, Chain}, - utils::{TestRandomSeed, timestamp_of_slot_start_sec}, + utils::{ + CONSENSUS_VERSION_HEADER, EncodingType, TestRandomSeed, deserialize_body, get_accept_types, + get_consensus_version_header, get_content_type, timestamp_of_slot_start_sec, + }, }; use cb_pbs::MAX_SIZE_SUBMIT_BLOCK_RESPONSE; use lh_types::KzgProof; +use reqwest::header::CONTENT_TYPE; +use ssz::Encode; use tokio::net::TcpListener; -use tracing::debug; +use tracing::{debug, error}; use tree_hash::TreeHash; pub async fn start_mock_relay_service(state: Arc, port: u16) -> eyre::Result<()> { - let app = mock_relay_app_router(state); - let socket = SocketAddr::new("0.0.0.0".parse()?, port); let listener = TcpListener::bind(socket).await?; + start_mock_relay_service_with_listener(state, listener).await +} +/// Like [`start_mock_relay_service`], but accepts a pre-bound [`TcpListener`]. +pub async fn start_mock_relay_service_with_listener( + state: Arc, + listener: TcpListener, +) -> eyre::Result<()> { + let app = mock_relay_app_router(state); axum::serve(listener, app).await?; Ok(()) } @@ -45,14 +58,29 @@ pub async fn start_mock_relay_service(state: Arc, port: u16) -> pub struct MockRelayState { pub chain: Chain, pub signer: BlsSecretKey, + pub supported_content_types: Arc>, large_body: bool, supports_submit_block_v2: bool, use_not_found_for_submit_block: bool, + /// If set, `handle_submit_block_v1`/`v2` short-circuits with this status + /// when the inbound request carries `Content-Type: + /// application/octet-stream`. The counter is still incremented before + /// the short-circuit so tests can observe the attempt. Used to drive C3 + /// (retry-as-JSON) tests. + submit_block_ssz_status_override: Option, + /// If set, this literal string is sent as the outgoing `Content-Type` + /// header on `handle_get_header` and `handle_submit_block_v1` responses + /// instead of the canonical `application/json` / `application/octet-stream` + /// value. The body is still serialized according to the encoding that was + /// negotiated via `Accept`. Used to exercise PBS tolerance of + /// MIME-parameter suffixes like `application/octet-stream; charset=binary`. + response_content_type_override: Option, received_get_header: Arc, received_get_status: Arc, received_register_validator: Arc, received_submit_block: Arc, response_override: RwLock>, + bid_value: RwLock, } impl MockRelayState { @@ -77,6 +105,12 @@ impl MockRelayState { pub fn use_not_found_for_submit_block(&self) -> bool { self.use_not_found_for_submit_block } + pub fn submit_block_ssz_status_override(&self) -> Option { + self.submit_block_ssz_status_override + } + pub fn response_content_type_override(&self) -> Option<&str> { + self.response_content_type_override.as_deref() + } pub fn set_response_override(&self, status: StatusCode) { *self.response_override.write().unwrap() = Some(status); } @@ -90,14 +124,27 @@ impl MockRelayState { large_body: false, supports_submit_block_v2: true, use_not_found_for_submit_block: false, + submit_block_ssz_status_override: None, + response_content_type_override: None, received_get_header: Default::default(), received_get_status: Default::default(), received_register_validator: Default::default(), received_submit_block: Default::default(), response_override: RwLock::new(None), + bid_value: RwLock::new(U256::from(10)), + supported_content_types: Arc::new( + [EncodingType::Json, EncodingType::Ssz].iter().cloned().collect(), + ), } } + /// Override the bid value returned by this relay. Defaults to + /// `U256::from(10)`. + pub fn with_bid_value(self, value: U256) -> Self { + *self.bid_value.write().unwrap() = value; + self + } + pub fn with_large_body(self) -> Self { Self { large_body: true, ..self } } @@ -109,6 +156,23 @@ impl MockRelayState { pub fn with_not_found_for_submit_block(self) -> Self { Self { use_not_found_for_submit_block: true, ..self } } + + /// Make `handle_submit_block_v1`/`v2` respond with `status` whenever the + /// request comes in as SSZ (`Content-Type: application/octet-stream`). + /// JSON requests still go through the normal happy path, which lets a + /// single test cover the SSZ→JSON retry behavior. + pub fn with_submit_block_ssz_status(self, status: StatusCode) -> Self { + Self { submit_block_ssz_status_override: Some(status), ..self } + } + + /// Make the relay advertise `raw_content_type` as the `Content-Type` + /// header on `get_header` and `submit_block_v1` responses. The body is + /// still encoded via the negotiated [`EncodingType`] — only the header + /// string changes. Use this to drive PBS tolerance of MIME-parameter + /// suffixes (e.g. `application/octet-stream; charset=binary`). + pub fn with_response_content_type(self, raw_content_type: impl Into) -> Self { + Self { response_content_type_override: Some(raw_content_type.into()), ..self } + } } pub fn mock_relay_app_router(state: Arc) -> Router { @@ -132,40 +196,126 @@ pub fn mock_relay_app_router(state: Arc) -> Router { async fn handle_get_header( State(state): State>, Path(GetHeaderParams { parent_hash, .. }): Path, + headers: HeaderMap, ) -> Response { state.received_get_header.fetch_add(1, Ordering::Relaxed); + let accept_types = get_accept_types(&headers) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("error parsing accept header: {e}"))); + if let Err(e) = accept_types { + return e.into_response(); + } + let accept_types = accept_types.unwrap(); + let consensus_version_header = + get_consensus_version_header(&headers).unwrap_or(ForkName::Electra); - let mut header = ExecutionPayloadHeaderElectra { - parent_hash: parent_hash.into(), - block_hash: Default::default(), - timestamp: timestamp_of_slot_start_sec(0, state.chain), - ..ExecutionPayloadHeaderElectra::test_random() + let content_type = if state.supported_content_types.contains(&EncodingType::Ssz) && + accept_types.contains(EncodingType::Ssz) + { + EncodingType::Ssz + } else if state.supported_content_types.contains(&EncodingType::Json) && + accept_types.contains(EncodingType::Json) + { + EncodingType::Json + } else { + return (StatusCode::NOT_ACCEPTABLE, "No acceptable content type found".to_string()) + .into_response(); }; - header.block_hash.0[0] = 1; + let bid_value = *state.bid_value.read().unwrap(); - let message = BuilderBid::Electra(BuilderBidElectra { - header, - blob_kzg_commitments: Default::default(), - execution_requests: ExecutionRequests::default(), - value: U256::from(10), - pubkey: state.signer.public_key().into(), - }); + let data = match consensus_version_header { + ForkName::Electra => { + let mut header = ExecutionPayloadHeaderElectra { + parent_hash: parent_hash.into(), + block_hash: Default::default(), + timestamp: timestamp_of_slot_start_sec(0, state.chain), + ..ExecutionPayloadHeaderElectra::test_random() + }; + header.block_hash.0[0] = 1; - let object_root = message.tree_hash_root(); - let signature = sign_builder_root(state.chain, &state.signer, &object_root); - let response = SignedBuilderBid { message, signature }; + let message = BuilderBid::Electra(BuilderBidElectra { + header, + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: bid_value, + pubkey: state.signer.public_key().into(), + }); + let object_root = message.tree_hash_root(); + let signature = sign_builder_root(state.chain, &state.signer, &object_root); + let response = SignedBuilderBid { message, signature }; + if content_type == EncodingType::Ssz { + response.as_ssz_bytes() + } else { + let versioned_response = GetHeaderResponse { + version: ForkName::Electra, + data: response, + metadata: Default::default(), + }; + serde_json::to_vec(&versioned_response).unwrap() + } + } + ForkName::Fulu => { + let mut header = ExecutionPayloadHeaderFulu { + parent_hash: parent_hash.into(), + block_hash: Default::default(), + timestamp: timestamp_of_slot_start_sec(0, state.chain), + ..ExecutionPayloadHeaderFulu::test_random() + }; + header.block_hash.0[0] = 1; - let response = GetHeaderResponse { - version: ForkName::Electra, - data: response, - metadata: Default::default(), + let message = BuilderBid::Fulu(BuilderBidFulu { + header, + blob_kzg_commitments: Default::default(), + execution_requests: ExecutionRequests::default(), + value: bid_value, + pubkey: state.signer.public_key().into(), + }); + let object_root = message.tree_hash_root(); + let signature = sign_builder_root(state.chain, &state.signer, &object_root); + let response = SignedBuilderBid { message, signature }; + if content_type == EncodingType::Ssz { + response.as_ssz_bytes() + } else { + let versioned_response = GetHeaderResponse { + version: ForkName::Fulu, + data: response, + metadata: Default::default(), + }; + serde_json::to_vec(&versioned_response).unwrap() + } + } + _ => { + return ( + StatusCode::BAD_REQUEST, + format!("Unsupported fork {consensus_version_header}"), + ) + .into_response(); + } }; - (StatusCode::OK, Json(response)).into_response() + + let mut response = (StatusCode::OK, data).into_response(); + let consensus_version_header = + HeaderValue::from_str(&consensus_version_header.to_string()).unwrap(); + let content_type_str = state + .response_content_type_override() + .map(|s| s.to_string()) + .unwrap_or_else(|| content_type.to_string()); + let content_type_header = HeaderValue::from_str(&content_type_str).unwrap(); + response.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + response.headers_mut().insert(CONTENT_TYPE, content_type_header); + response } async fn handle_get_status(State(state): State>) -> impl IntoResponse { state.received_get_status.fetch_add(1, Ordering::Relaxed); + // Production `get_status` dispatches relays concurrently via `select_ok`, + // which cancels losing futures as soon as any relay returns OK. On a + // loaded runner this can abort a sibling relay's reqwest send before + // its handler is entered, so the test-side counter only reaches 1. A + // tiny response delay (counter already bumped above) guarantees every + // concurrent request lands in a handler before any response is written, + // eliminating the flake without altering production behavior. + tokio::time::sleep(Duration::from_millis(20)).await; StatusCode::OK } @@ -184,17 +334,61 @@ async fn handle_register_validator( } async fn handle_submit_block_v1( + headers: HeaderMap, State(state): State>, - Json(submit_block): Json, + body_bytes: axum::body::Bytes, ) -> Response { if state.use_not_found_for_submit_block() { return StatusCode::NOT_FOUND.into_response(); } state.received_submit_block.fetch_add(1, Ordering::Relaxed); - if state.large_body() { - (StatusCode::OK, Json(vec![1u8; 1 + MAX_SIZE_SUBMIT_BLOCK_RESPONSE])).into_response() + // Short-circuit SSZ requests with an overridden status so tests can + // drive the PBS SSZ→JSON retry logic. JSON requests still take the + // normal path so a single mock run can exercise both attempts. + if let Some(status) = state.submit_block_ssz_status_override() && + get_content_type(&headers) == EncodingType::Ssz + { + return (status, "forced ssz override").into_response(); + } + let accept_types = get_accept_types(&headers) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("error parsing accept header: {e}"))); + if let Err(e) = accept_types { + return e.into_response(); + } + let accept_types = accept_types.unwrap(); + let consensus_version_header = get_consensus_version_header(&headers); + let response_content_type = if state.supported_content_types.contains(&EncodingType::Ssz) && + accept_types.contains(EncodingType::Ssz) + { + EncodingType::Ssz + } else if state.supported_content_types.contains(&EncodingType::Json) && + accept_types.contains(EncodingType::Json) + { + EncodingType::Json + } else { + return (StatusCode::NOT_ACCEPTABLE, "No acceptable content type found".to_string()) + .into_response(); + }; + + // Error out if the request content type is not supported + let content_type = get_content_type(&headers); + if !state.supported_content_types.contains(&content_type) { + return (StatusCode::UNSUPPORTED_MEDIA_TYPE, "Unsupported content type".to_string()) + .into_response(); + }; + + let data = if state.large_body() { + vec![1u8; 1 + MAX_SIZE_SUBMIT_BLOCK_RESPONSE] } else { let mut execution_payload = ExecutionPayloadElectra::test_random(); + let submit_block = deserialize_body(&headers, body_bytes).await.map_err(|e| { + error!(%e, "failed to deserialize signed blinded block"); + (StatusCode::BAD_REQUEST, format!("failed to deserialize body: {e}")) + }); + if let Err(e) = submit_block { + return e.into_response(); + } + let submit_block = submit_block.unwrap(); execution_payload.block_hash = submit_block.block_hash().into(); let mut blobs_bundle = BlobsBundle::default(); @@ -207,19 +401,60 @@ async fn handle_submit_block_v1( let response = PayloadAndBlobs { execution_payload: execution_payload.into(), blobs_bundle }; - let response = SubmitBlindedBlockResponse { - version: ForkName::Electra, - metadata: Default::default(), - data: response, - }; + if response_content_type == EncodingType::Ssz { + response.as_ssz_bytes() + } else { + // Return JSON for everything else; this is fine for the mock + let response = SubmitBlindedBlockResponse { + version: ForkName::Electra, + metadata: Default::default(), + data: response, + }; + serde_json::to_vec(&response).unwrap() + } + }; - (StatusCode::OK, Json(response)).into_response() + let mut response = (StatusCode::OK, data).into_response(); + if response_content_type == EncodingType::Ssz { + let consensus_version_header = match consensus_version_header { + Some(header) => header, + None => { + return (StatusCode::BAD_REQUEST, "Missing consensus version header".to_string()) + .into_response() + } + }; + let consensus_version_header = + HeaderValue::from_str(&consensus_version_header.to_string()).unwrap(); + response.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); } + let content_type_str = state + .response_content_type_override() + .map(|s| s.to_string()) + .unwrap_or_else(|| response_content_type.to_string()); + let content_type_header = HeaderValue::from_str(&content_type_str).unwrap(); + response.headers_mut().insert(CONTENT_TYPE, content_type_header); + response } -async fn handle_submit_block_v2(State(state): State>) -> Response { + +async fn handle_submit_block_v2( + headers: HeaderMap, + State(state): State>, +) -> Response { if state.use_not_found_for_submit_block() { return StatusCode::NOT_FOUND.into_response(); } state.received_submit_block.fetch_add(1, Ordering::Relaxed); + // See comment in `handle_submit_block_v1`. Override SSZ with the + // injected status so C3 tests can assert retry / no-retry behavior. + if let Some(status) = state.submit_block_ssz_status_override() && + get_content_type(&headers) == EncodingType::Ssz + { + return (status, "forced ssz override").into_response(); + } + let content_type = get_content_type(&headers); + if !state.supported_content_types.contains(&content_type) { + return (StatusCode::NOT_ACCEPTABLE, "No acceptable content type found".to_string()) + .into_response(); + }; (StatusCode::ACCEPTED, "").into_response() } diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index ab593277..07fa8f06 100644 --- a/tests/src/mock_validator.rs +++ b/tests/src/mock_validator.rs @@ -2,9 +2,9 @@ use alloy::{primitives::B256, rpc::types::beacon::relay::ValidatorRegistration}; use cb_common::{ pbs::{BuilderApiVersion, RelayClient, SignedBlindedBeaconBlock}, types::BlsPublicKey, - utils::bls_pubkey_from_hex, + utils::{CONSENSUS_VERSION_HEADER, EncodingType, ForkName, bls_pubkey_from_hex}, }; -use reqwest::Response; +use reqwest::{Response, header::ACCEPT}; use crate::utils::generate_mock_relay; @@ -20,13 +20,36 @@ impl MockValidator { Ok(Self { comm_boost: generate_mock_relay(port, pubkey)? }) } - pub async fn do_get_header(&self, pubkey: Option) -> eyre::Result { + pub async fn do_get_header( + &self, + pubkey: Option, + accept: Vec, + fork_name: ForkName, + ) -> eyre::Result { let default_pubkey = bls_pubkey_from_hex( "0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae", )?; let url = self.comm_boost.get_header_url(0, &B256::ZERO, &pubkey.unwrap_or(default_pubkey))?; - Ok(self.comm_boost.client.get(url).send().await?) + let accept = match accept.len() { + 0 => None, + 1 => Some(accept.into_iter().next().unwrap().to_string()), + _ => { + let accept_strings: Vec = + accept.into_iter().map(|e| e.to_string()).collect(); + Some(accept_strings.join(", ")) + } + }; + let mut res = self + .comm_boost + .client + .get(url) + .header(CONSENSUS_VERSION_HEADER, &fork_name.to_string()); + if let Some(accept_header) = accept { + res = res.header(ACCEPT, accept_header); + } + let res = res.send().await?; + Ok(res) } pub async fn do_get_status(&self) -> eyre::Result { @@ -49,16 +72,16 @@ impl MockValidator { pub async fn do_submit_block_v1( &self, - signed_blinded_block: Option, + signed_blinded_block_opt: Option, ) -> eyre::Result { - self.do_submit_block_impl(signed_blinded_block, BuilderApiVersion::V1).await + self.do_submit_block_impl(signed_blinded_block_opt, BuilderApiVersion::V1).await } pub async fn do_submit_block_v2( &self, - signed_blinded_block: Option, + signed_blinded_block_opt: Option, ) -> eyre::Result { - self.do_submit_block_impl(signed_blinded_block, BuilderApiVersion::V2).await + self.do_submit_block_impl(signed_blinded_block_opt, BuilderApiVersion::V2).await } async fn do_submit_block_impl( diff --git a/tests/src/utils.rs b/tests/src/utils.rs index aff7c335..f1ed0114 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -27,6 +27,18 @@ pub fn get_local_address(port: u16) -> String { format!("http://0.0.0.0:{port}") } +/// Bind to port 0 and let the OS assign an unused ephemeral port. +/// +/// The returned listener keeps the port reserved. Pass it to +/// [`start_mock_relay_service_with_listener`] so the socket is never released +/// between allocation and use (zero TOCTOU race). For servers that bind +/// internally (e.g. `PbsService::run`), read the port with +/// `listener.local_addr().unwrap().port()`, then `drop` the listener +/// immediately before starting the server. +pub async fn get_free_listener() -> tokio::net::TcpListener { + tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap() +} + static SYNC_SETUP: Once = Once::new(); pub fn setup_test_env() { SYNC_SETUP.call_once(|| { @@ -83,6 +95,7 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX, extra_validation_enabled: false, + ssv_node_api_url: Url::parse("http://localhost:0").unwrap(), ssv_public_api_url: Url::parse("http://localhost:0").unwrap(), rpc_url: None, diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index 770421a3..37dd4eb3 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -9,11 +9,14 @@ use cb_common::{ }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, + utils::{ + generate_mock_relay, get_free_listener, get_pbs_config, setup_test_env, to_pbs_config, + }, }; use eyre::Result; +use lh_types::ForkName; use reqwest::StatusCode; use tracing::info; use url::Url; @@ -28,20 +31,23 @@ async fn test_cfg_file_update() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3730; + let pbs_listener = get_free_listener().await; + let relay1_listener = get_free_listener().await; + let relay2_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay1_port = relay1_listener.local_addr().unwrap().port(); + let relay2_port = relay2_listener.local_addr().unwrap().port(); // Start relay 1 - let relay1_port = pbs_port + 1; let relay1 = generate_mock_relay(relay1_port, pubkey.clone())?; let relay1_state = Arc::new(MockRelayState::new(chain, signer.clone())); - tokio::spawn(start_mock_relay_service(relay1_state.clone(), relay1_port)); + tokio::spawn(start_mock_relay_service_with_listener(relay1_state.clone(), relay1_listener)); // Start relay 2 - let relay2_port = relay1_port + 1; let relay2 = generate_mock_relay(relay2_port, pubkey.clone())?; let relay2_id = relay2.id.clone().to_string(); let relay2_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(relay2_state.clone(), relay2_port)); + tokio::spawn(start_mock_relay_service_with_listener(relay2_state.clone(), relay2_listener)); // Make a config with relay 1 only let pbs_config = PbsConfig { @@ -104,6 +110,7 @@ async fn test_cfg_file_update() -> Result<()> { // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![relay1.clone()]); let state = PbsState::new(config, config_path.clone()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers - extra time for the file watcher @@ -112,7 +119,7 @@ async fn test_cfg_file_update() -> Result<()> { // Send a get header request - should go to relay 1 only let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(None).await?; + let res = mock_validator.do_get_header(None, Vec::new(), ForkName::Fulu).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(relay1_state.received_get_header(), 1); assert_eq!(relay2_state.received_get_header(), 0); @@ -154,7 +161,7 @@ async fn test_cfg_file_update() -> Result<()> { // Send another get header request - should go to relay 2 only info!("Sending get header after config update"); - let res = mock_validator.do_get_header(None).await?; + let res = mock_validator.do_get_header(None, Vec::new(), ForkName::Fulu).await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(relay1_state.received_get_header(), 1); // no change assert_eq!(relay2_state.received_get_header(), 1); // incremented diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index 1cfdc3bb..7228cb6e 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -1,60 +1,194 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration}; use alloy::primitives::{B256, U256}; use cb_common::{ - pbs::GetHeaderResponse, + pbs::{GetHeaderResponse, SignedBuilderBid}, signature::sign_builder_root, signer::random_secret, types::{BlsPublicKeyBytes, Chain}, - utils::timestamp_of_slot_start_sec, + utils::{EncodingType, ForkName, get_consensus_version_header, timestamp_of_slot_start_sec}, }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, + utils::{ + generate_mock_relay, get_free_listener, get_pbs_config, setup_test_env, to_pbs_config, + }, }; use eyre::Result; -use lh_types::ForkName; +use lh_eth2::EmptyMetadata; +use lh_types::ForkVersionDecode; use reqwest::StatusCode; use tracing::info; use tree_hash::TreeHash; +use url::Url; +/// Test requesting JSON when the relay supports JSON #[tokio::test] async fn test_get_header() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting SSZ when the relay supports SSZ +#[tokio::test] +async fn test_get_header_ssz() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting SSZ when the relay only supports JSON, which should be +/// handled because PBS supports both types internally and re-maps them on the +/// fly +#[tokio::test] +async fn test_get_header_ssz_into_json() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + 1, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting multiple types when the relay supports SSZ, which should +/// return SSZ +#[tokio::test] +async fn test_get_header_multitype_ssz() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Ssz]), + 1, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting multiple types when the relay supports JSON, which should +/// still work +#[tokio::test] +async fn test_get_header_multitype_json() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Core implementation for get_header tests. +/// Pass `rpc_url: Some(url)` when testing `HeaderValidationMode::Extra` — PBS +/// requires a non-None rpc_url to start in that mode. A non-existent address is +/// fine; if the parent block fetch fails the relay response is still returned +/// (extra validation is skipped with a warning). +#[allow(clippy::too_many_arguments)] +async fn test_get_header_impl( + accept_types: Vec, + relay_types: HashSet, + expected_try_count: u64, + expected_code: StatusCode, + bid_value: U256, + min_bid_wei: U256, + rpc_url: Option, + fork_name: ForkName, +) -> Result<()> { + // Setup test environment setup_test_env(); let signer = random_secret(); let pubkey = signer.public_key(); - let chain = Chain::Holesky; - let pbs_port = 3200; - let relay_port = pbs_port + 1; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); - // Run a mock relay - let mock_state = Arc::new(MockRelayState::new(chain, signer)); + let mut mock_state = MockRelayState::new(chain, signer).with_bid_value(bid_value); + mock_state.supported_content_types = Arc::new(relay_types); + let mock_state = Arc::new(mock_state); let mock_relay = generate_mock_relay(relay_port, pubkey)?; - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.min_bid_wei = min_bid_wei; + pbs_config.rpc_url = rpc_url; + let config = to_pbs_config(chain, pbs_config, vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; + // Send the get_header request let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(None).await?; - assert_eq!(res.status(), StatusCode::OK); + let res = mock_validator.do_get_header(None, accept_types.clone(), fork_name).await?; + assert_eq!(res.status(), expected_code); + assert_eq!(mock_state.received_get_header(), expected_try_count); + match expected_code { + StatusCode::OK => {} + _ => return Ok(()), + } - let res = serde_json::from_slice::(&res.bytes().await?)?; + // Get the content type + let content_type = match res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .unwrap() + { + ct if ct == EncodingType::Ssz.to_string() => EncodingType::Ssz, + ct if ct == EncodingType::Json.to_string() => EncodingType::Json, + _ => panic!("unexpected content type"), + }; + assert!(accept_types.contains(&content_type)); - assert_eq!(mock_state.received_get_header(), 1); - assert_eq!(res.version, ForkName::Electra); + // Get the data + let res = match content_type { + EncodingType::Json => serde_json::from_slice::(&res.bytes().await?)?, + EncodingType::Ssz => { + let fork = + get_consensus_version_header(res.headers()).expect("missing fork version header"); + let data = SignedBuilderBid::from_ssz_bytes_by_fork(&res.bytes().await?, fork).unwrap(); + GetHeaderResponse { version: fork, data, metadata: EmptyMetadata::default() } + } + }; assert_eq!(res.data.message.header().block_hash().0[0], 1); assert_eq!(res.data.message.header().parent_hash().0, B256::ZERO); - assert_eq!(*res.data.message.value(), U256::from(10)); + assert_eq!(*res.data.message.value(), bid_value); assert_eq!(*res.data.message.pubkey(), BlsPublicKeyBytes::from(mock_state.signer.public_key())); assert_eq!(res.data.message.header().timestamp(), timestamp_of_slot_start_sec(0, chain)); assert_eq!( @@ -65,25 +199,30 @@ async fn test_get_header() -> Result<()> { } #[tokio::test] -async fn test_get_header_returns_204_if_relay_down() -> Result<()> { +async fn test_get_header_returns_204_if_no_relay_reachable() -> Result<()> { setup_test_env(); let signer = random_secret(); let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3300; - let relay_port = pbs_port + 1; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); // Create a mock relay client let mock_state = Arc::new(MockRelayState::new(chain, signer)); let mock_relay = generate_mock_relay(relay_port, pubkey)?; // Don't start the relay - // tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); + // tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), + // relay_listener)); + drop(relay_listener); // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -91,7 +230,7 @@ async fn test_get_header_returns_204_if_relay_down() -> Result<()> { let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(None).await?; + let res = mock_validator.do_get_header(None, Vec::new(), ForkName::Electra).await?; assert_eq!(res.status(), StatusCode::NO_CONTENT); // 204 error assert_eq!(mock_state.received_get_header(), 0); // no header received @@ -105,17 +244,20 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3400; - let relay_port = pbs_port + 1; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); // Run a mock relay let mock_state = Arc::new(MockRelayState::new(chain, signer)); let mock_relay = generate_mock_relay(relay_port, pubkey.clone())?; - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay.clone()]); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -141,3 +283,204 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { assert_eq!(mock_state.received_get_header(), 0); // no header received Ok(()) } + +/// All validation modes (None, Standard, Extra) enforce the min-bid threshold. +/// None skips expensive crypto checks; Standard adds sigverify + structural +/// checks; Extra adds the parent-block check via EL RPC (which is skipped with +/// a warning if the fetch fails, so a non-existent RPC URL still passes here). +#[tokio::test] +async fn test_get_header_extra_validation_enforce_min_bid() -> Result<()> { + let relay_bid = U256::from(7u64); + let min_bid_above_relay = relay_bid + U256::from(1); + // A syntactically valid URL that will never connect — Extra mode config + // validation only requires rpc_url to be Some; the actual fetch failing is + // handled gracefully (extra validation is skipped with a warning). + let fake_rpc: Url = "http://127.0.0.1:1".parse()?; + + // Bid below min → all modes reject (204). + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + StatusCode::NO_CONTENT, + relay_bid, + min_bid_above_relay, + Some(fake_rpc.clone()), + ForkName::Electra, + ) + .await?; + + // Bid above min → all modes accept (200). + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + StatusCode::OK, + min_bid_above_relay, + U256::ZERO, + Some(fake_rpc), + ForkName::Electra, + ) + .await?; + + Ok(()) +} + +/// Verify the mock relay returns 400 when the validator requests an unsupported +/// fork. Tested by pointing MockValidator directly at the relay (no PBS) so the +/// assertion is on the relay's raw response, not PBS's 204 fallback. +#[tokio::test] +async fn test_get_header_unsupported_fork_returns_400() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let chain = Chain::Holesky; + + let relay_listener = get_free_listener().await; + let relay_port = relay_listener.local_addr().unwrap().port(); + let mock_state = Arc::new(MockRelayState::new(chain, signer.clone())); + tokio::spawn(start_mock_relay_service_with_listener(mock_state, relay_listener)); + + tokio::time::sleep(Duration::from_millis(100)).await; + + // Point MockValidator directly at the relay (no PBS in the path). + let direct = MockValidator::new(relay_port)?; + for unsupported_fork in [ForkName::Base, ForkName::Altair] { + let res = direct.do_get_header(None, vec![EncodingType::Json], unsupported_fork).await?; + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "expected 400 for unsupported fork {unsupported_fork}" + ); + } + Ok(()) +} + +/// Exhaustive bid-acceptance matrix across every (fork, encoding, mode, bid) +/// combination. +#[tokio::test] +async fn test_get_header_bid_validation_matrix() -> Result<()> { + let bid_low = U256::from(5u64); + let bid_high = U256::from(100u64); + let min_bid = U256::from(50u64); + + // (fork, encoding, mode, relay_bid, expected_status) + let cases: &[(ForkName, EncodingType, U256, StatusCode)] = &[ + (ForkName::Electra, EncodingType::Json, bid_low, StatusCode::NO_CONTENT), + (ForkName::Electra, EncodingType::Json, bid_high, StatusCode::OK), + (ForkName::Electra, EncodingType::Ssz, bid_low, StatusCode::NO_CONTENT), + (ForkName::Electra, EncodingType::Ssz, bid_high, StatusCode::OK), + (ForkName::Fulu, EncodingType::Json, bid_low, StatusCode::NO_CONTENT), + (ForkName::Fulu, EncodingType::Json, bid_high, StatusCode::OK), + (ForkName::Fulu, EncodingType::Ssz, bid_low, StatusCode::NO_CONTENT), + (ForkName::Fulu, EncodingType::Ssz, bid_high, StatusCode::OK), + (ForkName::Electra, EncodingType::Json, bid_low, StatusCode::NO_CONTENT), + (ForkName::Electra, EncodingType::Json, bid_high, StatusCode::OK), + (ForkName::Electra, EncodingType::Ssz, bid_low, StatusCode::NO_CONTENT), + (ForkName::Electra, EncodingType::Ssz, bid_high, StatusCode::OK), + (ForkName::Fulu, EncodingType::Json, bid_low, StatusCode::NO_CONTENT), + (ForkName::Fulu, EncodingType::Json, bid_high, StatusCode::OK), + (ForkName::Fulu, EncodingType::Ssz, bid_low, StatusCode::NO_CONTENT), + (ForkName::Fulu, EncodingType::Ssz, bid_high, StatusCode::OK), + ]; + + for (i, &(fork, encoding, relay_bid, expected_status)) in cases.iter().enumerate() { + test_get_header_impl( + vec![encoding], + HashSet::from([encoding]), + 1, + expected_status, + relay_bid, + min_bid, + None, + fork, + ) + .await + .map_err(|e| { + eyre::eyre!("case {i} (fork={fork} enc={encoding} bid={relay_bid} min={min_bid}): {e}") + })?; + } + Ok(()) +} + +/// PBS must accept relay `Content-Type` values that include MIME parameters +/// (e.g. `application/octet-stream; charset=binary`). The audit fix for C2 +/// switched `EncodingType::from_str` to parse via the `mediatype` crate; +/// this test exercises the full relay→PBS→BN path to guard against +/// regressions at the wire boundary. +#[tokio::test] +async fn test_get_header_tolerates_mime_params_in_content_type() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mut mock_state = MockRelayState::new(chain, signer) + .with_response_content_type("application/octet-stream; charset=binary"); + mock_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Ssz])); + let mock_state = Arc::new(mock_state); + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let pbs_config = get_pbs_config(pbs_port); + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); + tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let mock_validator = MockValidator::new(pbs_port)?; + let res = + mock_validator.do_get_header(None, vec![EncodingType::Ssz], ForkName::Electra).await?; + assert_eq!(res.status(), StatusCode::OK, "PBS should tolerate `; charset=binary` MIME param"); + assert_eq!(mock_state.received_get_header(), 1); + + let fork = get_consensus_version_header(res.headers()).expect("missing fork version header"); + let bytes = res.bytes().await?; + let data = SignedBuilderBid::from_ssz_bytes_by_fork(&bytes, fork).unwrap(); + assert_eq!(data.message.header().block_hash().0[0], 1); + Ok(()) +} + +/// Same guarantee on the JSON path: `application/json; charset=utf-8` (the +/// value some production relays actually emit) must be accepted as JSON. +#[tokio::test] +async fn test_get_header_tolerates_json_charset_param() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mut mock_state = MockRelayState::new(chain, signer) + .with_response_content_type("application/json; charset=utf-8"); + mock_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Json])); + let mock_state = Arc::new(mock_state); + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let pbs_config = get_pbs_config(pbs_port); + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); + tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let mock_validator = MockValidator::new(pbs_port)?; + let res = + mock_validator.do_get_header(None, vec![EncodingType::Json], ForkName::Electra).await?; + assert_eq!(res.status(), StatusCode::OK, "PBS should tolerate `; charset=utf-8` MIME param"); + assert_eq!(mock_state.received_get_header(), 1); + + let body: GetHeaderResponse = serde_json::from_slice(&res.bytes().await?)?; + assert_eq!(body.data.message.header().block_hash().0[0], 1); + Ok(()) +} diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index 4f842d56..6d093bef 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -12,17 +12,17 @@ use cb_common::{ }, signer::random_secret, types::Chain, - utils::{ResponseReadError, set_ignore_content_length}, + utils::{ForkName, ResponseReadError, set_ignore_content_length}, }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_ssv_node::{SsvNodeMockState, create_mock_ssv_node_server}, mock_ssv_public::{PublicSsvMockState, TEST_HTTP_TIMEOUT, create_mock_public_ssv_server}, mock_validator::MockValidator, utils::{ - bls_pubkey_from_hex_unchecked, generate_mock_relay, get_pbs_config, setup_test_env, - to_pbs_config, + bls_pubkey_from_hex_unchecked, generate_mock_relay, get_free_listener, get_pbs_config, + setup_test_env, to_pbs_config, }, }; use eyre::Result; @@ -36,7 +36,9 @@ use url::Url; /// from the public API async fn test_ssv_public_network_fetch() -> Result<()> { // Start the mock server - let port = 30100; + let listener = get_free_listener().await; + let port = listener.local_addr().unwrap().port(); + drop(listener); let server_handle = create_mock_public_ssv_server(port, None).await?; let url = Url::parse(&format!("http://localhost:{port}/api/v4/test_chain/validators/in_operator/1")) @@ -74,7 +76,9 @@ async fn test_ssv_public_network_fetch() -> Result<()> { /// body is too large async fn test_ssv_network_fetch_big_data() -> Result<()> { // Start the mock server - let port = 30101; + let listener = get_free_listener().await; + let port = listener.local_addr().unwrap().port(); + drop(listener); let server_handle = cb_tests::mock_ssv_public::create_mock_public_ssv_server(port, None).await?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); @@ -106,7 +110,9 @@ async fn test_ssv_network_fetch_big_data() -> Result<()> { /// times out async fn test_ssv_network_fetch_timeout() -> Result<()> { // Start the mock server - let port = 30102; + let listener = get_free_listener().await; + let port = listener.local_addr().unwrap().port(); + drop(listener); let state = PublicSsvMockState { validators: Arc::new(RwLock::new(vec![])), force_timeout: Arc::new(RwLock::new(true)), @@ -135,7 +141,9 @@ async fn test_ssv_network_fetch_timeout() -> Result<()> { /// content-length header is missing async fn test_ssv_network_fetch_big_data_without_content_length() -> Result<()> { // Start the mock server - let port = 30103; + let listener = get_free_listener().await; + let port = listener.local_addr().unwrap().port(); + drop(listener); set_ignore_content_length(true); let server_handle = create_mock_public_ssv_server(port, None).await?; let url = Url::parse(&format!("http://localhost:{port}/big_data")).unwrap(); @@ -167,7 +175,9 @@ async fn test_ssv_network_fetch_big_data_without_content_length() -> Result<()> /// from the node API async fn test_ssv_node_network_fetch() -> Result<()> { // Start the mock server - let port = 30104; + let listener = get_free_listener().await; + let port = listener.local_addr().unwrap().port(); + drop(listener); let _server_handle = create_mock_ssv_node_server(port, None).await?; let url = Url::parse(&format!("http://localhost:{port}/v1/validators")).unwrap(); let response = request_ssv_pubkeys_from_ssv_node( @@ -200,17 +210,24 @@ async fn test_mux() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3700; - - let mux_relay_1 = generate_mock_relay(pbs_port + 1, pubkey.clone())?; - let mux_relay_2 = generate_mock_relay(pbs_port + 2, pubkey.clone())?; - let default_relay = generate_mock_relay(pbs_port + 3, pubkey.clone())?; + let pbs_listener = get_free_listener().await; + let relay_1_listener = get_free_listener().await; + let relay_2_listener = get_free_listener().await; + let relay_3_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_1_port = relay_1_listener.local_addr().unwrap().port(); + let relay_2_port = relay_2_listener.local_addr().unwrap().port(); + let relay_3_port = relay_3_listener.local_addr().unwrap().port(); + + let mux_relay_1 = generate_mock_relay(relay_1_port, pubkey.clone())?; + let mux_relay_2 = generate_mock_relay(relay_2_port, pubkey.clone())?; + let default_relay = generate_mock_relay(relay_3_port, pubkey.clone())?; // Run 3 mock relays let mock_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 2)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 3)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_1_listener)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_2_listener)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_3_listener)); // Register all relays in PBS config let relays = vec![default_relay.clone()]; @@ -230,6 +247,7 @@ async fn test_mux() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -238,13 +256,19 @@ async fn test_mux() -> Result<()> { // Send default request without specifying a validator key let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header with default"); - assert_eq!(mock_validator.do_get_header(None).await?.status(), StatusCode::OK); + assert_eq!( + mock_validator.do_get_header(None, Vec::new(), ForkName::Electra).await?.status(), + StatusCode::OK + ); assert_eq!(mock_state.received_get_header(), 1); // only default relay was used // Send request specifying a validator key to use mux info!("Sending get header with mux"); assert_eq!( - mock_validator.do_get_header(Some(validator_pubkey)).await?.status(), + mock_validator + .do_get_header(Some(validator_pubkey), Vec::new(), ForkName::Electra) + .await? + .status(), StatusCode::OK ); assert_eq!(mock_state.received_get_header(), 3); // two mux relays were used @@ -261,12 +285,12 @@ async fn test_mux() -> Result<()> { // v1 Submit block requests should go to all relays info!("Sending submit block v1"); - assert_eq!(mock_validator.do_submit_block_v1(None).await?.status(), StatusCode::OK); + assert_eq!(mock_validator.do_submit_block_v1(None,).await?.status(), StatusCode::OK); assert_eq!(mock_state.received_submit_block(), 3); // default + 2 mux relays were used // v2 Submit block requests should go to all relays info!("Sending submit block v2"); - assert_eq!(mock_validator.do_submit_block_v2(None).await?.status(), StatusCode::ACCEPTED); + assert_eq!(mock_validator.do_submit_block_v2(None,).await?.status(), StatusCode::ACCEPTED); assert_eq!(mock_state.received_submit_block(), 6); // default + 2 mux relays were used Ok(()) @@ -282,10 +306,20 @@ async fn test_ssv_multi_with_node() -> Result<()> { let pubkey2 = signer2.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3711; + let pbs_listener = get_free_listener().await; + let ssv_node_listener = get_free_listener().await; + let ssv_public_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let ssv_node_port = ssv_node_listener.local_addr().unwrap().port(); + let ssv_public_port = ssv_public_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + // Drop SSV node + public listeners because their mock server helpers bind the + // port themselves. + drop(ssv_node_listener); + drop(ssv_public_listener); // Start the mock SSV node - let ssv_node_port = pbs_port + 1; let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; let mock_ssv_node_state = SsvNodeMockState { validators: Arc::new(RwLock::new(vec![ @@ -298,7 +332,6 @@ async fn test_ssv_multi_with_node() -> Result<()> { create_mock_ssv_node_server(ssv_node_port, Some(mock_ssv_node_state.clone())).await?; // Start the mock SSV public API - let ssv_public_port = ssv_node_port + 1; let ssv_public_url = Url::parse(&format!("http://localhost:{ssv_public_port}/api/v4/"))?; let mock_ssv_public_state = PublicSsvMockState { validators: Arc::new(RwLock::new(vec![SSVPublicValidator { pubkey: pubkey.clone() }])), @@ -308,11 +341,11 @@ async fn test_ssv_multi_with_node() -> Result<()> { create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; // Start a mock relay to be used by the mux - let relay_port = ssv_public_port + 1; let relay = generate_mock_relay(relay_port, pubkey.clone())?; let relay_id = relay.id.clone().to_string(); let relay_state = Arc::new(MockRelayState::new(chain, signer)); - let relay_task = tokio::spawn(start_mock_relay_service(relay_state.clone(), relay_port)); + let relay_task = + tokio::spawn(start_mock_relay_service_with_listener(relay_state.clone(), relay_listener)); // Create the registry mux let loader = MuxKeysLoader::Registry { @@ -346,6 +379,7 @@ async fn test_ssv_multi_with_node() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); info!("Started PBS server with pubkey {pubkey}"); @@ -356,9 +390,10 @@ async fn test_ssv_multi_with_node() -> Result<()> { // relay only since it hasn't been seen in the mux yet let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(Some(pubkey2.clone())).await?; + let res = + mock_validator.do_get_header(Some(pubkey2.clone()), Vec::new(), ForkName::Electra).await?; assert_eq!(res.status(), StatusCode::OK); - assert_eq!(relay_state.received_get_header(), 1); // pubkey2 was loaded from the SSV node + assert_eq!(relay_state.received_get_header(), 1); // pubkey2 was loaded from the SSV node // Shut down the server handles pbs_server.abort(); @@ -380,10 +415,20 @@ async fn test_ssv_multi_with_public() -> Result<()> { let pubkey2 = signer2.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3720; + let pbs_listener = get_free_listener().await; + let ssv_node_listener = get_free_listener().await; + let ssv_public_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let ssv_node_port = ssv_node_listener.local_addr().unwrap().port(); + let ssv_public_port = ssv_public_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + // SSV node is intentionally down — release its reserved port. + drop(ssv_node_listener); + // SSV public mock helper binds the port itself. + drop(ssv_public_listener); // Start the mock SSV node - let ssv_node_port = pbs_port + 1; let ssv_node_url = Url::parse(&format!("http://localhost:{ssv_node_port}/v1/"))?; // Don't start the SSV node server to simulate it being down @@ -391,7 +436,6 @@ async fn test_ssv_multi_with_public() -> Result<()> { // Some(mock_ssv_node_state.clone())).await?; // Start the mock SSV public API - let ssv_public_port = ssv_node_port + 1; let ssv_public_url = Url::parse(&format!("http://localhost:{ssv_public_port}/api/v4/"))?; let mock_ssv_public_state = PublicSsvMockState { validators: Arc::new(RwLock::new(vec![ @@ -404,11 +448,11 @@ async fn test_ssv_multi_with_public() -> Result<()> { create_mock_public_ssv_server(ssv_public_port, Some(mock_ssv_public_state.clone())).await?; // Start a mock relay to be used by the mux - let relay_port = ssv_public_port + 1; let relay = generate_mock_relay(relay_port, pubkey.clone())?; let relay_id = relay.id.clone().to_string(); let relay_state = Arc::new(MockRelayState::new(chain, signer)); - let relay_task = tokio::spawn(start_mock_relay_service(relay_state.clone(), relay_port)); + let relay_task = + tokio::spawn(start_mock_relay_service_with_listener(relay_state.clone(), relay_listener)); // Create the registry mux let loader = MuxKeysLoader::Registry { @@ -442,6 +486,7 @@ async fn test_ssv_multi_with_public() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); info!("Started PBS server with pubkey {pubkey}"); @@ -452,9 +497,10 @@ async fn test_ssv_multi_with_public() -> Result<()> { // relay only since it hasn't been seen in the mux yet let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(Some(pubkey2.clone())).await?; + let res = + mock_validator.do_get_header(Some(pubkey2.clone()), Vec::new(), ForkName::Electra).await?; assert_eq!(res.status(), StatusCode::OK); - assert_eq!(relay_state.received_get_header(), 1); // pubkey2 was loaded from the SSV public API + assert_eq!(relay_state.received_get_header(), 1); // pubkey2 was loaded from the SSV public API // Shut down the server handles pbs_server.abort(); diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 1d590a49..5935af98 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -8,12 +8,13 @@ use cb_common::{ }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_ssv_public::{PublicSsvMockState, create_mock_public_ssv_server}, mock_validator::MockValidator, - utils::{generate_mock_relay, get_pbs_config, to_pbs_config}, + utils::{generate_mock_relay, get_free_listener, get_pbs_config, to_pbs_config}, }; use eyre::Result; +use lh_types::ForkName; use reqwest::StatusCode; use tokio::sync::RwLock; use tracing::info; @@ -38,10 +39,18 @@ async fn test_auto_refresh() -> Result<()> { let new_mux_pubkey = new_mux_signer.public_key(); let chain = Chain::Hoodi; - let pbs_port = 3710; + let pbs_listener = get_free_listener().await; + let ssv_api_listener = get_free_listener().await; + let default_relay_listener = get_free_listener().await; + let mux_relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let ssv_api_port = ssv_api_listener.local_addr().unwrap().port(); + let default_relay_port = default_relay_listener.local_addr().unwrap().port(); + let mux_relay_port = mux_relay_listener.local_addr().unwrap().port(); + // create_mock_public_ssv_server binds the port itself. + drop(ssv_api_listener); // Start the mock SSV API server - let ssv_api_port = pbs_port + 1; // Intentionally missing a trailing slash to ensure this is handled properly let ssv_api_url = Url::parse(&format!("http://localhost:{ssv_api_port}/api/v4"))?; let mock_ssv_state = PublicSsvMockState { @@ -54,19 +63,21 @@ async fn test_auto_refresh() -> Result<()> { create_mock_public_ssv_server(ssv_api_port, Some(mock_ssv_state.clone())).await?; // Start a default relay for non-mux keys - let default_relay_port = ssv_api_port + 1; let default_relay = generate_mock_relay(default_relay_port, default_pubkey.clone())?; let default_relay_state = Arc::new(MockRelayState::new(chain, default_signer.clone())); - let default_relay_task = - tokio::spawn(start_mock_relay_service(default_relay_state.clone(), default_relay_port)); + let default_relay_task = tokio::spawn(start_mock_relay_service_with_listener( + default_relay_state.clone(), + default_relay_listener, + )); // Start a mock relay to be used by the mux - let mux_relay_port = default_relay_port + 1; let mux_relay = generate_mock_relay(mux_relay_port, default_pubkey.clone())?; let mux_relay_id = mux_relay.id.clone().to_string(); let mux_relay_state = Arc::new(MockRelayState::new(chain, default_signer)); - let mux_relay_task = - tokio::spawn(start_mock_relay_service(mux_relay_state.clone(), mux_relay_port)); + let mux_relay_task = tokio::spawn(start_mock_relay_service_with_listener( + mux_relay_state.clone(), + mux_relay_listener, + )); // Create the registry mux let loader = MuxKeysLoader::Registry { @@ -99,6 +110,7 @@ async fn test_auto_refresh() -> Result<()> { // Run PBS service let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); let pbs_server = tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); info!("Started PBS server with pubkey {default_pubkey}"); @@ -109,7 +121,9 @@ async fn test_auto_refresh() -> Result<()> { // relay only since it hasn't been seen in the mux yet let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(Some(new_mux_pubkey.clone())).await?; + let res = mock_validator + .do_get_header(Some(new_mux_pubkey.clone()), Vec::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 1); // default relay was used assert_eq!(mux_relay_state.received_get_header(), 0); // mux relay was not used @@ -137,14 +151,18 @@ async fn test_auto_refresh() -> Result<()> { assert!(logs_contain(&format!("fetched 2 pubkeys for registry mux {mux_relay_id}"))); // Try to run a get_header on the new pubkey - now it should use the mux relay - let res = mock_validator.do_get_header(Some(new_mux_pubkey.clone())).await?; + let res = mock_validator + .do_get_header(Some(new_mux_pubkey.clone()), Vec::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 1); // default relay was not used here assert_eq!(mux_relay_state.received_get_header(), 1); // mux relay was used // Now try to do a get_header with the old pubkey - it should only use the // default relay - let res = mock_validator.do_get_header(Some(default_pubkey.clone())).await?; + let res = mock_validator + .do_get_header(Some(default_pubkey.clone()), Vec::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 2); // default relay was used assert_eq!(mux_relay_state.received_get_header(), 1); // mux relay was not used @@ -161,7 +179,9 @@ async fn test_auto_refresh() -> Result<()> { // Try to do a get_header with the removed pubkey - it should only use the // default relay - let res = mock_validator.do_get_header(Some(existing_mux_pubkey.clone())).await?; + let res = mock_validator + .do_get_header(Some(existing_mux_pubkey.clone()), Vec::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 3); // default relay was used assert_eq!(mux_relay_state.received_get_header(), 1); // mux relay was not used From d4683656e5ceea38895cca3889618f2caa95d3fd Mon Sep 17 00:00:00 2001 From: Jason Vranek Date: Tue, 19 May 2026 16:05:35 -0700 Subject: [PATCH 3/3] Rewrite submit_block relay communication to support SSZ and JSON content negotiation. Includes: - SSZ-first request encoding with JSON fallback on 406/415 - Content-Type and Eth-Consensus-Version header handling - Fork-aware SSZ decoding for relay responses - MIME parameter tolerance on relay response Content-Type - v2 to v1 fallback forwards payload to BN (prevents silent block loss) - V2 fallback metric counter - Comprehensive submit_block integration tests for both encodings --- crates/pbs/src/metrics.rs | 10 + crates/pbs/src/mev_boost/submit_block.rs | 463 ++++++++++++++++------ crates/pbs/src/routes/submit_block.rs | 81 ++-- tests/src/mock_validator.rs | 65 ++- tests/tests/pbs_mux.rs | 28 +- tests/tests/pbs_post_blinded_blocks.rs | 477 +++++++++++++++++++++-- 6 files changed, 939 insertions(+), 185 deletions(-) diff --git a/crates/pbs/src/metrics.rs b/crates/pbs/src/metrics.rs index 1f91e47f..2bf9b912 100644 --- a/crates/pbs/src/metrics.rs +++ b/crates/pbs/src/metrics.rs @@ -60,4 +60,14 @@ lazy_static! { &["http_status_code", "endpoint"], PBS_METRICS_REGISTRY ).unwrap(); + + /// Count of v2 submit_block requests that fell back to the v1 endpoint + /// because the relay returned 404 on v2. A high value indicates the relay + /// fleet has not been upgraded to support submitBlindedBlockV2. + pub static ref V2_FALLBACK_TO_V1: IntCounterVec = register_int_counter_vec_with_registry!( + "pbs_submit_block_v2_fallback_to_v1_total", + "Count of v2 submit_block requests that fell back to v1 because the relay did not support v2", + &["relay_id"], + PBS_METRICS_REGISTRY + ).unwrap(); } diff --git a/crates/pbs/src/mev_boost/submit_block.rs b/crates/pbs/src/mev_boost/submit_block.rs index b416dba2..dfabcf60 100644 --- a/crates/pbs/src/mev_boost/submit_block.rs +++ b/crates/pbs/src/mev_boost/submit_block.rs @@ -1,5 +1,4 @@ use std::{ - str::FromStr, sync::Arc, time::{Duration, Instant}, }; @@ -8,26 +7,63 @@ use alloy::{eips::eip7594::CELLS_PER_EXT_BLOB, primitives::B256}; use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ pbs::{ - BlindedBeaconBlock, BlobsBundle, BuilderApiVersion, ForkName, HEADER_CONSENSUS_VERSION, - HEADER_START_TIME_UNIX_MS, KzgCommitments, RelayClient, SignedBlindedBeaconBlock, - SubmitBlindedBlockResponse, + BlindedBeaconBlock, BlobsBundle, BuilderApiVersion, ForkName, ForkVersionDecode, + HEADER_START_TIME_UNIX_MS, KzgCommitments, PayloadAndBlobs, RelayClient, + SignedBlindedBeaconBlock, SubmitBlindedBlockResponse, error::{PbsError, ValidationError}, }, - utils::{get_user_agent_with_version, read_chunked_body_with_max, utcnow_ms}, + utils::{ + CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT, get_user_agent_with_version, + parse_response_encoding_and_fork, read_chunked_body_with_max, utcnow_ms, + }, }; use futures::{FutureExt, future::select_ok}; -use reqwest::header::USER_AGENT; +use reqwest::{ + StatusCode, + header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, +}; +use ssz::Encode; use tracing::{debug, warn}; use url::Url; use crate::{ - constants::{ - MAX_SIZE_SUBMIT_BLOCK_RESPONSE, SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, TIMEOUT_ERROR_CODE_STR, - }, - metrics::{RELAY_LATENCY, RELAY_STATUS_CODE}, + TIMEOUT_ERROR_CODE_STR, + constants::{MAX_SIZE_SUBMIT_BLOCK_RESPONSE, SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG}, + metrics::{RELAY_LATENCY, RELAY_STATUS_CODE, V2_FALLBACK_TO_V1}, state::{BuilderApiState, PbsState}, }; +#[derive(Clone)] +struct ProposalInfo { + /// The signed blinded block to submit + signed_blinded_block: Arc, + + /// Common baseline of headers to send with each request + headers: HeaderMap, + + /// The version of the submit_block route being used + api_version: BuilderApiVersion, +} + +struct SubmitBlockResponseInfo { + /// The raw body of the response + response_bytes: Vec, + + /// The content type the response is encoded with. `None` on v2 + /// ACCEPTED/OK paths where no body is returned. + content_type: Option, + + /// Which fork the response bid is for (if provided as a header, rather than + /// part of the body) + fork: Option, + + /// The status code of the response, for logging + code: StatusCode, + + /// The round-trip latency of the request + request_latency: Duration, +} + /// Implements https://ethereum.github.io/builder-specs/#/Builder/submitBlindedBlock and /// https://ethereum.github.io/builder-specs/#/Builder/submitBlindedBlockV2. Use `api_version` to /// distinguish between the two. @@ -39,36 +75,27 @@ pub async fn submit_block( ) -> eyre::Result> { debug!(?req_headers, "received headers"); - let fork_name = req_headers - .get(HEADER_CONSENSUS_VERSION) - .and_then(|h| { - let str = h.to_str().ok()?; - ForkName::from_str(str).ok() - }) - .unwrap_or_else(|| { - let slot = signed_blinded_block.slot().as_u64(); - state.config.chain.fork_by_slot(slot) - }); - - // safe because ForkName is visible ASCII chars - let consensus_version = HeaderValue::from_str(&fork_name.to_string()).unwrap(); - // prepare headers let mut send_headers = HeaderMap::new(); send_headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(utcnow_ms())); send_headers.insert(USER_AGENT, get_user_agent_with_version(&req_headers)?); - send_headers.insert(HEADER_CONSENSUS_VERSION, consensus_version); + // Create the Accept headers for requests + // Use the documented, deterministic preference: + // SSZ first (wire-efficient), JSON fallback. + let accept_types = OUTBOUND_ACCEPT.to_string(); + send_headers.insert(ACCEPT, HeaderValue::from_str(&accept_types).unwrap()); + + // Send requests to all relays concurrently + let proposal_info = + Arc::new(ProposalInfo { signed_blinded_block, headers: send_headers, api_version }); let mut handles = Vec::with_capacity(state.all_relays().len()); - for relay in state.all_relays().iter().cloned() { + for relay in state.all_relays().iter() { handles.push( tokio::spawn(submit_block_with_timeout( - signed_blinded_block.clone(), - relay, - send_headers.clone(), + proposal_info.clone(), + relay.clone(), state.pbs_config().timeout_get_payload_ms, - api_version, - fork_name, )) .map(|join_result| match join_result { Ok(res) => res, @@ -87,40 +114,42 @@ pub async fn submit_block( /// Submit blinded block to relay, retry connection errors until the /// given timeout has passed async fn submit_block_with_timeout( - signed_blinded_block: Arc, + proposal_info: Arc, relay: RelayClient, - headers: HeaderMap, timeout_ms: u64, - api_version: BuilderApiVersion, - fork_name: ForkName, ) -> Result, PbsError> { - let mut url = relay.submit_block_url(api_version)?; + let mut url = Arc::new(relay.submit_block_url(proposal_info.api_version)?); let mut remaining_timeout_ms = timeout_ms; let mut retry = 0; let mut backoff = Duration::from_millis(250); - let mut request_api_version = api_version; + let mut request_api_version = proposal_info.api_version; loop { let start_request = Instant::now(); match send_submit_block( + proposal_info.clone(), url.clone(), - &signed_blinded_block, &relay, - headers.clone(), remaining_timeout_ms, retry, - &request_api_version, - fork_name, + request_api_version, ) .await { Ok(response) => { - // If the original request was for v2 but we had to fall back to v1, return a v2 - // response + // If the original request was for v2 but we had to fall back to v1, the + // V1 response body (execution payload + blobs bundle) MUST be forwarded + // back to the beacon node so the proposer can broadcast. Returning an + // empty 202 here would cause silent block loss because the BN never + // receives the unblinded payload. if request_api_version == BuilderApiVersion::V1 && - api_version != request_api_version + proposal_info.api_version != request_api_version { - return Ok(None); + warn!( + relay_id = relay.id.as_ref(), + "v2 submit_block fell back to v1; forwarding v1 payload to beacon node" + ); + V2_FALLBACK_TO_V1.with_label_values(&[relay.id.as_ref()]).inc(); } return Ok(response); } @@ -144,7 +173,7 @@ async fn submit_block_with_timeout( relay_id = relay.id.as_ref(), "relay does not support v2 endpoint, retrying with v1" ); - url = relay.submit_block_url(BuilderApiVersion::V1)?; + url = Arc::new(relay.submit_block_url(BuilderApiVersion::V1)?); request_api_version = BuilderApiVersion::V1; } @@ -159,22 +188,155 @@ async fn submit_block_with_timeout( // back #[allow(clippy::too_many_arguments)] async fn send_submit_block( - url: Url, - signed_blinded_block: &SignedBlindedBeaconBlock, + proposal_info: Arc, + url: Arc, relay: &RelayClient, - headers: HeaderMap, timeout_ms: u64, retry: u32, - api_version: &BuilderApiVersion, - fork_name: ForkName, + api_version: BuilderApiVersion, ) -> Result, PbsError> { + // Full processing: decode full response and validate + let response = + send_submit_block_full(proposal_info.clone(), url, relay, timeout_ms, retry, api_version) + .await?; + let response = match response { + None => { + // v2 request with no body + return Ok(None); + } + Some(res) => res, + }; + // Extract the info needed for validation + let got_block_hash = response.data.execution_payload.block_hash().0; + + // request has different type so cant be deserialized in the wrong version, + // response has a "version" field + match &proposal_info.signed_blinded_block.message() { + BlindedBeaconBlock::Electra(blinded_block) => { + let expected_block_hash = + blinded_block.body.execution_payload.execution_payload_header.block_hash.0; + let expected_commitments = &blinded_block.body.blob_kzg_commitments; + + validate_unblinded_block( + expected_block_hash, + got_block_hash, + expected_commitments, + &response.data.blobs_bundle, + response.version, + ) + } + + BlindedBeaconBlock::Fulu(blinded_block) => { + let expected_block_hash = + blinded_block.body.execution_payload.execution_payload_header.block_hash.0; + let expected_commitments = &blinded_block.body.blob_kzg_commitments; + + validate_unblinded_block( + expected_block_hash, + got_block_hash, + expected_commitments, + &response.data.blobs_bundle, + response.version, + ) + } + + _ => return Err(PbsError::Validation(ValidationError::UnsupportedFork)), + }?; + Ok(Some(response)) +} + +/// Send and fully process a submit_block request, returning a complete decoded +/// response +async fn send_submit_block_full( + proposal_info: Arc, + url: Arc, + relay: &RelayClient, + timeout_ms: u64, + retry: u32, + api_version: BuilderApiVersion, +) -> Result, PbsError> { + // Send the request + let block_response = send_submit_block_impl( + relay, + url, + timeout_ms, + proposal_info.headers.clone(), + &proposal_info.signed_blinded_block, + retry, + api_version, + ) + .await?; + + // If this is not v1, there's no body to decode + if api_version != BuilderApiVersion::V1 { + return Ok(None); + } + + // Decode the payload based on content type. The v1 guard above ensures + // `content_type` is Some. + let decoded_response = + decode_by_encoding(&block_response, decode_json_payload, decode_ssz_payload)?; + + // Log and return + debug!( + relay_id = relay.id.as_ref(), + retry, + latency = ?block_response.request_latency, + version =% decoded_response.version, + "received unblinded block" + ); + + Ok(Some(decoded_response)) +} + +/// Dispatch a v1 submit_block response to the appropriate decoder based on the +/// negotiated content-type. Caller guarantees `content_type` is Some (v2 +/// paths early-exit before reaching decode); an absent Content-Type on v1 is +/// treated as a protocol violation. SSZ additionally requires a fork header. +fn decode_by_encoding( + info: &SubmitBlockResponseInfo, + on_json: impl FnOnce(&[u8]) -> Result, + on_ssz: impl FnOnce(&[u8], ForkName) -> Result, +) -> Result { + let content_type = info.content_type.ok_or_else(|| PbsError::RelayResponse { + error_msg: "v1 submit_block response missing Content-Type".to_string(), + code: info.code.as_u16(), + })?; + match content_type { + EncodingType::Json => on_json(&info.response_bytes), + EncodingType::Ssz => { + let fork = info.fork.ok_or_else(|| PbsError::RelayResponse { + error_msg: "missing fork version header in SSZ submit_block response".to_string(), + code: info.code.as_u16(), + })?; + on_ssz(&info.response_bytes, fork) + } + } +} + +/// Sends the actual HTTP request to the relay's submit_block endpoint, +/// returning the response (if applicable), the round-trip time, and the +/// encoding type used for the body (if any). Used by send_submit_block. +async fn send_submit_block_impl( + relay: &RelayClient, + url: Arc, + timeout_ms: u64, + headers: HeaderMap, + signed_blinded_block: &SignedBlindedBeaconBlock, + retry: u32, + api_version: BuilderApiVersion, +) -> Result { let start_request = Instant::now(); - let res = match relay + + // Try SSZ first + let mut res = match relay .client - .post(url) + .post(url.as_ref().clone()) .timeout(Duration::from_millis(timeout_ms)) - .headers(headers) - .json(&signed_blinded_block) + .headers(headers.clone()) + .body(signed_blinded_block.as_ssz_bytes()) + .header(CONTENT_TYPE, EncodingType::Ssz.to_string()) + .header(CONSENSUS_VERSION_HEADER, signed_blinded_block.fork_name_unchecked().to_string()) .send() .await { @@ -190,96 +352,151 @@ async fn send_submit_block( return Err(err.into()); } }; + + // Retry as JSON only on the two status codes the builder-spec defines as + // "media type is the problem": 406 Not Acceptable and 415 Unsupported + // Media Type (RFC 7231 §6.5.13). Any other 4xx (400 malformed, 401/403 + // auth, 409 conflict, 429 rate limit, etc.) is orthogonal to encoding + // and MUST surface unchanged — retrying pollutes observability, doubles + // load on the relay, and can mask real errors behind a JSON-path reply. + if matches!(res.status(), StatusCode::NOT_ACCEPTABLE | StatusCode::UNSUPPORTED_MEDIA_TYPE,) { + warn!( + relay_id = relay.id.as_ref(), + status = %res.status(), + "relay rejected SSZ content-type, resubmitting block with JSON content-type" + ); + res = match relay + .client + .post(url.as_ref().clone()) + .timeout(Duration::from_millis(timeout_ms)) + .headers(headers) + .body(serde_json::to_vec(&signed_blinded_block).unwrap()) + .header(CONTENT_TYPE, EncodingType::Json.to_string()) + .send() + .await + { + Ok(res) => res, + Err(err) => { + RELAY_STATUS_CODE + .with_label_values(&[ + TIMEOUT_ERROR_CODE_STR, + SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, + &relay.id, + ]) + .inc(); + return Err(err.into()); + } + }; + } + + // Log the response code and latency + let code = res.status(); let request_latency = start_request.elapsed(); RELAY_LATENCY .with_label_values(&[SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, &relay.id]) .observe(request_latency.as_secs_f64()); - - let code = res.status(); RELAY_STATUS_CODE .with_label_values(&[code.as_str(), SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, &relay.id]) .inc(); - let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_SUBMIT_BLOCK_RESPONSE).await?; - if !code.is_success() { - let err = PbsError::RelayResponse { - error_msg: String::from_utf8_lossy(&response_bytes).into_owned(), - code: code.as_u16(), - }; - - // we requested the payload from all relays, but some may have not received it - warn!(relay_id = relay.id.as_ref(), retry, %err, "failed to get payload (this might be ok if other relays have it)"); - return Err(err); - }; - - if api_version != &BuilderApiVersion::V1 { - // v2 response is going to be empty, so just break here + // If this was API v2 and succeeded then we can just return here + if api_version != BuilderApiVersion::V1 { debug!( relay_id = relay.id.as_ref(), retry, latency = ?request_latency, - "successful request" + status = %code, + "received response for v2 submit_block" ); - return Ok(None); - } - - let block_response = match serde_json::from_slice::(&response_bytes) - { - Ok(parsed) => parsed, - Err(err) => { - return Err(PbsError::JsonDecode { - err, - raw: String::from_utf8_lossy(&response_bytes).into_owned(), - }); + match code { + StatusCode::ACCEPTED => { + return Ok(SubmitBlockResponseInfo { + response_bytes: Vec::new(), + content_type: None, + fork: None, + code, + request_latency, + }); + } + StatusCode::OK => { + warn!( + relay_id = relay.id.as_ref(), + "relay sent OK response for v2 submit_block, expected 202 Accepted" + ); + return Ok(SubmitBlockResponseInfo { + response_bytes: Vec::new(), + content_type: None, + fork: None, + code, + request_latency, + }); + } + _ => { + return Err(PbsError::RelayResponse { + error_msg: format!( + "relay sent unexpected code for builder route v2 {}: {code}", + relay.id.as_ref() + ), + code: code.as_u16(), + }); + } } - }; + } - debug!( - relay_id = relay.id.as_ref(), - retry, - latency = ?request_latency, - version =% block_response.version, - "received unblinded block" - ); + // If the code is not OK, return early + if code != StatusCode::OK { + let response_bytes = + read_chunked_body_with_max(res, MAX_SIZE_SUBMIT_BLOCK_RESPONSE).await?; + let err = PbsError::RelayResponse { + error_msg: String::from_utf8_lossy(&response_bytes).into_owned(), + code: code.as_u16(), + }; - let got_block_hash = block_response.data.execution_payload.block_hash().0; + // we requested the payload from all relays, but some may have not received it + warn!(relay_id = relay.id.as_ref(), %err, "failed to get payload (this might be ok if other relays have it)"); + return Err(err); + } - // request has different type so cant be deserialized in the wrong version, - // response has a "version" field - match &signed_blinded_block.message() { - BlindedBeaconBlock::Electra(blinded_block) => { - let expected_block_hash = - blinded_block.body.execution_payload.execution_payload_header.block_hash.0; - let expected_commitments = &blinded_block.body.blob_kzg_commitments; + // We're on v1 so decode the payload normally. Parse Content-Type + // (tolerating MIME parameters per RFC 7231 §3.1.1.1) and + // Eth-Consensus-Version headers + let (content_type, fork) = parse_response_encoding_and_fork(res.headers(), code.as_u16())?; - validate_unblinded_block( - expected_block_hash, - got_block_hash, - expected_commitments, - &block_response.data.blobs_bundle, - fork_name, - ) - } + // Decode the body + let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_SUBMIT_BLOCK_RESPONSE).await?; + Ok(SubmitBlockResponseInfo { + response_bytes, + content_type: Some(content_type), + fork, + code, + request_latency, + }) +} - BlindedBeaconBlock::Fulu(blinded_block) => { - let expected_block_hash = - blinded_block.body.execution_payload.execution_payload_header.block_hash.0; - let expected_commitments = &blinded_block.body.blob_kzg_commitments; +/// Decode a JSON-encoded submit_block response +fn decode_json_payload(response_bytes: &[u8]) -> Result { + match serde_json::from_slice::(response_bytes) { + Ok(parsed) => Ok(parsed), + Err(err) => Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(response_bytes).into_owned(), + }), + } +} - validate_unblinded_block( - expected_block_hash, - got_block_hash, - expected_commitments, - &block_response.data.blobs_bundle, - fork_name, - ) +/// Decode an SSZ-encoded submit_block response +fn decode_ssz_payload( + response_bytes: &[u8], + fork: ForkName, +) -> Result { + let data = PayloadAndBlobs::from_ssz_bytes_by_fork(response_bytes, fork).map_err(|e| { + PbsError::RelayResponse { + error_msg: (format!("error decoding relay payload: {e:?}")).to_string(), + code: 200, } - - _ => return Err(PbsError::Validation(ValidationError::UnsupportedFork)), - }?; - - Ok(Some(block_response)) + })?; + Ok(SubmitBlindedBlockResponse { version: fork, data, metadata: Default::default() }) } fn validate_unblinded_block( diff --git a/crates/pbs/src/routes/submit_block.rs b/crates/pbs/src/routes/submit_block.rs index 004b601e..8f70c79a 100644 --- a/crates/pbs/src/routes/submit_block.rs +++ b/crates/pbs/src/routes/submit_block.rs @@ -1,11 +1,20 @@ use std::sync::Arc; -use axum::{Json, extract::State, http::HeaderMap, response::IntoResponse}; +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, HeaderValue}, + response::IntoResponse, +}; use cb_common::{ - pbs::{BuilderApiVersion, GetPayloadInfo, SignedBlindedBeaconBlock}, - utils::{get_user_agent, timestamp_of_slot_start_millis, utcnow_ms}, + pbs::{BuilderApiVersion, GetPayloadInfo}, + utils::{ + CONSENSUS_VERSION_HEADER, EncodingType, deserialize_body, get_accept_types, get_user_agent, + timestamp_of_slot_start_millis, utcnow_ms, + }, }; -use reqwest::StatusCode; +use reqwest::{StatusCode, header::CONTENT_TYPE}; +use ssz::Encode; use tracing::{error, info, trace}; use crate::{ @@ -19,37 +28,26 @@ use crate::{ pub async fn handle_submit_block_v1>( state: State>, req_headers: HeaderMap, - Json(signed_blinded_block): Json>, + body_bytes: Bytes, ) -> Result { - handle_submit_block_impl::( - state, - req_headers, - signed_blinded_block, - BuilderApiVersion::V1, - ) - .await + handle_submit_block_impl::(state, req_headers, body_bytes, BuilderApiVersion::V1).await } pub async fn handle_submit_block_v2>( state: State>, req_headers: HeaderMap, - Json(signed_blinded_block): Json>, + body_bytes: Bytes, ) -> Result { - handle_submit_block_impl::( - state, - req_headers, - signed_blinded_block, - BuilderApiVersion::V2, - ) - .await + handle_submit_block_impl::(state, req_headers, body_bytes, BuilderApiVersion::V2).await } async fn handle_submit_block_impl>( State(state): State>, req_headers: HeaderMap, - signed_blinded_block: Arc, + body_bytes: Bytes, api_version: BuilderApiVersion, ) -> Result { + let signed_blinded_block = Arc::new(deserialize_body(&req_headers, body_bytes).await?); tracing::Span::current().record("slot", signed_blinded_block.slot().as_u64() as i64); tracing::Span::current() .record("block_hash", tracing::field::debug(signed_blinded_block.block_hash())); @@ -64,27 +62,60 @@ async fn handle_submit_block_impl>( let block_hash = signed_blinded_block.block_hash(); let slot_start_ms = timestamp_of_slot_start_millis(slot.into(), state.config.chain); let ua = get_user_agent(&req_headers); + let accept_types = get_accept_types(&req_headers).map_err(|e| { + error!(%e, "error parsing accept header"); + PbsClientError::DecodeError(format!("error parsing accept header: {e}")) + })?; + // Honor caller q-value preference: pick the highest-priority encoding that + // we can actually produce. Server preference for tiebreaks is SSZ first. + let response_encoding = accept_types.preferred(&[EncodingType::Ssz, EncodingType::Json]); info!(ua, ms_into_slot = now.saturating_sub(slot_start_ms), "new request"); match A::submit_block(signed_blinded_block, req_headers, state, api_version).await { Ok(res) => match res { - Some(block_response) => { - trace!(?block_response); + Some(payload_and_blobs) => { + trace!(?payload_and_blobs); info!("received unblinded block (v1)"); BEACON_NODE_STATUS .with_label_values(&["200", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) .inc(); - Ok((StatusCode::OK, Json(block_response).into_response())) + + // Three arms: no viable encoding (unreachable in practice — + // `get_accept_types` errors earlier if the caller offers + // nothing we support), SSZ, or JSON. + match response_encoding { + None => Err(PbsClientError::DecodeError( + "no viable accept types in request".to_string(), + )), + Some(EncodingType::Ssz) => { + let mut response = payload_and_blobs.data.as_ssz_bytes().into_response(); + + let content_type_header = EncodingType::Ssz.content_type_header().clone(); + response.headers_mut().insert(CONTENT_TYPE, content_type_header); + response.headers_mut().insert( + CONSENSUS_VERSION_HEADER, + HeaderValue::from_str(&payload_and_blobs.version.to_string()).unwrap(), + ); + info!("sending response as SSZ"); + Ok(response) + } + Some(EncodingType::Json) => { + info!("sending response as JSON"); + Ok((StatusCode::OK, axum::Json(payload_and_blobs)).into_response()) + } + } } None => { info!("received unblinded block (v2)"); + // Note: this doesn't provide consensus_version_header because it doesn't pass + // the body through, and there's no content-type header since the body is empty. BEACON_NODE_STATUS .with_label_values(&["202", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) .inc(); - Ok((StatusCode::ACCEPTED, "".into_response())) + Ok((StatusCode::ACCEPTED, "").into_response()) } }, diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index 07fa8f06..35adae87 100644 --- a/tests/src/mock_validator.rs +++ b/tests/src/mock_validator.rs @@ -4,7 +4,11 @@ use cb_common::{ types::BlsPublicKey, utils::{CONSENSUS_VERSION_HEADER, EncodingType, ForkName, bls_pubkey_from_hex}, }; -use reqwest::{Response, header::ACCEPT}; +use reqwest::{ + Response, + header::{ACCEPT, CONTENT_TYPE}, +}; +use ssz::Encode; use crate::utils::generate_mock_relay; @@ -73,28 +77,77 @@ impl MockValidator { pub async fn do_submit_block_v1( &self, signed_blinded_block_opt: Option, + accept: Vec, + content_type: EncodingType, + fork_name: ForkName, ) -> eyre::Result { - self.do_submit_block_impl(signed_blinded_block_opt, BuilderApiVersion::V1).await + self.do_submit_block_impl( + signed_blinded_block_opt, + accept, + content_type, + fork_name, + BuilderApiVersion::V1, + ) + .await } pub async fn do_submit_block_v2( &self, signed_blinded_block_opt: Option, + accept: Vec, + content_type: EncodingType, + fork_name: ForkName, ) -> eyre::Result { - self.do_submit_block_impl(signed_blinded_block_opt, BuilderApiVersion::V2).await + self.do_submit_block_impl( + signed_blinded_block_opt, + accept, + content_type, + fork_name, + BuilderApiVersion::V2, + ) + .await } async fn do_submit_block_impl( &self, - signed_blinded_block: Option, + signed_blinded_block_opt: Option, + accept: Vec, + content_type: EncodingType, + fork_name: ForkName, api_version: BuilderApiVersion, ) -> eyre::Result { let url = self.comm_boost.submit_block_url(api_version).unwrap(); let signed_blinded_block = - signed_blinded_block.unwrap_or_else(load_test_signed_blinded_block); + signed_blinded_block_opt.unwrap_or_else(load_test_signed_blinded_block); + let body = match content_type { + EncodingType::Json => serde_json::to_vec(&signed_blinded_block).unwrap(), + EncodingType::Ssz => signed_blinded_block.as_ssz_bytes(), + }; - Ok(self.comm_boost.client.post(url).json(&signed_blinded_block).send().await?) + let accept = match accept.len() { + 0 => None, + 1 => Some(accept.into_iter().next().unwrap().to_string()), + _ => { + // Ordered: first-listed is highest preference. Server honors + // RFC 9110 §12.5.1 (first-listed wins at equal q). + let accept_strings: Vec = + accept.into_iter().map(|e| e.to_string()).collect(); + Some(accept_strings.join(", ")) + } + }; + let mut res = self + .comm_boost + .client + .post(url) + .body(body) + .header(CONSENSUS_VERSION_HEADER, &fork_name.to_string()) + .header(CONTENT_TYPE, &content_type.to_string()); + if let Some(accept_header) = accept { + res = res.header(ACCEPT, accept_header); + } + let res = res.send().await?; + Ok(res) } } diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index 6d093bef..d8ce1356 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -12,7 +12,7 @@ use cb_common::{ }, signer::random_secret, types::Chain, - utils::{ForkName, ResponseReadError, set_ignore_content_length}, + utils::{EncodingType, ForkName, ResponseReadError, set_ignore_content_length}, }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ @@ -285,12 +285,34 @@ async fn test_mux() -> Result<()> { // v1 Submit block requests should go to all relays info!("Sending submit block v1"); - assert_eq!(mock_validator.do_submit_block_v1(None,).await?.status(), StatusCode::OK); + assert_eq!( + mock_validator + .do_submit_block_v1( + None, + vec![EncodingType::Json], + EncodingType::Json, + ForkName::Electra + ) + .await? + .status(), + StatusCode::OK + ); assert_eq!(mock_state.received_submit_block(), 3); // default + 2 mux relays were used // v2 Submit block requests should go to all relays info!("Sending submit block v2"); - assert_eq!(mock_validator.do_submit_block_v2(None,).await?.status(), StatusCode::ACCEPTED); + assert_eq!( + mock_validator + .do_submit_block_v2( + None, + vec![EncodingType::Json], + EncodingType::Json, + ForkName::Electra + ) + .await? + .status(), + StatusCode::ACCEPTED + ); assert_eq!(mock_state.received_submit_block(), 6); // default + 2 mux relays were used Ok(()) diff --git a/tests/tests/pbs_post_blinded_blocks.rs b/tests/tests/pbs_post_blinded_blocks.rs index bf4703c2..12cb58e0 100644 --- a/tests/tests/pbs_post_blinded_blocks.rs +++ b/tests/tests/pbs_post_blinded_blocks.rs @@ -1,25 +1,37 @@ -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration}; use cb_common::{ - pbs::{BuilderApiVersion, GetPayloadInfo, SubmitBlindedBlockResponse}, + pbs::{BuilderApiVersion, GetPayloadInfo, PayloadAndBlobs, SubmitBlindedBlockResponse}, signer::random_secret, types::Chain, + utils::{EncodingType, ForkName}, }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, mock_validator::{MockValidator, load_test_signed_blinded_block}, - utils::{generate_mock_relay, get_pbs_config, setup_test_env, to_pbs_config}, + utils::{ + generate_mock_relay, get_free_listener, get_pbs_config, setup_test_env, to_pbs_config, + }, }; use eyre::Result; +use lh_types::ForkVersionDecode; use reqwest::{Response, StatusCode}; use tracing::info; #[tokio::test] async fn test_submit_block_v1() -> Result<()> { - let res = submit_block_impl(3800, &BuilderApiVersion::V1, false, false).await?; - assert_eq!(res.status(), StatusCode::OK); - + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + StatusCode::OK, + false, + false, + ) + .await?; let signed_blinded_block = load_test_signed_blinded_block(); let response_body = serde_json::from_slice::(&res.bytes().await?)?; @@ -32,19 +44,46 @@ async fn test_submit_block_v1() -> Result<()> { #[tokio::test] async fn test_submit_block_v2() -> Result<()> { - let res = submit_block_impl(3802, &BuilderApiVersion::V2, false, false).await?; - assert_eq!(res.status(), StatusCode::ACCEPTED); + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; assert_eq!(res.bytes().await?.len(), 0); Ok(()) } // Test that when submitting a block using v2 to a relay that does not support -// v2, PBS falls back to v1 and successfully submits the block. +// v2, PBS falls back to v1 and forwards the v1 response body to the beacon +// node (a 200 with the execution payload), rather than swallowing the payload +// and replying 202 with an empty body — which would cause silent block loss. #[tokio::test] async fn test_submit_block_v2_without_relay_support() -> Result<()> { - let res = submit_block_impl(3804, &BuilderApiVersion::V2, true, false).await?; - assert_eq!(res.status(), StatusCode::ACCEPTED); - assert_eq!(res.bytes().await?.len(), 0); + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + StatusCode::OK, + true, + false, + ) + .await?; + // Payload must be forwarded so the BN can broadcast. + let signed_blinded_block = load_test_signed_blinded_block(); + let response_body = serde_json::from_slice::(&res.bytes().await?)?; + assert_eq!( + response_body.data.execution_payload.block_hash(), + signed_blinded_block.block_hash().into(), + "v2->v1 fallback must forward the execution payload to the BN" + ); Ok(()) } @@ -52,8 +91,155 @@ async fn test_submit_block_v2_without_relay_support() -> Result<()> { // for both v1 and v2, PBS doesn't loop forever. #[tokio::test] async fn test_submit_block_on_broken_relay() -> Result<()> { - let res = submit_block_impl(3806, &BuilderApiVersion::V2, true, true).await?; - assert_eq!(res.status(), StatusCode::BAD_GATEWAY); + let _res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + StatusCode::BAD_GATEWAY, + true, + true, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v1_ssz() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v2_ssz() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test that a v1 submit block request in SSZ is converted to JSON if the relay +/// only supports JSON +#[tokio::test] +async fn test_submit_block_v1_ssz_into_json() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +/// Test that a v2 submit block request in SSZ is converted to JSON if the relay +/// only supports JSON +#[tokio::test] +async fn test_submit_block_v2_ssz_into_json() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test v1 requesting multiple types when the relay supports SSZ, which should +/// return SSZ +#[tokio::test] +async fn test_submit_block_v1_multitype_ssz() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Ssz]), + EncodingType::Ssz, + 1, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +/// Test v1 requesting multiple types when the relay supports JSON, which should +/// still return SSZ +#[tokio::test] +async fn test_submit_block_v1_multitype_json() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + StatusCode::OK, + false, + false, + ) + .await?; + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); Ok(()) } @@ -64,14 +250,18 @@ async fn test_submit_block_too_large() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3900; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; let mock_state = Arc::new(MockRelayState::new(chain, signer).with_large_body()); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -79,7 +269,9 @@ async fn test_submit_block_too_large() -> Result<()> { let mock_validator = MockValidator::new(pbs_port)?; info!("Sending submit block"); - let res = mock_validator.do_submit_block_v1(None).await; + let res = mock_validator + .do_submit_block_v1(None, vec![EncodingType::Json], EncodingType::Json, ForkName::Electra) + .await; // response size exceeds max size: max: 20971520 assert_eq!(res.unwrap().status(), StatusCode::BAD_GATEWAY); @@ -87,21 +279,30 @@ async fn test_submit_block_too_large() -> Result<()> { Ok(()) } +#[allow(clippy::too_many_arguments)] async fn submit_block_impl( - pbs_port: u16, - api_version: &BuilderApiVersion, + api_version: BuilderApiVersion, + accept_types: Vec, + relay_types: HashSet, + serialization_mode: EncodingType, + expected_try_count: u64, + expected_code: StatusCode, remove_v2_support: bool, force_404s: bool, ) -> Result { setup_test_env(); let signer = random_secret(); let pubkey = signer.public_key(); - let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); // Run a mock relay - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; + let mock_relay = generate_mock_relay(relay_port, pubkey)?; let mut mock_relay_state = MockRelayState::new(chain, signer); + mock_relay_state.supported_content_types = Arc::new(relay_types); if remove_v2_support { mock_relay_state = mock_relay_state.with_no_submit_block_v2(); } @@ -109,28 +310,248 @@ async fn submit_block_impl( mock_relay_state = mock_relay_state.with_not_found_for_submit_block(); } let mock_state = Arc::new(mock_relay_state); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays); + let pbs_config = get_pbs_config(pbs_port); + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; + // Send the submit block request let signed_blinded_block = load_test_signed_blinded_block(); let mock_validator = MockValidator::new(pbs_port)?; info!("Sending submit block"); let res = match api_version { BuilderApiVersion::V1 => { - mock_validator.do_submit_block_v1(Some(signed_blinded_block)).await? + mock_validator + .do_submit_block_v1( + Some(signed_blinded_block), + accept_types, + serialization_mode, + ForkName::Electra, + ) + .await? } BuilderApiVersion::V2 => { - mock_validator.do_submit_block_v2(Some(signed_blinded_block)).await? + mock_validator + .do_submit_block_v2( + Some(signed_blinded_block), + accept_types, + serialization_mode, + ForkName::Electra, + ) + .await? } }; - let expected_count = if force_404s { 0 } else { 1 }; + let expected_count = if force_404s { 0 } else { expected_try_count }; assert_eq!(mock_state.received_submit_block(), expected_count); + assert_eq!(res.status(), expected_code); Ok(res) } + +// Retry-as-JSON trigger must be restricted +// to 406 Not Acceptable and 415 Unsupported Media Type. Any other 4xx is +// orthogonal to encoding and MUST surface unchanged. + +/// Shared fixture: relay returns `ssz_status` when the PBS sends SSZ, +/// everything else takes the happy path. Returns `(Response, attempt_count)`. +/// `api_version` picks v1 or v2 endpoint; `relay_types` controls what the +/// relay advertises as supported so the happy JSON path works when retried. +async fn submit_block_ssz_override( + api_version: BuilderApiVersion, + ssz_status: StatusCode, +) -> Result<(Response, u64)> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + let mut mock_relay_state = MockRelayState::new(chain, signer); + // Relay only advertises JSON so the retry (which goes out as JSON) lands + // on a clean success path. The SSZ-status override below intercepts + // before the supported-types check, so the first SSZ attempt still hits + // our injected status regardless of what's advertised here. + mock_relay_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Json])); + mock_relay_state = mock_relay_state.with_submit_block_ssz_status(ssz_status); + let mock_state = Arc::new(mock_relay_state); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let pbs_config = get_pbs_config(pbs_port); + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); + tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let signed_blinded_block = load_test_signed_blinded_block(); + let mock_validator = MockValidator::new(pbs_port)?; + // The BN sends SSZ; PBS forwards SSZ first, that's what our override hits. + let accept_types = vec![EncodingType::Ssz, EncodingType::Json]; + let res = match api_version { + BuilderApiVersion::V1 => { + mock_validator + .do_submit_block_v1( + Some(signed_blinded_block), + accept_types, + EncodingType::Ssz, + ForkName::Electra, + ) + .await? + } + BuilderApiVersion::V2 => { + mock_validator + .do_submit_block_v2( + Some(signed_blinded_block), + accept_types, + EncodingType::Ssz, + ForkName::Electra, + ) + .await? + } + }; + Ok((res, mock_state.received_submit_block())) +} + +/// 406 is the spec-defined "retry with a different media type" signal, so we +/// MUST retry as JSON and succeed. +#[tokio::test] +async fn test_submit_block_ssz_retries_as_json_on_406() -> Result<()> { + let (res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::NOT_ACCEPTABLE).await?; + assert_eq!(res.status(), StatusCode::OK, "retry-as-JSON must succeed on 406"); + assert_eq!(attempts, 2, "expected SSZ attempt + JSON retry"); + Ok(()) +} + +/// 415 is the other spec-defined media-type rejection status; same retry. +#[tokio::test] +async fn test_submit_block_ssz_retries_as_json_on_415() -> Result<()> { + let (res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::UNSUPPORTED_MEDIA_TYPE) + .await?; + assert_eq!(res.status(), StatusCode::OK, "retry-as-JSON must succeed on 415"); + assert_eq!(attempts, 2); + Ok(()) +} + +/// 400 Bad Request is a validation failure — encoding is not the problem. +/// Retrying doubles relay load and hides the real error. MUST NOT retry. +#[tokio::test] +async fn test_submit_block_ssz_does_not_retry_on_400() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::BAD_REQUEST).await?; + assert_eq!(attempts, 1, "400 is not a media-type error; must not retry"); + Ok(()) +} + +/// 401 Unauthorized — auth problem, not encoding. No retry. +#[tokio::test] +async fn test_submit_block_ssz_does_not_retry_on_401() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::UNAUTHORIZED).await?; + assert_eq!(attempts, 1); + Ok(()) +} + +/// 409 Conflict — state mismatch. No retry. +#[tokio::test] +async fn test_submit_block_ssz_does_not_retry_on_409() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::CONFLICT).await?; + assert_eq!(attempts, 1); + Ok(()) +} + +/// 429 Too Many Requests — `PbsError::should_retry` already excludes this; +/// retrying as JSON would add insult to injury. No retry. +#[tokio::test] +async fn test_submit_block_ssz_does_not_retry_on_429() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V1, StatusCode::TOO_MANY_REQUESTS).await?; + assert_eq!(attempts, 1); + Ok(()) +} + +/// Same policy applies to the v2 endpoint. +#[tokio::test] +async fn test_submit_block_v2_ssz_retries_as_json_on_415() -> Result<()> { + let (res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V2, StatusCode::UNSUPPORTED_MEDIA_TYPE) + .await?; + assert_eq!(res.status(), StatusCode::ACCEPTED, "v2 success is 202 Accepted"); + assert_eq!(attempts, 2); + Ok(()) +} + +/// v2 + 400: same no-retry rule as v1. +#[tokio::test] +async fn test_submit_block_v2_ssz_does_not_retry_on_400() -> Result<()> { + let (_res, attempts) = + submit_block_ssz_override(BuilderApiVersion::V2, StatusCode::BAD_REQUEST).await?; + assert_eq!(attempts, 1); + Ok(()) +} + +/// PBS must accept relay `Content-Type: application/octet-stream; +/// charset=binary` on `submit_block` responses. The audit fix for C2 switched +/// `EncodingType::from_str` to parse via the `mediatype` crate; this test +/// exercises the full relay→PBS→BN path to guard against regressions on the +/// v1 submit path. +#[tokio::test] +async fn test_submit_block_tolerates_mime_params_in_content_type() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + let mut mock_relay_state = MockRelayState::new(chain, signer) + .with_response_content_type("application/octet-stream; charset=binary"); + mock_relay_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Ssz])); + let mock_state = Arc::new(mock_relay_state); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let pbs_config = get_pbs_config(pbs_port); + let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); + tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let signed_blinded_block = load_test_signed_blinded_block(); + let mock_validator = MockValidator::new(pbs_port)?; + let res = mock_validator + .do_submit_block_v1( + Some(signed_blinded_block.clone()), + vec![EncodingType::Ssz], + EncodingType::Ssz, + ForkName::Electra, + ) + .await?; + assert_eq!(res.status(), StatusCode::OK, "PBS should tolerate `; charset=binary` MIME param"); + assert_eq!(mock_state.received_submit_block(), 1); + + let bytes = res.bytes().await?; + let response_body = PayloadAndBlobs::from_ssz_bytes_by_fork(&bytes, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +}