Skip to content

Commit 48e1eec

Browse files
authored
Add support for validation bypassing (#422)
1 parent a5d444d commit 48e1eec

File tree

26 files changed

+1626
-303
lines changed

26 files changed

+1626
-303
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config.example.toml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,16 @@ min_bid_eth = 0.0
4949
# to force local building and miniminzing the risk of missed slots. See also the timing games section below
5050
# OPTIONAL, DEFAULT: 2000
5151
late_in_slot_time_ms = 2000
52-
# Whether to enable extra validation of get_header responses, if this is enabled `rpc_url` must also be set
53-
# OPTIONAL, DEFAULT: false
54-
extra_validation_enabled = false
52+
# The level of validation to perform on get_header responses. Less is faster but not as safe. Supported values:
53+
# - "none": no validation, just accept the bid provided by the relay as-is and pass it back without decoding or checking it
54+
# - "standard": perform standard validation of the header provided by the relay, which checks the bid's signature and several hashes to make sure it's legal (default)
55+
# - "extra": perform extra validation on top of standard validation, which includes checking the bid against the execution layer via the `rpc_url` (requires `rpc_url` to be set)
56+
# OPTIONAL, DEFAULT: standard
57+
header_validation_mode = "standard"
58+
# The level of validation to perform on submit_block responses. Less is faster but not as safe. Supported values:
59+
# - "none": no validation, just accept the full unblinded block provided by the relay as-is and pass it back without decoding or checking it
60+
# - "standard": perform standard validation of the unblinded block provided by the relay, which verifies things like the included KZG commitments and the block hash (default)
61+
block_validation_mode = "standard"
5562
# Execution Layer RPC url to use for extra validation
5663
# OPTIONAL
5764
# rpc_url = "https://ethereum-holesky-rpc.publicnode.com"

crates/common/src/config/pbs.rs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,34 @@ use crate::{
3636
},
3737
};
3838

39+
/// Header validation modes for get_header responses
40+
#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)]
41+
#[serde(rename_all = "snake_case")]
42+
pub enum HeaderValidationMode {
43+
// Bypass all validation and minimize decoding, which is faster but requires complete trust in
44+
// the relays
45+
None,
46+
47+
// Validate the header itself, ensuring that it's for a correct block on the correct chain and
48+
// fork. This is the default mode.
49+
Standard,
50+
51+
// Standard header validation, plus validation that the parent block is correct as well
52+
Extra,
53+
}
54+
55+
/// Block validation modes for submit_block responses
56+
#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)]
57+
#[serde(rename_all = "snake_case")]
58+
pub enum BlockValidationMode {
59+
// Bypass all validation, which is faster but requires complete trust in the relays
60+
None,
61+
62+
// Validate the block matches the header previously received from get_header and that it's for
63+
// the correct chain and fork. This is the default mode.
64+
Standard,
65+
}
66+
3967
#[derive(Debug, Clone, Deserialize, Serialize)]
4068
#[serde(deny_unknown_fields)]
4169
pub struct RelayConfig {
@@ -120,8 +148,11 @@ pub struct PbsConfig {
120148
#[serde(default = "default_u64::<LATE_IN_SLOT_TIME_MS>")]
121149
pub late_in_slot_time_ms: u64,
122150
/// Enable extra validation of get_header responses
123-
#[serde(default = "default_bool::<false>")]
124-
pub extra_validation_enabled: bool,
151+
#[serde(default = "default_header_validation_mode")]
152+
pub header_validation_mode: HeaderValidationMode,
153+
/// Enable extra validation of submit_block requests
154+
#[serde(default = "default_block_validation_mode")]
155+
pub block_validation_mode: BlockValidationMode,
125156
/// Execution Layer RPC url to use for extra validation
126157
pub rpc_url: Option<Url>,
127158
/// URL for the user's own SSV node API endpoint
@@ -173,10 +204,10 @@ impl PbsConfig {
173204
format!("min bid is too high: {} ETH", format_ether(self.min_bid_wei))
174205
);
175206

176-
if self.extra_validation_enabled {
207+
if self.header_validation_mode == HeaderValidationMode::Extra {
177208
ensure!(
178209
self.rpc_url.is_some(),
179-
"rpc_url is required if extra_validation_enabled is true"
210+
"rpc_url is required if header_validation_mode is set to extra"
180211
);
181212
}
182213

@@ -412,6 +443,16 @@ pub async fn load_pbs_custom_config<T: DeserializeOwned>() -> Result<(PbsModuleC
412443
))
413444
}
414445

446+
/// Default value for header validation mode
447+
fn default_header_validation_mode() -> HeaderValidationMode {
448+
HeaderValidationMode::Standard
449+
}
450+
451+
/// Default value for block validation mode
452+
fn default_block_validation_mode() -> BlockValidationMode {
453+
BlockValidationMode::Standard
454+
}
455+
415456
/// Default URL for the user's SSV node API endpoint (/v1/validators).
416457
fn default_ssv_node_api_url() -> Url {
417458
Url::parse("http://localhost:16000/v1/").expect("default URL is valid")

crates/common/src/pbs/error.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,25 @@ pub enum ValidationError {
110110
#[error("unsupported fork")]
111111
UnsupportedFork,
112112
}
113+
114+
#[derive(Debug, Error, PartialEq, Eq)]
115+
pub enum SszValueError {
116+
#[error("invalid payload length: required {required} but payload was {actual}")]
117+
InvalidPayloadLength { required: usize, actual: usize },
118+
119+
#[error("unsupported fork")]
120+
UnsupportedFork { name: String },
121+
}
122+
123+
impl From<SszValueError> for PbsError {
124+
fn from(err: SszValueError) -> Self {
125+
match err {
126+
SszValueError::InvalidPayloadLength { required, actual } => PbsError::GeneralRequest(
127+
format!("invalid payload length: required {required} but payload was {actual}"),
128+
),
129+
SszValueError::UnsupportedFork { name } => {
130+
PbsError::GeneralRequest(format!("unsupported fork: {name}"))
131+
}
132+
}
133+
}
134+
}

crates/common/src/pbs/types/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,34 @@ pub type PayloadAndBlobs = lh_eth2::types::ExecutionPayloadAndBlobs<MainnetEthSp
2626
pub type SubmitBlindedBlockResponse = lh_types::ForkVersionedResponse<PayloadAndBlobs>;
2727

2828
pub type ExecutionPayloadHeader = lh_types::ExecutionPayloadHeader<MainnetEthSpec>;
29+
pub type ExecutionPayloadHeaderBellatrix =
30+
lh_types::ExecutionPayloadHeaderBellatrix<MainnetEthSpec>;
31+
pub type ExecutionPayloadHeaderCapella = lh_types::ExecutionPayloadHeaderCapella<MainnetEthSpec>;
32+
pub type ExecutionPayloadHeaderDeneb = lh_types::ExecutionPayloadHeaderDeneb<MainnetEthSpec>;
2933
pub type ExecutionPayloadHeaderElectra = lh_types::ExecutionPayloadHeaderElectra<MainnetEthSpec>;
3034
pub type ExecutionPayloadHeaderFulu = lh_types::ExecutionPayloadHeaderFulu<MainnetEthSpec>;
35+
pub type ExecutionPayloadHeaderGloas = lh_types::ExecutionPayloadHeaderGloas<MainnetEthSpec>;
3136
pub type ExecutionPayloadHeaderRef<'a> = lh_types::ExecutionPayloadHeaderRef<'a, MainnetEthSpec>;
3237
pub type ExecutionPayload = lh_types::ExecutionPayload<MainnetEthSpec>;
3338
pub type ExecutionPayloadElectra = lh_types::ExecutionPayloadElectra<MainnetEthSpec>;
3439
pub type ExecutionPayloadFulu = lh_types::ExecutionPayloadFulu<MainnetEthSpec>;
3540
pub type SignedBuilderBid = lh_types::builder_bid::SignedBuilderBid<MainnetEthSpec>;
3641
pub type BuilderBid = lh_types::builder_bid::BuilderBid<MainnetEthSpec>;
42+
pub type BuilderBidBellatrix = lh_types::builder_bid::BuilderBidBellatrix<MainnetEthSpec>;
43+
pub type BuilderBidCapella = lh_types::builder_bid::BuilderBidCapella<MainnetEthSpec>;
44+
pub type BuilderBidDeneb = lh_types::builder_bid::BuilderBidDeneb<MainnetEthSpec>;
3745
pub type BuilderBidElectra = lh_types::builder_bid::BuilderBidElectra<MainnetEthSpec>;
46+
pub type BuilderBidFulu = lh_types::builder_bid::BuilderBidFulu<MainnetEthSpec>;
47+
pub type BuilderBidGloas = lh_types::builder_bid::BuilderBidGloas<MainnetEthSpec>;
3848

3949
/// Response object of GET
4050
/// `/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}`
4151
pub type GetHeaderResponse = lh_types::ForkVersionedResponse<SignedBuilderBid>;
4252

4353
pub type KzgCommitments = lh_types::beacon_block_body::KzgCommitments<MainnetEthSpec>;
4454

55+
pub type Uint256 = lh_types::Uint256;
56+
4557
/// Response params of GET
4658
/// `/eth/v1/builder/header/{slot}/{parent_hash}/{pubkey}`
4759
#[derive(Debug, Serialize, Deserialize, Clone)]

crates/common/src/utils.rs

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#[cfg(feature = "testing-flags")]
22
use std::cell::Cell;
33
use std::{
4-
collections::HashSet,
4+
collections::{HashMap, HashSet},
55
fmt::Display,
66
net::Ipv4Addr,
77
str::FromStr,
@@ -17,9 +17,10 @@ use axum::{
1717
use bytes::Bytes;
1818
use futures::StreamExt;
1919
use headers_accept::Accept;
20+
use lazy_static::lazy_static;
2021
pub use lh_types::ForkName;
2122
use lh_types::{
22-
BeaconBlock,
23+
BeaconBlock, Signature,
2324
test_utils::{SeedableRng, TestRandom, XorShiftRng},
2425
};
2526
use rand::{Rng, distr::Alphanumeric};
@@ -29,7 +30,7 @@ use reqwest::{
2930
};
3031
use serde::{Serialize, de::DeserializeOwned};
3132
use serde_json::Value;
32-
use ssz::{Decode, Encode};
33+
use ssz::{BYTES_PER_LENGTH_OFFSET, Decode, Encode};
3334
use thiserror::Error;
3435
use tracing::Level;
3536
use tracing_appender::{non_blocking::WorkerGuard, rolling::Rotation};
@@ -42,7 +43,7 @@ use tracing_subscriber::{
4243
use crate::{
4344
config::LogsSettings,
4445
constants::SIGNER_JWT_EXPIRATION,
45-
pbs::{HEADER_VERSION_VALUE, SignedBlindedBeaconBlock},
46+
pbs::{error::SszValueError, *},
4647
types::{BlsPublicKey, Chain, Jwt, JwtClaims, ModuleId},
4748
};
4849

@@ -53,6 +54,25 @@ pub const WILDCARD: &str = "*/*";
5354
const MILLIS_PER_SECOND: u64 = 1_000;
5455
pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version";
5556

57+
lazy_static! {
58+
static ref SSZ_VALUE_OFFSETS_BY_FORK: HashMap<ForkName, usize> = {
59+
let mut map: HashMap<ForkName, usize> = HashMap::new();
60+
let forks = [
61+
ForkName::Bellatrix,
62+
ForkName::Capella,
63+
ForkName::Deneb,
64+
ForkName::Electra,
65+
ForkName::Fulu,
66+
ForkName::Gloas,
67+
];
68+
for fork in forks {
69+
let offset = get_ssz_value_offset_for_fork(fork).unwrap(); // If there isn't a supported fork, this needs to be updated prior to release so panicking is fine
70+
map.insert(fork, offset);
71+
}
72+
map
73+
};
74+
}
75+
5676
#[derive(Debug, Error)]
5777
pub enum ResponseReadError {
5878
#[error(
@@ -640,6 +660,115 @@ pub fn bls_pubkey_from_hex_unchecked(hex: &str) -> BlsPublicKey {
640660
bls_pubkey_from_hex(hex).unwrap()
641661
}
642662

663+
// Get the offset of the message in a SignedBuilderBid SSZ structure
664+
fn get_ssz_value_offset_for_fork(fork: ForkName) -> Option<usize> {
665+
match fork {
666+
ForkName::Bellatrix => {
667+
// Message goes header -> value -> pubkey
668+
Some(
669+
get_message_offset::<BuilderBidBellatrix>() +
670+
<ExecutionPayloadHeaderBellatrix as ssz::Decode>::ssz_fixed_len(),
671+
)
672+
}
673+
674+
ForkName::Capella => {
675+
// Message goes header -> value -> pubkey
676+
Some(
677+
get_message_offset::<BuilderBidCapella>() +
678+
<ExecutionPayloadHeaderCapella as ssz::Decode>::ssz_fixed_len(),
679+
)
680+
}
681+
682+
ForkName::Deneb => {
683+
// Message goes header -> blob_kzg_commitments -> value -> pubkey
684+
Some(
685+
get_message_offset::<BuilderBidDeneb>() +
686+
<ExecutionPayloadHeaderDeneb as ssz::Decode>::ssz_fixed_len() +
687+
<KzgCommitments as ssz::Decode>::ssz_fixed_len(),
688+
)
689+
}
690+
691+
ForkName::Electra => {
692+
// Message goes header -> blob_kzg_commitments -> execution_requests -> value ->
693+
// pubkey
694+
Some(
695+
get_message_offset::<BuilderBidElectra>() +
696+
<ExecutionPayloadHeaderElectra as ssz::Decode>::ssz_fixed_len() +
697+
<KzgCommitments as ssz::Decode>::ssz_fixed_len() +
698+
<ExecutionRequests as ssz::Decode>::ssz_fixed_len(),
699+
)
700+
}
701+
702+
ForkName::Fulu => {
703+
// Message goes header -> blob_kzg_commitments -> execution_requests -> value ->
704+
// pubkey
705+
Some(
706+
get_message_offset::<BuilderBidFulu>() +
707+
<ExecutionPayloadHeaderFulu as ssz::Decode>::ssz_fixed_len() +
708+
<KzgCommitments as ssz::Decode>::ssz_fixed_len() +
709+
<ExecutionRequests as ssz::Decode>::ssz_fixed_len(),
710+
)
711+
}
712+
713+
ForkName::Gloas => {
714+
// Message goes header -> blob_kzg_commitments -> execution_requests -> value ->
715+
// pubkey
716+
Some(
717+
get_message_offset::<BuilderBidGloas>() +
718+
<ExecutionPayloadHeaderGloas as ssz::Decode>::ssz_fixed_len() +
719+
<KzgCommitments as ssz::Decode>::ssz_fixed_len() +
720+
<ExecutionRequests as ssz::Decode>::ssz_fixed_len(),
721+
)
722+
}
723+
_ => None,
724+
}
725+
}
726+
727+
/// Extracts the bid value from SSZ-encoded SignedBuilderBid response bytes.
728+
pub fn get_bid_value_from_signed_builder_bid_ssz(
729+
response_bytes: &[u8],
730+
fork: ForkName,
731+
) -> Result<U256, SszValueError> {
732+
let value_offset = SSZ_VALUE_OFFSETS_BY_FORK
733+
.get(&fork)
734+
.ok_or(SszValueError::UnsupportedFork { name: fork.to_string() })?;
735+
736+
// Sanity check the response length so we don't panic trying to slice it
737+
let end_offset = value_offset + 32; // U256 is 32 bytes
738+
if response_bytes.len() < end_offset {
739+
return Err(SszValueError::InvalidPayloadLength {
740+
required: end_offset,
741+
actual: response_bytes.len(),
742+
});
743+
}
744+
745+
// Extract the value bytes and convert to U256
746+
let value_bytes = &response_bytes[*value_offset..end_offset];
747+
let value = U256::from_le_slice(value_bytes);
748+
Ok(value)
749+
}
750+
751+
// Get the offset where the `message` field starts in some SignedBuilderBid SSZ
752+
// data. Requires that SignedBuilderBid always has the following structure:
753+
// message -> signature
754+
// where `message` is a BuilderBid type determined by the fork choice, and
755+
// `signature` is a fixed-length Signature type.
756+
fn get_message_offset<BuilderBidType>() -> usize
757+
where
758+
BuilderBidType: ssz::Encode,
759+
{
760+
// Since `message` is the first field, its offset is always 0
761+
let mut offset = 0;
762+
763+
// If it's variable length, then it will be represented by a pointer to
764+
// the actual data, so we need to get the location of where that data starts
765+
if !BuilderBidType::is_ssz_fixed_len() {
766+
offset += BYTES_PER_LENGTH_OFFSET + <Signature as ssz::Decode>::ssz_fixed_len();
767+
}
768+
769+
offset
770+
}
771+
643772
#[cfg(test)]
644773
mod test {
645774
use axum::http::{HeaderMap, HeaderValue};

crates/pbs/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ axum.workspace = true
1212
axum-extra.workspace = true
1313
cb-common.workspace = true
1414
cb-metrics.workspace = true
15+
ethereum_serde_utils.workspace = true
1516
ethereum_ssz.workspace = true
1617
eyre.workspace = true
1718
futures.workspace = true
1819
headers.workspace = true
1920
lazy_static.workspace = true
21+
lh_types.workspace = true
2022
notify.workspace = true
2123
parking_lot.workspace = true
2224
prometheus.workspace = true

0 commit comments

Comments
 (0)