Skip to content
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jsonwebtoken = { version = "9.3.1", default-features = false }
lazy_static = "1.5.0"
lh_eth2_keystore = { package = "eth2_keystore", git = "https://github.com/sigp/lighthouse", tag = "v7.1.0" }
lh_types = { package = "types", git = "https://github.com/sigp/lighthouse", tag = "v7.1.0" }
mediatype = "0.20.0"
parking_lot = "0.12.3"
pbkdf2 = "0.12.2"
prometheus = "0.13.4"
Expand Down
1 change: 1 addition & 0 deletions crates/common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ futures.workspace = true
jsonwebtoken.workspace = true
lh_eth2_keystore.workspace = true
lh_types.workspace = true
mediatype.workspace = true
pbkdf2.workspace = true
rand.workspace = true
rayon.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion crates/common/src/signer/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ mod test {
.join(consensus_signer.pubkey().to_string())
.join("TEST_MODULE")
.join("bls")
.join(format!("{}.sig", proxy_signer.pubkey().to_string()))
.join(format!("{}.sig", proxy_signer.pubkey()))
)
.unwrap()
)
Expand Down
199 changes: 197 additions & 2 deletions crates/common/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
#[cfg(test)]
use std::cell::Cell;
use std::{
fmt,
net::Ipv4Addr,
str::FromStr,
time::{SystemTime, UNIX_EPOCH},
};

use alloy::{hex, primitives::U256};
use axum::http::HeaderValue;
use axum::{
extract::{FromRequest, Request},
http::HeaderValue,
response::{IntoResponse, Response as AxumResponse},
};
use bytes::Bytes;
use futures::StreamExt;
use lh_types::test_utils::{SeedableRng, TestRandom, XorShiftRng};
use mediatype::{MediaType, MediaTypeList, names};
use rand::{Rng, distr::Alphanumeric};
use reqwest::{Response, header::HeaderMap};
use reqwest::{
Response, StatusCode,
header::{ACCEPT, CONTENT_TYPE, HeaderMap},
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
use ssz::{Decode, Encode};
Expand All @@ -31,6 +42,7 @@ use crate::{
};

const MILLIS_PER_SECOND: u64 = 1_000;
pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version";

#[derive(Debug, Error)]
pub enum ResponseReadError {
Expand Down Expand Up @@ -408,6 +420,189 @@ pub fn get_user_agent_with_version(req_headers: &HeaderMap) -> eyre::Result<Head
Ok(HeaderValue::from_str(&format!("commit-boost/{HEADER_VERSION_VALUE} {ua}"))?)
}

/// Parse ACCEPT header, default to JSON if missing or mal-formatted
pub fn get_accept_header(req_headers: &HeaderMap) -> Accept {
Accept::from_str(
req_headers.get(ACCEPT).and_then(|value| value.to_str().ok()).unwrap_or("application/json"),
)
.unwrap_or(Accept::Json)
}

/// Parse CONTENT TYPE header, default to JSON if missing or mal-formatted
pub fn get_content_type_header(req_headers: &HeaderMap) -> ContentType {
ContentType::from_str(
req_headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/json"),
)
.unwrap_or(ContentType::Json)
}

/// Parse CONSENSUS_VERSION header
pub fn get_consensus_version_header(req_headers: &HeaderMap) -> Option<ForkName> {
ForkName::from_str(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

woth double checking this is not case sensitive

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just realized we have our own ForkName, we could also import it from lighthouse

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For case sensitivity, it's defined the way the spec defines it (https://github.com/ethereum/beacon-APIs/blob/672e03e25ace85a3bacaea553fbf374f4f844435/apis/beacon/blocks/blocks.yaml#L21) but Rust will convert everything to lower case regardless.

For ForkName, done in 86fa858.

req_headers
.get(CONSENSUS_VERSION_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or(""),
)
.ok()
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ForkName {
Electra,
}

impl std::fmt::Display for ForkName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ForkName::Electra => write!(f, "electra"),
}
}
}

impl FromStr for ForkName {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"electra" => Ok(ForkName::Electra),
_ => Err(format!("Invalid fork name {value}")),
}
}
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ContentType {
Json,
Ssz,
}

impl std::fmt::Display for ContentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContentType::Json => write!(f, "application/json"),
ContentType::Ssz => write!(f, "application/octet-stream"),
}
}
}

impl FromStr for ContentType {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"application/json" => Ok(ContentType::Json),
"application/octet-stream" => Ok(ContentType::Ssz),
_ => Ok(ContentType::Json),
}
}
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Accept {
Json,
Ssz,
Any,
}

impl fmt::Display for Accept {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Accept::Ssz => write!(f, "application/octet-stream"),
Accept::Json => write!(f, "application/json"),
Accept::Any => write!(f, "*/*"),
}
}
}

impl FromStr for Accept {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be easier to use crate for this for easier maintainability? eg https://github.com/bltavares/axum-content-negotiation or https://docs.rs/headers-accept/latest/headers_accept/

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e7335f6.

type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let media_type_list = MediaTypeList::new(s);

// [q-factor weighting]: https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2
// find the highest q-factor supported accept type
let mut highest_q = 0_u16;
let mut accept_type = None;

const APPLICATION: &str = names::APPLICATION.as_str();
const OCTET_STREAM: &str = names::OCTET_STREAM.as_str();
const JSON: &str = names::JSON.as_str();
const STAR: &str = names::_STAR.as_str();
const Q: &str = names::Q.as_str();

media_type_list.into_iter().for_each(|item| {
if let Ok(MediaType { ty, subty, suffix: _, params }) = item {
let q_accept = match (ty.as_str(), subty.as_str()) {
(APPLICATION, OCTET_STREAM) => Some(Accept::Ssz),
(APPLICATION, JSON) => Some(Accept::Json),
(STAR, STAR) => Some(Accept::Any),
_ => None,
}
.map(|item_accept_type| {
let q_val = params
.iter()
.find_map(|(n, v)| match n.as_str() {
Q => {
Some((v.as_str().parse::<f32>().unwrap_or(0_f32) * 1000_f32) as u16)
}
_ => None,
})
.or(Some(1000_u16));

(q_val.unwrap(), item_accept_type)
});

match q_accept {
Some((q, accept)) if q > highest_q => {
highest_q = q;
accept_type = Some(accept);
}
_ => (),
}
}
});
accept_type.ok_or_else(|| "accept header is not supported".to_string())
}
}

#[must_use]
#[derive(Debug, Clone, Copy, Default)]
pub struct JsonOrSsz<T>(pub T);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking about what we'll have to do with: #326, i 'd rather just do this in a function and use a simple "Bytes" extractor on the axum body. Can just pass headers and body and get back T

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, refactored a bunch of the setup in e7335f6 to provide exactly this.


impl<T, S> FromRequest<S> for JsonOrSsz<T>
where
T: serde::de::DeserializeOwned + ssz::Decode + 'static,
S: Send + Sync,
{
type Rejection = AxumResponse;

async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
let headers = req.headers().clone();
let content_type = headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok());

let bytes = Bytes::from_request(req, _state).await.map_err(IntoResponse::into_response)?;

if let Some(content_type) = content_type {
if content_type.starts_with(&ContentType::Json.to_string()) {
let payload: T = serde_json::from_slice(&bytes)
.map_err(|_| StatusCode::BAD_REQUEST.into_response())?;
return Ok(Self(payload));
}

if content_type.starts_with(&ContentType::Ssz.to_string()) {
let payload = T::from_ssz_bytes(&bytes)
.map_err(|_| StatusCode::BAD_REQUEST.into_response())?;
return Ok(Self(payload));
}
}

Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response())
}
}

#[cfg(unix)]
pub async fn wait_for_signal() -> eyre::Result<()> {
use tokio::signal::unix::{SignalKind, signal};
Expand Down
1 change: 1 addition & 0 deletions crates/pbs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ axum.workspace = true
axum-extra.workspace = true
cb-common.workspace = true
cb-metrics.workspace = true
ethereum_ssz.workspace = true
eyre.workspace = true
futures.workspace = true
lazy_static.workspace = true
Expand Down
41 changes: 35 additions & 6 deletions crates/pbs/src/routes/get_header.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
use alloy::primitives::utils::format_ether;
use axum::{
extract::{Path, State},
http::HeaderMap,
http::{HeaderMap, HeaderValue},
response::IntoResponse,
};
use cb_common::{
pbs::GetHeaderParams,
utils::{get_user_agent, ms_into_slot},
pbs::{GetHeaderParams, VersionedResponse},
utils::{Accept, CONSENSUS_VERSION_HEADER, get_accept_header, get_user_agent, ms_into_slot},
};
use reqwest::StatusCode;
use reqwest::{StatusCode, header::CONTENT_TYPE};
use ssz::Encode;
use tracing::{error, info};

use crate::{
Expand All @@ -32,16 +33,44 @@ pub async fn handle_get_header<S: BuilderApiState, A: BuilderApi<S>>(

let ua = get_user_agent(&req_headers);
let ms_into_slot = ms_into_slot(params.slot, state.config.chain);
let accept_header = get_accept_header(&req_headers);

info!(ua, ms_into_slot, "new request");

match A::get_header(params, req_headers, state.clone()).await {
Ok(res) => {
if let Some(max_bid) = res {
info!(value_eth = format_ether(max_bid.value()), block_hash =% max_bid.block_hash(), "received header");

BEACON_NODE_STATUS.with_label_values(&["200", GET_HEADER_ENDPOINT_TAG]).inc();
Ok((StatusCode::OK, axum::Json(max_bid)).into_response())
let response = match accept_header {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here we just asssume the relay just support both? probably fine but ok double checking

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do, if we want to add support for relays that only allow JSON for example, we'll have to probably figure that out on startup and flag them accordingly so we don't ping them to negotiate encoding with every request (assuming they never change it down the line). Do we have stats on how many support SSZ and how many don't?

Accept::Ssz => {
let mut res = match &max_bid {
VersionedResponse::Electra(max_bid) => {
(StatusCode::OK, max_bid.as_ssz_bytes()).into_response()
}
};
let Ok(consensus_version_header) = HeaderValue::from_str(max_bid.version())
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!("{}", Accept::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
}
Accept::Json | Accept::Any => {
(StatusCode::OK, axum::Json(max_bid)).into_response()
}
};
Ok(response)
} else {
// spec: return 204 if request is valid but no bid available
info!("no header available for slot");
Expand Down
Loading
Loading