Skip to content

Commit 4f2afff

Browse files
committed
fix(pbs): derive a proposal's fork from its slot, not the JSON decode
1 parent 9af88d3 commit 4f2afff

4 files changed

Lines changed: 144 additions & 13 deletions

File tree

‎config.example.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Chain spec ID. Supported values:
55
# A network ID. Supported values: Mainnet, Holesky, Sepolia, Hoodi. Lower case values e.g. "mainnet" are also accepted
66
# A custom object, e.g., chain = { genesis_time_secs = 1695902400, path = "/path/to/spec.json" }, with a path to a chain spec file, either in .json format (e.g., as returned by the beacon endpoint /eth/v1/config/spec), or in .yml format (see examples in tests/data).
7-
# A custom object, e.g., chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000", chain_id = 17000 }.
7+
# A custom object, e.g., chain = { genesis_time_secs = 1695902400, slot_time_secs = 12, genesis_fork_version = "0x01017000", fulu_fork_slot = 5283840, chain_id = 17000 }.
88
chain = "Holesky"
99

1010
# Configuration for the PBS module

‎crates/common/src/types.rs‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,11 @@ impl Chain {
181181
Chain::Holesky => KnownChain::Holesky.fulu_fork_slot(),
182182
Chain::Sepolia => KnownChain::Sepolia.fulu_fork_slot(),
183183
Chain::Hoodi => KnownChain::Hoodi.fulu_fork_slot(),
184-
Chain::Custom { slot_time_secs, .. } => *slot_time_secs,
184+
Chain::Custom { fulu_fork_slot, .. } => *fulu_fork_slot,
185185
}
186186
}
187187

188+
/// TODO(gloas): this resolves to Electra or Fulu only
188189
pub fn fork_by_slot(&self, slot: u64) -> ForkName {
189190
if slot >= self.fulu_fork_slot() { ForkName::Fulu } else { ForkName::Electra }
190191
}
@@ -490,6 +491,49 @@ mod tests {
490491
})
491492
}
492493

494+
/// A custom chain must report its CONFIGURED fulu fork slot.
495+
#[test]
496+
fn custom_chain_reports_its_configured_fulu_fork_slot() {
497+
let chain = Chain::Custom {
498+
genesis_time_secs: 1,
499+
slot_time_secs: 12,
500+
genesis_fork_version: [1, 0, 0, 0],
501+
fulu_fork_slot: 8192,
502+
chain_id: U256::from(123),
503+
};
504+
assert_eq!(chain.fulu_fork_slot(), 8192, "must not return slot_time_secs");
505+
}
506+
507+
/// `fork_by_slot` is the authoritative fork source for a proposal, so pin
508+
/// its boundary exactly: the fork slot itself is already Fulu.
509+
#[test]
510+
fn fork_by_slot_switches_at_the_fork_boundary() {
511+
let chain = Chain::Custom {
512+
genesis_time_secs: 1,
513+
slot_time_secs: 12,
514+
genesis_fork_version: [1, 0, 0, 0],
515+
fulu_fork_slot: 100,
516+
chain_id: U256::from(123),
517+
};
518+
assert_eq!(chain.fork_by_slot(99), ForkName::Electra);
519+
assert_eq!(chain.fork_by_slot(100), ForkName::Fulu);
520+
assert_eq!(chain.fork_by_slot(101), ForkName::Fulu);
521+
}
522+
523+
/// A chain that is Fulu from genesis (the devnet shape) reports Fulu at
524+
/// every slot, including slot 0.
525+
#[test]
526+
fn fork_by_slot_handles_fulu_from_genesis() {
527+
let chain = Chain::Custom {
528+
genesis_time_secs: 1,
529+
slot_time_secs: 12,
530+
genesis_fork_version: [1, 0, 0, 0],
531+
fulu_fork_slot: 0,
532+
chain_id: U256::from(123),
533+
};
534+
assert_eq!(chain.fork_by_slot(0), ForkName::Fulu);
535+
}
536+
493537
#[test]
494538
fn test_spec_mainnet_data_json() {
495539
let a = env!("CARGO_MANIFEST_DIR");

‎crates/common/src/wire.rs‎

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use axum::http::HeaderValue;
66
use bytes::Bytes;
77
use futures::StreamExt;
88
use headers_accept::Accept;
9-
use lh_types::{BeaconBlock, ForkName};
9+
use lh_types::{BeaconBlock, ForkName, SignedBeaconBlock, map_fork_name};
1010
use mediatype::{MediaType, ReadParams, names};
1111
use reqwest::{
1212
Response,
@@ -403,8 +403,22 @@ pub fn deserialize_body(
403403
};
404404

405405
match encoding {
406-
EncodingType::Json => serde_json::from_slice::<SignedBlindedBeaconBlock>(&body)
407-
.map_err(BodyDeserializeError::SerdeJsonError),
406+
EncodingType::Json => match get_consensus_version_header(headers) {
407+
// `SignedBlindedBeaconBlock` is untagged and Electra and Fulu are
408+
// field-identical, so a plain `from_slice` stops at Electra and
409+
// reports the wrong fork for every Fulu block.
410+
Some(version) => Ok(map_fork_name!(
411+
version,
412+
SignedBeaconBlock,
413+
serde_json::from_slice(&body).map_err(BodyDeserializeError::SerdeJsonError)?
414+
)),
415+
// builder-specs doesn't require the header for JSON bodies.
416+
// A request without it still has to decode and an untagged decode would silently pick
417+
// Electra. Assume Fulu to be conservative until ePBS warrants the refactor
418+
None => Ok(SignedBeaconBlock::Fulu(
419+
serde_json::from_slice(&body).map_err(BodyDeserializeError::SerdeJsonError)?,
420+
)),
421+
},
408422
EncodingType::Ssz => match get_consensus_version_header(headers) {
409423
Some(version) => SignedBlindedBeaconBlock::from_ssz_bytes_with(&body, |bytes| {
410424
BeaconBlock::from_ssz_bytes_for_fork(bytes, version)
@@ -930,4 +944,62 @@ mod test {
930944
let err = deserialize_body(&headers, body).unwrap_err();
931945
assert!(matches!(err, BodyDeserializeError::MissingVersionHeader));
932946
}
947+
948+
/// A blinded block body, encoded as JSON, with the given consensus version.
949+
///
950+
/// The Electra fixture is deliberately reused for the Fulu case: Electra
951+
/// and Fulu have *identical* `BeaconBlockBody` fields, and their
952+
/// `ExecutionPayloadHeader`s are identical too, so one byte string is a
953+
/// valid encoding of BOTH forks. That ambiguity is the whole point - the
954+
/// header is the only thing that can tell them apart.
955+
fn blinded_block_json(version: Option<&'static str>) -> (HeaderMap, Bytes) {
956+
let body = Bytes::from_static(include_bytes!(
957+
"pbs/types/testdata/signed-blinded-beacon-block-electra.json"
958+
));
959+
let mut headers = HeaderMap::new();
960+
headers.insert(CONTENT_TYPE, HeaderValue::from_static(APPLICATION_JSON));
961+
if let Some(version) = version {
962+
headers.insert(
963+
HeaderName::try_from(CONSENSUS_VERSION_HEADER).unwrap(),
964+
HeaderValue::from_static(version),
965+
);
966+
}
967+
(headers, body)
968+
}
969+
970+
/// A JSON body labelled `fulu` must decode AS Fulu.
971+
#[test]
972+
fn test_deserialize_body_json_decodes_fulu_as_fulu() {
973+
let (headers, body) = blinded_block_json(Some("fulu"));
974+
let block = deserialize_body(&headers, body).expect("fulu body decodes");
975+
assert_eq!(
976+
block.fork_name_unchecked(),
977+
ForkName::Fulu,
978+
"decoded fork must follow Eth-Consensus-Version, not the untagged variant scan"
979+
);
980+
}
981+
982+
/// The same bytes labelled `electra` must still decode as Electra
983+
/// The header is honoured in both directions
984+
#[test]
985+
fn test_deserialize_body_json_decodes_electra_as_electra() {
986+
let (headers, body) = blinded_block_json(Some("electra"));
987+
let block = deserialize_body(&headers, body).expect("electra body decodes");
988+
assert_eq!(block.fork_name_unchecked(), ForkName::Electra);
989+
}
990+
991+
/// The header is `required: false` for JSON requests in builder-specs
992+
/// ("Required if request is SSZ encoded"), so a body without it must still
993+
/// decode. It is assumed to be Fulu rather than left to the untagged scan,
994+
/// which would silently pick Electra.
995+
#[test]
996+
fn test_deserialize_body_json_without_version_header_assumes_fulu() {
997+
let (headers, body) = blinded_block_json(None);
998+
let block = deserialize_body(&headers, body).expect("decodes without the header");
999+
assert_eq!(
1000+
block.fork_name_unchecked(),
1001+
ForkName::Fulu,
1002+
"a headerless JSON body must not fall back to the untagged Electra match"
1003+
);
1004+
}
9331005
}

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

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ struct ProposalInfo {
4444

4545
/// The version of the submit_block route being used
4646
api_version: BuilderApiVersion,
47+
48+
/// Fork of this proposal, derived from its SLOT via the chain's fork
49+
/// schedule.
50+
///
51+
/// Deliberately not `signed_blinded_block.fork_name_unchecked()`:
52+
/// `SignedBlindedBeaconBlock` is an untagged enum, so a JSON body decodes
53+
/// into the first variant that parses, and field-identical forks (Electra
54+
/// and Fulu) cannot be told apart by shape. The slot can tell them apart,
55+
/// and unlike `Eth-Consensus-Version` it does not depend on the proposer
56+
/// sending a header that builder-specs makes optional for JSON.
57+
fork: ForkName,
4758
}
4859

4960
struct SubmitBlockResponseInfo {
@@ -92,8 +103,9 @@ pub async fn submit_block<S: BuilderApiState>(
92103
}
93104

94105
// Send requests to all relays concurrently
106+
let fork = state.config.chain.fork_by_slot(signed_blinded_block.slot().as_u64());
95107
let proposal_info =
96-
Arc::new(ProposalInfo { signed_blinded_block, headers: send_headers, api_version });
108+
Arc::new(ProposalInfo { signed_blinded_block, headers: send_headers, api_version, fork });
97109
let mut handles = Vec::with_capacity(state.all_relays().len());
98110
for relay in state.all_relays().iter() {
99111
handles.push(
@@ -201,12 +213,9 @@ async fn send_submit_block(
201213
// Extract the info needed for validation
202214
let got_block_hash = response.data.execution_payload.block_hash().0;
203215

204-
// Reject if response's fork mismatches BlindedBeaconBlock's fork
205-
let expected_fork = match &proposal_info.signed_blinded_block.message() {
206-
BlindedBeaconBlock::Electra(_) => ForkName::Electra,
207-
BlindedBeaconBlock::Fulu(_) => ForkName::Fulu,
208-
_ => return Err(PbsError::Validation(ValidationError::UnsupportedFork)),
209-
};
216+
// Reject if the response's fork mismatches the proposal's. Unsupported
217+
// forks are rejected by the variant match below.
218+
let expected_fork = proposal_info.fork;
210219
if response.version != expected_fork {
211220
return Err(PbsError::Validation(ValidationError::ForkMismatch {
212221
expected: expected_fork,
@@ -265,6 +274,7 @@ async fn send_submit_block_full(
265274
timeout_ms,
266275
&proposal_info.headers,
267276
&proposal_info.signed_blinded_block,
277+
proposal_info.fork,
268278
retry,
269279
api_version,
270280
)
@@ -320,12 +330,14 @@ fn decode_by_encoding<T>(
320330
/// Sends the actual HTTP request to the relay's submit_block endpoint,
321331
/// returning the response (if applicable), the round-trip time, and the
322332
/// encoding type used for the body (if any). Used by send_submit_block.
333+
#[allow(clippy::too_many_arguments)]
323334
async fn send_submit_block_impl(
324335
relay: &RelayClient,
325336
url: Arc<Url>,
326337
timeout_ms: u64,
327338
headers: &HeaderMap,
328339
signed_blinded_block: &SignedBlindedBeaconBlock,
340+
fork: ForkName,
329341
retry: u32,
330342
api_version: BuilderApiVersion,
331343
) -> Result<SubmitBlockResponseInfo, PbsError> {
@@ -339,7 +351,7 @@ async fn send_submit_block_impl(
339351
.headers(headers.clone())
340352
.body(signed_blinded_block.as_ssz_bytes())
341353
.header(CONTENT_TYPE, EncodingType::Ssz.content_type_header())
342-
.header(CONSENSUS_VERSION_HEADER, signed_blinded_block.fork_name_unchecked().to_string())
354+
.header(CONSENSUS_VERSION_HEADER, fork.to_string())
343355
.send()
344356
.await
345357
{
@@ -380,6 +392,9 @@ async fn send_submit_block_impl(
380392
.headers(headers.clone())
381393
.body(json_body)
382394
.header(CONTENT_TYPE, EncodingType::Json.content_type_header())
395+
// The SSZ attempt labels the fork; without it here the retry is the
396+
// ambiguous one, since Electra and Fulu are identical in JSON.
397+
.header(CONSENSUS_VERSION_HEADER, fork.to_string())
383398
.send()
384399
.await
385400
{

0 commit comments

Comments
 (0)