Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 22 additions & 92 deletions crates/common/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,38 +276,9 @@ fn essence_encoding(mt: &MediaType) -> Option<EncodingType> {
}
}

/// 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 `"<media-type>;q=<x.x>"`.
#[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::<Vec<_>>()
.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(
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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]
Expand All @@ -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() {
Expand Down Expand Up @@ -981,6 +895,22 @@ mod test {
);
}

/// Format a single `Accept` header entry as `"<media-type>;q=<x.x>"`.
#[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]
Expand Down
25 changes: 8 additions & 17 deletions crates/pbs/src/mev_boost/get_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -147,21 +147,12 @@ pub async fn get_header<S: BuilderApiState>(
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;
Expand Down
10 changes: 2 additions & 8 deletions crates/pbs/src/mev_boost/submit_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Expand Down Expand Up @@ -88,13 +88,7 @@ pub async fn submit_block<S: BuilderApiState>(
// 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
Expand Down
11 changes: 10 additions & 1 deletion tests/src/mock_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -89,12 +89,18 @@ pub struct MockRelayState {
received_submit_block: Arc<AtomicU64>,
response_override: RwLock<Option<StatusCode>>,
bid_value: RwLock<U256>,
/// 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<Option<String>>,
}

impl MockRelayState {
pub fn received_get_header(&self) -> u64 {
self.received_get_header.load(Ordering::Relaxed)
}
pub fn received_get_header_accept(&self) -> Option<String> {
self.received_get_header_accept.read().unwrap().clone()
}
pub fn received_get_status(&self) -> u64 {
self.received_get_status.load(Ordering::Relaxed)
}
Expand Down Expand Up @@ -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(),
),
Expand Down Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions tests/tests/pbs_get_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down
Loading