Skip to content

feat(l1): engine REST/SSZ API#6770

Open
iovoid wants to merge 16 commits into
mainfrom
feat/engine-rest-ssz-793
Open

feat(l1): engine REST/SSZ API#6770
iovoid wants to merge 16 commits into
mainfrom
feat/engine-rest-ssz-793

Conversation

@iovoid

@iovoid iovoid commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Motivation

We want to reduce the overhead of JSON serialization/deserialization for the large objects handled by the engine API. One of the proposals to do so is ethereum/execution-apis#793

Description

Most of the PR consists of boilerplate: defining RPC objects, route handlers and serialization/deserialization logic.

Benchmarks

Screenshot 2026-06-10 at 4 47 58 PM Screenshot 2026-06-10 at 4 48 13 PM Screenshot 2026-06-10 at 4 48 29 PM

iovoid added 6 commits May 20, 2026 09:15
Prototype implementation of the REST + SSZ engine API proposed in
ethereum/execution-apis PR #793. Coexists with the existing JSON-RPC
engine API on the same authrpc port (:8551) — JSON-RPC behavior is
unchanged.

Surface (all forks Paris → Amsterdam):
  GET  /identity                     ClientVersionV1 array (JSON)
  GET  /capabilities                 supported_forks + endpoint limits (JSON)
  POST /{fork}/payloads              newPayload over SSZ
  GET  /{fork}/payloads/{id}         poll built payload (SSZ envelope)
  POST /{fork}/forkchoice            forkchoice update + optional build trigger
  POST /{fork}/bodies/hash           bodies by hash (per-fork shape)
  GET  /{fork}/bodies?from=&count=   bodies by range
  POST /blobs/v{1..4}                blob + cell-proof retrieval

Reuses the existing handle_new_payload_v* / handle_forkchoice helpers;
SSZ handlers convert to internal Block + dispatch through the same
business logic the JSON-RPC handlers use. Amsterdam BAL is regenerated
via Blockchain::generate_bal_for_block (mirrors JSON-RPC V2 path).

Measurement (tooling/engine_bench + crates/networking/rpc/benches):
microbenches and an end-to-end harness compare JSON-RPC vs REST/SSZ
encode/decode and end-to-end latency for newPayload, getPayload,
blobs, bodies. SSZ wire size ~50% of JSON; SSZ encode ~250× faster.

Known limitations (documented inline):
- Amsterdam /blobs/v4 returns empty cell bytes (mempool lacks per-cell
  storage); proofs are populated where available.
- blobs/v2 vs blobs/v3 all-or-nothing vs partial semantics share the
  same code path today (mempool lacks Osaka-aware partial state).
- handle_new_payload_v3's expected_blob_versioned_hashes parameter was
  made Option<Vec<H256>>; JSON-RPC callers wrap in Some(_), REST
  callers pass None (the EL recomputes from txs per spec).
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Known Issues

Tests intentionally excluded from CI. Source of truth for the Known
Issues
section the L1 workflow appends to each ef-tests job summary
and posts as a sticky PR comment.

EF Tests — Stateless coverage narrowed to EIP-8025 optional-proofs

make -C tooling/ef_tests/blockchain test calls test-stateless-zkevm
instead of test-stateless. The zkevm@v0.3.3 fixtures are filled against
bal@v5.6.1, out of sync with current bal spec; the broad target trips ~549
fixtures. Re-broaden once the zkevm bundle is regenerated.

Why and resolution path

PR #6527 broadened
test-stateless to extract the entire for_amsterdam/ tree from the
zkevm bundle and run all of it under --features stateless; combined with
this branch's bal-devnet-7 semantics that scope produces ~549
GasUsedMismatch / ReceiptsRootMismatch /
BlockAccessListHashMismatch failures.

test-stateless-zkevm filters cargo to the eip8025_optional_proofs
suite, which still validates the stateless harness without the bal-version
mismatch.

Re-broaden by switching test: back to test-stateless in
tooling/ef_tests/blockchain/Makefile once the zkevm bundle is regenerated
against the current bal spec.

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 2704
Total lines removed: 0
Total lines changed: 2704

Detailed view
+---------------------------------------------------------------------+-------+------+
| File                                                                | Lines | Diff |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine/payload.rs                      | 1309  | +3   |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/auth.rs                    | 60    | +60  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/error.rs                   | 80    | +80  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/extractors.rs              | 63    | +63  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/fork_path.rs               | 34    | +34  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/blobs.rs          | 146   | +146 |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/bodies.rs         | 253   | +253 |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/capabilities.rs   | 47    | +47  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/forkchoice.rs     | 306   | +306 |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/helpers.rs        | 20    | +20  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/identity.rs       | 7     | +7   |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/mod.rs            | 7     | +7   |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/payloads.rs       | 583   | +583 |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/mod.rs                     | 47    | +47  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/responses.rs               | 16    | +16  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/amsterdam.rs         | 47    | +47  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/blobs.rs             | 95    | +95  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/bodies.rs            | 108   | +108 |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/built_payload.rs     | 62    | +62  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/cancun.rs            | 40    | +40  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/common.rs            | 167   | +167 |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/conversions.rs       | 341   | +341 |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/forkchoice_update.rs | 26    | +26  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/mod.rs               | 12    | +12  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/osaka.rs             | 3     | +3   |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/paris.rs             | 33    | +33  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/prague.rs            | 43    | +43  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/shanghai.rs          | 42    | +42  |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/lib.rs                                 | 33    | +1   |
+---------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/rpc.rs                                 | 1392  | +12  |
+---------------------------------------------------------------------+-------+------+

@iovoid iovoid force-pushed the feat/engine-rest-ssz-793 branch from c67250f to 8fc2a3f Compare June 4, 2026 19:50
@iovoid iovoid force-pushed the feat/engine-rest-ssz-793 branch 2 times, most recently from fc715ae to 30e847a Compare June 4, 2026 21:21
@iovoid iovoid marked this pull request as ready for review June 4, 2026 21:22
@iovoid iovoid requested review from a team as code owners June 4, 2026 21:22
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

This is a substantial and well-engineered PR implementing the REST/SSZ Engine API (execution-apis #793). The implementation is comprehensive, secure, and well-tested. Below are specific observations:

Security & Correctness

JWT Authentication (engine_rest/auth.rs:1-86)

  • Correctly implements Bearer token validation with case-insensitive scheme matching (RFC 7235 compliant).
  • Properly extracts X-Engine-Client-Version without panicking on non-ASCII values.

Request Limits (engine_rest/extractors.rs:1-90)

  • Good use of with_limited_body() to enforce DefaultBodyLimit (256 MiB) — prevents unbounded memory consumption on the authrpc port.
  • Accurate SSZ content-type validation with 415 rejection for mismatches.

Fork Validation (engine_rest/fork_path.rs:1-47)

  • Strict fork segment validation prevents historical forks (Frontier..London) from reaching handlers, returning 400 as expected.

Performance

BAL Retrieval Optimization (blockchain.rs:471-502)

  • The change to prefer stored BlockAccessList over re-execution is a significant performance win for engine_getPayloadBodiesByRangeV2 and the new REST /bodies endpoint. The fallback to re-execution is properly offloaded to a blocking thread (bodies.rs:227-241) to avoid stalling the async runtime.

SSZ vs JSON (benches/engine_transport.rs:1-170)

  • The microbenchmarks confirm the wire-size savings (SSZ ~30-50% of JSON for payloads). The benchmark design using deterministic fixtures is sound.

Code Quality

Error Handling (engine_rest/error.rs:1-97)

  • Excellent adoption of RFC 7807 Problem JSON for all error responses. Status codes are semantically correct (400 for malformed SSZ, 415 for wrong Content-Type, 413 for oversize bodies).

Type Safety (engine_rest/types/conversions.rs:1-408)

  • The IntoEngineCall trait cleanly separates fork-specific SSZ deserialization from the common validation logic. The EngineCall enum safely dispatches to the appropriate handler without re-parsing.

Refactoring (engine/payload.rs:992-1078)

  • Changing handle_new_payload_* to accept expected_block_hash: H256 instead of &ExecutionPayload reduces cloning in the new REST path while maintaining compatibility with existing JSON-RPC handlers.

Minor Observations

  1. Error Message Wording (engine/payload.rs:1113-1116): The block hash validation error says "Expected {actual}, got {expected}" which is backwards (should be "Expected {expected}, got {actual}"). This is pre-existing code, not new in this PR.

  2. Clone in Conversions (types/conversions.rs:208): withdrawals: self.withdrawals.clone() is necessary due to the change from into_block to to_block, but given the bounded size of withdrawals (max 16 per payload), this is acceptable.

  3. HTTP/2 Feature (Cargo.toml:12-17): The explicit enabling of the http2 feature is critical — without it, CLs using h2c prior-knowledge (like consensoor) would fail to connect. The code comment accurately documents this dependency.

Testing

The test coverage is exemplary:

  • Unit tests: SSZ round-trips for all fork types (tests.rs:2461 lines of tests).
  • Integration tests: Full router testing with JWT auth, content negotiation, and error paths.
  • Transport tests: Explicit verification of h2c serving (transport_tests module).

Summary

No blocking issues. The PR correctly implements the spec with strong security boundaries (auth, size limits), efficient resource usage (stored BAL preference, blocking task offload), and comprehensive validation. The code is production-ready.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/networking/rpc/engine_rest/handlers/payloads.rs never checks that the fetched payload actually belongs to the requested /{fork}. It parses the path, loads the payload, and then serializes whatever came back into the caller-selected SSZ shape at lines 234-296. That diverges from the JSON-RPC side, which does fork validation before serving versioned payloads at crates/networking/rpc/engine/payload.rs:528, 575, and 628, and from the REST bodies path, which explicitly filters by era at crates/networking/rpc/engine_rest/handlers/bodies.rs:104. As written, /amsterdam/payloads/{id} can serialize a non-Amsterdam payload with empty BAL/slot fields, and /cancun/payloads/{id} can strip Prague/Amsterdam-only data. This should reject on fork mismatch before encoding.

  2. crates/networking/rpc/engine_rest/handlers/forkchoice.rs:61 has the same missing fork-boundary enforcement for POST /{fork}/forkchoice. Each route just decodes fork-specific attributes and forwards into run_forkchoice, which eventually calls build_payload_v3 or v4 without checking that attrs.timestamp belongs to the URL fork. The underlying V3 validator only enforces “Cancun or later” at crates/networking/rpc/engine/fork_choice.rs:412, so /cancun/forkchoice can build Prague/Osaka/Amsterdam-era payloads instead of returning unsupported fork. That breaks the fork-scoped REST contract and can hand back payload IDs that later decode under the wrong schema.

  3. The new live benchmark harness is benchmarking invalid bodies requests. It hardcodes DEFAULT_BODIES_COUNT = 128 at tooling/engine_bench/src/fixtures.rs:25 and uses it directly for both transports at tooling/engine_bench/src/workloads.rs:227 and 255. The REST API advertises bodies.max_count = 32 at crates/networking/rpc/engine_rest/handlers/capabilities.rs:41, and the existing JSON-RPC handler also rejects count >= 128 at crates/networking/rpc/engine/payload.rs:721 and 838. So the Bodies benchmark is timing error paths, not successful body retrieval, which makes the reported transport comparison misleading.

I couldn’t run cargo test here because the toolchain bootstrap tries to write under /home/runner/.rustup, which is read-only in this environment.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have a comprehensive picture of this PR. Let me compose the review.


PR #6770 — feat(l1): engine REST/SSZ API

Overview

This PR implements the Engine REST/SSZ API per execution-apis PR #793. It adds a new engine_rest sub-router mounted at /engine/v2, covering all hot-path endpoints (payloads, forkchoice, bodies, blobs, identity, capabilities) with SSZ wire types per fork (Paris → Amsterdam), JWT auth middleware, RFC 7807 error responses, HTTP/2 cleartext (h2c) support, and both Criterion micro-benchmarks and an external bench tool.

The PR is well-structured, has good test coverage, and the intent behind each design decision is clearly documented. The concerns below are mostly correctness and edge-case issues rather than architectural ones.


Correctness Issues

1. Blob versioned hash validation silently skipped for all REST Cancun/Prague payloads (engine/payload.rs)

handle_new_payload_v3 now accepts Option<Vec<H256>> and skips validation when None is passed. The REST dispatch in handlers/payloads.rs always passes None:

EngineCall::V3 { .. } => {
    handle_new_payload_v3(expected_block_hash, ctx, block, None, None).await
}
EngineCall::V4 { .. } => {
    handle_new_payload_v3(expected_block_hash, ctx, block, None, None).await
}

The code comment acknowledges this (// V3 specific: validate blob hashes (skipped when None, e.g. REST callers)) but doesn't cite the spec justification. Execution-apis #793 removes blobVersionedHashes from the REST payload, so the change is correct, but it's worth adding a brief note at the call site explaining why it is safe to skip (CL validates blob sidecar integrity separately in the REST world). Without that, a future contributor may see the None and assume it is a bug.


2. blobs_v2 silently falls back to 204 No Content via a failed try_into() (handlers/blobs.rs)

BlobAndProofV2.proofs is typed as SszVector<[u8; 48], 128> (exactly 128 cell proofs per blob). The mempool stores Cancun-era whole-blob proofs — one [u8; 48] per blob. Attempting to convert a 1-element Vec to a fixed-size vector of 128 elements will always fail, so every blobs_v2 response will silently degrade to 204 No Content via the Err arm:

let proofs_ssz = match proofs.into_iter().collect::<Vec<_>>().try_into() {
    Ok(p) => p,
    Err(_) => return StatusCode::NO_CONTENT.into_response(),
};

blobs_v4 has an explicit comment stating it always returns 204 because per-cell data is not stored. blobs_v2 should have the same comment (or, if Osaka cell proofs are stored in the mempool, the handling needs a code fix). As written, the 204 path is reachable without any warning even when the hash list is valid.


3. Error message in validate_block_hash has inverted Expected/got labels (engine/payload.rs)

return Err(RpcErr::Internal(format!(
    "Invalid block hash. Expected {actual_block_hash:#x}, got {expected:#x}"
)));

actual_block_hash is the value ethrex computed; expected is what the CL claimed. The message reads "Expected [computed], got [CL-claimed]", which is the reverse of the usual convention. This is a pre-existing bug preserved through the refactor. The correct wording is:

"Invalid block hash. CL claimed {expected:#x}, computed {actual_block_hash:#x}"

4. Query parameter parse errors in bodies_by_range surface as axum errors, not ProblemJson (handlers/bodies.rs)

#[derive(Deserialize)]
pub struct BodiesRangeParams {
    pub from: Option<u64>,
    pub count: Option<u64>,
}

pub async fn bodies_by_range(
    ...
    Query(params): Query<BodiesRangeParams>,
) -> Response {

If the CL sends ?from=abc, axum's Query extractor rejects it before the handler runs, returning a plain-text 400 response rather than a ProblemJson. This breaks the RFC 7807 contract for all clients that parse error bodies. The fix is to accept Query<serde_json::Value> or a custom FromRequestParts that maps the rejection, or to define a rejection_handler in the router.


Security Notes

5. BEARER_PREFIX slice index is safe — but only by coincidence of the ASCII assumption (engine_rest/auth.rs)

const BEARER_PREFIX: &str = "Bearer ";

let token = match auth_str.get(..BEARER_PREFIX.len()) {
    Some(prefix) if prefix.eq_ignore_ascii_case(BEARER_PREFIX) => {
        &auth_str[BEARER_PREFIX.len()..]
    }

The comment says "BEARER_PREFIX is ASCII, so byte index 7 is also char index 7". This is true and the code is correct. Consider adding a const _: () = assert!(BEARER_PREFIX.is_ascii()); compile-time assertion to make the invariant explicit and future-proof.


6. ProblemJson::into_response panics on serialization failure (error.rs)

let body = serde_json::to_vec(&self).expect("ProblemJson serializes");

ProblemJson only contains String and u16 fields, so this cannot fail today. In a production server, a panic inside a response handler terminates the connection. A defensive fallback like:

let body = serde_json::to_vec(&self).unwrap_or_else(|_| br#"{"status":500}"#.to_vec());

would be more resilient to future field additions that accidentally make the type unserializable.


Performance Notes

7. 131 KiB blob allocation on every blobs_v1 entry (handlers/blobs.rs)

match blob.as_ref().to_vec().try_into() {
    Ok(blob_ssz) => BlobV1Entry::available(BlobAndProofV1 { blob: blob_ssz, proof: proofs[0] }),

blob.as_ref().to_vec() allocates a full 131072-byte Vec just to hand it off to try_into() (which presumably copies it into an SszVector). Depending on libssz's API, it may be possible to build the SszVector<u8, BYTES_PER_BLOB> directly from the slice without an intermediate allocation.


8. Block hash recomputed repeatedly during get_payload (handlers/payloads.rs)

In the get_payload handler and in several *_from_block conversion helpers:

block_hash: block.hash().0,

block.hash() triggers a full keccak of the RLP-encoded header on every call. The same block is passed through multiple conversion functions, each calling block.hash() independently. Caching the result in a local let block_hash = block.hash(); before entering the fork match would eliminate redundant hashing.


Minor Issues

9. PayloadId::from_str reimplements hex decoding (types/common.rs)

The existing hex crate dep can replace the manual byte-by-byte loop:

// current
for (i, byte) in bytes.iter_mut().enumerate() {
    *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
        .map_err(|e| format!("invalid hex: {e}"))?;
}

// simpler
let decoded = hex::decode(hex).map_err(|e| format!("invalid hex: {e}"))?;
decoded.try_into().map_err(|_| "...".to_string()).map(Self)

10. MAX_BLOCK_ACCESS_LIST_BYTES lacks a spec reference (types/common.rs)

pub const MAX_BLOCK_ACCESS_LIST_BYTES: usize = 16_777_216; // 16 MiB

Other constants cite their spec sources (EIP numbers, refactor-ssz.md). This one does not. Add a reference to EIP-7928 or the #793 limit table so reviewers can verify the value.

11. unreachable! arms in handler match blocks are panic paths (handlers/bodies.rs, handlers/payloads.rs)

_ => unreachable!("ForkPath restricts to spec forks"),

The invariant is maintained by ForkPath's from_request_parts, so these are correct today. Consider using return ProblemJson::bad_request("unsupported fork").into_response() as a graceful fallback rather than a panic in a server path, even if the case is theoretically unreachable — panics inside async Axum handlers bubble up as 500s but also log a large backtrace and can trigger tokio's task abort mechanism.

12. eip-8025 feature flag removed from libssz* deps without a changelog note (rpc/Cargo.toml)

The deps moved from optional (gated behind eip-8025) to unconditional. Any downstream build that previously excluded eip-8025 will now always pull in libssz, libssz-derive, libssz-types, and libssz-merkle. This is intentional given that the REST router is always built, but it increases the unconditional compile surface for all consumers of ethrex-rpc.


Positive Highlights

  • The BAL caching optimization in blockchain.rs (prefer stored copy over re-execution) is a solid improvement for the bodies-by-range path and well-motivated in the comment.
  • The to_block(&self, ...) rename (replacing into_block(self, ...)) is a clean ergonomic improvement; the clones added for withdrawals and extra_data are minimal.
  • The JWT auth middleware correctly uses eq_ignore_ascii_case for the scheme prefix, matching RFC 7235.
  • is_length_limit_error walking the source chain is fragile by nature, but the test coverage (wire_tests: oversize body → 413) validates it.
  • The per-fork EngineCall dispatch is clean and the IntoEngineCall trait gives a clear extension point for future forks.
  • SSZ round-trip tests per fork in tests.rs are thorough.

Automated review by Claude (Anthropic) · sonnet · custom prompt

@greptile-apps

greptile-apps Bot commented Jun 4, 2026

Copy link
Copy Markdown

Greptile Summary

This PR implements the REST/SSZ engine API proposed in execution-apis PR #793, adding a new sub-router mounted under /engine/v2 on the auth port. It defines per-fork SSZ wire types, RFC 7807 error responses, a JWT auth middleware, and handlers for all major engine endpoints (/{fork}/payloads, /{fork}/forkchoice, /{fork}/bodies, /blobs/v1-4, /capabilities, /identity).

  • All six spec forks (Paris through Amsterdam) are wired with correct SSZ envelope types and dispatch to the existing handle_new_payload_* and handle_forkchoice helpers, keeping the REST and JSON-RPC paths in sync.
  • handle_new_payload_v3/v4 signatures are refactored to accept Option<Vec<H256>> for blob hash validation, allowing REST callers to pass None while JSON-RPC callers continue to pass Some(...).
  • blockchain::generate_bal_for_block gains a fast path that returns the stored BAL rather than always re-executing, preventing up to BODIES_MAX_COUNT re-executions on a single bodies-range request.

Confidence Score: 4/5

The new REST/SSZ engine endpoint layer is well-structured; JWT auth is uniformly applied, body-size limits are respected, and the core forkchoice/newPayload logic is reused from the existing JSON-RPC path rather than duplicated. The three flagged items are minor: a missing RFC 7235 response header, an implicit contract in a now-optional validation parameter, and a middleware ordering quirk that allows unauthenticated requests to reset the CL activity watchdog.

The changes are almost entirely additive and delegate to well-tested internal helpers. The most subtle change — making expected_blob_versioned_hashes optional — is intentional per the REST spec and does not weaken block integrity since the block hash check still applies. The timer-reset ordering issue is cosmetic in a local-only auth port context. No data-loss, auth bypass, or correctness regressions were found in the core paths.

crates/networking/rpc/rpc.rs (middleware layering order), crates/networking/rpc/engine_rest/auth.rs (WWW-Authenticate header), crates/networking/rpc/engine/payload.rs (optional blob hash validation contract)

Important Files Changed

Filename Overview
crates/networking/rpc/engine_rest/auth.rs New JWT bearer auth middleware; correctly validates token and captures X-Engine-Client-Version, but omits the RFC 7235-required WWW-Authenticate header in 401 responses.
crates/networking/rpc/engine_rest/mod.rs Builds the engine REST sub-router under /engine/v2, wiring all fork-scoped and unscoped routes with JWT auth applied uniformly.
crates/networking/rpc/engine_rest/handlers/payloads.rs POST /{fork}/payloads and GET /{fork}/payloads/{id}; fork-boundary and BAL structural checks look correct; REST callers bypass blob versioned-hash validation intentionally.
crates/networking/rpc/engine_rest/handlers/forkchoice.rs POST /{fork}/forkchoice; maps SSZ fork-specific updates to internal types, delegates to handle_forkchoice, and builds payloads only when head is known.
crates/networking/rpc/engine_rest/handlers/bodies.rs bodies-by-hash and bodies-by-range; correctly enforces BODIES_MAX_COUNT, truncates range at chain head, marks cross-fork-era entries as unavailable.
crates/networking/rpc/engine_rest/handlers/blobs.rs /blobs/v1-v4 semantics (per-entry, all-or-nothing, partial, always-204) all look correct per spec.
crates/networking/rpc/engine_rest/types/conversions.rs Per-fork SSZ envelope to Block + EngineCall conversion; BAL RLP decoding, requests hash, and V5 structural checks mirror the JSON-RPC path correctly.
crates/networking/rpc/engine/payload.rs Refactored to accept expected_block_hash directly and made expected_blob_versioned_hashes Optional for REST callers; JSON-RPC callers still pass Some(...).
crates/networking/rpc/rpc.rs Mounts /engine/v2 on the auth port and resets the CL activity watchdog for REST requests; timer-reset fires before JWT auth due to middleware layering order.
crates/networking/rpc/engine_rest/extractors.rs SSZ body extractor with with_limited_body() honouring DefaultBodyLimit; LengthLimitError detection via error-chain walk is correct.
crates/networking/rpc/engine_rest/types/common.rs Fork-invariant SSZ types and Optional[T] = List[T,1] helpers; constants and wire layout correct.
crates/blockchain/blockchain.rs generate_bal_for_block now prefers stored BAL over re-execution, avoiding bulk re-execution on bodies range requests.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
crates/networking/rpc/engine_rest/auth.rs:63-67
**Missing `WWW-Authenticate` header in 401 responses**

RFC 7235 §3.1 states that a server MUST include at least one `WWW-Authenticate` challenge when responding with 401 Unauthorized. The current `ProblemJson::unauthorized(...)` response omits this header entirely. Some HTTP client stacks treat a 401 without `WWW-Authenticate` as malformed and may not surface it correctly to the CL. A minimal header like `WWW-Authenticate: Bearer realm="engine"` would satisfy the RFC.

### Issue 2 of 3
crates/networking/rpc/engine/payload.rs:1028-1044
**Blob versioned-hash validation is silently skipped for REST callers**

`handle_new_payload_v3` now accepts `expected_blob_versioned_hashes: Option<Vec<H256>>`, and the REST path consistently passes `None`, bypassing the hash equality check that the JSON-RPC path performs. This is intentional per the REST spec (execution-apis PR #793 omits `expectedBlobVersionedHashes`); the block-hash check still provides end-to-end integrity. However, if any future caller accidentally passes `None` for a fork that should validate blobs, the validation will silently vanish. Consider adding a doc comment that `None` is only valid for transports that carry no `expectedBlobVersionedHashes`.

### Issue 3 of 3
crates/networking/rpc/rpc.rs:598-612
**CL activity timer is reset before JWT auth is verified**

The timer-reset `from_fn` middleware is layered *outside* `engine_rest::router`, while the JWT auth middleware lives *inside* it. In axum's execution model outer layers run first, so the watchdog timer fires for every request on the auth port — including those with missing, expired, or forged JWT tokens — before credentials are checked. An unauthenticated process on the local machine could therefore prevent the "No messages from the consensus layer" warning from ever triggering. Consider placing the timer-reset layer inside the router after the JWT auth middleware.

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +63 to +67
};

if let Err(err) = validate_jwt_authentication(token, &secret) {
debug!("engine REST auth rejected: {err:?}");
return ProblemJson::unauthorized("JWT validation failed").into_response();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing WWW-Authenticate header in 401 responses

RFC 7235 §3.1 states that a server MUST include at least one WWW-Authenticate challenge when responding with 401 Unauthorized. The current ProblemJson::unauthorized(...) response omits this header entirely. Some HTTP client stacks treat a 401 without WWW-Authenticate as malformed and may not surface it correctly to the CL. A minimal header like WWW-Authenticate: Bearer realm="engine" would satisfy the RFC.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/engine_rest/auth.rs
Line: 63-67

Comment:
**Missing `WWW-Authenticate` header in 401 responses**

RFC 7235 §3.1 states that a server MUST include at least one `WWW-Authenticate` challenge when responding with 401 Unauthorized. The current `ProblemJson::unauthorized(...)` response omits this header entirely. Some HTTP client stacks treat a 401 without `WWW-Authenticate` as malformed and may not surface it correctly to the CL. A minimal header like `WWW-Authenticate: Bearer realm="engine"` would satisfy the RFC.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 1028 to +1044

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<H256>,
expected_blob_versioned_hashes: Option<Vec<H256>>,
bal: Option<BlockAccessList>,
) -> Result<PayloadStatus, RpcErr> {
// V3 specific: validate blob hashes
let blob_versioned_hashes: Vec<H256> = block
.body
.transactions
.iter()
.flat_map(|tx| tx.blob_versioned_hashes())
.collect();

if expected_blob_versioned_hashes != blob_versioned_hashes {
return Ok(PayloadStatus::invalid_with_err(
"Invalid blob_versioned_hashes",
));
// V3 specific: validate blob hashes (skipped when None, e.g. REST callers)
if let Some(expected) = expected_blob_versioned_hashes {
let blob_versioned_hashes: Vec<H256> = block
.body
.transactions
.iter()
.flat_map(|tx| tx.blob_versioned_hashes())
.collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Blob versioned-hash validation is silently skipped for REST callers

handle_new_payload_v3 now accepts expected_blob_versioned_hashes: Option<Vec<H256>>, and the REST path consistently passes None, bypassing the hash equality check that the JSON-RPC path performs. This is intentional per the REST spec (execution-apis PR #793 omits expectedBlobVersionedHashes); the block-hash check still provides end-to-end integrity. However, if any future caller accidentally passes None for a fork that should validate blobs, the validation will silently vanish. Consider adding a doc comment that None is only valid for transports that carry no expectedBlobVersionedHashes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/engine/payload.rs
Line: 1028-1044

Comment:
**Blob versioned-hash validation is silently skipped for REST callers**

`handle_new_payload_v3` now accepts `expected_blob_versioned_hashes: Option<Vec<H256>>`, and the REST path consistently passes `None`, bypassing the hash equality check that the JSON-RPC path performs. This is intentional per the REST spec (execution-apis PR #793 omits `expectedBlobVersionedHashes`); the block-hash check still provides end-to-end integrity. However, if any future caller accidentally passes `None` for a fork that should validate blobs, the validation will silently vanish. Consider adding a doc comment that `None` is only valid for transports that carry no `expectedBlobVersionedHashes`.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +598 to +612
let engine_rest_router =
crate::engine_rest::router(service_context.clone()).layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let timer = rest_timer_sender.clone();
async move {
let _ = timer.send(());
next.run(req).await
}
},
));

let authrpc_router = Router::new()
.route("/", post(authrpc_handler))
.with_state(service_context.clone())
// Engine REST/SSZ (#793) is scoped under /engine/v2/... per refactor.md;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CL activity timer is reset before JWT auth is verified

The timer-reset from_fn middleware is layered outside engine_rest::router, while the JWT auth middleware lives inside it. In axum's execution model outer layers run first, so the watchdog timer fires for every request on the auth port — including those with missing, expired, or forged JWT tokens — before credentials are checked. An unauthenticated process on the local machine could therefore prevent the "No messages from the consensus layer" warning from ever triggering. Consider placing the timer-reset layer inside the router after the JWT auth middleware.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/rpc.rs
Line: 598-612

Comment:
**CL activity timer is reset before JWT auth is verified**

The timer-reset `from_fn` middleware is layered *outside* `engine_rest::router`, while the JWT auth middleware lives *inside* it. In axum's execution model outer layers run first, so the watchdog timer fires for every request on the auth port — including those with missing, expired, or forged JWT tokens — before credentials are checked. An unauthenticated process on the local machine could therefore prevent the "No messages from the consensus layer" warning from ever triggering. Consider placing the timer-reset layer inside the router after the JWT auth middleware.

How can I resolve this? If you propose a fix, please make it concise.

@iovoid iovoid force-pushed the feat/engine-rest-ssz-793 branch from 30e847a to bad5c93 Compare June 5, 2026 12:58
// only need the expected block_hash from the payload, so we pass it
// directly and skip the `JsonExecutionPayload::from_block` intermediate.
let result = match call {
EngineCall::V1V2 => handle_new_payload_v1_v2(expected_block_hash, block, ctx, None).await,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REST path drops the expectedBlobVersionedHashes cross-check that the JSON-RPC NewPayloadV3/V4/V5 path runs (engine/payload.rs:142, 265, 408 now pass Some(self.expected_blob_versioned_hashes.clone()); REST passes None here and on V3/V4/V5 dispatch below).

If #793 intentionally removed the field from the SSZ envelope on the theory that the EL can derive blob hashes from the transactions itself, that's a design choice and this is correct — but the safety net that catches a CL miscomputing blob hashes is gone on this transport. Worth confirming with the spec and adding a one-line comment here noting why we pass None (it currently reads as forgotten rather than intentional). If the spec actually still requires the check via a header or trailer, the right place to enforce it is right here.

}
}
Fork::Amsterdam => {
let mut entries: Vec<BodyEntryAmsterdam> = Vec::with_capacity(blocks.len());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amsterdam bodies_by_hash / bodies_by_range does spawn_blocking per block in sequence — for BODIES_MAX_COUNT = 32 that's 32 serialized blocking-task hops. With the new stored-BAL fast path (generate_bal_for_block reads get_block_access_list first), each one is cheap, so the cost in steady state is mostly dispatch overhead. But on snap-synced nodes where pre-Amsterdam-cutover blocks lack a stored BAL, every miss falls back to full re-execution serially — a single missing BAL in a 32-block range can multi-second the response.

Not blocking — BAL persistence is the long-term answer. Two cheap mitigations to consider: (a) batch the 32 spawn_blocking dispatches into a single one that walks the slice; (b) for the re-execution fallback path, fail fast with 503 Service Unavailable instead of stalling the response (the CL can retry against another EL or drop the body request).

# libssz-merkle provides the `HashTreeRoot` trait used by the SSZ derives
# in engine_rest/types/. The libssz-derive macros expand to impls that
# reference this trait, so it must be a direct unconditional dep.
libssz-merkle = { workspace = true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These four ssz crates were previously optional = true, eip-8025-gated; now all four are unconditional rpc-crate deps. That's necessary (engine_rest is unconditional in lib.rs), but it does mean every default rpc build picks them up. The added compile time isn't dramatic, but the change widens the dependency surface for non-eip-8025 builds (think make dev, EF-tests, etc.). Worth confirming that's OK — the alternative is feature-gating pub mod engine_rest; in lib.rs behind a feature so consumers who don't need REST/SSZ don't pay for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants