diff --git a/benches/microbench/src/get_header.rs b/benches/microbench/src/get_header.rs index 44eff329..d9604ed4 100644 --- a/benches/microbench/src/get_header.rs +++ b/benches/microbench/src/get_header.rs @@ -40,16 +40,21 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use alloy::primitives::B256; use axum::http::HeaderMap; -use cb_common::{pbs::GetHeaderParams, signer::random_secret, types::Chain}; +use cb_common::{ + pbs::GetHeaderParams, + signer::random_secret, + types::Chain, + utils::{AcceptedEncodings, EncodingType}, +}; use cb_pbs::{PbsState, get_header}; use cb_tests::{ - mock_relay::{MockRelayState, start_mock_relay_service}, - utils::{generate_mock_relay, get_pbs_static_config, to_pbs_config}, + mock_relay::{MockRelayState, start_mock_relay_service_with_listener}, + utils::{generate_mock_relay, get_free_listener, get_pbs_config, to_pbs_config}, }; use criterion::{Criterion, black_box, criterion_group, criterion_main}; -// Ports 19201–19205 are reserved for the microbenchmark mock relays. -const BASE_PORT: u16 = 19200; +// Mock relay ports are allocated dynamically via get_free_listener() so that +// parallel test/bench runs don't collide on hardcoded ports. const CHAIN: Chain = Chain::Hoodi; const MAX_RELAYS: usize = 5; const RELAY_COUNTS: [usize; 3] = [1, 3, MAX_RELAYS]; @@ -83,10 +88,23 @@ fn bench_get_header(c: &mut Criterion) { let pubkey = signer.public_key(); let mock_state = Arc::new(MockRelayState::new(CHAIN, signer)); - let relay_clients: Vec<_> = (0..MAX_RELAYS) - .map(|i| { - let port = BASE_PORT + 1 + i as u16; - tokio::spawn(start_mock_relay_service(mock_state.clone(), port)); + // Allocate all listeners upfront so each port is reserved until the + // server takes ownership — avoids TOCTOU bind races. + let listeners: Vec<_> = { + let mut v = Vec::with_capacity(MAX_RELAYS); + for _ in 0..MAX_RELAYS { + v.push(get_free_listener().await); + } + v + }; + let ports: Vec = listeners.iter().map(|l| l.local_addr().unwrap().port()).collect(); + + let relay_clients: Vec<_> = listeners + .into_iter() + .enumerate() + .map(|(i, listener)| { + let port = ports[i]; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), listener)); generate_mock_relay(port, pubkey.clone()).expect("relay client") }) .collect(); @@ -103,8 +121,7 @@ fn bench_get_header(c: &mut Criterion) { let states: Vec = RELAY_COUNTS .iter() .map(|&n| { - let config = - to_pbs_config(CHAIN, get_pbs_static_config(0), relay_clients[..n].to_vec()); + let config = to_pbs_config(CHAIN, get_pbs_config(0), relay_clients[..n].to_vec()); PbsState::new(config, PathBuf::new()) }) .collect(); @@ -138,6 +155,10 @@ fn bench_get_header(c: &mut Criterion) { black_box(params.clone()), black_box(headers.clone()), black_box(state.clone()), + black_box(AcceptedEncodings { + primary: EncodingType::Json, + fallback: Some(EncodingType::Ssz), + }), )) .expect("get_header failed") }) diff --git a/config.example.toml b/config.example.toml index 6804faad..3d3af0fb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -49,9 +49,16 @@ min_bid_eth = 0.0 # to force local building and miniminzing the risk of missed slots. See also the timing games section below # OPTIONAL, DEFAULT: 2000 late_in_slot_time_ms = 2000 -# Whether to enable extra validation of get_header responses, if this is enabled `rpc_url` must also be set -# OPTIONAL, DEFAULT: false -extra_validation_enabled = false +# The level of validation to perform on get_header responses. Less is faster but not as safe. Supported values: +# - "none": no validation, just accept the bid provided by the relay as-is and pass it back without decoding or checking it +# - "standard": perform standard validation of the header provided by the relay, which checks the bid's signature and several hashes to make sure it's legal (default) +# - "extra": perform extra validation on top of standard validation, which includes checking the bid against the execution layer via the `rpc_url` (requires `rpc_url` to be set) +# OPTIONAL, DEFAULT: standard +header_validation_mode = "standard" +# The level of validation to perform on submit_block responses. Less is faster but not as safe. Supported values: +# - "none": no validation, just accept the full unblinded block provided by the relay as-is and pass it back without decoding or checking it +# - "standard": perform standard validation of the unblinded block provided by the relay, which verifies things like the included KZG commitments and the block hash (default) +block_validation_mode = "standard" # Execution Layer RPC url to use for extra validation # OPTIONAL # rpc_url = "https://ethereum-holesky-rpc.publicnode.com" diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 4bb1cff9..a6048298 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -38,6 +38,34 @@ use crate::{ }, }; +/// Header validation modes for get_header responses +#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HeaderValidationMode { + // Bypass all validation and minimize decoding, which is faster but requires complete trust in + // the relays + None, + + // Validate the header itself, ensuring that it's for a correct block on the correct chain and + // fork. This is the default mode. + Standard, + + // Standard header validation, plus validation that the parent block is correct as well + Extra, +} + +/// Block validation modes for submit_block responses +#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BlockValidationMode { + // Bypass all validation, which is faster but requires complete trust in the relays + None, + + // Validate the block matches the header previously received from get_header and that it's for + // the correct chain and fork. This is the default mode. + Standard, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct RelayConfig { @@ -122,8 +150,11 @@ pub struct PbsConfig { #[serde(default = "default_u64::")] pub late_in_slot_time_ms: u64, /// Enable extra validation of get_header responses - #[serde(default = "default_bool::")] - pub extra_validation_enabled: bool, + #[serde(default = "default_header_validation_mode")] + pub header_validation_mode: HeaderValidationMode, + /// Enable extra validation of submit_block requests + #[serde(default = "default_block_validation_mode")] + pub block_validation_mode: BlockValidationMode, /// Execution Layer RPC url to use for extra validation pub rpc_url: Option, /// URL for the user's own SSV node API endpoint @@ -175,10 +206,10 @@ impl PbsConfig { format!("min bid is too high: {} ETH", format_ether(self.min_bid_wei)) ); - if self.extra_validation_enabled { + if self.header_validation_mode == HeaderValidationMode::Extra { ensure!( self.rpc_url.is_some(), - "rpc_url is required if extra_validation_enabled is true" + "rpc_url is required if header_validation_mode is set to extra" ); } @@ -442,6 +473,16 @@ pub async fn load_pbs_custom_config() -> Result<(PbsModuleC )) } +/// Default value for header validation mode +fn default_header_validation_mode() -> HeaderValidationMode { + HeaderValidationMode::Standard +} + +/// Default value for block validation mode +fn default_block_validation_mode() -> BlockValidationMode { + BlockValidationMode::Standard +} + /// Default URL for the user's SSV node API endpoint (/v1/validators). fn default_ssv_node_api_url() -> Url { Url::parse("http://localhost:16000/v1/").expect("default URL is valid") diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 0ac6ce1b..a11be501 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -426,8 +426,8 @@ mod tests { use super::*; use crate::config::{ - COMMIT_BOOST_IMAGE_DEFAULT, LogsSettings, ModuleKind, PbsConfig, StaticModuleConfig, - StaticPbsConfig, + BlockValidationMode, COMMIT_BOOST_IMAGE_DEFAULT, HeaderValidationMode, LogsSettings, + ModuleKind, PbsConfig, StaticModuleConfig, StaticPbsConfig, }; // Wrapper needed because TOML requires a top-level struct (can't serialize @@ -476,7 +476,8 @@ mod tests { skip_sigverify: false, min_bid_wei: Uint::<256, 4>::from(0), late_in_slot_time_ms: 0, - extra_validation_enabled: false, + header_validation_mode: HeaderValidationMode::Standard, + block_validation_mode: BlockValidationMode::Standard, rpc_url: None, http_timeout_seconds: 30, register_validator_retry_limit: 3, diff --git a/crates/pbs/src/api.rs b/crates/pbs/src/api.rs index 594b7d36..f55cce2e 100644 --- a/crates/pbs/src/api.rs +++ b/crates/pbs/src/api.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use async_trait::async_trait; use axum::{Router, http::HeaderMap}; -use cb_common::pbs::{ - BuilderApiVersion, GetHeaderParams, GetHeaderResponse, SignedBlindedBeaconBlock, - SubmitBlindedBlockResponse, +use cb_common::{ + pbs::{BuilderApiVersion, GetHeaderParams, SignedBlindedBeaconBlock}, + utils::AcceptedEncodings, }; use crate::{ - mev_boost, + CompoundGetHeaderResponse, CompoundSubmitBlockResponse, mev_boost, state::{BuilderApiState, PbsState, PbsStateGuard}, }; @@ -24,8 +24,9 @@ pub trait BuilderApi: 'static { params: GetHeaderParams, req_headers: HeaderMap, state: PbsState, - ) -> eyre::Result> { - mev_boost::get_header(params, req_headers, state).await + accepted_types: AcceptedEncodings, + ) -> eyre::Result> { + mev_boost::get_header(params, req_headers, state, accepted_types).await } /// https://ethereum.github.io/builder-specs/#/Builder/status @@ -40,8 +41,16 @@ pub trait BuilderApi: 'static { req_headers: HeaderMap, state: PbsState, api_version: BuilderApiVersion, - ) -> eyre::Result> { - mev_boost::submit_block(signed_blinded_block, req_headers, state, api_version).await + accepted_types: AcceptedEncodings, + ) -> eyre::Result { + mev_boost::submit_block( + signed_blinded_block, + req_headers, + state, + api_version, + accepted_types, + ) + .await } /// https://ethereum.github.io/builder-specs/#/Builder/registerValidator diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index 49b3d2f0..7c32d1ff 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -10,6 +10,7 @@ use alloy::{ }; use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ + config::HeaderValidationMode, constants::APPLICATION_BUILDER_DOMAIN, pbs::{ EMPTY_TX_ROOT_HASH, ExecutionPayloadHeaderRef, ForkName, ForkVersionDecode, GetHeaderInfo, @@ -20,7 +21,8 @@ use cb_common::{ signature::verify_signed_message, types::{BlsPublicKey, BlsPublicKeyBytes, BlsSignature, Chain}, utils::{ - EncodingType, OUTBOUND_ACCEPT, get_user_agent_with_version, ms_into_slot, + AcceptedEncodings, EncodingType, OUTBOUND_ACCEPT, build_outbound_accept, + get_bid_value_from_signed_builder_bid_ssz, 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, }, @@ -31,6 +33,7 @@ use reqwest::{ StatusCode, header::{ACCEPT, USER_AGENT}, }; +use serde::Deserialize; use tokio::time::sleep; use tracing::{Instrument, debug, error, info, warn}; use tree_hash::TreeHash; @@ -42,6 +45,7 @@ use crate::{ TIMEOUT_ERROR_CODE_STR, }, metrics::{RELAY_HEADER_VALUE, RELAY_LAST_SLOT, RELAY_LATENCY, RELAY_STATUS_CODE}, + mev_boost::{CompoundGetHeaderResponse, LightGetHeaderResponse}, state::{BuilderApiState, PbsState}, utils::check_gas_limit, }; @@ -62,6 +66,10 @@ struct RequestInfo { /// Context for validating the header returned by the relay validation: ValidationContext, + + /// The accepted encoding types from the original request, ordered by + /// descending caller preference (q-value). + accepted_types: AcceptedEncodings, } struct GetHeaderResponseInfo { @@ -85,11 +93,19 @@ struct GetHeaderResponseInfo { request_latency: Duration, } +/// Context for validating the header #[derive(Clone)] struct ValidationContext { + /// Whether to skip signature verification skip_sigverify: bool, + + /// Minimum acceptable bid, in wei min_bid_wei: U256, - extra_validation_enabled: bool, + + /// The mode used for response validation + mode: HeaderValidationMode, + + /// The parent block, if fetched parent_block: Arc>>, } @@ -99,11 +115,12 @@ pub async fn get_header( params: GetHeaderParams, req_headers: HeaderMap, state: PbsState, -) -> eyre::Result> { + accepted_types: AcceptedEncodings, +) -> eyre::Result> { let parent_block = Arc::new(RwLock::new(None)); - if state.extra_validation_enabled() && - let Some(rpc_url) = state.pbs_config().rpc_url.clone() - { + let extra_validation_enabled = + state.config.pbs_config.header_validation_mode == HeaderValidationMode::Extra; + if extra_validation_enabled && let Some(rpc_url) = state.pbs_config().rpc_url.clone() { tokio::spawn( fetch_parent_block(rpc_url, params.parent_hash, parent_block.clone()).in_current_span(), ); @@ -150,9 +167,19 @@ pub async fn get_header( 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(); + let mode = state.pbs_config().header_validation_mode; + let accept_types = match mode { + HeaderValidationMode::None => { + // No validation mode, so forward the caller's preference verbatim + // (still q-ordered) — the relay's response is passed through. + build_outbound_accept(accepted_types) + } + _ => { + // We're unpacking the body, so use the documented, deterministic + // preference: SSZ first (wire-efficient), JSON fallback. + OUTBOUND_ACCEPT.to_string() + } + }; send_headers.insert( ACCEPT, HeaderValue::from_str(&accept_types) @@ -168,9 +195,10 @@ pub async fn get_header( 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(), + mode, parent_block, }, + accepted_types, }); let mut handles = Vec::with_capacity(relays.len()); for relay in relays.iter() { @@ -192,10 +220,12 @@ pub async fn get_header( match res { Ok(Some(res)) => { + let value = match &res { + CompoundGetHeaderResponse::Full(full) => *full.value(), + CompoundGetHeaderResponse::Light(light) => light.value, + }; 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(); + let value_gwei = (value / U256::from(1_000_000_000)).try_into().unwrap_or_default(); RELAY_HEADER_VALUE.with_label_values(&[relay_id]).set(value_gwei); relay_bids.push((relay_id, res)) @@ -206,15 +236,29 @@ pub async fn get_header( } } - let max_bid = relay_bids.into_iter().max_by_key(|(_, bid)| *bid.value()); + let max_bid = relay_bids.into_iter().max_by_key(|(_, bid)| match bid { + CompoundGetHeaderResponse::Full(full) => *full.value(), + CompoundGetHeaderResponse::Light(light) => light.value, + }); if let Some((winning_relay_id, ref bid)) = max_bid { - info!( - relay_id = winning_relay_id, - value_eth = format_ether(*bid.value()), - block_hash = %bid.block_hash(), - "auction winner" - ); + match bid { + CompoundGetHeaderResponse::Full(full) => { + info!( + relay_id = winning_relay_id, + value_eth = format_ether(*full.value()), + block_hash = %full.block_hash(), + "auction winner" + ); + } + CompoundGetHeaderResponse::Light(light) => { + info!( + relay_id = winning_relay_id, + value_eth = format_ether(light.value), + "auction winner (light mode, no block_hash available)" + ); + } + } } Ok(max_bid.map(|(_, bid)| bid)) @@ -250,7 +294,7 @@ async fn send_timed_get_header( relay: RelayClient, ms_into_slot: u64, mut timeout_left_ms: u64, -) -> Result, PbsError> { +) -> Result, PbsError> { let params = &request_info.params; let url = Arc::new(relay.get_header_url(params.slot, ¶ms.parent_hash, ¶ms.pubkey)?); @@ -356,86 +400,133 @@ async fn send_one_get_header( 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?; - 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)) +) -> Result<(u64, Option), PbsError> { + match request_info.validation.mode { + HeaderValidationMode::None => { + // Minimal processing: extract fork and value, forward response bytes directly. + // Expensive crypto/structural validation is skipped (sigverify, parent hash, + // timestamp), but the min_bid check is applied. + let (start_request_time, get_header_response) = send_get_header_light( + &relay, + url, + timeout_left_ms, + (*request_info.headers).clone(), /* Create a copy of the HeaderMap because the + * impl + * will + * modify it */ + ) + .await?; + match get_header_response { + None => Ok((start_request_time, None)), + Some(res) => { + let min_bid = request_info.validation.min_bid_wei; + if res.value < min_bid { + return Err(PbsError::Validation(ValidationError::BidTooLow { + min: min_bid, + got: res.value, + })); + } + + // Make sure the response is encoded in one of the accepted + // types since we're passing the raw response directly to the client + if !request_info.accepted_types.contains(res.encoding_type) { + return Err(PbsError::RelayResponse { + error_msg: format!( + "relay returned unsupported encoding type for get_header in no-validation mode: {:?}", + res.encoding_type + ), + code: 406, // Not Acceptable + }); + } + Ok((start_request_time, Some(CompoundGetHeaderResponse::Light(res)))) + } + } } - 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, - }), - }?; + _ => { + // 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?; + 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 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.mode == HeaderValidationMode::Extra { + 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" + ); + } + } - // 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(CompoundGetHeaderResponse::Full(Box::new(get_header_response))), + )) } } - - Ok((start_request_time, Some(get_header_response))) } /// Send and decode a full get_header response, with all of the fields. @@ -472,6 +563,52 @@ async fn send_get_header_full( Ok((start_request_time, Some(get_header_response))) } +/// Send a get_header request and decode only the fork and bid value from the +/// response, leaving the raw bytes intact for direct forwarding to the caller. +/// Used in `HeaderValidationMode::None` where expensive crypto/structural +/// checks are skipped. +async fn send_get_header_light( + 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 value / fork from the response + let (fork, value) = decode_by_encoding(&info, get_light_info_from_json, |bytes, fork| { + Ok((fork, get_bid_value_from_signed_builder_bid_ssz(bytes, fork)?)) + })?; + + // Log and return + debug!( + relay_id = info.relay_id.as_ref(), + header_size_bytes = info.response_bytes.len(), + latency = ?info.request_latency, + version =? fork, + value_eth = format_ether(value), + content_type = ?info.content_type, + "received new header (light processing)" + ); + Ok(( + start_request_time, + Some(LightGetHeaderResponse { + version: fork, + value, + raw_bytes: info.response_bytes, + encoding_type: info.content_type, + }), + )) +} + /// 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 @@ -506,6 +643,7 @@ async fn send_get_header_impl( // 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 = Instant::now(); let start_request_time = utcnow_ms(); headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); @@ -513,7 +651,6 @@ async fn send_get_header_impl( // minimize timing games without losing the bid headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(timeout_left_ms)); - let start_request = Instant::now(); let res = match relay .client .get(url.as_ref().clone()) @@ -531,12 +668,12 @@ async fn send_get_header_impl( } }; + // Log the response code and latency + let code = res.status(); let request_latency = start_request.elapsed(); RELAY_LATENCY .with_label_values(&[GET_HEADER_ENDPOINT_TAG, &relay.id]) .observe(request_latency.as_secs_f64()); - - let code = res.status(); RELAY_STATUS_CODE.with_label_values(&[code.as_str(), GET_HEADER_ENDPOINT_TAG, &relay.id]).inc(); // Per the builder spec, 200 carries a bid payload and 204 means no bid @@ -594,6 +731,35 @@ fn decode_json_payload(response_bytes: &[u8]) -> Result Result<(ForkName, U256), PbsError> { + #[derive(Deserialize)] + struct LightBuilderBid { + #[serde(with = "serde_utils::quoted_u256")] + pub value: U256, + } + + #[derive(Deserialize)] + struct LightSignedBuilderBid { + pub message: LightBuilderBid, + } + + #[derive(Deserialize)] + struct LightHeaderResponse { + version: ForkName, + data: LightSignedBuilderBid, + } + + match serde_json::from_slice::(response_bytes) { + Ok(parsed) => Ok((parsed.version, parsed.data.message.value)), + Err(err) => Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(response_bytes).into_owned(), + }), + } +} + /// Decode an SSZ-encoded get_header response fn decode_ssz_payload( response_bytes: &[u8], @@ -711,9 +877,7 @@ mod tests { pbs::*, signature::sign_builder_message, types::{BlsPublicKeyBytes, BlsSecretKey, BlsSignature, Chain}, - utils::{ - TestRandomSeed, get_bid_value_from_signed_builder_bid_ssz, timestamp_of_slot_start_sec, - }, + utils::{TestRandomSeed, timestamp_of_slot_start_sec}, }; use ssz::Encode; diff --git a/crates/pbs/src/mev_boost/mod.rs b/crates/pbs/src/mev_boost/mod.rs index a41b79db..81dc4bf6 100644 --- a/crates/pbs/src/mev_boost/mod.rs +++ b/crates/pbs/src/mev_boost/mod.rs @@ -4,8 +4,73 @@ mod reload; mod status; mod submit_block; +use alloy::primitives::U256; +use cb_common::{ + pbs::{GetHeaderResponse, SubmitBlindedBlockResponse}, + utils::EncodingType, +}; pub use get_header::get_header; +use lh_types::ForkName; pub use register_validator::register_validator; pub use reload::reload; pub use status::get_status; pub use submit_block::submit_block; + +/// Enum that handles different GetHeader response types based on the level of +/// validation required +pub enum CompoundGetHeaderResponse { + /// Standard response type, fully parsing the response from a relay into a + /// complete response struct + Full(Box), + + /// Light response type, only extracting the fork and value from the builder + /// bid with the entire (undecoded) payload for forwarding + Light(LightGetHeaderResponse), +} + +/// Core details of a GetHeaderResponse, used for light processing when +/// validation mode is set to none. +#[derive(Clone)] +pub struct LightGetHeaderResponse { + /// The fork name for the bid + pub version: ForkName, + + /// The bid value in wei + pub value: U256, + + /// The raw bytes of the response, for forwarding to the caller + pub raw_bytes: Vec, + + /// The format the response bytes are encoded with + pub encoding_type: EncodingType, +} + +/// Enum that handles different SubmitBlock response types based on the level of +/// validation required +pub enum CompoundSubmitBlockResponse { + /// Standard response type, fully parsing the response from a relay into a + /// complete response struct + Full(Box), + + /// Light response type, only extracting the fork from the response with the + /// entire (undecoded) payload for forwarding + Light(LightSubmitBlockResponse), + + /// Response with no body, used for v2 requests when the relay does not + /// return any content intentionally + EmptyBody, +} + +/// Core details of a SubmitBlockResponse, used for light processing when +/// validation mode is set to none. +#[derive(Clone, Debug)] +pub struct LightSubmitBlockResponse { + /// The fork name for the bid + pub version: ForkName, + + /// The raw bytes of the response, for forwarding to the caller + pub raw_bytes: Vec, + + /// The format the response bytes are encoded with + pub encoding_type: EncodingType, +} diff --git a/crates/pbs/src/mev_boost/submit_block.rs b/crates/pbs/src/mev_boost/submit_block.rs index dfabcf60..421d68e3 100644 --- a/crates/pbs/src/mev_boost/submit_block.rs +++ b/crates/pbs/src/mev_boost/submit_block.rs @@ -6,6 +6,7 @@ use std::{ use alloy::{eips::eip7594::CELLS_PER_EXT_BLOB, primitives::B256}; use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ + config::BlockValidationMode, pbs::{ BlindedBeaconBlock, BlobsBundle, BuilderApiVersion, ForkName, ForkVersionDecode, HEADER_START_TIME_UNIX_MS, KzgCommitments, PayloadAndBlobs, RelayClient, @@ -13,8 +14,9 @@ use cb_common::{ error::{PbsError, ValidationError}, }, utils::{ - CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT, get_user_agent_with_version, - parse_response_encoding_and_fork, read_chunked_body_with_max, utcnow_ms, + AcceptedEncodings, CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT, + build_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}; @@ -22,12 +24,13 @@ use reqwest::{ StatusCode, header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, }; +use serde::Deserialize; use ssz::Encode; use tracing::{debug, warn}; use url::Url; use crate::{ - TIMEOUT_ERROR_CODE_STR, + CompoundSubmitBlockResponse, LightSubmitBlockResponse, 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}, @@ -43,6 +46,13 @@ struct ProposalInfo { /// The version of the submit_block route being used api_version: BuilderApiVersion, + + /// How to validate the block returned by the relay + validation_mode: BlockValidationMode, + + /// The accepted encoding types from the original request, ordered by + /// descending caller preference (q-value). + accepted_types: AcceptedEncodings, } struct SubmitBlockResponseInfo { @@ -72,7 +82,8 @@ pub async fn submit_block( req_headers: HeaderMap, state: PbsState, api_version: BuilderApiVersion, -) -> eyre::Result> { + accepted_types: AcceptedEncodings, +) -> eyre::Result { debug!(?req_headers, "received headers"); // prepare headers @@ -81,14 +92,29 @@ pub async fn submit_block( 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(); + let mode = state.pbs_config().block_validation_mode; + let accept_types = match mode { + BlockValidationMode::None => { + // No validation mode, so forward the caller's preference verbatim + // (still q-ordered) — the relay's response is passed through. + build_outbound_accept(accepted_types) + } + _ => { + // We're unpacking the body, so use the documented, deterministic + // preference: SSZ first (wire-efficient), JSON fallback. + 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 proposal_info = Arc::new(ProposalInfo { + signed_blinded_block, + headers: send_headers, + api_version, + validation_mode: mode, + accepted_types, + }); let mut handles = Vec::with_capacity(state.all_relays().len()); for relay in state.all_relays().iter() { handles.push( @@ -117,7 +143,7 @@ async fn submit_block_with_timeout( proposal_info: Arc, relay: RelayClient, timeout_ms: u64, -) -> Result, PbsError> { +) -> Result { let mut url = Arc::new(relay.submit_block_url(proposal_info.api_version)?); let mut remaining_timeout_ms = timeout_ms; let mut retry = 0; @@ -142,6 +168,10 @@ async fn submit_block_with_timeout( // 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. + // + // The caller (routes/submit_block.rs) serialises Full/Light responses + // with the caller's negotiated encoding, independent of which endpoint + // the relay actually served. if request_api_version == BuilderApiVersion::V1 && proposal_info.api_version != request_api_version { @@ -194,55 +224,94 @@ async fn send_submit_block( timeout_ms: u64, retry: u32, 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) +) -> Result { + match proposal_info.validation_mode { + BlockValidationMode::None => { + // No validation so do some light processing and forward the response directly + let response = send_submit_block_light( + proposal_info.clone(), + url, + relay, + timeout_ms, + retry, + api_version, + ) .await?; - let response = match response { - None => { - // v2 request with no body - return Ok(None); + match response { + None => Ok(CompoundSubmitBlockResponse::EmptyBody), + Some(res) => { + // Make sure the response is encoded in one of the accepted + // types since we're passing the raw response directly to the client + if !proposal_info.accepted_types.contains(res.encoding_type) { + return Err(PbsError::RelayResponse { + error_msg: format!( + "relay returned unsupported encoding type for submit_block in no-validation mode: {:?}", + res.encoding_type + ), + code: 406, // Not Acceptable + }); + } + Ok(CompoundSubmitBlockResponse::Light(res)) + } + } } - 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, + _ => { + // 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(CompoundSubmitBlockResponse::EmptyBody); + } + 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, - ) - } + 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)) + _ => return Err(PbsError::Validation(ValidationError::UnsupportedFork)), + }?; + Ok(CompoundSubmitBlockResponse::Full(Box::new(response))) + } + } } /// Send and fully process a submit_block request, returning a complete decoded @@ -289,6 +358,61 @@ async fn send_submit_block_full( Ok(Some(decoded_response)) } +/// Send and lightly process a submit_block request, minimizing the amount of +/// decoding and validation done +async fn send_submit_block_light( + 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?; + + // v2 responses have no body to decode. Use the endpoint version we actually + // dispatched to (api_version), not the original proposal_info.api_version, + // because the caller may have fallen back from v2 to v1 — in which case we + // DO have a body that must be forwarded to the beacon node. + 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 fork = + decode_by_encoding(&block_response, get_light_info_from_json, |_bytes, fork| Ok(fork))?; + // `content_type` is guaranteed Some on v1 per decode_by_encoding. + let encoding_type = block_response.content_type.expect( + "v1 submit_block response carries Content-Type; decode_by_encoding already enforced this", + ); + + // Log and return + debug!( + relay_id = relay.id.as_ref(), + retry, + latency = ?block_response.request_latency, + version =% fork, + "received unblinded block (light processing)" + ); + + Ok(Some(LightSubmitBlockResponse { + version: fork, + encoding_type, + raw_bytes: block_response.response_bytes, + })) +} + /// 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 @@ -485,6 +609,23 @@ fn decode_json_payload(response_bytes: &[u8]) -> Result Result { + #[derive(Deserialize)] + struct LightSubmitBlockResponse { + version: ForkName, + } + + match serde_json::from_slice::(response_bytes) { + Ok(parsed) => Ok(parsed.version), + Err(err) => Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(response_bytes).into_owned(), + }), + } +} + /// Decode an SSZ-encoded submit_block response fn decode_ssz_payload( response_bytes: &[u8], diff --git a/crates/pbs/src/routes/get_header.rs b/crates/pbs/src/routes/get_header.rs index 34a10b7c..9fbab0a1 100644 --- a/crates/pbs/src/routes/get_header.rs +++ b/crates/pbs/src/routes/get_header.rs @@ -15,6 +15,7 @@ use ssz::Encode; use tracing::{error, info}; use crate::{ + CompoundGetHeaderResponse, api::BuilderApi, constants::GET_HEADER_ENDPOINT_TAG, error::PbsClientError, @@ -45,39 +46,67 @@ pub async fn handle_get_header>( info!(ua, ms_into_slot, "new request"); - match A::get_header(params, req_headers, state).await { + match A::get_header(params, req_headers, state, accept_types).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"); + match max_bid { + CompoundGetHeaderResponse::Light(light_bid) => { + // Light validation mode, so just forward the raw response + info!( + value_eth = format_ether(light_bid.value), + "received header (unvalidated)" + ); - // 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. + // 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()) + HeaderValue::from_str(&light_bid.version.to_string()) .expect("fork name is always a valid header value"); - let content_type_header = EncodingType::Ssz.content_type_header().clone(); + let content_type_header = + light_bid.encoding_type.content_type_header().clone(); - let mut res = max_bid.data.as_ssz_bytes().into_response(); + // Build response + let mut res = light_bid.raw_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"); + info!("sending response as {} (light)", light_bid.encoding_type); Ok(res) } - Some(EncodingType::Json) => { - info!("sending response as JSON"); - Ok((StatusCode::OK, axum::Json(max_bid)).into_response()) + CompoundGetHeaderResponse::Full(max_bid) => { + // Full validation mode, so respond based on requester accept types + info!(value_eth = format_ether(*max_bid.data.message.value()), block_hash =% max_bid.block_hash(), "received header"); + + // 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 { diff --git a/crates/pbs/src/routes/submit_block.rs b/crates/pbs/src/routes/submit_block.rs index 8f70c79a..360bc33a 100644 --- a/crates/pbs/src/routes/submit_block.rs +++ b/crates/pbs/src/routes/submit_block.rs @@ -18,6 +18,7 @@ use ssz::Encode; use tracing::{error, info, trace}; use crate::{ + CompoundSubmitBlockResponse, api::BuilderApi, constants::SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, error::PbsClientError, @@ -72,9 +73,50 @@ async fn handle_submit_block_impl>( 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 { + match A::submit_block(signed_blinded_block, req_headers, state, api_version, accept_types).await + { Ok(res) => match res { - Some(payload_and_blobs) => { + crate::CompoundSubmitBlockResponse::EmptyBody => { + 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()) + } + CompoundSubmitBlockResponse::Light(payload_and_blobs) => { + trace!(?payload_and_blobs); + info!("received unblinded block (v1, unvalidated)"); + + BEACON_NODE_STATUS + .with_label_values(&["200", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) + .inc(); + + // Create the headers + let consensus_version_header = + match HeaderValue::from_str(&payload_and_blobs.version.to_string()) { + Ok(consensus_version_header) => { + Ok::(consensus_version_header) + } + Err(e) => { + return Err(PbsClientError::RelayError(format!( + "error decoding consensus version from relay payload: {e}" + ))); + } + }?; + let content_type_header = + payload_and_blobs.encoding_type.content_type_header().clone(); + + // Build response + let mut res = payload_and_blobs.raw_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 {} (light)", payload_and_blobs.encoding_type); + Ok(res) + } + CompoundSubmitBlockResponse::Full(payload_and_blobs) => { trace!(?payload_and_blobs); info!("received unblinded block (v1)"); @@ -107,16 +149,6 @@ async fn handle_submit_block_impl>( } } } - 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()) - } }, Err(err) => { diff --git a/crates/pbs/src/state.rs b/crates/pbs/src/state.rs index bd683e5f..cbe86af9 100644 --- a/crates/pbs/src/state.rs +++ b/crates/pbs/src/state.rs @@ -64,8 +64,4 @@ where None => (self.pbs_config(), &self.config.relays, None), } } - - pub fn extra_validation_enabled(&self) -> bool { - self.config.pbs_config.extra_validation_enabled - } } diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index 7eefb277..db1072a7 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -9,10 +9,31 @@ Commit-Boost needs a configuration file detailing all the services that you want - For a full explanation of all the fields, check out [here](https://github.com/Commit-Boost/commit-boost-client/blob/main/config.example.toml). - For some additional examples on config presets, check out [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/configs). -## Minimal PBS setup on Holesky +## Validation + +The PBS service can be configured to perform various levels of validation against both builder bid requests and unblinded blocks returned by relays. This allows the user to trade-off between speed and safety. + +For requesting builder bids, you can specify the `header_validation_mode` setting within the `[pbs]` configuration section. It has three modes: + +- `header_validation_mode = "none"`: The bids returned by the relay will not undergo any validation, and they will only be partially decoded to check the fork version and the value. The bid with the highest value will still be returned, but the PBS service won't check to confirm whether or not the bid is actually legal. We recommend that this only gets used when you absolutely trust each relay you've configured. + +- `header_validation_mode = "standard"`: The bids returned by the relay will be fully decoded and validated against the expected request (such as a matching parent hash, correct relay signature, and so on). This takes a small amount of extra computing power but ensures any invalid bids will be ignored. + +- `header_validation_mode = "extra"`: Performs all of the `standard` validation, plus ensures the block number is correct and the block's gas limit is legal. Requires the `rpc_url` parameter to be set, so the PBS service can query an Execution Client to confirm those details. + +For submitting signed blinded blocks and retrieving unblinded blocks, you can specify the `block_validation_mode` setting: + +- `block_validation_mode = "none"`: The unblinded blocks returned by the relay will not undergo any validation, and they will only be partially decoded to check that the fork version is correct. The unblinded block won't be checked to verify that it matches the original blinded block you submitted. We recommend that this only gets used when you absolutely trust each relay you've configured. + + Blocks will be returned directly from the relay to the Beacon Node, and may not necessarily be in a format the Beacon Node requested. For example, if the Beacon Node sends the signed blinded block as SSZ, but the relay only accepts JSON, it will return the unblinded block to the Beacon Node as JSON rather than having the PBS service re-encode it into SSZ. Whether or not this is supported is an implementation detail of the particular Beacon Node you're using. + +- `block_validation_mode = "standard"`: The unblinded blocks returned by the relay will be fully decoded and validated to ensure they match the original request, and are valid according to the rules of the Beacon Chain. This takes a small amount of extra computing power but ensures the block was properly unblinded. + + +## Minimal PBS Setup on Hoodi ```toml -chain = "Holesky" +chain = "Hoodi" [pbs] port = 18550 @@ -24,20 +45,20 @@ url = "" enabled = true ``` -You can find a list of MEV-Boost Holesky relays [here](https://www.coincashew.com/coins/overview-eth/mev-boost/mev-relay-list#holesky-testnet-relays). +You can find a list of MEV-Boost Hoodi relays [here](https://www.coincashew.com/coins/overview-eth/mev-boost/mev-relay-list#hoodi-testnet-relays). After the sidecar is started, it will expose a port (`18550` in this example), that you need to point your CL to. This may be different depending on which CL you're running, check out [here](https://docs.flashbots.net/flashbots-mev-boost/getting-started/system-requirements#consensus-client-configuration-guides) for a list of configuration guides. :::note -In this setup, the signer module will not be started. +In this setup, the Signer service will not be started. ::: -## Signer module +## Signer Service -Commit-Boost supports both local and remote signers. The signer module is responsible for signing the transactions that other modules generates. Please note that only one signer at a time is allowed. +Commit-Boost supports both local and remote signers. The Signer service is responsible for signing the transactions that other modules generates. Please note that only one Signer at a time is allowed. -### Local signer +### Local Signer -To start a local signer module, you need to include its parameters in the config file +To start a local Signer Service, you need to include its parameters in the config file ```toml [pbs] @@ -219,9 +240,9 @@ All keys have the same password stored in `secrets/password.txt` ``` -### Proxy keys store +### Proxy Keys -Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer module and authorized by the validator key. Each module have their own proxy keys, that can be BLS or ECDSA. +Proxy keys can be used to sign transactions with a different key than the one used to sign the block. Proxy keys are generated by the Signer service and authorized by the validator key. Each service can have their own proxy keys, both BLS and ECDSA. To persist proxy keys across restarts, you must enable the proxy store in the config file. There are 2 options for this: @@ -230,7 +251,7 @@ To persist proxy keys across restarts, you must enable the proxy store in the co The keys are stored in plain text in a file. This method is unsafe and should only be used for testing. -#### File structure +#### File Structure ``` @@ -269,7 +290,7 @@ Where each `` file contains the following: The keys are stored in a ERC-2335 style keystore, along with a password. This way, you can safely share the keys directory as without the password they are useless. -#### File structure +#### File Structure ``` ├── @@ -305,13 +326,13 @@ Where the `.json` files contain ERC-2335 keystore, the ` -### Remote signer +### Remote Signer You might choose to use an external service to sign the transactions. For now, two types of remote signers are supported: Web3Signer and Dirk. #### Web3Signer -Web3Signer implements the same API as Commit-Boost, so there's no need to set up a Signer module. The parameters needed for the remote signer are: +Web3Signer implements the same API as Commit-Boost, so there's no need to set up a Signer service. The parameters needed for the remote signer are: ```toml [signer.remote] @@ -320,7 +341,7 @@ url = "https://remote.signer.url" #### Dirk -Dirk is a distributed key management system that can be used to sign transactions. In this case the Signer module is needed as an intermediary between the modules and Dirk. The following parameters are needed: +Dirk is a distributed key management system that can be used to sign transactions. In this case the Signer service is needed as an intermediary between the modules and Dirk. The following parameters are needed: ```toml [signer.dirk] @@ -344,7 +365,7 @@ wallets = ["AnotherWallet", "DistributedWallet"] ``` - `cert_path` and `key_path` are the paths to the client certificate and key used to authenticate with Dirk. -- `wallets` is a list of wallets from which the Signer module will load all accounts as consensus keys. Generated proxy keys will have format `///`, so accounts found with that pattern will be ignored. +- `wallets` is a list of wallets from which the Signer service will load all accounts as consensus keys. Generated proxy keys will have format `///`, so accounts found with that pattern will be ignored. - `secrets_path` is the path to the folder containing the passwords of the generated proxy accounts, which will be stored in `////.pass`. Additionally, you can set a proxy store so that the delegation signatures for generated proxy keys are stored locally. As these signatures are not sensitive, the only supported store type is `File`: @@ -424,7 +445,7 @@ Note: `trusted_count` is the number of trusted proxies in front of the Signer se ## Custom module -We currently provide a test module that needs to be built locally. To build the module run: +We currently provide a test module that needs to be built locally. To build the module, run: ```bash just docker-build-test-modules @@ -474,7 +495,7 @@ To learn more about developing modules, check out [here](/category/developing). ## Vouch -[Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS module is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. +[Vouch](https://github.com/attestantio/vouch) is a multi-node validator client built by [Attestant](https://www.attestant.io/). Vouch is particular in that it also integrates an MEV-Boost client to interact with relays. The Commit-Boost PBS service is compatible with the Vouch `blockrelay` since it implements the same Builder-API as relays. For example, depending on your setup and preference, you may want to fetch headers from a given relay using Commit-Boost vs using the built-in Vouch `blockrelay`. ### Configuration @@ -497,7 +518,7 @@ Modify the `blockrelay.config` file to add Commit-Boost: #### Beacon Node to Commit-Boost -In this setup, the BN Builder-API endpoint will be pointing to the PBS module (e.g. for Lighthouse you will need the flag `--builder=http://127.0.0.0:18550`). +In this setup, the BN Builder-API endpoint will be pointing to the PBS service (e.g. for Lighthouse you will need the flag `--builder=http://127.0.0.0:18550`). This will bypass the `blockrelay` entirely so make sure all relays are properly configured in the `[[relays]]` section. @@ -512,7 +533,7 @@ This approach could also work if you have a multi-beacon-node setup, where some ## Hot Reload -Commit-Boost supports hot-reloading the configuration file. This means that you can modify the `cb-config.toml` file and apply the changes without needing to restart the modules. To do this, you need to send a `POST` request to the `/reload` endpoint on each module you want to reload the configuration. In the case the module is running in a Docker container without the port exposed (like the signer), you can use the following command: +Commit-Boost supports hot-reloading the configuration file. This means that you can modify the `cb-config.toml` file and apply the changes without needing to restart the services. To do this, you need to send a `POST` request to the `/reload` endpoint on each service you want to reload the configuration. In the case the service is running in a Docker container without the port exposed (like the signer), you can use the following command: ```bash docker compose -f cb.docker-compose.yml exec cb_signer curl -X POST http://localhost:20000/reload @@ -556,7 +577,7 @@ Send `POST /revoke_jwt` with the module ID. This removes the module from the sig ### Notes -- The hot reload feature is available for PBS modules (both default and custom) and signer module. +- The hot reload feature is available for both the PBS service (both default and custom) and Signer service. - Changes related to listening hosts and ports will not been applied, as it requires the server to be restarted. - If running in Docker containers, changes in `volumes` will not be applied, as it requires the container to be recreated. Be careful if changing a path to a local file as it may not be accessible from the container. - Custom PBS modules may override the default behaviour of the hot reload feature to parse extra configuration fields. Check the [examples](https://github.com/Commit-Boost/commit-boost-client/blob/main/examples/status_api/src/main.rs) for more details. diff --git a/tests/data/configs/pbs.happy.toml b/tests/data/configs/pbs.happy.toml index f6050233..3d883013 100644 --- a/tests/data/configs/pbs.happy.toml +++ b/tests/data/configs/pbs.happy.toml @@ -1,6 +1,8 @@ chain = "Holesky" [pbs] +header_validation_mode = "standard" +block_validation_mode = "standard" docker_image = "ghcr.io/commit-boost/commit-boost:latest" extra_validation_enabled = false host = "127.0.0.1" diff --git a/tests/src/utils.rs b/tests/src/utils.rs index f1ed0114..0129e118 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -8,11 +8,11 @@ use std::{ use alloy::primitives::{B256, U256}; use cb_common::{ config::{ - COMMIT_BOOST_IMAGE_DEFAULT, CommitBoostConfig, LogsSettings, ModuleKind, - ModuleSigningConfig, PbsConfig, PbsModuleConfig, RelayConfig, ReverseProxyHeaderSetup, - SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, - SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig, - StaticPbsConfig, TlsMode, + BlockValidationMode, COMMIT_BOOST_IMAGE_DEFAULT, CommitBoostConfig, HeaderValidationMode, + LogsSettings, ModuleKind, ModuleSigningConfig, PbsConfig, PbsModuleConfig, RelayConfig, + ReverseProxyHeaderSetup, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, + SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_PORT_DEFAULT, SignerConfig, + SignerType, StartSignerConfig, StaticModuleConfig, StaticPbsConfig, TlsMode, }, pbs::{RelayClient, RelayEntry}, signer::SignerLoader, @@ -94,8 +94,8 @@ pub fn get_pbs_config(port: u16) -> PbsConfig { skip_sigverify: false, min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX, - extra_validation_enabled: false, - + header_validation_mode: HeaderValidationMode::Standard, + block_validation_mode: BlockValidationMode::Standard, 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/config.rs b/tests/tests/config.rs index b84ce54c..6245d600 100644 --- a/tests/tests/config.rs +++ b/tests/tests/config.rs @@ -1,7 +1,11 @@ use std::{net::Ipv4Addr, path::PathBuf}; use alloy::primitives::U256; -use cb_common::{config::CommitBoostConfig, types::Chain, utils::WEI_PER_ETH}; +use cb_common::{ + config::{BlockValidationMode, CommitBoostConfig, HeaderValidationMode}, + types::Chain, + utils::WEI_PER_ETH, +}; use eyre::Result; use url::Url; @@ -54,7 +58,8 @@ async fn test_load_pbs_happy() -> Result<()> { dbg!(&U256::from(0.5)); assert_eq!(config.pbs.pbs_config.min_bid_wei, U256::from((0.5 * WEI_PER_ETH as f64) as u64)); assert_eq!(config.pbs.pbs_config.late_in_slot_time_ms, 2000); - assert!(!config.pbs.pbs_config.extra_validation_enabled); + assert_eq!(config.pbs.pbs_config.header_validation_mode, HeaderValidationMode::Standard); + assert_eq!(config.pbs.pbs_config.block_validation_mode, BlockValidationMode::Standard); // Relay specific settings let relay = &config.relays[0]; @@ -156,7 +161,7 @@ async fn test_validate_bad_min_bid() -> Result<()> { #[tokio::test] async fn test_validate_missing_rpc_url() -> Result<()> { let mut config = load_happy_config().await?; - config.pbs.pbs_config.extra_validation_enabled = true; + config.pbs.pbs_config.header_validation_mode = HeaderValidationMode::Extra; config.pbs.pbs_config.rpc_url = None; let result = config.validate().await; @@ -165,7 +170,7 @@ async fn test_validate_missing_rpc_url() -> Result<()> { result .unwrap_err() .to_string() - .contains("rpc_url is required if extra_validation_enabled is true") + .contains("rpc_url is required if header_validation_mode is set to extra") ); Ok(()) } diff --git a/tests/tests/pbs_cfg_file_update.rs b/tests/tests/pbs_cfg_file_update.rs index 37dd4eb3..ae97590d 100644 --- a/tests/tests/pbs_cfg_file_update.rs +++ b/tests/tests/pbs_cfg_file_update.rs @@ -2,7 +2,10 @@ use std::{net::Ipv4Addr, sync::Arc, time::Duration}; use alloy::primitives::U256; use cb_common::{ - config::{CommitBoostConfig, LogsSettings, PbsConfig, RelayConfig, StaticPbsConfig}, + config::{ + BlockValidationMode, CommitBoostConfig, HeaderValidationMode, LogsSettings, PbsConfig, + RelayConfig, StaticPbsConfig, + }, pbs::RelayEntry, signer::random_secret, types::Chain, @@ -63,7 +66,8 @@ async fn test_cfg_file_update() -> Result<()> { min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX / 2, /* serde gets very upset about serializing u64::MAX * or anything close to it */ - extra_validation_enabled: false, + block_validation_mode: BlockValidationMode::Standard, + header_validation_mode: HeaderValidationMode::Standard, rpc_url: None, ssv_node_api_url: Url::parse("http://example.com").unwrap(), ssv_public_api_url: Url::parse("http://example.com").unwrap(), diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index 7228cb6e..cd60142e 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -2,11 +2,15 @@ use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration}; use alloy::primitives::{B256, U256}; use cb_common::{ + config::HeaderValidationMode, pbs::{GetHeaderResponse, SignedBuilderBid}, signature::sign_builder_root, signer::random_secret, types::{BlsPublicKeyBytes, Chain}, - utils::{EncodingType, ForkName, get_consensus_version_header, timestamp_of_slot_start_sec}, + utils::{ + EncodingType, ForkName, get_bid_value_from_signed_builder_bid_ssz, + get_consensus_version_header, timestamp_of_slot_start_sec, + }, }; use cb_pbs::{DefaultBuilderApi, PbsService, PbsState}; use cb_tests::{ @@ -31,6 +35,7 @@ async fn test_get_header() -> Result<()> { vec![EncodingType::Json], HashSet::from([EncodingType::Ssz, EncodingType::Json]), 1, + HeaderValidationMode::Standard, StatusCode::OK, U256::from(10u64), U256::ZERO, @@ -47,6 +52,7 @@ async fn test_get_header_ssz() -> Result<()> { vec![EncodingType::Ssz], HashSet::from([EncodingType::Ssz, EncodingType::Json]), 1, + HeaderValidationMode::Standard, StatusCode::OK, U256::from(10u64), U256::ZERO, @@ -65,6 +71,7 @@ async fn test_get_header_ssz_into_json() -> Result<()> { vec![EncodingType::Ssz], HashSet::from([EncodingType::Json]), 1, + HeaderValidationMode::Standard, StatusCode::OK, U256::from(10u64), U256::ZERO, @@ -82,6 +89,7 @@ async fn test_get_header_multitype_ssz() -> Result<()> { vec![EncodingType::Ssz, EncodingType::Json], HashSet::from([EncodingType::Ssz]), 1, + HeaderValidationMode::Standard, StatusCode::OK, U256::from(10u64), U256::ZERO, @@ -99,6 +107,98 @@ async fn test_get_header_multitype_json() -> Result<()> { vec![EncodingType::Ssz, EncodingType::Json], HashSet::from([EncodingType::Json]), 1, + HeaderValidationMode::Standard, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +// === Light Mode Tests === + +/// Test requesting JSON without validation when the relay supports JSON +#[tokio::test] +async fn test_get_header_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + HeaderValidationMode::None, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting SSZ without validation when the relay supports SSZ +#[tokio::test] +async fn test_get_header_ssz_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + HeaderValidationMode::None, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting SSZ without validation when the relay only supports JSON. +/// This should actually fail because in no-validation mode we just forward the +/// response without re-encoding it. +#[tokio::test] +async fn test_get_header_ssz_into_json_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + 1, + HeaderValidationMode::None, + StatusCode::NO_CONTENT, // Should fail because the only relay can't be used + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting multiple types without validation when the relay supports +/// SSZ, which should return SSZ +#[tokio::test] +async fn test_get_header_multitype_ssz_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Ssz]), + 1, + HeaderValidationMode::None, + StatusCode::OK, + U256::from(10u64), + U256::ZERO, + None, + ForkName::Electra, + ) + .await +} + +/// Test requesting multiple types without validation when the relay supports +/// JSON, which should still work +#[tokio::test] +async fn test_get_header_multitype_json_light() -> Result<()> { + test_get_header_impl( + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + HeaderValidationMode::None, StatusCode::OK, U256::from(10u64), U256::ZERO, @@ -118,6 +218,7 @@ async fn test_get_header_impl( accept_types: Vec, relay_types: HashSet, expected_try_count: u64, + mode: HeaderValidationMode, expected_code: StatusCode, bid_value: U256, min_bid_wei: U256, @@ -142,6 +243,7 @@ async fn test_get_header_impl( // Run the PBS service let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.header_validation_mode = mode; 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()]); @@ -199,7 +301,7 @@ async fn test_get_header_impl( } #[tokio::test] -async fn test_get_header_returns_204_if_no_relay_reachable() -> Result<()> { +async fn test_get_header_returns_204_if_relay_down() -> Result<()> { setup_test_env(); let signer = random_secret(); let pubkey = signer.public_key(); @@ -289,7 +391,7 @@ async fn test_get_header_returns_400_if_request_is_invalid() -> Result<()> { /// 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<()> { +async fn test_get_header_all_modes_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 @@ -297,32 +399,90 @@ async fn test_get_header_extra_validation_enforce_min_bid() -> Result<()> { // 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?; + for (mode, rpc_url) in [ + (HeaderValidationMode::Standard, None), + (HeaderValidationMode::None, None), + (HeaderValidationMode::Extra, Some(fake_rpc.clone())), + ] { + // Bid below min → all modes reject (204). + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + mode, + StatusCode::NO_CONTENT, + relay_bid, + min_bid_above_relay, + rpc_url.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?; + // Bid above min → all modes accept (200). + test_get_header_impl( + vec![EncodingType::Json], + HashSet::from([EncodingType::Json]), + 1, + mode, + StatusCode::OK, + min_bid_above_relay, + U256::ZERO, + rpc_url, + ForkName::Electra, + ) + .await?; + } + Ok(()) +} + +/// SSZ round-trip: configure the relay with a specific bid value, request via +/// PBS in None mode with SSZ encoding, and verify the raw response bytes decode +/// to the exact value that was configured. This exercises the byte-offset +/// extraction logic (`get_bid_value_from_signed_builder_bid_ssz`) end-to-end +/// through a live HTTP relay for both currently-supported forks. +#[tokio::test] +async fn test_get_header_ssz_bid_value_round_trip() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Holesky; + // Use a distinctive value so accidental zero-matches are impossible. + let relay_bid = U256::from(999_888_777u64); + + for fork_name in [ForkName::Electra, ForkName::Fulu] { + 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_state = + Arc::new(MockRelayState::new(chain, signer.clone()).with_bid_value(relay_bid)); + let mock_relay = generate_mock_relay(relay_port, pubkey.clone())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + // None mode: PBS forwards the raw SSZ bytes without re-encoding. + pbs_config.header_validation_mode = HeaderValidationMode::None; + pbs_config.min_bid_wei = U256::ZERO; + 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], fork_name).await?; + assert_eq!(res.status(), StatusCode::OK, "fork {fork_name}: expected 200"); + + let bytes = res.bytes().await?; + let extracted = get_bid_value_from_signed_builder_bid_ssz(&bytes, fork_name) + .map_err(|e| eyre::eyre!("fork {fork_name}: SSZ extraction failed: {e}"))?; + assert_eq!( + extracted, relay_bid, + "fork {fork_name}: SSZ-extracted bid value does not match configured relay bid" + ); + } Ok(()) } @@ -364,30 +524,115 @@ async fn test_get_header_bid_validation_matrix() -> Result<()> { 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), + let cases: &[(ForkName, EncodingType, HeaderValidationMode, U256, StatusCode)] = &[ + ( + ForkName::Electra, + EncodingType::Json, + HeaderValidationMode::None, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Electra, + EncodingType::Json, + HeaderValidationMode::None, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Electra, + EncodingType::Ssz, + HeaderValidationMode::None, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Electra, + EncodingType::Ssz, + HeaderValidationMode::None, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Fulu, + EncodingType::Json, + HeaderValidationMode::None, + bid_low, + StatusCode::NO_CONTENT, + ), + (ForkName::Fulu, EncodingType::Json, HeaderValidationMode::None, bid_high, StatusCode::OK), + ( + ForkName::Fulu, + EncodingType::Ssz, + HeaderValidationMode::None, + bid_low, + StatusCode::NO_CONTENT, + ), + (ForkName::Fulu, EncodingType::Ssz, HeaderValidationMode::None, bid_high, StatusCode::OK), + ( + ForkName::Electra, + EncodingType::Json, + HeaderValidationMode::Standard, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Electra, + EncodingType::Json, + HeaderValidationMode::Standard, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Electra, + EncodingType::Ssz, + HeaderValidationMode::Standard, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Electra, + EncodingType::Ssz, + HeaderValidationMode::Standard, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Fulu, + EncodingType::Json, + HeaderValidationMode::Standard, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Fulu, + EncodingType::Json, + HeaderValidationMode::Standard, + bid_high, + StatusCode::OK, + ), + ( + ForkName::Fulu, + EncodingType::Ssz, + HeaderValidationMode::Standard, + bid_low, + StatusCode::NO_CONTENT, + ), + ( + ForkName::Fulu, + EncodingType::Ssz, + HeaderValidationMode::Standard, + bid_high, + StatusCode::OK, + ), ]; - for (i, &(fork, encoding, relay_bid, expected_status)) in cases.iter().enumerate() { + for (i, &(fork, encoding, mode, relay_bid, expected_status)) in cases.iter().enumerate() { test_get_header_impl( vec![encoding], HashSet::from([encoding]), 1, + mode, expected_status, relay_bid, min_bid, @@ -395,9 +640,7 @@ async fn test_get_header_bid_validation_matrix() -> Result<()> { fork, ) .await - .map_err(|e| { - eyre::eyre!("case {i} (fork={fork} enc={encoding} bid={relay_bid} min={min_bid}): {e}") - })?; + .map_err(|e| eyre::eyre!("case {i} (fork={fork} enc={encoding} mode={mode:?} bid={relay_bid} min={min_bid}): {e}"))?; } Ok(()) } @@ -484,3 +727,47 @@ async fn test_get_header_tolerates_json_charset_param() -> Result<()> { assert_eq!(body.data.message.header().block_hash().0[0], 1); Ok(()) } + +/// Standard mode rejects a bid whose embedded pubkey does not match the relay's +/// configured pubkey; None mode forwards it unchecked, proving the bypass works +/// for the signature/pubkey validation check. +#[tokio::test] +async fn test_get_header_none_mode_bypasses_pubkey_validation() -> Result<()> { + setup_test_env(); + let chain = Chain::Holesky; + + // The mock relay signs with `signer` and embeds `signer.public_key()` in + // its message, but we register the relay in PBS with a *different* pubkey. + // Standard mode catches this mismatch; None mode does not check. + let signer = random_secret(); + let wrong_pubkey = random_secret().public_key(); + + for (mode, expected_status) in [ + (HeaderValidationMode::Standard, StatusCode::NO_CONTENT), + (HeaderValidationMode::None, StatusCode::OK), + ] { + 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_state = Arc::new(MockRelayState::new(chain, signer.clone())); + // Register with `wrong_pubkey` — PBS will expect this key but the relay + // embeds `signer.public_key()`, causing a mismatch in Standard mode. + let mock_relay = generate_mock_relay(relay_port, wrong_pubkey.clone())?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.header_validation_mode = mode; + 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::new(), ForkName::Electra).await?; + assert_eq!(res.status(), expected_status, "unexpected status for mode {mode:?}"); + } + Ok(()) +} diff --git a/tests/tests/pbs_get_status.rs b/tests/tests/pbs_get_status.rs index cd2ab51d..9f49fb78 100644 --- a/tests/tests/pbs_get_status.rs +++ b/tests/tests/pbs_get_status.rs @@ -3,9 +3,11 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use cb_common::{signer::random_secret, types::Chain}; 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 reqwest::StatusCode; @@ -18,20 +20,24 @@ async fn test_get_status() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3500; - let relay_0_port = pbs_port + 1; - let relay_1_port = pbs_port + 2; + let pbs_listener = get_free_listener().await; + let relay_0_listener = get_free_listener().await; + let relay_1_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_0_port = relay_0_listener.local_addr().unwrap().port(); + let relay_1_port = relay_1_listener.local_addr().unwrap().port(); let relays = vec![ generate_mock_relay(relay_0_port, pubkey.clone())?, generate_mock_relay(relay_1_port, pubkey)?, ]; let mock_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_0_port)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_1_port)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_0_listener)); + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_1_listener)); let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers @@ -54,17 +60,22 @@ async fn test_get_status_returns_502_if_relay_down() -> Result<()> { let pubkey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 3600; - 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(); let relays = vec![generate_mock_relay(relay_port, pubkey)?]; let mock_state = Arc::new(MockRelayState::new(chain, signer)); // 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); let config = to_pbs_config(chain, get_pbs_config(pbs_port), relays.clone()); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index d8ce1356..6d301e00 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -415,7 +415,7 @@ async fn test_ssv_multi_with_node() -> Result<()> { 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(); @@ -522,7 +522,7 @@ async fn test_ssv_multi_with_public() -> Result<()> { 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_post_blinded_blocks.rs b/tests/tests/pbs_post_blinded_blocks.rs index 12cb58e0..aa793aa4 100644 --- a/tests/tests/pbs_post_blinded_blocks.rs +++ b/tests/tests/pbs_post_blinded_blocks.rs @@ -1,6 +1,7 @@ use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration}; use cb_common::{ + config::BlockValidationMode, pbs::{BuilderApiVersion, GetPayloadInfo, PayloadAndBlobs, SubmitBlindedBlockResponse}, signer::random_secret, types::Chain, @@ -27,6 +28,7 @@ async fn test_submit_block_v1() -> Result<()> { HashSet::from([EncodingType::Ssz, EncodingType::Json]), EncodingType::Json, 1, + BlockValidationMode::Standard, StatusCode::OK, false, false, @@ -50,6 +52,7 @@ async fn test_submit_block_v2() -> Result<()> { HashSet::from([EncodingType::Ssz, EncodingType::Json]), EncodingType::Json, 1, + BlockValidationMode::Standard, StatusCode::ACCEPTED, false, false, @@ -71,6 +74,7 @@ async fn test_submit_block_v2_without_relay_support() -> Result<()> { HashSet::from([EncodingType::Ssz, EncodingType::Json]), EncodingType::Json, 1, + BlockValidationMode::Standard, StatusCode::OK, true, false, @@ -87,6 +91,31 @@ async fn test_submit_block_v2_without_relay_support() -> Result<()> { Ok(()) } +// Same guarantee as above, but exercising the unvalidated (light) path. +// In BlockValidationMode::None the v1 body is passed through as raw bytes; +// the v2->v1 fallback must still deliver those bytes to the beacon node. +#[tokio::test] +async fn test_submit_block_v2_without_relay_support_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::None, + StatusCode::OK, + true, + false, + ) + .await?; + let body = res.bytes().await?; + assert!(!body.is_empty(), "v2->v1 fallback (light) must forward a non-empty body"); + // Body is a raw forwarded v1 response — should decode as + // SubmitBlindedBlockResponse. + let _: SubmitBlindedBlockResponse = serde_json::from_slice(&body)?; + Ok(()) +} + // Test that when submitting a block using v2 to a relay that returns 404s // for both v1 and v2, PBS doesn't loop forever. #[tokio::test] @@ -97,6 +126,7 @@ async fn test_submit_block_on_broken_relay() -> Result<()> { HashSet::from([EncodingType::Ssz, EncodingType::Json]), EncodingType::Json, 1, + BlockValidationMode::Standard, StatusCode::BAD_GATEWAY, true, true, @@ -113,6 +143,7 @@ async fn test_submit_block_v1_ssz() -> Result<()> { HashSet::from([EncodingType::Ssz, EncodingType::Json]), EncodingType::Ssz, 1, + BlockValidationMode::Standard, StatusCode::OK, false, false, @@ -137,6 +168,7 @@ async fn test_submit_block_v2_ssz() -> Result<()> { HashSet::from([EncodingType::Ssz, EncodingType::Json]), EncodingType::Ssz, 1, + BlockValidationMode::Standard, StatusCode::ACCEPTED, false, false, @@ -156,6 +188,7 @@ async fn test_submit_block_v1_ssz_into_json() -> Result<()> { HashSet::from([EncodingType::Json]), EncodingType::Ssz, 2, + BlockValidationMode::Standard, StatusCode::OK, false, false, @@ -182,6 +215,7 @@ async fn test_submit_block_v2_ssz_into_json() -> Result<()> { HashSet::from([EncodingType::Json]), EncodingType::Ssz, 2, + BlockValidationMode::Standard, StatusCode::ACCEPTED, false, false, @@ -201,6 +235,7 @@ async fn test_submit_block_v1_multitype_ssz() -> Result<()> { HashSet::from([EncodingType::Ssz]), EncodingType::Ssz, 1, + BlockValidationMode::Standard, StatusCode::OK, false, false, @@ -227,6 +262,158 @@ async fn test_submit_block_v1_multitype_json() -> Result<()> { HashSet::from([EncodingType::Json]), EncodingType::Ssz, 2, + BlockValidationMode::Standard, + 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_v1_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::None, + StatusCode::OK, + false, + false, + ) + .await?; + 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() + ); + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v2_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Json], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + BlockValidationMode::None, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +#[tokio::test] +async fn test_submit_block_v1_ssz_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + BlockValidationMode::None, + 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_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + BlockValidationMode::None, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test that a v1 submit block request in light mode, with SSZ, is converted to +/// JSON if the relay only supports JSON +#[tokio::test] +async fn test_submit_block_v1_ssz_into_json_light() -> Result<()> { + submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::None, + StatusCode::BAD_GATEWAY, + false, + false, + ) + .await?; + Ok(()) +} + +/// Test that a v2 submit block request in light mode, with SSZ, is converted to +/// JSON if the relay only supports JSON +#[tokio::test] +async fn test_submit_block_v2_ssz_into_json_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V2, + vec![EncodingType::Ssz], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::Standard, + StatusCode::ACCEPTED, + false, + false, + ) + .await?; + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test v1 requesting multiple types in light mode when the relay supports SSZ, +/// which should return SSZ +#[tokio::test] +async fn test_submit_block_v1_multitype_ssz_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Ssz]), + EncodingType::Ssz, + 1, + BlockValidationMode::None, StatusCode::OK, false, false, @@ -243,6 +430,32 @@ async fn test_submit_block_v1_multitype_json() -> Result<()> { Ok(()) } +/// Test v1 requesting multiple types in light mode when the relay supports +/// JSON, which should be able to handle an SSZ request by converting to JSON +#[tokio::test] +async fn test_submit_block_v1_multitype_json_light() -> Result<()> { + let res = submit_block_impl( + BuilderApiVersion::V1, + vec![EncodingType::Ssz, EncodingType::Json], + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + BlockValidationMode::None, + StatusCode::OK, + false, + false, + ) + .await?; + 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() + ); + Ok(()) +} + #[tokio::test] async fn test_submit_block_too_large() -> Result<()> { setup_test_env(); @@ -286,6 +499,7 @@ async fn submit_block_impl( relay_types: HashSet, serialization_mode: EncodingType, expected_try_count: u64, + mode: BlockValidationMode, expected_code: StatusCode, remove_v2_support: bool, force_404s: bool, @@ -313,7 +527,8 @@ async fn submit_block_impl( tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service - let pbs_config = get_pbs_config(pbs_port); + let mut pbs_config = get_pbs_config(pbs_port); + pbs_config.block_validation_mode = mode; let config = to_pbs_config(chain, pbs_config, vec![mock_relay]); let state = PbsState::new(config, PathBuf::new()); drop(pbs_listener); diff --git a/tests/tests/pbs_post_validators.rs b/tests/tests/pbs_post_validators.rs index 12601cda..9210502a 100644 --- a/tests/tests/pbs_post_validators.rs +++ b/tests/tests/pbs_post_validators.rs @@ -7,9 +7,11 @@ 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 reqwest::StatusCode; @@ -22,16 +24,20 @@ async fn test_register_validators() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4000; + 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 relays = vec![generate_mock_relay(relay_port, pubkey)?]; 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_with_listener(mock_state.clone(), relay_listener)); // Run the PBS service 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 @@ -68,19 +74,23 @@ async fn test_register_validators_does_not_retry_on_429() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4200; + 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(); // Set up mock relay state and override response to 429 let mock_state = Arc::new(MockRelayState::new(chain, signer)); mock_state.set_response_override(StatusCode::TOO_MANY_REQUESTS); // Run a mock relay - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; + 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 state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); // Leave some time to start servers @@ -121,14 +131,17 @@ async fn test_register_validators_retries_on_500() -> Result<()> { let pubkey: BlsPublicKey = signer.public_key(); let chain = Chain::Holesky; - let pbs_port = 4300; + 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(); // Set up internal mock relay with 500 response override let mock_state = Arc::new(MockRelayState::new(chain, signer)); mock_state.set_response_override(StatusCode::INTERNAL_SERVER_ERROR); // 500 - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + let relays = vec![generate_mock_relay(relay_port, pubkey)?]; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); // Set retry limit to 3 let mut pbs_config = get_pbs_config(pbs_port); @@ -136,6 +149,7 @@ async fn test_register_validators_retries_on_500() -> Result<()> { let config = to_pbs_config(chain, pbs_config, relays); let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state.clone())); tokio::time::sleep(Duration::from_millis(100)).await;