Skip to content

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

Open
iovoid wants to merge 1 commit into
mainfrom
feat/engine-rest-ssz-764
Open

feat(l1): engine REST/SSZ API#6710
iovoid wants to merge 1 commit into
mainfrom
feat/engine-rest-ssz-764

Conversation

@iovoid
Copy link
Copy Markdown
Contributor

@iovoid iovoid commented May 21, 2026

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#764

Description

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

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<T> = SszList<T,1>`
  for SSZ's `Option<T>` 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<T>` 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<RpcErr>` 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<u64>` 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.
@iovoid iovoid requested review from a team as code owners May 21, 2026 21:16
@github-actions
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 added the L1 Ethereum client label May 21, 2026
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

This PR implements SSZ REST endpoints for the Engine API (per execution-apis PR #764) and EIP-7783 target gas limit support. The implementation is generally solid, well-tested, and follows Rust best practices.

Critical Issues

  1. Off-by-one error in blob request validation (crates/networking/rpc/engine_rest/handlers/blobs.rs:26)
    if n >= MAX_BLOB_HASHES_REQUEST {
    The SSZ type is SszList<Bytes32, MAX_BLOB_HASHES_REQUEST> which can hold up to 128 elements, but this check rejects exactly 128. Use > instead of >= to allow the maximum permitted count.

Correctness & Safety

  1. BAL hash consistency (crates/networking/rpc/engine_rest/handlers/payloads.rs:375-379)
    The comment correctly notes that you hash the raw SSZ bytes to avoid RLP re-encoding issues. This is the right approach to ensure block_access_list_hash matches the block hash calculation.

  2. Payload ID stability (crates/blockchain/payload.rs:121-124)
    Including target_gas_limit in the ID hash only when Some is correct per EIP-7783. The test payload_id_includes_target_gas_limit properly validates this.

Performance & Idioms

  1. Efficient cloning in to_block (crates/networking/rpc/types/payload.rs:102-147) Changing from into_block(self)toto_block(&self)avoids cloning the entireExecutionPayload` when only specific fields are needed. Good optimization.

  2. Spawn blocking for BAL generation (crates/networking/rpc/engine_rest/handlers/bodies.rs:195-218) Correctly using spawn_blocking` for the EVM-bound BAL generation to avoid blocking the async runtime.

Nits

  1. Error message reuse (`crates/networking/rpc/engine_rest/conversions.rs:170)
    The error "BAL RLP exceeds MAX_BYTES_PER_TRANSACTION" is confusing since BAL isn't a transaction. Consider using a generic max bytes constant or separate error message.

  2. Unnecessary allocation (`crates/networking/rpc/engine_rest/handlers/capabilities.rs:20-21)

    let bytes: Vec<u8> = cap.as_bytes().to_vec();

    You can avoid the intermediate Vec by iterating bytes directly or using cap.as_bytes().iter().copied().

Summary

The PR is well-structured with good test coverage (unit tests for payload ID calculation, integration tests for the REST router, and benchmarks). Fix Item 1 (the off-by-one check) before merging. The SSZ type safety and JWT auth integration are implemented correctly.


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

@github-actions
Copy link
Copy Markdown

Lines of code report

Total lines added: 2713
Total lines removed: 0
Total lines changed: 2713

Detailed view
+----------------------------------------------------------------------+-------+------+
| File                                                                 | Lines | Diff |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/payload.rs                                  | 878   | +37  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/block_producer.rs                         | 325   | +1   |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine/fork_choice.rs                   | 567   | +2   |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine/mod.rs                           | 65    | +2   |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine/payload.rs                       | 1012  | +17  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/auth.rs                     | 25    | +25  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/conversions.rs              | 471   | +471 |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/error.rs                    | 139   | +139 |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/extractors.rs               | 40    | +40  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/blobs.rs           | 190   | +190 |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/bodies.rs          | 263   | +263 |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/capabilities.rs    | 33    | +33  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/client_version.rs  | 47    | +47  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/forkchoice.rs      | 231   | +231 |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/mod.rs             | 6     | +6   |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/handlers/payloads.rs        | 408   | +408 |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/mod.rs                      | 141   | +141 |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/observe.rs                  | 80    | +80  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/responses.rs                | 24    | +24  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/blobs.rs              | 52    | +52  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/bodies.rs             | 43    | +43  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/capabilities.rs       | 12    | +12  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/client_version.rs     | 21    | +21  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/common.rs             | 132   | +132 |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/execution_payload.rs  | 87    | +87  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/forkchoice.rs         | 26    | +26  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/get_payload.rs        | 43    | +43  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/mod.rs                | 11    | +11  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/new_payload.rs        | 39    | +39  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/payload_attributes.rs | 34    | +34  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine_rest/types/withdrawal.rs         | 9     | +9   |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/examples/decode_v4_body.rs              | 42    | +42  |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/lib.rs                                  | 33    | +1   |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/rpc.rs                                  | 1303  | +2   |
+----------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/types/fork_choice.rs                    | 57    | +2   |
+----------------------------------------------------------------------+-------+------+

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. Pre-Shanghai newPayloadV2 can still hash the block as a Shanghai-style header even when the withdrawals list is empty. In payloads.rs, the handler only avoids the 422 path for empty withdrawals, but it still calls ssz_payload_v2_to_block, which always sets withdrawals = Some(vec![]) and withdrawals_root = Some(empty_root) at conversions.rs and conversions.rs. Since block hashing encodes optional header fields, None vs Some(empty_root) changes the hash at block.rs. That means a valid pre-Shanghai SSZ engine_newPayloadV2 request with an empty withdrawals list can be reported INVALID during block-hash validation instead of behaving like V1. This should strip empty withdrawals pre-Shanghai before building the block, or route through the V1 conversion.

  2. GET /engine/v5/payloads/{payload_id} is not capped at pre-Amsterdam payloads, but its SSZ response type still drops Amsterdam fields. The handler only checks is_osaka_activated at payloads.rs, then serializes with json_to_execution_payload_v3 at payloads.rs. However, GetPayloadResponseV5 is defined with ExecutionPayloadV3 at get_payload.rs, while Amsterdam fields only appear in V6 at get_payload.rs. So once Amsterdam activates, /engine/v5/payloads/... can return 200 OK while silently omitting block_access_list and slot_number. Either reject Amsterdam-era payloads from V5, like V4 is rejected after Osaka, or change the wire type if V5 is meant to carry Amsterdam payloads.

  3. Blob request size validation has an off-by-one bug. blobs.rs rejects n >= MAX_BLOB_HASHES_REQUEST, but the SSZ request type itself allows exactly 128 entries via MAX_BLOB_HASHES_REQUEST = 128 at common.rs. As written, a maximum-sized valid request is decoded successfully and then rejected as too large. This should be > if 128 is intended to be allowed.

I did not run the Rust tests successfully here: cargo test -p test --test rpc -- engine_rest_tests failed because rustup could not create temp files under /home/runner/.rustup in this environment.


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

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 21, 2026

Greptile Summary

This PR adds a REST/SSZ transport for the Engine API (per execution-apis #764), introducing binary SSZ endpoints under /engine/v{N}/... on the authrpc port as an alternative to the existing JSON-RPC surface.

  • Adds engine_rest module with JWT auth middleware, SSZ request/response extractors, per-version handlers for newPayload (V1–V5), getPayload (V1–V6), getPayloadBodies (V1–V2), forkchoiceUpdated (V1–V4), getBlobs (V1–V3), and support utilities for capabilities/client-version.
  • Refactors engine/payload.rs to accept H256 instead of &ExecutionPayload in the shared helpers and promotes several private functions to pub(crate) so the SSZ layer can reuse them without duplication.
  • Includes a benchmark crate (tooling/engine_rest_bench) and integration tests exercising the full SSZ round-trip through the router.

Confidence Score: 3/5

The JWT auth wiring, SSZ encode/decode plumbing, and middleware ordering are all correct, but the off-by-one in the blob hash count guard will cause any CL request with exactly 128 blob hashes to be rejected with a 413 instead of served normally.

The blob check_count uses >= where it should use >, meaning a full-sized request of 128 hashes is incorrectly rejected. Because blob data is needed by the CL for block validation, this could interrupt normal operation for clients that happen to request the maximum count. All other handler logic and the refactored engine core functions look correct.

crates/networking/rpc/engine_rest/handlers/blobs.rs -- the check_count guard and the optional Osaka check in require_osaka_tip

Important Files Changed

Filename Overview
crates/networking/rpc/engine_rest/handlers/blobs.rs New getBlobs V1/V2/V3 SSZ handlers; check_count uses >= instead of > which rejects exactly 128 hashes (the allowed maximum)
crates/networking/rpc/engine_rest/handlers/payloads.rs New SSZ newPayload/getPayload handlers V1-V6; logic mirrors JSON-RPC path with correct fork validation checks
crates/networking/rpc/engine_rest/handlers/forkchoice.rs New SSZ forkchoiceUpdated V1-V4 handlers; SSZ to internal type conversions and build_payload flow are correct
crates/networking/rpc/engine_rest/handlers/bodies.rs SSZ getPayloadBodies V1/V2 handlers; check_count correctly uses > for the bodies cap; BAL via spawn_blocking is correct
crates/networking/rpc/engine_rest/conversions.rs SSZ to internal type conversions; one misleading error message re BAL using MAX_BYTES_PER_TRANSACTION label
crates/networking/rpc/engine_rest/auth.rs JWT Bearer auth middleware shared with JSON-RPC; correct implementation
crates/networking/rpc/engine_rest/mod.rs Engine REST router construction; auth and observe middleware are correctly ordered (auth outermost)
crates/networking/rpc/engine_rest/error.rs EngineRestError and RpcErr to HTTP status mapping; clean implementation with proper status codes
crates/networking/rpc/engine/payload.rs Refactored to accept H256 instead of &ExecutionPayload for block hash validation; functions made pub(crate) for SSZ layer reuse
test/tests/rpc/engine_rest_tests.rs Integration tests covering JWT auth, capabilities, newPayload, getPayload, bodies, blobs, and forkchoice round-trips
crates/networking/rpc/engine_rest/observe.rs Metrics/warn middleware mapping HTTP method+path to JSON-RPC method labels; correctly placed inside auth layer
crates/networking/rpc/engine_rest/types/common.rs Fork-invariant SSZ types and constants; nullable encoding helpers are correct

Sequence Diagram

sequenceDiagram
    participant CL as Consensus Layer
    participant AM as JWT Auth Middleware
    participant OM as Observe Middleware
    participant RH as REST Handler (SSZ)
    participant EC as Engine Core (pub(crate))
    participant ST as Storage / Mempool

    CL->>AM: POST /engine/vN/... (Bearer JWT + SSZ body)
    AM->>AM: validate_jwt_authentication()
    alt Invalid JWT
        AM-->>CL: 401 Unauthorized (text/plain)
    end
    AM->>OM: forward request
    OM->>RH: forward request
    RH->>RH: Ssz extractor (Content-Type check + SszDecode)
    alt Bad Content-Type or malformed SSZ
        RH-->>CL: 400 Bad Request
    end
    RH->>EC: handle_new_payload / handle_forkchoice / get_payload
    EC->>ST: read/write block data
    ST-->>EC: Result
    EC-->>RH: PayloadStatus / PayloadBundle
    RH->>RH: SszEncode response
    RH-->>OM: Response (200 + application/octet-stream)
    OM->>OM: record_rpc_outcome / warn on non-2xx
    OM-->>CL: SSZ response
Loading
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/handlers/blobs.rs:24-31
Off-by-one in blob hash count guard: the condition `n >= MAX_BLOB_HASHES_REQUEST` rejects requests that contain exactly 128 hashes, even though 128 is the defined maximum. Compare with `bodies.rs` which correctly uses `n > MAX_PAYLOAD_BODIES_REQUEST`. A CL sending exactly 128 blob hashes will get a 413 instead of a 200.

```suggestion
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(())
}
```

### Issue 2 of 3
crates/networking/rpc/engine_rest/handlers/blobs.rs:85-98
**Silent pass-through when no genesis header exists**

`require_osaka_tip` returns `Ok(())` when `get_block_header(latest)` returns `None` (e.g. on a freshly-initialized in-memory store before any block is written). In that state `blobs_v2` and `blobs_v3` would be served without verifying Osaka activation, so a non-Osaka chain that hasn't yet written a genesis header would incorrectly serve these endpoints instead of returning 422.

### Issue 3 of 3
crates/networking/rpc/engine_rest/conversions.rs:184-186
The error message says "BAL RLP exceeds MAX_BYTES_PER_TRANSACTION" when the value being bounded is the Block Access List, not a transaction. The numeric cap itself is correct (the SSZ field type is `SszList<u8, MAX_BYTES_PER_TRANSACTION>`) but the label will confuse anyone reading logs or error responses.

```suggestion
    let bal_ssz = bal_bytes
        .try_into()
        .map_err(|_| ConversionError::internal("BAL RLP exceeds MAX_BYTES_PER_TRANSACTION (block_access_list field)"))?;
```

Reviews (1): Last reviewed commit: "feat(rpc): engine REST/SSZ API per execu..." | Re-trigger Greptile

Comment on lines +24 to +31
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(())
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Off-by-one in blob hash count guard: the condition n >= MAX_BLOB_HASHES_REQUEST rejects requests that contain exactly 128 hashes, even though 128 is the defined maximum. Compare with bodies.rs which correctly uses n > MAX_PAYLOAD_BODIES_REQUEST. A CL sending exactly 128 blob hashes will get a 413 instead of a 200.

Suggested change
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 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(())
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/engine_rest/handlers/blobs.rs
Line: 24-31

Comment:
Off-by-one in blob hash count guard: the condition `n >= MAX_BLOB_HASHES_REQUEST` rejects requests that contain exactly 128 hashes, even though 128 is the defined maximum. Compare with `bodies.rs` which correctly uses `n > MAX_PAYLOAD_BODIES_REQUEST`. A CL sending exactly 128 blob hashes will get a 413 instead of a 200.

```suggestion
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(())
}
```

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

Comment on lines +85 to +98
.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(())
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 Silent pass-through when no genesis header exists

require_osaka_tip returns Ok(()) when get_block_header(latest) returns None (e.g. on a freshly-initialized in-memory store before any block is written). In that state blobs_v2 and blobs_v3 would be served without verifying Osaka activation, so a non-Osaka chain that hasn't yet written a genesis header would incorrectly serve these endpoints instead of returning 422.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/engine_rest/handlers/blobs.rs
Line: 85-98

Comment:
**Silent pass-through when no genesis header exists**

`require_osaka_tip` returns `Ok(())` when `get_block_header(latest)` returns `None` (e.g. on a freshly-initialized in-memory store before any block is written). In that state `blobs_v2` and `blobs_v3` would be served without verifying Osaka activation, so a non-Osaka chain that hasn't yet written a genesis header would incorrectly serve these endpoints instead of returning 422.

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

Comment on lines +184 to +186
let bal_ssz = bal_bytes
.try_into()
.map_err(|_| ConversionError::internal("BAL RLP exceeds MAX_BYTES_PER_TRANSACTION"))?;
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 The error message says "BAL RLP exceeds MAX_BYTES_PER_TRANSACTION" when the value being bounded is the Block Access List, not a transaction. The numeric cap itself is correct (the SSZ field type is SszList<u8, MAX_BYTES_PER_TRANSACTION>) but the label will confuse anyone reading logs or error responses.

Suggested change
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("BAL RLP exceeds MAX_BYTES_PER_TRANSACTION (block_access_list field)"))?;
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/networking/rpc/engine_rest/conversions.rs
Line: 184-186

Comment:
The error message says "BAL RLP exceeds MAX_BYTES_PER_TRANSACTION" when the value being bounded is the Block Access List, not a transaction. The numeric cap itself is correct (the SSZ field type is `SszList<u8, MAX_BYTES_PER_TRANSACTION>`) but the label will confuse anyone reading logs or error responses.

```suggestion
    let bal_ssz = bal_bytes
        .try_into()
        .map_err(|_| ConversionError::internal("BAL RLP exceeds MAX_BYTES_PER_TRANSACTION (block_access_list field)"))?;
```

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

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

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


PR #6710 Review: feat(l1): engine REST/SSZ API

Overview

This PR implements the binary SSZ transport for the Engine API (execution-apis PR #764), covering 24 endpoints across newPayload V1–V5, getPayload V1–V6, bodies, blobs, forkchoice V1–V4, and capabilities. The architecture is clean: shared auth middleware, a dedicated Ssz<T> extractor, typed SszBody<T> responses, and a thin conversion layer between SSZ wire types and ethrex internals. The code is well-structured and the test suite is solid.


Bugs

1. Off-by-one in check_count for blobs — blobs.rs:25

// current (wrong):
if n >= MAX_BLOB_HASHES_REQUEST {

// should be:
if n > MAX_BLOB_HASHES_REQUEST {

MAX_BLOB_HASHES_REQUEST = 128. The SSZ list type SszList<Bytes32, MAX_BLOB_HASHES_REQUEST> decodes up to 128 elements successfully, but then check_count(128) fires >= 128 and returns HTTP 413. A valid 128-hash request is incorrectly rejected. Compare bodies.rs:75 which correctly uses >.


2. Silent Osaka check bypass in require_osaka_tipblobs.rs:88–98

if let Some(h) = header
    && !ctx.storage.get_chain_config().is_osaka_activated(h.timestamp)
{
    return Err(...);
}
Ok(())  // ← returns Ok if header is None

If get_block_header(latest) returns None (e.g. genesis node, corrupted state), the entire activation check is skipped and Ok(()) is returned. The V2/V3 blob endpoints would then proceed on a non-Osaka chain. The guard should fail closed:

match header {
    None => return Err(EngineRestError::internal("no latest block header")),
    Some(h) if !ctx.storage.get_chain_config().is_osaka_activated(h.timestamp) => {
        return Err(EngineRestError::unprocessable(...));
    }
    _ => {}
}

Correctness / Spec Conformance

3. blobs_v1 drops missing slots without positional nulls — blobs.rs:60–75

for t in tuples.into_iter().flatten() {  // silently drops None entries

A client requesting [A, B, C] where B is missing gets back [A, C] — there is no way to tell from the response which index was missing. GetBlobsV1Response uses a non-nullable flat list so null slots cannot be encoded. This is consistent with the type definition, but if execution-apis#764 requires position correspondence for V1 (as V2/V3 both address this gap in their own ways), this would be non-conformant. Worth explicitly verifying against the spec and adding a comment documenting the "index-blind" behavior.


4. get_payload_v5 passes block_access_list to from_block but uses V3 converter — payloads.rs:366–368

let json = JsonExecutionPayload::from_block(bundle.block, bundle.block_access_list);
let payload = match json_to_execution_payload_v3(&json) {  // V3 ignores block_access_list

The BAL is loaded into the JSON struct then silently discarded by json_to_execution_payload_v3. This is logically harmless (Osaka doesn't have BAL in the V3 payload wire format), but it's misleading. Pass None instead:

let json = JsonExecutionPayload::from_block(bundle.block, None);

5. new_payload_v4 missing upper-bound fork check — payloads.rs:133–138

new_payload_v4 checks !is_prague_activated(...) but not is_osaka_activated(...), unlike get_payload_v4 which explicitly rejects Osaka+ payloads (lines 309–311). An Amsterdam-era payload submitted to V4 would pass the activation check, then fail on block hash mismatch (since ExecutionPayloadV3 has no BAL field), yielding INVALID status rather than a clean HTTP 422. While the end result is correct, the inconsistency with get_payload_v4 will confuse operators and could mask a real misuse. Add the equivalent guard:

if chain_config.is_osaka_activated(block.header.timestamp) {
    return EngineError::unprocessable(&format!("{:?}", Fork::Osaka));
}

6. Pre-Shanghai block via new_payload_v2 gets wrong withdrawals_rootconversions.rs:364–367

ssz_payload_v2_to_block always sets withdrawals: Some(ssz_withdrawals_to_vec(&p.withdrawals)), which for an empty SSZ list produces withdrawals_root: Some(compute_withdrawals_root(&[])) instead of None. A pre-Shanghai block (where withdrawals_root must be absent from the header) will fail the block hash check rather than receiving a clean "pre-Shanghai" error. The existing pre_shanghai && has_withdrawals guard in new_payload_v2 only rejects non-empty withdrawals; it doesn't prevent the withdrawals_root from being wrongly set. For correctness, ssz_payload_v2_to_block should conditionally clear withdrawals based on the timestamp:

// or: callers should use ssz_payload_v1_to_block for pre-Shanghai paths
let withdrawals = if is_post_shanghai { Some(ssz_withdrawals_to_vec(...)) } else { None };

Minor / Nits

7. Misleading error string — conversions.rs:186

.map_err(|_| ConversionError::internal("BAL RLP exceeds MAX_BYTES_PER_TRANSACTION"))

The field is a BAL, not a transaction. The constant MAX_BYTES_PER_TRANSACTION is being reused as a size bound for the BAL bytes, but the message is confusing. Use something like "BAL RLP exceeds field byte limit" or define a dedicated constant.


8. validate_fork inconsistency between V3 and V4/V5 — payloads.rs:102, 133, 176

new_payload_v3 uses the exact fork check validate_fork(&block, Fork::Cancun, &ctx) (rejects anything not exactly Cancun), while new_payload_v4 and new_payload_v5 use inclusive is_prague_activated / is_amsterdam_activated. This inconsistency could lead to surprises when new forks are introduced. Consider adopting validate_fork uniformly or documenting why V4/V5 take the inclusive approach.


9. encoded_requests_to_ssz filters empty requests — conversions.rs:279

.filter(|r| !r.0.is_empty())

Empty request arrays are silently dropped before hashing. This is fine as long as the compute_requests_hash caller is consistent (which it appears to be), but the filtering is implicit. A comment explaining that empty request arrays are spec-invalid and omitted intentionally would prevent future confusion.


10. assemble_blocks_with_bal blocks the thread for up to 32 full EVM re-executions — bodies.rs:157–193

generate_bal_for_block re-executes each block in the EVM inside spawn_blocking. A bodies_by_hash_v2 or bodies_by_range_v2 request with 32 complex blocks could occupy a blocking thread for a significant duration. This is bounded by MAX_PAYLOAD_BODIES_REQUEST and constrained to the authrpc port, so it is not a security issue, but it may degrade responsiveness under load. Tracking or logging elapsed time here would help diagnose slow responses.


What Looks Good

  • JWT middleware is applied before the observe layer — unauthenticated requests don't pollute metrics.
  • SszOption<T> = SszList<T, 1> with explicit ssz_some/ssz_none helpers is idiomatic and clear.
  • check_ssz_content_type correctly uses starts_with to tolerate application/octet-stream; charset=utf-8.
  • ssz_payload_v4_to_block correctly hashes raw BAL bytes before RLP decode to preserve the wire-encoding for the block hash check.
  • decode_ssz error handling is appropriately descriptive without leaking internals.
  • The spawn_blocking in assemble_blocks_with_bal correctly handles task panics.
  • SSZ capability strings in SSZ_REST_CAPABILITIES match the axum route patterns exactly.
  • The u64_to_uint256_le / uint256_le_to_u64 round-trip is correct; the high-byte check in uint256_le_to_u64 properly rejects overflows.

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

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

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant