Skip to content

feat: SSZ-REST Engine API transport#10728

Draft
Giulio2002 wants to merge 5 commits intoNethermindEth:masterfrom
Giulio2002:eip-8161-ssz-rest
Draft

feat: SSZ-REST Engine API transport#10728
Giulio2002 wants to merge 5 commits intoNethermindEth:masterfrom
Giulio2002:eip-8161-ssz-rest

Conversation

@Giulio2002
Copy link
Copy Markdown

@Giulio2002 Giulio2002 commented Mar 5, 2026

Summary

  • Add binary SSZ-over-HTTP REST server (SszRestServer) for the Engine API, wire-compatible with geth and erigon implementations
  • Hand-rolled SSZ encoding/decoding (SszEncoding) for all Engine API types: new_payload, forkchoice_updated, get_payload, get_blobs, exchange_capabilities, get_client_version
  • New config options Merge.SszRestEnabled (default false) and Merge.SszRestPort (default 8552) to opt in

Motivation

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.

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>
@flcl42
Copy link
Copy Markdown
Contributor

flcl42 commented Mar 5, 2026

@Giulio2002 Giulio2002 marked this pull request as draft March 5, 2026 22:49
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Giulio2002 and others added 3 commits March 6, 2026 16:04
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>
@Giulio2002 Giulio2002 changed the title feat: EIP-8161 SSZ-REST Engine API transport feat: SSZ-REST Engine API transport Mar 8, 2026
@Giulio2002
Copy link
Copy Markdown
Author

Implements the SSZ-REST Engine API transport spec: ethereum/execution-apis#764

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 SszRestCodec for encoding/decoding Engine API request/response payloads.
  • Adds an SszRestHandler and 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
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +114
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}")
};
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
if (result.Result != Result.Success)
return MakeJsonError(400, result.ErrorCode, result.Result.Error ?? "Unknown error");

return MakeSszResponse([]);
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
return MakeSszResponse([]);
return MakeSszResponse(SszRestCodec.EncodeBlobs(result.Data));

Copilot uses AI. Check for mistakes.
Comment on lines +143 to +154
(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));
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +175
(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));
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +202 to +216
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);
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +357 to +373
// 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);
})
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines 73 to 79
[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; }

}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
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.

3 participants