Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
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(
req_headers
.get(CONSENSUS_VERSION_HEADER)
.and_then(|value| value.to_str().ok())
.unwrap_or(""),
Copy link
Collaborator

Choose a reason for hiding this comment

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

if missing should we default to the current fork instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm inclined to stick to the spec on this one, which says if the request / response use SSZ, then the header must be present; otherwise it doesn't have to be. Either way, this particular function probably isn't the proper place to backfill a missing header; whatever uses it can decide to do that if this reports a missing header, it really needs to.

)
.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 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

should we match on lowercase string?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think Hyper will convert them all to lowercase, but I added an explicit conversion just in case in b22eed8.

"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 {
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);

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?

Copy link
Collaborator

Choose a reason for hiding this comment

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

how about catching an error and resubmitting with a different encoding? i assume that's what the BN does already instead of mapping whether a given sidecar supports what

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was a significant rework; so much so that it now lives in its own branch built on top of this one: https://github.com/Commit-Boost/commit-boost-client/tree/ssz-update-v2

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