Skip to content

Commit e6bc9c7

Browse files
committed
validate using the submitted block's fork, not the relay's fork
1 parent ef6e25a commit e6bc9c7

4 files changed

Lines changed: 99 additions & 13 deletions

File tree

‎crates/common/src/pbs/error.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ pub enum ValidationError {
119119

120120
#[error("unsupported fork")]
121121
UnsupportedFork,
122+
123+
#[error("fork mismatch: request is {expected} but relay response is {got}")]
124+
ForkMismatch { expected: ForkName, got: ForkName },
122125
}
123126

124127
#[derive(Debug, Error, PartialEq, Eq)]

‎crates/pbs/src/mev_boost/submit_block.rs‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,19 @@ async fn send_submit_block(
221221
// Extract the info needed for validation
222222
let got_block_hash = response.data.execution_payload.block_hash().0;
223223

224-
// request has different type so cant be deserialized in the wrong version,
225-
// response has a "version" field
224+
// Reject if response's fork mismatches BlindedBeaconBlock's fork
225+
let expected_fork = match &proposal_info.signed_blinded_block.message() {
226+
BlindedBeaconBlock::Electra(_) => ForkName::Electra,
227+
BlindedBeaconBlock::Fulu(_) => ForkName::Fulu,
228+
_ => return Err(PbsError::Validation(ValidationError::UnsupportedFork)),
229+
};
230+
if response.version != expected_fork {
231+
return Err(PbsError::Validation(ValidationError::ForkMismatch {
232+
expected: expected_fork,
233+
got: response.version,
234+
}));
235+
}
236+
226237
match &proposal_info.signed_blinded_block.message() {
227238
BlindedBeaconBlock::Electra(blinded_block) => {
228239
let expected_block_hash =
@@ -234,7 +245,7 @@ async fn send_submit_block(
234245
got_block_hash,
235246
expected_commitments,
236247
&response.data.blobs_bundle,
237-
response.version,
248+
expected_fork,
238249
)
239250
}
240251

@@ -248,7 +259,7 @@ async fn send_submit_block(
248259
got_block_hash,
249260
expected_commitments,
250261
&response.data.blobs_bundle,
251-
response.version,
262+
expected_fork,
252263
)
253264
}
254265

@@ -525,12 +536,6 @@ fn validate_unblinded_block(
525536
fork_name: ForkName,
526537
) -> Result<(), PbsError> {
527538
match fork_name {
528-
ForkName::Base |
529-
ForkName::Altair |
530-
ForkName::Bellatrix |
531-
ForkName::Capella |
532-
ForkName::Deneb |
533-
ForkName::Gloas => Err(PbsError::Validation(ValidationError::UnsupportedFork)),
534539
ForkName::Electra => validate_unblinded_block_electra(
535540
expected_block_hash,
536541
got_block_hash,
@@ -543,6 +548,7 @@ fn validate_unblinded_block(
543548
expected_commitments,
544549
blobs_bundle,
545550
),
551+
_ => Err(PbsError::Validation(ValidationError::UnsupportedFork)),
546552
}
547553
}
548554

‎tests/src/mock_relay.rs‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ use std::{
88
time::Duration,
99
};
1010

11-
use alloy::{primitives::U256, rpc::types::beacon::relay::ValidatorRegistration};
11+
use alloy::{
12+
eips::eip7594::CELLS_PER_EXT_BLOB, primitives::U256,
13+
rpc::types::beacon::relay::ValidatorRegistration,
14+
};
1215
use axum::{
1316
Json, Router,
1417
extract::{Path, State},
@@ -76,6 +79,10 @@ pub struct MockRelayState {
7679
/// negotiated via `Accept`. Used to exercise PBS tolerance of
7780
/// MIME-parameter suffixes like `application/octet-stream; charset=binary`.
7881
response_content_type_override: Option<String>,
82+
/// If set, `handle_submit_block_v1` labels its JSON response with this fork
83+
/// in the `version` field instead of the request's fork. Used to drive the
84+
/// fork-mismatch rejection test
85+
submit_block_version_override: Option<ForkName>,
7986
received_get_header: Arc<AtomicU64>,
8087
received_get_status: Arc<AtomicU64>,
8188
received_register_validator: Arc<AtomicU64>,
@@ -112,6 +119,9 @@ impl MockRelayState {
112119
pub fn response_content_type_override(&self) -> Option<&str> {
113120
self.response_content_type_override.as_deref()
114121
}
122+
pub fn submit_block_version_override(&self) -> Option<ForkName> {
123+
self.submit_block_version_override
124+
}
115125
pub fn set_response_override(&self, status: StatusCode) {
116126
*self.response_override.write().unwrap() = Some(status);
117127
}
@@ -127,6 +137,7 @@ impl MockRelayState {
127137
use_not_found_for_submit_block: false,
128138
submit_block_ssz_status_override: None,
129139
response_content_type_override: None,
140+
submit_block_version_override: None,
130141
received_get_header: Default::default(),
131142
received_get_status: Default::default(),
132143
received_register_validator: Default::default(),
@@ -174,6 +185,13 @@ impl MockRelayState {
174185
pub fn with_response_content_type(self, raw_content_type: impl Into<String>) -> Self {
175186
Self { response_content_type_override: Some(raw_content_type.into()), ..self }
176187
}
188+
189+
/// Make `handle_submit_block_v1` label its JSON response with `fork` in the
190+
/// `version` field, regardless of the request's fork. Used to exercise the
191+
/// PBS fork-mismatch rejection.
192+
pub fn with_submit_block_version(self, fork: ForkName) -> Self {
193+
Self { submit_block_version_override: Some(fork), ..self }
194+
}
177195
}
178196

179197
pub fn mock_relay_app_router(state: Arc<MockRelayState>) -> Router {
@@ -366,7 +384,17 @@ async fn handle_submit_block_v1(
366384
blobs_bundle.blobs.push(Default::default()).unwrap();
367385
blobs_bundle.commitments =
368386
submit_block.as_electra().unwrap().message.body.blob_kzg_commitments.clone();
369-
blobs_bundle.proofs.push(KzgProof([0; 48])).unwrap();
387+
// Emit a proof layout matching the fork we label the response with, so a
388+
// mismatch-labelled response still passes that fork's blob validation and
389+
// the only thing left to reject it is the fork check itself. Electra uses
390+
// one proof per blob; Fulu uses CELLS_PER_EXT_BLOB per blob.
391+
let proofs_per_blob = match state.submit_block_version_override() {
392+
Some(ForkName::Fulu) => CELLS_PER_EXT_BLOB,
393+
_ => 1,
394+
};
395+
for _ in 0..(blobs_bundle.blobs.len() * proofs_per_blob) {
396+
blobs_bundle.proofs.push(KzgProof([0; 48])).unwrap();
397+
}
370398

371399
let response =
372400
PayloadAndBlobs { execution_payload: execution_payload.into(), blobs_bundle };
@@ -376,7 +404,7 @@ async fn handle_submit_block_v1(
376404
} else {
377405
// Return JSON for everything else; this is fine for the mock
378406
let response = SubmitBlindedBlockResponse {
379-
version: ForkName::Electra,
407+
version: state.submit_block_version_override().unwrap_or(ForkName::Electra),
380408
metadata: Default::default(),
381409
data: response,
382410
};

‎tests/tests/pbs_post_blinded_blocks.rs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,55 @@ async fn test_submit_block_too_large() -> Result<()> {
281281
Ok(())
282282
}
283283

284+
// A relay must not be able to have its response validated, or its version
285+
// header forwarded to the beacon node, under a fork other than the one the
286+
// request was for. Here the relay answers an Electra request but labels the
287+
// JSON response Fulu; PBS must reject it rather than trust the relay-controlled
288+
// version.
289+
#[tokio::test]
290+
async fn test_submit_block_rejects_fork_mismatch() -> Result<()> {
291+
setup_test_env();
292+
let signer = random_secret();
293+
let pubkey = signer.public_key();
294+
let chain = Chain::Holesky;
295+
let pbs_listener = get_free_listener().await;
296+
let relay_listener = get_free_listener().await;
297+
let pbs_port = pbs_listener.local_addr().unwrap().port();
298+
let relay_port = relay_listener.local_addr().unwrap().port();
299+
300+
let mock_relay = generate_mock_relay(relay_port, pubkey)?;
301+
let mut mock_relay_state =
302+
MockRelayState::new(chain, signer).with_submit_block_version(ForkName::Fulu);
303+
// JSON-only so the response parses cleanly and the rejection is on the fork
304+
// mismatch, not an SSZ decode error.
305+
mock_relay_state.supported_content_types = Arc::new(HashSet::from([EncodingType::Json]));
306+
let mock_state = Arc::new(mock_relay_state);
307+
tokio::spawn(start_mock_relay_service_with_listener(mock_state.clone(), relay_listener));
308+
309+
let config = to_pbs_config(chain, get_pbs_config(pbs_port), vec![mock_relay]);
310+
let state = PbsState::new(config, PathBuf::new());
311+
drop(pbs_listener);
312+
tokio::spawn(PbsService::run::<(), DefaultBuilderApi>(state));
313+
tokio::time::sleep(Duration::from_millis(100)).await;
314+
315+
let mock_validator = MockValidator::new(pbs_port)?;
316+
let res = mock_validator
317+
.do_submit_block_v1(
318+
Some(load_test_signed_blinded_block()),
319+
vec![EncodingType::Json],
320+
EncodingType::Json,
321+
ForkName::Electra,
322+
)
323+
.await?;
324+
325+
// The relay was reached (SSZ attempt 415s on this JSON-only relay, then a
326+
// JSON retry carries the mismatched version), but the fork-mismatched
327+
// response is rejected rather than trusted.
328+
assert_eq!(mock_state.received_submit_block(), 2);
329+
assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
330+
Ok(())
331+
}
332+
284333
#[allow(clippy::too_many_arguments)]
285334
async fn submit_block_impl(
286335
api_version: BuilderApiVersion,

0 commit comments

Comments
 (0)