diff --git a/Cargo.lock b/Cargo.lock index 583184b9..a03ee6f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,10 +68,12 @@ dependencies = [ "alloy-core", "alloy-eips", "alloy-genesis", + "alloy-network", "alloy-provider", "alloy-rpc-client", "alloy-rpc-types", "alloy-serde", + "alloy-transport", "alloy-transport-http", ] @@ -1185,6 +1187,7 @@ dependencies = [ "eyre", "futures", "lazy_static", + "parking_lot", "prometheus", "reqwest", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f97fb29f..91198e15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ alloy = { version = "0.5.4", features = [ "serde", "ssz", "getrandom", + "providers", ] } ssz_types = "0.8" ethereum_serde_utils = "0.7.0" @@ -47,6 +48,7 @@ tokio = { version = "1.37.0", features = ["full"] } futures = "0.3.30" async-trait = "0.1.80" dashmap = "5.5.3" +parking_lot = "0.12.3" # serialization toml = "0.8.13" diff --git a/config.example.toml b/config.example.toml index d0ca499c..4c019706 100644 --- a/config.example.toml +++ b/config.example.toml @@ -52,6 +52,12 @@ relay_monitors = [] # to force local building and miniminzing the risk of missed slots. See also the timing games section below # OPTIONAL, DEFAULT: 2000 late_in_slot_time_ms = 2000 +# Whether to enable extra validation of get_header responses, if this is enabled you need to set `rpc_url` +# OPTIONAL, DEFAULT: false +extra_validation_enabled = false +# Execution Layer RPC url to use for extra validation +# OPTIONAL +rpc_url = "http://abc.xyz" # The PBS module needs one or more [[relays]] as defined below. [[relays]] diff --git a/crates/common/src/config/pbs.rs b/crates/common/src/config/pbs.rs index 980da29c..3b36d40f 100644 --- a/crates/common/src/config/pbs.rs +++ b/crates/common/src/config/pbs.rs @@ -80,6 +80,11 @@ pub struct PbsConfig { /// How late in the slot we consider to be "late" #[serde(default = "default_u64::")] pub late_in_slot_time_ms: u64, + /// Enable extra validation of get_header responses + #[serde(default = "default_bool::")] + pub extra_validation_enabled: bool, + /// Execution Layer RPC url to use for extra validation + pub rpc_url: Option, } impl PbsConfig { @@ -104,6 +109,13 @@ impl PbsConfig { format!("min bid is too high: {} ETH", format_ether(self.min_bid_wei)) ); + if self.extra_validation_enabled { + ensure!( + self.rpc_url.is_some(), + "rpc_url is required if extra_validation_enabled is true" + ); + } + Ok(()) } } diff --git a/crates/common/src/pbs/error.rs b/crates/common/src/pbs/error.rs index 8330d03a..ba40e883 100644 --- a/crates/common/src/pbs/error.rs +++ b/crates/common/src/pbs/error.rs @@ -69,4 +69,13 @@ pub enum ValidationError { #[error("failed signature verification: {0:?}")] Sigverify(#[from] BlstErrorWrapper), + + #[error("wrong timestamp: expected {expected} got {got}")] + TimestampMismatch { expected: u64, got: u64 }, + + #[error("wrong block number: parent: {parent} header: {header}")] + BlockNumberMismatch { parent: u64, header: u64 }, + + #[error("invalid gas limit: parent: {parent} header: {header}")] + GasLimit { parent: u64, header: u64 }, } diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 63fc06e9..a1bbfe8d 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -25,9 +25,11 @@ use crate::{ const MILLIS_PER_SECOND: u64 = 1_000; +pub fn timestamp_of_slot_start_sec(slot: u64, chain: Chain) -> u64 { + chain.genesis_time_sec() + slot * chain.slot_time_sec() +} pub fn timestamp_of_slot_start_millis(slot: u64, chain: Chain) -> u64 { - let slot_start_seconds = chain.genesis_time_sec() + slot * chain.slot_time_sec(); - slot_start_seconds * MILLIS_PER_SECOND + timestamp_of_slot_start_sec(slot, chain) * MILLIS_PER_SECOND } pub fn ms_into_slot(slot: u64, chain: Chain) -> u64 { let slot_start_ms = timestamp_of_slot_start_millis(slot, chain); diff --git a/crates/pbs/Cargo.toml b/crates/pbs/Cargo.toml index 7d6809b1..5ef8bc64 100644 --- a/crates/pbs/Cargo.toml +++ b/crates/pbs/Cargo.toml @@ -21,6 +21,7 @@ tokio.workspace = true futures.workspace = true async-trait.workspace = true dashmap.workspace = true +parking_lot.workspace = true # serialization serde_json.workspace = true @@ -37,4 +38,4 @@ thiserror.workspace = true eyre.workspace = true url.workspace = true uuid.workspace = true -lazy_static.workspace = true \ No newline at end of file +lazy_static.workspace = true diff --git a/crates/pbs/src/mev_boost/get_header.rs b/crates/pbs/src/mev_boost/get_header.rs index f51e30b5..b13a4e0c 100644 --- a/crates/pbs/src/mev_boost/get_header.rs +++ b/crates/pbs/src/mev_boost/get_header.rs @@ -1,12 +1,15 @@ -use std::time::{Duration, Instant}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use alloy::{ primitives::{utils::format_ether, B256, U256}, - rpc::types::beacon::BlsPublicKey, + providers::Provider, + rpc::types::{beacon::BlsPublicKey, Block, BlockTransactionsKind}, }; use axum::http::{HeaderMap, HeaderValue}; use cb_common::{ - config::PbsConfig, constants::APPLICATION_BUILDER_DOMAIN, pbs::{ error::{PbsError, ValidationError}, @@ -15,9 +18,10 @@ use cb_common::{ }, signature::verify_signed_message, types::Chain, - utils::{get_user_agent_with_version, ms_into_slot, utcnow_ms}, + utils::{get_user_agent_with_version, ms_into_slot, timestamp_of_slot_start_sec, utcnow_ms}, }; use futures::future::join_all; +use parking_lot::RwLock; use reqwest::{header::USER_AGENT, StatusCode}; use tokio::time::sleep; use tracing::{debug, error, warn, Instrument}; @@ -29,7 +33,7 @@ use crate::{ }, metrics::{RELAY_HEADER_VALUE, RELAY_LAST_SLOT, RELAY_LATENCY, RELAY_STATUS_CODE}, state::{BuilderApiState, PbsState}, - utils::read_chunked_body_with_max, + utils::{check_gas_limit, read_chunked_body_with_max}, }; /// Implements https://ethereum.github.io/builder-specs/#/Builder/getHeader @@ -39,6 +43,13 @@ pub async fn get_header( req_headers: HeaderMap, state: PbsState, ) -> eyre::Result> { + let parent_block = Arc::new(RwLock::new(None)); + if state.extra_validation_enabled() { + if let Some(rpc_url) = state.pbs_config().rpc_url.clone() { + tokio::spawn(fetch_parent_block(rpc_url, params.parent_hash, parent_block.clone())); + } + } + let ms_into_slot = ms_into_slot(params.slot, state.config.chain); let max_timeout_ms = state .pbs_config() @@ -69,10 +80,15 @@ pub async fn get_header( params, relay.clone(), state.config.chain, - state.pbs_config(), send_headers.clone(), ms_into_slot, max_timeout_ms, + ValidationContext { + skip_sigverify: state.pbs_config().skip_sigverify, + min_bid_wei: state.pbs_config().min_bid_wei, + extra_validation_enabled: state.extra_validation_enabled(), + parent_block: parent_block.clone(), + }, )); } @@ -99,15 +115,41 @@ pub async fn get_header( Ok(state.add_bids(params.slot, relay_bids)) } +/// Fetch the parent block from the RPC URL for extra validation of the header. +/// Extra validation will be skipped if: +/// - relay returns header before parent block is fetched +/// - parent block is not found, eg because of a RPC delay +#[tracing::instrument(skip_all, name = "parent_block_fetch")] +async fn fetch_parent_block( + rpc_url: Url, + parent_hash: B256, + parent_block: Arc>>, +) { + let provider = alloy::providers::ProviderBuilder::new().on_http(rpc_url).to_owned(); + + debug!(%parent_hash, "fetching parent block"); + + match provider.get_block_by_hash(parent_hash, BlockTransactionsKind::Hashes).await { + Ok(maybe_block) => { + debug!(block_found = maybe_block.is_some(), "fetched parent block"); + let mut guard = parent_block.write(); + *guard = maybe_block; + } + Err(err) => { + error!(%err, "fetch failed"); + } + } +} + #[tracing::instrument(skip_all, name = "handler", fields(relay_id = relay.id.as_ref()))] async fn send_timed_get_header( params: GetHeaderParams, relay: RelayClient, chain: Chain, - pbs_config: &PbsConfig, headers: HeaderMap, ms_into_slot: u64, mut timeout_left_ms: u64, + validation: ValidationContext, ) -> Result, PbsError> { let url = relay.get_header_url(params.slot, params.parent_hash, params.pubkey)?; @@ -136,13 +178,12 @@ async fn send_timed_get_header( params, relay.clone(), chain, - pbs_config.skip_sigverify, - pbs_config.min_bid_wei, - RequestConfig { + RequestContext { timeout_ms: timeout_left_ms, url: url.clone(), headers: headers.clone(), }, + validation.clone(), ) .in_current_span(), )); @@ -202,27 +243,33 @@ async fn send_timed_get_header( params, relay, chain, - pbs_config.skip_sigverify, - pbs_config.min_bid_wei, - RequestConfig { timeout_ms: timeout_left_ms, url, headers }, + RequestContext { timeout_ms: timeout_left_ms, url, headers }, + validation, ) .await .map(|(_, maybe_header)| maybe_header) } -struct RequestConfig { +struct RequestContext { url: Url, timeout_ms: u64, headers: HeaderMap, } +#[derive(Clone)] +struct ValidationContext { + skip_sigverify: bool, + min_bid_wei: U256, + extra_validation_enabled: bool, + parent_block: Arc>>, +} + async fn send_one_get_header( params: GetHeaderParams, relay: RelayClient, chain: Chain, - skip_sigverify: bool, - min_bid_wei: U256, - mut req_config: RequestConfig, + mut 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 @@ -295,10 +342,20 @@ async fn send_one_get_header( chain, relay.pubkey(), params.parent_hash, - skip_sigverify, - min_bid_wei, + validation.skip_sigverify, + validation.min_bid_wei, + params.slot, )?; + if validation.extra_validation_enabled { + let parent_block = validation.parent_block.read(); + if let Some(parent_block) = parent_block.as_ref() { + extra_validation(parent_block, &get_header_response.data)?; + } else { + warn!("parent block not found, skipping extra validation"); + } + } + Ok((start_request_time, Some(get_header_response))) } @@ -309,6 +366,7 @@ fn validate_header( parent_hash: B256, skip_sig_verify: bool, minimum_bid_wei: U256, + slot: u64, ) -> Result<(), ValidationError> { let block_hash = signed_header.message.header.block_hash; let received_relay_pubkey = signed_header.message.pubkey; @@ -341,6 +399,14 @@ fn validate_header( }); } + let expected_timestamp = timestamp_of_slot_start_sec(slot, chain); + if expected_timestamp != signed_header.message.header.timestamp { + return Err(ValidationError::TimestampMismatch { + expected: expected_timestamp, + got: signed_header.message.header.timestamp, + }) + } + if !skip_sig_verify { verify_signed_message( chain, @@ -355,6 +421,27 @@ fn validate_header( Ok(()) } +fn extra_validation( + parent_block: &Block, + signed_header: &SignedExecutionPayloadHeader, +) -> Result<(), ValidationError> { + if signed_header.message.header.block_number != parent_block.header.number + 1 { + return Err(ValidationError::BlockNumberMismatch { + parent: parent_block.header.number, + header: signed_header.message.header.block_number, + }); + } + + if !check_gas_limit(signed_header.message.header.gas_limit, parent_block.header.gas_limit) { + return Err(ValidationError::GasLimit { + parent: parent_block.header.number, + header: signed_header.message.header.block_number, + }); + }; + + Ok(()) +} + #[cfg(test)] mod tests { use alloy::{ @@ -366,6 +453,7 @@ mod tests { pbs::{error::ValidationError, SignedExecutionPayloadHeader, EMPTY_TX_ROOT_HASH}, signature::sign_builder_message, types::Chain, + utils::timestamp_of_slot_start_sec, }; use super::validate_header; @@ -374,6 +462,7 @@ mod tests { fn test_validate_header() { let mut mock_header = SignedExecutionPayloadHeader::default(); + let slot = 5; let parent_hash = B256::from_slice(&[1; 32]); let chain = Chain::Holesky; let min_bid = U256::from(10); @@ -394,7 +483,8 @@ mod tests { BlsPublicKey::default(), parent_hash, false, - min_bid + min_bid, + slot, ), Err(ValidationError::EmptyBlockhash) ); @@ -408,7 +498,8 @@ mod tests { BlsPublicKey::default(), parent_hash, false, - min_bid + min_bid, + slot, ), Err(ValidationError::ParentHashMismatch { expected: parent_hash, @@ -425,7 +516,8 @@ mod tests { BlsPublicKey::default(), parent_hash, false, - min_bid + min_bid, + slot, ), Err(ValidationError::EmptyTxRoot) ); @@ -439,7 +531,8 @@ mod tests { BlsPublicKey::default(), parent_hash, false, - min_bid + min_bid, + slot, ), Err(ValidationError::BidTooLow { min: min_bid, got: U256::ZERO }) ); @@ -455,19 +548,32 @@ mod tests { BlsPublicKey::default(), parent_hash, false, - min_bid + min_bid, + slot, ), Err(ValidationError::PubkeyMismatch { expected: BlsPublicKey::default(), got: pubkey }) ); + let expected = timestamp_of_slot_start_sec(slot, chain); + assert_eq!( + validate_header(&mock_header, chain, pubkey, parent_hash, false, min_bid, slot,), + Err(ValidationError::TimestampMismatch { expected, got: 0 }) + ); + + mock_header.message.header.timestamp = expected; + assert!(matches!( - validate_header(&mock_header, chain, pubkey, parent_hash, false, min_bid), + validate_header(&mock_header, chain, pubkey, parent_hash, false, min_bid, slot), Err(ValidationError::Sigverify(_)) )); - assert!(validate_header(&mock_header, chain, pubkey, parent_hash, true, min_bid).is_ok()); + assert!( + validate_header(&mock_header, chain, pubkey, parent_hash, true, min_bid, slot).is_ok() + ); mock_header.signature = sign_builder_message(chain, &secret_key, &mock_header.message); - assert!(validate_header(&mock_header, chain, pubkey, parent_hash, false, min_bid).is_ok()) + assert!( + validate_header(&mock_header, chain, pubkey, parent_hash, false, min_bid, slot).is_ok() + ) } } diff --git a/crates/pbs/src/state.rs b/crates/pbs/src/state.rs index 5fb48e1c..eb910f0a 100644 --- a/crates/pbs/src/state.rs +++ b/crates/pbs/src/state.rs @@ -78,6 +78,7 @@ where pub fn pbs_config(&self) -> &PbsConfig { &self.config.pbs_config } + pub fn relays(&self) -> &[RelayClient] { &self.config.relays } @@ -86,6 +87,10 @@ where !self.config.pbs_config.relay_monitors.is_empty() } + pub fn extra_validation_enabled(&self) -> bool { + self.config.pbs_config.extra_validation_enabled + } + /// Add some bids to the cache, the bids are all assumed to be for the /// provided slot Returns the bid with the max value pub fn add_bids(&self, slot: u64, bids: Vec) -> Option { diff --git a/crates/pbs/src/utils.rs b/crates/pbs/src/utils.rs index a7b99f00..f1673431 100644 --- a/crates/pbs/src/utils.rs +++ b/crates/pbs/src/utils.rs @@ -25,3 +25,25 @@ pub async fn read_chunked_body_with_max( Ok(response_bytes) } + +const GAS_LIMIT_ADJUSTMENT_FACTOR: u64 = 1024; +const GAS_LIMIT_MINIMUM: u64 = 5_000; + +/// Validates the gas limit against the parent gas limit, according to the +/// execution spec https://github.com/ethereum/execution-specs/blob/98d6ddaaa709a2b7d0cd642f4cfcdadc8c0808e1/src/ethereum/cancun/fork.py#L1118-L1154 +pub fn check_gas_limit(gas_limit: u64, parent_gas_limit: u64) -> bool { + let max_adjustment_delta = parent_gas_limit / GAS_LIMIT_ADJUSTMENT_FACTOR; + if gas_limit >= parent_gas_limit + max_adjustment_delta { + return false; + } + + if gas_limit <= parent_gas_limit - max_adjustment_delta { + return false; + } + + if gas_limit < GAS_LIMIT_MINIMUM { + return false; + } + + true +} diff --git a/tests/src/mock_relay.rs b/tests/src/mock_relay.rs index 80dd8f15..672ca806 100644 --- a/tests/src/mock_relay.rs +++ b/tests/src/mock_relay.rs @@ -22,7 +22,7 @@ use cb_common::{ signature::sign_builder_root, signer::BlsSecretKey, types::Chain, - utils::blst_pubkey_to_alloy, + utils::{blst_pubkey_to_alloy, timestamp_of_slot_start_sec}, }; use cb_pbs::MAX_SIZE_SUBMIT_BLOCK; use tokio::net::TcpListener; @@ -104,9 +104,10 @@ async fn handle_get_header( response.data.message.header.block_hash.0[0] = 1; response.data.message.value = U256::from(10); response.data.message.pubkey = blst_pubkey_to_alloy(&state.signer.sk_to_pk()); + response.data.message.header.timestamp = timestamp_of_slot_start_sec(0, state.chain); + let object_root = response.data.message.tree_hash_root().0; response.data.signature = sign_builder_root(state.chain, &state.signer, object_root); - (StatusCode::OK, axum::Json(response)).into_response() } diff --git a/tests/tests/pbs_integration.rs b/tests/tests/pbs_integration.rs index 81bff558..9fdf2bab 100644 --- a/tests/tests/pbs_integration.rs +++ b/tests/tests/pbs_integration.rs @@ -35,6 +35,8 @@ fn get_pbs_static_config(port: u16) -> PbsConfig { min_bid_wei: U256::ZERO, late_in_slot_time_ms: u64::MAX, relay_monitors: vec![], + extra_validation_enabled: false, + rpc_url: None, } }