feat: SSZ-REST Engine API transport#10728
feat: SSZ-REST Engine API transport#10728Giulio2002 wants to merge 5 commits intoNethermindEth:masterfrom
Conversation
Add binary SSZ-over-HTTP REST transport for the Engine API alongside existing JSON-RPC, matching the wire format used by geth and erigon. - SszRestServer: HTTP server on configurable port (default 8552) with JWT auth, routing for new_payload, forkchoice_updated, get_payload, get_blobs, exchange_capabilities, get_client_version - SszEncoding: hand-rolled SSZ encode/decode for all Engine API types including Union encoding for nullable fields - Config: Merge.SszRestEnabled / Merge.SszRestPort - Server starts during InitRpcModules to ensure IBlockProducer is available - Dockerfile exposes port 8552 Tested in multi-client kurtosis devnet with prysm CL using SSZ-REST transport to communicate with Nethermind EL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove --Merge.SszRestEnabled and --Merge.SszRestPort flags. SSZ-REST is now served on the engine port (8551) under /engine/* paths via ASP.NET Core middleware, alongside JSON-RPC. The CL auto-detects SSZ-REST availability on the engine endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
Implements the SSZ-REST Engine API transport spec: ethereum/execution-apis#764 |
There was a problem hiding this comment.
Pull request overview
Adds an SSZ-over-HTTP REST transport for Engine API calls, intended to be wire-compatible with other clients’ SSZ-REST implementations and reduce JSON serialization overhead on blob-heavy paths.
Changes:
- Introduces SSZ wire structs and an
SszRestCodecfor encoding/decoding Engine API request/response payloads. - Adds an
SszRestHandlerand wires it into the ASP.NET Core pipeline to serve/engine/*routes on the authenticated Engine API port. - Updates project references/lockfile to include SSZ generator + merkleization dependencies, and exposes an additional Docker port.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Nethermind/Nethermind.Serialization.Ssz/SszSerializableAttribute.cs | Extends SSZ-serializable marker attribute to support “collection-itself” encoding. |
| src/Nethermind/Nethermind.Runner/packages.lock.json | Adds Merge plugin dependencies for merkleization + SSZ. |
| src/Nethermind/Nethermind.Runner/JsonRpc/Startup.cs | Adds middleware to route /engine/* on authenticated port to SSZ-REST handler + JWT auth. |
| src/Nethermind/Nethermind.Runner/JsonRpc/Bootstrap.cs | Allows passing an SszRestHandler instance into ASP.NET DI registration. |
| src/Nethermind/Nethermind.Runner/Ethereum/Steps/StartRpc.cs | Resolves SszRestHandler from Autofac and forwards it into Bootstrap. |
| src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszWireTypes.cs | Adds SSZ wire types for Engine API payloads/capabilities/client version. |
| src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszRestServer.cs | Implements the SSZ-REST request router/handler. |
| src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszRestCodec.cs | Implements SSZ encode/decode + domain↔wire conversions. |
| src/Nethermind/Nethermind.Merge.Plugin/Nethermind.Merge.Plugin.csproj | Adds SSZ generator analyzer + SSZ/merkleization references. |
| src/Nethermind/Nethermind.Merge.Plugin/MergePlugin.cs | Registers SszRestHandler in the Merge plugin container. |
| src/Nethermind/Nethermind.Merge.Plugin/MergeConfig.cs | No functional changes (formatting only). |
| src/Nethermind/Nethermind.Merge.Plugin/IMergeConfig.cs | No functional changes (formatting only). |
| Dockerfile | Exposes port 8552 in container image. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| VOLUME /nethermind/nethermind_db | ||
|
|
||
| EXPOSE 8545 8551 30303 | ||
| EXPOSE 8545 8551 8552 30303 |
There was a problem hiding this comment.
The image exposes port 8552, but the SSZ-REST middleware is wired to serve /engine/* on the existing authenticated Engine API port (same listener as JSON-RPC) and there is no separate SSZ-REST server bound to 8552 in this PR. Either wire up the separate port described in the PR (and config) or avoid exposing an unused port in the Dockerfile.
| private async Task<SszRestResponse> RouteRequest(string path, byte[] body) | ||
| { | ||
| if (!TryParsePath(path, out int version, out string method)) | ||
| { | ||
| return MakeJsonError(404, -32601, $"Unknown endpoint: {path}"); | ||
| } | ||
|
|
||
| if (_logger.IsInfo) _logger.Info($"SSZ-REST << engine_v{version}_{method} ({body.Length} bytes)"); | ||
|
|
||
| if (method.StartsWith("payloads/", StringComparison.Ordinal)) | ||
| { | ||
| string payloadIdHex = method["payloads/".Length..]; | ||
| return await HandleGetPayloadByPath(payloadIdHex, version); | ||
| } | ||
|
|
||
| return method switch | ||
| { | ||
| "new_payload" or "payloads" => await HandleNewPayload(body, version), | ||
| "forkchoice_updated" or "forkchoice" => await HandleForkchoiceUpdated(body, version), | ||
| "get_payload" => await HandleGetPayload(SszRestCodec.DecodeGetPayloadRequest(body), version), | ||
| "get_blobs" or "blobs" => await HandleGetBlobs(body), | ||
| "exchange_capabilities" or "capabilities" => await HandleExchangeCapabilities(body), | ||
| "get_client_version" or "client/version" => await HandleGetClientVersion(body), | ||
| _ => MakeJsonError(404, -32601, $"Unknown method: {method}") | ||
| }; | ||
| } |
There was a problem hiding this comment.
There are extensive Engine API tests under Nethermind.Merge.Plugin.Test, but this PR adds a new transport layer (SszRestHandler + SszRestCodec) without adding any tests for routing and SSZ encode/decode round-trips per version (e.g., newPayloadV3/V4 validation, getPayloadV2/V3 response schema, getBlobs encoding). Adding targeted tests would help prevent subtle wire-compat regressions against geth/erigon SSZ-REST clients.
| if (result.Result != Result.Success) | ||
| return MakeJsonError(400, result.ErrorCode, result.Result.Error ?? "Unknown error"); | ||
|
|
||
| return MakeSszResponse([]); |
There was a problem hiding this comment.
HandleGetBlobs ignores the blobs returned by _getBlobsHandler and always returns an empty SSZ response (MakeSszResponse([])). This will make engine_getBlobs over SSZ-REST unusable. Encode result.Data into an SSZ response (and define the necessary SSZ wire type/codec for BlobAndProofV1).
| return MakeSszResponse([]); | |
| return MakeSszResponse(SszRestCodec.EncodeBlobs(result.Data)); |
| (ExecutionPayloadV3 payload, byte[]?[] _, Hash256? parentBeaconBlockRoot, byte[][]? executionRequests) = | ||
| SszRestCodec.DecodeNewPayloadRequest(body, version); | ||
|
|
||
| payload.ParentBeaconBlockRoot = parentBeaconBlockRoot; | ||
| payload.ExecutionRequests = executionRequests; | ||
|
|
||
| ResultWrapper<PayloadStatusV1> result = await _newPayloadHandler.HandleAsync(payload); | ||
|
|
||
| if (result.Result != Result.Success) | ||
| return MakeJsonError(400, result.ErrorCode, result.Result.Error ?? "Unknown error"); | ||
|
|
||
| return MakeSszResponse(SszRestCodec.EncodePayloadStatus(result.Data)); |
There was a problem hiding this comment.
HandleNewPayload decodes (payload, versionedHashes, ...) but discards versionedHashes and calls NewPayloadHandler.HandleAsync(payload) directly. This bypasses the validation performed in EngineRpcModule.NewPayload (fork checks + ExecutionPayloadParams<T>.ValidateParams, including “blob versioned hashes do not match” handling), so SSZ-REST can accept invalid V3/V4 payload params that JSON-RPC would reject. Consider routing through the same EngineRpcModule.NewPayload path (or duplicating the ValidateFork/ValidateParams logic here) so both transports enforce identical rules.
| (ForkchoiceStateV1 state, PayloadAttributes? attributes) = | ||
| SszRestCodec.DecodeForkchoiceUpdatedRequest(body, version); | ||
|
|
||
| ResultWrapper<ForkchoiceUpdatedV1Result> result = await _forkchoiceUpdatedHandler.Handle(state, attributes, version); | ||
|
|
||
| if (result.Result != Result.Success) | ||
| return MakeJsonError(400, result.ErrorCode, result.Result.Error ?? "Unknown error"); | ||
|
|
||
| return MakeSszResponse(SszRestCodec.EncodeForkchoiceUpdatedResponse(result.Data)); | ||
| } |
There was a problem hiding this comment.
HandleForkchoiceUpdated calls _forkchoiceUpdatedHandler.Handle(...) directly, but the JSON-RPC path wraps forkchoiceUpdated in EngineRpcModule.ForkchoiceUpdated, which serializes requests via _locker and updates request tracking/metrics. Without the same locking, SSZ-REST requests can run concurrently with JSON-RPC Engine API requests and violate the module’s intended single-flight behavior. Consider reusing the same synchronization/entrypoint as JSON-RPC (e.g., delegate to EngineRpcModule or share its _locker/tracking dependencies).
| public static byte[] EncodeGetPayloadResponse(ExecutionPayload ep, UInt256 blockValue, BlobsBundleV1? blobsBundle, bool shouldOverrideBuilder, byte[][]? executionRequests, int version) | ||
| { | ||
| if (version == 1) | ||
| return EncodeExecutionPayload(ep, 1); | ||
|
|
||
| GetPayloadResponseWire wire = new() | ||
| { | ||
| ExecutionPayload = ExecutionPayloadToV3Wire(ep), | ||
| BlockValue = blockValue, | ||
| BlobsBundle = BlobsBundleToWire(blobsBundle), | ||
| ShouldOverrideBuilder = shouldOverrideBuilder, | ||
| ExecutionRequests = ExecutionRequestsToWire(executionRequests) | ||
| }; | ||
|
|
||
| return SszEncoding.Encode(wire); |
There was a problem hiding this comment.
EncodeGetPayloadResponse encodes GetPayloadResponseWire for all version != 1 using ExecutionPayloadToV3Wire(ep). For engine_getPayloadV2 this will produce a V3-shaped payload (includes Deneb-only fields) instead of the V2 payload shape, which will not be wire-compatible with clients expecting the SSZ schema for V2. Additionally, engine_getPayloadV5 currently passes null for blobsBundle in the handler, so even for V3+ responses the blobs bundle may be silently dropped. The encoder should select the correct wire type per Engine API version and include the correct blobs bundle variant.
| // EIP-8161 SSZ-REST Engine API transport (served on engine port alongside JSON-RPC) | ||
| .AddSingleton<SszRestHandler>(ctx => | ||
| { | ||
| ILogManager logManager = ctx.Resolve<ILogManager>(); | ||
|
|
||
| return new SszRestHandler( | ||
| ctx.Resolve<IAsyncHandler<ExecutionPayload, PayloadStatusV1>>(), | ||
| ctx.Resolve<IForkchoiceUpdatedHandler>(), | ||
| ctx.Resolve<IAsyncHandler<byte[], ExecutionPayload?>>(), | ||
| ctx.Resolve<IAsyncHandler<byte[], GetPayloadV2Result?>>(), | ||
| ctx.Resolve<IAsyncHandler<byte[], GetPayloadV3Result?>>(), | ||
| ctx.Resolve<IAsyncHandler<byte[], GetPayloadV4Result?>>(), | ||
| ctx.Resolve<IAsyncHandler<byte[], GetPayloadV5Result?>>(), | ||
| ctx.Resolve<IHandler<IEnumerable<string>, IEnumerable<string>>>(), | ||
| ctx.Resolve<IAsyncHandler<byte[][], IEnumerable<BlobAndProofV1?>>>(), | ||
| logManager); | ||
| }) |
There was a problem hiding this comment.
SszRestHandler is registered unconditionally in the Merge plugin container. Because Startup enables SSZ-REST routing whenever SszRestHandler is present, this effectively enables the SSZ-REST Engine API whenever the Merge plugin is loaded. The PR description mentions an opt-in config (Merge.SszRestEnabled / Merge.SszRestPort), but there is no gating here. Consider registering SszRestHandler only when the config enables it (and/or wiring it behind a feature flag) so the endpoint isn’t exposed by default.
| [ConfigItem(Description = "[TECHNICAL] Simulate block production for every possible slot. Just for stress-testing purposes.", DefaultValue = "false", HiddenFromDocs = true)] | ||
| bool SimulateBlockProduction { get; set; } | ||
|
|
||
| [ConfigItem(Description = "Delay, in milliseconds, between `newPayload` and GC trigger. If not set, defaults to 1/8th of `Blocks.SecondsPerSlot`.", DefaultValue = null, HiddenFromDocs = true)] | ||
| int? PostBlockGcDelayMs { get; set; } | ||
|
|
||
| } |
There was a problem hiding this comment.
The PR description states new config options Merge.SszRestEnabled and Merge.SszRestPort, but this interface did not gain any corresponding properties. As written, there is no way to opt in/out or configure the SSZ-REST listener/behavior via IMergeConfig. Add the config items (and implement them in MergeConfig) or update the description/implementation so they match.
Summary
SszRestServer) for the Engine API, wire-compatible with geth and erigon implementationsSszEncoding) for all Engine API types:new_payload,forkchoice_updated,get_payload,get_blobs,exchange_capabilities,get_client_versionMerge.SszRestEnabled(default false) andMerge.SszRestPort(default 8552) to opt inMotivation
Replaces JSON-RPC with binary SSZ encoding for the Engine API, as specified in ethereum/execution-apis#764. At 72 blobs, JSON encoding takes 57-211ms; SSZ takes 13-26ms with 2× smaller wire size.