diff --git a/Cargo.lock b/Cargo.lock index 6dd9c8ae..17ec065f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1575,7 +1575,7 @@ dependencies = [ [[package]] name = "cb-bench-pbs" -version = "0.9.0" +version = "0.9.2" dependencies = [ "alloy", "cb-common", @@ -1592,7 +1592,7 @@ dependencies = [ [[package]] name = "cb-cli" -version = "0.9.0" +version = "0.9.2" dependencies = [ "cb-common", "clap", @@ -1604,7 +1604,7 @@ dependencies = [ [[package]] name = "cb-common" -version = "0.9.0" +version = "0.9.2" dependencies = [ "aes 0.8.4", "alloy", @@ -1652,7 +1652,7 @@ dependencies = [ [[package]] name = "cb-metrics" -version = "0.9.0" +version = "0.9.2" dependencies = [ "axum 0.8.4", "cb-common", @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "cb-pbs" -version = "0.9.0" +version = "0.9.2" dependencies = [ "alloy", "async-trait", @@ -1676,6 +1676,7 @@ dependencies = [ "ethereum_ssz", "eyre", "futures", + "headers", "lazy_static", "parking_lot", "prometheus", @@ -1692,7 +1693,7 @@ dependencies = [ [[package]] name = "cb-signer" -version = "0.9.0" +version = "0.9.2" dependencies = [ "alloy", "axum 0.8.4", @@ -1721,7 +1722,7 @@ dependencies = [ [[package]] name = "cb-tests" -version = "0.9.0" +version = "0.9.2" dependencies = [ "alloy", "axum 0.8.4", @@ -1881,7 +1882,7 @@ dependencies = [ [[package]] name = "commit-boost" -version = "0.9.0" +version = "0.9.2" dependencies = [ "cb-cli", "cb-common", @@ -2169,7 +2170,7 @@ dependencies = [ [[package]] name = "da_commit" -version = "0.9.0" +version = "0.9.2" dependencies = [ "alloy", "color-eyre", @@ -6097,7 +6098,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "status_api" -version = "0.9.0" +version = "0.9.2" dependencies = [ "async-trait", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index a4920cca..d94c8cdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2024" rust-version = "1.89" -version = "0.9.0" +version = "0.9.2" [workspace.dependencies] aes = "0.8" diff --git a/crates/common/src/pbs/error.rs b/crates/common/src/pbs/error.rs index 77d942cd..58066c4f 100644 --- a/crates/common/src/pbs/error.rs +++ b/crates/common/src/pbs/error.rs @@ -14,6 +14,9 @@ pub enum PbsError { #[error("json decode error: {err:?}, raw: {raw}")] JsonDecode { err: serde_json::Error, raw: String }, + #[error("error with request: {0}")] + GeneralRequest(String), + #[error("{0}")] ReadResponse(#[from] ResponseReadError), diff --git a/crates/common/src/pbs/mod.rs b/crates/common/src/pbs/mod.rs index af2c07b4..a1152b58 100644 --- a/crates/common/src/pbs/mod.rs +++ b/crates/common/src/pbs/mod.rs @@ -6,5 +6,6 @@ mod types; pub use builder::*; pub use constants::*; +pub use lh_types::ForkVersionDecode; pub use relay::*; pub use types::*; diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 077b4ccd..6d6d55f1 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -233,7 +233,8 @@ impl KnownChain { pub fn fulu_fork_slot(&self) -> u64 { match self { - KnownChain::Mainnet | KnownChain::Helder => u64::MAX, + KnownChain::Mainnet => 13164544, + KnownChain::Helder => u64::MAX, KnownChain::Holesky => 5283840, KnownChain::Sepolia => 8724480, KnownChain::Hoodi => 1622016, diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 291932d8..ddc93e1b 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,6 +1,7 @@ #[cfg(feature = "testing-flags")] use std::cell::Cell; use std::{ + collections::HashSet, fmt::Display, net::Ipv4Addr, str::FromStr, @@ -45,9 +46,9 @@ use crate::{ types::{BlsPublicKey, Chain, Jwt, JwtClaims, ModuleId}, }; -const APPLICATION_JSON: &str = "application/json"; -const APPLICATION_OCTET_STREAM: &str = "application/octet-stream"; -const WILDCARD: &str = "*/*"; +pub const APPLICATION_JSON: &str = "application/json"; +pub const APPLICATION_OCTET_STREAM: &str = "application/octet-stream"; +pub const WILDCARD: &str = "*/*"; const MILLIS_PER_SECOND: u64 = 1_000; pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; @@ -433,36 +434,34 @@ pub fn get_user_agent_with_version(req_headers: &HeaderMap) -> eyre::Result eyre::Result { - let accept = Accept::from_str( - req_headers.get(ACCEPT).and_then(|value| value.to_str().ok()).unwrap_or(APPLICATION_JSON), - ) - .map_err(|e| eyre::eyre!("invalid accept header: {e}"))?; - - if accept.media_types().count() == 0 { - // No valid media types found, default to JSON - return Ok(EncodingType::Json); - } - - // Get the SSZ and JSON media types if present - let mut ssz_type = false; - let mut json_type = false; +pub fn get_accept_types(req_headers: &HeaderMap) -> eyre::Result> { + let mut accepted_types = HashSet::new(); let mut unsupported_type = false; - accept.media_types().for_each(|mt| match mt.essence().to_string().as_str() { - APPLICATION_OCTET_STREAM => ssz_type = true, - APPLICATION_JSON | WILDCARD => json_type = true, - _ => unsupported_type = true, - }); - - // If SSZ is present, prioritize it - if ssz_type { - return Ok(EncodingType::Ssz); + for header in req_headers.get_all(ACCEPT).iter() { + let accept = Accept::from_str(header.to_str()?) + .map_err(|e| eyre::eyre!("invalid accept header: {e}"))?; + for mt in accept.media_types() { + match mt.essence().to_string().as_str() { + APPLICATION_OCTET_STREAM => { + accepted_types.insert(EncodingType::Ssz); + } + APPLICATION_JSON | WILDCARD => { + accepted_types.insert(EncodingType::Json); + } + _ => unsupported_type = true, + }; + } } - // If there aren't any unsupported types, use JSON - if !unsupported_type { - return Ok(EncodingType::Json); + + if accepted_types.is_empty() { + if unsupported_type { + return Err(eyre::eyre!("unsupported accept type")); + } + + // No accept header so just return the same type as the content type + accepted_types.insert(get_content_type(req_headers)); } - Err(eyre::eyre!("unsupported accept type")) + Ok(accepted_types) } /// Parse CONTENT TYPE header to get the encoding type of the body, defaulting @@ -490,7 +489,7 @@ pub fn get_consensus_version_header(req_headers: &HeaderMap) -> Option /// Enum for types that can be used to encode incoming request bodies or /// outgoing response bodies -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EncodingType { /// Body is UTF-8 encoded as JSON Json, @@ -499,21 +498,28 @@ pub enum EncodingType { Ssz, } -impl std::fmt::Display for EncodingType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl EncodingType { + /// Get the content type string for the encoding type + pub fn content_type(&self) -> &str { match self { - EncodingType::Json => write!(f, "application/json"), - EncodingType::Ssz => write!(f, "application/octet-stream"), + EncodingType::Json => APPLICATION_JSON, + EncodingType::Ssz => APPLICATION_OCTET_STREAM, } } } +impl std::fmt::Display for EncodingType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.content_type()) + } +} + impl FromStr for EncodingType { type Err = String; fn from_str(value: &str) -> Result { - match value { - "application/json" | "" => Ok(EncodingType::Json), - "application/octet-stream" => Ok(EncodingType::Ssz), + match value.to_ascii_lowercase().as_str() { + APPLICATION_JSON | "" => Ok(EncodingType::Json), + APPLICATION_OCTET_STREAM => Ok(EncodingType::Ssz), _ => Err(format!("unsupported encoding type: {value}")), } } @@ -636,8 +642,18 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey { #[cfg(test)] mod test { + use axum::http::{HeaderMap, HeaderValue}; + use reqwest::header::ACCEPT; + use super::{create_jwt, decode_jwt, validate_jwt}; - use crate::types::{Jwt, ModuleId}; + use crate::{ + types::{Jwt, ModuleId}, + utils::{ + APPLICATION_JSON, APPLICATION_OCTET_STREAM, EncodingType, WILDCARD, get_accept_types, + }, + }; + + const APPLICATION_TEXT: &str = "application/text"; #[test] fn test_jwt_validation() { @@ -660,4 +676,100 @@ mod test { assert!(response.is_err()); assert_eq!(response.unwrap_err().to_string(), "InvalidSignature"); } + + /// Make sure a missing Accept header is interpreted as JSON + #[test] + fn test_missing_accept_header() { + let headers = HeaderMap::new(); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result.len(), 1); + assert!(result.contains(&EncodingType::Json)); + } + + /// Test accepting JSON + #[test] + fn test_accept_header_json() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_JSON).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result.len(), 1); + assert!(result.contains(&EncodingType::Json)); + } + + /// Test accepting SSZ + #[test] + fn test_accept_header_ssz() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result.len(), 1); + assert!(result.contains(&EncodingType::Ssz)); + } + + /// Test accepting wildcards + #[test] + fn test_accept_header_wildcard() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(WILDCARD).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result.len(), 1); + assert!(result.contains(&EncodingType::Json)); + } + + /// Test accepting one header with multiple values + #[test] + fn test_accept_header_multiple_values() { + let header_string = format!("{APPLICATION_JSON}, {APPLICATION_OCTET_STREAM}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result.len(), 2); + assert!(result.contains(&EncodingType::Json)); + assert!(result.contains(&EncodingType::Ssz)); + } + + /// Test accepting multiple headers + #[test] + fn test_multiple_accept_headers() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_JSON).unwrap()); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_OCTET_STREAM).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result.len(), 2); + assert!(result.contains(&EncodingType::Json)); + assert!(result.contains(&EncodingType::Ssz)); + } + + /// Test accepting one header with multiple values, including a type that + /// can't be used + #[test] + fn test_accept_header_multiple_values_including_unknown() { + let header_string = + format!("{APPLICATION_JSON}, {APPLICATION_OCTET_STREAM}, {APPLICATION_TEXT}"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers).unwrap(); + assert_eq!(result.len(), 2); + assert!(result.contains(&EncodingType::Json)); + assert!(result.contains(&EncodingType::Ssz)); + } + + /// Test rejecting an unknown accept type + #[test] + fn test_invalid_accept_header_type() { + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(APPLICATION_TEXT).unwrap()); + let result = get_accept_types(&headers); + assert!(result.is_err()); + } + + /// Test accepting one header with multiple values + #[test] + fn test_accept_header_invalid_parse() { + let header_string = format!("{APPLICATION_JSON}, a?;ef)"); + let mut headers = HeaderMap::new(); + headers.append(ACCEPT, HeaderValue::from_str(&header_string).unwrap()); + let result = get_accept_types(&headers); + assert!(result.is_err()); + } } diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index 1c5c2f1f..b0c1585e 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -15,6 +15,7 @@ cb-metrics.workspace = true ethereum_ssz.workspace = true eyre.workspace = true futures.workspace = true +headers.workspace = true lazy_static.workspace = true parking_lot.workspace = true prometheus.workspace = true diff --git a/crates/pbs/src/error.rs b/crates/pbs/src/error.rs index 6c1c5c68..1214fd6a 100644 --- a/crates/pbs/src/error.rs +++ b/crates/pbs/src/error.rs @@ -1,4 +1,5 @@ use axum::{http::StatusCode, response::IntoResponse}; +use cb_common::utils::BodyDeserializeError; #[derive(Debug)] /// Errors that the PbsService returns to client @@ -7,6 +8,7 @@ pub enum PbsClientError { NoPayload, Internal, DecodeError(String), + RelayError(String), } impl PbsClientError { @@ -16,10 +18,17 @@ impl PbsClientError { PbsClientError::NoPayload => StatusCode::BAD_GATEWAY, PbsClientError::Internal => StatusCode::INTERNAL_SERVER_ERROR, PbsClientError::DecodeError(_) => StatusCode::BAD_REQUEST, + PbsClientError::RelayError(_) => StatusCode::FAILED_DEPENDENCY, } } } +impl From for PbsClientError { + fn from(e: BodyDeserializeError) -> Self { + PbsClientError::DecodeError(format!("failed to deserialize body: {e}")) + } +} + impl IntoResponse for PbsClientError { fn into_response(self) -> axum::response::Response { let msg = match &self { @@ -27,6 +36,7 @@ impl IntoResponse for PbsClientError { PbsClientError::NoPayload => "no payload from relays".to_string(), PbsClientError::Internal => "internal server error".to_string(), PbsClientError::DecodeError(e) => format!("error decoding request: {e}"), + PbsClientError::RelayError(e) => format!("error processing relay response: {e}"), }; (self.status_code(), msg).into_response() diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index 86743703..e7992c31 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -12,20 +12,24 @@ use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ constants::APPLICATION_BUILDER_DOMAIN, pbs::{ - EMPTY_TX_ROOT_HASH, ExecutionPayloadHeaderRef, GetHeaderInfo, GetHeaderParams, - GetHeaderResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, RelayClient, + EMPTY_TX_ROOT_HASH, ExecutionPayloadHeaderRef, ForkVersionDecode, GetHeaderInfo, + GetHeaderParams, GetHeaderResponse, HEADER_START_TIME_UNIX_MS, HEADER_TIMEOUT_MS, + RelayClient, SignedBuilderBid, error::{PbsError, ValidationError}, }, signature::verify_signed_message, types::{BlsPublicKey, BlsPublicKeyBytes, BlsSignature, Chain}, utils::{ - get_user_agent_with_version, ms_into_slot, read_chunked_body_with_max, - timestamp_of_slot_start_sec, utcnow_ms, + EncodingType, get_accept_types, get_consensus_version_header, get_user_agent_with_version, + ms_into_slot, read_chunked_body_with_max, timestamp_of_slot_start_sec, utcnow_ms, }, }; use futures::future::join_all; use parking_lot::RwLock; -use reqwest::{StatusCode, header::USER_AGENT}; +use reqwest::{ + Response, StatusCode, + header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, +}; use tokio::time::sleep; use tracing::{Instrument, debug, error, warn}; use tree_hash::TreeHash; @@ -97,6 +101,11 @@ pub async fn get_header( let mut send_headers = HeaderMap::new(); send_headers.insert(USER_AGENT, get_user_agent_with_version(&req_headers)?); + // Get the accept types from the request and forward them + for value in req_headers.get_all(ACCEPT).iter() { + send_headers.append(ACCEPT, value.clone()); + } + let mut handles = Vec::with_capacity(relays.len()); for relay in relays.iter() { handles.push( @@ -305,43 +314,73 @@ async fn send_one_get_header( params: GetHeaderParams, relay: RelayClient, chain: Chain, - mut req_config: RequestContext, + req_config: RequestContext, validation: ValidationContext, ) -> Result<(u64, Option), PbsError> { - // the timestamp in the header is the consensus block time which is fixed, - // use the beginning of the request as proxy to make sure we use only the - // last one received - let start_request_time = utcnow_ms(); - req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); + let mut original_headers = req_config.headers.clone(); + + // Check which types this request is for + let accept_types = get_accept_types(&req_config.headers).map_err(|e| { + PbsError::GeneralRequest(format!("error reading accept types: {e}").to_string()) + })?; + let accepts_ssz = accept_types.contains(&EncodingType::Ssz); + let accepts_json = accept_types.contains(&EncodingType::Json); + + // Send the header request + let mut start_request = Instant::now(); + let config = RequestContext { + url: req_config.url.clone(), + timeout_ms: req_config.timeout_ms, + headers: req_config.headers, + }; + let (mut res, mut start_request_time, mut content_type) = + send_get_header_impl(&relay, config).await?; + let mut code = res.status(); - // The timeout header indicating how long a relay has to respond, so they can - // minimize timing games without losing the bid - req_config.headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(req_config.timeout_ms)); + // If the request only supports SSZ, but the relay only supports JSON, resubmit + // to the relay with JSON - we'll convert it ourselves + if code.is_client_error() && accepts_ssz && !accepts_json { + debug!( + relay_id = relay.id.as_ref(), + "relay does not support SSZ, resubmitting request with JSON accept header" + ); - let start_request = Instant::now(); - let res = match relay - .client - .get(req_config.url) - .timeout(Duration::from_millis(req_config.timeout_ms)) - .headers(req_config.headers) - .send() - .await - { - Ok(res) => res, - Err(err) => { + // Make sure there's enough time left to resubmit + let elapsed = start_request.elapsed().as_millis() as u64; + if elapsed >= req_config.timeout_ms { RELAY_STATUS_CODE .with_label_values(&[TIMEOUT_ERROR_CODE_STR, GET_HEADER_ENDPOINT_TAG, &relay.id]) .inc(); - return Err(err.into()); + return Err(PbsError::RelayResponse { + error_msg: "not enough time left to resubmit request with JSON accept header" + .to_string(), + code: TIMEOUT_ERROR_CODE, + }); } - }; + + // Resubmit the request with JSON accept header + // Also resets the start request timer + original_headers + .insert(ACCEPT, HeaderValue::from_str(EncodingType::Json.content_type()).unwrap()); + let config = RequestContext { + url: req_config.url.clone(), + timeout_ms: req_config.timeout_ms - elapsed, + headers: original_headers, + }; + start_request = Instant::now(); + (res, start_request_time, content_type) = send_get_header_impl(&relay, config).await?; + code = res.status(); + } + + // Get the consensus fork version if provided (to avoid cloning later) + let fork = get_consensus_version_header(res.headers()); + let content_type_header = res.headers().get(CONTENT_TYPE).cloned(); 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(); let response_bytes = read_chunked_body_with_max(res, MAX_SIZE_GET_HEADER_RESPONSE).await?; @@ -363,15 +402,42 @@ async fn send_one_get_header( return Ok((start_request_time, None)); } - let get_header_response = match serde_json::from_slice::(&response_bytes) { - Ok(parsed) => parsed, - Err(err) => { - return Err(PbsError::JsonDecode { - err, - raw: String::from_utf8_lossy(&response_bytes).into_owned(), - }); - } - }; + // Regenerate the header from the response + let get_header_response = + match content_type { + Some(EncodingType::Ssz) => { + // Get the consensus fork version - this is required according to the spec + let fork = fork.ok_or(PbsError::RelayResponse { + error_msg: "relay did not provide consensus version header for ssz payload" + .to_string(), + code: code.as_u16(), + })?; + let data = SignedBuilderBid::from_ssz_bytes_by_fork(&response_bytes, fork) + .map_err(|e| PbsError::RelayResponse { + error_msg: (format!("error decoding relay payload: {e:?}")).to_string(), + code: (code.as_u16()), + })?; + GetHeaderResponse { version: fork, data, metadata: Default::default() } + } + Some(EncodingType::Json) => { + match serde_json::from_slice::(&response_bytes) { + Ok(parsed) => parsed, + Err(err) => { + return Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(&response_bytes).into_owned(), + }); + } + } + } + None => { + let error_msg = match content_type_header { + None => "relay response missing content type header".to_string(), + Some(ct) => format!("relay response has unsupported content type {ct:?}"), + }; + return Err(PbsError::RelayResponse { error_msg, code: code.as_u16() }); + } + }; debug!( relay_id = relay.id.as_ref(), @@ -380,6 +446,7 @@ async fn send_one_get_header( version =? get_header_response.version, value_eth = format_ether(*get_header_response.value()), block_hash = %get_header_response.block_hash(), + content_type = ?content_type, "received new header" ); @@ -461,6 +528,56 @@ async fn send_one_get_header( Ok((start_request_time, Some(get_header_response))) } +async fn send_get_header_impl( + relay: &RelayClient, + mut req_config: RequestContext, +) -> Result<(Response, u64, Option), PbsError> { + // the timestamp in the header is the consensus block time which is fixed, + // use the beginning of the request as proxy to make sure we use only the + // last one received + let start_request_time = utcnow_ms(); + req_config.headers.insert(HEADER_START_TIME_UNIX_MS, HeaderValue::from(start_request_time)); + + // The timeout header indicating how long a relay has to respond, so they can + // minimize timing games without losing the bid + req_config.headers.insert(HEADER_TIMEOUT_MS, HeaderValue::from(req_config.timeout_ms)); + + let res = match relay + .client + .get(req_config.url) + .timeout(Duration::from_millis(req_config.timeout_ms)) + .headers(req_config.headers) + .send() + .await + { + Ok(res) => res, + Err(err) => { + RELAY_STATUS_CODE + .with_label_values(&[TIMEOUT_ERROR_CODE_STR, GET_HEADER_ENDPOINT_TAG, &relay.id]) + .inc(); + return Err(err.into()); + } + }; + + // Get the content type; this is only really useful for OK responses, and + // doesn't handle encoding types besides SSZ and JSON + let mut content_type: Option = None; + if res.status() == StatusCode::OK && + let Some(header) = res.headers().get(CONTENT_TYPE) + { + let header_str = header.to_str().map_err(|e| PbsError::RelayResponse { + error_msg: format!("cannot decode content-type header: {e}").to_string(), + code: (res.status().as_u16()), + })?; + if header_str.eq_ignore_ascii_case(&EncodingType::Ssz.to_string()) { + content_type = Some(EncodingType::Ssz) + } else if header_str.eq_ignore_ascii_case(&EncodingType::Json.to_string()) { + content_type = Some(EncodingType::Json) + } + } + Ok((res, start_request_time, content_type)) +} + struct HeaderData { block_hash: B256, parent_hash: B256, diff --git a/crates/pbs/src/mev_boost/submit_block.rs b/crates/pbs/src/mev_boost/submit_block.rs index 2b10dcaa..a4666949 100644 --- a/crates/pbs/src/mev_boost/submit_block.rs +++ b/crates/pbs/src/mev_boost/submit_block.rs @@ -8,22 +8,28 @@ use alloy::{eips::eip7594::CELLS_PER_EXT_BLOB, primitives::B256}; use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ pbs::{ - BlindedBeaconBlock, BlobsBundle, BuilderApiVersion, ForkName, HEADER_CONSENSUS_VERSION, - HEADER_START_TIME_UNIX_MS, KzgCommitments, RelayClient, SignedBlindedBeaconBlock, - SubmitBlindedBlockResponse, + BlindedBeaconBlock, BlobsBundle, BuilderApiVersion, ForkName, ForkVersionDecode, + HEADER_CONSENSUS_VERSION, HEADER_START_TIME_UNIX_MS, KzgCommitments, PayloadAndBlobs, + RelayClient, SignedBlindedBeaconBlock, SubmitBlindedBlockResponse, error::{PbsError, ValidationError}, }, - utils::{get_user_agent_with_version, read_chunked_body_with_max, utcnow_ms}, + utils::{ + EncodingType, get_accept_types, get_content_type, get_user_agent_with_version, + read_chunked_body_with_max, utcnow_ms, + }, }; use futures::{FutureExt, future::select_ok}; -use reqwest::header::USER_AGENT; +use reqwest::{ + Response, StatusCode, + header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, +}; +use ssz::Encode; use tracing::{debug, warn}; use url::Url; use crate::{ - constants::{ - MAX_SIZE_SUBMIT_BLOCK_RESPONSE, SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, TIMEOUT_ERROR_CODE_STR, - }, + TIMEOUT_ERROR_CODE_STR, + constants::{MAX_SIZE_SUBMIT_BLOCK_RESPONSE, SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG}, metrics::{RELAY_LATENCY, RELAY_STATUS_CODE}, state::{BuilderApiState, PbsState}, }; @@ -59,6 +65,17 @@ pub async fn submit_block( send_headers.insert(USER_AGENT, get_user_agent_with_version(&req_headers)?); send_headers.insert(HEADER_CONSENSUS_VERSION, consensus_version); + // Get the accept types from the request and forward them + for value in req_headers.get_all(ACCEPT).iter() { + send_headers.append(ACCEPT, value.clone()); + } + + // Copy the content type header + send_headers.insert( + CONTENT_TYPE, + HeaderValue::from_str(get_content_type(&req_headers).content_type()).unwrap(), + ); + let mut handles = Vec::with_capacity(state.all_relays().len()); for relay in state.all_relays().iter().cloned() { handles.push( @@ -155,34 +172,55 @@ async fn send_submit_block( api_version: &BuilderApiVersion, fork_name: ForkName, ) -> Result, PbsError> { - let start_request = Instant::now(); - let res = match relay - .client - .post(url) - .timeout(Duration::from_millis(timeout_ms)) - .headers(headers) - .json(&signed_blinded_block) - .send() - .await - { - Ok(res) => res, - Err(err) => { - RELAY_STATUS_CODE - .with_label_values(&[ - TIMEOUT_ERROR_CODE_STR, - SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, - &relay.id, - ]) - .inc(); - return Err(err.into()); - } - }; + let mut original_headers = headers.clone(); + + // Check which types this request is for + let accept_types = get_accept_types(&headers).map_err(|e| { + PbsError::GeneralRequest(format!("error reading accept types: {e}").to_string()) + })?; + let accepts_ssz = accept_types.contains(&EncodingType::Ssz); + let accepts_json = accept_types.contains(&EncodingType::Json); + + // Send the request + let mut start_request = Instant::now(); + let (mut res, mut content_type) = + send_submit_block_impl(url.clone(), signed_blinded_block, relay, headers, timeout_ms) + .await?; + let mut code = res.status(); + + // If the request only supports SSZ, but the relay only supports JSON, resubmit + // to the relay with JSON - we'll convert it ourselves + if code.is_client_error() && accepts_ssz && !accepts_json { + debug!( + relay_id = relay.id.as_ref(), + "relay does not support SSZ, resubmitting request with JSON accept and content-type" + ); + + // Resubmit the request with JSON accept and content-type headers + let elapsed = start_request.elapsed().as_millis() as u64; + let json_header_value = HeaderValue::from_str(EncodingType::Json.content_type()).unwrap(); + original_headers.insert(ACCEPT, json_header_value.clone()); + original_headers.insert(CONTENT_TYPE, json_header_value); + start_request = Instant::now(); + (res, content_type) = send_submit_block_impl( + url, + signed_blinded_block, + relay, + original_headers, + timeout_ms - elapsed, + ) + .await?; + code = res.status(); + } + + // Get the consensus fork version if provided (to avoid cloning later) + let content_type_header = res.headers().get(CONTENT_TYPE).cloned(); + let request_latency = start_request.elapsed(); RELAY_LATENCY .with_label_values(&[SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, &relay.id]) .observe(request_latency.as_secs_f64()); - let code = res.status(); RELAY_STATUS_CODE .with_label_values(&[code.as_str(), SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, &relay.id]) .inc(); @@ -211,14 +249,33 @@ async fn send_submit_block( return Ok(None); } - let block_response = match serde_json::from_slice::(&response_bytes) - { - Ok(parsed) => parsed, - Err(err) => { - return Err(PbsError::JsonDecode { - err, - raw: String::from_utf8_lossy(&response_bytes).into_owned(), - }); + // Regenerate the block from the response + let block_response = match content_type { + Some(EncodingType::Ssz) => { + let data = PayloadAndBlobs::from_ssz_bytes_by_fork(&response_bytes, fork_name) + .map_err(|e| PbsError::RelayResponse { + error_msg: (format!("error decoding relay payload: {e:?}")).to_string(), + code: (code.as_u16()), + })?; + SubmitBlindedBlockResponse { version: fork_name, data, metadata: Default::default() } + } + Some(EncodingType::Json) => { + match serde_json::from_slice::(&response_bytes) { + Ok(parsed) => parsed, + Err(err) => { + return Err(PbsError::JsonDecode { + err, + raw: String::from_utf8_lossy(&response_bytes).into_owned(), + }); + } + } + } + None => { + let error_msg = match content_type_header { + None => "relay response missing content type header".to_string(), + Some(ct) => format!("relay response has unsupported content type {ct:?}"), + }; + return Err(PbsError::RelayResponse { error_msg, code: code.as_u16() }); } }; @@ -269,6 +326,55 @@ async fn send_submit_block( Ok(Some(block_response)) } +async fn send_submit_block_impl( + url: Url, + signed_blinded_block: &SignedBlindedBeaconBlock, + relay: &RelayClient, + headers: HeaderMap, + timeout_ms: u64, +) -> Result<(Response, Option), PbsError> { + // Get the content type of the request + let content_type = get_content_type(&headers); + + // Send the request + let res = relay.client.post(url).timeout(Duration::from_millis(timeout_ms)).headers(headers); + let body = match content_type { + EncodingType::Json => serde_json::to_vec(&signed_blinded_block).unwrap(), + EncodingType::Ssz => signed_blinded_block.as_ssz_bytes(), + }; + let res = match res.body(body).header(CONTENT_TYPE, &content_type.to_string()).send().await { + Ok(res) => res, + Err(err) => { + RELAY_STATUS_CODE + .with_label_values(&[ + TIMEOUT_ERROR_CODE_STR, + SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG, + &relay.id, + ]) + .inc(); + return Err(err.into()); + } + }; + + // Get the content type; this is only really useful for OK responses, and + // doesn't handle encoding types besides SSZ and JSON + let mut content_type: Option = None; + if res.status() == StatusCode::OK && + let Some(header) = res.headers().get(CONTENT_TYPE) + { + let header_str = header.to_str().map_err(|e| PbsError::RelayResponse { + error_msg: format!("cannot decode content-type header: {e}").to_string(), + code: (res.status().as_u16()), + })?; + if header_str.eq_ignore_ascii_case(&EncodingType::Ssz.to_string()) { + content_type = Some(EncodingType::Ssz) + } else if header_str.eq_ignore_ascii_case(&EncodingType::Json.to_string()) { + content_type = Some(EncodingType::Json) + } + } + Ok((res, content_type)) +} + fn validate_unblinded_block( expected_block_hash: B256, got_block_hash: B256, diff --git a/crates/pbs/src/routes/get_header.rs b/crates/pbs/src/routes/get_header.rs index ca8d2d7c..896ab781 100644 --- a/crates/pbs/src/routes/get_header.rs +++ b/crates/pbs/src/routes/get_header.rs @@ -7,7 +7,7 @@ use axum::{ use cb_common::{ pbs::{GetHeaderInfo, GetHeaderParams}, utils::{ - CONSENSUS_VERSION_HEADER, EncodingType, get_accept_type, get_user_agent, ms_into_slot, + CONSENSUS_VERSION_HEADER, EncodingType, get_accept_types, get_user_agent, ms_into_slot, }, }; use reqwest::{StatusCode, header::CONTENT_TYPE}; @@ -35,14 +35,10 @@ pub async fn handle_get_header>( let ua = get_user_agent(&req_headers); let ms_into_slot = ms_into_slot(params.slot, state.config.chain); - let accept_type = get_accept_type(&req_headers).map_err(|e| { + let accept_types = get_accept_types(&req_headers).map_err(|e| { error!(%e, "error parsing accept header"); PbsClientError::DecodeError(format!("error parsing accept header: {e}")) - }); - if let Err(e) = accept_type { - return Ok((StatusCode::BAD_REQUEST, e).into_response()); - } - let accept_type = accept_type.unwrap(); + })?; info!(ua, ms_into_slot, "new request"); @@ -52,30 +48,50 @@ pub async fn handle_get_header>( info!(value_eth = format_ether(*max_bid.data.message.value()), block_hash =% max_bid.block_hash(), "received header"); BEACON_NODE_STATUS.with_label_values(&["200", GET_HEADER_ENDPOINT_TAG]).inc(); - let response = match accept_type { - EncodingType::Ssz => { - let mut res = max_bid.data.as_ssz_bytes().into_response(); - let Ok(consensus_version_header) = - HeaderValue::from_str(&max_bid.version.to_string()) - else { - info!("sending response as JSON"); - return Ok((StatusCode::OK, axum::Json(max_bid)).into_response()); - }; - let Ok(content_type_header) = - HeaderValue::from_str(&format!("{}", EncodingType::Ssz)) - else { - info!("sending response as JSON"); - return Ok((StatusCode::OK, axum::Json(max_bid)).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"); - res - } - EncodingType::Json => (StatusCode::OK, axum::Json(max_bid)).into_response(), - }; - Ok(response) + + let accepts_ssz = accept_types.contains(&EncodingType::Ssz); + let accepts_json = accept_types.contains(&EncodingType::Json); + + // Handle SSZ + if accepts_ssz { + let mut res = max_bid.data.as_ssz_bytes().into_response(); + let consensus_version_header = match HeaderValue::from_str( + &max_bid.version.to_string(), + ) { + Ok(consensus_version_header) => { + Ok::(consensus_version_header) + } + Err(e) => { + if accepts_json { + info!("sending response as JSON"); + return Ok((StatusCode::OK, axum::Json(max_bid)).into_response()); + } else { + return Err(PbsClientError::RelayError(format!( + "error decoding consensus version from relay payload: {e}" + ))); + } + } + }?; + + // This won't actually fail since the string is a const + let content_type_header = + HeaderValue::from_str(EncodingType::Ssz.content_type()).unwrap(); + + res.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + res.headers_mut().insert(CONTENT_TYPE, content_type_header); + info!("sending response as SSZ"); + return Ok(res); + } + + // Handle JSON + if accepts_json { + Ok((StatusCode::OK, axum::Json(max_bid)).into_response()) + } else { + // This shouldn't ever happen but the compiler needs it + Err(PbsClientError::DecodeError( + "no viable accept types in request".to_string(), + )) + } } else { // spec: return 204 if request is valid but no bid available info!("no header available for slot"); diff --git a/crates/pbs/src/routes/submit_block.rs b/crates/pbs/src/routes/submit_block.rs index 1134b462..539ce631 100644 --- a/crates/pbs/src/routes/submit_block.rs +++ b/crates/pbs/src/routes/submit_block.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use axum::{ - Json, extract::State, http::{HeaderMap, HeaderValue}, response::IntoResponse, @@ -9,8 +8,8 @@ use axum::{ use cb_common::{ pbs::{BuilderApiVersion, GetPayloadInfo}, utils::{ - CONSENSUS_VERSION_HEADER, EncodingType, RawRequest, deserialize_body, get_accept_type, - get_user_agent, timestamp_of_slot_start_millis, utcnow_ms + EncodingType, RawRequest, deserialize_body, get_accept_types, get_user_agent, + timestamp_of_slot_start_millis, utcnow_ms, }, }; use reqwest::{StatusCode, header::CONTENT_TYPE}; @@ -47,11 +46,8 @@ async fn handle_submit_block_impl>( raw_request: RawRequest, api_version: BuilderApiVersion, ) -> Result { - let signed_blinded_block = Arc::new( - deserialize_body(&req_headers, raw_request.body_bytes).await.map_err(|e| { - error!(%e, "failed to deserialize signed blinded block"); - PbsClientError::DecodeError(format!("failed to deserialize body: {e}")) - })?); + let signed_blinded_block = + Arc::new(deserialize_body(&req_headers, raw_request.body_bytes).await?); tracing::Span::current().record("slot", signed_blinded_block.slot().as_u64() as i64); tracing::Span::current() .record("block_hash", tracing::field::debug(signed_blinded_block.block_hash())); @@ -66,14 +62,14 @@ async fn handle_submit_block_impl>( let block_hash = signed_blinded_block.block_hash(); let slot_start_ms = timestamp_of_slot_start_millis(slot.into(), state.config.chain); let ua = get_user_agent(&req_headers); - let response_type = get_accept_type(&req_headers).map_err(|e| { + let response_types = get_accept_types(&req_headers).map_err(|e| { error!(%e, "error parsing accept header"); PbsClientError::DecodeError(format!("error parsing accept header: {e}")) }); - if let Err(e) = response_type { - return Ok((StatusCode::BAD_REQUEST, e.into_response())); + if let Err(e) = response_types { + return Ok((StatusCode::BAD_REQUEST, e).into_response()); } - let response_type = response_type.unwrap(); + let response_types = response_types.unwrap(); info!(ua, ms_into_slot = now.saturating_sub(slot_start_ms), "new request"); @@ -86,41 +82,31 @@ async fn handle_submit_block_impl>( BEACON_NODE_STATUS .with_label_values(&["200", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) .inc(); - let response = match response_type { - EncodingType::Json => { - info!("sending response as JSON"); - Json(payload_and_blobs).into_response() - } - EncodingType::Ssz => { - let mut response = payload_and_blobs.data.as_ssz_bytes().into_response(); - let Ok(consensus_version_header) = - HeaderValue::from_str(&payload_and_blobs.version.to_string()) - else { - info!("sending response as JSON"); - return Ok(( - StatusCode::OK, - axum::Json(payload_and_blobs).into_response(), - )); - }; - let Ok(content_type_header) = - HeaderValue::from_str(&EncodingType::Ssz.to_string()) - else { - info!("sending response as JSON"); - return Ok(( - StatusCode::OK, - axum::Json(payload_and_blobs).into_response(), - )); - }; - response - .headers_mut() - .insert(CONSENSUS_VERSION_HEADER, consensus_version_header); - response.headers_mut().insert(CONTENT_TYPE, content_type_header); - info!("sending response as SSZ"); - response - } - }; - - Ok((StatusCode::OK, response)) + + let accepts_ssz = response_types.contains(&EncodingType::Ssz); + let accepts_json = response_types.contains(&EncodingType::Json); + + // Try SSZ + if accepts_ssz { + let mut response = payload_and_blobs.data.as_ssz_bytes().into_response(); + + // This won't actually fail since the string is a const + let content_type_header = + HeaderValue::from_str(EncodingType::Ssz.content_type()).unwrap(); + response.headers_mut().insert(CONTENT_TYPE, content_type_header); + info!("sending response as SSZ"); + return Ok(response); + } + + // Handle JSON + if accepts_json { + Ok((StatusCode::OK, axum::Json(payload_and_blobs)).into_response()) + } else { + // This shouldn't ever happen but the compiler needs it + Err(PbsClientError::DecodeError( + "no viable accept types in request".to_string(), + )) + } } None => { info!("received unblinded block (v2)"); @@ -130,7 +116,7 @@ async fn handle_submit_block_impl>( BEACON_NODE_STATUS .with_label_values(&["202", SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG]) .inc(); - Ok((StatusCode::ACCEPTED, "".into_response())) + Ok((StatusCode::ACCEPTED, "").into_response()) } }, diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index 5611a1eb..7a55ca26 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, net::SocketAddr, sync::{ Arc, RwLock, @@ -26,7 +27,8 @@ use cb_common::{ types::{BlsSecretKey, Chain}, utils::{ CONSENSUS_VERSION_HEADER, EncodingType, RawRequest, TestRandomSeed, deserialize_body, - get_accept_type, get_consensus_version_header, timestamp_of_slot_start_sec, + get_accept_types, get_consensus_version_header, get_content_type, + timestamp_of_slot_start_sec, }, }; use cb_pbs::MAX_SIZE_SUBMIT_BLOCK_RESPONSE; @@ -50,6 +52,7 @@ pub async fn start_mock_relay_service(state: Arc, port: u16) -> pub struct MockRelayState { pub chain: Chain, pub signer: BlsSecretKey, + pub supported_content_types: Arc>, large_body: bool, received_get_header: Arc, received_get_status: Arc, @@ -90,6 +93,9 @@ impl MockRelayState { received_register_validator: Default::default(), received_submit_block: Default::default(), response_override: RwLock::new(None), + supported_content_types: Arc::new( + [EncodingType::Json, EncodingType::Ssz].iter().cloned().collect(), + ), } } @@ -118,15 +124,28 @@ async fn handle_get_header( headers: HeaderMap, ) -> Response { state.received_get_header.fetch_add(1, Ordering::Relaxed); - let accept_type = get_accept_type(&headers) + let accept_types = get_accept_types(&headers) .map_err(|e| (StatusCode::BAD_REQUEST, format!("error parsing accept header: {e}"))); - if let Err(e) = accept_type { + if let Err(e) = accept_types { return e.into_response(); } - let accept_header = accept_type.unwrap(); + let accept_types = accept_types.unwrap(); let consensus_version_header = get_consensus_version_header(&headers).unwrap_or(ForkName::Electra); + let content_type = if state.supported_content_types.contains(&EncodingType::Ssz) && + accept_types.contains(&EncodingType::Ssz) + { + EncodingType::Ssz + } else if state.supported_content_types.contains(&EncodingType::Json) && + accept_types.contains(&EncodingType::Json) + { + EncodingType::Json + } else { + return (StatusCode::NOT_ACCEPTABLE, "No acceptable content type found".to_string()) + .into_response(); + }; + let data = match consensus_version_header { // Add Fusaka and other forks here when necessary ForkName::Electra => { @@ -150,16 +169,16 @@ async fn handle_get_header( let object_root = message.tree_hash_root(); let signature = sign_builder_root(state.chain, &state.signer, object_root); let response = SignedBuilderBid { message, signature }; - match accept_header { - EncodingType::Json => { - let versioned_response = GetHeaderResponse { - version: ForkName::Electra, - data: response, - metadata: Default::default(), - }; - serde_json::to_vec(&versioned_response).unwrap() - } - EncodingType::Ssz => response.as_ssz_bytes(), + if content_type == EncodingType::Ssz { + response.as_ssz_bytes() + } else { + // Return JSON for everything else; this is fine for the mock + let versioned_response = GetHeaderResponse { + version: ForkName::Electra, + data: response, + metadata: Default::default(), + }; + serde_json::to_vec(&versioned_response).unwrap() } } _ => { @@ -174,7 +193,7 @@ async fn handle_get_header( let mut response = (StatusCode::OK, data).into_response(); let consensus_version_header = HeaderValue::from_str(&consensus_version_header.to_string()).unwrap(); - let content_type_header = HeaderValue::from_str(&accept_header.to_string()).unwrap(); + let content_type_header = HeaderValue::from_str(&content_type.to_string()).unwrap(); response.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); response.headers_mut().insert(CONTENT_TYPE, content_type_header); response @@ -205,15 +224,24 @@ async fn handle_submit_block_v1( raw_request: RawRequest, ) -> Response { state.received_submit_block.fetch_add(1, Ordering::Relaxed); - let accept_header = get_accept_type(&headers); - if let Err(e) = accept_header { - error!(%e, "error parsing accept header"); - return (StatusCode::BAD_REQUEST, format!("error parsing accept header: {e}")) - .into_response(); + let accept_types = get_accept_types(&headers) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("error parsing accept header: {e}"))); + if let Err(e) = accept_types { + return e.into_response(); } - let accept_header = accept_header.unwrap(); - let consensus_version_header = - get_consensus_version_header(&headers).unwrap_or(ForkName::Electra); + let accept_types = accept_types.unwrap(); + let content_type = if state.supported_content_types.contains(&EncodingType::Ssz) && + accept_types.contains(&EncodingType::Ssz) + { + EncodingType::Ssz + } else if state.supported_content_types.contains(&EncodingType::Json) && + accept_types.contains(&EncodingType::Json) + { + EncodingType::Json + } else { + return (StatusCode::NOT_ACCEPTABLE, "No acceptable content type found".to_string()) + .into_response(); + }; let data = if state.large_body() { vec![1u8; 1 + MAX_SIZE_SUBMIT_BLOCK_RESPONSE] @@ -239,40 +267,34 @@ async fn handle_submit_block_v1( let response = PayloadAndBlobs { execution_payload: execution_payload.into(), blobs_bundle }; - match accept_header { - EncodingType::Json => { - // Response is versioned for JSON - let response = SubmitBlindedBlockResponse { - version: ForkName::Electra, - metadata: Default::default(), - data: response, - }; - serde_json::to_vec(&response).unwrap() - } - EncodingType::Ssz => match consensus_version_header { - // Response isn't versioned for SSZ - ForkName::Electra => response.as_ssz_bytes(), - _ => { - return ( - StatusCode::BAD_REQUEST, - format!("Unsupported fork {consensus_version_header}"), - ) - .into_response(); - } - }, + if content_type == EncodingType::Ssz { + response.as_ssz_bytes() + } else { + // Return JSON for everything else; this is fine for the mock + let response = SubmitBlindedBlockResponse { + version: ForkName::Electra, + metadata: Default::default(), + data: response, + }; + serde_json::to_vec(&response).unwrap() } }; let mut response = (StatusCode::OK, data).into_response(); - let consensus_version_header = - HeaderValue::from_str(&consensus_version_header.to_string()).unwrap(); - let content_type_header = HeaderValue::from_str(&accept_header.to_string()).unwrap(); - response.headers_mut().insert(CONSENSUS_VERSION_HEADER, consensus_version_header); + let content_type_header = HeaderValue::from_str(&content_type.to_string()).unwrap(); response.headers_mut().insert(CONTENT_TYPE, content_type_header); response } -async fn handle_submit_block_v2(State(state): State>) -> Response { +async fn handle_submit_block_v2( + headers: HeaderMap, + State(state): State>, +) -> Response { state.received_submit_block.fetch_add(1, Ordering::Relaxed); + let content_type = get_content_type(&headers); + if !state.supported_content_types.contains(&content_type) { + return (StatusCode::NOT_ACCEPTABLE, "No acceptable content type found".to_string()) + .into_response(); + }; (StatusCode::ACCEPTED, "").into_response() } diff --git a/tests/src/mock_validator.rs b/tests/src/mock_validator.rs index 80aed0c2..092b97a5 100644 --- a/tests/src/mock_validator.rs +++ b/tests/src/mock_validator.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use alloy::{primitives::B256, rpc::types::beacon::relay::ValidatorRegistration}; use cb_common::{ pbs::{BuilderApiVersion, RelayClient, SignedBlindedBeaconBlock}, @@ -27,7 +29,7 @@ impl MockValidator { pub async fn do_get_header( &self, pubkey: Option, - accept: Option, + accept: HashSet, fork_name: ForkName, ) -> eyre::Result { let default_pubkey = bls_pubkey_from_hex( @@ -35,14 +37,24 @@ impl MockValidator { )?; let url = self.comm_boost.get_header_url(0, &B256::ZERO, &pubkey.unwrap_or(default_pubkey))?; - let res = self + let accept = match accept.len() { + 0 => None, + 1 => Some(accept.into_iter().next().unwrap().to_string()), + _ => { + let accept_strings: Vec = + accept.into_iter().map(|e| e.to_string()).collect(); + Some(accept_strings.join(", ")) + } + }; + let mut res = self .comm_boost .client .get(url) - .header(ACCEPT, &accept.unwrap_or(EncodingType::Json).to_string()) - .header(CONSENSUS_VERSION_HEADER, &fork_name.to_string()) - .send() - .await?; + .header(CONSENSUS_VERSION_HEADER, &fork_name.to_string()); + if let Some(accept_header) = accept { + res = res.header(ACCEPT, accept_header); + } + let res = res.send().await?; Ok(res) } @@ -67,7 +79,7 @@ impl MockValidator { pub async fn do_submit_block_v1( &self, signed_blinded_block_opt: Option, - accept: EncodingType, + accept: HashSet, content_type: EncodingType, fork_name: ForkName, ) -> eyre::Result { @@ -84,7 +96,7 @@ impl MockValidator { pub async fn do_submit_block_v2( &self, signed_blinded_block_opt: Option, - accept: EncodingType, + accept: HashSet, content_type: EncodingType, fork_name: ForkName, ) -> eyre::Result { @@ -101,7 +113,7 @@ impl MockValidator { async fn do_submit_block_impl( &self, signed_blinded_block_opt: Option, - accept: EncodingType, + accept: HashSet, content_type: EncodingType, fork_name: ForkName, api_version: BuilderApiVersion, @@ -115,16 +127,27 @@ impl MockValidator { EncodingType::Ssz => signed_blinded_block.as_ssz_bytes(), }; - Ok(self + let accept = match accept.len() { + 0 => None, + 1 => Some(accept.into_iter().next().unwrap().to_string()), + _ => { + let accept_strings: Vec = + accept.into_iter().map(|e| e.to_string()).collect(); + Some(accept_strings.join(", ")) + } + }; + let mut res = self .comm_boost .client .post(url) .body(body) .header(CONSENSUS_VERSION_HEADER, &fork_name.to_string()) - .header(CONTENT_TYPE, &content_type.to_string()) - .header(ACCEPT, &accept.to_string()) - .send() - .await?) + .header(CONTENT_TYPE, &content_type.to_string()); + if let Some(accept_header) = accept { + res = res.header(ACCEPT, accept_header); + } + let res = res.send().await?; + Ok(res) } } diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index eebb0113..67fa5f30 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{collections::HashSet, sync::Arc, time::Duration}; use alloy::primitives::{B256, U256}; use cb_common::{ @@ -15,42 +15,136 @@ use cb_tests::{ utils::{generate_mock_relay, get_pbs_static_config, setup_test_env, to_pbs_config}, }; use eyre::Result; -use lh_types::ForkVersionDecode; +use lh_types::{ForkVersionDecode, beacon_response::EmptyMetadata}; use reqwest::StatusCode; use tracing::info; use tree_hash::TreeHash; +/// Test requesting JSON when the relay supports JSON #[tokio::test] async fn test_get_header() -> Result<()> { + test_get_header_impl( + 3200, + HashSet::from([EncodingType::Json]), + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + ) + .await +} + +/// Test requesting SSZ when the relay supports SSZ +#[tokio::test] +async fn test_get_header_ssz() -> Result<()> { + test_get_header_impl( + 3210, + HashSet::from([EncodingType::Ssz]), + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + 1, + ) + .await +} + +/// Test requesting SSZ when the relay only supports JSON, which should cause +/// PBS to retry internally with JSON +#[tokio::test] +async fn test_get_header_ssz_into_json() -> Result<()> { + test_get_header_impl( + 3220, + HashSet::from([EncodingType::Ssz]), + HashSet::from([EncodingType::Json]), + 2, + ) + .await +} + +/// Test requesting multiple types when the relay supports SSZ, which should +/// return SSZ +#[tokio::test] +async fn test_get_header_multitype_ssz() -> Result<()> { + test_get_header_impl( + 3230, + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + HashSet::from([EncodingType::Ssz]), + 1, + ) + .await +} + +/// Test requesting multiple types when the relay supports JSON, which should +/// still work +#[tokio::test] +async fn test_get_header_multitype_json() -> Result<()> { + test_get_header_impl( + 3240, + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + HashSet::from([EncodingType::Json]), + 1, + ) + .await +} + +/// Core implementation for get_header tests +async fn test_get_header_impl( + pbs_port: u16, + accept_types: HashSet, + relay_types: HashSet, + expected_try_count: u64, +) -> Result<()> { + // Setup test environment setup_test_env(); let signer = random_secret(); let pubkey = signer.public_key(); - let chain = Chain::Holesky; - let pbs_port = 3200; let relay_port = pbs_port + 1; // Run a mock relay - let mock_state = Arc::new(MockRelayState::new(chain, signer)); + let mut mock_state = MockRelayState::new(chain, signer); + mock_state.supported_content_types = Arc::new(relay_types); + let mock_state = Arc::new(mock_state); let mock_relay = generate_mock_relay(relay_port, pubkey)?; tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); + let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay]); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; + // Send the get_header request let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(None, None, ForkName::Electra).await?; + let res = mock_validator.do_get_header(None, accept_types.clone(), ForkName::Electra).await?; assert_eq!(res.status(), StatusCode::OK); - let res = serde_json::from_slice::(&res.bytes().await?)?; - - assert_eq!(mock_state.received_get_header(), 1); + // Get the content type + let content_type = match res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .unwrap() + { + ct if ct == EncodingType::Ssz.to_string() => EncodingType::Ssz, + ct if ct == EncodingType::Json.to_string() => EncodingType::Json, + _ => panic!("unexpected content type"), + }; + assert!(accept_types.contains(&content_type)); + + // Get the data + let res = match content_type { + EncodingType::Json => serde_json::from_slice::(&res.bytes().await?)?, + EncodingType::Ssz => { + let fork = + get_consensus_version_header(res.headers()).expect("missing fork version header"); + assert_eq!(fork, ForkName::Electra); + let data = SignedBuilderBid::from_ssz_bytes_by_fork(&res.bytes().await?, fork).unwrap(); + GetHeaderResponse { version: fork, data, metadata: EmptyMetadata::default() } + } + }; + + // Validate the data + assert_eq!(mock_state.received_get_header(), expected_try_count); assert_eq!(res.version, ForkName::Electra); assert_eq!(res.data.message.header().block_hash().0[0], 1); assert_eq!(res.data.message.header().parent_hash().0, B256::ZERO); @@ -64,52 +158,6 @@ async fn test_get_header() -> Result<()> { Ok(()) } -#[tokio::test] -async fn test_get_header_ssz() -> Result<()> { - setup_test_env(); - let signer = random_secret(); - let pubkey = signer.public_key(); - - let chain = Chain::Holesky; - let pbs_port = 3210; - let relay_port = pbs_port + 1; - - // Run a mock relay - let mock_state = Arc::new(MockRelayState::new(chain, signer)); - let mock_relay = generate_mock_relay(relay_port, pubkey)?; - tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); - - // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay.clone()]); - let state = PbsState::new(config); - tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); - - // leave some time to start servers - tokio::time::sleep(Duration::from_millis(100)).await; - - let mock_validator = MockValidator::new(pbs_port)?; - info!("Sending get header"); - let res = - mock_validator.do_get_header(None, Some(EncodingType::Ssz), ForkName::Electra).await?; - assert_eq!(res.status(), StatusCode::OK); - - let fork = get_consensus_version_header(res.headers()).expect("missing fork version header"); - assert_eq!(fork, ForkName::Electra); - let data = SignedBuilderBid::from_ssz_bytes_by_fork(&res.bytes().await?, fork).unwrap(); - - assert_eq!(mock_state.received_get_header(), 1); - assert_eq!(data.message.header().block_hash().0[0], 1); - assert_eq!(data.message.header().parent_hash().0, B256::ZERO); - assert_eq!(*data.message.value(), U256::from(10)); - assert_eq!(*data.message.pubkey(), BlsPublicKeyBytes::from(mock_state.signer.public_key())); - assert_eq!(data.message.header().timestamp(), timestamp_of_slot_start_sec(0, chain)); - assert_eq!( - data.signature, - sign_builder_root(chain, &mock_state.signer, data.message.tree_hash_root()) - ); - Ok(()) -} - #[tokio::test] async fn test_get_header_returns_204_if_relay_down() -> Result<()> { setup_test_env(); @@ -137,7 +185,7 @@ async fn test_get_header_returns_204_if_relay_down() -> Result<()> { let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = mock_validator.do_get_header(None, None, ForkName::Electra).await?; + let res = mock_validator.do_get_header(None, HashSet::new(), ForkName::Electra).await?; assert_eq!(res.status(), StatusCode::NO_CONTENT); // 204 error assert_eq!(mock_state.received_get_header(), 0); // no header received diff --git a/tests/tests/pbs_mux.rs b/tests/tests/pbs_mux.rs index 34bc76de..6b5afe44 100644 --- a/tests/tests/pbs_mux.rs +++ b/tests/tests/pbs_mux.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; use cb_common::{ config::{HTTP_TIMEOUT_SECONDS_DEFAULT, MUXER_HTTP_MAX_LENGTH, RuntimeMuxConfig}, @@ -196,7 +200,7 @@ async fn test_mux() -> Result<()> { let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header with default"); assert_eq!( - mock_validator.do_get_header(None, None, ForkName::Electra).await?.status(), + mock_validator.do_get_header(None, HashSet::new(), ForkName::Electra).await?.status(), StatusCode::OK ); assert_eq!(mock_state.received_get_header(), 1); // only default relay was used @@ -205,7 +209,7 @@ async fn test_mux() -> Result<()> { info!("Sending get header with mux"); assert_eq!( mock_validator - .do_get_header(Some(validator_pubkey), None, ForkName::Electra) + .do_get_header(Some(validator_pubkey), HashSet::new(), ForkName::Electra) .await? .status(), StatusCode::OK @@ -226,7 +230,12 @@ async fn test_mux() -> Result<()> { info!("Sending submit block v1"); assert_eq!( mock_validator - .do_submit_block_v1(None, EncodingType::Json, EncodingType::Json, ForkName::Electra) + .do_submit_block_v1( + None, + HashSet::from([EncodingType::Json]), + EncodingType::Json, + ForkName::Electra + ) .await? .status(), StatusCode::OK @@ -237,7 +246,12 @@ async fn test_mux() -> Result<()> { info!("Sending submit block v2"); assert_eq!( mock_validator - .do_submit_block_v2(None, EncodingType::Json, EncodingType::Json, ForkName::Electra) + .do_submit_block_v2( + None, + HashSet::from([EncodingType::Json]), + EncodingType::Json, + ForkName::Electra + ) .await? .status(), StatusCode::ACCEPTED diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 1c8d3cdc..d4a5888a 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{collections::HashSet, sync::Arc, time::Duration}; use cb_common::{ config::{MuxConfig, MuxKeysLoader, PbsMuxes}, @@ -109,8 +109,9 @@ async fn test_auto_refresh() -> Result<()> { // relay only since it hasn't been seen in the mux yet let mock_validator = MockValidator::new(pbs_port)?; info!("Sending get header"); - let res = - mock_validator.do_get_header(Some(new_mux_pubkey.clone()), None, ForkName::Electra).await?; + let res = mock_validator + .do_get_header(Some(new_mux_pubkey.clone()), HashSet::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 1); // default relay was used assert_eq!(mux_relay_state.received_get_header(), 0); // mux relay was not used @@ -138,16 +139,18 @@ async fn test_auto_refresh() -> Result<()> { assert!(logs_contain(&format!("fetched 2 pubkeys for registry mux {mux_relay_id}"))); // Try to run a get_header on the new pubkey - now it should use the mux relay - let res = - mock_validator.do_get_header(Some(new_mux_pubkey.clone()), None, ForkName::Electra).await?; + let res = mock_validator + .do_get_header(Some(new_mux_pubkey.clone()), HashSet::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 1); // default relay was not used here assert_eq!(mux_relay_state.received_get_header(), 1); // mux relay was used // Now try to do a get_header with the old pubkey - it should only use the // default relay - let res = - mock_validator.do_get_header(Some(default_pubkey.clone()), None, ForkName::Electra).await?; + let res = mock_validator + .do_get_header(Some(default_pubkey.clone()), HashSet::new(), ForkName::Electra) + .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 2); // default relay was used assert_eq!(mux_relay_state.received_get_header(), 1); // mux relay was not used @@ -165,7 +168,7 @@ async fn test_auto_refresh() -> Result<()> { // Try to do a get_header with the removed pubkey - it should only use the // default relay let res = mock_validator - .do_get_header(Some(existing_mux_pubkey.clone()), None, ForkName::Electra) + .do_get_header(Some(existing_mux_pubkey.clone()), HashSet::new(), ForkName::Electra) .await?; assert_eq!(res.status(), StatusCode::OK); assert_eq!(default_relay_state.received_get_header(), 3); // default relay was used diff --git a/tests/tests/pbs_post_blinded_blocks.rs b/tests/tests/pbs_post_blinded_blocks.rs index 79725b3e..c6201e52 100644 --- a/tests/tests/pbs_post_blinded_blocks.rs +++ b/tests/tests/pbs_post_blinded_blocks.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{collections::HashSet, sync::Arc, time::Duration}; use cb_common::{ pbs::{BuilderApiVersion, GetPayloadInfo, PayloadAndBlobs, SubmitBlindedBlockResponse}, @@ -19,7 +19,15 @@ use tracing::info; #[tokio::test] async fn test_submit_block_v1() -> Result<()> { - let res = submit_block_impl(3800, BuilderApiVersion::V1, EncodingType::Json).await?; + let res = submit_block_impl( + 3800, + BuilderApiVersion::V1, + HashSet::from([EncodingType::Json]), + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + ) + .await?; assert_eq!(res.status(), StatusCode::OK); let signed_blinded_block = load_test_signed_blinded_block(); @@ -34,7 +42,15 @@ async fn test_submit_block_v1() -> Result<()> { #[tokio::test] async fn test_submit_block_v2() -> Result<()> { - let res = submit_block_impl(3850, BuilderApiVersion::V2, EncodingType::Json).await?; + let res = submit_block_impl( + 3810, + BuilderApiVersion::V2, + HashSet::from([EncodingType::Json]), + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Json, + 1, + ) + .await?; assert_eq!(res.status(), StatusCode::ACCEPTED); assert_eq!(res.bytes().await?.len(), 0); Ok(()) @@ -42,7 +58,15 @@ async fn test_submit_block_v2() -> Result<()> { #[tokio::test] async fn test_submit_block_v1_ssz() -> Result<()> { - let res = submit_block_impl(3810, BuilderApiVersion::V1, EncodingType::Ssz).await?; + let res = submit_block_impl( + 3820, + BuilderApiVersion::V1, + HashSet::from([EncodingType::Ssz]), + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + ) + .await?; assert_eq!(res.status(), StatusCode::OK); let signed_blinded_block = load_test_signed_blinded_block(); @@ -58,12 +82,116 @@ async fn test_submit_block_v1_ssz() -> Result<()> { #[tokio::test] async fn test_submit_block_v2_ssz() -> Result<()> { - let res = submit_block_impl(3860, BuilderApiVersion::V2, EncodingType::Ssz).await?; + let res = submit_block_impl( + 3830, + BuilderApiVersion::V2, + HashSet::from([EncodingType::Ssz]), + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + EncodingType::Ssz, + 1, + ) + .await?; assert_eq!(res.status(), StatusCode::ACCEPTED); assert_eq!(res.bytes().await?.len(), 0); Ok(()) } +/// Test that a v1 submit block request in SSZ is converted to JSON if the relay +/// only supports JSON +#[tokio::test] +async fn test_submit_block_v1_ssz_into_json() -> Result<()> { + let res = submit_block_impl( + 3840, + BuilderApiVersion::V1, + HashSet::from([EncodingType::Ssz]), + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +/// Test that a v2 submit block request in SSZ is converted to JSON if the relay +/// only supports JSON +#[tokio::test] +async fn test_submit_block_v2_ssz_into_json() -> Result<()> { + let res = submit_block_impl( + 3850, + BuilderApiVersion::V2, + HashSet::from([EncodingType::Ssz]), + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 2, + ) + .await?; + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(res.bytes().await?.len(), 0); + Ok(()) +} + +/// Test v1 requesting multiple types when the relay supports SSZ, which should +/// return SSZ +#[tokio::test] +async fn test_submit_block_v1_multitype_ssz() -> Result<()> { + let res = submit_block_impl( + 3860, + BuilderApiVersion::V1, + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + HashSet::from([EncodingType::Ssz]), + EncodingType::Ssz, + 1, + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + + let signed_blinded_block = load_test_signed_blinded_block(); + + let response_body = + PayloadAndBlobs::from_ssz_bytes_by_fork(&res.bytes().await?, ForkName::Electra).unwrap(); + assert_eq!( + response_body.execution_payload.block_hash(), + signed_blinded_block.block_hash().into() + ); + Ok(()) +} + +/// Test v1 requesting multiple types when the relay supports JSON, which should +/// still return SSZ +#[tokio::test] +async fn test_submit_block_v1_multitype_json() -> Result<()> { + let res = submit_block_impl( + 3870, + BuilderApiVersion::V1, + HashSet::from([EncodingType::Ssz, EncodingType::Json]), + HashSet::from([EncodingType::Json]), + EncodingType::Ssz, + 1, + ) + .await?; + assert_eq!(res.status(), StatusCode::OK); + + 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_too_large() -> Result<()> { setup_test_env(); @@ -87,7 +215,12 @@ async fn test_submit_block_too_large() -> Result<()> { let mock_validator = MockValidator::new(pbs_port)?; info!("Sending submit block"); let res = mock_validator - .do_submit_block_v1(None, EncodingType::Json, EncodingType::Json, ForkName::Electra) + .do_submit_block_v1( + None, + HashSet::from([EncodingType::Json]), + EncodingType::Json, + ForkName::Electra, + ) .await; // response size exceeds max size: max: 20971520 @@ -99,29 +232,34 @@ async fn test_submit_block_too_large() -> Result<()> { async fn submit_block_impl( pbs_port: u16, api_version: BuilderApiVersion, + accept_types: HashSet, + relay_types: HashSet, serialization_mode: EncodingType, + expected_try_count: u64, ) -> Result { - let accept = serialization_mode; - + // Setup test environment setup_test_env(); let signer = random_secret(); let pubkey = signer.public_key(); - let chain = Chain::Holesky; + let relay_port = pbs_port + 1; // Run a mock relay - let relays = vec![generate_mock_relay(pbs_port + 1, pubkey)?]; - let mock_state = Arc::new(MockRelayState::new(chain, signer)); - tokio::spawn(start_mock_relay_service(mock_state.clone(), pbs_port + 1)); + let mut mock_state = MockRelayState::new(chain, signer); + mock_state.supported_content_types = Arc::new(relay_types); + let mock_state = Arc::new(mock_state); + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + tokio::spawn(start_mock_relay_service(mock_state.clone(), relay_port)); // Run the PBS service - let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), relays); + let config = to_pbs_config(chain, get_pbs_static_config(pbs_port), vec![mock_relay]); let state = PbsState::new(config); tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); // leave some time to start servers tokio::time::sleep(Duration::from_millis(100)).await; + // Send the submit block request let signed_blinded_block = load_test_signed_blinded_block(); let mock_validator = MockValidator::new(pbs_port)?; info!("Sending submit block"); @@ -130,7 +268,7 @@ async fn submit_block_impl( mock_validator .do_submit_block_v1( Some(signed_blinded_block), - accept, + accept_types, serialization_mode, ForkName::Electra, ) @@ -140,13 +278,13 @@ async fn submit_block_impl( mock_validator .do_submit_block_v2( Some(signed_blinded_block), - accept, + accept_types, serialization_mode, ForkName::Electra, ) .await? } }; - assert_eq!(mock_state.received_submit_block(), 1); + assert_eq!(mock_state.received_submit_block(), expected_try_count); Ok(res) }