feat(engine): introduce REST based + SSZ serialized new-payload-with-witness#11623
feat(engine): introduce REST based + SSZ serialized new-payload-with-witness#11623Dyslex7c wants to merge 78 commits into
new-payload-with-witness#11623Conversation
|
Deep review against the spec (execution-apis#773 / High-priority1. Wire format does not match the spec's
|
Re-review (commits
|
Round 3 — commits
|
|
Don't make things virtual just to override them in test, use decorator pattern instead, tests shouldn't shape production code API's |
|
addressed all the reviews, opened a follow-up issue for double execution and updated PR description, @LukaszRozmej could you please re-review? |
|
Isn't avoiding double-execution sole purpose of this endpoint? Why not do that here? Why create issues? |
LukaszRozmej
left a comment
There was a problem hiding this comment.
Overview
Implements POST /new-payload-with-witness per execution-apis#773, plus a parallel engine_newPayloadWithWitness JSON-RPC method. Core design correctly decouples the V5 execution step via a delegate and hand-rolls the Union[None, T] SSZ encoding the spec requires.
Spec compliance — ✅ matches the spec
Verified against src/engine/rest.md from execution-apis#773:
NewPayloadWithWitnessResponseV1container: 1 bytestatus+ 3 × 4-byte offsets for the threeUnionfields → 13-byte fixed header. Implementation matches (FixedHeaderBytes = 1 + 4 + 4 + 4).- Status byte mapping:
VALID=0, INVALID=1, SYNCING=2, ACCEPTED=3, INVALID_BLOCK_HASH=4— all present inEngineStatusToSsz, andPayloadStatus.InvalidBlockHashwas added correctly. VALIDATION_ERROR_MAX = 8192:PayloadStatusWire.ValidationErrorcap bumped, andEncodeNewPayloadWithWitnessResponseuses the same constant.- UTF-8 truncation:
TruncateUtf8correctly walks backward over continuation bytes (0x80..0xBF) and drops the partial codepoint. Covered byEncodePayloadStatus_truncation_does_not_split_multibyte_utf8_codepoint. - Union[None, T] hand-rolled: 0x00 for None, 0x01 + variant body for Some. The
[SszCompatibleUnion]generator's inability to encode the 0x00 selector is the right reason to hand-roll, and the comment inSszWireTypes.csdocuments it well. - Witness emptiness rule:
hasWitness = witness is not null && ps.Status == PayloadStatus.Valid— covers both spec conditions ("None when status is not VALID or no witness was produced"). ExecutionWitnessV1wire: state/codes/headers (3 fields). Correctly drops Nethermind's internalWitness.Keysfrom the wire — spec defines only 3 fields.- HTTP semantics: 200+octet-stream on VALID/INVALID/SYNCING, 400 for malformed JSON / UnsupportedFork (-38005), 401 auth failure, 405 non-POST, 500 for other engine errors, 415 for wrong content type. All errors use
application/json. - Capability
rest_engine_newPayloadWithWitnessregistered inEngineRpcCapabilitiesProvider.
Spec interpretation note: spec text in §Specification point 3 says "the witness field MUST be empty ([])" for non-VALID, which is awkward given the field's type is Union[None, ExecutionWitnessV1]. The PR treats this as the None variant, which is the only sensible reading consistent with the structure definition higher in the same spec.
Issues
1. Dead code: IBlockTree constructor param on EngineRpcModule
public partial class EngineRpcModule(... IBlockTree blockTree, ILogManager logManager)
{
protected readonly IBlockTree _blockTree = blockTree ?? throw new ArgumentNullException(nameof(blockTree));_blockTree is declared protected readonly but never referenced anywhere in EngineRpcModule.cs or any partial (.Paris/Cancun/Prague/Osaka/Amsterdam). The witness handler already takes its own IBlockTree directly via the DI factory in MergePlugin.cs. Removing this would:
- Drop one unused public constructor parameter (still a breaking change for downstream forks subclassing
EngineRpcModule, but a smaller one). - Revert the noise added to
TaikoEngineRpcModule.cs,CertainBatchLookupTests.cs,TxPoolContentListsTests.cs, and the manual-construction test inEngineModuleTests.V3.cs.
The PR description claims EngineRpcModule gains two params (IBlockTree and IWitnessGeneratingBlockProcessingEnvFactory), but only IBlockTree is added — and it isn't used. Either wire the field in for some forthcoming purpose or remove it.
2. Witness-generation logic duplicated across the two handlers
NewPayloadWithWitnessSszHandler.TryGenerateWitness and NewPayloadWithWitnessHandler.TryGenerateWitnessForBlock are byte-for-byte the same:
TryGetBlock()+ log on failureBlockTree.FindHeader(parentHash, DoNotCreateLevelIfMissing)+ log on nullusing scope; collector.GetWitnessForExistingBlock(parent, block)with identicalOperationCanceledException/Exceptionhandling and identical log wording (the only diff is a prefix string).
Cleanest fix: have NewPayloadWithWitnessSszHandler call _engineModule.engine_newPayloadWithWitness(...) instead of engine_newPayloadV5(...), then SSZ-encode the resulting NewPayloadWithWitnessV1Result. That collapses both handlers' witness paths into one, and removes the SSZ handler's dependencies on IBlockTree and IWitnessGeneratingBlockProcessingEnvFactory — simplifying SszMiddlewareConfigurer (two Bridge<> calls go away) and SszMiddlewareTests setup.
3. Duplicate / inconsistent log severities for witness-null on VALID
In NewPayloadWithWitnessHandler.HandleAsync (and mirrored in the SSZ handler): when witness generation returns null, TryGenerate* already logs the specific reason at Warn ("could not decode block", "parent header not found") or Error ("witness generation failed: …"). Then the outer code logs again at Error with a generic "could not be generated" message.
Result: a benign re-org (missing parent) emits Warn + Error for the same event, which will trip alerting on noise. Drop the outer log — the inner sites already cover all branches with appropriate severities.
4. Hand-rolled JSON for error bodies (SszEndpointHandlerBase.WriteErrorAsync)
string json = $"{{\"code\":{jsonRpcCode},\"message\":{System.Text.Json.JsonSerializer.Serialize(message)}}}";JsonSerializer.Serialize(message) correctly escapes the message, but the outer object is string-interpolated. Negligible but inconsistent — JsonSerializer.Serialize(new { code = jsonRpcCode, message }) is one allocation, no manual brace matching, and reads better.
5. WriteErrorAsync content-type change applies to all SSZ-REST endpoints (heads-up)
The PR description acknowledges this but it's worth re-confirming with CL teams: every previously-text/plain error response (auth 401s on /engine/v*/…, 404s for unknown methods, 413s, 400 malformed-SSZ, 500s) now emits application/json. The new REST spec mandates this format for /new-payload-with-witness only; the older /engine/v{N}/… paths aren't governed by this spec.
6. Minor: ownership of Witness? in WriteSszNewPayloadWithWitnessAsync is non-obvious
private static async Task WriteSszNewPayloadWithWitnessAsync(HttpContext ctx, PayloadStatusV1 status, Witness? witness)
{
using Witness? w = witness;
...Not a bug — the witness is created in TryGenerateWitness and not owned by the outer result's using — but a one-line comment on TryGenerateWitness saying "caller takes ownership" would help the reader.
Tests
EngineModuleTests.Amsterdam.cs: solid coverage — VALID with witness, VALID with witness-gen failure (null parent), VALID with collector throw, SYNCING/INVALID skipping witness, engine-error propagation. TheWitnessHandlerBuilderis a nice testable seam.SszMiddlewareTests: covers 200/VALID, 200/SYNCING, 200/null-witness-on-VALID, 400 malformed JSON, 405 non-POST, 415 wrong content-type, 404 versioned path, 400 UnsupportedFork, 500 internal error. Good breadth.SszCodecTests: bytewise verification of the 13-byte header and offsets is exactly the right test for hand-rolled encoding — would catch any regression to the offset layout.
Risks
- Double execution on VALID payloads — well-documented (PR body + TODO + #11636). Eliminates real-time attestation benefits the spec promised, but tracked as follow-up.
engine_newPayloadWithWitnessJSON-RPC is Nethermind-only — spec defines only the REST endpoint. Adding the JSON-RPC method is fine (matches the spec author's earlier prototype phase) but is not portable across other clients.PayloadStatusWireValidationError cap 1024→8192 affects allPayloadStatusV1SSZ responses (not just witness). Spec-correct, but changes merkleization ofPayloadStatusV1if any tooling has golden-byte fixtures.
Summary
Spec compliance is correct and the SSZ encoding has the right tests. Main asks: drop the unused IBlockTree from EngineRpcModule, collapse the duplicate witness-generation logic by having the SSZ handler delegate to engine_newPayloadWithWitness, and fix the duplicate Warn+Error logging when witness generation legitimately fails.
yes actually this needs a bigger refactor so in your review it asked to create a follow-up issue. Will implement this here and close the issue
|
| } | ||
|
|
||
| private static async Task<(ExecutionPayloadV4 Payload, byte[][]? ExecutionRequests)> | ||
| BuildAmsterdamPayload(MergeTestBlockchain chain) |
There was a problem hiding this comment.
why the "asmterdam payload" ? what's the link with amsterdam ?
There was a problem hiding this comment.
Amsterdam is the fork which enabled EIP 7928 so witness capture was activated on this. This is also stale and was implemented during the AI sweep on zkEVM tests I believe. we can use the default test fork here instead
| { | ||
| protected override void Load(ContainerBuilder builder) | ||
| { | ||
| if (!specProvider.GetFinalSpec().IsEip7928Enabled) return; |
There was a problem hiding this comment.
is the endpoint enabled only when eip 7928 is enabled ? that's also what the comments above the class suggests
There was a problem hiding this comment.
it should probably be available even when EIP 7928 is disabled. It was likely introduced to fix the failing EIP 7928 BAL tests
The entire directory of amsterdam was actually stale from some previous tests according to @Dyslex7c. Anyway, i removed code related to zkevm tests from this PR as it was only partially supporting them. Also then this PR now focuses on the |
…ain processing modules in MainProcessingContext
| ? new PreCachedTrieStore(store, nodeStorageCache) | ||
| : store; | ||
| }) | ||
| .As<ITrieStore>() |
There was a problem hiding this comment.
Probably a bad idea to expose this.
|
Since this is a standard ethereum change, I recommend modifying the IWorldStateScopeProvider instead. In the BeginScope, add a |
This is standard endpoint but fairly non-standard use-case. We should kind of avoid pollution of the normal path? |
|
Something along this line. #12018 |
This reverts commit 65073e0.
|
Continued PR in #12048 as this one was from a fork |
Summary
Implements the
POST /new-payload-with-witnessEngine API REST endpoint as specified in ethereum/execution-apis#773. This endpoint combines payload execution and witness generation into a single HTTP+SSZ call, replacing the previous two-stepengine_newPayload+debug_executionWitnessflow.Also adds a
engine_newPayloadWithWitnessJSON-RPC method that exposes the same semantics over the standard JSON-RPC transport.Changes
NewPayloadWithWitnessSszHandler— handlesPOST /new-payload-with-witness, accepts the same JSON params asengine_newPayloadV5, delegates to theengine_newPayloadWithWitnessJSON-RPC handler, and returns an SSZ-encodedNewPayloadWithWitnessResponseV1engine_newPayloadWithWitnessJSON-RPC method onEngineRpcModulebacked byNewPayloadWithWitnessHandler— same execution + witness generation logic available over JSON-RPCNewPayloadWithWitnessResponseV1is hand-rolled inSszCodec(not auto-generated) to correctly implement the spec'sUnion[None, T]layout with explicit selector bytes (0x00 = None, 0x01 ++ T = Some). The generator's[SszCompatibleUnion]does not support selector byte 0x00, so this cannot go through the standard path; a comment inSszWireTypes.csexplains the constraint.SszCodec.EncodeNewPayloadWithWitnessResponseandDecodeNewPayloadWithWitnessResponse— encode/decode the combined status + witness response. Non-VALIDstatuses encode witness asUnion None(selector byte0x00), not an empty list.SszMiddlewarefor the non-versioned/new-payload-with-witnesspath; the versioned/engine/v{N}/new-payload-with-witnesspath correctly returns 404SszRestCapabilitiesfromSszRestPaths; registeredrest_engine_newPayloadWithWitnesscapability inEngineRpcCapabilitiesProvider, gated onIsEip7928EnabledBehaviour changes affecting all SSZ-REST callers
SszEndpointHandlerBase.WriteErrorAsyncnow emitsapplication/jsonwith{"code": …, "message": "…"}for all SSZ-REST error responses — auth failures on/engine/v*/…, 404s, 413s, malformed-SSZ 400s, and 500s. Previously these weretext/plainwith a raw message string. Any tooling that parses the raw text body of error responses from these endpoints will need to be updated.PayloadStatusWire.ValidationErrorSSZ list cap bumped from 1024 → 8192 bytes (spec correctness; affects allPayloadStatusV1SSZ responses, not just the witness endpoint). This changes the merkleization parameters forPayloadStatusV1. Downstream tooling with hardcoded golden-byte test fixtures against the old 1024 cap will need updating.Constructor signature change for
EngineRpcModuleEngineRpcModulenow requires one additional constructor parameter:INewPayloadWithWitnessHandler. It is needed unconditionally (the JSON-RPC method is registered on every network); the capability advertisement is still gated onIsEip7928Enabled.In-tree subclasses affected:
TaikoEngineRpcModule(updated in this PR).OptimismEngineRpcModuleuses composition (IEngineRpcModule) and is unaffected. Downstream forks that subclassEngineRpcModuledirectly will need to update their constructor call sites.Types of changes
Testing
Requires testing
If yes, did you write tests?
Notes on testing
Unit tests cover:
VALIDATION_ERROR_MAX = 8192acceptance and truncationEncodeNewPayloadWithWitnessResponsefor all non-VALIDstatuses (witness field isUnion None, selector byte0x00)EncodeNewPayloadWithWitnessResponseforVALIDwith witness (selector byte0x01+ encoded body) andVALIDwith null witness (Union None)VALIDandSYNCINGstatuses, 400 for malformed JSON, 405 for non-POST, 400 + correct error code for unsupported forkapplication/jsonwithcodeandmessagefieldsJSON-RPC method (
EngineModuleTests.Amsterdam):VALIDstatus →executionWitnesspopulatedVALIDwith null parent header →executionWitnessnull, status stillVALIDVALIDwith collector exception →executionWitnessnull, status stillVALIDSYNCING→executionWitnessnull, witness factory not invokedINVALID→ status,LatestValidHash, andValidationErrorround-trip correctly, witness factory not invokedengine_newPayloadV5RPC failure → error code and message propagated correctly