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 62 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
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snapshot and IWorldStateScopeProvider live in Nethermind.Evm.State, already imported. CI's IDE0005 caught this; full-solution lint reproduction now clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tness-ssz # Conflicts: # src/Nethermind/Ethereum.Test.Base/BlockchainTestBase.cs # src/Nethermind/Nethermind.Merge.Plugin/Data/EngineApiJsonContext.cs # src/Nethermind/Nethermind.Merge.Plugin/EngineRpcModule.Amsterdam.cs # src/Nethermind/Nethermind.Merge.Plugin/SszRest/SszMiddleware.cs
|
I've got a few first questions:
|
I'm all for reusing, but we want single processing. Can you propose/push required changes?
Let's merge PR's. |
…tness-ssz # Conflicts: # src/Nethermind/Ethereum.Blockchain.Pyspec.Test/PyspecTestFixture.cs
Haven't looked into that, will do surely. I think it is necessary for single block execution.
Let's merge both PRs then
It is used during re-execution but not for our endpoint |
My initial idea was to do something similar to
I think that would:
So, as an overview, when Also i am not fully familiar with the constraints associated to engine API and main chain processing. I mean, main chain registers for example BlockProcessor in BlockProcessingModule as scoped. So, would having another BlockProcessor instance executing the @LukaszRozmej @Dyslex7c Let me know whether my idea makes sense and is preferrable or not, and if so, i can implement it. It can also be as a refactor if we wanna merge this PR quickly !
I opened my PR as draft. This PR fixes most of the tests in witness comparison for in-process block processing (i mean, the non-engine API path, but from what i saw in fixtures, tests are almost all equal for both paths), some of my tests are still producing a few excess state nodes preimages for some tests (looking into that).
Okay thats strange because this was useful during state root recomputation after execution where some trie nodes could get deleted and the branch node would need to read some sibling node to be able collapse etc. And those cases could not get caught at the world state level through the |
|
Also another question: there are some tests especially useful for stateless validation which have a field So, personally, locally i ignored those few tests for now, i'll ask in the discord what to do with them for the witness assertion, whether we should just ignore them or smth else |
In the spawning env approach, both the env we create and the main block-processing pipeline would be able to write to the same DB simultaneously so we have to avoid concurrent DB writes and any form of out-of-sync problems. (because
Sounds good to me. you can check my PR for the tests I was also getting similar errors before. could you share the error logs?
I'll add this now and check if it interferes with our goal and get back to you |
We aren't ignoring those tests. The // Payloads without an executionWitness field fall back to the standard path.
if (enginePayload.ExecutionWitness is null || enginePayload.ExecutionWitnessMutated)
{
return await base.SendPayloadAsync(rpcService, rpcContext, enginePayload, newPayloadVersion, paramsJson);
} |
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