feat(l1): support canonical SSZ input bytes#6550
feat(l1): support canonical SSZ input bytes#6550jsign wants to merge 4 commits intolambdaclass:mainfrom
Conversation
Signed-off-by: jsign <jsign.uy@gmail.com>
…pec compliance issue
| /// TODO: the specs have a non-compliant value compared to consensus | ||
| /// specs. Whenever the specs can resolve an underlying issue, | ||
| /// this value should be updated. | ||
| /// See https://github.com/ethereum/execution-specs/blob/ec23140720d6a9257a907c470ba1874623bd7b50/src/ethereum/forks/amsterdam/stateless_ssz.py#L40-L43 | ||
| const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 65536; |
There was a problem hiding this comment.
There is a current issue in the testing framework that forced us to temporarily use a non-consensus valid value. For now, sticking to the correct max since, if not, the hash tree root won't match the fixture expectations.
This will be eventually fixed, but leaving a comment here since somebody else might find the value surprising.
| pub excess_blob_gas: u64, | ||
| } | ||
|
|
||
| /// SSZ `ExecutionPayload` execution payload V4. |
There was a problem hiding this comment.
The current EEST zkVM releases are based on the latest BAL devnet.
The current NewPayloadRequest is based on the current active fork in mainnet. I'm adding support now for new execution witness and new payload request versions for Amsterdam.
I think at some point it might be worth moving these structs to another module for better organisation or similar, but it might be good as is for a while.
| if let Some(bal) = &bal { | ||
| report_cycles("validate_block_access_list_hash", || { | ||
| validate_block_access_list_hash( | ||
| &block.header, | ||
| &chain_config, | ||
| bal, | ||
| block.body.transactions.len(), | ||
| ) | ||
| .map_err(ExecutionError::BlockValidation) | ||
| })?; | ||
| } |
There was a problem hiding this comment.
Since prev code had _bal, this validation was missing.
| /// Chain ID from the stateless validation chain configuration. | ||
| pub chain_id: u64, |
There was a problem hiding this comment.
Technically is needed for the spec StatelessValidationResult.
This part of the API is a bit unstable, so not sure yet if this will be the final form. Opened a convo to explore this a bit more with this doc.
For now sticking to the current specs -- we can easily adjust whenever that convo comes to some conclusion and we change the specs.
| new_payload_request_root: request_root, | ||
| valid, | ||
| }) | ||
| #[cfg(feature = "eip-8025")] |
There was a problem hiding this comment.
Next four methods are simply extracted existing logic so can be reused between the legacy and canonical block processing.
| Ok(()) | ||
| } | ||
|
|
||
| #[cfg(feature = "eip-8025")] |
There was a problem hiding this comment.
As mentioned in the PR description, in my PR I'm focusing on Ethrex working with canonical SSZ bytes and not necessarily being efficient.
I've the sensation the canonical ExecutionWitness validation might have some other more efficient form in the future, but I think that can be done in later work.
Or maybe this approach is totally fine -- requires some benchmarks to be sure.
| let rpc_witness = canonical_execution_witness_to_rpc(stateless_input.witness); | ||
| let execution_witness = rpc_witness.into_execution_witness(chain_config, block_number)?; |
There was a problem hiding this comment.
To highlight reg my previous comment: these are the two lines that might require to be evaluated if are optimal, since the RPC->Execution witness logic today I think was mainly thought to happen in the host.
Greptile SummaryThis PR adds versioned EIP-8025 wire decoding to the L1 guest program, introducing a Confidence Score: 4/5Safe to merge as a draft; only P2 documentation gaps found — no correctness or security issues in the execution path. All findings are P2 (stale docs, silently-ignored trailing bytes in canonical decode). Core logic — versioned dispatch, BAL round-trip check, block-hash validation, chain-id consistency — appears correct. The output-format breaking change (33 → 41 bytes) is intentional and the relevant call sites are updated. docs/eip-8025.md (Input and Wire format sections not updated); crates/guest-program/src/l1/input.rs (trailing-bytes behaviour in canonical decoder).
|
| Filename | Overview |
|---|---|
| crates/guest-program/src/l1/program.rs | Core dispatch logic for versioned EIP-8025 decode/execute; adds canonical Amsterdam block reconstruction with BAL hash validation and block-hash round-trip check. Well-structured refactors. No critical logic issues found. |
| crates/guest-program/src/l1/input.rs | Introduces versioned wire decode (0x00 legacy / 0x01 canonical) and canonical SSZ types. Trailing bytes in canonical payload are silently ignored (P2). Breaking wire-format change: legacy encoding now requires a 0x00 prefix that was absent before. |
| crates/common/types/eip8025_ssz.rs | Adds ExecutionPayloadV4 (Amsterdam, includes block_access_list and slot_number) and NewPayloadRequestAmsterdam; bumps MAX_WITHDRAWALS_PER_PAYLOAD to 65536 with a TODO referencing the upstream spec discrepancy. |
| crates/guest-program/src/common/execution.rs | Wires up validate_block_access_list_hash into the per-block execution loop for Amsterdam blocks when execute_block returns a non-None BAL. |
| crates/guest-program/src/l1/output.rs | Extends ProgramOutput with chain_id: u64, changing the encoded output from 33 to 41 bytes. Breaking change for existing verifiers; doc and prover/exec.rs updated accordingly. |
| docs/eip-8025.md | Output format updated to 41 bytes, but the Input and Wire format sections still describe the pre-versioning layout — a documentation gap that will mislead external callers. |
| crates/prover/src/backend/exec.rs | Trivial update: populates chain_id in the non-EIP-8025 exec-backend stub output to match the expanded ProgramOutput. |
| crates/guest-program/src/l1/mod.rs | Re-exports new public types from input.rs (CanonicalChainConfig, CanonicalExecutionWitness, CanonicalStatelessInput, DecodedEip8025, version constants, decode/encode functions). |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["EIP-8025 bytes"] --> B["decode_eip8025(bytes)"]
B --> C{version byte}
C -->|0x00 Legacy| D["decode_eip8025_legacy\n[ssz_len u32][ssz_bytes][rkyv witness]"]
C -->|0x01 Canonical| E["decode_eip8025_canonical\n[ssz_len u32][ssz_bytes][cfg_len u32][rkyv ChainConfig]"]
C -->|other| F["Err: UnknownVersion"]
D --> G["DecodedEip8025::Legacy\n{NewPayloadRequest, ExecutionWitness}"]
E --> H["DecodedEip8025::Canonical\n{CanonicalStatelessInput, ChainConfig}"]
G --> I["validate_eip8025_execution\nnew_payload_request_to_block\nvalidate block_hash\nvalidate versioned_hashes\nexecute_blocks"]
H --> J["validate_eip8025_canonical_execution\nchain_id consistency check\ncanonical_execution_witness_to_rpc\ninto_execution_witness\nvalidate_eip8025_amsterdam_execution"]
J --> K["new_payload_request_amsterdam_to_block\ndecode BAL, validate ordering\ncanonical encoding check\ncompute BAL hash in header\nvalidate block_hash"]
K --> L["validate_versioned_hashes\nexecute_blocks\nvalidate_block_access_list_hash"]
I --> M["ProgramOutput\n{root, valid, chain_id}"]
L --> M
Comments Outside Diff (1)
-
docs/eip-8025.md, line 403-416 (link)Stale wire-format documentation
Both the Input description (line 403) and the Wire format block (line 416) reflect the pre-versioning format. After this PR the first byte is a version discriminator (
0x00= legacy,0x01= canonical), and each variant has a distinct layout. Readers following this doc to implement a caller will produce bytes thatdecode_eip8025rejects.The Wire format section should be updated to document both variants:
Wire format (legacy, version = 0x00): [0x00] [ssz_len: u32 LE] [ssz_bytes] [rkyv ExecutionWitness] Wire format (canonical, version = 0x01): [0x01] [ssz_len: u32 LE] [ssz_bytes] [cfg_len: u32 LE] [rkyv ChainConfig]Prompt To Fix With AI
This is a comment left during a code review. Path: docs/eip-8025.md Line: 403-416 Comment: **Stale wire-format documentation** Both the Input description (line 403) and the Wire format block (line 416) reflect the pre-versioning format. After this PR the first byte is a version discriminator (`0x00` = legacy, `0x01` = canonical), and each variant has a distinct layout. Readers following this doc to implement a caller will produce bytes that `decode_eip8025` rejects. The Wire format section should be updated to document both variants: ``` Wire format (legacy, version = 0x00): [0x00] [ssz_len: u32 LE] [ssz_bytes] [rkyv ExecutionWitness] Wire format (canonical, version = 0x01): [0x01] [ssz_len: u32 LE] [ssz_bytes] [cfg_len: u32 LE] [rkyv ChainConfig] ``` How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
docs/eip-8025.md:403-416
**Stale wire-format documentation**
Both the Input description (line 403) and the Wire format block (line 416) reflect the pre-versioning format. After this PR the first byte is a version discriminator (`0x00` = legacy, `0x01` = canonical), and each variant has a distinct layout. Readers following this doc to implement a caller will produce bytes that `decode_eip8025` rejects.
The Wire format section should be updated to document both variants:
```
Wire format (legacy, version = 0x00): [0x00] [ssz_len: u32 LE] [ssz_bytes] [rkyv ExecutionWitness]
Wire format (canonical, version = 0x01): [0x01] [ssz_len: u32 LE] [ssz_bytes] [cfg_len: u32 LE] [rkyv ChainConfig]
```
### Issue 2 of 2
crates/guest-program/src/l1/input.rs:203-248
**Trailing bytes in canonical payload are silently ignored**
If `bytes.len() > cfg_end`, the bytes beyond `cfg_end` are not validated and are silently dropped. Any caller that accidentally appends extra data (e.g. due to a serialisation bug) will see a successful decode rather than an error, making such bugs harder to detect. The legacy path has the same property by design (the rkyv tail is unbounded), but for the length-prefixed canonical format a strict equality check would catch accidental padding earlier.
Consider adding:
```rust
if bytes.len() != cfg_end {
return Err(ProgramInputDecodeError::TooShort); // or a new TrailingBytes variant
}
```
Reviews (1): Last reviewed commit: "fix(eip8025): update MAX_WITHDRAWALS_PER..." | Re-trigger Greptile
| Ok((new_payload_request, execution_witness)) | ||
| } | ||
|
|
||
| #[cfg(feature = "eip-8025")] | ||
| fn decode_eip8025_canonical( | ||
| bytes: &[u8], | ||
| ) -> Result<(CanonicalStatelessInput, ethrex_common::types::ChainConfig), ProgramInputDecodeError> { | ||
| use libssz::SszDecode; | ||
|
|
||
| if bytes.len() < 4 { | ||
| return Err(ProgramInputDecodeError::TooShort); | ||
| } | ||
| let ssz_len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; | ||
| let cfg_len_off = 4usize | ||
| .checked_add(ssz_len) | ||
| .ok_or(ProgramInputDecodeError::TooShort)?; | ||
| if bytes.len() < cfg_len_off + 4 { | ||
| return Err(ProgramInputDecodeError::TooShort); | ||
| } | ||
| let ssz_bytes = &bytes[4..cfg_len_off]; | ||
|
|
||
| let cfg_len = u32::from_le_bytes([ | ||
| bytes[cfg_len_off], | ||
| bytes[cfg_len_off + 1], | ||
| bytes[cfg_len_off + 2], | ||
| bytes[cfg_len_off + 3], | ||
| ]) as usize; | ||
| let cfg_off = cfg_len_off + 4; | ||
| let cfg_end = cfg_off | ||
| .checked_add(cfg_len) | ||
| .ok_or(ProgramInputDecodeError::TooShort)?; | ||
| if bytes.len() < cfg_end { | ||
| return Err(ProgramInputDecodeError::TooShort); | ||
| } | ||
| let cfg_bytes = &bytes[cfg_off..cfg_end]; | ||
|
|
||
| let stateless_input = | ||
| CanonicalStatelessInput::from_ssz_bytes(ssz_bytes).map_err(ProgramInputDecodeError::Ssz)?; | ||
| let chain_config = | ||
| rkyv::from_bytes::<ethrex_common::types::ChainConfig, rkyv::rancor::Error>(cfg_bytes) | ||
| .map_err(|e| ProgramInputDecodeError::Rkyv(e.to_string()))?; | ||
|
|
||
| Ok((stateless_input, chain_config)) | ||
| } | ||
|
|
||
| #[cfg(feature = "eip-8025")] |
There was a problem hiding this comment.
Trailing bytes in canonical payload are silently ignored
If bytes.len() > cfg_end, the bytes beyond cfg_end are not validated and are silently dropped. Any caller that accidentally appends extra data (e.g. due to a serialisation bug) will see a successful decode rather than an error, making such bugs harder to detect. The legacy path has the same property by design (the rkyv tail is unbounded), but for the length-prefixed canonical format a strict equality check would catch accidental padding earlier.
Consider adding:
if bytes.len() != cfg_end {
return Err(ProgramInputDecodeError::TooShort); // or a new TrailingBytes variant
}Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/guest-program/src/l1/input.rs
Line: 203-248
Comment:
**Trailing bytes in canonical payload are silently ignored**
If `bytes.len() > cfg_end`, the bytes beyond `cfg_end` are not validated and are silently dropped. Any caller that accidentally appends extra data (e.g. due to a serialisation bug) will see a successful decode rather than an error, making such bugs harder to detect. The legacy path has the same property by design (the rkyv tail is unbounded), but for the length-prefixed canonical format a strict equality check would catch accidental padding earlier.
Consider adding:
```rust
if bytes.len() != cfg_end {
return Err(ProgramInputDecodeError::TooShort); // or a new TrailingBytes variant
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Pull request overview
Adds versioned EIP-8025 input decoding to the L1 guest program so it can accept both the existing “legacy” framing and the new canonical SSZ stateless input bytes (e.g., from EEST zkEVM fixtures), while keeping the current stateless execution flow.
Changes:
- Introduces version-dispatched EIP-8025 wire decoding (
0x00legacy,0x01canonical) plus canonical SSZ container types. - Adds Amsterdam-specific SSZ payload/request types and reconstructs Amsterdam blocks (incl. BAL hash + slot number) for execution/validation.
- Extends EIP-8025 program output commitment to include
chain_idand validates BAL hashes during execution.
Reviewed changes
Copilot reviewed 9 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/eip-8025.md | Updates documented EIP-8025 output commitment size/fields. |
| crates/prover/src/backend/exec.rs | Propagates chain_id into the Exec backend’s EIP-8025 dummy output. |
| crates/guest-program/src/l1/program.rs | Adds versioned decoding dispatch + canonical execution path + Amsterdam block reconstruction/BAL handling. |
| crates/guest-program/src/l1/output.rs | Extends EIP-8025 ProgramOutput encoding to include chain_id (41 bytes). |
| crates/guest-program/src/l1/mod.rs | Re-exports new EIP-8025 input API items (versions/types). |
| crates/guest-program/src/l1/input.rs | Adds version byte framing + canonical SSZ structs + decoding entrypoint returning DecodedEip8025. |
| crates/guest-program/src/common/execution.rs | Validates block_access_list_hash when VM returns a BAL. |
| crates/guest-program/bin/*/Cargo.lock | Pulls in libssz-types / libssz-derive for guest binaries. |
| crates/guest-program/Cargo.toml | Adds optional libssz-types / libssz-derive under eip-8025 feature. |
| crates/common/types/eip8025_ssz.rs | Adds Amsterdam ExecutionPayloadV4 + NewPayloadRequestAmsterdam and adjusts SSZ limits. |
| Cargo.lock | Locks new libssz-* deps at the workspace level. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn decode_eip8025_legacy( | ||
| bytes: &[u8], | ||
| ) -> Result< | ||
| ( |
| let cfg_len_off = 4usize | ||
| .checked_add(ssz_len) | ||
| .ok_or(ProgramInputDecodeError::TooShort)?; | ||
| if bytes.len() < cfg_len_off + 4 { | ||
| return Err(ProgramInputDecodeError::TooShort); | ||
| } | ||
| let ssz_bytes = &bytes[4..cfg_len_off]; | ||
|
|
||
| let cfg_len = u32::from_le_bytes([ | ||
| bytes[cfg_len_off], | ||
| bytes[cfg_len_off + 1], | ||
| bytes[cfg_len_off + 2], | ||
| bytes[cfg_len_off + 3], | ||
| ]) as usize; | ||
| let cfg_off = cfg_len_off + 4; | ||
| let cfg_end = cfg_off | ||
| .checked_add(cfg_len) | ||
| .ok_or(ProgramInputDecodeError::TooShort)?; | ||
| if bytes.len() < cfg_end { | ||
| return Err(ProgramInputDecodeError::TooShort); | ||
| } |
| /// Wire-format version byte for the legacy EIP-8025 framing. | ||
| #[cfg(feature = "eip-8025")] | ||
| pub const EIP8025_VERSION_LEGACY: u8 = 0x00; | ||
|
|
||
| /// Wire-format version byte for the canonical EIP-8025 framing. | ||
| #[cfg(feature = "eip-8025")] | ||
| pub const EIP8025_VERSION_CANONICAL: u8 = 0x01; |
| **EIP-8025 mode** (with `eip-8025` feature): | ||
| ``` | ||
| Input: (NewPayloadRequest [SSZ], ExecutionWitness [rkyv]) | ||
| Output: ProgramOutput { new_payload_request_root: [u8; 32], valid: bool } | ||
| → 33 bytes: 32-byte root + 1-byte boolean | ||
| Output: ProgramOutput { new_payload_request_root: [u8; 32], valid: bool, chain_id: u64 } | ||
| → 41 bytes: 32-byte root + 1-byte boolean + 8-byte chain_id |
| /// `MAX_TRANSACTIONS_PER_PAYLOAD` (Electra). | ||
| const MAX_TRANSACTIONS_PER_PAYLOAD: usize = 1_048_576; | ||
| /// `MAX_WITHDRAWALS_PER_PAYLOAD` (Electra). | ||
| const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 16; | ||
| /// TODO: the specs have a non-compliant value compared to consensus | ||
| /// specs. Whenever the specs can resolve an underlying issue, | ||
| /// this value should be updated. | ||
| /// See https://github.com/ethereum/execution-specs/blob/ec23140720d6a9257a907c470ba1874623bd7b50/src/ethereum/forks/amsterdam/stateless_ssz.py#L40-L43 | ||
| const MAX_WITHDRAWALS_PER_PAYLOAD: usize = 65536; |
| let decoded = super::decode_eip8025(bytes).map_err(|err| { | ||
| ExecutionError::Internal(format!("failed to decode EIP-8025 input: {err}")) | ||
| })?; | ||
|
|
||
| let request_root = new_payload_request.hash_tree_root(&Sha2Hasher); | ||
| let valid = validate_eip8025_execution(&new_payload_request, execution_witness, crypto).is_ok(); | ||
| match decoded { | ||
| DecodedEip8025::Legacy { | ||
| new_payload_request, | ||
| execution_witness, | ||
| } => { | ||
| let request_root = new_payload_request.hash_tree_root(&Sha2Hasher); | ||
| let chain_id = execution_witness.chain_config.chain_id; | ||
| let valid = | ||
| validate_eip8025_execution(&new_payload_request, execution_witness, crypto).is_ok(); | ||
|
|
||
| Ok(ProgramOutput { | ||
| new_payload_request_root: request_root, | ||
| valid, | ||
| chain_id, | ||
| }) | ||
| } | ||
| DecodedEip8025::Canonical { | ||
| stateless_input, | ||
| chain_config, | ||
| } => { | ||
| let request_root = stateless_input | ||
| .new_payload_request | ||
| .hash_tree_root(&Sha2Hasher); | ||
| let chain_id = stateless_input.chain_config.chain_id; | ||
| let valid = | ||
| validate_eip8025_canonical_execution(stateless_input, chain_config, crypto).is_ok(); | ||
|
|
||
| Ok(ProgramOutput { | ||
| new_payload_request_root: request_root, | ||
| valid, | ||
| chain_id, | ||
| }) | ||
| } |
Adds support for canonical EIP-8025 stateless input bytes in the L1 guest program.
The goal of this PR is to take the "last step" regarding API support towards the execution-specs definition for the guest program. For context and reference, these bytes are SSZ of StatelessInput.
Ethrex guest program still supports both the current SSZ bytes (what I now call
Legacy) and the new canonical SSZ bytes (which I now callCanonical). The SSZ byte decoder now expects a single byte at the front, which indicates which bytes were sent, so it can decode accordingly.The reality is that this two-style support is somewhat temporary, since medium/long term we should aim the canonical being the only one. But prefer to walk this middle step since we need all guest program users to always support generating canonical inputs.
The main goal here is to have a way for Ethrex to receive the
sszInputBytesfrom the zkEVM EEST fixtures directly without any middle layer manipulation, which always adds complexity and more surface for bugs in transformations.Two main notes to have in mind:
Canonicalbytes have format0x02 + size(SSZ_bytes_EEST) + SSZ_bytes_EEST + size(Ethrex_ChainConfig_rkyv) + Ethrex_ChainConfig_rkyv. The main reason for this is that the fullChainConfigisn't standarized in EEST. Started a discussion to see if this can be more formalized in the specs.ExecutionWitnessis transformed into usable tries leverages the existing method that was useful for theLegacystyle, but which was used at the host level. I suspect doing this in the guest will be slow, but for now I'm only interested in Ethrex supporting spec input bytes. I think further PRs/optimizations can always find more optimal ways to do this.Summary of changes:
0x00for the existing legacy payload format0x01for canonical stateless inputsNewPayloadRequestvalues into L1 blocks, including block access list hash, slot number, request hash, block hash, and versioned hash validation.Leaving this PR as a draft for a bit since I want to run some extra tests in ere-guests using EEST fixtures and double-check the whole pipeline of running official EEST fixture releases
statelessInputBytesworks as expected.