diff --git a/crates/common/src/wire.rs b/crates/common/src/wire.rs index 94cfdd3d..0773dddb 100644 --- a/crates/common/src/wire.rs +++ b/crates/common/src/wire.rs @@ -276,38 +276,9 @@ fn essence_encoding(mt: &MediaType) -> Option { } } -/// Compute the q-value for the `index`-th preferred encoding when building an -/// outbound `Accept` header. The first entry gets q=1.0, each subsequent entry -/// decreases by 0.1, and the value is clamped to a minimum of 0.1 so we never -/// emit q=0 (which per RFC 7231 §5.3.1 means "not acceptable"). -fn accept_q_value_for_index(index: usize) -> f32 { - // `as i32` would silently wrap for large indices (e.g. usize::MAX → -1), - // which would invert the clamp. Saturate the cast explicitly. - let idx = i32::try_from(index).unwrap_or(i32::MAX); - let step = 10_i32.saturating_sub(idx).max(1); - step as f32 / 10.0 -} - -/// Format a single `Accept` header entry as `";q="`. -#[inline] -fn format_accept_entry(enc: EncodingType, q: f32) -> String { - format!("{};q={:.1}", enc.content_type(), q) -} - -/// Build an `Accept` header that mirrors the caller's preference order -/// so the relay sees the same priority the beacon node asked us for. Each -/// subsequent entry receives a q-value 0.1 lower than the previous one, -/// starting at 1.0. Returns a ready-to-use `HeaderValue` — the output is -/// always valid ASCII, so infallible. -pub fn build_outbound_accept(preferred: AcceptedEncodings) -> HeaderValue { - let s = preferred - .iter() - .enumerate() - .map(|(i, enc)| format_accept_entry(enc, accept_q_value_for_index(i))) - .collect::>() - .join(","); - HeaderValue::from_str(&s).expect("build_outbound_accept produces valid header value") -} +/// The `Accept` header PBS sends to relays: SSZ first, JSON as fallback. +pub static OUTBOUND_ACCEPT_SSZ_FIRST: HeaderValue = + HeaderValue::from_static("application/octet-stream;q=1.0,application/json;q=0.9"); pub fn get_content_type(req_headers: &HeaderMap) -> EncodingType { EncodingType::from_str( @@ -453,10 +424,9 @@ mod test { use super::{ APPLICATION_JSON, APPLICATION_OCTET_STREAM, AcceptedEncodings, BodyDeserializeError, - CONSENSUS_VERSION_HEADER, EncodingType, NO_PREFERENCE_DEFAULT, WILDCARD, - accept_q_value_for_index, build_outbound_accept, deserialize_body, format_accept_entry, - get_accept_types, get_consensus_version_header, get_content_type, - parse_response_encoding_and_fork, + CONSENSUS_VERSION_HEADER, EncodingType, NO_PREFERENCE_DEFAULT, OUTBOUND_ACCEPT_SSZ_FIRST, + WILDCARD, deserialize_body, get_accept_types, get_consensus_version_header, + get_content_type, parse_response_encoding_and_fork, }; const APPLICATION_TEXT: &str = "application/text"; @@ -646,32 +616,6 @@ mod test { assert_eq!(accepts.preferred(&supported), None); } - /// Outbound Accept should be deterministic and q-ordered to match caller - /// preference. - #[test] - fn test_build_outbound_accept_deterministic() { - let ssz_then_json = - AcceptedEncodings { primary: EncodingType::Ssz, fallback: Some(EncodingType::Json) }; - let json_then_ssz = - AcceptedEncodings { primary: EncodingType::Json, fallback: Some(EncodingType::Ssz) }; - assert_eq!( - build_outbound_accept(ssz_then_json), - "application/octet-stream;q=1.0,application/json;q=0.9" - ); - assert_eq!( - build_outbound_accept(json_then_ssz), - "application/json;q=1.0,application/octet-stream;q=0.9" - ); - - // Stable across repeats - for _ in 0..100 { - assert_eq!( - build_outbound_accept(ssz_then_json), - "application/octet-stream;q=1.0,application/json;q=0.9" - ); - } - } - /// `AcceptedEncodings::single` produces a primary with no fallback. #[test] fn test_accepted_encodings_single() { @@ -783,17 +727,6 @@ mod test { ); } - /// `build_outbound_accept` on a single-value `AcceptedEncodings` emits - /// exactly one entry at q=1.0 (no trailing comma, no orphan fallback). - #[test] - fn test_build_outbound_accept_single_value() { - let only_ssz = AcceptedEncodings::single(EncodingType::Ssz); - assert_eq!(build_outbound_accept(only_ssz), "application/octet-stream;q=1.0"); - - let only_json = AcceptedEncodings::single(EncodingType::Json); - assert_eq!(build_outbound_accept(only_json), "application/json;q=1.0"); - } - /// `preferred` walks the caller's preference order and returns the /// first supported match — not the server's first choice. #[test] @@ -807,25 +740,6 @@ mod test { ); } - /// q-value ladder: first entry is 1.0, each subsequent entry drops by 0.1. - #[test] - fn test_accept_q_value_for_index_ladder() { - assert!((accept_q_value_for_index(0) - 1.0).abs() < f32::EPSILON); - assert!((accept_q_value_for_index(1) - 0.9).abs() < f32::EPSILON); - assert!((accept_q_value_for_index(5) - 0.5).abs() < f32::EPSILON); - assert!((accept_q_value_for_index(9) - 0.1).abs() < f32::EPSILON); - } - - /// Clamp at 0.1: we never emit q=0 (which per RFC 7231 §5.3.1 would mean - /// "not acceptable"). - #[test] - fn test_accept_q_value_for_index_clamps_to_minimum() { - assert!((accept_q_value_for_index(10) - 0.1).abs() < f32::EPSILON); - assert!((accept_q_value_for_index(100) - 0.1).abs() < f32::EPSILON); - // Even an adversarial usize::MAX must not underflow or drop to zero. - assert!((accept_q_value_for_index(usize::MAX) - 0.1).abs() < f32::EPSILON); - } - /// Entry formatter emits the spec-shaped string. #[test] fn test_format_accept_entry_shape() { @@ -981,6 +895,22 @@ mod test { ); } + /// Format a single `Accept` header entry as `";q="`. + #[inline] + fn format_accept_entry(enc: EncodingType, q: f32) -> String { + format!("{};q={:.1}", enc.content_type(), q) + } + + // Pins the wire format PBS sends to relays: SSZ preferred (q=1.0), JSON as + // fallback (q=0.9). + #[test] + fn test_outbound_accept_ssz_first() { + assert_eq!( + OUTBOUND_ACCEPT_SSZ_FIRST, + "application/octet-stream;q=1.0,application/json;q=0.9" + ); + } + /// Present-but-unrecognized Content-Type still bails as /// `UnsupportedMediaType`; the fallback only covers *missing* headers. #[tokio::test] diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index dd2b1036..28455395 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -21,8 +21,8 @@ use cb_common::{ types::{BlsPublicKey, BlsPublicKeyBytes, BlsSignature, Chain}, utils::{ms_into_slot, timestamp_of_slot_start_sec, utcnow_ms}, wire::{ - AcceptedEncodings, EncodingType, build_outbound_accept, get_accept_types, - get_user_agent_with_version, parse_response_encoding_and_fork, safe_read_http_response, + EncodingType, OUTBOUND_ACCEPT_SSZ_FIRST, get_user_agent_with_version, + parse_response_encoding_and_fork, safe_read_http_response, }, }; use futures::future::join_all; @@ -147,21 +147,12 @@ pub async fn get_header( let mut send_headers = HeaderMap::new(); send_headers.insert(USER_AGENT, get_user_agent_with_version(&req_headers)?); - // Forward the caller's Accept preference to the relay so the relay - // returns data in the format the BN expects, avoiding decode→re-encode. - // Always offer both encodings as fallback so a format-limited relay - // still returns a bid (PBS converts if needed). - let caller_accept = get_accept_types(&req_headers).inspect_err(|err| { - error!(%err, "error parsing accept header"); - })?; - let relay_accept = AcceptedEncodings { - primary: caller_accept.primary, - fallback: Some(match caller_accept.primary { - EncodingType::Ssz => EncodingType::Json, - EncodingType::Json => EncodingType::Ssz, - }), - }; - send_headers.insert(ACCEPT, build_outbound_accept(relay_accept)); + // PBS always decodes and re-validates every relay bid, then the route + // re-encodes the winning bid to the BN's Accept. So always request SSZ from + // the relay (smaller on the wire, faster to decode than JSON); JSON is the + // fallback for a relay that can't do SSZ. The BN's own format preference is + // applied later by the route, not here. + send_headers.insert(ACCEPT, OUTBOUND_ACCEPT_SSZ_FIRST.clone()); // Send requests to all relays concurrently let slot = params.slot as i64; diff --git a/crates/pbs/src/mev_boost/submit_block.rs b/crates/pbs/src/mev_boost/submit_block.rs index f8ee2e23..7ccc5ce7 100644 --- a/crates/pbs/src/mev_boost/submit_block.rs +++ b/crates/pbs/src/mev_boost/submit_block.rs @@ -14,7 +14,7 @@ use cb_common::{ }, utils::utcnow_ms, wire::{ - AcceptedEncodings, CONSENSUS_VERSION_HEADER, EncodingType, build_outbound_accept, + CONSENSUS_VERSION_HEADER, EncodingType, OUTBOUND_ACCEPT_SSZ_FIRST, get_user_agent_with_version, parse_response_encoding_and_fork, read_chunked_body_with_max, }, }; @@ -88,13 +88,7 @@ pub async fn submit_block( // by the route, not here. Skip for v2, whose success is an empty 202 with no // body to negotiate. if api_version == BuilderApiVersion::V1 { - send_headers.insert( - ACCEPT, - build_outbound_accept(AcceptedEncodings { - primary: EncodingType::Ssz, - fallback: Some(EncodingType::Json), - }), - ); + send_headers.insert(ACCEPT, OUTBOUND_ACCEPT_SSZ_FIRST.clone()); } // Send requests to all relays concurrently diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index 8f3382bf..467910e0 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -37,7 +37,7 @@ use cb_common::{ }; use cb_pbs::MAX_SIZE_SUBMIT_BLOCK_RESPONSE; use lh_types::KzgProof; -use reqwest::header::CONTENT_TYPE; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; use ssz::Encode; use tokio::net::TcpListener; use tracing::{debug, error}; @@ -89,12 +89,18 @@ pub struct MockRelayState { received_submit_block: Arc, response_override: RwLock>, bid_value: RwLock, + /// The raw `Accept` header PBS sent on the most recent get_header request, + /// so a test can assert what encoding PBS asked the relay for. + received_get_header_accept: RwLock>, } impl MockRelayState { pub fn received_get_header(&self) -> u64 { self.received_get_header.load(Ordering::Relaxed) } + pub fn received_get_header_accept(&self) -> Option { + self.received_get_header_accept.read().unwrap().clone() + } pub fn received_get_status(&self) -> u64 { self.received_get_status.load(Ordering::Relaxed) } @@ -144,6 +150,7 @@ impl MockRelayState { received_submit_block: Default::default(), response_override: RwLock::new(None), bid_value: RwLock::new(U256::from(10)), + received_get_header_accept: RwLock::new(None), supported_content_types: Arc::new( [EncodingType::Json, EncodingType::Ssz].iter().cloned().collect(), ), @@ -218,6 +225,8 @@ async fn handle_get_header( headers: HeaderMap, ) -> Response { state.received_get_header.fetch_add(1, Ordering::Relaxed); + *state.received_get_header_accept.write().unwrap() = + headers.get(ACCEPT).and_then(|v| v.to_str().ok()).map(String::from); 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 { diff --git a/tests/tests/pbs_get_header.rs b/tests/tests/pbs_get_header.rs index bf92c86d..8c0950ce 100644 --- a/tests/tests/pbs_get_header.rs +++ b/tests/tests/pbs_get_header.rs @@ -25,6 +25,49 @@ use tracing::info; use tree_hash::TreeHash; use url::Url; +/// PBS must always request SSZ from the relay (JSON fallback) regardless of the +/// beacon node's own Accept: it decodes and re-validates every bid and the +/// route re-encodes the winner to the BN's Accept anyway, so SSZ is the +/// cheapest wire format on the relay hop. Here the BN asks for JSON, and the +/// relay must still see SSZ as the preferred encoding. +#[tokio::test] +async fn test_get_header_always_requests_ssz_from_relay() -> Result<()> { + setup_test_env(); + let signer = random_secret(); + let pubkey = signer.public_key(); + let chain = Chain::Hoodi; + let pbs_listener = get_free_listener().await; + let relay_listener = get_free_listener().await; + let pbs_port = pbs_listener.local_addr().unwrap().port(); + let relay_port = relay_listener.local_addr().unwrap().port(); + + let mut mock_state = MockRelayState::new(chain, signer); + mock_state.supported_content_types = + Arc::new(HashSet::from([EncodingType::Ssz, EncodingType::Json])); + let mock_state = Arc::new(mock_state); + let mock_relay = generate_mock_relay(relay_port, pubkey)?; + tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener)); + + let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]); + let state = PbsState::new(config, PathBuf::new()); + drop(pbs_listener); + tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state)); + tokio::time::sleep(Duration::from_millis(100)).await; + + let mock_validator = MockValidator::new(pbs_port)?; + let res = mock_validator.do_get_header(None, vec![EncodingType::Json], ForkName::Fulu).await?; + assert_eq!(res.status(), StatusCode::OK); + + let relay_accept = mock_state + .received_get_header_accept() + .expect("relay should have received an Accept header"); + assert!( + relay_accept.starts_with(EncodingType::Ssz.content_type()), + "relay Accept must prefer SSZ regardless of the BN's JSON request, got: {relay_accept}" + ); + Ok(()) +} + /// Test requesting JSON when the relay supports JSON #[tokio::test] async fn test_get_header() -> Result<()> {