Skip to content

feat(engine): align SSZ-REST wire format with execution-apis#793 update#11998

Merged
LukaszRozmej merged 5 commits into
masterfrom
spec/refactor-ssz-2026-06-14
Jun 18, 2026
Merged

feat(engine): align SSZ-REST wire format with execution-apis#793 update#11998
LukaszRozmej merged 5 commits into
masterfrom
spec/refactor-ssz-2026-06-14

Conversation

@LukaszRozmej

@LukaszRozmej LukaszRozmej commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

Tracks the 2026-06-17 update to ethereum/execution-apis#793 (commit e509f20fadvertise all forks), which supersedes the 2026-06-14 commit 83151eeaengine-api: resolve feedback from implementers this PR originally targeted. Follow-up to #11887.

The 2026-06-17 spec update is largely documentation — per-fork worked examples for every endpoint, an explicit normative statement that the {fork} URL segment spans Paris through Amsterdam, and a clarification that BPO forks do not introduce their own {fork} segment (they reuse the named fork they layer on). Two items affect implementations:

  • BuiltPayloadShanghai resolved as {payload, block_value}. The spec author confirmed in 2026-06-17 that the Shanghai wrapper does NOT carry should_override_builder — the field was introduced in Cancun (engine_getPayloadV3), alongside blobs_bundle. Nethermind already shipped the Shanghai shape this way in the previous commit on this PR (V2 wire = {payload, block_value}, intentionally diverging from the as-written-but-flagged-as-typo 2026-06-14 text), so no further code change is needed.
  • BlobsBundleV{1,2} list caps renamed. The spec now uses MAX_BLOB_COMMITMENTS_PER_BLOCK (4096) in place of MAX_BLOBS_PER_PAYLOAD. Both resolve to the same numeric cap (4096), so the wire format is unchanged — only the spec naming was clarified. Nethermind already encodes with the 4096 cap.

Everything else the 2026-06-17 commit codifies — fork-segment range, BPO routing to the parent named fork, the 400 /engine-api/errors/unsupported-fork response — already matched the existing implementation. No wire or behavioral changes vs. the previous commit on this PR.

The earlier (2026-06-14) wire-incompatible changes this PR ships, unchanged from the previous commit:

  • Paris BuiltPayload is now wrapped. BuiltPayloadParis = { payload, block_value } per spec — was a bare ExecutionPayloadParis before. Added BuiltPayloadParisWire; GetPayloadDescriptorV1 routes to engine_getPayloadV2 to source block_value. The JSON-RPC V1 surface is unchanged — only the SSZ-REST /paris/payloads/{id} response is now wrapped.

  • Prague+ field order swapped. Per the spec's now-explicit normative ordering, execution_requests precedes should_override_builder on the wire — intentionally diverging from the legacy JSON-RPC engine_getPayload envelope order. Reordered GetPayloadResponseV{4,5,6}Wire.

  • /v3 reuses BlobV2Entry verbatim. The spec now explicitly forbids defining a separate BlobV3Entry wire type (to avoid the two drifting apart). Removed BlobV3EntryWire and GetBlobsV3ResponseWire; EncodeGetBlobsV3Response aliases the V2 encoder.

  • Trailing slashes no longer rejected. The spec dropped the Trailing slashes are forbidden sentence. SszMiddleware.TryRoute now strips a single trailing slash before routing, so /engine/v2/capabilities and /engine/v2/capabilities/ resolve identically.

Types of changes

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Description

The SSZ-REST wire format changes here are breaking against the surface introduced in #11887, but that surface is itself unreleased and the spec it targets is still draft. No JSON-RPC behaviour changes.

Testing

  • Yes
  • No

Notes on testing

  • SszCodecTests — V1 GetPayload assertions updated for the new wrapper offsets (payload now lives at offset 36 inside the outer container; outer carries the 4-byte payload offset + 32-byte block_value). V4 assertions adjusted for the swapped execution_requests / should_override_builder order (should-override-builder moves from offset 40 to 44, execution-requests offset from 41 to 40).
  • SszMiddlewareTests — Removed two trailing-slash 404 cases that are no longer applicable; added a positive Trailing_slash_on_capabilities_resolves_normally case. V1 routing test updated for the V2-source change.
  • All 1521 Nethermind.Merge.Plugin.Test tests pass; 107 SSZ-specific tests pass.

Hive engine test suite run against /engine/v2/... endpoints still required.

Documentation

  • Yes
  • No

🤖 Generated with Claude Code

Tracks the 2026-06-14 spec amendment that resolved implementer feedback
on execution-apis#793. Wire-incompatible changes that required code:

* Paris BuiltPayload is now a wrapped `{payload, block_value}` container
  rather than a bare ExecutionPayload. The SSZ V1 GetPayload descriptor
  routes to engine_getPayloadV2 to obtain block_value; the JSON-RPC V1
  surface is unchanged.

* Prague+ BuiltPayload now puts `execution_requests` before
  `should_override_builder`, diverging from the legacy JSON-RPC envelope
  order. Reordered V4/V5/V6 wire structs accordingly.

* /v3 reuses BlobV2Entry verbatim; the spec now explicitly forbids a
  separate BlobV3Entry wire type. Removed BlobV3EntryWire and
  GetBlobsV3ResponseWire; EncodeGetBlobsV3Response aliases V2.

* Trailing slashes are no longer rejected — the spec dropped the
  prohibition. SszMiddleware now strips a single trailing slash before
  routing.

Already aligned with the updated spec, no code change required:
BYTES_PER_CELL=2048, MAX_REQUEST_BODY_SIZE=64 MiB, bodies.max_count=32,
HTTP/1.1+HTTP/2 negotiation on the engine port, single-field container
wrappers on bodies/blobs requests/responses, range-query truncation at
head, custody_columns Optional on Amsterdam ForkchoiceUpdate.

Open spec question deferred: BuiltPayloadShanghai literally includes
`should_override_builder` per the spec text, but this is being
questioned on execution-apis#793 as a possible typo. Left V2 wire
unchanged pending resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 6m 18s —— View job


PR Review: feat(engine): align SSZ-REST wire format with execution-apis#793 update

Tasks:

  • Gather context and understand the PR
  • Read relevant rules files
  • Read changed files
  • Analyze for correctness, edge cases, security, and performance
  • Post inline review comments
  • Post final summary

Summary

The PR faithfully tracks the 2026-06-14 amendment to execution-apis#793. Each of the four wire-breaking changes is correctly implemented and tested:

  • Paris BuiltPayload wrapper (GetPayloadResponseV1Wire + GetPayloadDescriptorV1engine_getPayloadV2) — correct. The routing test GetPayload_routes_to_correct_handler_with_no_store_header confirms engine_getPayloadV1 is never called and engine_getPayloadV2 handles both V1 and V2 SSZ requests.
  • Prague+ field order (ExecutionRequests before ShouldOverrideBuilder in V4/V5/V6 wire types) — correct. The struct declaration order drives SSZ serialization; the V4 offset test verifies execution_requests @ 40, should_override_builder @ 44 against the 45-byte fixed section.
  • /v3 reuses BlobV2Entry verbatim — correct. EncodeGetBlobsV3Response aliases EncodeGetBlobsV2Response; the removed BlobV3EntryWire/GetBlobsV3ResponseWire types were the only deletions.
  • Trailing slash — partially correct; see Medium finding below.

The open Shanghai/BuiltPayloadShanghai question is appropriately deferred.


Findings

Medium

Trailing slash fix is incomplete for fork-scoped {id} paths — see inline comment at SszMiddleware.cs:258.

TryRoute trims the trailing slash from the local span but computes pathSegment from the original path string. For any fork-scoped endpoint that carries a path extra (e.g. GET /engine/v2/paris/payloads/0x0102030405060708/), the trailing slash leaks into extra, causing the payload-ID hex parser to reject it with 400. Only simple unscoped endpoints (/capabilities/, /identity/) are correctly handled by this change.

The spec says trailing slashes are no longer forbidden, so this is spec non-conformant. In practice, well-implemented CLs rarely add a trailing slash to an ID-bearing URL, but the inconsistency is worth fixing. Simplest fix: strip the trailing slash from pathSegment before returning it from TryRoute.

Low

Initializer order in V4/V5/V6 encode methods (SszCodec.cs): The struct declares ExecutionRequests before ShouldOverrideBuilder, but the object initializers set them in the opposite order. No functional impact (SSZ codec uses the struct field declaration order), but it makes it harder to visually verify spec compliance. This pre-exists this PR, so not blocking.

No dedicated offset tests for V5/V6 wire types: EncodeGetPayloadV4Response_all_static_fields_land_at_spec_defined_offsets verifies the swapped field order for V4. V5/V6 share the same pattern, so they're unlikely to be wrong, but parallel tests would give stronger confidence.


Verdict

Not mergeable due to the Medium finding (incomplete trailing slash handling). The fix is small and isolated to TryRoute. Everything else in the PR is correct and well-tested.

return false;
// execution-apis#793 no longer forbids trailing slashes — collapse them so the route resolves.
if (span.Length > EnginePrefix.Length && span[^1] == '/')
span = span[..^1];

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.

Medium – trailing slash handling is incomplete for fork-scoped {id} paths.

The span is trimmed here, but pathSegment is subsequently computed from the original path string, not from the trimmed span. This means the trailing slash survives into extra for any fork-scoped endpoint that carries a path extra, e.g.:

GET /engine/v2/paris/payloads/0x0102030405060708/

Trace for that URL:

  • span after trim → paris/payloads/0x0102030405060708
  • offset → 18 (unchanged)
  • pathSegment = path.AsMemory(18)"payloads/0x0102030405060708/" (trailing slash from original path)
  • TryResolveHandler: extraMem = "0x0102030405060708/" → ID parsing fails → 400

Only simple endpoints without a path extra (i.e. /capabilities/, /identity/) are actually fixed by this change. The spec says trailing slashes are no longer forbidden, so this is spec non-conformant for ID-bearing GET endpoints.

Simplest fix — strip from the pathSegment before returning it:

ReadOnlyMemory<char> ps = path.AsMemory(offset);
if (!ps.IsEmpty && ps.Span[^1] == '/')
    ps = ps[..^1];
pathSegment = ps;

Or normalize the path string at the top of the method and work from that everywhere.

LukaszRozmej and others added 3 commits June 15, 2026 13:26
Drops the V1↔V2 naming confusion: the Paris SSZ surface wraps a
BuiltPayloadParis container, so renames

  GetPayloadResponseV1Wire   -> BuiltPayloadParisWire
  EncodeGetPayloadV1Response -> EncodeBuiltPayloadParis

The descriptor stays GetPayloadDescriptorV1 (it identifies the Engine
API routing version) but now calls the encoder by its spec-level name.
Test method names follow.

Also strips inline `execution-apis#793` references that read more as
PR-changelog than code rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The descriptor now returns GetPayloadV2Result (V1 SSZ surface wraps
{payload, block_value}); the benchmark host registration still passed
ExecutionPayload as TResult, breaking the debug Benchmarks build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…span

TryRoute trimmed the slash from the local span used for matching but
still sliced `pathSegment` from the original `path`, so for fork-scoped
endpoints carrying a path id (e.g. /paris/payloads/0x.../) the slash
leaked into `extra` and the payload-ID hex parser rejected it with 400.
Slice every `path.AsMemory(...)` against the trimmed length so the route
is fully canonicalised before dispatch.

Adds a regression test on the V1 GetPayload route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukaszRozmej

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 4m 13s —— View job


PR Re-Review: feat(engine): align SSZ-REST wire format with execution-apis#793 update

Tasks:

  • Gather context
  • Read changed files
  • Check if previous Medium finding (trailing slash) was fixed
  • Review all changed files for correctness
  • Post findings

Summary

The previous Medium finding (trailing slash leaking into extra for ID-bearing paths) has been addressed by commit 4270425. All four spec-mandated wire changes are correctly implemented. The PR is mergeable.


Previous Medium Finding — FIXED ✅

SszMiddleware.cs trailing slash (was: Medium, now resolved)

The old approach (if (span.EndsWith("/")) return false) has been replaced by introducing pathLen and using it to bound all subsequent path.AsMemory(…) slices:

int pathLen = path.Length;
if (pathLen > EnginePrefix.Length && path[pathLen - 1] == '/')
{
    pathLen--;
    span = span[..pathLen];
}
// ...
pathSegment = path.AsMemory(offset, pathLen - offset);  // correctly excludes trailing slash

Both the unscoped (identity, capabilities) and the fork-scoped (with {id}) cases use pathLen when constructing pathSegment, so the trailing slash never reaches extra. The new test Trailing_slash_on_fork_scoped_path_with_id_does_not_leak_into_extra verifies the exact scenario from the previous finding (GET /paris/payloads/0x0102030405060708/ → 200 OK).

IsDiagnosticGetPath also correctly identifies /engine/v2/capabilities/ as a diagnostic path, allowing the request past the Accept-header gate even without application/octet-stream.


Remaining Findings

Low

Benchmark round-trip URL is stale (GetPayloadV3SerializationBenchmarks.cs:128)

The SszRoundTrip benchmark sends to /engine/v3/payloads/{PayloadIdHex}, but SszMiddleware only handles /engine/v2/… paths — that request resolves to a 404 and EnsureSuccessStatusCode() would throw. This is pre-existing from the #11887 migration to /engine/v2/, not introduced here. The fix(benchmark) commit in this branch only updated the TResult type parameter, not the URL. It should be /engine/v2/{ParisUrl}/payloads/{PayloadIdHex} (or the equivalent fork-scoped path). Not blocking since benchmarks aren't run in CI, but worth a follow-up fix.

Initializer order in EncodeGetPayloadV4/V5/V6Response (pre-existing)

The object initializers list ShouldOverrideBuilder before ExecutionRequests, opposite to the struct declaration order. SSZ serialization follows the struct declaration order, so there is no functional impact, but it makes spec compliance harder to verify at a glance.

No offset tests for V5/V6 (pre-existing)

EncodeGetPayloadV4Response_all_static_fields_land_at_spec_defined_offsets verifies the field reorder for V4; V5/V6 follow the same pattern but lack equivalent tests.


Verdict

Mergeable. The Medium blocking finding from the previous review is correctly fixed. Remaining issues are Low/pre-existing and do not warrant blocking merge.

Address the review's Low findings:

* `EncodeGetPayload{V4,V5,V6}Response` initializers now list
  `ExecutionRequests` before `ShouldOverrideBuilder`, matching the
  struct field declaration order. SSZ serialisation order is governed
  by the declaration, not the initializer, so this is purely a
  readability fix — but it removes the visual mismatch when verifying
  spec compliance.

* Added `EncodeGetPayloadV{5,6}Response_all_static_fields_land_at_spec_defined_offsets`
  paralleling the existing V4 offset test. Shared assertion helper
  `AssertPragueBuiltPayloadOffsets` since the 45-byte fixed section is
  identical across V4/V5/V6 (BlobsBundleV{1,2} and ExecutionPayloadV{3,4}
  are all variable-size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukaszRozmej

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 2m 19s —— View job


PR Re-Review: feat(engine): align SSZ-REST wire format with execution-apis#793 update

Tasks:

  • Gather context
  • Read changed files
  • Analyze for correctness, spec compliance, and edge cases
  • Post findings

Summary

All four wire-breaking changes mandated by the 2026-06-14 amendment to execution-apis#793 are correctly implemented and tested. The trailing-slash Medium finding from the first review is fixed. The V5/V6 offset-test gap noted as Low in the second review has also been filled.


Findings

All previous Medium findings — FIXED ✅

Trailing slash fix (commit 4270425): TryRoute now introduces pathLen and uses it to bound every path.AsMemory(…) slice. Both the unscoped case (/capabilities/, /identity/) and the fork-scoped ID-bearing case (/paris/payloads/0x…/) correctly exclude the trailing slash from pathSegment. IsDiagnosticGetPath correctly passes /capabilities/ through the Accept-header gate (checks span[prefix.Length] == '/'). Both new tests — Trailing_slash_on_capabilities_resolves_normally and Trailing_slash_on_fork_scoped_path_with_id_does_not_leak_into_extra — cover the exact scenarios from the prior finding.


Spec correctness checklist

Change Wire types Tests Verdict
Paris wrapper (BuiltPayloadParisWire) EncodeBuiltPayloadParis_all_static_fields_land_at_spec_defined_offsets, routing test confirms engine_getPayloadV2 called, never V1
Prague+ field order (ExecutionRequests before ShouldOverrideBuilder) ✅ V4/V5/V6 AssertPragueBuiltPayloadOffsets run for all three versions: execution_requests @ 40, should_override_builder @ 44, fixed section = 45
V3 reuses BlobV2Entry verbatim EncodeGetBlobsV3Response aliases V2 EncodeGetBlobsV3Response_null_vs_present_entry_differ_in_size
Trailing slashes no longer rejected Two new positive cases + removed two old 404 negative cases

Low (pre-existing, not blocking)

Benchmark round-trip URL (GetPayloadV3SerializationBenchmarks.cs:128): SszRoundTrip sends to /engine/v3/payloads/{PayloadIdHex} — a path SszMiddleware does not handle (/engine/v2/… only), so EnsureSuccessStatusCode() would throw. The only change in this file was fixing the GetPayloadDescriptorV1 generic type parameter (ExecutionPayloadGetPayloadV2Result); the stale URL predates this PR. Not in CI so not blocking, but worth a follow-up fix: Fix this →

Initializer order in EncodeGetPayloadV4/V5/V6Response (SszCodec.cs): Object initializers set ShouldOverrideBuilder before ExecutionRequests, opposite to the struct field declaration order (which drives SSZ serialisation). No functional impact. Pre-existing.


Verdict

Mergeable. No Critical, High, or Medium findings. All spec changes from the 2026-06-14 amendment are correctly implemented, the trailing-slash Medium finding is resolved, and the previously-noted V5/V6 offset-test gap has been filled.

@LukaszRozmej LukaszRozmej merged commit e15db12 into master Jun 18, 2026
560 checks passed
@LukaszRozmej LukaszRozmej deleted the spec/refactor-ssz-2026-06-14 branch June 18, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants