Introduction of REST based + SSZ serialized new-payload-with-witness#773
Introduction of REST based + SSZ serialized new-payload-with-witness#773developeruche wants to merge 5 commits intoethereum:mainfrom
new-payload-with-witness#773Conversation
| | 0 | `status` | `uint8` | | ||
| | 1 | `latest_valid_hash` | `Union[None, ByteVector[32]]` | | ||
| | 2 | `validation_error` | `Union[None, List[uint8, VALIDATION_ERROR_MAX]]` | | ||
| | 3 | `witness` | `List[uint8, MAX_WITNESS_BYTES]` | |
There was a problem hiding this comment.
Is there a reason why witness is SSZ encoded if the container is also meant to be SSZ serialized?
As in why not:
| Index | Field name | SSZ type |
| ----- | ---------- | -------- |
| 0 | `status` | `uint8` |
| 1 | `latest_valid_hash` | `Union[None, ByteVector[32]]` |
| 2 | `validation_error` | `Union[None, List[uint8, VALIDATION_ERROR_MAX]]` |
| 3 | `witness` | `Union[None, ExecutionWitnessV1]` |
or even
| Index | Field name | SSZ type |
| ----- | ---------- | -------- |
| 0 | `payload_status` | `PayloadStatusV1` |
| 1 | `witness` | `Union[None, ExecutionWitnessV1]` |
Or this embedded SSZ bytes is done intentionally to be able to avoid re-serializing at the client size? Like trying to use verbatim to inject as part of guest program input (I mean "inject" since guest program input isn't only the execution witness which is a field in a bigger container)
There was a problem hiding this comment.
Yeah, the goal was to prevent reserialization when preparing zkVM I/O buf, and I kept in validation_error and latest_valid_hash so this response could still be used in a similar manner engine_newPayload response if been used in the CL.
There was a problem hiding this comment.
Makes sense -- I'm thinking if ssz libs support "injecting" serialized SSZ of a field which is part of a container. As in, see here for the StatelessInput struct. This struct is what we send as unique input to the guest program. The ExecutionWitness is a field there, so if we want to leverage this "already serialized" reality, I feel prob ssz libs should kind of support this feature?
I think I see three potential options:
- We assume SSZ libs should support this kind of feature
- Pass the
ExecutionInputas an independent input from the rest ofStatelessInput-- so the guest program has two inputs instead of one. Here using the already serialized execution witness feels more natural -- might feel a bit awkward from an API design perspective (but not much I think), but if saves enough time might be justified. - Don't do this optmization in the EngineAPI layer, and just make it part of the
StatelessInputserialization that is happening anyway. I'm not sure if we already benchmarked how much overhead in time this is? (i.e. don't do this optimisation)
cc @kevaundray if he might want to chime in too.
There was a problem hiding this comment.
For option one, SZZ don't currently have this feature. I ran the benchmark for option 3, for our worst case(500mb), the overhead to be saved was 1ms, and for a regular block, the overhead is 0.2ms. This makes the optimization almost irrelevant. I would propose we just move on with option 3
Currently, for a zkVM prover to statelessly validate a block, the Execution Witness (the primary input needed for this validation) can only be obtained by calling two separate Engine API methods sequentially: first
engine_newPayload, thendebug_executionWitness. This introduces two fundamental problems:1. Impossible for zkAttestors to follow the exact head of the chain
A block must first be fully executed via
engine_newPayloadbeforedebug_executionWitnesscan be called. This means in the very best case, attestation happens one block behind the chain's head — making true real-time attestation impossible.2. Slow witness retrieval, especially for large witnesses
On an average Ethereum block, the Execution Witness is about 17 MB. Using the current two-call method:
debug_executionWitnessRetrieval (Geth)These numbers are from the Geth client.
Approach
The key question was: how do we cut this down enough for true real-time attestation?
Step 1: Combine the two calls
The first step was introducing
engine_newPayloadWithWitness, a JSON-RPC method that combinesengine_newPayloadanddebug_executionWitnessinto a single call. This gave roughly a 2× improvement — but was still not enough. The timeout forengine_newPayloadis 8 seconds; we need the new method to be comfortably within this budget even for worst-case blocks (~500 MB witness).Step 2: Fix the transport and serialization bottlenecks
Profiling
engine_newPayloadWithWitnessover JSON-RPC revealed two dominant bottlenecks:To address both, this spec introduces
POST /new-payload-with-witness:Step 3: Optimize the EL pipeline
After eliminating serialization and transport bottlenecks, the dominant cost was the EL's "store" phase (3,791 ms, 98% of block time). Profiling revealed that this was not CPU-bound by the Merkle trie update itself, but rather by I/O contention and blocking synchronization:
sync_channel(0)), forcing block N's in-memory trie update to wait until block N−1's disk flush completed. Decoupling these into two threads with a buffered channel eliminated the blocking.store_witnesswas on the critical path, serializing a ~300 MB witness and writing it to RocksDB beforestore_blockcould proceed. This was moved to a background thread since it's only needed fordebug_executionWitnessRPC lookups.These optimizations reduced the store phase from 3,791 ms to 726 ms (~5.2× improvement).
Benchmark Results
All benchmarks were performed using the Ethrex client running on a Kurtosis local testnet with Lighthouse as the CL. The test block contained 203 transactions consuming 36 Mgas, producing a ~302 MB SSZ-encoded witness which is about 500mb if it were to be JSON encoded.
engine_newPayloadWithWitness(JSON-RPC + JSON)Implementation: ethrex
engine_newPayloadWithWitnessLatency by witness size
Component breakdown (500 MB witness)
POST /new-payload-with-witness(HTTP + SSZ) — InitialImplementation: ethrex
feat/zkengine-http(before EL optimizations)Component breakdown (500 MB witness → 306 MB SSZ)
CL side:
POST /new-payload-with-witness(HTTP + SSZ) — OptimizedImplementation: ethrex
feat/zkengine-http(with EL pipeline optimizations)Component breakdown (302 MB SSZ witness, 203 txs, 36 Mgas)
Store breakdown (726 ms)
Summary comparison
Note
With the EL pipeline optimizations, the dominant cost has shifted from the Merkle trie update (previously ~3,500 ms) to witness generation (574 ms, 79% of the store phase). Witness generation involves re-traversing the state and storage tries with logging enabled to capture the pre-state and post-update trie nodes required for stateless validation. This is the current irreducible floor.
For more context on "Store"
The "store" phase is the EL's state commitment step. It makes the executed block's state available for subsequent blocks. After EL pipeline optimizations, its sub-steps are:
ExecutionWitness. This is the dominant cost — I/O-bound by MPT node reads.debug_executionWitnessRPC lookups. Can be deferred to a background task.The witness generation cost scales with the number of state accesses in the block and is unaffected by the choice of transport or serialization format.
Prototype Code
engine_newPayloadWithWitness(JSON-RPC method): In its Ethrex implementation here, a new JSON-RPC method was introduced. This method behaves exactly likeengine_newPayloadV5but additionally returns theExecutionWitnessof the block in the response.POST /new-payload-with-witness(HTTP + SSZ endpoint): In the Ethrex implementation here, the same logic is exposed as a REST endpoint on the Engine API server. The request accepts the same JSON parameters asengine_newPayloadV5. The response is SSZ-encoded bytes containing thePayloadStatusand theExecutionWitness.