From 6e808f9d0d77422b6a5bfe83d1dd773fccbcb3ee Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Thu, 21 May 2026 16:58:05 -0300 Subject: [PATCH 01/11] feat(rpc): engine REST/SSZ API per execution-apis #764 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the binary SSZ engine API alongside JSON-RPC on the authrpc port, per execution-apis PR #764. JSON-RPC remains the default; supported endpoints are advertised via engine_exchangeCapabilities as strings like "POST /engine/v5/payloads" so the CL can opt into the binary transport per endpoint. Endpoints (version corresponds to engine_*V{N} JSON-RPC method version): POST /engine/v{1..5}/payloads newPayload GET /engine/v{1..6}/payloads/{payload_id} getPayload POST /engine/v{1,2}/payloads/bodies/by-hash getPayloadBodiesByHash POST /engine/v{1,2}/payloads/bodies/by-range getPayloadBodiesByRange POST /engine/v{1..4}/forkchoice forkchoiceUpdated POST /engine/v{1..3}/blobs getBlobs POST /engine/v1/client/version getClientVersion POST /engine/v1/capabilities exchangeCapabilities Surface lives under `crates/networking/rpc/engine_rest/`: - `types/`: SSZ wire types for every container in #764 (ExecutionPayloadV1..V4, BlobsBundleV1/V2, ForkchoiceState, payload attributes, payload bodies, blob requests/responses, capabilities, client version) plus shared constants and `SszOption = SszList` for SSZ's `Option` encoding. - `handlers/`: per-endpoint axum handlers. - `conversions.rs`: SSZ <-> ethrex internal type conversions. New direct SSZ payload -> Block decoders skip the JsonExecutionPayload intermediate on the incoming newPayload path; per-tx allocations go from two to one. - `auth.rs`: JWT bearer middleware (shared secret with JSON-RPC). - `extractors.rs`: `Ssz` axum extractor + Content-Type guard. - `responses.rs`: `SszBody` 200-OK wrapper with `application/octet-stream`. - `observe.rs`: per-request metrics + warn-on-error middleware feeding the same `rpc_request_duration_seconds` / `rpc_requests_total` series JSON-RPC uses. `error_kind` labels are carried in `EngineErrorContext` so they match the JSON-RPC vocabulary (`UnsupportedFork`, `WrongParam`, ...). - `error.rs`: typed `EngineRestError` (status + message + error_kind) with `From` mapping to consistent HTTP status codes (404 for UnknownPayload, 409 for InvalidForkChoiceState/TooDeepReorg, 422 for UnsupportedFork/InvalidPayloadAttributes, 413 for TooLargeRequest, 401 for auth failure, 400 for malformed params, 500 otherwise). - `tests.rs`: end-to-end tower-oneshot tests covering JWT, content-type rejection, capability advertisement, blobs empty mempool, bodies unknown-hash, forkchoice_v1 / new_payload_v1 status codes, payload-id parse errors, and pre-Shanghai newPayloadV2 with/without withdrawals. Also adds: - EIP-7783 `target_gas_limit` plumbed through `BuildPayloadArgs`. The 0-sentinel ("unset") collapse happens in `build_payload_v4` so JSON-RPC and SSZ converge on the same args and payload ID. JSON-RPC's `PayloadAttributesV4` gains `Option` via `hex_str_opt`. - The JSON-RPC `handle_new_payload_v{1,2,3,4}` signatures take `expected_block_hash: H256` directly instead of `&ExecutionPayload`; callers updated. - `ExecutionPayload::into_block` -> `to_block(&self, ...)` to avoid the payload clone on the JSON-RPC newPayload path. - engine_exchangeCapabilities (JSON-RPC) now advertises SSZ REST endpoints alongside method names. Fork gating + correctness: - newPayloadV2 rejects withdrawals on pre-Shanghai chains. - getBlobsV{2,3} require Osaka tip. - forkchoiceV1/V2 reject building Cancun payloads. - bodies_by_range_v{1,2} guard `start + count - 1` against overflow via `saturating_add(...).min(latest)` plus a `start > latest` short-circuit. - V2 body handlers run header lookup + EVM-bound BAL generation inside a single `spawn_blocking` per request (BAL re-executes the block); bodies_by_hash_v2 / bodies_by_range_v2 share `assemble_blocks_with_bal`. - client_version returns [0u8;4] on non-hex commit instead of 500. - payload_id parse uses a typed `PayloadIdParseError`. Limits: SSZ-side caps (`MAX_PAYLOAD_BODIES_REQUEST = 32`, etc.) match #764 spec values; JSON-RPC remains permissive (32 floor / up to 128). The body-limit asymmetry is documented on both constants. Per-endpoint Content-Length caps from #764 §Security considerations are not enforced; the authrpc router's global 256 MB DefaultBodyLimit covers both transports. Bench (`benches/engine_transport.rs`): - direct SSZ -> Block decode ~28us vs JSON-intermediate ~33us per 150-tx payload (~17% faster, ~80 KB less per request). - `blobs_bundle_to_ssz_v2` 6-blob bundle drops from ~10us to ~7ns by taking the bundle by value and moving Vecs. Workspace fallout: `BuildPayloadArgs { target_gas_limit: None }` added to four blockchain integration tests and one bench. `Cargo.lock` / `Cargo.toml` pull `libssz`, `libssz-derive`, `libssz-types`, `futures`, and a dev-only `tower`+`criterion` for the new bench. --- Cargo.lock | 10 +- benches/benches/build_block_benchmark.rs | 1 + crates/blockchain/payload.rs | 46 +- crates/l2/sequencer/block_producer.rs | 1 + crates/networking/rpc/Cargo.toml | 8 +- crates/networking/rpc/engine/fork_choice.rs | 20 +- crates/networking/rpc/engine/mod.rs | 7 +- crates/networking/rpc/engine/payload.rs | 70 +- crates/networking/rpc/engine_rest/auth.rs | 32 + .../networking/rpc/engine_rest/conversions.rs | 502 +++++++++++++++ crates/networking/rpc/engine_rest/error.rs | 175 +++++ .../networking/rpc/engine_rest/extractors.rs | 54 ++ .../rpc/engine_rest/handlers/blobs.rs | 206 ++++++ .../rpc/engine_rest/handlers/bodies.rs | 281 ++++++++ .../rpc/engine_rest/handlers/capabilities.rs | 38 ++ .../engine_rest/handlers/client_version.rs | 55 ++ .../rpc/engine_rest/handlers/forkchoice.rs | 263 ++++++++ .../rpc/engine_rest/handlers/mod.rs | 8 + .../rpc/engine_rest/handlers/payloads.rs | 433 +++++++++++++ crates/networking/rpc/engine_rest/mod.rs | 177 +++++ crates/networking/rpc/engine_rest/observe.rs | 99 +++ .../networking/rpc/engine_rest/responses.rs | 32 + .../networking/rpc/engine_rest/types/blobs.rs | 66 ++ .../rpc/engine_rest/types/bodies.rs | 55 ++ .../rpc/engine_rest/types/capabilities.rs | 18 + .../rpc/engine_rest/types/client_version.rs | 27 + .../rpc/engine_rest/types/common.rs | 170 +++++ .../engine_rest/types/execution_payload.rs | 100 +++ .../rpc/engine_rest/types/forkchoice.rs | 36 ++ .../rpc/engine_rest/types/get_payload.rs | 52 ++ .../networking/rpc/engine_rest/types/mod.rs | 13 + .../rpc/engine_rest/types/new_payload.rs | 48 ++ .../engine_rest/types/payload_attributes.rs | 41 ++ .../rpc/engine_rest/types/withdrawal.rs | 13 + .../networking/rpc/examples/decode_v4_body.rs | 53 ++ crates/networking/rpc/lib.rs | 1 + crates/networking/rpc/rpc.rs | 5 +- crates/networking/rpc/types/fork_choice.rs | 6 + crates/networking/rpc/types/payload.rs | 19 +- test/Cargo.toml | 7 + test/tests/blockchain/batch_tests.rs | 1 + .../eip7702_revert_authority_tests.rs | 1 + .../blockchain/eip7702_zero_transfer_tests.rs | 1 + test/tests/blockchain/smoke_tests.rs | 1 + test/tests/rpc/engine_rest_tests.rs | 504 +++++++++++++++ test/tests/rpc/mod.rs | 1 + tooling/Cargo.lock | 188 ++++++ tooling/Cargo.toml | 1 + tooling/engine_rest_bench/Cargo.toml | 27 + .../benches/engine_transport.rs | 603 ++++++++++++++++++ tooling/engine_rest_bench/src/lib.rs | 0 tooling/reorgs/src/simulator.rs | 2 +- 52 files changed, 4529 insertions(+), 49 deletions(-) create mode 100644 crates/networking/rpc/engine_rest/auth.rs create mode 100644 crates/networking/rpc/engine_rest/conversions.rs create mode 100644 crates/networking/rpc/engine_rest/error.rs create mode 100644 crates/networking/rpc/engine_rest/extractors.rs create mode 100644 crates/networking/rpc/engine_rest/handlers/blobs.rs create mode 100644 crates/networking/rpc/engine_rest/handlers/bodies.rs create mode 100644 crates/networking/rpc/engine_rest/handlers/capabilities.rs create mode 100644 crates/networking/rpc/engine_rest/handlers/client_version.rs create mode 100644 crates/networking/rpc/engine_rest/handlers/forkchoice.rs create mode 100644 crates/networking/rpc/engine_rest/handlers/mod.rs create mode 100644 crates/networking/rpc/engine_rest/handlers/payloads.rs create mode 100644 crates/networking/rpc/engine_rest/mod.rs create mode 100644 crates/networking/rpc/engine_rest/observe.rs create mode 100644 crates/networking/rpc/engine_rest/responses.rs create mode 100644 crates/networking/rpc/engine_rest/types/blobs.rs create mode 100644 crates/networking/rpc/engine_rest/types/bodies.rs create mode 100644 crates/networking/rpc/engine_rest/types/capabilities.rs create mode 100644 crates/networking/rpc/engine_rest/types/client_version.rs create mode 100644 crates/networking/rpc/engine_rest/types/common.rs create mode 100644 crates/networking/rpc/engine_rest/types/execution_payload.rs create mode 100644 crates/networking/rpc/engine_rest/types/forkchoice.rs create mode 100644 crates/networking/rpc/engine_rest/types/get_payload.rs create mode 100644 crates/networking/rpc/engine_rest/types/mod.rs create mode 100644 crates/networking/rpc/engine_rest/types/new_payload.rs create mode 100644 crates/networking/rpc/engine_rest/types/payload_attributes.rs create mode 100644 crates/networking/rpc/engine_rest/types/withdrawal.rs create mode 100644 crates/networking/rpc/examples/decode_v4_body.rs create mode 100644 test/tests/rpc/engine_rest_tests.rs create mode 100644 tooling/engine_rest_bench/Cargo.toml create mode 100644 tooling/engine_rest_bench/benches/engine_transport.rs create mode 100644 tooling/engine_rest_bench/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 597a67a451b..57ea0586f61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4334,11 +4334,13 @@ dependencies = [ "ethrex-storage-rollup", "ethrex-trie", "ethrex-vm", + "futures", "hex", "hex-literal 0.4.1", "jemalloc_pprof", "jsonwebtoken", - "libssz-merkle", + "libssz", + "libssz-derive", "libssz-types", "rand 0.8.5", "reqwest 0.12.28", @@ -4435,6 +4437,7 @@ version = "12.0.0" dependencies = [ "aes-gcm", "anyhow", + "axum 0.8.9", "bytes", "cita_trie", "ethereum-types 0.15.1", @@ -4457,7 +4460,10 @@ dependencies = [ "hasher", "hex", "hex-literal 0.4.1", + "jsonwebtoken", "lazy_static", + "libssz", + "libssz-types", "once_cell", "proptest", "rand 0.8.5", @@ -4465,9 +4471,11 @@ dependencies = [ "rkyv", "rustc-hash 2.1.2", "secp256k1", + "serde", "serde_json", "tokio", "tokio-util", + "tower 0.5.3", ] [[package]] diff --git a/benches/benches/build_block_benchmark.rs b/benches/benches/build_block_benchmark.rs index 6be13ac1256..544a538643f 100644 --- a/benches/benches/build_block_benchmark.rs +++ b/benches/benches/build_block_benchmark.rs @@ -150,6 +150,7 @@ fn create_payload_block(genesis_block: &Block, store: &Store) -> (Block, u64) { version: 3, elasticity_multiplier: 1, gas_ceil: DEFAULT_BUILDER_GAS_CEIL, + target_gas_limit: None, }; let id = payload_args.id(); let block = create_payload(&payload_args, store, Bytes::new()).unwrap(); diff --git a/crates/blockchain/payload.rs b/crates/blockchain/payload.rs index 0e2a36e2203..0c8a089473d 100644 --- a/crates/blockchain/payload.rs +++ b/crates/blockchain/payload.rs @@ -95,6 +95,10 @@ pub struct BuildPayloadArgs { pub version: u8, pub elasticity_multiplier: u64, pub gas_ceil: u64, + /// CL-provided target gas limit (EIP-7783, Amsterdam). When `Some`, takes + /// precedence over `gas_ceil` for `calc_gas_limit`. `None` falls back to + /// the builder's `gas_ceil`. + pub target_gas_limit: Option, } #[derive(Debug, Error)] @@ -117,6 +121,9 @@ impl BuildPayloadArgs { if let Some(beacon_root) = self.beacon_root { hasher.update(beacon_root); } + if let Some(target_gas_limit) = self.target_gas_limit { + hasher.update(target_gas_limit.to_be_bytes()); + } let res = &mut hasher.finalize()[..8]; res[0] = self.version; Ok(u64::from_be_bytes(res.try_into().map_err(|_| { @@ -137,7 +144,8 @@ pub fn create_payload( .ok_or_else(|| ChainError::ParentNotFound)?; let chain_config = storage.get_chain_config(); let fork = chain_config.fork(args.timestamp); - let gas_limit = calc_gas_limit(parent_block.gas_limit, args.gas_ceil); + let desired_gas_limit = args.target_gas_limit.unwrap_or(args.gas_ceil); + let gas_limit = calc_gas_limit(parent_block.gas_limit, desired_gas_limit); let excess_blob_gas = chain_config .get_fork_blob_schedule(args.timestamp) .map(|schedule| calc_excess_blob_gas(&parent_block, schedule, fork)); @@ -1072,3 +1080,39 @@ impl PartialOrd for HeadTransaction { Some(self.cmp(other)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn base_args() -> BuildPayloadArgs { + BuildPayloadArgs { + parent: BlockHash::zero(), + timestamp: 1_700_000_000, + fee_recipient: Address::zero(), + random: H256::zero(), + withdrawals: None, + beacon_root: None, + slot_number: Some(42), + version: 4, + elasticity_multiplier: 2, + gas_ceil: 30_000_000, + target_gas_limit: None, + } + } + + #[test] + fn payload_id_includes_target_gas_limit() { + let a = base_args(); + let mut b = base_args(); + b.target_gas_limit = Some(36_000_000); + assert_ne!(a.id().unwrap(), b.id().unwrap()); + } + + #[test] + fn payload_id_stable_when_target_unchanged() { + let a = base_args(); + let b = base_args(); + assert_eq!(a.id().unwrap(), b.id().unwrap()); + } +} diff --git a/crates/l2/sequencer/block_producer.rs b/crates/l2/sequencer/block_producer.rs index fd6026399f5..5993a9d3b05 100644 --- a/crates/l2/sequencer/block_producer.rs +++ b/crates/l2/sequencer/block_producer.rs @@ -159,6 +159,7 @@ impl BlockProducer { version, elasticity_multiplier: self.elasticity_multiplier, gas_ceil: self.block_gas_limit, + target_gas_limit: None, }; let payload = create_payload(&args, &self.store, Bytes::new())?; diff --git a/crates/networking/rpc/Cargo.toml b/crates/networking/rpc/Cargo.toml index 27852b0ce01..46ff557c171 100644 --- a/crates/networking/rpc/Cargo.toml +++ b/crates/networking/rpc/Cargo.toml @@ -42,10 +42,12 @@ jemalloc_pprof = { version = "0.8.0", optional = true, features = [ ] } spawned-rt.workspace = true spawned-concurrency.workspace = true +futures.workspace = true -# EIP-8025 dependencies (optional) -libssz-merkle = { workspace = true, optional = true } -libssz-types = { workspace = true, optional = true } +# Engine REST/SSZ (execution-apis PR #764) +libssz.workspace = true +libssz-derive.workspace = true +libssz-types.workspace = true # Clients envy = "0.4.2" diff --git a/crates/networking/rpc/engine/fork_choice.rs b/crates/networking/rpc/engine/fork_choice.rs index 6646fa58750..f92453c270a 100644 --- a/crates/networking/rpc/engine/fork_choice.rs +++ b/crates/networking/rpc/engine/fork_choice.rs @@ -207,7 +207,7 @@ fn parse( Ok((forkchoice_state, payload_attributes)) } -async fn handle_forkchoice( +pub(crate) async fn handle_forkchoice( fork_choice_state: &ForkChoiceState, context: RpcApiContext, version: usize, @@ -382,7 +382,7 @@ async fn handle_forkchoice( } } -fn validate_attributes_v1( +pub(crate) fn validate_attributes_v1( attributes: &PayloadAttributesV3, head_block: &BlockHeader, ) -> Result<(), RpcErr> { @@ -392,7 +392,7 @@ fn validate_attributes_v1( validate_timestamp(attributes, head_block) } -fn validate_attributes_v2( +pub(crate) fn validate_attributes_v2( attributes: &PayloadAttributesV3, head_block: &BlockHeader, ) -> Result<(), RpcErr> { @@ -402,7 +402,7 @@ fn validate_attributes_v2( validate_timestamp(attributes, head_block) } -fn validate_attributes_v2_pre_shanghai( +pub(crate) fn validate_attributes_v2_pre_shanghai( attributes: &PayloadAttributesV3, head_block: &BlockHeader, ) -> Result<(), RpcErr> { @@ -412,7 +412,7 @@ fn validate_attributes_v2_pre_shanghai( validate_timestamp(attributes, head_block) } -fn validate_attributes_v3( +pub(crate) fn validate_attributes_v3( attributes: &PayloadAttributesV3, head_block: &BlockHeader, context: &RpcApiContext, @@ -448,7 +448,7 @@ fn validate_timestamp( Ok(()) } -async fn build_payload( +pub(crate) async fn build_payload( attributes: &PayloadAttributesV3, context: RpcApiContext, fork_choice_state: &ForkChoiceState, @@ -465,6 +465,7 @@ async fn build_payload( version, elasticity_multiplier: ELASTICITY_MULTIPLIER, gas_ceil: context.gas_ceil, + target_gas_limit: None, }; let payload_id = args .id() @@ -514,7 +515,7 @@ fn parse_v4( Ok((forkchoice_state, payload_attributes)) } -fn validate_attributes_v4( +pub(crate) fn validate_attributes_v4( attributes: &PayloadAttributesV4, head_block: &BlockHeader, context: &RpcApiContext, @@ -551,7 +552,7 @@ fn validate_timestamp_v4( Ok(()) } -async fn build_payload_v4( +pub(crate) async fn build_payload_v4( attributes: &PayloadAttributesV4, context: RpcApiContext, fork_choice_state: &ForkChoiceState, @@ -567,6 +568,9 @@ async fn build_payload_v4( version: 4, elasticity_multiplier: ELASTICITY_MULTIPLIER, gas_ceil: context.gas_ceil, + // Collapse the EIP-7783 0-sentinel to `None`; see the field doc on + // `PayloadAttributesV4::target_gas_limit`. + target_gas_limit: attributes.target_gas_limit.filter(|&v| v != 0), }; let payload_id = args .id() diff --git a/crates/networking/rpc/engine/mod.rs b/crates/networking/rpc/engine/mod.rs index 753cec1aab5..2af7a7e9443 100644 --- a/crates/networking/rpc/engine/mod.rs +++ b/crates/networking/rpc/engine/mod.rs @@ -66,6 +66,11 @@ impl RpcHandler for ExchangeCapabilitiesRequest { } async fn handle(&self, _context: RpcApiContext) -> Result { - Ok(json!(CAPABILITIES)) + // Per execution-apis PR #764, advertise both JSON-RPC method names and + // the SSZ REST endpoint identifiers we support so the CL can pick the + // binary transport per endpoint. + let mut all: Vec<&str> = CAPABILITIES.to_vec(); + all.extend_from_slice(crate::engine_rest::SSZ_REST_CAPABILITIES); + Ok(json!(all)) } } diff --git a/crates/networking/rpc/engine/payload.rs b/crates/networking/rpc/engine/payload.rs index d8dcf15a853..af9573f7165 100644 --- a/crates/networking/rpc/engine/payload.rs +++ b/crates/networking/rpc/engine/payload.rs @@ -19,8 +19,11 @@ use crate::types::payload::{ use crate::utils::RpcErr; use crate::utils::{RpcRequest, parse_json_hex}; -// Must support rquest sizes of at least 32 blocks -// Chosen an arbitrary x4 value +// Spec requires supporting request sizes of at least 32 blocks; JSON-RPC is +// permissive and accepts up to 4× that. The SSZ REST surface enforces the +// stricter 32 cap because `MAX_PAYLOAD_BODIES_REQUEST` is also the SSZ +// `List` length bound (`engine_rest::types::common`) — wire data with more +// entries will not decode. // -> https://github.com/ethereum/execution-apis/blob/main/src/engine/shanghai.md#specification-3 const GET_PAYLOAD_BODIES_REQUEST_MAX_SIZE: u64 = 128; @@ -46,7 +49,8 @@ impl RpcHandler for NewPayloadV1Request { ))?); } }; - let payload_status = handle_new_payload_v1_v2(&self.payload, block, context, None).await?; + let payload_status = + handle_new_payload_v1_v2(self.payload.block_hash, block, context, None).await?; serde_json::to_value(payload_status).map_err(|error| RpcErr::Internal(error.to_string())) } } @@ -78,7 +82,8 @@ impl RpcHandler for NewPayloadV2Request { ))?); } }; - let payload_status = handle_new_payload_v1_v2(&self.payload, block, context, None).await?; + let payload_status = + handle_new_payload_v1_v2(self.payload.block_hash, block, context, None).await?; serde_json::to_value(payload_status).map_err(|error| RpcErr::Internal(error.to_string())) } } @@ -138,7 +143,7 @@ impl RpcHandler for NewPayloadV3Request { validate_fork(&block, Fork::Cancun, &context)?; validate_execution_payload_v3(&self.payload)?; let payload_status = handle_new_payload_v3( - &self.payload, + self.payload.block_hash, context, block, self.expected_blob_versioned_hashes.clone(), @@ -221,7 +226,7 @@ impl RpcHandler for NewPayloadV4Request { // We use v3 since the execution payload remains the same. validate_execution_payload_v3(&self.payload)?; let payload_status = handle_new_payload_v3( - &self.payload, + self.payload.block_hash, context, block, self.expected_blob_versioned_hashes.clone(), @@ -331,7 +336,7 @@ impl RpcHandler for NewPayloadV5Request { let bal = self.payload.block_access_list.clone(); let payload_status = handle_new_payload_v4( - &self.payload, + self.payload.block_hash, context, block, self.expected_blob_versioned_hashes.clone(), @@ -846,7 +851,7 @@ fn validate_execution_payload_v4(payload: &ExecutionPayload) -> Result<(), RpcEr Ok(()) } -fn validate_payload_v1_v2(block: &Block, context: &RpcApiContext) -> Result<(), RpcErr> { +pub(crate) fn validate_payload_v1_v2(block: &Block, context: &RpcApiContext) -> Result<(), RpcErr> { let chain_config = &context.storage.get_chain_config(); if chain_config.is_cancun_activated(block.header.timestamp) { return Err(RpcErr::UnsupportedFork( @@ -893,8 +898,8 @@ async fn validate_ancestors( Ok(None) } -async fn handle_new_payload_v1_v2( - payload: &ExecutionPayload, +pub(crate) async fn handle_new_payload_v1_v2( + expected_block_hash: H256, block: Block, context: RpcApiContext, bal: Option, @@ -905,7 +910,7 @@ async fn handle_new_payload_v1_v2( )); }; // Validate block hash - if let Err(RpcErr::Internal(error_msg)) = validate_block_hash(payload, &block) { + if let Err(RpcErr::Internal(error_msg)) = validate_block_hash(expected_block_hash, &block) { return Ok(PayloadStatus::invalid_with_err(&error_msg)); } @@ -927,8 +932,8 @@ async fn handle_new_payload_v1_v2( Ok(payload_status) } -async fn handle_new_payload_v3( - payload: &ExecutionPayload, +pub(crate) async fn handle_new_payload_v3( + expected_block_hash: H256, context: RpcApiContext, block: Block, expected_blob_versioned_hashes: Vec, @@ -948,11 +953,11 @@ async fn handle_new_payload_v3( )); } - handle_new_payload_v1_v2(payload, block, context, bal).await + handle_new_payload_v1_v2(expected_block_hash, block, context, bal).await } -async fn handle_new_payload_v4( - payload: &ExecutionPayload, +pub(crate) async fn handle_new_payload_v4( + expected_block_hash: H256, context: RpcApiContext, block: Block, expected_blob_versioned_hashes: Vec, @@ -963,12 +968,21 @@ async fn handle_new_payload_v4( { return Ok(PayloadStatus::invalid_with_err(&err)); } - handle_new_payload_v3(payload, context, block, expected_blob_versioned_hashes, bal).await + handle_new_payload_v3( + expected_block_hash, + context, + block, + expected_blob_versioned_hashes, + bal, + ) + .await } // Elements of the list MUST be ordered by request_type in ascending order. // Elements with empty request_data MUST be excluded from the list. -fn validate_execution_requests(execution_requests: &[EncodedRequests]) -> Result<(), RpcErr> { +pub(crate) fn validate_execution_requests( + execution_requests: &[EncodedRequests], +) -> Result<(), RpcErr> { let mut last_type: i32 = -1; for requests in execution_requests { if requests.0.len() < 2 { @@ -993,19 +1007,18 @@ fn get_block_from_payload( let block_number = payload.block_number; debug!(%block_hash, %block_number, "Received new payload"); - payload.clone().into_block( + payload.to_block( parent_beacon_block_root, requests_hash, block_access_list_hash, ) } -fn validate_block_hash(payload: &ExecutionPayload, block: &Block) -> Result<(), RpcErr> { - let block_hash = payload.block_hash; +fn validate_block_hash(expected: H256, block: &Block) -> Result<(), RpcErr> { let actual_block_hash = block.hash(); - if block_hash != actual_block_hash { + if expected != actual_block_hash { return Err(RpcErr::Internal(format!( - "Invalid block hash. Expected {actual_block_hash:#x}, got {block_hash:#x}" + "Invalid block hash. Expected {actual_block_hash:#x}, got {expected:#x}" ))); } Ok(()) @@ -1128,7 +1141,11 @@ fn parse_get_payload_request(params: &Option>) -> Result Ok(payload_id) } -fn validate_fork(block: &Block, fork: Fork, context: &RpcApiContext) -> Result<(), RpcErr> { +pub(crate) fn validate_fork( + block: &Block, + fork: Fork, + context: &RpcApiContext, +) -> Result<(), RpcErr> { // Check timestamp matches valid fork let chain_config = &context.storage.get_chain_config(); let current_fork = chain_config.get_fork(block.header.timestamp); @@ -1139,7 +1156,10 @@ fn validate_fork(block: &Block, fork: Fork, context: &RpcApiContext) -> Result<( Ok(()) } -async fn get_payload(payload_id: u64, context: &RpcApiContext) -> Result { +pub(crate) async fn get_payload( + payload_id: u64, + context: &RpcApiContext, +) -> Result { info!( id = %format!("{:#018x}", payload_id), "Requested payload with" diff --git a/crates/networking/rpc/engine_rest/auth.rs b/crates/networking/rpc/engine_rest/auth.rs new file mode 100644 index 00000000000..84ceaf75bb6 --- /dev/null +++ b/crates/networking/rpc/engine_rest/auth.rs @@ -0,0 +1,32 @@ +//! JWT bearer auth middleware shared with JSON-RPC. + +use axum::extract::{Request, State}; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::middleware::Next; +use axum::response::Response; +use bytes::Bytes; + +use crate::authentication::validate_jwt_authentication; +use crate::engine_rest::error::error_response; + +pub async fn engine_auth_middleware( + State(secret): State, + request: Request, + next: Next, +) -> Response { + let token = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| HeaderValue::to_str(v).ok()) + .and_then(|s| s.strip_prefix("Bearer ")); + + let Some(token) = token else { + return error_response(StatusCode::UNAUTHORIZED, "missing bearer token"); + }; + + if let Err(e) = validate_jwt_authentication(token, &secret) { + return error_response(StatusCode::UNAUTHORIZED, &format!("auth failed: {e:?}")); + } + + next.run(request).await +} diff --git a/crates/networking/rpc/engine_rest/conversions.rs b/crates/networking/rpc/engine_rest/conversions.rs new file mode 100644 index 00000000000..cd6b73d355b --- /dev/null +++ b/crates/networking/rpc/engine_rest/conversions.rs @@ -0,0 +1,502 @@ +//! Conversions between SSZ wire types and ethrex internal types. + +use bytes::Bytes; +use ethrex_common::constants::DEFAULT_OMMERS_HASH; +use ethrex_common::types::block_access_list::BlockAccessList; +use ethrex_common::types::requests::EncodedRequests; +use ethrex_common::types::{ + BlobsBundle, Block, BlockBody, BlockHeader, Transaction, Withdrawal, compute_transactions_root, + compute_withdrawals_root, +}; +use ethrex_common::{Address, Bloom, H256}; +use ethrex_crypto::NativeCrypto; +use ethrex_rlp::{decode::RLPDecode, encode::RLPEncode, error::RLPDecodeError}; +use libssz_types::SszList; + +use crate::engine_rest::error::ConversionError; +use crate::engine_rest::types::blobs::{BlobsBundleV1, BlobsBundleV2}; +use crate::engine_rest::types::common::{ + Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK, MAX_BYTES_PER_TRANSACTION, MAX_EXTRA_DATA_BYTES, + PayloadStatusCode, PayloadStatusV1 as SszPayloadStatus, ssz_none, ssz_some, u64_to_uint256_le, + uint256_le_to_u64, +}; +use crate::engine_rest::types::execution_payload::{ + ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3, ExecutionPayloadV4, Transactions, + Withdrawals, +}; +use crate::engine_rest::types::new_payload::ExecutionRequests; +use crate::engine_rest::types::withdrawal::WithdrawalV1; +use crate::types::payload::{ + EncodedTransaction, ExecutionPayload as JsonExecutionPayload, + PayloadStatus as JsonPayloadStatus, PayloadValidationStatus, +}; + +pub fn encoded_transactions_to_ssz( + txs: &[EncodedTransaction], +) -> Result { + let inner: Result>, _> = + txs.iter().map(|tx| tx.0.to_vec().try_into()).collect(); + inner + .map_err(|_| ConversionError::internal("transaction exceeds MAX_BYTES_PER_TRANSACTION"))? + .try_into() + .map_err(|_| ConversionError::internal("tx count exceeds MAX_TRANSACTIONS_PER_PAYLOAD")) +} + +pub fn ssz_withdrawals_to_vec(ws: &Withdrawals) -> Vec { + ws.iter() + .map(|w| Withdrawal { + index: w.index, + validator_index: w.validator_index, + address: Address::from_slice(&w.address), + amount: w.amount, + }) + .collect() +} + +pub fn vec_withdrawals_to_ssz(ws: &[Withdrawal]) -> Result { + let v: Vec = ws + .iter() + .map(|w| WithdrawalV1 { + index: w.index, + validator_index: w.validator_index, + address: w.address.0, + amount: w.amount, + }) + .collect(); + v.try_into().map_err(|_| { + ConversionError::internal("withdrawal count exceeds MAX_WITHDRAWALS_PER_PAYLOAD") + }) +} + +fn ssz_extra_data_to_bytes(e: &SszList) -> Bytes { + Bytes::copy_from_slice(e) +} + +fn bytes_to_ssz_extra_data( + b: &Bytes, +) -> Result, ConversionError> { + b.to_vec() + .try_into() + .map_err(|_| ConversionError::internal("extra_data exceeds MAX_EXTRA_DATA_BYTES")) +} + +fn empty_withdrawals() -> Result { + Vec::::new() + .try_into() + .map_err(|_| ConversionError::internal("empty withdrawals list overflow")) +} + +fn decode_bal(bytes: &[u8]) -> Result { + BlockAccessList::decode(bytes) + .map_err(|e: RLPDecodeError| ConversionError::bad_request(format!("invalid BAL RLP: {e}"))) +} + +pub fn json_to_execution_payload_v1( + p: &JsonExecutionPayload, +) -> Result { + Ok(ExecutionPayloadV1 { + parent_hash: p.parent_hash.0, + fee_recipient: p.fee_recipient.0, + state_root: p.state_root.0, + receipts_root: p.receipts_root.0, + logs_bloom: p.logs_bloom.0, + prev_randao: p.prev_randao.0, + block_number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: bytes_to_ssz_extra_data(&p.extra_data)?, + base_fee_per_gas: u64_to_uint256_le(p.base_fee_per_gas), + block_hash: p.block_hash.0, + transactions: encoded_transactions_to_ssz(&p.transactions)?, + }) +} + +pub fn json_to_execution_payload_v2( + p: &JsonExecutionPayload, +) -> Result { + let withdrawals = match &p.withdrawals { + Some(ws) => vec_withdrawals_to_ssz(ws)?, + None => empty_withdrawals()?, + }; + Ok(ExecutionPayloadV2 { + parent_hash: p.parent_hash.0, + fee_recipient: p.fee_recipient.0, + state_root: p.state_root.0, + receipts_root: p.receipts_root.0, + logs_bloom: p.logs_bloom.0, + prev_randao: p.prev_randao.0, + block_number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: bytes_to_ssz_extra_data(&p.extra_data)?, + base_fee_per_gas: u64_to_uint256_le(p.base_fee_per_gas), + block_hash: p.block_hash.0, + transactions: encoded_transactions_to_ssz(&p.transactions)?, + withdrawals, + }) +} + +pub fn json_to_execution_payload_v3( + p: &JsonExecutionPayload, +) -> Result { + let withdrawals = match &p.withdrawals { + Some(ws) => vec_withdrawals_to_ssz(ws)?, + None => empty_withdrawals()?, + }; + Ok(ExecutionPayloadV3 { + parent_hash: p.parent_hash.0, + fee_recipient: p.fee_recipient.0, + state_root: p.state_root.0, + receipts_root: p.receipts_root.0, + logs_bloom: p.logs_bloom.0, + prev_randao: p.prev_randao.0, + block_number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: bytes_to_ssz_extra_data(&p.extra_data)?, + base_fee_per_gas: u64_to_uint256_le(p.base_fee_per_gas), + block_hash: p.block_hash.0, + transactions: encoded_transactions_to_ssz(&p.transactions)?, + withdrawals, + blob_gas_used: p.blob_gas_used.unwrap_or(0), + excess_blob_gas: p.excess_blob_gas.unwrap_or(0), + }) +} + +pub fn json_to_execution_payload_v4( + p: &JsonExecutionPayload, +) -> Result { + let withdrawals = match &p.withdrawals { + Some(ws) => vec_withdrawals_to_ssz(ws)?, + None => empty_withdrawals()?, + }; + let bal_bytes: Vec = match &p.block_access_list { + Some(b) => { + let mut buf = Vec::new(); + b.encode(&mut buf); + buf + } + None => Vec::new(), + }; + let bal_ssz = bal_bytes + .try_into() + .map_err(|_| ConversionError::internal("BAL RLP exceeds MAX_BYTES_PER_TRANSACTION"))?; + Ok(ExecutionPayloadV4 { + parent_hash: p.parent_hash.0, + fee_recipient: p.fee_recipient.0, + state_root: p.state_root.0, + receipts_root: p.receipts_root.0, + logs_bloom: p.logs_bloom.0, + prev_randao: p.prev_randao.0, + block_number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: bytes_to_ssz_extra_data(&p.extra_data)?, + base_fee_per_gas: u64_to_uint256_le(p.base_fee_per_gas), + block_hash: p.block_hash.0, + transactions: encoded_transactions_to_ssz(&p.transactions)?, + withdrawals, + blob_gas_used: p.blob_gas_used.unwrap_or(0), + excess_blob_gas: p.excess_blob_gas.unwrap_or(0), + block_access_list: bal_ssz, + slot_number: p.slot_number.unwrap_or(0), + }) +} + +pub fn json_payload_status_to_ssz( + s: &JsonPayloadStatus, +) -> Result { + let code: u8 = match s.status { + PayloadValidationStatus::Valid => PayloadStatusCode::Valid as u8, + PayloadValidationStatus::Invalid => PayloadStatusCode::Invalid as u8, + PayloadValidationStatus::Syncing => PayloadStatusCode::Syncing as u8, + PayloadValidationStatus::Accepted => PayloadStatusCode::Accepted as u8, + }; + let latest_valid_hash = match s.latest_valid_hash { + Some(h) => ssz_some(h.0), + None => ssz_none(), + }; + let validation_error = s + .validation_error + .as_deref() + .unwrap_or("") + .as_bytes() + .to_vec() + .try_into() + .map_err(|_| { + ConversionError::internal("validation_error exceeds MAX_ERROR_MESSAGE_LENGTH") + })?; + Ok(SszPayloadStatus { + status: code, + latest_valid_hash, + validation_error, + }) +} + +pub fn blobs_bundle_to_ssz_v1(bundle: BlobsBundle) -> Result { + Ok(BlobsBundleV1 { + commitments: bundle + .commitments + .try_into() + .map_err(|_| ConversionError::internal("commitments overflow"))?, + proofs: bundle + .proofs + .try_into() + .map_err(|_| ConversionError::internal("proofs overflow"))?, + blobs: bundle + .blobs + .try_into() + .map_err(|_| ConversionError::internal("blobs overflow"))?, + }) +} + +pub fn blobs_bundle_to_ssz_v2(bundle: BlobsBundle) -> Result { + Ok(BlobsBundleV2 { + commitments: bundle + .commitments + .try_into() + .map_err(|_| ConversionError::internal("commitments overflow"))?, + proofs: bundle + .proofs + .try_into() + .map_err(|_| ConversionError::internal("proofs overflow"))?, + blobs: bundle + .blobs + .try_into() + .map_err(|_| ConversionError::internal("blobs overflow"))?, + }) +} + +pub fn encoded_requests_to_ssz( + reqs: &[EncodedRequests], +) -> Result { + let inner: Result>, _> = reqs + .iter() + .filter(|r| !r.0.is_empty()) + .map(|r| r.0.to_vec().try_into()) + .collect(); + inner + .map_err(|_| { + ConversionError::internal("execution request exceeds MAX_BYTES_PER_TRANSACTION") + })? + .try_into() + .map_err(|_| ConversionError::internal("execution_requests overflow")) +} + +pub fn ssz_to_encoded_requests(reqs: &ExecutionRequests) -> Vec { + reqs.iter() + .map(|r| EncodedRequests(Bytes::copy_from_slice(r))) + .collect() +} + +pub fn ssz_blob_hashes_to_vec( + hashes: &SszList, +) -> Vec { + hashes.iter().map(H256::from).collect() +} + +// Direct SSZ → Block: decode each tx slice into a `Transaction` without +// going through `EncodedTransaction(Bytes)` or `JsonExecutionPayload`. + +fn decode_transactions(txs: &Transactions) -> Result, ConversionError> { + txs.iter() + .map(|raw| Transaction::decode_canonical(raw)) + .collect::, _>>() + .map_err(|e| ConversionError::bad_request(format!("invalid transaction: {e}"))) +} + +pub fn ssz_payload_v1_to_block( + p: ExecutionPayloadV1, + parent_beacon_block_root: Option, + requests_hash: Option, + block_access_list_hash: Option, +) -> Result { + let base_fee = uint256_le_to_u64(&p.base_fee_per_gas) + .ok_or_else(|| ConversionError::bad_request("base_fee_per_gas exceeds u64"))?; + let transactions = decode_transactions(&p.transactions)?; + let body = BlockBody { + transactions, + ommers: vec![], + withdrawals: None, + }; + let header = BlockHeader { + parent_hash: H256::from(p.parent_hash), + ommers_hash: *DEFAULT_OMMERS_HASH, + coinbase: Address::from(p.fee_recipient), + state_root: H256::from(p.state_root), + transactions_root: compute_transactions_root(&body.transactions, &NativeCrypto), + receipts_root: H256::from(p.receipts_root), + logs_bloom: Bloom::from_slice(&p.logs_bloom), + difficulty: 0.into(), + number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: ssz_extra_data_to_bytes(&p.extra_data), + prev_randao: H256::from(p.prev_randao), + nonce: 0, + base_fee_per_gas: Some(base_fee), + withdrawals_root: None, + blob_gas_used: None, + excess_blob_gas: None, + parent_beacon_block_root, + requests_hash, + slot_number: None, + block_access_list_hash, + ..Default::default() + }; + Ok(Block::new(header, body)) +} + +pub fn ssz_payload_v2_to_block( + p: ExecutionPayloadV2, + parent_beacon_block_root: Option, + requests_hash: Option, + block_access_list_hash: Option, +) -> Result { + let base_fee = uint256_le_to_u64(&p.base_fee_per_gas) + .ok_or_else(|| ConversionError::bad_request("base_fee_per_gas exceeds u64"))?; + let transactions = decode_transactions(&p.transactions)?; + let withdrawals = Some(ssz_withdrawals_to_vec(&p.withdrawals)); + let withdrawals_root = withdrawals + .as_ref() + .map(|w| compute_withdrawals_root(w, &NativeCrypto)); + let body = BlockBody { + transactions, + ommers: vec![], + withdrawals, + }; + let header = BlockHeader { + parent_hash: H256::from(p.parent_hash), + ommers_hash: *DEFAULT_OMMERS_HASH, + coinbase: Address::from(p.fee_recipient), + state_root: H256::from(p.state_root), + transactions_root: compute_transactions_root(&body.transactions, &NativeCrypto), + receipts_root: H256::from(p.receipts_root), + logs_bloom: Bloom::from_slice(&p.logs_bloom), + difficulty: 0.into(), + number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: ssz_extra_data_to_bytes(&p.extra_data), + prev_randao: H256::from(p.prev_randao), + nonce: 0, + base_fee_per_gas: Some(base_fee), + withdrawals_root, + blob_gas_used: None, + excess_blob_gas: None, + parent_beacon_block_root, + requests_hash, + slot_number: None, + block_access_list_hash, + ..Default::default() + }; + Ok(Block::new(header, body)) +} + +pub fn ssz_payload_v3_to_block( + p: ExecutionPayloadV3, + parent_beacon_block_root: Option, + requests_hash: Option, + block_access_list_hash: Option, +) -> Result { + let base_fee = uint256_le_to_u64(&p.base_fee_per_gas) + .ok_or_else(|| ConversionError::bad_request("base_fee_per_gas exceeds u64"))?; + let transactions = decode_transactions(&p.transactions)?; + let withdrawals = Some(ssz_withdrawals_to_vec(&p.withdrawals)); + let withdrawals_root = withdrawals + .as_ref() + .map(|w| compute_withdrawals_root(w, &NativeCrypto)); + let body = BlockBody { + transactions, + ommers: vec![], + withdrawals, + }; + let header = BlockHeader { + parent_hash: H256::from(p.parent_hash), + ommers_hash: *DEFAULT_OMMERS_HASH, + coinbase: Address::from(p.fee_recipient), + state_root: H256::from(p.state_root), + transactions_root: compute_transactions_root(&body.transactions, &NativeCrypto), + receipts_root: H256::from(p.receipts_root), + logs_bloom: Bloom::from_slice(&p.logs_bloom), + difficulty: 0.into(), + number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: ssz_extra_data_to_bytes(&p.extra_data), + prev_randao: H256::from(p.prev_randao), + nonce: 0, + base_fee_per_gas: Some(base_fee), + withdrawals_root, + blob_gas_used: Some(p.blob_gas_used), + excess_blob_gas: Some(p.excess_blob_gas), + parent_beacon_block_root, + requests_hash, + slot_number: None, + block_access_list_hash, + ..Default::default() + }; + Ok(Block::new(header, body)) +} + +/// Returns `(Block, Option)`. The BAL is decoded from the +/// SSZ payload's `block_access_list` bytes and returned separately for the +/// caller to pass to `handle_new_payload_v4`. An empty SSZ BAL maps to `None`. +pub fn ssz_payload_v4_to_block( + p: ExecutionPayloadV4, + parent_beacon_block_root: Option, + requests_hash: Option, + block_access_list_hash: Option, +) -> Result<(Block, Option), ConversionError> { + let base_fee = uint256_le_to_u64(&p.base_fee_per_gas) + .ok_or_else(|| ConversionError::bad_request("base_fee_per_gas exceeds u64"))?; + let transactions = decode_transactions(&p.transactions)?; + let withdrawals = Some(ssz_withdrawals_to_vec(&p.withdrawals)); + let withdrawals_root = withdrawals + .as_ref() + .map(|w| compute_withdrawals_root(w, &NativeCrypto)); + let bal = if p.block_access_list.is_empty() { + None + } else { + Some(decode_bal(&p.block_access_list)?) + }; + let body = BlockBody { + transactions, + ommers: vec![], + withdrawals, + }; + let header = BlockHeader { + parent_hash: H256::from(p.parent_hash), + ommers_hash: *DEFAULT_OMMERS_HASH, + coinbase: Address::from(p.fee_recipient), + state_root: H256::from(p.state_root), + transactions_root: compute_transactions_root(&body.transactions, &NativeCrypto), + receipts_root: H256::from(p.receipts_root), + logs_bloom: Bloom::from_slice(&p.logs_bloom), + difficulty: 0.into(), + number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: ssz_extra_data_to_bytes(&p.extra_data), + prev_randao: H256::from(p.prev_randao), + nonce: 0, + base_fee_per_gas: Some(base_fee), + withdrawals_root, + blob_gas_used: Some(p.blob_gas_used), + excess_blob_gas: Some(p.excess_blob_gas), + parent_beacon_block_root, + requests_hash, + slot_number: Some(p.slot_number), + block_access_list_hash, + ..Default::default() + }; + Ok((Block::new(header, body), bal)) +} diff --git a/crates/networking/rpc/engine_rest/error.rs b/crates/networking/rpc/engine_rest/error.rs new file mode 100644 index 00000000000..a35b455759a --- /dev/null +++ b/crates/networking/rpc/engine_rest/error.rs @@ -0,0 +1,175 @@ +//! Engine REST error responses with text/plain body. + +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; + +use crate::utils::RpcErr; + +/// Carries the error message and the metric `error_kind` label into +/// `Response::extensions` so the observe middleware can log/record without +/// re-buffering the body or re-deriving the label from the HTTP status. +#[derive(Clone, Debug)] +pub struct EngineErrorContext { + pub message: String, + pub error_kind: &'static str, +} + +/// Static `error_kind` label for the metric counter when only the HTTP +/// status code is known (no source `RpcErr`). Kept aligned with +/// `crate::rpc::get_error_kind` where the categories overlap. +pub(crate) fn error_kind_from_status(status: StatusCode) -> &'static str { + match status.as_u16() { + 400 => "BadRequest", + 401 => "Unauthorized", + 404 => "NotFound", + 409 => "InvalidForkChoiceState", + 413 => "TooLargeRequest", + 422 => "InvalidPayloadAttributes", + 500 => "Internal", + _ => "Other", + } +} + +pub fn error_response(status: StatusCode, msg: &str) -> Response { + error_response_with_kind(status, msg, error_kind_from_status(status)) +} + +fn error_response_with_kind(status: StatusCode, msg: &str, error_kind: &'static str) -> Response { + let message = msg.to_string(); + let mut resp = (status, message.clone()).into_response(); + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + resp.extensions_mut().insert(EngineErrorContext { + message, + error_kind, + }); + resp +} + +/// Convenience builders for the error categories listed in the spec. +pub struct EngineError; + +impl EngineError { + pub fn bad_request(msg: &str) -> Response { + error_response(StatusCode::BAD_REQUEST, msg) + } + + pub fn unauthorized(msg: &str) -> Response { + error_response(StatusCode::UNAUTHORIZED, msg) + } + + pub fn not_found(msg: &str) -> Response { + error_response(StatusCode::NOT_FOUND, msg) + } + + pub fn conflict(msg: &str) -> Response { + error_response(StatusCode::CONFLICT, msg) + } + + pub fn payload_too_large(msg: &str) -> Response { + error_response(StatusCode::PAYLOAD_TOO_LARGE, msg) + } + + pub fn unprocessable(msg: &str) -> Response { + error_response(StatusCode::UNPROCESSABLE_ENTITY, msg) + } + + pub fn internal(msg: &str) -> Response { + error_response(StatusCode::INTERNAL_SERVER_ERROR, msg) + } +} + +/// Small (status + message) error returned from engine-REST helpers; converts +/// to an HTTP `Response` at the handler boundary via `From`/`IntoResponse`. +/// `error_kind` is the label used by the metric counter; it defaults from +/// the status code but can be overridden (e.g. by `From`) to match +/// the JSON-RPC vocabulary. +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub struct EngineRestError { + pub status: StatusCode, + pub message: String, + pub error_kind: &'static str, +} + +impl EngineRestError { + pub fn new(status: StatusCode, msg: impl Into) -> Self { + Self { + status, + message: msg.into(), + error_kind: error_kind_from_status(status), + } + } + pub fn bad_request(msg: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, msg) + } + pub fn unauthorized(msg: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, msg) + } + pub fn not_found(msg: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, msg) + } + pub fn conflict(msg: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, msg) + } + pub fn payload_too_large(msg: impl Into) -> Self { + Self::new(StatusCode::PAYLOAD_TOO_LARGE, msg) + } + pub fn unprocessable(msg: impl Into) -> Self { + Self::new(StatusCode::UNPROCESSABLE_ENTITY, msg) + } + pub fn internal(msg: impl Into) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, msg) + } +} + +impl From for Response { + fn from(e: EngineRestError) -> Response { + error_response_with_kind(e.status, &e.message, e.error_kind) + } +} + +impl IntoResponse for EngineRestError { + fn into_response(self) -> Response { + self.into() + } +} + +/// Alias kept so `conversions.rs` reads naturally. +pub type ConversionError = EngineRestError; + +impl From for EngineRestError { + fn from(err: RpcErr) -> Self { + let error_kind = crate::rpc::get_error_kind(&err); + let (status, message) = match err { + RpcErr::UnsupportedFork(m) | RpcErr::InvalidPayloadAttributes(m) => { + (StatusCode::UNPROCESSABLE_ENTITY, m) + } + RpcErr::InvalidForkChoiceState(m) | RpcErr::TooDeepReorg(m) => { + (StatusCode::CONFLICT, m) + } + RpcErr::UnknownPayload(m) => (StatusCode::NOT_FOUND, m), + RpcErr::TooLargeRequest => (StatusCode::PAYLOAD_TOO_LARGE, "request too large".into()), + RpcErr::AuthenticationError(_) => { + (StatusCode::UNAUTHORIZED, "authentication failed".into()) + } + RpcErr::WrongParam(_) + | RpcErr::BadParams(_) + | RpcErr::MissingParam(_) + | RpcErr::BadHexFormat(_) => (StatusCode::BAD_REQUEST, err.to_string()), + other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), + }; + Self { + status, + message, + error_kind, + } + } +} + +/// Map an `RpcErr` to an HTTP error response. +pub fn classify_rpc_err(err: RpcErr) -> Response { + EngineRestError::from(err).into() +} diff --git a/crates/networking/rpc/engine_rest/extractors.rs b/crates/networking/rpc/engine_rest/extractors.rs new file mode 100644 index 00000000000..86cef0b0ae9 --- /dev/null +++ b/crates/networking/rpc/engine_rest/extractors.rs @@ -0,0 +1,54 @@ +//! SSZ request extractor + Content-Type guard. + +use axum::body::Bytes; +use axum::extract::FromRequest; +use axum::http::{self, HeaderMap, Request}; +use libssz::SszDecode; + +use crate::engine_rest::error::EngineRestError; + +pub const SSZ_CONTENT_TYPE: &str = "application/octet-stream"; + +/// Validate that the incoming request advertises SSZ bytes. +pub fn check_ssz_content_type(headers: &HeaderMap) -> Result<(), EngineRestError> { + match headers.get(http::header::CONTENT_TYPE) { + Some(ct) if ct.as_bytes().starts_with(SSZ_CONTENT_TYPE.as_bytes()) => Ok(()), + Some(ct) => Err(EngineRestError::bad_request(format!( + "unsupported Content-Type {:?}, expected {SSZ_CONTENT_TYPE}", + ct.to_str().unwrap_or(""), + ))), + None => Err(EngineRestError::bad_request(format!( + "missing Content-Type, expected {SSZ_CONTENT_TYPE}" + ))), + } +} + +/// SSZ-decode the request body. Returns an `EngineRestError::bad_request` +/// (mapped to a 400 response at the handler boundary) on failure. +pub fn decode_ssz(bytes: &[u8]) -> Result { + T::from_ssz_bytes(bytes) + .map_err(|e| EngineRestError::bad_request(format!("invalid SSZ: {e:?}"))) +} + +/// Axum extractor that enforces Content-Type and SSZ-decodes the body. +pub struct Ssz(pub T); + +impl FromRequest for Ssz +where + T: SszDecode + Send + 'static, + S: Send + Sync, +{ + type Rejection = EngineRestError; + + async fn from_request( + req: Request, + state: &S, + ) -> Result { + check_ssz_content_type(req.headers())?; + let bytes = Bytes::from_request(req, state) + .await + .map_err(|e| EngineRestError::bad_request(format!("failed to read body: {e}")))?; + let value = decode_ssz::(&bytes)?; + Ok(Ssz(value)) + } +} diff --git a/crates/networking/rpc/engine_rest/handlers/blobs.rs b/crates/networking/rpc/engine_rest/handlers/blobs.rs new file mode 100644 index 00000000000..5f615595532 --- /dev/null +++ b/crates/networking/rpc/engine_rest/handlers/blobs.rs @@ -0,0 +1,206 @@ +//! POST /engine/v{1,2,3}/blobs. +//! +//! V1 returns `BlobAndProofV1` (single proof, no nullability). V2 returns +//! `BlobAndProofV2` (cell proofs); all-or-nothing — empty list when any blob +//! is missing. V3 returns per-element nullable `BlobAndProofV2`. + +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use ethrex_common::H256; +use libssz_types::SszList; + +use crate::engine_rest::error::{EngineError, EngineRestError}; +use crate::engine_rest::extractors::Ssz; +use crate::engine_rest::responses::SszBody; +use crate::engine_rest::types::blobs::{ + BlobAndProofV1, BlobAndProofV2, GetBlobsV1Request, GetBlobsV1Response, GetBlobsV2Request, + GetBlobsV2Response, GetBlobsV3Request, GetBlobsV3Response, +}; +use crate::engine_rest::types::common::{ + BLOB_SIZE, Blob, MAX_BLOB_HASHES_REQUEST, ssz_none, ssz_some, +}; +use crate::rpc::RpcApiContext; + +fn check_count(n: usize) -> Result<(), EngineRestError> { + if n >= MAX_BLOB_HASHES_REQUEST { + return Err(EngineRestError::payload_too_large(format!( + "request exceeds MAX_BLOB_HASHES_REQUEST ({MAX_BLOB_HASHES_REQUEST})" + ))); + } + Ok(()) +} + +fn copy_blob(b: &[u8]) -> Blob { + let mut out = [0u8; BLOB_SIZE]; + out.copy_from_slice(b); + out +} + +pub async fn blobs_v1( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + if let Err(e) = check_count(req.blob_versioned_hashes.len()) { + return e.into(); + } + let hashes: Vec = req + .blob_versioned_hashes + .iter() + .map(|h| H256::from(*h)) + .collect(); + let tuples = match ctx + .blockchain + .mempool + .get_blobs_data_by_versioned_hashes(&hashes) + { + Ok(t) => t, + Err(e) => return EngineError::internal(&format!("mempool: {e}")), + }; + + let mut items: Vec = Vec::new(); + for t in tuples.into_iter().flatten() { + let (blob, _commitment, proofs) = t; + let Some(proof) = proofs.first() else { + continue; + }; + items.push(BlobAndProofV1 { + blob: copy_blob(blob.as_ref()), + proof: *proof, + }); + } + let blobs_and_proofs = match items.try_into() { + Ok(s) => s, + Err(_) => return EngineError::internal("blobs_and_proofs overflow"), + }; + SszBody(GetBlobsV1Response { blobs_and_proofs }).into_response() +} + +async fn require_osaka_tip(ctx: &RpcApiContext, version: u8) -> Result<(), EngineRestError> { + let latest = ctx + .storage + .get_latest_block_number() + .await + .map_err(|e| EngineRestError::internal(format!("storage: {e}")))?; + let header = ctx + .storage + .get_block_header(latest) + .map_err(|e| EngineRestError::internal(format!("storage: {e}")))?; + if let Some(h) = header + && !ctx + .storage + .get_chain_config() + .is_osaka_activated(h.timestamp) + { + return Err(EngineRestError::unprocessable(format!( + "getBlobsV{version} engine only supported for Osaka" + ))); + } + Ok(()) +} + +pub async fn blobs_v2( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + if let Err(e) = check_count(req.blob_versioned_hashes.len()) { + return e.into(); + } + if let Err(e) = require_osaka_tip(&ctx, 2).await { + return e.into(); + } + let hashes: Vec = req + .blob_versioned_hashes + .iter() + .map(|h| H256::from(*h)) + .collect(); + let tuples = match ctx + .blockchain + .mempool + .get_blobs_data_by_versioned_hashes(&hashes) + { + Ok(t) => t, + Err(e) => return EngineError::internal(&format!("mempool: {e}")), + }; + // Spec allows 204 on missing blobs but Prysm's SSZ-REST decoder rejects + // 0-byte bodies; emit a 200 with an empty list instead (also spec-valid). + if tuples.iter().any(|t| match t { + None => true, + Some((_, _, proofs)) => proofs.is_empty(), + }) { + let blobs_and_proofs = match Vec::::new().try_into() { + Ok(s) => s, + Err(_) => return EngineError::internal("empty V2 list overflow"), + }; + return SszBody(GetBlobsV2Response { blobs_and_proofs }).into_response(); + } + let mut items: Vec = Vec::with_capacity(tuples.len()); + for t in tuples.into_iter().flatten() { + let (blob, _commitment, proofs) = t; + let proofs_list: SszList< + [u8; 48], + { crate::engine_rest::types::common::CELLS_PER_EXT_BLOB }, + > = match proofs.into_iter().collect::>().try_into() { + Ok(p) => p, + Err(_) => return EngineError::internal("proofs overflow CELLS_PER_EXT_BLOB"), + }; + items.push(BlobAndProofV2 { + blob: copy_blob(blob.as_ref()), + proofs: proofs_list, + }); + } + let blobs_and_proofs = match items.try_into() { + Ok(s) => s, + Err(_) => return EngineError::internal("blobs_and_proofs overflow"), + }; + SszBody(GetBlobsV2Response { blobs_and_proofs }).into_response() +} + +pub async fn blobs_v3( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + if let Err(e) = check_count(req.blob_versioned_hashes.len()) { + return e.into(); + } + if let Err(e) = require_osaka_tip(&ctx, 3).await { + return e.into(); + } + let hashes: Vec = req + .blob_versioned_hashes + .iter() + .map(|h| H256::from(*h)) + .collect(); + let tuples = match ctx + .blockchain + .mempool + .get_blobs_data_by_versioned_hashes(&hashes) + { + Ok(t) => t, + Err(e) => return EngineError::internal(&format!("mempool: {e}")), + }; + let mut slots: Vec> = Vec::with_capacity(tuples.len()); + for t in tuples.into_iter() { + let slot = match t { + Some((blob, _commitment, proofs)) if !proofs.is_empty() => { + let proofs_list: SszList< + [u8; 48], + { crate::engine_rest::types::common::CELLS_PER_EXT_BLOB }, + > = match proofs.into_iter().collect::>().try_into() { + Ok(p) => p, + Err(_) => return EngineError::internal("proofs overflow CELLS_PER_EXT_BLOB"), + }; + ssz_some(BlobAndProofV2 { + blob: copy_blob(blob.as_ref()), + proofs: proofs_list, + }) + } + _ => ssz_none(), + }; + slots.push(slot); + } + let blobs_and_proofs = match slots.try_into() { + Ok(s) => s, + Err(_) => return EngineError::internal("blobs_and_proofs overflow"), + }; + SszBody(GetBlobsV3Response { blobs_and_proofs }).into_response() +} diff --git a/crates/networking/rpc/engine_rest/handlers/bodies.rs b/crates/networking/rpc/engine_rest/handlers/bodies.rs new file mode 100644 index 00000000000..af18b3f2ced --- /dev/null +++ b/crates/networking/rpc/engine_rest/handlers/bodies.rs @@ -0,0 +1,281 @@ +//! POST /engine/v{1,2}/payloads/bodies/by-hash and by-range. + +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use ethrex_common::H256; +use ethrex_common::types::block_access_list::BlockAccessList; +use ethrex_common::types::{Block, BlockBody, BlockHeader}; +use ethrex_rlp::encode::RLPEncode; +use ethrex_storage::Store; +use ethrex_storage::error::StoreError; +use libssz_types::SszList; + +use crate::engine_rest::conversions::vec_withdrawals_to_ssz; +use crate::engine_rest::error::{EngineError, EngineRestError}; +use crate::engine_rest::extractors::Ssz; +use crate::engine_rest::responses::SszBody; +use crate::engine_rest::types::bodies::{ + ExecutionPayloadBodyV1, ExecutionPayloadBodyV2, GetPayloadBodiesByHashV1Request, + GetPayloadBodiesByHashV2Request, GetPayloadBodiesByRangeV1Request, + GetPayloadBodiesByRangeV2Request, PayloadBodiesV1Response, PayloadBodiesV2Response, +}; +use crate::engine_rest::types::common::{ + MAX_BYTES_PER_TRANSACTION, MAX_PAYLOAD_BODIES_REQUEST, ssz_none, ssz_some, +}; +use crate::engine_rest::types::execution_payload::{Transactions, Withdrawals}; +use crate::rpc::RpcApiContext; + +fn encode_txs(txs: &[ethrex_common::types::Transaction]) -> Result { + let inner: Result>, _> = txs + .iter() + .map(|tx| tx.encode_canonical_to_vec().try_into()) + .collect(); + inner + .map_err(|_| EngineRestError::internal("transaction exceeds MAX_BYTES_PER_TRANSACTION"))? + .try_into() + .map_err(|_| EngineRestError::internal("tx count exceeds MAX_TRANSACTIONS_PER_PAYLOAD")) +} + +fn encode_withdrawals( + ws: &Option>, +) -> Result { + vec_withdrawals_to_ssz(ws.as_deref().unwrap_or(&[])) +} + +fn body_to_v1(body: &BlockBody) -> Result { + Ok(ExecutionPayloadBodyV1 { + transactions: encode_txs(&body.transactions)?, + withdrawals: encode_withdrawals(&body.withdrawals)?, + }) +} + +fn body_to_v2( + body: &BlockBody, + bal: Option<&BlockAccessList>, +) -> Result { + let block_access_list = match bal { + Some(b) => { + let mut buf = Vec::new(); + b.encode(&mut buf); + let inner: SszList = buf + .try_into() + .map_err(|_| EngineRestError::internal("BAL exceeds MAX_BYTES_PER_TRANSACTION"))?; + ssz_some(inner) + } + None => ssz_none(), + }; + Ok(ExecutionPayloadBodyV2 { + transactions: encode_txs(&body.transactions)?, + withdrawals: encode_withdrawals(&body.withdrawals)?, + block_access_list, + }) +} + +fn check_count(n: usize) -> Result<(), EngineRestError> { + if n > MAX_PAYLOAD_BODIES_REQUEST { + return Err(EngineRestError::payload_too_large(format!( + "request exceeds MAX_PAYLOAD_BODIES_REQUEST ({MAX_PAYLOAD_BODIES_REQUEST})" + ))); + } + Ok(()) +} + +pub async fn bodies_by_hash_v1( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + if let Err(e) = check_count(req.block_hashes.len()) { + return e.into(); + } + let lookups = req + .block_hashes + .iter() + .map(|h| ctx.storage.get_block_body_by_hash(H256::from(*h))); + let bodies = match futures::future::try_join_all(lookups).await { + Ok(b) => b, + Err(e) => return EngineError::internal(&format!("storage: {e}")), + }; + bodies_v1_response(bodies) +} + +pub async fn bodies_by_range_v1( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + if req.start == 0 { + return EngineError::bad_request("start must be ≥ 1"); + } + if req.count == 0 { + return EngineError::bad_request("count must be ≥ 1"); + } + if req.count as usize > MAX_PAYLOAD_BODIES_REQUEST { + return EngineError::payload_too_large(&format!( + "count exceeds MAX_PAYLOAD_BODIES_REQUEST ({MAX_PAYLOAD_BODIES_REQUEST})" + )); + } + let latest = match ctx.storage.get_latest_block_number().await { + Ok(n) => n, + Err(e) => return EngineError::internal(&format!("storage: {e}")), + }; + if req.start > latest { + return bodies_v1_response(Vec::new()); + } + let last = req.start.saturating_add(req.count - 1).min(latest); + let bodies = match ctx.storage.get_block_bodies(req.start, last).await { + Ok(b) => b, + Err(e) => return EngineError::internal(&format!("storage: {e}")), + }; + bodies_v1_response(bodies) +} + +fn bodies_v1_response(bodies: Vec>) -> Response { + let mut payload_bodies: Vec> = + Vec::with_capacity(bodies.len()); + for body in bodies { + let slot = match body { + Some(b) => match body_to_v1(&b) { + Ok(v) => ssz_some(v), + Err(e) => return e.into(), + }, + None => ssz_none(), + }; + payload_bodies.push(slot); + } + let resp = match payload_bodies.try_into() { + Ok(s) => s, + Err(_) => return EngineError::internal("payload_bodies overflow"), + }; + SszBody(PayloadBodiesV1Response { + payload_bodies: resp, + }) + .into_response() +} + +// Pair each body with its (sync) header lookup and EVM-bound BAL generation +// inside a single `spawn_blocking` so the runtime isn't blocked. +// `generate_bal_for_block` re-executes the block in the EVM. +async fn assemble_blocks_with_bal( + ctx: &RpcApiContext, + bodies: Vec>, + keys: Vec, + fetch_header: fn(&Store, &K) -> Result, StoreError>, +) -> Result)>>, Response> +where + K: Send + 'static, +{ + let storage = ctx.storage.clone(); + let blockchain = ctx.blockchain.clone(); + let result = tokio::task::spawn_blocking(move || { + bodies + .into_iter() + .zip(keys) + .map(|(body_opt, key)| { + let Some(body) = body_opt else { + return Ok(None); + }; + let Some(header) = fetch_header(&storage, &key)? else { + return Ok(None); + }; + let block = Block { header, body }; + let bal = blockchain + .generate_bal_for_block(&block) + .map_err(|e| StoreError::Custom(e.to_string()))?; + Ok(Some((block, bal))) + }) + .collect::, StoreError>>() + }) + .await; + match result { + Ok(Ok(v)) => Ok(v), + Ok(Err(e)) => Err(EngineError::internal(&format!("storage: {e}"))), + Err(e) => Err(EngineError::internal(&format!("BAL fetch panicked: {e}"))), + } +} + +pub async fn bodies_by_hash_v2( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + if let Err(e) = check_count(req.block_hashes.len()) { + return e.into(); + } + let hashes: Vec = req.block_hashes.iter().map(|h| H256::from(*h)).collect(); + let lookups = hashes + .iter() + .map(|h| ctx.storage.get_block_body_by_hash(*h)); + let bodies = match futures::future::try_join_all(lookups).await { + Ok(b) => b, + Err(e) => return EngineError::internal(&format!("storage: {e}")), + }; + let blocks_with_bal = + match assemble_blocks_with_bal(&ctx, bodies, hashes, |s, h| s.get_block_header_by_hash(*h)) + .await + { + Ok(v) => v, + Err(resp) => return resp, + }; + bodies_v2_response(blocks_with_bal) +} + +pub async fn bodies_by_range_v2( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + if req.start == 0 { + return EngineError::bad_request("start must be ≥ 1"); + } + if req.count == 0 { + return EngineError::bad_request("count must be ≥ 1"); + } + if req.count as usize > MAX_PAYLOAD_BODIES_REQUEST { + return EngineError::payload_too_large(&format!( + "count exceeds MAX_PAYLOAD_BODIES_REQUEST ({MAX_PAYLOAD_BODIES_REQUEST})" + )); + } + let latest = match ctx.storage.get_latest_block_number().await { + Ok(n) => n, + Err(e) => return EngineError::internal(&format!("storage: {e}")), + }; + if req.start > latest { + return bodies_v2_response(Vec::new()); + } + let last = req.start.saturating_add(req.count - 1).min(latest); + let block_bodies = match ctx.storage.get_block_bodies(req.start, last).await { + Ok(b) => b, + Err(e) => return EngineError::internal(&format!("storage: {e}")), + }; + let block_numbers: Vec = (req.start..=last).collect(); + let blocks_with_bal = + match assemble_blocks_with_bal(&ctx, block_bodies, block_numbers, |s, n| { + s.get_block_header(*n) + }) + .await + { + Ok(v) => v, + Err(resp) => return resp, + }; + bodies_v2_response(blocks_with_bal) +} + +fn bodies_v2_response(blocks_with_bal: Vec)>>) -> Response { + let mut payload_bodies: Vec> = + Vec::with_capacity(blocks_with_bal.len()); + for entry in blocks_with_bal { + let slot = match entry { + Some((block, bal)) => match body_to_v2(&block.body, bal.as_ref()) { + Ok(v) => ssz_some(v), + Err(e) => return e.into(), + }, + None => ssz_none(), + }; + payload_bodies.push(slot); + } + let resp = match payload_bodies.try_into() { + Ok(s) => s, + Err(_) => return EngineError::internal("payload_bodies overflow"), + }; + SszBody(PayloadBodiesV2Response { + payload_bodies: resp, + }) + .into_response() +} diff --git a/crates/networking/rpc/engine_rest/handlers/capabilities.rs b/crates/networking/rpc/engine_rest/handlers/capabilities.rs new file mode 100644 index 00000000000..e01ae65bad2 --- /dev/null +++ b/crates/networking/rpc/engine_rest/handlers/capabilities.rs @@ -0,0 +1,38 @@ +//! POST /engine/v1/capabilities. + +use axum::response::{IntoResponse, Response}; +use libssz_types::SszList; + +use crate::engine::CAPABILITIES; +use crate::engine_rest::SSZ_REST_CAPABILITIES; +use crate::engine_rest::error::EngineError; +use crate::engine_rest::extractors::Ssz; +use crate::engine_rest::responses::SszBody; +use crate::engine_rest::types::capabilities::{ + ExchangeCapabilitiesRequest, ExchangeCapabilitiesResponse, +}; +use crate::engine_rest::types::common::MAX_CAPABILITY_NAME_LENGTH; + +pub async fn capabilities(Ssz(_req): Ssz) -> Response { + let mut all: Vec<&'static str> = CAPABILITIES.to_vec(); + all.extend_from_slice(SSZ_REST_CAPABILITIES); + + let mut inner: Vec> = Vec::with_capacity(all.len()); + for cap in &all { + let bytes: Vec = cap.as_bytes().to_vec(); + let entry = match bytes.try_into() { + Ok(e) => e, + Err(_) => { + return EngineError::internal(&format!( + "capability name '{cap}' exceeds MAX_CAPABILITY_NAME_LENGTH" + )); + } + }; + inner.push(entry); + } + let capabilities = match inner.try_into() { + Ok(c) => c, + Err(_) => return EngineError::internal("capabilities list overflow"), + }; + SszBody(ExchangeCapabilitiesResponse { capabilities }).into_response() +} diff --git a/crates/networking/rpc/engine_rest/handlers/client_version.rs b/crates/networking/rpc/engine_rest/handlers/client_version.rs new file mode 100644 index 00000000000..638f2cc0571 --- /dev/null +++ b/crates/networking/rpc/engine_rest/handlers/client_version.rs @@ -0,0 +1,55 @@ +//! POST /engine/v1/client/version. + +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use tracing::debug; + +use crate::engine_rest::error::{EngineError, EngineRestError}; +use crate::engine_rest::extractors::Ssz; +use crate::engine_rest::responses::SszBody; +use crate::engine_rest::types::client_version::{ + ClientVersionV1 as SszClientVersionV1, GetClientVersionV1Request, GetClientVersionV1Response, +}; +use crate::rpc::ClientVersion; + +fn ssz_from_node_client_version(cv: &ClientVersion) -> Result { + // The SSZ wire requires exactly 4 commit bytes; if the build metadata + // isn't a valid 8-char hex prefix (e.g. a `vergen` "unknown" fallback or + // a non-hex tag like "dirty"), fall back to zeros rather than 500. + let commit_hex = cv.commit.get(..8).unwrap_or("00000000"); + let commit: [u8; 4] = hex::decode(commit_hex) + .ok() + .and_then(|bytes| bytes.try_into().ok()) + .unwrap_or([0u8; 4]); + let code = b"EX".to_vec(); + let name = cv.name.as_bytes().to_vec(); + let version = format!("v{}", cv.version).into_bytes(); + Ok(SszClientVersionV1 { + code: code + .try_into() + .map_err(|_| EngineRestError::internal("client code overflow"))?, + name: name + .try_into() + .map_err(|_| EngineRestError::internal("client name overflow"))?, + version: version + .try_into() + .map_err(|_| EngineRestError::internal("client version overflow"))?, + commit, + }) +} + +pub async fn client_version( + State(cv): State, + Ssz(_req): Ssz, +) -> Response { + debug!("engine REST: /engine/v1/client/version"); + let entry = match ssz_from_node_client_version(&cv) { + Ok(v) => v, + Err(e) => return e.into(), + }; + let versions = match vec![entry].try_into() { + Ok(v) => v, + Err(_) => return EngineError::internal("versions overflow MAX_CLIENT_VERSIONS"), + }; + SszBody(GetClientVersionV1Response { versions }).into_response() +} diff --git a/crates/networking/rpc/engine_rest/handlers/forkchoice.rs b/crates/networking/rpc/engine_rest/handlers/forkchoice.rs new file mode 100644 index 00000000000..0e4c80a90f4 --- /dev/null +++ b/crates/networking/rpc/engine_rest/handlers/forkchoice.rs @@ -0,0 +1,263 @@ +//! POST /engine/v{1..4}/forkchoice. + +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use ethrex_common::{Address, H256}; + +use crate::engine::fork_choice::{ + build_payload, build_payload_v4, handle_forkchoice, validate_attributes_v1, + validate_attributes_v2, validate_attributes_v2_pre_shanghai, validate_attributes_v3, + validate_attributes_v4, +}; +use crate::engine_rest::conversions::{json_payload_status_to_ssz, ssz_withdrawals_to_vec}; +use crate::engine_rest::error::{EngineError, EngineRestError, classify_rpc_err}; +use crate::engine_rest::extractors::Ssz; +use crate::engine_rest::responses::SszBody; +use crate::engine_rest::types::common::{ + ForkchoiceStateV1 as SszForkchoiceState, ForkchoiceUpdatedResponseV1, ssz_none, ssz_some, +}; +use crate::engine_rest::types::forkchoice::{ + ForkchoiceUpdatedV1Request, ForkchoiceUpdatedV2Request, ForkchoiceUpdatedV3Request, + ForkchoiceUpdatedV4Request, +}; +use crate::engine_rest::types::payload_attributes::{ + PayloadAttributesV1, PayloadAttributesV2, PayloadAttributesV3, PayloadAttributesV4, +}; +use crate::rpc::RpcApiContext; +use crate::types::fork_choice::{ + ForkChoiceState, PayloadAttributesV3 as JsonPayloadAttributesV3, + PayloadAttributesV4 as JsonPayloadAttributesV4, +}; +use crate::types::payload::PayloadStatus; + +fn to_fork_choice_state(s: &SszForkchoiceState) -> ForkChoiceState { + ForkChoiceState { + head_block_hash: H256::from(s.head_block_hash), + safe_block_hash: H256::from(s.safe_block_hash), + finalized_block_hash: H256::from(s.finalized_block_hash), + } +} + +fn payload_attributes_v1_to_internal(a: &PayloadAttributesV1) -> JsonPayloadAttributesV3 { + JsonPayloadAttributesV3 { + timestamp: a.timestamp, + prev_randao: H256::from(a.prev_randao), + suggested_fee_recipient: Address::from_slice(&a.suggested_fee_recipient), + withdrawals: None, + parent_beacon_block_root: None, + } +} + +fn payload_attributes_v2_to_internal(a: &PayloadAttributesV2) -> JsonPayloadAttributesV3 { + JsonPayloadAttributesV3 { + timestamp: a.timestamp, + prev_randao: H256::from(a.prev_randao), + suggested_fee_recipient: Address::from_slice(&a.suggested_fee_recipient), + withdrawals: Some(ssz_withdrawals_to_vec(&a.withdrawals)), + parent_beacon_block_root: None, + } +} + +fn payload_attributes_v3_to_internal(a: &PayloadAttributesV3) -> JsonPayloadAttributesV3 { + JsonPayloadAttributesV3 { + timestamp: a.timestamp, + prev_randao: H256::from(a.prev_randao), + suggested_fee_recipient: Address::from_slice(&a.suggested_fee_recipient), + withdrawals: Some(ssz_withdrawals_to_vec(&a.withdrawals)), + parent_beacon_block_root: Some(H256::from(a.parent_beacon_block_root)), + } +} + +fn payload_attributes_v4_to_internal(a: &PayloadAttributesV4) -> JsonPayloadAttributesV4 { + JsonPayloadAttributesV4 { + timestamp: a.timestamp, + prev_randao: H256::from(a.prev_randao), + suggested_fee_recipient: Address::from_slice(&a.suggested_fee_recipient), + withdrawals: Some(ssz_withdrawals_to_vec(&a.withdrawals)), + parent_beacon_block_root: Some(H256::from(a.parent_beacon_block_root)), + slot_number: a.slot_number, + target_gas_limit: Some(a.target_gas_limit), + } +} + +fn response_to_ssz( + payload_status: PayloadStatus, + payload_id: Option, +) -> Result { + let ssz_status = json_payload_status_to_ssz(&payload_status)?; + let id_list = match payload_id { + Some(id) => ssz_some(id.to_be_bytes()), + None => ssz_none(), + }; + Ok(ForkchoiceUpdatedResponseV1 { + payload_status: ssz_status, + payload_id: id_list, + }) +} + +pub async fn forkchoice_v1( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let fcs = to_fork_choice_state(&req.forkchoice_state); + let attrs: Option = req + .payload_attributes + .first() + .map(payload_attributes_v1_to_internal); + + let (head_block_opt, mut response) = match handle_forkchoice(&fcs, ctx.clone(), 1).await { + Ok(r) => r, + Err(e) => return classify_rpc_err(e), + }; + + if let (Some(head_block), Some(attrs)) = (head_block_opt, attrs.as_ref()) { + if ctx + .storage + .get_chain_config() + .is_cancun_activated(attrs.timestamp) + { + return EngineError::unprocessable("forkChoiceV1 used to build Cancun payload"); + } + if let Err(e) = validate_attributes_v1(attrs, &head_block) { + return classify_rpc_err(e); + } + match build_payload(attrs, ctx, &fcs, 1).await { + Ok(id) => response.set_id(id), + Err(e) => return classify_rpc_err(e), + } + } + + finalize(response) +} + +pub async fn forkchoice_v2( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let fcs = to_fork_choice_state(&req.forkchoice_state); + let attrs: Option = req + .payload_attributes + .first() + .map(payload_attributes_v2_to_internal); + + let (head_block_opt, mut response) = match handle_forkchoice(&fcs, ctx.clone(), 2).await { + Ok(r) => r, + Err(e) => return classify_rpc_err(e), + }; + + if let (Some(head_block), Some(attrs)) = (head_block_opt, attrs.as_ref()) { + let chain_config = ctx.storage.get_chain_config(); + if chain_config.is_cancun_activated(attrs.timestamp) { + return EngineError::unprocessable("forkChoiceV2 used to build Cancun payload"); + } + let validation = if chain_config.is_shanghai_activated(attrs.timestamp) { + validate_attributes_v2(attrs, &head_block) + } else { + validate_attributes_v2_pre_shanghai(attrs, &head_block) + }; + if let Err(e) = validation { + return classify_rpc_err(e); + } + match build_payload(attrs, ctx, &fcs, 2).await { + Ok(id) => response.set_id(id), + Err(e) => return classify_rpc_err(e), + } + } + + finalize(response) +} + +pub async fn forkchoice_v3( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let fcs = to_fork_choice_state(&req.forkchoice_state); + let attrs: Option = req + .payload_attributes + .first() + .map(payload_attributes_v3_to_internal); + + let (head_block_opt, mut response) = match handle_forkchoice(&fcs, ctx.clone(), 3).await { + Ok(r) => r, + Err(e) => return classify_rpc_err(e), + }; + + if let (Some(head_block), Some(attrs)) = (head_block_opt, attrs.as_ref()) { + if let Err(e) = validate_attributes_v3(attrs, &head_block, &ctx) { + return classify_rpc_err(e); + } + match build_payload(attrs, ctx, &fcs, 3).await { + Ok(id) => response.set_id(id), + Err(e) => return classify_rpc_err(e), + } + } + + finalize(response) +} + +pub async fn forkchoice_v4( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let fcs = to_fork_choice_state(&req.forkchoice_state); + let attrs: Option = req + .payload_attributes + .first() + .map(payload_attributes_v4_to_internal); + + let (head_block_opt, mut response) = match handle_forkchoice(&fcs, ctx.clone(), 4).await { + Ok(r) => r, + Err(e) => return classify_rpc_err(e), + }; + + if let (Some(head_block), Some(attrs)) = (head_block_opt, attrs.as_ref()) { + if let Err(e) = validate_attributes_v4(attrs, &head_block, &ctx) { + return classify_rpc_err(e); + } + match build_payload_v4(attrs, ctx, &fcs).await { + Ok(id) => response.set_id(id), + Err(e) => return classify_rpc_err(e), + } + } + + finalize(response) +} + +fn finalize(response: crate::types::fork_choice::ForkChoiceResponse) -> Response { + match response_to_ssz(response.payload_status, response.payload_id) { + Ok(ssz) => SszBody(ssz).into_response(), + Err(e) => e.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ssz_v4(target_gas_limit: u64) -> PayloadAttributesV4 { + PayloadAttributesV4 { + timestamp: 100, + prev_randao: [1u8; 32], + suggested_fee_recipient: [2u8; 20], + withdrawals: Vec::new().try_into().unwrap(), + parent_beacon_block_root: [3u8; 32], + slot_number: 42, + target_gas_limit, + } + } + + // The SSZ wire is a non-nullable u64; we pass `Some(value)` through. The + // 0-sentinel collapse to `None` happens in `build_payload_v4` per EIP-7783 + // so JSON-RPC and SSZ share one source of truth. + #[test] + fn target_gas_limit_round_trips_as_some() { + assert_eq!( + payload_attributes_v4_to_internal(&ssz_v4(0)).target_gas_limit, + Some(0) + ); + assert_eq!( + payload_attributes_v4_to_internal(&ssz_v4(36_000_000)).target_gas_limit, + Some(36_000_000) + ); + } +} diff --git a/crates/networking/rpc/engine_rest/handlers/mod.rs b/crates/networking/rpc/engine_rest/handlers/mod.rs new file mode 100644 index 00000000000..d86c75ed351 --- /dev/null +++ b/crates/networking/rpc/engine_rest/handlers/mod.rs @@ -0,0 +1,8 @@ +//! Engine REST handlers. + +pub mod blobs; +pub mod bodies; +pub mod capabilities; +pub mod client_version; +pub mod forkchoice; +pub mod payloads; diff --git a/crates/networking/rpc/engine_rest/handlers/payloads.rs b/crates/networking/rpc/engine_rest/handlers/payloads.rs new file mode 100644 index 00000000000..90708b0a45d --- /dev/null +++ b/crates/networking/rpc/engine_rest/handlers/payloads.rs @@ -0,0 +1,433 @@ +//! POST /engine/v{1..5}/payloads and GET /engine/v{1..6}/payloads/{payload_id}. + +use std::str::FromStr; + +use axum::extract::{Path, State}; +use axum::response::{IntoResponse, Response}; +use ethrex_common::H256; +use ethrex_common::types::Fork; +use ethrex_common::types::requests::compute_requests_hash; +use ethrex_common::utils::keccak; + +use crate::engine::payload::{ + get_payload, handle_new_payload_v1_v2, handle_new_payload_v3, handle_new_payload_v4, + validate_execution_requests, validate_fork, validate_payload_v1_v2, +}; +use crate::engine_rest::conversions::{ + blobs_bundle_to_ssz_v1, blobs_bundle_to_ssz_v2, encoded_requests_to_ssz, + json_payload_status_to_ssz, json_to_execution_payload_v1, json_to_execution_payload_v2, + json_to_execution_payload_v3, json_to_execution_payload_v4, ssz_blob_hashes_to_vec, + ssz_payload_v1_to_block, ssz_payload_v2_to_block, ssz_payload_v3_to_block, + ssz_payload_v4_to_block, ssz_to_encoded_requests, +}; +use crate::engine_rest::error::{EngineError, EngineRestError, classify_rpc_err}; +use crate::engine_rest::extractors::Ssz; +use crate::engine_rest::responses::{SszBody, add_no_store}; +use crate::engine_rest::types::common::{PayloadId, u256_to_uint256_le}; +use crate::engine_rest::types::get_payload::{ + GetPayloadResponseV2, GetPayloadResponseV3, GetPayloadResponseV4, GetPayloadResponseV5, + GetPayloadResponseV6, +}; +use crate::engine_rest::types::new_payload::{ + NewPayloadV1Request, NewPayloadV2Request, NewPayloadV3Request, NewPayloadV4Request, + NewPayloadV5Request, +}; +use crate::rpc::RpcApiContext; +use crate::types::payload::{ExecutionPayload as JsonExecutionPayload, PayloadStatus}; + +pub async fn new_payload_v1( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let expected_hash = H256::from(req.execution_payload.block_hash); + let block = match ssz_payload_v1_to_block(req.execution_payload, None, None, None) { + Ok(b) => b, + Err(e) => return e.into(), + }; + if let Err(e) = validate_payload_v1_v2(&block, &ctx) { + return classify_rpc_err(e); + } + let status = match handle_new_payload_v1_v2(expected_hash, block, ctx, None).await { + Ok(s) => s, + Err(e) => return classify_rpc_err(e), + }; + ssz_status_response(&status) +} + +pub async fn new_payload_v2( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let payload = req.execution_payload; + let expected_hash = H256::from(payload.block_hash); + // SSZ always carries a (possibly empty) withdrawals list; pre-Shanghai + // JSON-RPC's V2 rejects any withdrawals, so do the same here. + let timestamp = payload.timestamp; + let has_withdrawals = !payload.withdrawals.is_empty(); + if pre_shanghai(&ctx, timestamp) && has_withdrawals { + return EngineError::unprocessable("pre-Shanghai payload must not carry withdrawals"); + } + let block = match ssz_payload_v2_to_block(payload, None, None, None) { + Ok(b) => b, + Err(e) => return e.into(), + }; + if let Err(e) = validate_payload_v1_v2(&block, &ctx) { + return classify_rpc_err(e); + } + let status = match handle_new_payload_v1_v2(expected_hash, block, ctx, None).await { + Ok(s) => s, + Err(e) => return classify_rpc_err(e), + }; + ssz_status_response(&status) +} + +fn pre_shanghai(ctx: &RpcApiContext, ts: u64) -> bool { + !ctx.storage.get_chain_config().is_shanghai_activated(ts) +} + +pub async fn new_payload_v3( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let expected_hash = H256::from(req.execution_payload.block_hash); + let block = match ssz_payload_v3_to_block( + req.execution_payload, + Some(H256::from(req.parent_beacon_block_root)), + None, + None, + ) { + Ok(b) => b, + Err(e) => return e.into(), + }; + if let Err(e) = validate_fork(&block, Fork::Cancun, &ctx) { + return classify_rpc_err(e); + } + let expected = ssz_blob_hashes_to_vec(&req.expected_blob_versioned_hashes); + let status = match handle_new_payload_v3(expected_hash, ctx, block, expected, None).await { + Ok(s) => s, + Err(e) => return classify_rpc_err(e), + }; + ssz_status_response(&status) +} + +pub async fn new_payload_v4( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let expected_hash = H256::from(req.execution_payload.block_hash); + let exec_requests = ssz_to_encoded_requests(&req.execution_requests); + if let Err(e) = validate_execution_requests(&exec_requests) { + return classify_rpc_err(e); + } + let requests_hash = compute_requests_hash(&exec_requests); + let block = match ssz_payload_v3_to_block( + req.execution_payload, + Some(H256::from(req.parent_beacon_block_root)), + Some(requests_hash), + None, + ) { + Ok(b) => b, + Err(e) => return e.into(), + }; + let chain_config = ctx.storage.get_chain_config(); + if !chain_config.is_prague_activated(block.header.timestamp) { + return EngineError::unprocessable(&format!( + "{:?}", + chain_config.get_fork(block.header.timestamp) + )); + } + let expected = ssz_blob_hashes_to_vec(&req.expected_blob_versioned_hashes); + let status = match handle_new_payload_v3(expected_hash, ctx, block, expected, None).await { + Ok(s) => s, + Err(e) => return classify_rpc_err(e), + }; + ssz_status_response(&status) +} + +pub async fn new_payload_v5( + State(ctx): State, + Ssz(req): Ssz, +) -> Response { + let expected_hash = H256::from(req.execution_payload.block_hash); + // Hash the raw BAL bytes as-received: re-encoding through RLP can change + // ordering and break the block-hash check. + let raw_bal_hash = if req.execution_payload.block_access_list.is_empty() { + None + } else { + Some(keccak(&req.execution_payload.block_access_list[..])) + }; + + let exec_requests = ssz_to_encoded_requests(&req.execution_requests); + if let Err(e) = validate_execution_requests(&exec_requests) { + return classify_rpc_err(e); + } + let requests_hash = compute_requests_hash(&exec_requests); + + let (block, bal) = match ssz_payload_v4_to_block( + req.execution_payload, + Some(H256::from(req.parent_beacon_block_root)), + Some(requests_hash), + raw_bal_hash, + ) { + Ok(b) => b, + Err(e) => return e.into(), + }; + let chain_config = ctx.storage.get_chain_config(); + if !chain_config.is_amsterdam_activated(block.header.timestamp) { + return EngineError::unprocessable(&format!( + "{:?}", + chain_config.get_fork(block.header.timestamp) + )); + } + let expected = ssz_blob_hashes_to_vec(&req.expected_blob_versioned_hashes); + let status = match handle_new_payload_v4(expected_hash, ctx, block, expected, bal).await { + Ok(s) => s, + Err(e) => return classify_rpc_err(e), + }; + ssz_status_response(&status) +} + +fn parse_payload_id(s: &str) -> Result { + PayloadId::from_str(s) + .map_err(|e| EngineRestError::bad_request(format!("invalid payload_id: {e}"))) +} + +async fn fetch_payload( + ctx: &RpcApiContext, + id: PayloadId, +) -> Result { + get_payload(id.as_u64(), ctx).await.map_err(Into::into) +} + +pub async fn get_payload_v1( + State(ctx): State, + Path(id_str): Path, +) -> Response { + let id = match parse_payload_id(&id_str) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let bundle = match fetch_payload(&ctx, id).await { + Ok(b) => b, + Err(e) => return e.into(), + }; + if let Err(e) = validate_payload_v1_v2(&bundle.block, &ctx) { + return classify_rpc_err(e); + } + let json = JsonExecutionPayload::from_block(bundle.block, None); + match json_to_execution_payload_v1(&json) { + Ok(p) => add_no_store(SszBody(p).into_response()), + Err(e) => e.into(), + } +} + +pub async fn get_payload_v2( + State(ctx): State, + Path(id_str): Path, +) -> Response { + let id = match parse_payload_id(&id_str) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let bundle = match fetch_payload(&ctx, id).await { + Ok(b) => b, + Err(e) => return e.into(), + }; + if let Err(e) = validate_payload_v1_v2(&bundle.block, &ctx) { + return classify_rpc_err(e); + } + let block_value = u256_to_uint256_le(bundle.block_value); + let json = JsonExecutionPayload::from_block(bundle.block, None); + let payload = match json_to_execution_payload_v2(&json) { + Ok(p) => p, + Err(e) => return e.into(), + }; + add_no_store( + SszBody(GetPayloadResponseV2 { + execution_payload: payload, + block_value, + }) + .into_response(), + ) +} + +pub async fn get_payload_v3( + State(ctx): State, + Path(id_str): Path, +) -> Response { + let id = match parse_payload_id(&id_str) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let bundle = match fetch_payload(&ctx, id).await { + Ok(b) => b, + Err(e) => return e.into(), + }; + if let Err(e) = validate_fork(&bundle.block, Fork::Cancun, &ctx) { + return classify_rpc_err(e); + } + let block_value = u256_to_uint256_le(bundle.block_value); + let blobs_bundle = match blobs_bundle_to_ssz_v1(bundle.blobs_bundle) { + Ok(b) => b, + Err(e) => return e.into(), + }; + let json = JsonExecutionPayload::from_block(bundle.block, None); + let payload = match json_to_execution_payload_v3(&json) { + Ok(p) => p, + Err(e) => return e.into(), + }; + add_no_store( + SszBody(GetPayloadResponseV3 { + execution_payload: payload, + block_value, + blobs_bundle, + should_override_builder: false, + }) + .into_response(), + ) +} + +pub async fn get_payload_v4( + State(ctx): State, + Path(id_str): Path, +) -> Response { + let id = match parse_payload_id(&id_str) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let bundle = match fetch_payload(&ctx, id).await { + Ok(b) => b, + Err(e) => return e.into(), + }; + let chain_config = ctx.storage.get_chain_config(); + if !chain_config.is_prague_activated(bundle.block.header.timestamp) { + return EngineError::unprocessable(&format!( + "{:?}", + chain_config.get_fork(bundle.block.header.timestamp) + )); + } + if chain_config.is_osaka_activated(bundle.block.header.timestamp) { + return EngineError::unprocessable(&format!("{:?}", Fork::Osaka)); + } + let block_value = u256_to_uint256_le(bundle.block_value); + let blobs_bundle = match blobs_bundle_to_ssz_v1(bundle.blobs_bundle) { + Ok(b) => b, + Err(e) => return e.into(), + }; + let execution_requests = match encoded_requests_to_ssz(&bundle.requests) { + Ok(r) => r, + Err(e) => return e.into(), + }; + let json = JsonExecutionPayload::from_block(bundle.block, None); + let payload = match json_to_execution_payload_v3(&json) { + Ok(p) => p, + Err(e) => return e.into(), + }; + add_no_store( + SszBody(GetPayloadResponseV4 { + execution_payload: payload, + block_value, + blobs_bundle, + should_override_builder: false, + execution_requests, + }) + .into_response(), + ) +} + +pub async fn get_payload_v5( + State(ctx): State, + Path(id_str): Path, +) -> Response { + let id = match parse_payload_id(&id_str) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let bundle = match fetch_payload(&ctx, id).await { + Ok(b) => b, + Err(e) => return e.into(), + }; + let chain_config = ctx.storage.get_chain_config(); + if !chain_config.is_osaka_activated(bundle.block.header.timestamp) { + return EngineError::unprocessable(&format!( + "{:?}", + chain_config.get_fork(bundle.block.header.timestamp) + )); + } + let block_value = u256_to_uint256_le(bundle.block_value); + let blobs_bundle = match blobs_bundle_to_ssz_v2(bundle.blobs_bundle) { + Ok(b) => b, + Err(e) => return e.into(), + }; + let execution_requests = match encoded_requests_to_ssz(&bundle.requests) { + Ok(r) => r, + Err(e) => return e.into(), + }; + let json = JsonExecutionPayload::from_block(bundle.block, bundle.block_access_list); + let payload = match json_to_execution_payload_v3(&json) { + Ok(p) => p, + Err(e) => return e.into(), + }; + add_no_store( + SszBody(GetPayloadResponseV5 { + execution_payload: payload, + block_value, + blobs_bundle, + should_override_builder: false, + execution_requests, + }) + .into_response(), + ) +} + +pub async fn get_payload_v6( + State(ctx): State, + Path(id_str): Path, +) -> Response { + let id = match parse_payload_id(&id_str) { + Ok(id) => id, + Err(e) => return e.into(), + }; + let bundle = match fetch_payload(&ctx, id).await { + Ok(b) => b, + Err(e) => return e.into(), + }; + let chain_config = ctx.storage.get_chain_config(); + if !chain_config.is_amsterdam_activated(bundle.block.header.timestamp) { + return EngineError::unprocessable(&format!( + "{:?}", + chain_config.get_fork(bundle.block.header.timestamp) + )); + } + let block_value = u256_to_uint256_le(bundle.block_value); + let blobs_bundle = match blobs_bundle_to_ssz_v2(bundle.blobs_bundle) { + Ok(b) => b, + Err(e) => return e.into(), + }; + let execution_requests = match encoded_requests_to_ssz(&bundle.requests) { + Ok(r) => r, + Err(e) => return e.into(), + }; + let json = JsonExecutionPayload::from_block(bundle.block, bundle.block_access_list); + let payload = match json_to_execution_payload_v4(&json) { + Ok(p) => p, + Err(e) => return e.into(), + }; + add_no_store( + SszBody(GetPayloadResponseV6 { + execution_payload: payload, + block_value, + blobs_bundle, + should_override_builder: false, + execution_requests, + }) + .into_response(), + ) +} + +fn ssz_status_response(s: &PayloadStatus) -> Response { + match json_payload_status_to_ssz(s) { + Ok(ssz) => SszBody(ssz).into_response(), + Err(e) => e.into(), + } +} diff --git a/crates/networking/rpc/engine_rest/mod.rs b/crates/networking/rpc/engine_rest/mod.rs new file mode 100644 index 00000000000..b7ac9021bf8 --- /dev/null +++ b/crates/networking/rpc/engine_rest/mod.rs @@ -0,0 +1,177 @@ +//! Engine REST/SSZ transport per execution-apis PR #764. +//! +//! Binary SSZ endpoints under `/engine/v{N}/...` on the authrpc port. JSON-RPC +//! stays the default; supported endpoints are advertised via +//! `engine_exchangeCapabilities` as strings like `"POST /engine/v5/payloads"`. +//! +//! Endpoints (versions correspond to `engine_*V{N}` JSON-RPC method versions): +//! POST /engine/v{1..5}/payloads newPayload +//! GET /engine/v{1..6}/payloads/{payload_id} getPayload +//! POST /engine/v{1,2}/payloads/bodies/by-hash getPayloadBodiesByHash +//! POST /engine/v{1,2}/payloads/bodies/by-range getPayloadBodiesByRange +//! POST /engine/v{1..4}/forkchoice forkchoiceUpdated +//! POST /engine/v{1..3}/blobs getBlobs +//! POST /engine/v1/client/version getClientVersion +//! POST /engine/v1/capabilities exchangeCapabilities +//! +//! Per-endpoint Content-Length caps from #764 §Security considerations are +//! not enforced; the authrpc router's global 256 MB `DefaultBodyLimit` covers +//! both transports. + +pub mod auth; +pub mod conversions; +pub mod error; +pub mod extractors; +pub mod handlers; +pub mod observe; +pub mod responses; +pub mod types; + +use axum::Router; +use axum::routing::{get, post}; + +use crate::rpc::{ClientVersion, RpcApiContext}; + +/// Build the engine REST sub-router. JWT auth middleware is applied uniformly. +pub fn router(ctx: RpcApiContext) -> Router { + let secret = ctx.node_data.jwt_secret.clone(); + let client_version: ClientVersion = ctx.node_data.client_version.clone(); + + let client_router = Router::new() + .route( + "/engine/v1/client/version", + post(handlers::client_version::client_version), + ) + .with_state(client_version); + + let other_router: Router<()> = Router::new() + // payloads + .route( + "/engine/v1/payloads", + post(handlers::payloads::new_payload_v1), + ) + .route( + "/engine/v2/payloads", + post(handlers::payloads::new_payload_v2), + ) + .route( + "/engine/v3/payloads", + post(handlers::payloads::new_payload_v3), + ) + .route( + "/engine/v4/payloads", + post(handlers::payloads::new_payload_v4), + ) + .route( + "/engine/v5/payloads", + post(handlers::payloads::new_payload_v5), + ) + .route( + "/engine/v1/payloads/{payload_id}", + get(handlers::payloads::get_payload_v1), + ) + .route( + "/engine/v2/payloads/{payload_id}", + get(handlers::payloads::get_payload_v2), + ) + .route( + "/engine/v3/payloads/{payload_id}", + get(handlers::payloads::get_payload_v3), + ) + .route( + "/engine/v4/payloads/{payload_id}", + get(handlers::payloads::get_payload_v4), + ) + .route( + "/engine/v5/payloads/{payload_id}", + get(handlers::payloads::get_payload_v5), + ) + .route( + "/engine/v6/payloads/{payload_id}", + get(handlers::payloads::get_payload_v6), + ) + // bodies + .route( + "/engine/v1/payloads/bodies/by-hash", + post(handlers::bodies::bodies_by_hash_v1), + ) + .route( + "/engine/v2/payloads/bodies/by-hash", + post(handlers::bodies::bodies_by_hash_v2), + ) + .route( + "/engine/v1/payloads/bodies/by-range", + post(handlers::bodies::bodies_by_range_v1), + ) + .route( + "/engine/v2/payloads/bodies/by-range", + post(handlers::bodies::bodies_by_range_v2), + ) + // forkchoice + .route( + "/engine/v1/forkchoice", + post(handlers::forkchoice::forkchoice_v1), + ) + .route( + "/engine/v2/forkchoice", + post(handlers::forkchoice::forkchoice_v2), + ) + .route( + "/engine/v3/forkchoice", + post(handlers::forkchoice::forkchoice_v3), + ) + .route( + "/engine/v4/forkchoice", + post(handlers::forkchoice::forkchoice_v4), + ) + // blobs + .route("/engine/v1/blobs", post(handlers::blobs::blobs_v1)) + .route("/engine/v2/blobs", post(handlers::blobs::blobs_v2)) + .route("/engine/v3/blobs", post(handlers::blobs::blobs_v3)) + // capabilities + .route( + "/engine/v1/capabilities", + post(handlers::capabilities::capabilities), + ) + .with_state(ctx); + + client_router + .merge(other_router) + // Observe runs inside auth so unauthenticated requests don't pollute counters. + .layer(axum::middleware::from_fn( + observe::engine_rest_observe_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + secret, + auth::engine_auth_middleware, + )) +} + +/// SSZ-REST endpoints advertised via `engine_exchangeCapabilities`, formatted +/// as `"METHOD /path"`. +pub const SSZ_REST_CAPABILITIES: &[&str] = &[ + "POST /engine/v1/payloads", + "POST /engine/v2/payloads", + "POST /engine/v3/payloads", + "POST /engine/v4/payloads", + "POST /engine/v5/payloads", + "GET /engine/v1/payloads/{payload_id}", + "GET /engine/v2/payloads/{payload_id}", + "GET /engine/v3/payloads/{payload_id}", + "GET /engine/v4/payloads/{payload_id}", + "GET /engine/v5/payloads/{payload_id}", + "GET /engine/v6/payloads/{payload_id}", + "POST /engine/v1/payloads/bodies/by-hash", + "POST /engine/v2/payloads/bodies/by-hash", + "POST /engine/v1/payloads/bodies/by-range", + "POST /engine/v2/payloads/bodies/by-range", + "POST /engine/v1/forkchoice", + "POST /engine/v2/forkchoice", + "POST /engine/v3/forkchoice", + "POST /engine/v4/forkchoice", + "POST /engine/v1/blobs", + "POST /engine/v2/blobs", + "POST /engine/v3/blobs", + "POST /engine/v1/client/version", + "POST /engine/v1/capabilities", +]; diff --git a/crates/networking/rpc/engine_rest/observe.rs b/crates/networking/rpc/engine_rest/observe.rs new file mode 100644 index 00000000000..bb6ea553deb --- /dev/null +++ b/crates/networking/rpc/engine_rest/observe.rs @@ -0,0 +1,99 @@ +//! Metrics + warn-on-error middleware for engine REST. +//! +//! Records into the same `rpc_request_duration_seconds` / `rpc_requests_total` +//! series JSON-RPC uses, with `method=engine_*` labels so dashboards combine +//! both transports. The `error_kind` label is carried in +//! `EngineErrorContext` from the error sites so it matches the JSON-RPC +//! `crate::rpc::get_error_kind` vocabulary where possible. + +use axum::extract::Request; +use axum::http::Method; +use axum::middleware::Next; +use axum::response::Response; +use ethrex_metrics::rpc::{RpcOutcome, record_async_duration, record_rpc_outcome}; +use std::time::Instant; +use tracing::warn; + +use crate::engine_rest::error::{EngineErrorContext, error_kind_from_status}; + +pub async fn engine_rest_observe_middleware(req: Request, next: Next) -> Response { + let method = req.method().clone(); + let Some(name) = jsonrpc_method_for(&method, req.uri().path()) else { + return next.run(req).await; + }; + + let uri = req.uri().clone(); + + let started = Instant::now(); + let resp = record_async_duration("engine", name, next.run(req)).await; + let elapsed = started.elapsed(); + + let status = resp.status(); + if status.is_success() { + record_rpc_outcome("engine", name, RpcOutcome::Success); + return resp; + } + + let (err_msg, error_kind) = resp + .extensions() + .get::() + .map(|c| (c.message.as_str(), c.error_kind)) + .unwrap_or(("", error_kind_from_status(status))); + record_rpc_outcome("engine", name, RpcOutcome::Error(error_kind)); + warn!( + method = name, + http_method = %method, + path = uri.path(), + status = %status, + duration_ms = elapsed.as_millis() as u64, + err_msg, + transport = "ssz", + "engine REST non-2xx response (CL will fall back to JSON-RPC)", + ); + resp +} + +/// Map (HTTP method, path) → JSON-RPC method name. `None` skips instrumentation. +fn jsonrpc_method_for(http_method: &Method, path: &str) -> Option<&'static str> { + if !path.starts_with("/engine/v") { + return None; + } + let method = http_method.as_str(); + + // `GET /engine/v{N}/payloads/{id}` — match on the prefix before the id segment. + if method == "GET" + && let Some((prefix, _id)) = path.rsplit_once("/payloads/") + { + return Some(match prefix { + "/engine/v1" => "engine_getPayloadV1", + "/engine/v2" => "engine_getPayloadV2", + "/engine/v3" => "engine_getPayloadV3", + "/engine/v4" => "engine_getPayloadV4", + "/engine/v5" => "engine_getPayloadV5", + "/engine/v6" => "engine_getPayloadV6", + _ => return None, + }); + } + + Some(match (method, path) { + ("POST", "/engine/v1/payloads") => "engine_newPayloadV1", + ("POST", "/engine/v2/payloads") => "engine_newPayloadV2", + ("POST", "/engine/v3/payloads") => "engine_newPayloadV3", + ("POST", "/engine/v4/payloads") => "engine_newPayloadV4", + ("POST", "/engine/v5/payloads") => "engine_newPayloadV5", + ("POST", "/engine/v1/payloads/bodies/by-hash") => "engine_getPayloadBodiesByHashV1", + ("POST", "/engine/v2/payloads/bodies/by-hash") => "engine_getPayloadBodiesByHashV2", + ("POST", "/engine/v1/payloads/bodies/by-range") => "engine_getPayloadBodiesByRangeV1", + ("POST", "/engine/v2/payloads/bodies/by-range") => "engine_getPayloadBodiesByRangeV2", + ("POST", "/engine/v1/forkchoice") => "engine_forkchoiceUpdatedV1", + ("POST", "/engine/v2/forkchoice") => "engine_forkchoiceUpdatedV2", + ("POST", "/engine/v3/forkchoice") => "engine_forkchoiceUpdatedV3", + ("POST", "/engine/v4/forkchoice") => "engine_forkchoiceUpdatedV4", + ("POST", "/engine/v1/blobs") => "engine_getBlobsV1", + ("POST", "/engine/v2/blobs") => "engine_getBlobsV2", + ("POST", "/engine/v3/blobs") => "engine_getBlobsV3", + ("POST", "/engine/v1/client/version") => "engine_getClientVersionV1", + ("POST", "/engine/v1/capabilities") => "engine_exchangeCapabilities", + _ => return None, + }) +} diff --git a/crates/networking/rpc/engine_rest/responses.rs b/crates/networking/rpc/engine_rest/responses.rs new file mode 100644 index 00000000000..d2811c0dc8c --- /dev/null +++ b/crates/networking/rpc/engine_rest/responses.rs @@ -0,0 +1,32 @@ +//! SSZ response wrapper. + +use axum::body::Body; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use libssz::SszEncode; + +use crate::engine_rest::extractors::SSZ_CONTENT_TYPE; + +/// SSZ-encoded 200 OK response. +pub struct SszBody(pub T); + +impl IntoResponse for SszBody { + fn into_response(self) -> Response { + let mut bytes = Vec::with_capacity(self.0.encoded_len()); + self.0.ssz_append(&mut bytes); + let mut resp = Response::new(Body::from(bytes)); + *resp.status_mut() = StatusCode::OK; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(SSZ_CONTENT_TYPE), + ); + resp + } +} + +/// Add `Cache-Control: no-store`; payloads keep changing until the slot deadline. +pub fn add_no_store(mut resp: Response) -> Response { + resp.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + resp +} diff --git a/crates/networking/rpc/engine_rest/types/blobs.rs b/crates/networking/rpc/engine_rest/types/blobs.rs new file mode 100644 index 00000000000..a7d04c2e07c --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/blobs.rs @@ -0,0 +1,66 @@ +//! Blob request/response + bundle containers. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::engine_rest::types::common::{ + Blob, Bytes32, Bytes48, CELLS_PER_EXT_BLOB, MAX_BLOB_COMMITMENTS_PER_BLOCK, + MAX_BLOB_HASHES_REQUEST, MAX_BLOB_PROOFS_PER_BUNDLE, +}; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetBlobsV1Request { + pub blob_versioned_hashes: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetBlobsV2Request { + pub blob_versioned_hashes: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetBlobsV3Request { + pub blob_versioned_hashes: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct BlobsBundleV1 { + pub commitments: SszList, + pub proofs: SszList, + pub blobs: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct BlobsBundleV2 { + pub commitments: SszList, + pub proofs: SszList, + pub blobs: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct BlobAndProofV1 { + pub blob: Blob, + pub proof: Bytes48, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct BlobAndProofV2 { + pub blob: Blob, + pub proofs: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetBlobsV1Response { + pub blobs_and_proofs: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetBlobsV2Response { + pub blobs_and_proofs: SszList, +} + +/// V3 uses per-element nullability: each inner list has 0 or 1 element. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetBlobsV3Response { + pub blobs_and_proofs: SszList, MAX_BLOB_HASHES_REQUEST>, +} diff --git a/crates/networking/rpc/engine_rest/types/bodies.rs b/crates/networking/rpc/engine_rest/types/bodies.rs new file mode 100644 index 00000000000..a716d576be0 --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/bodies.rs @@ -0,0 +1,55 @@ +//! Payload bodies containers. `payload_bodies` entries use `List[Body, 1]` +//! (0 = unknown block, 1 = known). `block_access_list` is nullable similarly. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::engine_rest::types::common::{ + Bytes32, MAX_BYTES_PER_TRANSACTION, MAX_PAYLOAD_BODIES_REQUEST, +}; +use crate::engine_rest::types::execution_payload::{Transactions, Withdrawals}; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadBodiesByHashV1Request { + pub block_hashes: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadBodiesByHashV2Request { + pub block_hashes: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadBodiesByRangeV1Request { + pub start: u64, + pub count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadBodiesByRangeV2Request { + pub start: u64, + pub count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ExecutionPayloadBodyV1 { + pub transactions: Transactions, + pub withdrawals: Withdrawals, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ExecutionPayloadBodyV2 { + pub transactions: Transactions, + pub withdrawals: Withdrawals, + pub block_access_list: SszList, 1>, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct PayloadBodiesV1Response { + pub payload_bodies: SszList, MAX_PAYLOAD_BODIES_REQUEST>, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct PayloadBodiesV2Response { + pub payload_bodies: SszList, MAX_PAYLOAD_BODIES_REQUEST>, +} diff --git a/crates/networking/rpc/engine_rest/types/capabilities.rs b/crates/networking/rpc/engine_rest/types/capabilities.rs new file mode 100644 index 00000000000..a43da67c8c3 --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/capabilities.rs @@ -0,0 +1,18 @@ +//! ExchangeCapabilities request/response. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::engine_rest::types::common::{MAX_CAPABILITIES, MAX_CAPABILITY_NAME_LENGTH}; + +pub type CapabilityList = SszList, MAX_CAPABILITIES>; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ExchangeCapabilitiesRequest { + pub capabilities: CapabilityList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ExchangeCapabilitiesResponse { + pub capabilities: CapabilityList, +} diff --git a/crates/networking/rpc/engine_rest/types/client_version.rs b/crates/networking/rpc/engine_rest/types/client_version.rs new file mode 100644 index 00000000000..6d6a0ec80bc --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/client_version.rs @@ -0,0 +1,27 @@ +//! Client version request/response. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::engine_rest::types::common::{ + Bytes4, MAX_CLIENT_CODE_LENGTH, MAX_CLIENT_NAME_LENGTH, MAX_CLIENT_VERSION_LENGTH, + MAX_CLIENT_VERSIONS, +}; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ClientVersionV1 { + pub code: SszList, + pub name: SszList, + pub version: SszList, + pub commit: Bytes4, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetClientVersionV1Request { + pub client_version: ClientVersionV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetClientVersionV1Response { + pub versions: SszList, +} diff --git a/crates/networking/rpc/engine_rest/types/common.rs b/crates/networking/rpc/engine_rest/types/common.rs new file mode 100644 index 00000000000..fa41b9e70de --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/common.rs @@ -0,0 +1,170 @@ +//! Fork-invariant SSZ types and constants. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; +use thiserror::Error; + +pub const MAX_BYTES_PER_TRANSACTION: usize = 1 << 30; // 2**30 +pub const MAX_TRANSACTIONS_PER_PAYLOAD: usize = 1 << 20; // 2**20 +pub const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 16; // 2**4 +pub const BYTES_PER_LOGS_BLOOM: usize = 256; +pub const MAX_EXTRA_DATA_BYTES: usize = 32; // 2**5 +pub const MAX_BLOB_COMMITMENTS_PER_BLOCK: usize = 4096; // 2**12 +pub const FIELD_ELEMENTS_PER_BLOB: usize = 4096; +pub const BYTES_PER_FIELD_ELEMENT: usize = 32; +pub const CELLS_PER_EXT_BLOB: usize = 128; +// Also the SSZ `List` length bound on the `block_hashes` / `payload_bodies` +// containers, so larger requests can't be decoded over this transport. +pub const MAX_PAYLOAD_BODIES_REQUEST: usize = 32; // 2**5 +pub const MAX_BLOB_HASHES_REQUEST: usize = 128; +pub const MAX_EXECUTION_REQUESTS: usize = 256; // 2**8 +pub const MAX_ERROR_MESSAGE_LENGTH: usize = 1024; +pub const MAX_CLIENT_CODE_LENGTH: usize = 2; +pub const MAX_CLIENT_NAME_LENGTH: usize = 64; +pub const MAX_CLIENT_VERSION_LENGTH: usize = 64; +pub const MAX_CLIENT_VERSIONS: usize = 4; +pub const MAX_CAPABILITY_NAME_LENGTH: usize = 64; +pub const MAX_CAPABILITIES: usize = 64; +pub const BLOB_SIZE: usize = FIELD_ELEMENTS_PER_BLOB * BYTES_PER_FIELD_ELEMENT; // 131_072 +pub const MAX_BLOB_PROOFS_PER_BUNDLE: usize = MAX_BLOB_COMMITMENTS_PER_BLOCK * CELLS_PER_EXT_BLOB; + +pub type Bytes4 = [u8; 4]; +pub type Bytes8 = [u8; 8]; +pub type Bytes20 = [u8; 20]; +pub type Bytes32 = [u8; 32]; +pub type Bytes48 = [u8; 48]; +pub type LogsBloom = [u8; BYTES_PER_LOGS_BLOOM]; +pub type Blob = [u8; BLOB_SIZE]; + +/// SSZ uint256 encoded as little-endian Bytes32. +pub type Uint256 = [u8; 32]; + +/// Convert a u64 to the SSZ uint256 (little-endian, 32-byte) representation. +pub fn u64_to_uint256_le(v: u64) -> Uint256 { + let mut out = [0u8; 32]; + out[..8].copy_from_slice(&v.to_le_bytes()); + out +} + +/// Convert an ethrex_common::U256 to the SSZ uint256 (little-endian) representation. +pub fn u256_to_uint256_le(v: ethrex_common::U256) -> Uint256 { + v.to_little_endian() +} + +/// Decode SSZ uint256 (little-endian) back to ethrex_common::U256. +pub fn uint256_le_to_u256(v: &Uint256) -> ethrex_common::U256 { + ethrex_common::U256::from_little_endian(v) +} + +/// Decode SSZ uint256 (little-endian) to u64. Returns None if any high byte is non-zero. +pub fn uint256_le_to_u64(v: &Uint256) -> Option { + if v[8..].iter().any(|&b| b != 0) { + return None; + } + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&v[..8]); + Some(u64::from_le_bytes(bytes)) +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PayloadStatusCode { + Valid = 0, + Invalid = 1, + Syncing = 2, + Accepted = 3, +} + +// `latest_valid_hash` uses nullable encoding (`List[Bytes32, 1]`); +// `validation_error` is a ByteList where empty = absent. +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct PayloadStatusV1 { + pub status: u8, + pub latest_valid_hash: SszList, + pub validation_error: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode, Default)] +pub struct ForkchoiceStateV1 { + pub head_block_hash: Bytes32, + pub safe_block_hash: Bytes32, + pub finalized_block_hash: Bytes32, +} + +// `payload_id` uses nullable encoding (`List[Bytes8, 1]`). +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ForkchoiceUpdatedResponseV1 { + pub payload_status: PayloadStatusV1, + pub payload_id: SszList, +} + +/// Hex-encoded `Bytes8` path parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PayloadId(pub Bytes8); + +impl PayloadId { + pub fn as_u64(self) -> u64 { + u64::from_be_bytes(self.0) + } + + pub fn from_u64(v: u64) -> Self { + PayloadId(v.to_be_bytes()) + } +} + +#[derive(Debug, Error)] +pub enum PayloadIdParseError { + #[error("payload_id must be 0x-prefixed")] + MissingPrefix, + #[error("payload_id must be 16 hex chars (8 bytes), got {0}")] + WrongLength(usize), + #[error("invalid hex: {0}")] + InvalidHex(#[from] hex::FromHexError), +} + +impl core::str::FromStr for PayloadId { + type Err = PayloadIdParseError; + + fn from_str(s: &str) -> Result { + let hex = s + .strip_prefix("0x") + .ok_or(PayloadIdParseError::MissingPrefix)?; + if hex.len() != 16 { + return Err(PayloadIdParseError::WrongLength(hex.len())); + } + let bytes = hex::decode(hex)?; + let mut arr = [0u8; 8]; + arr.copy_from_slice(&bytes); + Ok(PayloadId(arr)) + } +} + +/// SSZ wire encoding of `Option` as `List[T, 1]`: 0 elements = `None`, +/// 1 element = `Some`. The list-ness is a wire-format convention only — +/// semantically this is an `Option`. +pub type SszOption = SszList; + +pub fn ssz_some(v: T) -> SszOption +where + SszOption: TryFrom>, + as TryFrom>>::Error: core::fmt::Debug, +{ + vec![v] + .try_into() + .expect("single element fits in SszOption") +} + +pub fn ssz_none() -> SszOption +where + SszOption: TryFrom>, + as TryFrom>>::Error: core::fmt::Debug, +{ + Vec::::new() + .try_into() + .expect("empty list fits in SszOption") +} + +/// Read an SSZ-encoded `Option` back to `Option`. +pub fn ssz_into_option(list: &SszList) -> Option { + list.first().cloned() +} diff --git a/crates/networking/rpc/engine_rest/types/execution_payload.rs b/crates/networking/rpc/engine_rest/types/execution_payload.rs new file mode 100644 index 00000000000..b7dbb40afd4 --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/execution_payload.rs @@ -0,0 +1,100 @@ +//! ExecutionPayload containers V1..V4. +//! +//! V1 (Paris) base; V2 (Shanghai) adds `withdrawals`; V3 (Cancun) adds +//! `blob_gas_used`/`excess_blob_gas`; V4 (Amsterdam) adds `block_access_list` +//! and `slot_number`. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::engine_rest::types::common::{ + Bytes20, Bytes32, LogsBloom, MAX_BYTES_PER_TRANSACTION, MAX_EXTRA_DATA_BYTES, + MAX_TRANSACTIONS_PER_PAYLOAD, MAX_WITHDRAWALS_PER_PAYLOAD, Uint256, +}; +use crate::engine_rest::types::withdrawal::WithdrawalV1; + +pub type Transactions = + SszList, MAX_TRANSACTIONS_PER_PAYLOAD>; + +pub type Withdrawals = SszList; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ExecutionPayloadV1 { + pub parent_hash: Bytes32, + pub fee_recipient: Bytes20, + pub state_root: Bytes32, + pub receipts_root: Bytes32, + pub logs_bloom: LogsBloom, + pub prev_randao: Bytes32, + pub block_number: u64, + pub gas_limit: u64, + pub gas_used: u64, + pub timestamp: u64, + pub extra_data: SszList, + pub base_fee_per_gas: Uint256, + pub block_hash: Bytes32, + pub transactions: Transactions, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ExecutionPayloadV2 { + pub parent_hash: Bytes32, + pub fee_recipient: Bytes20, + pub state_root: Bytes32, + pub receipts_root: Bytes32, + pub logs_bloom: LogsBloom, + pub prev_randao: Bytes32, + pub block_number: u64, + pub gas_limit: u64, + pub gas_used: u64, + pub timestamp: u64, + pub extra_data: SszList, + pub base_fee_per_gas: Uint256, + pub block_hash: Bytes32, + pub transactions: Transactions, + pub withdrawals: Withdrawals, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ExecutionPayloadV3 { + pub parent_hash: Bytes32, + pub fee_recipient: Bytes20, + pub state_root: Bytes32, + pub receipts_root: Bytes32, + pub logs_bloom: LogsBloom, + pub prev_randao: Bytes32, + pub block_number: u64, + pub gas_limit: u64, + pub gas_used: u64, + pub timestamp: u64, + pub extra_data: SszList, + pub base_fee_per_gas: Uint256, + pub block_hash: Bytes32, + pub transactions: Transactions, + pub withdrawals: Withdrawals, + pub blob_gas_used: u64, + pub excess_blob_gas: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ExecutionPayloadV4 { + pub parent_hash: Bytes32, + pub fee_recipient: Bytes20, + pub state_root: Bytes32, + pub receipts_root: Bytes32, + pub logs_bloom: LogsBloom, + pub prev_randao: Bytes32, + pub block_number: u64, + pub gas_limit: u64, + pub gas_used: u64, + pub timestamp: u64, + pub extra_data: SszList, + pub base_fee_per_gas: Uint256, + pub block_hash: Bytes32, + pub transactions: Transactions, + pub withdrawals: Withdrawals, + pub blob_gas_used: u64, + pub excess_blob_gas: u64, + pub block_access_list: SszList, + pub slot_number: u64, +} diff --git a/crates/networking/rpc/engine_rest/types/forkchoice.rs b/crates/networking/rpc/engine_rest/types/forkchoice.rs new file mode 100644 index 00000000000..df2bcd6b026 --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/forkchoice.rs @@ -0,0 +1,36 @@ +//! Forkchoice V1..V4 request containers. +//! +//! `payload_attributes` uses nullable encoding `List[T, 1]`: 0 elements when +//! no payload build is requested, 1 element otherwise. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::engine_rest::types::common::ForkchoiceStateV1; +use crate::engine_rest::types::payload_attributes::{ + PayloadAttributesV1, PayloadAttributesV2, PayloadAttributesV3, PayloadAttributesV4, +}; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ForkchoiceUpdatedV1Request { + pub forkchoice_state: ForkchoiceStateV1, + pub payload_attributes: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ForkchoiceUpdatedV2Request { + pub forkchoice_state: ForkchoiceStateV1, + pub payload_attributes: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ForkchoiceUpdatedV3Request { + pub forkchoice_state: ForkchoiceStateV1, + pub payload_attributes: SszList, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct ForkchoiceUpdatedV4Request { + pub forkchoice_state: ForkchoiceStateV1, + pub payload_attributes: SszList, +} diff --git a/crates/networking/rpc/engine_rest/types/get_payload.rs b/crates/networking/rpc/engine_rest/types/get_payload.rs new file mode 100644 index 00000000000..f1f8d3e4391 --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/get_payload.rs @@ -0,0 +1,52 @@ +//! GetPayload response containers. V1 is a bare `ExecutionPayloadV1`; V2..V6 +//! are envelopes with block value, blobs bundle, etc. + +use libssz_derive::{SszDecode, SszEncode}; + +use crate::engine_rest::types::blobs::{BlobsBundleV1, BlobsBundleV2}; +use crate::engine_rest::types::common::Uint256; +use crate::engine_rest::types::execution_payload::{ + ExecutionPayloadV2, ExecutionPayloadV3, ExecutionPayloadV4, +}; +use crate::engine_rest::types::new_payload::ExecutionRequests; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadResponseV2 { + pub execution_payload: ExecutionPayloadV2, + pub block_value: Uint256, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadResponseV3 { + pub execution_payload: ExecutionPayloadV3, + pub block_value: Uint256, + pub blobs_bundle: BlobsBundleV1, + pub should_override_builder: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadResponseV4 { + pub execution_payload: ExecutionPayloadV3, + pub block_value: Uint256, + pub blobs_bundle: BlobsBundleV1, + pub should_override_builder: bool, + pub execution_requests: ExecutionRequests, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadResponseV5 { + pub execution_payload: ExecutionPayloadV3, + pub block_value: Uint256, + pub blobs_bundle: BlobsBundleV2, + pub should_override_builder: bool, + pub execution_requests: ExecutionRequests, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct GetPayloadResponseV6 { + pub execution_payload: ExecutionPayloadV4, + pub block_value: Uint256, + pub blobs_bundle: BlobsBundleV2, + pub should_override_builder: bool, + pub execution_requests: ExecutionRequests, +} diff --git a/crates/networking/rpc/engine_rest/types/mod.rs b/crates/networking/rpc/engine_rest/types/mod.rs new file mode 100644 index 00000000000..4b0c6552ce2 --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/mod.rs @@ -0,0 +1,13 @@ +//! SSZ wire types for the engine REST API. + +pub mod blobs; +pub mod bodies; +pub mod capabilities; +pub mod client_version; +pub mod common; +pub mod execution_payload; +pub mod forkchoice; +pub mod get_payload; +pub mod new_payload; +pub mod payload_attributes; +pub mod withdrawal; diff --git a/crates/networking/rpc/engine_rest/types/new_payload.rs b/crates/networking/rpc/engine_rest/types/new_payload.rs new file mode 100644 index 00000000000..b0de82d0a2c --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/new_payload.rs @@ -0,0 +1,48 @@ +//! NewPayload V1..V5 request containers. + +use libssz_derive::{SszDecode, SszEncode}; +use libssz_types::SszList; + +use crate::engine_rest::types::common::{ + Bytes32, MAX_BLOB_COMMITMENTS_PER_BLOCK, MAX_BYTES_PER_TRANSACTION, MAX_EXECUTION_REQUESTS, +}; +use crate::engine_rest::types::execution_payload::{ + ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3, ExecutionPayloadV4, +}; + +pub type BlobVersionedHashes = SszList; +pub type ExecutionRequests = + SszList, MAX_EXECUTION_REQUESTS>; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct NewPayloadV1Request { + pub execution_payload: ExecutionPayloadV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct NewPayloadV2Request { + pub execution_payload: ExecutionPayloadV2, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct NewPayloadV3Request { + pub execution_payload: ExecutionPayloadV3, + pub expected_blob_versioned_hashes: BlobVersionedHashes, + pub parent_beacon_block_root: Bytes32, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct NewPayloadV4Request { + pub execution_payload: ExecutionPayloadV3, + pub expected_blob_versioned_hashes: BlobVersionedHashes, + pub parent_beacon_block_root: Bytes32, + pub execution_requests: ExecutionRequests, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct NewPayloadV5Request { + pub execution_payload: ExecutionPayloadV4, + pub expected_blob_versioned_hashes: BlobVersionedHashes, + pub parent_beacon_block_root: Bytes32, + pub execution_requests: ExecutionRequests, +} diff --git a/crates/networking/rpc/engine_rest/types/payload_attributes.rs b/crates/networking/rpc/engine_rest/types/payload_attributes.rs new file mode 100644 index 00000000000..cb56caf67b2 --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/payload_attributes.rs @@ -0,0 +1,41 @@ +//! PayloadAttributes V1..V4. + +use libssz_derive::{SszDecode, SszEncode}; + +use crate::engine_rest::types::common::{Bytes20, Bytes32}; +use crate::engine_rest::types::execution_payload::Withdrawals; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct PayloadAttributesV1 { + pub timestamp: u64, + pub prev_randao: Bytes32, + pub suggested_fee_recipient: Bytes20, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct PayloadAttributesV2 { + pub timestamp: u64, + pub prev_randao: Bytes32, + pub suggested_fee_recipient: Bytes20, + pub withdrawals: Withdrawals, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct PayloadAttributesV3 { + pub timestamp: u64, + pub prev_randao: Bytes32, + pub suggested_fee_recipient: Bytes20, + pub withdrawals: Withdrawals, + pub parent_beacon_block_root: Bytes32, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct PayloadAttributesV4 { + pub timestamp: u64, + pub prev_randao: Bytes32, + pub suggested_fee_recipient: Bytes20, + pub withdrawals: Withdrawals, + pub parent_beacon_block_root: Bytes32, + pub slot_number: u64, + pub target_gas_limit: u64, +} diff --git a/crates/networking/rpc/engine_rest/types/withdrawal.rs b/crates/networking/rpc/engine_rest/types/withdrawal.rs new file mode 100644 index 00000000000..b063bc00b13 --- /dev/null +++ b/crates/networking/rpc/engine_rest/types/withdrawal.rs @@ -0,0 +1,13 @@ +//! Shanghai withdrawal container. + +use libssz_derive::{SszDecode, SszEncode}; + +use crate::engine_rest::types::common::Bytes20; + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +pub struct WithdrawalV1 { + pub index: u64, + pub validator_index: u64, + pub address: Bytes20, + pub amount: u64, +} diff --git a/crates/networking/rpc/examples/decode_v4_body.rs b/crates/networking/rpc/examples/decode_v4_body.rs new file mode 100644 index 00000000000..e4d739dad44 --- /dev/null +++ b/crates/networking/rpc/examples/decode_v4_body.rs @@ -0,0 +1,53 @@ +//! One-shot diagnostic: try to SSZ-decode a captured /engine/v4/payloads body +//! using my NewPayloadV4Request and print the exact error. +//! +//! cargo run -p ethrex-rpc --example decode_v4_body -- /tmp/v4_body.bin + +use ethrex_rpc::engine_rest::types::new_payload::NewPayloadV4Request; +use libssz::SszDecode; +use std::env; +use std::fs; + +fn main() { + let path = env::args().nth(1).expect("usage: decode_v4_body "); + let bytes = fs::read(&path).expect("read body"); + println!("body len: {} bytes", bytes.len()); + + // Show the first 44 bytes (the SSZ container fixed part for NewPayloadV4Request) + let head = &bytes[..bytes.len().min(48)]; + println!("first 48 bytes: {}", hex::encode(head)); + + // Decode offsets + if bytes.len() >= 44 { + let payload_off = u32::from_le_bytes(bytes[0..4].try_into().expect("4 bytes at [0..4]")); + let hashes_off = u32::from_le_bytes(bytes[4..8].try_into().expect("4 bytes at [4..8]")); + let beacon = &bytes[8..40]; + let requests_off = + u32::from_le_bytes(bytes[40..44].try_into().expect("4 bytes at [40..44]")); + println!("payload_off = {payload_off}"); + println!("hashes_off = {hashes_off}"); + println!("beacon_root = 0x{}", hex::encode(beacon)); + println!("requests_off = {requests_off}"); + } + + println!("\n=== attempt SSZ decode as NewPayloadV4Request ==="); + match NewPayloadV4Request::from_ssz_bytes(&bytes) { + Ok(req) => { + println!( + "OK decoded: block_number={} block_hash=0x{}", + req.execution_payload.block_number, + hex::encode(req.execution_payload.block_hash), + ); + println!( + " tx_count={}, withdrawals={}, exec_requests={}, expected_blob_hashes={}", + req.execution_payload.transactions.len(), + req.execution_payload.withdrawals.len(), + req.execution_requests.len(), + req.expected_blob_versioned_hashes.len(), + ); + } + Err(e) => { + println!("DECODE ERROR: {e:?}"); + } + } +} diff --git a/crates/networking/rpc/lib.rs b/crates/networking/rpc/lib.rs index da07e19c4da..383a135bef0 100644 --- a/crates/networking/rpc/lib.rs +++ b/crates/networking/rpc/lib.rs @@ -63,6 +63,7 @@ mod admin; mod authentication; pub mod debug; pub mod engine; +pub mod engine_rest; mod eth; mod mempool; mod net; diff --git a/crates/networking/rpc/rpc.rs b/crates/networking/rpc/rpc.rs index 1d848c4eecc..57c08ac1b89 100644 --- a/crates/networking/rpc/rpc.rs +++ b/crates/networking/rpc/rpc.rs @@ -387,7 +387,7 @@ pub trait RpcHandler: Sized { async fn handle(&self, context: RpcApiContext) -> Result; } -fn get_error_kind(err: &RpcErr) -> &'static str { +pub(crate) fn get_error_kind(err: &RpcErr) -> &'static str { match err { RpcErr::MethodNotFound(_) => "MethodNotFound", RpcErr::WrongParam(_) => "WrongParam", @@ -589,9 +589,12 @@ pub async fn start_api( handle_authrpc_request(ctx, auth, body).await }; + let engine_rest_router = crate::engine_rest::router(service_context.clone()); + let authrpc_router = Router::new() .route("/", post(authrpc_handler)) .with_state(service_context.clone()) + .merge(engine_rest_router) // Bump the body limit for the engine API to 256MB // This is needed to receive payloads bigger than the default limit of 2MB .layer(DefaultBodyLimit::max(256 * 1024 * 1024)); diff --git a/crates/networking/rpc/types/fork_choice.rs b/crates/networking/rpc/types/fork_choice.rs index 27e04ab1d5f..dd4da0b54d0 100644 --- a/crates/networking/rpc/types/fork_choice.rs +++ b/crates/networking/rpc/types/fork_choice.rs @@ -35,6 +35,12 @@ pub struct PayloadAttributesV4 { pub parent_beacon_block_root: Option, #[serde(with = "serde_utils::u64::hex_str")] pub slot_number: u64, + /// EIP-7783 target gas limit. JSON-RPC clients that don't set this leave + /// it as `None`; the SSZ wire transport always supplies a `u64`. Per the + /// EIP, `0` is the "unset" sentinel — `build_payload_v4` collapses both + /// `Some(0)` (JSON) and `0` (SSZ) to the builder's `gas_ceil`. + #[serde(default, with = "serde_utils::u64::hex_str_opt")] + pub target_gas_limit: Option, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/networking/rpc/types/payload.rs b/crates/networking/rpc/types/payload.rs index 287a77c3c30..2c9578b939c 100644 --- a/crates/networking/rpc/types/payload.rs +++ b/crates/networking/rpc/types/payload.rs @@ -99,16 +99,19 @@ impl EncodedTransaction { Transaction::decode_canonical(self.0.as_ref()) } - fn encode(tx: &Transaction) -> Self { + pub fn encode(tx: &Transaction) -> Self { Self(Bytes::from(tx.encode_canonical_to_vec())) } } impl ExecutionPayload { - /// Converts an `ExecutionPayload` into a block (aka a BlockHeader and BlockBody) - /// using the parentBeaconBlockRoot received along with the payload in the rpc call `engine_newPayloadV2/V3` - pub fn into_block( - self, + /// Build a `Block` (BlockHeader + BlockBody) from this payload using the + /// CL-supplied `parentBeaconBlockRoot`/`requests_hash`/`block_access_list_hash`. + /// Takes `&self` so callers don't have to clone the full payload — the + /// only fields actually moved are the transactions (decoded per-element) + /// and the small `withdrawals`/`extra_data` (cheap clone / refcount bump). + pub fn to_block( + &self, parent_beacon_block_root: Option, requests_hash: Option, block_access_list_hash: Option, @@ -120,7 +123,7 @@ impl ExecutionPayload { .map(|encoded_tx| encoded_tx.decode()) .collect::, RLPDecodeError>>()?, ommers: vec![], - withdrawals: self.withdrawals, + withdrawals: self.withdrawals.clone(), }; let header = BlockHeader { parent_hash: self.parent_hash, @@ -138,7 +141,7 @@ impl ExecutionPayload { gas_limit: self.gas_limit, gas_used: self.gas_used, timestamp: self.timestamp, - extra_data: self.extra_data, + extra_data: self.extra_data.clone(), prev_randao: self.prev_randao, nonce: 0, base_fee_per_gas: Some(self.base_fee_per_gas), @@ -338,6 +341,6 @@ mod test { // Payload extracted from running kurtosis, only some transactions are included to reduce it's size. let json = r#"{"baseFeePerGas":"0x342770c0","blobGasUsed":"0x0","blockHash":"0x4029a2342bb6d54db91457bc8e442be22b3481df8edea24cc721f9d0649f65be","blockNumber":"0x1","excessBlobGas":"0x0","extraData":"0xd883010e06846765746888676f312e32322e34856c696e7578","feeRecipient":"0x8943545177806ed17b9f23f0a21ee5948ecaa776","gasLimit":"0x17dd79d","gasUsed":"0x401640","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","parentHash":"0x2971eefd1f71f3548728cad87c16cc91b979ef035054828c59a02e49ae300a84","prevRandao":"0x2971eefd1f71f3548728cad87c16cc91b979ef035054828c59a02e49ae300a84","receiptsRoot":"0x0185e8473b81c3a504c4919249a94a94965a2f61c06367ee6ffb88cb7a3ef02b","stateRoot":"0x0eb8fd0af53174e65bb660d0904e5016425a713d8f11c767c26148b526fc05f3","timestamp":"0x66846fb2","transactions":["0xf86d80843baa0c4082f618946177843db3138ae69679a54b95cf345ed759450d870aa87bee538000808360306ba0151ccc02146b9b11adf516e6787b59acae3e76544fdcd75e77e67c6b598ce65da064c5dd5aae2fbb535830ebbdad0234975cd7ece3562013b63ea18cc0df6c97d4","0xf86d01843baa0c4082f61894687704db07e902e9a8b3754031d168d46e3d586e870aa87bee538000808360306ba0f6c479c3e9135a61d7cca17b7354ddc311cda2d8df265d0378f940bdefd62b54a077786891b0b6bcd438d8c24d00fa6628bc2f1caa554f9dec0a96daa4f40eb0d7","0xf86d02843baa0c4082f6189415e6a5a2e131dd5467fa1ff3acd104f45ee5940b870aa87bee538000808360306ca084469ec8ee41e9104cbe3ad7e7fe4225de86076dd2783749b099a4d155900305a07e64e8848c692f0fc251e78e6f3c388eb303349f3e247481366517c2a5ae2d89","0xf86d03843baa0c4082f6189480c4c7125967139acaa931ee984a9db4100e0f3b870aa87bee538000808360306ba021d2d8a35b8da03d7e0b494f71c9ed1c28a195b94c298407b81d65163a79fbdaa024a9bfcf5bbe75ba35130fa784ab88cd21c12c4e7daf3464de91bc1ed07d1bf6","0xf86d04843baa0c4082f61894d08a63244fcd28b0aec5075052cdce31ba04fead870aa87bee538000808360306ca07ee42fee5e426595056ad406aa65a3c7adb1d3d77279f56ebe2410bcf5118b2ca07b8a0e1d21578e9043a7331f60bafc71d15788d1a2d70d00b3c46e0856ff56d2","0xf86d05843baa0c4082f618940b06ef8be65fcda88f2dbae5813480f997ee8e35870aa87bee538000808360306ba0620669c8d6a781d3131bca874152bf833622af0edcd2247eab1b086875d5242ba01632353388f46946b5ce037130e92128e5837fe35d6c7de2b9e56a0f8cc1f5e6", "0x02f8ef83301824048413f157f8842daf517a830186a094000000000000000000000000000000000000000080b8807a0a600060a0553db8600060c855c77fb29ecd7661d8aefe101a0db652a728af0fded622ff55d019b545d03a7532932a60ad52604260cd5360bf60ce53609460cf53603e60d05360f560d153bc596000609e55600060c6556000601f556000609155535660556057536055605853606e60595360e7605a5360d0605b5360eb60c080a03acb03b1fc20507bc66210f7e18ff5af65038fb22c626ae488ad9513d9b6debca05d38459e9d2a221eb345b0c2761b719b313d062ff1ea3d10cf5b8762c44385a6"],"withdrawals":[]}"#; let payload: ExecutionPayload = serde_json::from_str(json).unwrap(); - assert!(payload.into_block(Some(H256::zero()), None, None).is_ok()); + assert!(payload.to_block(Some(H256::zero()), None, None).is_ok()); } } diff --git a/test/Cargo.toml b/test/Cargo.toml index 41fc3b14546..3808e5fea42 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -52,6 +52,13 @@ ethrex-l2.workspace = true ethrex-l2-rpc.workspace = true reqwest.workspace = true tokio-util.workspace = true +# engine REST/SSZ integration tests +tower = { version = "0.5", features = ["util"] } +axum = { workspace = true } +jsonwebtoken.workspace = true +libssz.workspace = true +libssz-types.workspace = true +serde.workspace = true [[test]] name = "ethrex_tests" diff --git a/test/tests/blockchain/batch_tests.rs b/test/tests/blockchain/batch_tests.rs index 3b3ed724b9e..db753d78da0 100644 --- a/test/tests/blockchain/batch_tests.rs +++ b/test/tests/blockchain/batch_tests.rs @@ -80,6 +80,7 @@ async fn build_block(store: &Store, blockchain: &Blockchain, parent_header: &Blo version: 1, elasticity_multiplier: ELASTICITY_MULTIPLIER, gas_ceil: DEFAULT_BUILDER_GAS_CEIL, + target_gas_limit: None, }; let block = create_payload(&args, store, Bytes::new()).unwrap(); diff --git a/test/tests/blockchain/eip7702_revert_authority_tests.rs b/test/tests/blockchain/eip7702_revert_authority_tests.rs index db83ba619b7..5449286d0f3 100644 --- a/test/tests/blockchain/eip7702_revert_authority_tests.rs +++ b/test/tests/blockchain/eip7702_revert_authority_tests.rs @@ -101,6 +101,7 @@ async fn build_block(store: &Store, blockchain: &Blockchain, parent_header: &Blo version: 1, elasticity_multiplier: ELASTICITY_MULTIPLIER, gas_ceil: DEFAULT_BUILDER_GAS_CEIL, + target_gas_limit: None, }; let block = create_payload(&args, store, Bytes::new()).unwrap(); diff --git a/test/tests/blockchain/eip7702_zero_transfer_tests.rs b/test/tests/blockchain/eip7702_zero_transfer_tests.rs index fcd3bb34064..70d32e7bec8 100644 --- a/test/tests/blockchain/eip7702_zero_transfer_tests.rs +++ b/test/tests/blockchain/eip7702_zero_transfer_tests.rs @@ -70,6 +70,7 @@ async fn build_block(store: &Store, blockchain: &Blockchain, parent_header: &Blo version: 1, elasticity_multiplier: ELASTICITY_MULTIPLIER, gas_ceil: DEFAULT_BUILDER_GAS_CEIL, + target_gas_limit: None, }; let block = create_payload(&args, store, Bytes::new()).unwrap(); diff --git a/test/tests/blockchain/smoke_tests.rs b/test/tests/blockchain/smoke_tests.rs index fcf8f494d0d..525dd8b4628 100644 --- a/test/tests/blockchain/smoke_tests.rs +++ b/test/tests/blockchain/smoke_tests.rs @@ -357,6 +357,7 @@ async fn new_block(store: &Store, parent: &BlockHeader) -> Block { version: 1, elasticity_multiplier: ELASTICITY_MULTIPLIER, gas_ceil: DEFAULT_BUILDER_GAS_CEIL, + target_gas_limit: None, }; // Create blockchain diff --git a/test/tests/rpc/engine_rest_tests.rs b/test/tests/rpc/engine_rest_tests.rs new file mode 100644 index 00000000000..c17ed4e082d --- /dev/null +++ b/test/tests/rpc/engine_rest_tests.rs @@ -0,0 +1,504 @@ +//! Integration tests for the engine REST/SSZ surface. +//! +//! These exercise the router end-to-end via `tower::ServiceExt::oneshot`, with +//! a JWT-authenticated request body containing real SSZ-encoded payloads. + +#![allow(clippy::unwrap_used)] + +use axum::body::{Body, to_bytes}; +use axum::http::{HeaderValue, Request, StatusCode, header}; +use bytes::Bytes; +use ethrex_storage::{EngineType, Store}; +use jsonwebtoken::{EncodingKey, Header, encode}; +use libssz::{SszDecode, SszEncode}; +use libssz_types::SszList; +use serde::Serialize; +use std::time::{SystemTime, UNIX_EPOCH}; +use tower::ServiceExt; + +use ethrex_common::Address; +use ethrex_common::types::ChainConfig; +use ethrex_rpc::engine_rest::SSZ_REST_CAPABILITIES; +use ethrex_rpc::engine_rest::types::blobs::{GetBlobsV1Request, GetBlobsV1Response}; +use ethrex_rpc::engine_rest::types::bodies::{ + GetPayloadBodiesByHashV1Request, PayloadBodiesV1Response, +}; +use ethrex_rpc::engine_rest::types::capabilities::{ + ExchangeCapabilitiesRequest, ExchangeCapabilitiesResponse, +}; +use ethrex_rpc::engine_rest::types::client_version::{ + ClientVersionV1, GetClientVersionV1Request, GetClientVersionV1Response, +}; +use ethrex_rpc::engine_rest::types::common::{ + Bytes32, ForkchoiceStateV1, ForkchoiceUpdatedResponseV1, MAX_CAPABILITY_NAME_LENGTH, + PayloadStatusV1, +}; +use ethrex_rpc::engine_rest::types::execution_payload::{ExecutionPayloadV1, ExecutionPayloadV2}; +use ethrex_rpc::engine_rest::types::forkchoice::ForkchoiceUpdatedV1Request; +use ethrex_rpc::engine_rest::types::new_payload::{NewPayloadV1Request, NewPayloadV2Request}; +use ethrex_rpc::engine_rest::types::withdrawal::WithdrawalV1; +use ethrex_rpc::rpc::{ClientVersion, NodeData, RpcApiContext}; +use ethrex_rpc::test_utils::{ + default_context_with_storage, example_local_node_record, example_p2p_node, +}; + +const TEST_SECRET: &[u8] = b"test-secret-keytest-secret-keyaa"; + +fn make_jwt() -> String { + #[derive(Serialize)] + struct Claims { + iat: usize, + } + let iat = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as usize; + encode( + &Header::default(), + &Claims { iat }, + &EncodingKey::from_secret(TEST_SECRET), + ) + .unwrap() +} + +async fn make_router() -> axum::Router { + let storage = Store::new("", EngineType::InMemory).unwrap(); + make_router_with(storage).await +} + +async fn make_router_with(storage: Store) -> axum::Router { + let mut ctx: RpcApiContext = default_context_with_storage(storage).await; + ctx.node_data = NodeData { + jwt_secret: Bytes::copy_from_slice(TEST_SECRET), + local_p2p_node: example_p2p_node(), + local_node_record: example_local_node_record(), + client_version: ClientVersion::new( + "ethrex".to_string(), + "0.1.0".to_string(), + "test".to_string(), + "abcd1234".to_string(), + "x86_64-unknown-linux".to_string(), + "1.70.0".to_string(), + ), + extra_data: Bytes::new(), + }; + ethrex_rpc::engine_rest::router(ctx) +} + +async fn make_router_with_chain_config(cc: ChainConfig) -> axum::Router { + let mut storage = Store::new("", EngineType::InMemory).unwrap(); + storage.set_chain_config(&cc).await.unwrap(); + make_router_with(storage).await +} + +fn auth_get(path: &str) -> Request { + Request::builder() + .method("GET") + .uri(path) + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", make_jwt())).unwrap(), + ) + .body(Body::empty()) + .unwrap() +} + +fn ssz_body(v: &T) -> Vec { + let mut buf = Vec::with_capacity(v.encoded_len()); + v.ssz_append(&mut buf); + buf +} + +fn auth_post(path: &str, body: Vec) -> Request { + Request::builder() + .method("POST") + .uri(path) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", make_jwt())).unwrap(), + ) + .body(Body::from(body)) + .unwrap() +} + +#[tokio::test] +async fn rest_endpoint_requires_jwt() { + let app = make_router().await; + let req = Request::builder() + .method("POST") + .uri("/engine/v1/capabilities") + .header(header::CONTENT_TYPE, "application/octet-stream") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn capabilities_round_trip() { + let app = make_router().await; + // Build an empty CL capabilities request (we don't filter by it). + let req_body = ExchangeCapabilitiesRequest { + capabilities: Vec::>::new() + .try_into() + .unwrap(), + }; + let bytes = ssz_body(&req_body); + let resp = app + .oneshot(auth_post("/engine/v1/capabilities", bytes)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let decoded = ExchangeCapabilitiesResponse::from_ssz_bytes(&body).unwrap(); + let strings: Vec = decoded + .capabilities + .iter() + .map(|c| String::from_utf8_lossy(c).to_string()) + .collect(); + // Should include both JSON-RPC method names and SSZ REST endpoints. + assert!(strings.iter().any(|s| s == "engine_newPayloadV1")); + for cap in SSZ_REST_CAPABILITIES { + assert!(strings.iter().any(|s| s == cap), "missing capability {cap}"); + } +} + +#[tokio::test] +async fn client_version_round_trip() { + let app = make_router().await; + let cl_version = ClientVersionV1 { + code: b"CL".to_vec().try_into().unwrap(), + name: b"lighthouse".to_vec().try_into().unwrap(), + version: b"v5.0.0".to_vec().try_into().unwrap(), + commit: [1, 2, 3, 4], + }; + let req = GetClientVersionV1Request { + client_version: cl_version, + }; + let bytes = ssz_body(&req); + let resp = app + .oneshot(auth_post("/engine/v1/client/version", bytes)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let decoded = GetClientVersionV1Response::from_ssz_bytes(&body).unwrap(); + assert_eq!(decoded.versions.len(), 1); + let v0 = &decoded.versions[0]; + assert_eq!(&v0.code[..], b"EX"); + assert_eq!(&v0.name[..], b"ethrex"); + assert_eq!(&v0.version[..], b"v0.1.0"); + assert_eq!(v0.commit, [0xab, 0xcd, 0x12, 0x34]); +} + +#[tokio::test] +async fn bodies_by_hash_unknown_returns_empty_slots() { + let app = make_router().await; + let hashes: Vec = vec![[0xaa; 32], [0xbb; 32]]; + let req = GetPayloadBodiesByHashV1Request { + block_hashes: hashes.try_into().unwrap(), + }; + let resp = app + .oneshot(auth_post( + "/engine/v1/payloads/bodies/by-hash", + ssz_body(&req), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let decoded = PayloadBodiesV1Response::from_ssz_bytes(&body).unwrap(); + assert_eq!(decoded.payload_bodies.len(), 2); + for slot in decoded.payload_bodies.iter() { + assert_eq!(slot.len(), 0, "unknown blocks must produce empty slot"); + } +} + +#[tokio::test] +async fn blobs_v1_empty_mempool_returns_empty_list() { + let app = make_router().await; + let hashes: Vec = vec![[0xcc; 32]]; + let req = GetBlobsV1Request { + blob_versioned_hashes: hashes.try_into().unwrap(), + }; + let resp = app + .oneshot(auth_post("/engine/v1/blobs", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let decoded = GetBlobsV1Response::from_ssz_bytes(&body).unwrap(); + assert!(decoded.blobs_and_proofs.is_empty()); +} + +#[tokio::test] +async fn missing_content_type_rejects() { + let app = make_router().await; + let req = Request::builder() + .method("POST") + .uri("/engine/v1/blobs") + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", make_jwt())).unwrap(), + ) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn payload_status_v1_roundtrip() { + let s = PayloadStatusV1 { + status: 0, + latest_valid_hash: vec![[7u8; 32]].try_into().unwrap(), + validation_error: Vec::new().try_into().unwrap(), + }; + let bytes = ssz_body(&s); + let decoded = PayloadStatusV1::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(decoded, s); +} + +#[test] +fn execution_payload_v1_roundtrip() { + let p = ExecutionPayloadV1 { + parent_hash: [1u8; 32], + fee_recipient: [2u8; 20], + state_root: [3u8; 32], + receipts_root: [4u8; 32], + logs_bloom: [5u8; 256], + prev_randao: [6u8; 32], + block_number: 42, + gas_limit: 30_000_000, + gas_used: 21_000, + timestamp: 1_700_000_000, + extra_data: vec![0xde, 0xad].try_into().unwrap(), + base_fee_per_gas: { + let mut a = [0u8; 32]; + a[0] = 0xff; + a + }, + block_hash: [7u8; 32], + transactions: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + }; + let bytes = ssz_body(&p); + let decoded = ExecutionPayloadV1::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(decoded, p); +} + +#[test] +fn forkchoice_response_nullable_id_none() { + let r = ForkchoiceUpdatedResponseV1 { + payload_status: PayloadStatusV1 { + status: 2, // SYNCING + latest_valid_hash: Vec::new().try_into().unwrap(), + validation_error: Vec::new().try_into().unwrap(), + }, + payload_id: Vec::new().try_into().unwrap(), + }; + let bytes = ssz_body(&r); + let decoded = ForkchoiceUpdatedResponseV1::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(decoded, r); + assert!(decoded.payload_id.is_empty()); +} + +#[test] +fn forkchoice_response_nullable_id_some() { + let r = ForkchoiceUpdatedResponseV1 { + payload_status: PayloadStatusV1 { + status: 0, + latest_valid_hash: vec![[9u8; 32]].try_into().unwrap(), + validation_error: Vec::new().try_into().unwrap(), + }, + payload_id: vec![[0xde, 0xad, 0xbe, 0xef, 0, 0, 0, 0]] + .try_into() + .unwrap(), + }; + let bytes = ssz_body(&r); + let decoded = ForkchoiceUpdatedResponseV1::from_ssz_bytes(&bytes).unwrap(); + assert_eq!(decoded, r); + assert_eq!(decoded.payload_id.len(), 1); +} + +#[tokio::test] +async fn forkchoice_v1_no_attrs_returns_status() { + // All-zero forkchoice state against the default-genesis store: the head + // hash doesn't resolve to a known block, apply_fork_choice falls through + // to the catch-all branch which reports INVALID against the canonical tip. + // Result: 200 OK with PayloadStatus=INVALID (1) and no payload_id. + let app = make_router().await; + let req = ForkchoiceUpdatedV1Request { + forkchoice_state: ForkchoiceStateV1::default(), + payload_attributes: Vec::new().try_into().unwrap(), + }; + let resp = app + .oneshot(auth_post("/engine/v1/forkchoice", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let decoded = ForkchoiceUpdatedResponseV1::from_ssz_bytes(&body).unwrap(); + assert_eq!(decoded.payload_status.status, 1, "expected INVALID"); + assert!(decoded.payload_id.is_empty()); +} + +#[tokio::test] +async fn new_payload_v1_invalid_returns_status() { + let app = make_router().await; + let p = ExecutionPayloadV1 { + parent_hash: [0u8; 32], + fee_recipient: [0u8; 20], + state_root: [0u8; 32], + receipts_root: [0u8; 32], + logs_bloom: [0u8; 256], + prev_randao: [0u8; 32], + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 1, + extra_data: Vec::new().try_into().unwrap(), + base_fee_per_gas: [0u8; 32], + block_hash: [0u8; 32], + transactions: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + }; + let req = NewPayloadV1Request { + execution_payload: p, + }; + let resp = app + .oneshot(auth_post("/engine/v1/payloads", ssz_body(&req))) + .await + .unwrap(); + // All-zero payload at timestamp=1: validators pass, then validate_block_hash + // rejects (computed hash != payload.block_hash=0), which is reported as a + // 200 OK with status=INVALID per the engine spec. + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let s = PayloadStatusV1::from_ssz_bytes(&body).unwrap(); + assert_eq!(s.status, 1, "expected INVALID, got {}", s.status); +} + +#[tokio::test] +async fn malformed_ssz_body_returns_bad_request() { + let app = make_router().await; + let resp = app + .oneshot(auth_post("/engine/v1/payloads", vec![0u8; 4])) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn get_payload_v1_unknown_returns_404() { + // Empty store has no built payload — classify_rpc_err must map + // UnknownPayload to 404, not 500. + let app = make_router().await; + let resp = app + .oneshot(auth_get("/engine/v1/payloads/0x0000000000000000")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn get_payload_v1_invalid_hex_returns_400() { + let app = make_router().await; + let resp = app + .oneshot(auth_get("/engine/v1/payloads/notHex")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn get_payload_v1_wrong_length_returns_400() { + let app = make_router().await; + let resp = app + .oneshot(auth_get("/engine/v1/payloads/0xdeadbeef")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +fn empty_payload_v2(timestamp: u64) -> ExecutionPayloadV2 { + ExecutionPayloadV2 { + parent_hash: [0u8; 32], + fee_recipient: [0u8; 20], + state_root: [0u8; 32], + receipts_root: [0u8; 32], + logs_bloom: [0u8; 256], + prev_randao: [0u8; 32], + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp, + extra_data: Vec::new().try_into().unwrap(), + base_fee_per_gas: [0u8; 32], + block_hash: [0u8; 32], + transactions: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + withdrawals: Vec::::new().try_into().unwrap(), + } +} + +#[tokio::test] +async fn new_payload_v2_pre_shanghai_with_withdrawals_returns_422() { + // Shanghai not activated; sending any withdrawals must be rejected with 422. + let cc = ChainConfig { + chain_id: 1, + shanghai_time: None, + deposit_contract_address: Address::zero(), + ..Default::default() + }; + let app = make_router_with_chain_config(cc).await; + let mut payload = empty_payload_v2(1); + payload.withdrawals = vec![WithdrawalV1 { + index: 0, + validator_index: 0, + address: [0u8; 20], + amount: 0, + }] + .try_into() + .unwrap(); + let req = NewPayloadV2Request { + execution_payload: payload, + }; + let resp = app + .oneshot(auth_post("/engine/v2/payloads", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn new_payload_v2_pre_shanghai_empty_withdrawals_accepted() { + // Empty withdrawals pre-Shanghai must be silently stripped, not rejected. + let cc = ChainConfig { + chain_id: 1, + shanghai_time: None, + deposit_contract_address: Address::zero(), + ..Default::default() + }; + let app = make_router_with_chain_config(cc).await; + let req = NewPayloadV2Request { + execution_payload: empty_payload_v2(1), + }; + let resp = app + .oneshot(auth_post("/engine/v2/payloads", ssz_body(&req))) + .await + .unwrap(); + // We accept any status except 422 — the pre-Shanghai-with-withdrawals + // rejection should NOT fire when the withdrawals list is empty. + assert_ne!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} diff --git a/test/tests/rpc/mod.rs b/test/tests/rpc/mod.rs index a075dfceb4f..93b760485ec 100644 --- a/test/tests/rpc/mod.rs +++ b/test/tests/rpc/mod.rs @@ -1,2 +1,3 @@ mod client_version_tests; +mod engine_rest_tests; mod subscription_manager_tests; diff --git a/tooling/Cargo.lock b/tooling/Cargo.lock index db2503f429d..fd21405985a 100644 --- a/tooling/Cargo.lock +++ b/tooling/Cargo.lock @@ -764,6 +764,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "ansi_term" version = "0.12.1" @@ -1816,6 +1822,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -1919,6 +1931,33 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2214,6 +2253,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap 4.6.0", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -3042,6 +3117,23 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "engine_rest_bench" +version = "4.0.0" +dependencies = [ + "bytes", + "criterion", + "ethrex-common 12.0.0", + "ethrex-rpc", + "ethrex-storage 12.0.0", + "futures", + "libssz", + "libssz-types", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "enum-map" version = "2.7.3" @@ -3714,9 +3806,13 @@ dependencies = [ "ethrex-storage 12.0.0", "ethrex-trie 12.0.0", "ethrex-vm", + "futures", "hex", "hex-literal", "jsonwebtoken", + "libssz", + "libssz-derive", + "libssz-types", "rand 0.8.5", "reqwest 0.12.28", "secp256k1 0.30.0", @@ -4400,6 +4496,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "halfbrown" version = "0.3.0" @@ -5463,6 +5570,43 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "libssz" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "smallvec", +] + +[[package]] +name = "libssz-derive" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "libssz-merkle" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "libssz", + "sha2 0.10.9", +] + +[[package]] +name = "libssz-types" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "libssz", + "libssz-merkle", + "smallvec", +] + [[package]] name = "libtest-mimic" version = "0.8.2" @@ -6150,6 +6294,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -6936,6 +7086,34 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "polyval" version = "0.6.2" @@ -9890,6 +10068,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" diff --git a/tooling/Cargo.toml b/tooling/Cargo.toml index 0f8a310280e..159087ffa03 100644 --- a/tooling/Cargo.toml +++ b/tooling/Cargo.toml @@ -4,6 +4,7 @@ members = [ "ef_tests/blockchain", "ef_tests/state", "ef_tests/state_v2", + "engine_rest_bench", "hive_report", "load_test", "loc", diff --git a/tooling/engine_rest_bench/Cargo.toml b/tooling/engine_rest_bench/Cargo.toml new file mode 100644 index 00000000000..26fe464aff8 --- /dev/null +++ b/tooling/engine_rest_bench/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "engine_rest_bench" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +publish = false + +[lib] +path = "src/lib.rs" + +[dev-dependencies] +ethrex-common.workspace = true +ethrex-rpc.workspace = true +ethrex-storage.workspace = true +bytes.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +futures.workspace = true +libssz = { git = "https://github.com/lambdaclass/libssz", rev = "7262a4f" } +libssz-types = { git = "https://github.com/lambdaclass/libssz", rev = "7262a4f" } +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "engine_transport" +harness = false diff --git a/tooling/engine_rest_bench/benches/engine_transport.rs b/tooling/engine_rest_bench/benches/engine_transport.rs new file mode 100644 index 00000000000..ae5b5bee349 --- /dev/null +++ b/tooling/engine_rest_bench/benches/engine_transport.rs @@ -0,0 +1,603 @@ +//! Microbenchmarks comparing SSZ-REST (PR #764) wire codec to JSON-RPC for +//! the hottest Engine API messages. Run with: +//! +//! cargo bench -p ethrex-rpc --bench engine_transport + +#![allow(clippy::unwrap_used)] + +use bytes::Bytes; +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use ethrex_common::{Address, Bloom, H256}; +use ethrex_rpc::engine_rest::types::common::{ + MAX_BYTES_PER_TRANSACTION, MAX_TRANSACTIONS_PER_PAYLOAD, MAX_WITHDRAWALS_PER_PAYLOAD, + u64_to_uint256_le, +}; +use ethrex_rpc::engine_rest::types::execution_payload::ExecutionPayloadV4; +use ethrex_rpc::engine_rest::types::new_payload::{ + BlobVersionedHashes, ExecutionRequests, NewPayloadV5Request, +}; +use ethrex_rpc::engine_rest::types::withdrawal::WithdrawalV1; +use libssz::{SszDecode, SszEncode}; +use libssz_types::SszList; + +// ── Builders ────────────────────────────────────────────────────────────────── + +/// Build a realistic NewPayloadV5Request (Amsterdam) with `n_txs` random-sized +/// transactions, full withdrawals slate, and `n_blobs` blob hashes. +fn build_new_payload_v5(n_txs: usize, n_blobs: usize) -> NewPayloadV5Request { + let withdrawals: Vec = (0..MAX_WITHDRAWALS_PER_PAYLOAD) + .map(|i| WithdrawalV1 { + index: i as u64, + validator_index: 1_000_000 + i as u64, + address: [0x11; 20], + amount: 32_000_000_000, + }) + .collect(); + + // Average post-Cancun L1 tx is ~120 bytes; use 256 to overshoot a bit. + let tx_body = vec![0xab; 256]; + let txs: Vec> = (0..n_txs) + .map(|_| { + tx_body + .clone() + .try_into() + .expect("tx body fits MAX_BYTES_PER_TRANSACTION") + }) + .collect(); + let txs_ssz: SszList, MAX_TRANSACTIONS_PER_PAYLOAD> = + txs.try_into() + .expect("txs fit MAX_TRANSACTIONS_PER_PAYLOAD"); + + let exec_payload = ExecutionPayloadV4 { + parent_hash: [0xaa; 32], + fee_recipient: [0x42; 20], + state_root: [0xbb; 32], + receipts_root: [0xcc; 32], + logs_bloom: [0xdd; 256], + prev_randao: [0xee; 32], + block_number: 21_000_000, + gas_limit: 30_000_000, + gas_used: 28_500_000, + timestamp: 1_730_000_000, + extra_data: vec![0x65, 0x74, 0x68, 0x72, 0x65, 0x78] + .try_into() + .expect("extra_data ≤ 32"), + base_fee_per_gas: u64_to_uint256_le(15_000_000_000), + block_hash: [0xff; 32], + transactions: txs_ssz, + withdrawals: withdrawals.try_into().expect("withdrawals fit"), + blob_gas_used: 786_432, + excess_blob_gas: 196_608, + block_access_list: vec![0u8; 4096].try_into().expect("BAL ≤ MAX_BYTES"), + slot_number: 8_400_000, + }; + + let blob_hashes: Vec<[u8; 32]> = (0..n_blobs).map(|i| [i as u8; 32]).collect(); + let expected_blob_versioned_hashes: BlobVersionedHashes = blob_hashes + .try_into() + .expect("blob hashes ≤ MAX_BLOB_COMMITMENTS_PER_BLOCK"); + + let execution_requests: ExecutionRequests = + Vec::>::new() + .try_into() + .expect("empty execution_requests"); + + NewPayloadV5Request { + execution_payload: exec_payload, + expected_blob_versioned_hashes, + parent_beacon_block_root: [0x77; 32], + execution_requests, + } +} + +// ── JSON shadow type for fair comparison ────────────────────────────────────── +// +// We bench against the production `ethrex_rpc::types::payload::ExecutionPayload` +// via `from_block` would require building a Block. To keep the comparison +// scoped to wire codec cost, we use a serde_json round-trip on a struct that +// mirrors the SSZ container's fields with hex-encoded bytes (matching the +// JSON-RPC Engine API wire format). + +#[derive(serde::Serialize, serde::Deserialize)] +struct JsonExecutionPayloadV4 { + #[serde(rename = "parentHash")] + parent_hash: H256, + #[serde(rename = "feeRecipient")] + fee_recipient: Address, + #[serde(rename = "stateRoot")] + state_root: H256, + #[serde(rename = "receiptsRoot")] + receipts_root: H256, + #[serde(rename = "logsBloom")] + logs_bloom: Bloom, + #[serde(rename = "prevRandao")] + prev_randao: H256, + #[serde( + rename = "blockNumber", + with = "ethrex_common::serde_utils::u64::hex_str" + )] + block_number: u64, + #[serde(rename = "gasLimit", with = "ethrex_common::serde_utils::u64::hex_str")] + gas_limit: u64, + #[serde(rename = "gasUsed", with = "ethrex_common::serde_utils::u64::hex_str")] + gas_used: u64, + #[serde(with = "ethrex_common::serde_utils::u64::hex_str")] + timestamp: u64, + #[serde(rename = "extraData", with = "ethrex_common::serde_utils::bytes")] + extra_data: Bytes, + #[serde( + rename = "baseFeePerGas", + with = "ethrex_common::serde_utils::u64::hex_str" + )] + base_fee_per_gas: u64, + #[serde(rename = "blockHash")] + block_hash: H256, + transactions: Vec, + withdrawals: Vec, + #[serde( + rename = "blobGasUsed", + with = "ethrex_common::serde_utils::u64::hex_str" + )] + blob_gas_used: u64, + #[serde( + rename = "excessBlobGas", + with = "ethrex_common::serde_utils::u64::hex_str" + )] + excess_blob_gas: u64, + // V4 (Amsterdam) additions: + #[serde(rename = "blockAccessList", with = "ethrex_common::serde_utils::bytes")] + block_access_list: Bytes, + #[serde( + rename = "slotNumber", + with = "ethrex_common::serde_utils::u64::hex_str" + )] + slot_number: u64, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct JsonWithdrawal { + #[serde(with = "ethrex_common::serde_utils::u64::hex_str")] + index: u64, + #[serde( + rename = "validatorIndex", + with = "ethrex_common::serde_utils::u64::hex_str" + )] + validator_index: u64, + address: Address, + #[serde(with = "ethrex_common::serde_utils::u64::hex_str")] + amount: u64, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct JsonBytes(#[serde(with = "ethrex_common::serde_utils::bytes")] Bytes); + +/// JSON-RPC `engine_newPayloadV5` params are a 4-element array: +/// [executionPayloadV4, expectedBlobVersionedHashes, parentBeaconBlockRoot, executionRequests]. +/// Serializing a tuple yields exactly that JSON array, matching what an EL receives. +type JsonNewPayloadV5Request = (JsonExecutionPayloadV4, Vec, H256, Vec); + +fn build_json_request(req: &NewPayloadV5Request) -> JsonNewPayloadV5Request { + let p = &req.execution_payload; + let payload = JsonExecutionPayloadV4 { + parent_hash: H256(p.parent_hash), + fee_recipient: Address::from_slice(&p.fee_recipient), + state_root: H256(p.state_root), + receipts_root: H256(p.receipts_root), + logs_bloom: Bloom(p.logs_bloom), + prev_randao: H256(p.prev_randao), + block_number: p.block_number, + gas_limit: p.gas_limit, + gas_used: p.gas_used, + timestamp: p.timestamp, + extra_data: Bytes::copy_from_slice(&p.extra_data), + base_fee_per_gas: 15_000_000_000, + block_hash: H256(p.block_hash), + transactions: p + .transactions + .iter() + .map(|raw| JsonBytes(Bytes::copy_from_slice(raw))) + .collect(), + withdrawals: p + .withdrawals + .iter() + .map(|w| JsonWithdrawal { + index: w.index, + validator_index: w.validator_index, + address: Address::from_slice(&w.address), + amount: w.amount, + }) + .collect(), + blob_gas_used: p.blob_gas_used, + excess_blob_gas: p.excess_blob_gas, + block_access_list: Bytes::copy_from_slice(&p.block_access_list), + slot_number: p.slot_number, + }; + + let expected_blob_versioned_hashes: Vec = req + .expected_blob_versioned_hashes + .iter() + .map(|h| H256(*h)) + .collect(); + let parent_beacon_block_root = H256(req.parent_beacon_block_root); + let execution_requests: Vec = req + .execution_requests + .iter() + .map(|raw| JsonBytes(Bytes::copy_from_slice(raw))) + .collect(); + + ( + payload, + expected_blob_versioned_hashes, + parent_beacon_block_root, + execution_requests, + ) +} + +// ── Benches ─────────────────────────────────────────────────────────────────── + +fn bench_codec(c: &mut Criterion) { + // Realistic Amsterdam newPayload: ~150 txs, 6 blob hashes. + let req = build_new_payload_v5(150, 6); + let json = build_json_request(&req); + + let ssz_bytes = { + let mut buf = Vec::with_capacity(req.encoded_len()); + req.ssz_append(&mut buf); + buf + }; + let json_bytes = serde_json::to_vec(&json).unwrap(); + + eprintln!( + "wire size: SSZ = {} B, JSON = {} B (SSZ is {:.0}% of JSON)", + ssz_bytes.len(), + json_bytes.len(), + 100.0 * ssz_bytes.len() as f64 / json_bytes.len() as f64, + ); + + let mut group = c.benchmark_group("new_payload_v5_encode_150tx_6blob"); + group.throughput(Throughput::Bytes(ssz_bytes.len() as u64)); + group.bench_function("ssz_encode", |b| { + b.iter(|| { + let r = std::hint::black_box(&req); + let mut buf = Vec::with_capacity(r.encoded_len()); + r.ssz_append(&mut buf); + std::hint::black_box(buf); + }) + }); + group.throughput(Throughput::Bytes(json_bytes.len() as u64)); + group.bench_function("json_encode", |b| { + b.iter(|| { + let v = serde_json::to_vec(std::hint::black_box(&json)).unwrap(); + std::hint::black_box(v); + }) + }); + group.finish(); + + let mut group = c.benchmark_group("new_payload_v5_decode_150tx_6blob"); + group.throughput(Throughput::Bytes(ssz_bytes.len() as u64)); + group.bench_function("ssz_decode", |b| { + b.iter(|| { + let v = NewPayloadV5Request::from_ssz_bytes(std::hint::black_box(&ssz_bytes)).unwrap(); + std::hint::black_box(v); + }) + }); + group.throughput(Throughput::Bytes(json_bytes.len() as u64)); + group.bench_function("json_decode", |b| { + b.iter(|| { + let v: JsonNewPayloadV5Request = + serde_json::from_slice(std::hint::black_box(&json_bytes)).unwrap(); + std::hint::black_box(v); + }) + }); + group.finish(); +} + +// Blob-heavy variant: GetPayload V6 carries 6× 131KB blobs in real Amsterdam +// blocks. Bench just the blob list to isolate the hex-encoding overhead. +fn bench_blob_list(c: &mut Criterion) { + use ethrex_rpc::engine_rest::types::blobs::{BlobAndProofV2, GetBlobsV2Response}; + use ethrex_rpc::engine_rest::types::common::{ + BLOB_SIZE, CELLS_PER_EXT_BLOB, MAX_BLOB_HASHES_REQUEST, + }; + + let blob = [0xcd; BLOB_SIZE]; + let proofs: SszList<[u8; 48], CELLS_PER_EXT_BLOB> = + vec![[0xef; 48]; CELLS_PER_EXT_BLOB].try_into().unwrap(); + + let items: Vec = (0..6) + .map(|_| BlobAndProofV2 { + blob, + proofs: proofs.clone(), + }) + .collect(); + let resp = GetBlobsV2Response { + blobs_and_proofs: items.try_into().unwrap(), + }; + let _ = MAX_BLOB_HASHES_REQUEST; + + let ssz_bytes = { + let mut buf = Vec::with_capacity(resp.encoded_len()); + resp.ssz_append(&mut buf); + buf + }; + + // JSON shadow: hex-encoded blob bytes per spec. + #[derive(serde::Serialize, serde::Deserialize)] + struct JsonBlob(#[serde(with = "ethrex_common::serde_utils::bytes")] Bytes); + #[derive(serde::Serialize, serde::Deserialize)] + struct JsonBlobAndProofs { + blob: JsonBlob, + proofs: Vec, + } + let json_items: Vec = resp + .blobs_and_proofs + .iter() + .map(|i| JsonBlobAndProofs { + blob: JsonBlob(Bytes::copy_from_slice(&i.blob)), + proofs: i + .proofs + .iter() + .map(|p| JsonBlob(Bytes::copy_from_slice(p))) + .collect(), + }) + .collect(); + let json_bytes = serde_json::to_vec(&json_items).unwrap(); + + eprintln!( + "blob bundle wire size: SSZ = {:.2} MB, JSON = {:.2} MB (SSZ {:.0}%)", + ssz_bytes.len() as f64 / 1e6, + json_bytes.len() as f64 / 1e6, + 100.0 * ssz_bytes.len() as f64 / json_bytes.len() as f64, + ); + + let mut group = c.benchmark_group("blobs_v2_response_6_blobs"); + group.sample_size(20); + group.throughput(Throughput::Bytes(ssz_bytes.len() as u64)); + group.bench_function("ssz_encode", |b| { + b.iter(|| { + let r = std::hint::black_box(&resp); + let mut buf = Vec::with_capacity(r.encoded_len()); + r.ssz_append(&mut buf); + std::hint::black_box(buf); + }) + }); + group.throughput(Throughput::Bytes(json_bytes.len() as u64)); + group.bench_function("json_encode", |b| { + b.iter(|| { + let v = serde_json::to_vec(std::hint::black_box(&json_items)).unwrap(); + std::hint::black_box(v); + }) + }); + group.throughput(Throughput::Bytes(ssz_bytes.len() as u64)); + group.bench_function("ssz_decode", |b| { + b.iter(|| { + let v = GetBlobsV2Response::from_ssz_bytes(std::hint::black_box(&ssz_bytes)).unwrap(); + std::hint::black_box(v); + }) + }); + group.throughput(Throughput::Bytes(json_bytes.len() as u64)); + group.bench_function("json_decode", |b| { + b.iter(|| { + let v: Vec = + serde_json::from_slice(std::hint::black_box(&json_bytes)).unwrap(); + std::hint::black_box(v); + }) + }); + group.finish(); +} + +// `blobs_bundle_to_ssz_v2` by-value (move) vs the previous &-borrow that copied +// each blob byte-by-byte. Fresh clone per iter so the clone cost is excluded. +fn bench_blobs_bundle_conversion(c: &mut Criterion) { + use ethrex_common::types::{BYTES_PER_BLOB, BlobsBundle}; + use ethrex_rpc::engine_rest::conversions::blobs_bundle_to_ssz_v2; + use ethrex_rpc::engine_rest::types::common::CELLS_PER_EXT_BLOB; + + const N_BLOBS: usize = 6; // Cancun blob target per block. + let bundle = BlobsBundle { + blobs: vec![[0xab; BYTES_PER_BLOB]; N_BLOBS], + commitments: vec![[0xcd; 48]; N_BLOBS], + proofs: vec![[0xef; 48]; N_BLOBS * CELLS_PER_EXT_BLOB], + version: 1, + }; + + let mut group = c.benchmark_group("blobs_bundle_to_ssz_v2_6blob"); + group.sample_size(50); + group.throughput(Throughput::Bytes((N_BLOBS * BYTES_PER_BLOB) as u64)); + + group.bench_function("by_value_move", |b| { + b.iter_batched( + || bundle.clone(), + |bnd| std::hint::black_box(blobs_bundle_to_ssz_v2(bnd).unwrap()), + criterion::BatchSize::SmallInput, + ) + }); + + // Old &-borrow path: materialise fresh Vecs + per-blob copy_from_slice. + group.bench_function("by_ref_copy", |b| { + b.iter(|| { + let r = std::hint::black_box(&bundle); + let commitments: Vec<[u8; 48]> = r.commitments.to_vec(); + let proofs: Vec<[u8; 48]> = r.proofs.to_vec(); + let blobs: Vec<[u8; BYTES_PER_BLOB]> = r + .blobs + .iter() + .map(|b| { + let mut arr = [0u8; BYTES_PER_BLOB]; + arr.copy_from_slice(b.as_ref()); + arr + }) + .collect(); + std::hint::black_box((commitments, proofs, blobs)); + }) + }); + group.finish(); +} + +// Sequential vs `try_join_all` for `bodies_by_hash_v1`'s 32-key storage fan-out. +// Real win depends on whether the underlying KV layer parallelises read txns. +fn bench_body_lookups(c: &mut Criterion) { + use ethrex_common::H256; + use ethrex_common::types::{Block, BlockBody, BlockHeader}; + use ethrex_storage::{EngineType, Store}; + + let rt = tokio::runtime::Runtime::new().unwrap(); + let storage = Store::new("", EngineType::InMemory).unwrap(); + let hashes: Vec = rt.block_on(async { + let mut hashes = Vec::with_capacity(32); + for i in 0..32u64 { + let block = Block { + header: BlockHeader { + number: i, + timestamp: 1_700_000_000 + i, + ..Default::default() + }, + body: BlockBody::default(), + }; + hashes.push(block.hash()); + storage.add_block(block).await.unwrap(); + } + hashes + }); + + let mut group = c.benchmark_group("bodies_by_hash_v1_32_blocks"); + group.sample_size(20); + + group.bench_function("sequential", |b| { + b.iter(|| { + rt.block_on(async { + let mut out = Vec::with_capacity(hashes.len()); + for h in &hashes { + out.push(storage.get_block_body_by_hash(*h).await.unwrap()); + } + std::hint::black_box(out); + }) + }) + }); + + group.bench_function("join_all", |b| { + b.iter(|| { + rt.block_on(async { + let futs = hashes.iter().map(|h| storage.get_block_body_by_hash(*h)); + let out = futures::future::try_join_all(futs).await.unwrap(); + std::hint::black_box(out); + }) + }) + }); + group.finish(); +} + +// SSZ-tx → `Transaction` direct decode vs the previous `Bytes::copy_from_slice` +// + decode chain. Both arms produce `Vec` from the same input. +fn bench_ssz_tx_decoding(c: &mut Criterion) { + use ethrex_common::types::{EIP1559Transaction, Transaction}; + use ethrex_rpc::engine_rest::types::common::MAX_BYTES_PER_TRANSACTION; + use ethrex_rpc::engine_rest::types::execution_payload::Transactions; + + // A realistic-ish tx: EIP-1559 with ~512 bytes of calldata, encoding to + // ~530 bytes. Matches the rough average of post-Cancun L1 transactions. + let sample_tx = Transaction::EIP1559Transaction(EIP1559Transaction { + data: vec![0xab; 512].into(), + ..Default::default() + }); + let tx_bytes = sample_tx.encode_canonical_to_vec(); + let txs_vec: Vec> = (0..150) + .map(|_| tx_bytes.clone().try_into().unwrap()) + .collect(); + let transactions: Transactions = txs_vec.try_into().unwrap(); + + let mut group = c.benchmark_group("ssz_tx_decoding_150tx"); + group.sample_size(20); + + // New direct path — decode straight from each SSZ slice. + group.bench_function("direct", |b| { + b.iter(|| { + let txs: Vec = transactions + .iter() + .map(|raw| Transaction::decode_canonical(raw).unwrap()) + .collect(); + std::hint::black_box(txs); + }) + }); + + // Old path emulation — per-tx Bytes::copy_from_slice into an intermediate + // Vec before decoding. + group.bench_function("via_bytes_copy", |b| { + b.iter(|| { + let copied: Vec = transactions + .iter() + .map(|raw| Bytes::copy_from_slice(raw)) + .collect(); + let txs: Vec = copied + .iter() + .map(|b| Transaction::decode_canonical(b.as_ref()).unwrap()) + .collect(); + std::hint::black_box(txs); + }) + }); + group.finish(); +} + +// JSON-RPC newPayload `payload.clone() → into_block` vs the new +// `to_block(&self)` that skips the upfront clone. +fn bench_json_payload_to_block(c: &mut Criterion) { + use ethrex_common::H256; + use ethrex_common::types::{ + Block, BlockBody, BlockHeader, EIP1559Transaction, Transaction, Withdrawal, + }; + use ethrex_rpc::types::payload::ExecutionPayload; + + // `ExecutionPayload` has `pub(crate)` fields, so construct via from_block. + let sample_tx = Transaction::EIP1559Transaction(EIP1559Transaction { + data: vec![0xab; 512].into(), + ..Default::default() + }); + let body = BlockBody { + transactions: vec![sample_tx; 150], + ommers: Vec::new(), + withdrawals: Some(Vec::::new()), + }; + let header = BlockHeader { + number: 1, + gas_limit: 30_000_000, + timestamp: 1_700_000_000, + base_fee_per_gas: Some(0), + blob_gas_used: Some(0), + excess_blob_gas: Some(0), + ..Default::default() + }; + let block = Block::new(header, body); + let payload = ExecutionPayload::from_block(block, None); + + let mut group = c.benchmark_group("json_payload_to_block_150tx"); + group.sample_size(20); + + // New path — no upfront clone. + group.bench_function("to_block_ref", |b| { + b.iter(|| { + let block = payload.to_block(Some(H256::zero()), None, None).unwrap(); + std::hint::black_box(block); + }) + }); + + // Old path — payload.clone() then into_block(self). + group.bench_function("clone_then_into_block", |b| { + b.iter(|| { + let cloned = payload.clone(); + let block = cloned.to_block(Some(H256::zero()), None, None).unwrap(); + std::hint::black_box(block); + }) + }); + group.finish(); +} + +criterion_group!( + benches, + bench_codec, + bench_blob_list, + bench_blobs_bundle_conversion, + bench_body_lookups, + bench_ssz_tx_decoding, + bench_json_payload_to_block +); +criterion_main!(benches); diff --git a/tooling/engine_rest_bench/src/lib.rs b/tooling/engine_rest_bench/src/lib.rs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tooling/reorgs/src/simulator.rs b/tooling/reorgs/src/simulator.rs index 73145490c43..18cbf25eb1c 100644 --- a/tooling/reorgs/src/simulator.rs +++ b/tooling/reorgs/src/simulator.rs @@ -310,7 +310,7 @@ impl Node { .map(|bal| bal.compute_hash()); let block = payload_response .execution_payload - .into_block( + .to_block( parent_beacon_block_root, Some(requests_hash), block_access_list_hash, From f47450109e0255b34ca0c965f0f718caef938411 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Wed, 27 May 2026 13:03:13 -0300 Subject: [PATCH 02/11] fix(rpc): address engine REST review nits --- crates/networking/rpc/engine_rest/auth.rs | 4 ++-- crates/networking/rpc/engine_rest/conversions.rs | 6 +++--- .../networking/rpc/engine_rest/handlers/blobs.rs | 16 +++++++++------- .../rpc/engine_rest/handlers/bodies.rs | 8 +++++--- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/crates/networking/rpc/engine_rest/auth.rs b/crates/networking/rpc/engine_rest/auth.rs index 84ceaf75bb6..f876b793f27 100644 --- a/crates/networking/rpc/engine_rest/auth.rs +++ b/crates/networking/rpc/engine_rest/auth.rs @@ -24,8 +24,8 @@ pub async fn engine_auth_middleware( return error_response(StatusCode::UNAUTHORIZED, "missing bearer token"); }; - if let Err(e) = validate_jwt_authentication(token, &secret) { - return error_response(StatusCode::UNAUTHORIZED, &format!("auth failed: {e:?}")); + if validate_jwt_authentication(token, &secret).is_err() { + return error_response(StatusCode::UNAUTHORIZED, "authentication failed"); } next.run(request).await diff --git a/crates/networking/rpc/engine_rest/conversions.rs b/crates/networking/rpc/engine_rest/conversions.rs index cd6b73d355b..c832d5d4ae2 100644 --- a/crates/networking/rpc/engine_rest/conversions.rs +++ b/crates/networking/rpc/engine_rest/conversions.rs @@ -181,9 +181,9 @@ pub fn json_to_execution_payload_v4( } None => Vec::new(), }; - let bal_ssz = bal_bytes - .try_into() - .map_err(|_| ConversionError::internal("BAL RLP exceeds MAX_BYTES_PER_TRANSACTION"))?; + let bal_ssz = bal_bytes.try_into().map_err(|_| { + ConversionError::internal("block_access_list RLP exceeds MAX_BYTES_PER_TRANSACTION cap") + })?; Ok(ExecutionPayloadV4 { parent_hash: p.parent_hash.0, fee_recipient: p.fee_recipient.0, diff --git a/crates/networking/rpc/engine_rest/handlers/blobs.rs b/crates/networking/rpc/engine_rest/handlers/blobs.rs index 5f615595532..f348359f1c2 100644 --- a/crates/networking/rpc/engine_rest/handlers/blobs.rs +++ b/crates/networking/rpc/engine_rest/handlers/blobs.rs @@ -22,7 +22,7 @@ use crate::engine_rest::types::common::{ use crate::rpc::RpcApiContext; fn check_count(n: usize) -> Result<(), EngineRestError> { - if n >= MAX_BLOB_HASHES_REQUEST { + if n > MAX_BLOB_HASHES_REQUEST { return Err(EngineRestError::payload_too_large(format!( "request exceeds MAX_BLOB_HASHES_REQUEST ({MAX_BLOB_HASHES_REQUEST})" ))); @@ -84,12 +84,14 @@ async fn require_osaka_tip(ctx: &RpcApiContext, version: u8) -> Result<(), Engin let header = ctx .storage .get_block_header(latest) - .map_err(|e| EngineRestError::internal(format!("storage: {e}")))?; - if let Some(h) = header - && !ctx - .storage - .get_chain_config() - .is_osaka_activated(h.timestamp) + .map_err(|e| EngineRestError::internal(format!("storage: {e}")))? + .ok_or_else(|| { + EngineRestError::internal(format!("missing header for latest block {latest}")) + })?; + if !ctx + .storage + .get_chain_config() + .is_osaka_activated(header.timestamp) { return Err(EngineRestError::unprocessable(format!( "getBlobsV{version} engine only supported for Osaka" diff --git a/crates/networking/rpc/engine_rest/handlers/bodies.rs b/crates/networking/rpc/engine_rest/handlers/bodies.rs index af18b3f2ced..ec1b44f40f7 100644 --- a/crates/networking/rpc/engine_rest/handlers/bodies.rs +++ b/crates/networking/rpc/engine_rest/handlers/bodies.rs @@ -57,9 +57,11 @@ fn body_to_v2( Some(b) => { let mut buf = Vec::new(); b.encode(&mut buf); - let inner: SszList = buf - .try_into() - .map_err(|_| EngineRestError::internal("BAL exceeds MAX_BYTES_PER_TRANSACTION"))?; + let inner: SszList = buf.try_into().map_err(|_| { + EngineRestError::internal( + "block_access_list RLP exceeds MAX_BYTES_PER_TRANSACTION cap", + ) + })?; ssz_some(inner) } None => ssz_none(), From adc3fc9b4895c8889c8ef0f84282d057e8a1ec96 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Wed, 27 May 2026 13:11:23 -0300 Subject: [PATCH 03/11] feat(rpc): per-route body caps on engine_rest --- .../networking/rpc/engine_rest/extractors.rs | 16 +++- crates/networking/rpc/engine_rest/mod.rs | 91 ++++++++++++++----- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/crates/networking/rpc/engine_rest/extractors.rs b/crates/networking/rpc/engine_rest/extractors.rs index 86cef0b0ae9..a6f8942fe5a 100644 --- a/crates/networking/rpc/engine_rest/extractors.rs +++ b/crates/networking/rpc/engine_rest/extractors.rs @@ -2,7 +2,8 @@ use axum::body::Bytes; use axum::extract::FromRequest; -use axum::http::{self, HeaderMap, Request}; +use axum::http::{self, HeaderMap, Request, StatusCode}; +use axum::response::IntoResponse; use libssz::SszDecode; use crate::engine_rest::error::EngineRestError; @@ -45,9 +46,16 @@ where state: &S, ) -> Result { check_ssz_content_type(req.headers())?; - let bytes = Bytes::from_request(req, state) - .await - .map_err(|e| EngineRestError::bad_request(format!("failed to read body: {e}")))?; + let bytes = Bytes::from_request(req, state).await.map_err(|e| { + // Preserve `413 Payload Too Large` from `DefaultBodyLimit`; map + // everything else (encoding/IO errors) to `400 Bad Request`. + let status = e.into_response().status(); + if status == StatusCode::PAYLOAD_TOO_LARGE { + EngineRestError::payload_too_large("request body exceeds endpoint limit") + } else { + EngineRestError::bad_request("failed to read request body") + } + })?; let value = decode_ssz::(&bytes)?; Ok(Ssz(value)) } diff --git a/crates/networking/rpc/engine_rest/mod.rs b/crates/networking/rpc/engine_rest/mod.rs index b7ac9021bf8..98b2a2433b9 100644 --- a/crates/networking/rpc/engine_rest/mod.rs +++ b/crates/networking/rpc/engine_rest/mod.rs @@ -14,9 +14,10 @@ //! POST /engine/v1/client/version getClientVersion //! POST /engine/v1/capabilities exchangeCapabilities //! -//! Per-endpoint Content-Length caps from #764 §Security considerations are -//! not enforced; the authrpc router's global 256 MB `DefaultBodyLimit` covers -//! both transports. +//! Per-route body caps follow #764 §Security considerations: tight bounds for +//! small request types (forkchoice, blobs, bodies, capabilities, client/version) +//! and a generous cap for `newPayload`. Caps shadow the authrpc-wide 256 MB +//! `DefaultBodyLimit` for engine_rest routes only. pub mod auth; pub mod conversions; @@ -28,44 +29,80 @@ pub mod responses; pub mod types; use axum::Router; +use axum::extract::DefaultBodyLimit; use axum::routing::{get, post}; use crate::rpc::{ClientVersion, RpcApiContext}; +/// Per-route body size caps. Bounds derived from the SSZ `MAX_*` constants in +/// `types::common`, rounded up to convenient powers of two. `newPayload` must +/// accept full execution payloads (incl. all transactions, blob commitments, +/// BAL bytes); other endpoints carry only small fixed structures. +mod body_limits { + /// `newPayload` carries a full execution payload. Worst-case mainnet blocks + /// are well under 10 MB; 32 MB leaves headroom for future fork bloat without + /// inheriting the 256 MB authrpc-wide cap. + pub const NEW_PAYLOAD: usize = 32 * 1024 * 1024; + /// `forkchoiceUpdated` carries a ForkchoiceState (96 B) + optional + /// PayloadAttributes (bounded by `MAX_WITHDRAWALS_PER_PAYLOAD = 16`). + pub const FORKCHOICE: usize = 64 * 1024; + /// `getBlobs` carries up to `MAX_BLOB_HASHES_REQUEST (128)` Bytes32 hashes + /// (4 KB payload). + pub const BLOBS: usize = 16 * 1024; + /// `getPayloadBodiesByHash` carries up to `MAX_PAYLOAD_BODIES_REQUEST (32)` + /// Bytes32 hashes (1 KB payload). + pub const BODIES_BY_HASH: usize = 8 * 1024; + /// `getPayloadBodiesByRange` carries `start` + `count` (16 B). + pub const BODIES_BY_RANGE: usize = 1024; + /// `exchangeCapabilities` carries a list of method-name strings bounded by + /// `MAX_CAPABILITIES (64) * MAX_CAPABILITY_NAME_LENGTH (64) = 4 KB`. + pub const CAPABILITIES: usize = 16 * 1024; + /// `getClientVersion` carries one ClientVersionV1 (≤ ~150 B). + pub const CLIENT_VERSION: usize = 4 * 1024; +} + /// Build the engine REST sub-router. JWT auth middleware is applied uniformly. pub fn router(ctx: RpcApiContext) -> Router { let secret = ctx.node_data.jwt_secret.clone(); let client_version: ClientVersion = ctx.node_data.client_version.clone(); + let new_payload_limit = DefaultBodyLimit::max(body_limits::NEW_PAYLOAD); + let forkchoice_limit = DefaultBodyLimit::max(body_limits::FORKCHOICE); + let blobs_limit = DefaultBodyLimit::max(body_limits::BLOBS); + let bodies_by_hash_limit = DefaultBodyLimit::max(body_limits::BODIES_BY_HASH); + let bodies_by_range_limit = DefaultBodyLimit::max(body_limits::BODIES_BY_RANGE); + let client_router = Router::new() .route( "/engine/v1/client/version", - post(handlers::client_version::client_version), + post(handlers::client_version::client_version) + .layer(DefaultBodyLimit::max(body_limits::CLIENT_VERSION)), ) .with_state(client_version); let other_router: Router<()> = Router::new() - // payloads + // payloads (newPayload) — full execution payload, generous cap .route( "/engine/v1/payloads", - post(handlers::payloads::new_payload_v1), + post(handlers::payloads::new_payload_v1).layer(new_payload_limit), ) .route( "/engine/v2/payloads", - post(handlers::payloads::new_payload_v2), + post(handlers::payloads::new_payload_v2).layer(new_payload_limit), ) .route( "/engine/v3/payloads", - post(handlers::payloads::new_payload_v3), + post(handlers::payloads::new_payload_v3).layer(new_payload_limit), ) .route( "/engine/v4/payloads", - post(handlers::payloads::new_payload_v4), + post(handlers::payloads::new_payload_v4).layer(new_payload_limit), ) .route( "/engine/v5/payloads", - post(handlers::payloads::new_payload_v5), + post(handlers::payloads::new_payload_v5).layer(new_payload_limit), ) + // getPayload — GET, no request body .route( "/engine/v1/payloads/{payload_id}", get(handlers::payloads::get_payload_v1), @@ -93,45 +130,55 @@ pub fn router(ctx: RpcApiContext) -> Router { // bodies .route( "/engine/v1/payloads/bodies/by-hash", - post(handlers::bodies::bodies_by_hash_v1), + post(handlers::bodies::bodies_by_hash_v1).layer(bodies_by_hash_limit), ) .route( "/engine/v2/payloads/bodies/by-hash", - post(handlers::bodies::bodies_by_hash_v2), + post(handlers::bodies::bodies_by_hash_v2).layer(bodies_by_hash_limit), ) .route( "/engine/v1/payloads/bodies/by-range", - post(handlers::bodies::bodies_by_range_v1), + post(handlers::bodies::bodies_by_range_v1).layer(bodies_by_range_limit), ) .route( "/engine/v2/payloads/bodies/by-range", - post(handlers::bodies::bodies_by_range_v2), + post(handlers::bodies::bodies_by_range_v2).layer(bodies_by_range_limit), ) // forkchoice .route( "/engine/v1/forkchoice", - post(handlers::forkchoice::forkchoice_v1), + post(handlers::forkchoice::forkchoice_v1).layer(forkchoice_limit), ) .route( "/engine/v2/forkchoice", - post(handlers::forkchoice::forkchoice_v2), + post(handlers::forkchoice::forkchoice_v2).layer(forkchoice_limit), ) .route( "/engine/v3/forkchoice", - post(handlers::forkchoice::forkchoice_v3), + post(handlers::forkchoice::forkchoice_v3).layer(forkchoice_limit), ) .route( "/engine/v4/forkchoice", - post(handlers::forkchoice::forkchoice_v4), + post(handlers::forkchoice::forkchoice_v4).layer(forkchoice_limit), ) // blobs - .route("/engine/v1/blobs", post(handlers::blobs::blobs_v1)) - .route("/engine/v2/blobs", post(handlers::blobs::blobs_v2)) - .route("/engine/v3/blobs", post(handlers::blobs::blobs_v3)) + .route( + "/engine/v1/blobs", + post(handlers::blobs::blobs_v1).layer(blobs_limit), + ) + .route( + "/engine/v2/blobs", + post(handlers::blobs::blobs_v2).layer(blobs_limit), + ) + .route( + "/engine/v3/blobs", + post(handlers::blobs::blobs_v3).layer(blobs_limit), + ) // capabilities .route( "/engine/v1/capabilities", - post(handlers::capabilities::capabilities), + post(handlers::capabilities::capabilities) + .layer(DefaultBodyLimit::max(body_limits::CAPABILITIES)), ) .with_state(ctx); From f1df9151f2d993488eda96e90b8802687f238952 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Wed, 27 May 2026 13:11:27 -0300 Subject: [PATCH 04/11] test(rpc): engine_rest jwt/blob/body-cap/newPayload coverage --- test/tests/rpc/engine_rest_tests.rs | 264 +++++++++++++++++++++++++++- 1 file changed, 262 insertions(+), 2 deletions(-) diff --git a/test/tests/rpc/engine_rest_tests.rs b/test/tests/rpc/engine_rest_tests.rs index c17ed4e082d..7414b263fd9 100644 --- a/test/tests/rpc/engine_rest_tests.rs +++ b/test/tests/rpc/engine_rest_tests.rs @@ -33,9 +33,14 @@ use ethrex_rpc::engine_rest::types::common::{ Bytes32, ForkchoiceStateV1, ForkchoiceUpdatedResponseV1, MAX_CAPABILITY_NAME_LENGTH, PayloadStatusV1, }; -use ethrex_rpc::engine_rest::types::execution_payload::{ExecutionPayloadV1, ExecutionPayloadV2}; +use ethrex_rpc::engine_rest::types::execution_payload::{ + ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3, ExecutionPayloadV4, +}; use ethrex_rpc::engine_rest::types::forkchoice::ForkchoiceUpdatedV1Request; -use ethrex_rpc::engine_rest::types::new_payload::{NewPayloadV1Request, NewPayloadV2Request}; +use ethrex_rpc::engine_rest::types::new_payload::{ + NewPayloadV1Request, NewPayloadV2Request, NewPayloadV3Request, NewPayloadV4Request, + NewPayloadV5Request, +}; use ethrex_rpc::engine_rest::types::withdrawal::WithdrawalV1; use ethrex_rpc::rpc::{ClientVersion, NodeData, RpcApiContext}; use ethrex_rpc::test_utils::{ @@ -502,3 +507,258 @@ async fn new_payload_v2_pre_shanghai_empty_withdrawals_accepted() { // rejection should NOT fire when the withdrawals list is empty. assert_ne!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } + +// --- JWT iat boundary tests --- + +fn make_jwt_with_iat(iat: i64) -> String { + #[derive(Serialize)] + struct Claims { + iat: i64, + } + encode( + &Header::default(), + &Claims { iat }, + &EncodingKey::from_secret(TEST_SECRET), + ) + .unwrap() +} + +fn auth_post_with_jwt(path: &str, body: Vec, jwt: &str) -> Request { + Request::builder() + .method("POST") + .uri(path) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {jwt}")).unwrap(), + ) + .body(Body::from(body)) + .unwrap() +} + +#[tokio::test] +async fn jwt_expired_iat_rejected() { + // `validate_jwt_authentication` rejects JWTs whose `iat` is more than 60s off. + let app = make_router().await; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let jwt = make_jwt_with_iat(now - 120); + let req = ExchangeCapabilitiesRequest { + capabilities: Vec::>::new() + .try_into() + .unwrap(), + }; + let resp = app + .oneshot(auth_post_with_jwt( + "/engine/v1/capabilities", + ssz_body(&req), + &jwt, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn jwt_future_iat_rejected() { + let app = make_router().await; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let jwt = make_jwt_with_iat(now + 120); + let req = ExchangeCapabilitiesRequest { + capabilities: Vec::>::new() + .try_into() + .unwrap(), + }; + let resp = app + .oneshot(auth_post_with_jwt( + "/engine/v1/capabilities", + ssz_body(&req), + &jwt, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// --- Blob hash batch boundary (catches the >= vs > off-by-one) --- + +#[tokio::test] +async fn blobs_v1_at_max_hashes_accepted() { + // Exactly MAX_BLOB_HASHES_REQUEST (128) entries must be accepted: the cap + // is inclusive. A `>=` check (the pre-fix behavior) would reject this. + use ethrex_rpc::engine_rest::types::common::MAX_BLOB_HASHES_REQUEST; + let app = make_router().await; + let hashes: Vec = (0..MAX_BLOB_HASHES_REQUEST as u8) + .map(|i| [i; 32]) + .collect(); + let req = GetBlobsV1Request { + blob_versioned_hashes: hashes.try_into().unwrap(), + }; + let resp = app + .oneshot(auth_post("/engine/v1/blobs", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +// --- Per-route body cap --- + +#[tokio::test] +async fn bodies_by_range_body_too_large_returns_413() { + // The bodies/by-range endpoint has a 1 KB body cap. A 4 KB payload of + // valid Content-Type must be rejected with 413 before any SSZ decoding. + let app = make_router().await; + let req = Request::builder() + .method("POST") + .uri("/engine/v1/payloads/bodies/by-range") + .header(header::CONTENT_TYPE, "application/octet-stream") + .header( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", make_jwt())).unwrap(), + ) + .body(Body::from(vec![0u8; 4 * 1024])) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + +// --- newPayload V3/V4/V5 end-to-end fork gating --- + +fn empty_payload_v3(timestamp: u64) -> ExecutionPayloadV3 { + ExecutionPayloadV3 { + parent_hash: [0u8; 32], + fee_recipient: [0u8; 20], + state_root: [0u8; 32], + receipts_root: [0u8; 32], + logs_bloom: [0u8; 256], + prev_randao: [0u8; 32], + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp, + extra_data: Vec::new().try_into().unwrap(), + base_fee_per_gas: [0u8; 32], + block_hash: [0u8; 32], + transactions: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + withdrawals: Vec::::new().try_into().unwrap(), + blob_gas_used: 0, + excess_blob_gas: 0, + } +} + +fn empty_payload_v4(timestamp: u64) -> ExecutionPayloadV4 { + ExecutionPayloadV4 { + parent_hash: [0u8; 32], + fee_recipient: [0u8; 20], + state_root: [0u8; 32], + receipts_root: [0u8; 32], + logs_bloom: [0u8; 256], + prev_randao: [0u8; 32], + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp, + extra_data: Vec::new().try_into().unwrap(), + base_fee_per_gas: [0u8; 32], + block_hash: [0u8; 32], + transactions: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + withdrawals: Vec::::new().try_into().unwrap(), + blob_gas_used: 0, + excess_blob_gas: 0, + block_access_list: Vec::::new().try_into().unwrap(), + slot_number: 0, + } +} + +#[tokio::test] +async fn new_payload_v3_pre_cancun_returns_422() { + let cc = ChainConfig { + chain_id: 1, + shanghai_time: Some(0), + cancun_time: None, + deposit_contract_address: Address::zero(), + ..Default::default() + }; + let app = make_router_with_chain_config(cc).await; + let req = NewPayloadV3Request { + execution_payload: empty_payload_v3(1), + expected_blob_versioned_hashes: Vec::::new().try_into().unwrap(), + parent_beacon_block_root: [0u8; 32], + }; + let resp = app + .oneshot(auth_post("/engine/v3/payloads", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn new_payload_v4_pre_prague_returns_422() { + let cc = ChainConfig { + chain_id: 1, + shanghai_time: Some(0), + cancun_time: Some(0), + prague_time: None, + deposit_contract_address: Address::zero(), + ..Default::default() + }; + let app = make_router_with_chain_config(cc).await; + let req = NewPayloadV4Request { + execution_payload: empty_payload_v3(1), + expected_blob_versioned_hashes: Vec::::new().try_into().unwrap(), + parent_beacon_block_root: [0u8; 32], + execution_requests: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + }; + let resp = app + .oneshot(auth_post("/engine/v4/payloads", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn new_payload_v5_pre_amsterdam_returns_422() { + let cc = ChainConfig { + chain_id: 1, + shanghai_time: Some(0), + cancun_time: Some(0), + prague_time: Some(0), + osaka_time: Some(0), + amsterdam_time: None, + deposit_contract_address: Address::zero(), + ..Default::default() + }; + let app = make_router_with_chain_config(cc).await; + let req = NewPayloadV5Request { + execution_payload: empty_payload_v4(1), + expected_blob_versioned_hashes: Vec::::new().try_into().unwrap(), + parent_beacon_block_root: [0u8; 32], + execution_requests: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + }; + let resp = app + .oneshot(auth_post("/engine/v5/payloads", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} From 06c9ff3116740ced00a7710359bc1a9f7e99e429 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Wed, 27 May 2026 14:24:39 -0300 Subject: [PATCH 05/11] fix(rpc): reject Amsterdam blocks on engine_rest getPayloadV5 V5 SSZ response is ExecutionPayloadV3 per execution-apis #764 and has no block_access_list field; the prior code only checked the lower (Osaka) bound, so an Amsterdam block would have its BAL silently dropped. Mirror getPayloadV4's two-sided fork check. --- .../networking/rpc/engine_rest/handlers/payloads.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/networking/rpc/engine_rest/handlers/payloads.rs b/crates/networking/rpc/engine_rest/handlers/payloads.rs index 90708b0a45d..17cab784b96 100644 --- a/crates/networking/rpc/engine_rest/handlers/payloads.rs +++ b/crates/networking/rpc/engine_rest/handlers/payloads.rs @@ -348,7 +348,13 @@ pub async fn get_payload_v5( Err(e) => return e.into(), }; let chain_config = ctx.storage.get_chain_config(); - if !chain_config.is_osaka_activated(bundle.block.header.timestamp) { + // V5 is Osaka-only (execution-apis #764): the response carries + // `ExecutionPayloadV3`, which has no `block_access_list` / `slot_number`. + // Amsterdam blocks must be retrieved via getPayloadV6; serving them here + // would silently drop the BAL and prevent block-hash reconstruction. + if !chain_config.is_osaka_activated(bundle.block.header.timestamp) + || chain_config.is_amsterdam_activated(bundle.block.header.timestamp) + { return EngineError::unprocessable(&format!( "{:?}", chain_config.get_fork(bundle.block.header.timestamp) @@ -363,7 +369,8 @@ pub async fn get_payload_v5( Ok(r) => r, Err(e) => return e.into(), }; - let json = JsonExecutionPayload::from_block(bundle.block, bundle.block_access_list); + // BAL intentionally not propagated: V5 = Osaka, pre-BAL fork. + let json = JsonExecutionPayload::from_block(bundle.block, None); let payload = match json_to_execution_payload_v3(&json) { Ok(p) => p, Err(e) => return e.into(), From 56f87082f1f13d3a8d42f3ccc82abfe78838ad82 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Wed, 27 May 2026 15:15:35 -0300 Subject: [PATCH 06/11] fix(rpc): enforce engine V4/V5 fork upper bounds --- crates/networking/rpc/engine/payload.rs | 18 ++++++---- .../rpc/engine_rest/handlers/payloads.rs | 3 ++ test/tests/rpc/engine_rest_tests.rs | 33 +++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/networking/rpc/engine/payload.rs b/crates/networking/rpc/engine/payload.rs index af9573f7165..fee75b5f19e 100644 --- a/crates/networking/rpc/engine/payload.rs +++ b/crates/networking/rpc/engine/payload.rs @@ -223,6 +223,9 @@ impl RpcHandler for NewPayloadV4Request { chain_config.get_fork(block.header.timestamp) ))); } + if chain_config.is_osaka_activated(block.header.timestamp) { + return Err(RpcErr::UnsupportedFork(format!("{:?}", Fork::Osaka))); + } // We use v3 since the execution payload remains the same. validate_execution_payload_v3(&self.payload)?; let payload_status = handle_new_payload_v3( @@ -512,19 +515,22 @@ impl RpcHandler for GetPayloadV5Request { let payload_bundle = get_payload(self.payload_id, &context).await?; let chain_config = &context.storage.get_chain_config(); - if !chain_config.is_osaka_activated(payload_bundle.block.header.timestamp) { + // V5 is Osaka-only per execution-apis Osaka spec: the response shape is + // `ExecutionPayloadV3`, which has no `blockAccessList`. Amsterdam blocks + // must be retrieved via `engine_getPayloadV6`; serving them here would + // either drop the BAL (breaking block-hash reconstruction) or leak a + // non-spec field into the V5 response. + if !chain_config.is_osaka_activated(payload_bundle.block.header.timestamp) + || chain_config.is_amsterdam_activated(payload_bundle.block.header.timestamp) + { return Err(RpcErr::UnsupportedFork(format!( "{:?}", chain_config.get_fork(payload_bundle.block.header.timestamp) ))); } - // V5 supports BAL (Amsterdam fork, EIP-7928) let response = ExecutionPayloadResponse { - execution_payload: ExecutionPayload::from_block( - payload_bundle.block, - payload_bundle.block_access_list, - ), + execution_payload: ExecutionPayload::from_block(payload_bundle.block, None), block_value: payload_bundle.block_value, blobs_bundle: Some(payload_bundle.blobs_bundle), should_override_builder: Some(false), diff --git a/crates/networking/rpc/engine_rest/handlers/payloads.rs b/crates/networking/rpc/engine_rest/handlers/payloads.rs index 17cab784b96..0d9a606efe0 100644 --- a/crates/networking/rpc/engine_rest/handlers/payloads.rs +++ b/crates/networking/rpc/engine_rest/handlers/payloads.rs @@ -136,6 +136,9 @@ pub async fn new_payload_v4( chain_config.get_fork(block.header.timestamp) )); } + if chain_config.is_osaka_activated(block.header.timestamp) { + return EngineError::unprocessable(&format!("{:?}", Fork::Osaka)); + } let expected = ssz_blob_hashes_to_vec(&req.expected_blob_versioned_hashes); let status = match handle_new_payload_v3(expected_hash, ctx, block, expected, None).await { Ok(s) => s, diff --git a/test/tests/rpc/engine_rest_tests.rs b/test/tests/rpc/engine_rest_tests.rs index 7414b263fd9..a47f74e210a 100644 --- a/test/tests/rpc/engine_rest_tests.rs +++ b/test/tests/rpc/engine_rest_tests.rs @@ -733,6 +733,39 @@ async fn new_payload_v4_pre_prague_returns_422() { assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } +#[tokio::test] +async fn new_payload_v4_post_prague_returns_422() { + // Per execution-apis Prague spec, V4 MUST return Unsupported fork if the + // payload timestamp does not fall within the time frame of Prague. An + // Osaka-activated block sent to V4 must be rejected with 422, not pass + // through to block hash validation. + let cc = ChainConfig { + chain_id: 1, + shanghai_time: Some(0), + cancun_time: Some(0), + prague_time: Some(0), + osaka_time: Some(0), + deposit_contract_address: Address::zero(), + ..Default::default() + }; + let app = make_router_with_chain_config(cc).await; + let req = NewPayloadV4Request { + execution_payload: empty_payload_v3(1), + expected_blob_versioned_hashes: Vec::::new().try_into().unwrap(), + parent_beacon_block_root: [0u8; 32], + execution_requests: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + }; + let resp = app + .oneshot(auth_post("/engine/v4/payloads", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + #[tokio::test] async fn new_payload_v5_pre_amsterdam_returns_422() { let cc = ChainConfig { From 3aacb85f0921cfc066d0ce48cc81ef058e765ba4 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Wed, 27 May 2026 15:51:49 -0300 Subject: [PATCH 07/11] docs(rpc): engine_rest self-review nits --- crates/networking/rpc/engine/payload.rs | 8 +++----- crates/networking/rpc/engine_rest/handlers/blobs.rs | 8 +++++--- crates/networking/rpc/engine_rest/mod.rs | 4 ++++ crates/networking/rpc/engine_rest/types/common.rs | 5 ----- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/networking/rpc/engine/payload.rs b/crates/networking/rpc/engine/payload.rs index fee75b5f19e..b13e91edaa9 100644 --- a/crates/networking/rpc/engine/payload.rs +++ b/crates/networking/rpc/engine/payload.rs @@ -19,11 +19,9 @@ use crate::types::payload::{ use crate::utils::RpcErr; use crate::utils::{RpcRequest, parse_json_hex}; -// Spec requires supporting request sizes of at least 32 blocks; JSON-RPC is -// permissive and accepts up to 4× that. The SSZ REST surface enforces the -// stricter 32 cap because `MAX_PAYLOAD_BODIES_REQUEST` is also the SSZ -// `List` length bound (`engine_rest::types::common`) — wire data with more -// entries will not decode. +// Spec floor is 32. JSON-RPC accepts <128 (the check is `>=`, so the +// effective max is 127); SSZ REST is capped at 32 because that value is +// also the SSZ `List` length bound and over-cap wire data won't decode. // -> https://github.com/ethereum/execution-apis/blob/main/src/engine/shanghai.md#specification-3 const GET_PAYLOAD_BODIES_REQUEST_MAX_SIZE: u64 = 128; diff --git a/crates/networking/rpc/engine_rest/handlers/blobs.rs b/crates/networking/rpc/engine_rest/handlers/blobs.rs index f348359f1c2..eef951c89df 100644 --- a/crates/networking/rpc/engine_rest/handlers/blobs.rs +++ b/crates/networking/rpc/engine_rest/handlers/blobs.rs @@ -1,8 +1,10 @@ //! POST /engine/v{1,2,3}/blobs. //! -//! V1 returns `BlobAndProofV1` (single proof, no nullability). V2 returns -//! `BlobAndProofV2` (cell proofs); all-or-nothing — empty list when any blob -//! is missing. V3 returns per-element nullable `BlobAndProofV2`. +//! V1: flat non-nullable list (missing blobs are dropped, response is +//! positionally compacted — JSON-RPC V1 keeps positional `Option`s; use V3 +//! over either transport for positional info). +//! V2: all-or-nothing — empty list when any blob or its proofs are missing. +//! V3: per-element nullable `BlobAndProofV2`. use axum::extract::State; use axum::response::{IntoResponse, Response}; diff --git a/crates/networking/rpc/engine_rest/mod.rs b/crates/networking/rpc/engine_rest/mod.rs index 98b2a2433b9..4ab98b34dd8 100644 --- a/crates/networking/rpc/engine_rest/mod.rs +++ b/crates/networking/rpc/engine_rest/mod.rs @@ -14,6 +14,10 @@ //! POST /engine/v1/client/version getClientVersion //! POST /engine/v1/capabilities exchangeCapabilities //! +//! V5/V6 fork mapping (per #764): newPayloadV5 = Amsterdam (ExecutionPayloadV4), +//! getPayloadV5 = Osaka-only (ExecutionPayloadV3, no BAL), getPayloadV6 = +//! Amsterdam (ExecutionPayloadV4). +//! //! Per-route body caps follow #764 §Security considerations: tight bounds for //! small request types (forkchoice, blobs, bodies, capabilities, client/version) //! and a generous cap for `newPayload`. Caps shadow the authrpc-wide 256 MB diff --git a/crates/networking/rpc/engine_rest/types/common.rs b/crates/networking/rpc/engine_rest/types/common.rs index fa41b9e70de..5966dbcf6dd 100644 --- a/crates/networking/rpc/engine_rest/types/common.rs +++ b/crates/networking/rpc/engine_rest/types/common.rs @@ -163,8 +163,3 @@ where .try_into() .expect("empty list fits in SszOption") } - -/// Read an SSZ-encoded `Option` back to `Option`. -pub fn ssz_into_option(list: &SszList) -> Option { - list.first().cloned() -} From 23707da8164b8b2d3f9146ff447e35c4f870880b Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Thu, 28 May 2026 09:01:30 -0300 Subject: [PATCH 08/11] fix(ci): fmt + lockfiles --- crates/l2/tee/quote-gen/Cargo.lock | 41 ++++++++++++++++++++++++++++ tooling/Cargo.lock | 4 +-- tooling/engine_rest_bench/src/lib.rs | 1 + 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/l2/tee/quote-gen/Cargo.lock b/crates/l2/tee/quote-gen/Cargo.lock index d4f836f16d7..69f21353b0e 100644 --- a/crates/l2/tee/quote-gen/Cargo.lock +++ b/crates/l2/tee/quote-gen/Cargo.lock @@ -1241,9 +1241,13 @@ dependencies = [ "ethrex-storage", "ethrex-trie", "ethrex-vm", + "futures", "hex", "hex-literal", "jsonwebtoken", + "libssz", + "libssz-derive", + "libssz-types", "rand 0.8.5", "reqwest", "secp256k1", @@ -2216,6 +2220,43 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libssz" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "smallvec", +] + +[[package]] +name = "libssz-derive" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "proc-macro2", + "quote 1.0.44", + "syn 2.0.116", +] + +[[package]] +name = "libssz-merkle" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "libssz", + "sha2", +] + +[[package]] +name = "libssz-types" +version = "0.2.1" +source = "git+https://github.com/lambdaclass/libssz?rev=7262a4f#7262a4f17f71fb9108166ba260659bcdd64feb4c" +dependencies = [ + "libssz", + "libssz-merkle", + "smallvec", +] + [[package]] name = "libz-sys" version = "1.1.23" diff --git a/tooling/Cargo.lock b/tooling/Cargo.lock index 82a82103e23..2599a068107 100644 --- a/tooling/Cargo.lock +++ b/tooling/Cargo.lock @@ -3123,9 +3123,9 @@ version = "4.0.0" dependencies = [ "bytes", "criterion", - "ethrex-common 12.0.0", + "ethrex-common 13.0.0", "ethrex-rpc", - "ethrex-storage 12.0.0", + "ethrex-storage 13.0.0", "futures", "libssz", "libssz-types", diff --git a/tooling/engine_rest_bench/src/lib.rs b/tooling/engine_rest_bench/src/lib.rs index e69de29bb2d..8b137891791 100644 --- a/tooling/engine_rest_bench/src/lib.rs +++ b/tooling/engine_rest_bench/src/lib.rs @@ -0,0 +1 @@ + From 56499614a518b0b7bb2ffb456633b6f1ef447d88 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Thu, 28 May 2026 09:47:35 -0300 Subject: [PATCH 09/11] revert(rpc): V4 Osaka upper-bound check The check rejected Osaka payloads via V4 newPayload, which broke the reorg simulator (uses V5 getPayload + V4 newPayload on Osaka chains) and the EELS test_invalid_pre_fork_block_with_bal_hash_field fixture (expects WrongParam/-32602 from the EIP-7928 detector, not UnsupportedFork). The detector merged from main handles the V5-shape-misrouted-to-V4 case more precisely. Keep the V5 Amsterdam upper-bound (different bug). --- crates/networking/rpc/engine/payload.rs | 3 -- .../rpc/engine_rest/handlers/payloads.rs | 3 -- test/tests/rpc/engine_rest_tests.rs | 33 ------------------- 3 files changed, 39 deletions(-) diff --git a/crates/networking/rpc/engine/payload.rs b/crates/networking/rpc/engine/payload.rs index 599ecd30e00..134aabf6f9b 100644 --- a/crates/networking/rpc/engine/payload.rs +++ b/crates/networking/rpc/engine/payload.rs @@ -241,9 +241,6 @@ impl RpcHandler for NewPayloadV4Request { chain_config.get_fork(block.header.timestamp) ))); } - if chain_config.is_osaka_activated(block.header.timestamp) { - return Err(RpcErr::UnsupportedFork(format!("{:?}", Fork::Osaka))); - } // EIP-7928 fork-boundary detector: V4 doesn't carry block_access_list_hash // in its header schema. If the payload's block_hash matches what a V5-style diff --git a/crates/networking/rpc/engine_rest/handlers/payloads.rs b/crates/networking/rpc/engine_rest/handlers/payloads.rs index 0d9a606efe0..17cab784b96 100644 --- a/crates/networking/rpc/engine_rest/handlers/payloads.rs +++ b/crates/networking/rpc/engine_rest/handlers/payloads.rs @@ -136,9 +136,6 @@ pub async fn new_payload_v4( chain_config.get_fork(block.header.timestamp) )); } - if chain_config.is_osaka_activated(block.header.timestamp) { - return EngineError::unprocessable(&format!("{:?}", Fork::Osaka)); - } let expected = ssz_blob_hashes_to_vec(&req.expected_blob_versioned_hashes); let status = match handle_new_payload_v3(expected_hash, ctx, block, expected, None).await { Ok(s) => s, diff --git a/test/tests/rpc/engine_rest_tests.rs b/test/tests/rpc/engine_rest_tests.rs index a47f74e210a..7414b263fd9 100644 --- a/test/tests/rpc/engine_rest_tests.rs +++ b/test/tests/rpc/engine_rest_tests.rs @@ -733,39 +733,6 @@ async fn new_payload_v4_pre_prague_returns_422() { assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } -#[tokio::test] -async fn new_payload_v4_post_prague_returns_422() { - // Per execution-apis Prague spec, V4 MUST return Unsupported fork if the - // payload timestamp does not fall within the time frame of Prague. An - // Osaka-activated block sent to V4 must be rejected with 422, not pass - // through to block hash validation. - let cc = ChainConfig { - chain_id: 1, - shanghai_time: Some(0), - cancun_time: Some(0), - prague_time: Some(0), - osaka_time: Some(0), - deposit_contract_address: Address::zero(), - ..Default::default() - }; - let app = make_router_with_chain_config(cc).await; - let req = NewPayloadV4Request { - execution_payload: empty_payload_v3(1), - expected_blob_versioned_hashes: Vec::::new().try_into().unwrap(), - parent_beacon_block_root: [0u8; 32], - execution_requests: Vec::< - SszList, - >::new() - .try_into() - .unwrap(), - }; - let resp = app - .oneshot(auth_post("/engine/v4/payloads", ssz_body(&req))) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); -} - #[tokio::test] async fn new_payload_v5_pre_amsterdam_returns_422() { let cc = ChainConfig { From 23ad40b0137bc5636a809a4183b061f6004c6b37 Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Thu, 28 May 2026 09:49:54 -0300 Subject: [PATCH 10/11] fix(ci): add libssz outputHashes to quote-gen Nix derivation --- crates/l2/tee/quote-gen/service.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/l2/tee/quote-gen/service.nix b/crates/l2/tee/quote-gen/service.nix index 458ecd081be..1af081e5d23 100644 --- a/crates/l2/tee/quote-gen/service.nix +++ b/crates/l2/tee/quote-gen/service.nix @@ -39,6 +39,10 @@ let lockFile = ./Cargo.lock; outputHashes = { "bls12_381-0.8.0" = "sha256-tpKF3wxog7eH1oDbpjoFjYibvH6u2kiR/H2Ysazqeok="; + "libssz-0.2.1" = "sha256-5btt/O1qqPnj6z3FKkCqWEtxqDPZ3Du62xnV6Sevfwc="; + "libssz-derive-0.2.1" = "sha256-5btt/O1qqPnj6z3FKkCqWEtxqDPZ3Du62xnV6Sevfwc="; + "libssz-types-0.2.1" = "sha256-5btt/O1qqPnj6z3FKkCqWEtxqDPZ3Du62xnV6Sevfwc="; + "libssz-merkle-0.2.1" = "sha256-5btt/O1qqPnj6z3FKkCqWEtxqDPZ3Du62xnV6Sevfwc="; }; }; From cbbf86db61ee7f2572dd51cf88971c9f86108aed Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Thu, 28 May 2026 10:24:53 -0300 Subject: [PATCH 11/11] fix(rpc): engine_rest V4/V5 parity with JSON-RPC Two SSZ-vs-JSON-RPC divergences flagged in PR review: 1. new_payload_v5: reject empty block_access_list with 400 (mirrors JSON-RPC's WrongParam). Per EIP-7928, BAL is structurally mandatory in V5; absence is not a block-validity failure. 2. new_payload_v4/v5: port the EIP-7928 fork-boundary detector from the JSON-RPC handlers. Catches V5-shaped headers misrouted to V4 (and the inverse on V5) by rebuilding the alt header and comparing hashes. EELS fixtures test_invalid_pre/post_fork_block_with*bal_hash_field expect this distinction. --- .../rpc/engine_rest/handlers/payloads.rs | 41 +++++++++++++++-- test/tests/rpc/engine_rest_tests.rs | 46 ++++++++++++++++++- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/networking/rpc/engine_rest/handlers/payloads.rs b/crates/networking/rpc/engine_rest/handlers/payloads.rs index 17cab784b96..d0ebb938c65 100644 --- a/crates/networking/rpc/engine_rest/handlers/payloads.rs +++ b/crates/networking/rpc/engine_rest/handlers/payloads.rs @@ -136,6 +136,20 @@ pub async fn new_payload_v4( chain_config.get_fork(block.header.timestamp) )); } + // EIP-7928 fork-boundary detector: see engine/payload.rs (V4 variant). + // V4 has no block_access_list_hash; if the CL sent a V5-shaped header, + // the alt with the field zeroed will reproduce the payload's block_hash. + if block.hash() != expected_hash { + let mut alt_header = block.header.clone(); + alt_header.block_access_list_hash = Some(H256::zero()); + let alt_hash = alt_header.compute_block_hash(ðrex_crypto::NativeCrypto); + if alt_hash == expected_hash { + return EngineRestError::bad_request( + "engine_newPayloadV4 received header with Amsterdam block_access_list_hash field", + ) + .into(); + } + } let expected = ssz_blob_hashes_to_vec(&req.expected_blob_versioned_hashes); let status = match handle_new_payload_v3(expected_hash, ctx, block, expected, None).await { Ok(s) => s, @@ -149,13 +163,15 @@ pub async fn new_payload_v5( Ssz(req): Ssz, ) -> Response { let expected_hash = H256::from(req.execution_payload.block_hash); + // EIP-7928 / Amsterdam: V5 payloads MUST include the BAL field — its + // absence is a structural error, not a block-validity failure. + if req.execution_payload.block_access_list.is_empty() { + return EngineRestError::bad_request("block_access_list required in engine_newPayloadV5") + .into(); + } // Hash the raw BAL bytes as-received: re-encoding through RLP can change // ordering and break the block-hash check. - let raw_bal_hash = if req.execution_payload.block_access_list.is_empty() { - None - } else { - Some(keccak(&req.execution_payload.block_access_list[..])) - }; + let raw_bal_hash = Some(keccak(&req.execution_payload.block_access_list[..])); let exec_requests = ssz_to_encoded_requests(&req.execution_requests); if let Err(e) = validate_execution_requests(&exec_requests) { @@ -179,6 +195,21 @@ pub async fn new_payload_v5( chain_config.get_fork(block.header.timestamp) )); } + // EIP-7928 fork-boundary detector: see engine/payload.rs (V5 variant). + // V5 requires block_access_list_hash; if the CL sent a V4-shaped header + // (field absent), the alt with the field cleared reproduces the payload's + // block_hash. + if block.hash() != expected_hash { + let mut alt_header = block.header.clone(); + alt_header.block_access_list_hash = None; + let alt_hash = alt_header.compute_block_hash(ðrex_crypto::NativeCrypto); + if alt_hash == expected_hash { + return EngineRestError::bad_request( + "engine_newPayloadV5 received header missing block_access_list_hash field", + ) + .into(); + } + } let expected = ssz_blob_hashes_to_vec(&req.expected_blob_versioned_hashes); let status = match handle_new_payload_v4(expected_hash, ctx, block, expected, bal).await { Ok(s) => s, diff --git a/test/tests/rpc/engine_rest_tests.rs b/test/tests/rpc/engine_rest_tests.rs index 7414b263fd9..e0b60251407 100644 --- a/test/tests/rpc/engine_rest_tests.rs +++ b/test/tests/rpc/engine_rest_tests.rs @@ -18,6 +18,8 @@ use tower::ServiceExt; use ethrex_common::Address; use ethrex_common::types::ChainConfig; +use ethrex_common::types::block_access_list::BlockAccessList; +use ethrex_rlp::encode::RLPEncode; use ethrex_rpc::engine_rest::SSZ_REST_CAPABILITIES; use ethrex_rpc::engine_rest::types::blobs::{GetBlobsV1Request, GetBlobsV1Response}; use ethrex_rpc::engine_rest::types::bodies::{ @@ -733,6 +735,12 @@ async fn new_payload_v4_pre_prague_returns_422() { assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } +fn rlp_empty_bal() -> Vec { + let mut buf = Vec::new(); + BlockAccessList::new().encode(&mut buf); + buf +} + #[tokio::test] async fn new_payload_v5_pre_amsterdam_returns_422() { let cc = ChainConfig { @@ -746,8 +754,12 @@ async fn new_payload_v5_pre_amsterdam_returns_422() { ..Default::default() }; let app = make_router_with_chain_config(cc).await; + let mut payload = empty_payload_v4(1); + // V5 requires a non-empty BAL field; use a valid (empty-list) BAL so the + // fork check is what fires. + payload.block_access_list = rlp_empty_bal().try_into().unwrap(); let req = NewPayloadV5Request { - execution_payload: empty_payload_v4(1), + execution_payload: payload, expected_blob_versioned_hashes: Vec::::new().try_into().unwrap(), parent_beacon_block_root: [0u8; 32], execution_requests: Vec::< @@ -762,3 +774,35 @@ async fn new_payload_v5_pre_amsterdam_returns_422() { .unwrap(); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); } + +#[tokio::test] +async fn new_payload_v5_empty_bal_returns_400() { + // EIP-7928: V5 payloads MUST carry block_access_list; absence is a + // structural error, matching the JSON-RPC engine_newPayloadV5 behavior. + let cc = ChainConfig { + chain_id: 1, + shanghai_time: Some(0), + cancun_time: Some(0), + prague_time: Some(0), + osaka_time: Some(0), + amsterdam_time: Some(0), + deposit_contract_address: Address::zero(), + ..Default::default() + }; + let app = make_router_with_chain_config(cc).await; + let req = NewPayloadV5Request { + execution_payload: empty_payload_v4(1), + expected_blob_versioned_hashes: Vec::::new().try_into().unwrap(), + parent_beacon_block_root: [0u8; 32], + execution_requests: Vec::< + SszList, + >::new() + .try_into() + .unwrap(), + }; + let resp = app + .oneshot(auth_post("/engine/v5/payloads", ssz_body(&req))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +}