Skip to content

feat(merge): add SSZ-REST transport for Engine-API#11301

Open
Dyslex7c wants to merge 72 commits intoNethermindEth:masterfrom
Dyslex7c:feat/ssz-engine-api
Open

feat(merge): add SSZ-REST transport for Engine-API#11301
Dyslex7c wants to merge 72 commits intoNethermindEth:masterfrom
Dyslex7c:feat/ssz-engine-api

Conversation

@Dyslex7c
Copy link
Copy Markdown

@Dyslex7c Dyslex7c commented Apr 22, 2026

Replaces #10728

Summary

Implements the binary SSZ-REST Engine API transport per ethereum/execution-apis#764. The binary transport coexists with the existing JSON-RPC endpoint on the same authenticated port (8551) and is negotiated via engine_exchangeCapabilities — no breaking changes to existing behaviour.

Architecture

  • Wire types (SszWireTypes.cs) — [SszContainer] definitions for every Engine-API structure across Paris → Amsterdam: payload V1–V4, forkchoice, blob bundles V1/V2, BlobAndProof V1/V2, payload bodies V1/V2, capabilities, client version, transition configuration. Driven by the existing Nethermind.Serialization.SszGenerator source generator.

  • Codec (SszCodec.cs) — Stateless bridge between domain objects (ExecutionPayload, ForkchoiceStateV1, …) and wire types. Encoders write straight into an IBufferWriter<byte>, returning a byte count instead of a pooled buffer. Decoders accept ReadOnlySequence<byte>. Status-enum mapping, nullable-list (List[T, 1]) handling, little-endian UInt256 layout for BaseFeePerGas / TerminalTotalDifficulty.

  • Middleware (SszMiddleware.cs) — ASP.NET Core middleware on the authenticated Engine-API port. Triggers on Content-Type: application/octet-stream (POST) or Accept: application/octet-stream (GET). Performs JWT auth, route resolution (/engine/v{N}/{resource}[/{extra}]), 16 MiB body-size enforcement, and dispatches to the appropriate versioned handler. Non-SSZ requests pass through to JSON-RPC unchanged. Error-code → HTTP-status mapping (404, 409, 413, 422, 500) is per-spec.

  • Handler dispatch — Generic versioned handlers parameterized by per-version descriptor structs (INewPayloadVersion<TWire>, IForkchoiceUpdatedVersion<TWire>, IGetPayloadVersion<TResult>, IPayloadBodiesByHash/RangeVersion<TResult>, IGetBlobsV2Version). Adding a new endpoint version is one descriptor struct + one DI registration — no version switches anywhere. Every descriptor calls into IEngineRpcModule.engine_*V{N}, so JSON-RPC and SSZ-REST share the same engine-module entry points.

  • Capabilities provider (EngineRpcCapabilitiesProvider.cs) — Single Build method emits both engine_*V{N} JSON-RPC method names and SSZ-REST path strings, each gated by the same fork checks via a shared helper. Capability options live in [Flags] enum RpcCapabilityOptions : byte { None, Enabled, WarnIfMissing } with IsEnabled() / ShouldWarnIfMissing() extensions.

  • DI bridge (SszMiddlewareConfigurer.cs) — IJsonRpcServiceConfigurer that bridges Autofac (where Engine-API handlers live) to ASP.NET's MS DI container so Kestrel can resolve SszMiddleware and its handlers without duplicating registration.

Performance

  • Encode side — Encoders write directly into ctx.Response.BodyWriter (a PipeWriter) via GetSpan / Encode / Advance / set ContentLength from the returned byte count / FlushAsync. No intermediate ArrayPoolSpan<byte> rent + copy, no MemoryStream rebuild. Connection is aborted if encode throws mid-stream so the CL never sees a 500 with garbled binary.

  • Decode side — Middleware reads via PipeReader.ReadAtLeastAsync(ContentLength) and hands the resulting ReadOnlySequence<byte> straight to the generator-emitted Decode(ReadOnlySequence<byte>, out T) overload. Single-segment input is zero-copy; multi-segment consolidates once via ArrayPool then dispatches to the existing span decoder. Eliminates the MemoryStream + ToArray intermediate copy that was dominant on blob-bearing payloads. SszLib gains primitive sequence overloads (bool/byte/ushort/uint/int/ulong/long/UInt128/UInt256/BitArray) as infrastructure for a future fully-recursive sequence-aware decoder.

  • RoutingFrozenDictionary with AlternateLookup<ReadOnlySpan<char>> for zero-alloc resource lookup. Path extra (e.g. payloadId) threaded as ReadOnlyMemory<char> end-to-end. Span<char>-based hex parse for payload IDs avoids substring allocation.

  • Wire-encoder dedupMerkleizeRefType{Vector,List,ProgressiveList} collapsed into one core helper; small chunks stack-allocate (≤4 chunks), larger payloads use ArrayPool. Single-allocation Blob V1/V2 encoders (count nulls first, allocate exact size).

Spec conformance

Aligned with ethereum/execution-apis#764:

  • application/octet-stream content/accept negotiation
  • 400 Bad Request for malformed SSZ bodies
  • 404 Not Found for unknown payload IDs / endpoints
  • 409 Conflict for InvalidForkchoiceState
  • 413 Payload Too Large past the 16 MiB limit
  • 422 Unprocessable Entity for InvalidPayloadAttributes
  • 500 Internal Server Error for InternalError

Follow-up

Issue #11525 tracks SSZ generator support for InlineArray<T, N> to remove the byte[] allocation in Bytes32 / Hash256 value paths.

What types of changes does your code introduce?

  • 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

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

  • SszCodecTests (29 tests) — Encode/decode roundtrips for payload status, forkchoice response, transition configuration, capabilities, blob requests, payload bodies V1/V2, and getPayload responses V1–V6. Spec-defined byte-offset assertions for fixed sections of ExecutionPayload, PayloadStatus, ForkchoiceUpdatedResponse, GetPayloadResponseV3, GetPayloadResponseV4, PayloadAttributesV3 (regression for the parent_beacon_block_root placement bug).

  • SszMiddlewareTests (26 tests) — Drive SszMiddleware through DefaultHttpContext (no Kestrel required). Verify handler dispatch per version, route fallback (versioned → version-agnostic), Cache-Control: no-store on getPayload responses, status-code mapping, capability advertising, and 401/404/413/422 error paths.

  • SszMultiSegmentDecodeTests (26 tests) — Cover the multi-segment ReadOnlySequence<byte> branch that production traffic from Kestrel actually hits (4 KB pooled blocks). Builds explicit multi-segment sequences via ReadOnlySequenceSegment<byte> chains at boundary sizes 1/3/7/4096 across five wire types (TransitionConfig, Capabilities, GetBlobsRequest, GetPayloadBodiesByRange, NewPayloadV3RequestWire) and the SszLib primitive boundary path (uint/ulong/UInt256).

@LukaszRozmej
Copy link
Copy Markdown
Member

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 22, 2026

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


PR Review: feat(merge): add SSZ-REST transport for Engine-API

Tasks

  • Gather context and read rule files
  • Read all changed files
  • Analyze for correctness, security, performance
  • Post inline comments for specific issues
  • Write final review summary

Summary

Good foundation — the middleware architecture, route dispatch, and SSZ codec coverage are well-structured. Several issues need to be addressed before merge:


High

1. Memory exhaustion / DoS in ReadBodyAsync (SszMiddleware.cs:458–476)

MemoryStream grows without bound before the second size check. When Content-Length is absent (e.g. chunked transfer encoding), an authenticated client can stream multiple gigabytes of data, causing the node to OOM before the 16 MiB gate fires. This is the authenticated port, but a stolen or leaked JWT, or a misconfigured CL client, would be sufficient.

// Current – dangerous with chunked bodies
using MemoryStream ms = new();
await ctx.Request.Body.CopyToAsync(ms);   // unbounded growth here
if (ms.Length > MaxBodySize) { throw}

Fix: pre-allocate the stream at the limit and abort early if the data overflows, or use Stream.CopyToAsync with a wrapper that stops at the byte limit:

using MemoryStream ms = new(MaxBodySize + 1);
byte[] buffer = ArrayPool<byte>.Shared.Rent(81920);
try {
    int read;
    while ((read = await ctx.Request.Body.ReadAsync(buffer, ctx.RequestAborted)) > 0) {
        if (ms.Length + read > MaxBodySize)
            throw new InvalidOperationException($"Request body exceeds {MaxBodySize} bytes");
        ms.Write(buffer, 0, read);
    }
} finally { ArrayPool<byte>.Shared.Return(buffer); }

2. int.Parse overflow on version number (SszMiddleware.cs:107)

The regex captures \d+ with no length cap. A URL like /engine/v99999999999/payloads causes int.Parse to throw OverflowException, which is caught by the outer catch and returns a 500 with the exception message. Should return 404 instead.

// Current
int version = int.Parse(match.Groups["version"].Value);  // OverflowException → 500

// Fix
if (!int.TryParse(match.Groups["version"].Value, out int version))
{
    await WriteErrorAsync(ctx, StatusCodes.Status404NotFound, $"Invalid version in path: {path}");
    return;
}

Medium

3. Internal exception messages leaked to callers (SszMiddleware.cs:130–132)

catch (Exception ex)
{
    if (_logger.IsError) _logger.Error($"SSZ-REST handler error for {path}", ex);
    await WriteErrorAsync(ctx, StatusCodes.Status500InternalServerError, ex.Message);
}

ex.Message for SSZ decode failures or null-ref exceptions can contain file paths, type names, or internal state. The spec only requires an HTTP 500 with an opaque error; return a generic message and keep the detail in the log:

await WriteErrorAsync(ctx, StatusCodes.Status500InternalServerError, "Internal server error");

4. Missing CancellationToken in async I/O (SszMiddleware.cs:468, 413)

Per robustness.md: "Async methods that perform I/O or network calls must accept a CancellationToken". Neither CopyToAsync (body read) nor WriteAsync (response write) is passed ctx.RequestAborted:

await ctx.Request.Body.CopyToAsync(ms);          // should use ctx.RequestAborted
await ctx.Response.Body.WriteAsync(data);         // should use ctx.RequestAborted

A client disconnect leaves the body-read in flight indefinitely.

5. No graceful shutdown check

The JSON-RPC fast lane in Startup.cs (line 298) checks _jsonRpcProcessor.ProcessExit.IsCancellationRequested before accepting work. SszMiddleware has no equivalent check, so during node shutdown the SSZ endpoint keeps processing requests while the rest of the pipeline is draining.

6. newPayloadV5 vs getPayloadV5 format asymmetry — needs spec reference

DecodeNewPayloadRequest for version >= 5 decodes an Amsterdam-format ExecutionPayloadV4Wire (with BlockAccessList/SlotNumber), but HandleGetPayloadAsync for version == 5 encodes a Pectra-format ExecutionPayloadV3Wire (no Amsterdam fields). A CL client that calls POST /engine/v5/payloads with an Amsterdam payload would then expect GET /engine/v5/payloads/{id} to return the same Amsterdam format — and would receive a Pectra-format response instead, which would fail deserialization.

Please add a comment citing the relevant section of ethereum/execution-apis#764 that defines this split, or fix the mapping so the same version number is consistent in both directions. Without the spec reference it's impossible for reviewers to verify this is intentional.


Low

7. Indentation inconsistency in SszCodec.cs (lines 495–656)

Private methods PayloadV3FromV1Wire, PayloadV3FromV2Wire, PayloadV3FromV3Wire, PayloadToV1WirePayloadV4FromV4Wire are indented at column 0 instead of the class-level 4 spaces. This is inconsistent with the rest of the file and breaks editorconfig formatting.

8. Duplicated pattern match for ExecutionPayloadV3 (SszCodec.cs:607–609)

BlobGasUsed   = ep is ExecutionPayloadV3 v3  ? v3.BlobGasUsed   ?? 0 : 0,
ExcessBlobGas = ep is ExecutionPayloadV3 v3b ? v3b.ExcessBlobGas ?? 0 : 0   // separate match, different var name

Extract once: ExecutionPayloadV3? v3 = ep as ExecutionPayloadV3;

9. PUT treated as a potential SSZ request (SszMiddleware.cs:444)

IsSszRequest checks is "POST" or "PUT". PUT is not defined by the Engine API SSZ spec; this is dead code that silently accepts any PUT with application/octet-stream content-type.

10. User-supplied path echoed in 404 error bodies (SszMiddleware.cs:103)

await WriteErrorAsync(ctx, StatusCodes.Status404NotFound, $"Unknown SSZ endpoint: {path}");

Harmless in plain text (text/plain), but good practice not to reflect raw request data in error bodies.

11. SszCapabilityName list byte limit (SszWireTypes.cs:312)

[SszList(64)] means the Name field supports up to 64 bytes. Capability strings like "POST /engine/v1/payloads/bodies/by-range" are 42 bytes; this is fine today, but a future long name could silently truncate. Consider bumping to 128 to match room for growth, and add a comment referencing the spec limit.


Code coverage gaps

  • No test for authentication failure path (401)
  • No test for oversized body (413)
  • No test for unknown path (404)
  • No test for malformed SSZ body (should return 500 without panicking)
  • No test for engine_getBlobsV1 with a null entry in the result (the 204 path)
  • No round-trip test for newPayloadV4/newPayloadV5 decode → encode

Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs Outdated
@LukaszRozmej LukaszRozmej marked this pull request as draft April 22, 2026 21:57
@LukaszRozmej
Copy link
Copy Markdown
Member

@claude re-review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 24, 2026

Claude finished @LukaszRozmej's task in 8m 0s —— View job


PR Re-Review: feat(merge): add SSZ-REST transport for Engine-API

Tasks

  • Load rule files
  • Read changed files
  • Analyze correctness, security, performance
  • Post inline comments
  • Write final review summary

What Was Fixed Since the Last Review

All six High issues from the previous review are resolved:

# Issue Status
1 Memory exhaustion in ReadBodyAsync (unbounded MemoryStream) ✅ Fixed — chunked read with ArrayPool enforcing limit during read
2 int.Parse overflow → 500 on long version numbers ✅ Fixed — int.TryParse
3 Internal exception messages leaked to callers ✅ Fixed — returns "Internal server error"
4 Missing CancellationToken in async I/O ✅ Fixed — ctx.RequestAborted passed to ReadAsync and WriteAsync
5 No graceful shutdown guard ✅ Fixed — _processExitToken.IsCancellationRequested check added
6 newPayloadV5/getPayloadV5 asymmetry undocumented ✅ Addressed — spec comment at lines 77–81

All five test-coverage gaps from the previous review are now covered: 401 auth failure, 413 oversized body, 404 unknown path, malformed SSZ → 500, 204 null-blob path.

PUT as fake SSZ method removed, indentation fixed, duplicate V3 pattern match consolidated.


Remaining Issues

High

1. PayloadAttributesWire is version-unaware — V1/V2/V3 forkchoice will fail SSZ decode (inline comment)

PayloadAttributesWire always encodes all six fields including SlotNumber (8 bytes). The SSZ-REST spec defines version-specific PayloadAttributes types:

  • V1 = timestamp + prevRandao + feeRecipient — 60-byte fixed, no variable section
  • V2 = V1 + withdrawals
  • V3 = V2 + parentBeaconBlockRoot
  • V4 = V3 + slotNumber

When a CL sends engine_forkchoiceUpdatedV1 with payload attributes, it sends the V1-format bytes (60 bytes fixed + variable offset header). The decoder, however, parses a struct that includes SlotNumber as a fixed field, producing a layout 8 bytes larger. The decode silently corrupts or throws.

The fix is to dispatch to version-specific payload-attributes wire types in SszCodec.DecodeForkchoiceUpdatedRequest.

2. ExecutionPayload SSZ includes SszWithdrawals, breaking V1 newPayload format (inline comment)

NewPayloadV1RequestWire embeds ExecutionPayload whose generated SSZ codec includes SszWithdrawals (a variable-length list with a 4-byte offset in the fixed header). Per the spec ExecutionPayloadV1 is fully fixed-size — no withdrawals field. A CL sending a spec-compliant V1 payload produces fewer bytes than this decoder expects, causing parse corruption or failure. Nulling Withdrawals after decode only affects the domain object; it does not fix the wire layout.

A separate V1 wire type (or suppressing SszWithdrawals from the V1 codec path) is required.


Medium

3. EncodeGetPayloadV1Response temporarily mutates a shared domain object (inline comment)

The method sets ep.Withdrawals = null, encodes, then restores. If the same ExecutionPayload is ever read concurrently (e.g. payload cache, background metric flush), a race will produce a corrupted SSZ body. Currently safe because handlers produce per-request objects, but this needs a code comment explaining the safety invariant so it isn't broken by future caching.

4. SszCapabilityName 64-byte limit (inline comment)

Still 64 bytes; unaddressed from the prior review. 128 bytes recommended with a spec citation.


Low

5. LINQ .ToArray() on IReadOnlyList<byte[]> (inline comment)

b.Transactions.ToArray() and b.Withdrawals?.ToArray() in EncodePayloadBodiesV1/V2Response use the LINQ extension, not a collection method. Per coding-style.md: "No LINQ when a simple for/foreach works."

6. Leftover commented-out using directive (inline comment)

// using Nethermind.Core.ExecutionRequest; at ExecutionPayload.cs:14.

7. User-supplied path echoed in 404 error body (SszMiddleware.cs:217)

$"Unknown SSZ endpoint: {path}" reflects the raw request path. Harmless in text/plain over the local authenticated port, but good hygiene to strip it.


Summary

Good progress — all previous High findings are resolved, and the new tests are a meaningful improvement. The two new High issues relate to SSZ wire-format mismatches for older versioned endpoints (PayloadAttributesWire has extra bytes for V1–V3, and ExecutionPayload has a withdrawals field that V1 doesn't). These will cause protocol-level decode failures when CL clients use the V1 SSZ endpoints. The Medium object-mutation issue should at minimum get a safety comment. Blocking on High items.

Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszCodec.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/Data/ExecutionPayload.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs Outdated
@LukaszRozmej
Copy link
Copy Markdown
Member

@claude re-review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Claude finished @LukaszRozmej's task in 9m 17s —— View job


PR Re-Review: feat(merge): add SSZ-REST transport for Engine-API

Tasks

  • Load rule files
  • Read changed files
  • Check previous High/Medium findings
  • Analyze new code for correctness, security, performance
  • Post inline comments
  • Write final review summary

What Was Fixed Since the Last Review

All findings from the previous re-review are addressed:

# Issue Status
High 1 PayloadAttributesWire version-unaware (V1/V2/V3 forkchoice) ✅ Fixed — separate PayloadAttributesV1Wire, V2Wire, V3Wire types with correct dispatch in SszCodec.DecodeForkchoiceUpdatedRequest
High 2 ExecutionPayloadV1 SSZ included withdrawals field ✅ Fixed — new ExecutionPayloadV1Ssz/ExecutionPayloadSsz/V3Ssz/V4Ssz wrapper classes in SszExecutionPayload.cs
Medium EncodeGetPayloadV1Response mutating a shared domain object ✅ Fixed — uses ExecutionPayloadV1Ssz.Wrap(ep)
Medium SszCapabilityName 64-byte limit ✅ Fixed — bumped to 128 bytes
Low LINQ .ToArray() on IReadOnlyList ✅ Fixed — collection expressions [.. b.Transactions]
Low Commented-out using in ExecutionPayload.cs ✅ Fixed
Low Raw request path echoed in 404 error body ✅ Fixed

Remaining Issues

Critical

1. Field ordering mismatch in SSZ payload wrappers breaks wire compatibility (inline comment)

ExecutionPayloadV1Ssz and ExecutionPayloadSsz (and by inheritance, V3Ssz, V4Ssz) declare BaseFeePerGas and BlockHash before ExtraData. The SSZ source generator uses property declaration order, so it emits the wrong fixed-section layout.

The Ethereum consensus spec (Bellatrix+) specifies:

...timestamp(8) | offset:extra_data(4) | base_fee_per_gas(32) | block_hash(32) | offset:transactions(4)...

The code produces:

...timestamp(8) | base_fee_per_gas(32) | block_hash(32) | offset:extra_data(4) | offset:transactions(4)...

baseFeePerGas lands at byte 436 in the spec but byte 500 in the code. Any spec-compliant CL client sending a newPayload request will have its baseFeePerGas decoded from the wrong bytes, and every field from extraData onwards will be misread. This affects all versioned payload types V1–V4.

Fix: in both ExecutionPayloadV1Ssz and ExecutionPayloadSsz, move the ExtraData property declaration to appear before BaseFeePerGas and BlockHash.

Medium

2. IJsonRpcProcessor not registered as interface in MS DI (inline comment)

JsonRpcRunner calls s.AddSingleton(_jsonRpcProcessor) where the field is typed JsonRpcProcessor (concrete class). This registers the service under typeof(JsonRpcProcessor). SszMiddleware's constructor requires IJsonRpcProcessor. SszMiddlewareConfigurer does not bridge this interface. When ASP.NET Core builds the middleware pipeline, it will fail to resolve IJsonRpcProcessor and throw at startup. Fix: add s.AddSingleton<IJsonRpcProcessor>(_jsonRpcProcessor) in JsonRpcRunner, or bridge the interface in SszMiddlewareConfigurer.

3. Round-trip tests cannot catch field ordering bugs (inline comment)

Every codec test encodes then decodes the same struct, so they pass even when the wire layout is wrong (because both sides use the same wrong layout). The Critical field ordering bug above was introduced by c4765b0 and is not caught by any existing test. Add at least one test that verifies known fields land at spec-defined byte offsets (e.g. baseFeePerGas at offset 440 for V1).


Summary

The SszExecutionPayload.cs refactor introduced in commit c4765b0 resolved two previous High findings cleanly, but introduced a new Critical field-ordering bug in all four payload wrapper types. No CL client that sends spec-compliant SSZ execution payloads would interoperate correctly until this is fixed. The DI wiring for IJsonRpcProcessor also needs to be verified before the middleware can load in production. Blocking on Critical item 1 and Medium item 2.

Comment thread src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszExecutionPayload.cs Outdated
Comment thread src/Nethermind/Nethermind.Merge.Plugin.Test/SszRest/SszCodecTests.cs Outdated
LukaszRozmej and others added 9 commits May 8, 2026 14:40
…ng literal

Remove the duplicate SszIgnoreAttribute declaration from the generator's
local Attributes.cs and replace `typeof(SszIgnoreAttribute).FullName` with
a const string `"Nethermind.Serialization.Ssz.SszIgnoreAttribute"` directly.

The analyzer compares against the resolved attribute symbol's
`ToDisplayString()`, which is name-based regardless of which CLR type
backs the comparison. Adding a generator-local attribute class just to
make `typeof(...)` compile was machinery in service of a single string
constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the ArrayPoolSpan<byte> intermediate with `Func<T, IBufferWriter<byte>, int>`
encoders that GetSpan/Encode/Advance straight into ctx.Response.BodyWriter, then set
ContentLength from the returned byte count before flushing. Saves a pool rent + copy
per response. Streaming-decode (read-side rewrite) is the next PR.

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

Encoders write directly into the response PipeWriter; if encode throws after
one or more Advance calls, the partial bytes can't be rewound. The middleware's
outer catch then writes a text error into the same pipe, producing a 500 with
[partial-SSZ][text] body. Wrap encode in a try/catch that calls ctx.Abort() so
the CL sees a connection reset instead of a garbled response.

Also Debug.Assert that the encoder's reported length matches pipe.UnflushedBytes
delta — catches drift between T.GetLength and T.Encode in test builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Middleware now uses PipeReader.ReadAtLeastAsync(ContentLength) and passes the
resulting ReadOnlySequence<byte> through the handler chain. Generator emits a
Decode(ReadOnlySequence<byte>, out T) overload at every container/list/union
emission site: single-segment dispatches to span (zero copy); multi-segment
consolidates once via ArrayPool. Eliminates the prior MemoryStream + ToArray
intermediate copy. SszLib gains primitive sequence overloads as infrastructure
for a future fully recursive sequence-aware decoder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Existing SszCodecTests wrap byte[] via the single-segment ReadOnlySequence
constructor — exercises only the IsSingleSegment fast path. Production traffic
from Kestrel arrives in 4 KB pooled blocks, so blob-bearing NewPayload always
runs through the generator's pool-rent + CopyTo branch and SszLib's stack-copy
fallback. New fixture builds explicit multi-segment sequences via a
ReadOnlySequenceSegment chain at boundary sizes 1/3/7/4096, covering the wire
roundtrip path (TransitionConfig, Capabilities, GetBlobs, BodiesByRange,
NewPayloadV3) and the SszLib primitive boundary path (uint, ulong, UInt256).

Also restructures SszMiddleware.InvokeAsync as a chained if/else if/else with
the body-handling work nested in the final else clause so handler/version/extra
stay in scope without scattering early returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After merging master, IEngineRpcModule.engine_getBlobsV{1,2,3} returns
IReadOnlyList<BlobAndProof?> (the V{2,3} variant additionally nullable on the
outer wrapper) instead of IEnumerable<BlobAndProof>. Update the NSubstitute
returns in StartupTests and turn on #nullable enable so the annotations parse.

Also: master tightened Decode(ReadOnlySpan<byte>, out bool) to throw on bytes
other than 0/1. Route the sequence overload through ToContiguous so it can't
silently disagree with the span path on invalid input.

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

Routing slow path:
* Build _postPrefixRoutes / _getPrefixRoutes from only resources that have at
  least one AcceptsPathExtra candidate, and only those candidates. Today no
  POST handler accepts a path-extra suffix, so _postPrefixRoutes is empty —
  any failed POST exact-match no longer scans dead routes.
* Drop the redundant pathSpan.Equals(resourceSpan, ...) check in the prefix
  loop (the next length check rules out exact-match) and the per-candidate
  AcceptsPathExtra filter (now enforced at construction).

EncodeGetBlobsV{1,2}Response:
* Two-pass count + single exact-size allocation, replacing the previous
  alloc-full-then-slice-trim pattern. One heap allocation per response
  instead of two when any null entries are dropped.

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

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 8, 2026

Claude finished @LukaszRozmej's task in 8m 42s —— View job


Round 12 Review — SSZ-REST Engine API Transport

Prior findings: all resolved ✅

Round 11 Mediums

# Finding Status
M1 CollectionTypeAnalyzer: raw string "SszIgnore" used instead of symbol-resolved FQN ✅ Resolved — replaced by private const string SszIgnoreAttributeFullName = "Nethermind.Serialization.Ssz.SszIgnoreAttribute" + SemanticModel resolution via ctor.ContainingType.ToDisplayString(). The fix is strictly better than the nameof approach requested (which cannot produce FQNs); semantic resolution eliminates both false positives and false negatives.
M2 Name-only attribute matching in IsSszIgnoreAttribute produces false positives for any attribute named SszIgnore in any namespace ✅ Resolved — same fix as M1; attribute identity is now verified against the fully-qualified type name.

Round 10 Lows

# Finding Status
L1 SszRestPaths.PayloadsGet dead duplicate constant ✅ Removed — file now contains only the 25 clean spec-path constants, no duplicates.
L2 SszMiddlewareConfigurer constructed a fresh JsonRpcUrlCollection instead of resolving the existing singleton ✅ Removed — configurer now bridges only ILogManager, IRpcAuthentication, IEngineRpcModule, and IProcessExitSource; the comment // IJsonRpcUrlCollection is registered by JsonRpcRunner.Start; we bridge only what isn't. documents the intent.

Fresh pass on recent commits

Three perf-focused commits landed since Round 11. No new issues found.

PipeWriter encode path (SszEndpointHandlerBase)

The encode-to-pipe flow is correct:

  • ctx.Abort() on encode failure prevents a garbled partial response from being flushed to the client — the right choice given that IBufferWriter<byte> cannot be rewound.
  • Debug.Assert guards the length invariant in debug builds without penalising release throughput.
  • ContentLength is set from the return value of encode(value, pipe) before FlushAsync, so Kestrel emits a correct Content-Length header.
  • ctx.Response.CompleteAsync() is called after flush, cleanly finalising the response.

PipeReader decode path (SszMiddleware)

Lifecycle is well-managed:

  • The finally { if (bodyRead) reader.AdvanceTo(body.End); } pattern is correct: AdvanceTo is called only when the buffer was successfully obtained, avoiding a double-advance on the error path.
  • ReadAtLeastAsync + slice-to-ContentLength correctly handles HTTP keep-alive pipelining (next request's bytes may be buffered but are not consumed).
  • The chunked path calls reader.AdvanceTo(rr.Buffer.Start, rr.Buffer.End) in the drain loop — marking all bytes examined but none consumed — which is the standard pattern for "wait for more data".

Prefix-route pre-filtering (SszMiddleware.BuildRoutes)

_postPrefixRoutes / _getPrefixRoutes are built at construction time by filtering only handlers with AcceptsPathExtra == true. The TryResolveHandler hot path iterates these smaller arrays rather than the full handler sets — correct and no new issues.


Summary

No new Critical, High, or Medium findings. No remaining open findings from any prior round.

The PR is ready to merge.

Counts: Critical 0 · High 0 · Medium 0 · Low 0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LukaszRozmej and others added 4 commits May 8, 2026 17:26
Capability tables are built once on first GetXxx call and never mutated.
Switch the internal storage and the IRpcCapabilitiesProvider return type to
FrozenDictionary<string, RpcCapabilityOptions> — exposes that immutability
in the API and lets callers use FrozenDictionary-specific lookups (e.g.
AlternateLookup<ReadOnlySpan<char>>) without re-freezing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If GetTypeByMetadataName for SszBasicTypeAttribute returns null (e.g. during
a parallel-build race where Nethermind.Serialization.Ssz reference resolution
lags), the previous gate cached the failure. Subsequent emits in the same
compilation then short-circuited via the gate with KnownTypes empty and
silently treated [SszBasicType] types like SszBytes8 as plain containers,
producing references to nonexistent SszBytes8.GetLength/Encode/Decode/MerkleizeList
methods.

Only short-circuit when prior discovery actually populated KnownTypes; never
cache the empty/null result. A re-walked namespace tree is far cheaper than
a build that fails with confusing CS0117s.

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

Replace the [SszContainer]+byte[] shape of SszKzgCommitment with an
[InlineArray(48)] basic-type struct (mirrors SszBytes8). SszKzgCommitment[]
is now a single contiguous count*48-byte heap allocation with no
inter-element references — better cache locality during encode and
removes the per-element pointer chase the GC otherwise marks through.

Wire format is identical (raw 48 bytes per element); the existing SSZ
generator already supports basic ref-types with custom encode/decode
templates, so no generator changes were needed. SszBlob (131,072) stays
on byte[] per the issue's explicit non-conversion clause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@svlachakis
Copy link
Copy Markdown
Contributor

svlachakis commented May 8, 2026

@claude what do you think about the following?

  1. SszCapabilityName wraps each capability in a Container — wire format diverges from spec

Files: SszWireTypes.cs:312-322

[SszContainer] SszCapabilityName { [SszList(64)] byte[]? Name } is a Container with one variable field, defaulting to isCollectionItself: false. Verified against SszGenerator.cs:729-988: that path emits [uint32 offset = 4][bytes] per element. The isCollectionItself flag exists exactly to skip that wrapper (used on SszTransaction). Spec (ssz-encoding.md:570) wants List[ByteList[64], 64] — raw bytes, no per-element container prefix.

Fix: add (isCollectionItself: true) on SszCapabilityName. Apply the same to ExchangeCapabilitiesRequestWire.

  1. transition-configuration SSZ endpoint is not in the spec

Files: SszRestPaths.cs:16,29, EngineRpcCapabilitiesProvider.cs:83, TransitionConfigurationSszHandler.cs, SszCodec.cs:215-236, SszWireTypes.cs:438-451

The spec endpoint summary (ssz-encoding.md:899-924) lists every SSZ endpoint; transition-configuration is absent — the spec author confirmed deletion in commit aca7eb6. The implementation still serves the path, advertises it in engine_exchangeCapabilities, and carries dead wire types and a handler.

Fix: delete the handler, wire types, capability registration, and SszRestPaths constants. The JSON-RPC engine_exchangeTransitionConfigurationV1 can stay for legacy CLs.

  1. V1 newPayload can emit status = 4, but the spec defines only 0–3

Files: SszCodec.cs:22-29, 290-298

SszPayloadStatus.InvalidBlockHash = 4 and the EngineStatusToSsz switch maps PayloadStatus.InvalidBlockHash → 4. Spec (ssz-encoding.md:334-340) defines exactly four values (VALID/INVALID/SYNCING/ACCEPTED); the spec author dropped INVALID_BLOCK_HASH per Lukasz's review thread. The engine's V1 newPayload path can still return InvalidBlockHash (Paris semantics).

Fix: in the V1 SSZ encode path, normalise InvalidBlockHash → Invalid. Drop the constant.

  1. Truncated request body returns 500 instead of 400

File: SszMiddleware.cs:380-388

ReadResult rr = await reader.ReadAtLeastAsync(len, ctx.RequestAborted);
return rr.Buffer.Slice(0, len);

ReadAtLeastAsync returns with IsCompleted == true && Buffer.Length < len if the writer completes early. ReadOnlySequence.Slice(0, len) then throws ArgumentOutOfRangeException, which isn't in the inner catch list (InvalidDataException or IndexOutOfRangeException or EndOfStreamException) and falls through to 500. Spec (ssz-encoding.md:148) says malformed SSZ → 400.

Fix: check rr.Buffer.Length < len after the read and throw EndOfStreamException (already mapped to 400), or add ArgumentOutOfRangeException to the inner catch.

  1. Codec tests are roundtrip-only — wire-format bugs slip through

File: SszCodecTests.cs

Capabilities_roundtrip, DecodeGetBlobsRequest_roundtrip, DecodeGetPayloadBodiesByHashRequest_roundtrip, DecodeClientVersionRequest_roundtrip, etc. encode and decode through the same generator, so they pass regardless of wire shape. That's how finding 1. went undetected. The earlier rounds added byte-offset assertions for ExecutionPayload, PayloadStatusV1, ForkchoiceUpdatedResponseV1, PayloadAttributesV3 — the rest still need them.

Fix: for each container type, add one explicit byte-layout test. For capabilities specifically, encode ["abc"] and assert exact length (4 outer + 4 inner offset + 3 = 11 once 1. is fixed) and the offset bytes themselves. Same pattern for blobs requests, payload bodies, client version, get-blobs response.

1., 2., 3. are the spec-conformance fixes. 4. is the small status-code correctness bug. 5. is the testing-pattern change that prevents future 1s.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 8, 2026

Claude finished @svlachakis's task in 3m 54s —— View job


Analyzing @svlachakis's findings

Tasks

}

[SszContainer]
public partial struct SszCapabilityName
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.

High — SszCapabilityName wire format diverges from spec (missing isCollectionItself: true)

The spec (execution-apis#764 ExchangeCapabilitiesResponse) defines each capability as ByteList[64] — raw bytes with no container header. SszTransaction uses the same pattern and is correctly annotated with [SszContainer(isCollectionItself: true)].

Without isCollectionItself: true, the generator emits a container codec: each SszCapabilityName in the Capabilities[] array is encoded as [4-byte offset → Name][Name bytes] (7 bytes for "abc"). With the flag set, it encodes as just [Name bytes] (3 bytes for "abc").

The resulting outer buffer for ["abc"]:

  • Current (wrong): outer-offset(4) + array-offset(4) + inner-container-offset(4) + bytes(3) = 15 bytes
  • Spec-correct: outer-offset(4) + array-offset(4) + bytes(3) = 11 bytes

The Capabilities_roundtrip test passes regardless because encode and decode share the same (wrong) layout — this is exactly the roundtrip-blindness that finding #5 calls out.

Fix:

[SszContainer(isCollectionItself: true)]
public partial struct SszCapabilityName
{
    [SszList(64)] public byte[]? Name { get; set; }
}

Apply the same fix to ExchangeCapabilitiesRequestWire — same field, same problem on the decode side when a CL sends its capabilities list.

A spec-vector test:

byte[] encoded = Encode<IReadOnlyList<string>>(["abc"], SszCodec.EncodeCapabilitiesResponse);
encoded.Length.Should().Be(11); // 4 outer + 4 array-offset + 3 bytes

public const byte Invalid = 1;
public const byte Syncing = 2;
public const byte Accepted = 3;
public const byte InvalidBlockHash = 4;
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.

High — InvalidBlockHash = 4 is not defined in the SSZ spec; V1 newPayload can return it

The SSZ-REST spec (execution-apis#764, PayloadStatusV1 definition) defines exactly four values: VALID=0, INVALID=1, SYNCING=2, ACCEPTED=3. INVALID_BLOCK_HASH was explicitly dropped from the SSZ wire enumeration per the spec author.

The Paris engine API path (engine_newPayloadV1) can still return PayloadStatus.InvalidBlockHash from the domain layer when block-hash validation fails. EngineStatusToSsz maps this to 4, which no spec-compliant CL knows how to interpret — it will either treat it as an unexpected byte or a parse error.

Fix — normalize InvalidBlockHash → Invalid in the SSZ encode path:

PayloadStatus.InvalidBlockHash => SszPayloadStatus.Invalid,

And remove SszPayloadStatus.InvalidBlockHash = 4 entirely, since it should not appear on the wire.

Note: the INVALID_BLOCK_HASH constant itself in PayloadStatus.cs and its use in JSON-RPC should stay unchanged; this normalization is only in the SSZ codec path.

ReadResult rr = await reader.ReadAtLeastAsync(len, ctx.RequestAborted);
// Slice to the declared ContentLength even if Kestrel buffered extra bytes
// (HTTP keep-alive could carry the next request's framing in the same buffer).
return rr.Buffer.Slice(0, 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.

Medium — truncated body throws ArgumentOutOfRangeException → 500 instead of 400

ReadAtLeastAsync(len) returns with IsCompleted = true and Buffer.Length < len when the writer closes before sending len bytes (truncated body). rr.Buffer.Slice(0, len) then throws ArgumentOutOfRangeException because len > Buffer.Length.

ArgumentOutOfRangeException is not in the inner catch list (InvalidDataException or IndexOutOfRangeException or EndOfStreamException), so it falls through to the outer catch (Exception ex) → 500 "Internal server error". The spec requires 400 for malformed/truncated bodies.

Fix — check length before slicing:

ReadResult rr = await reader.ReadAtLeastAsync(len, ctx.RequestAborted);
if (rr.Buffer.Length < len)
    throw new EndOfStreamException($"Expected {len} bytes but stream ended with {rr.Buffer.Length}");
return rr.Buffer.Slice(0, len);

EndOfStreamException is already in the 400-mapped catch list.

@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
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 — TransitionConfigurationSszHandler and its wire types should be removed; the SSZ-REST spec deleted this endpoint

The spec (execution-apis#764) endpoint summary does not include POST /engine/v1/transition-configuration. The spec author removed it (confirmed deleted in commit aca7eb6 of that PR). Currently, the implementation:

  1. Serves the SSZ-REST path POST /engine/v1/transition-configuration
  2. Advertises it in engine_exchangeCapabilities (gated on preCancun = !spec.IsEip4844Enabled)
  3. Carries TransitionConfigurationSszHandler.cs, TransitionConfigurationV1Wire, ExchangeTransitionConfigurationWire, and the DecodeTransitionConfigurationRequest/EncodeTransitionConfigurationResponse codec methods

Since all production networks are post-Cancun, the capability is never advertised and the handler is never reachable in practice. But a pre-Cancun devnet (e.g., testing with an old chain) would advertise this non-spec endpoint, and a spec-compliant CL would reject it or ignore it.

Fix: delete the handler, the SSZ wire types (TransitionConfigurationV1Wire, ExchangeTransitionConfigurationWire), the codec methods, the SszRestPaths.TransitionConfiguration / PostV1TransitionConfig constants, and the EngineRpcCapabilitiesProvider entry. The JSON-RPC engine_exchangeTransitionConfigurationV1 is independent and should stay.

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