Skip to content

feat(l1): support canonical SSZ input bytes#6550

Open
jsign wants to merge 4 commits intolambdaclass:mainfrom
jsign:jsign-8025-canonical-input
Open

feat(l1): support canonical SSZ input bytes#6550
jsign wants to merge 4 commits intolambdaclass:mainfrom
jsign:jsign-8025-canonical-input

Conversation

@jsign
Copy link
Copy Markdown
Contributor

@jsign jsign commented Apr 29, 2026

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 call Canonical). 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 sszInputBytes from 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:

  1. The new Canonical bytes have format 0x02 + size(SSZ_bytes_EEST) + SSZ_bytes_EEST + size(Ethrex_ChainConfig_rkyv) + Ethrex_ChainConfig_rkyv. The main reason for this is that the full ChainConfig isn't standarized in EEST. Started a discussion to see if this can be more formalized in the specs.
  2. The way the canonical ExecutionWitness is transformed into usable tries leverages the existing method that was useful for the Legacy style, 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:

  • Introduces versioned EIP-8025 decoding:
    • 0x00 for the existing legacy payload format
    • 0x01 for canonical stateless inputs
  • Adds canonical SSZ input types for Amsterdam payloads, execution witnesses, chain config, and public keys.
  • Reconstructs Amsterdam NewPayloadRequest values into L1 blocks, including block access list hash, slot number, request hash, block hash, and versioned hash validation.
  • Converts canonical witnesses into the existing execution witness path so canonical inputs reuse the current stateless execution flow.
  • Validates block access list hashes during guest block execution.

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 statelessInputBytes works as expected.

@jsign jsign changed the title feat(eip-8025): support canonical SSZ input bytes feat(l1): support canonical SSZ input bytes Apr 29, 2026
Comment on lines +20 to +24
/// 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;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +169 to +179
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)
})?;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Since prev code had _bal, this validation was missing.

Comment on lines +49 to +50
/// Chain ID from the stateless validation chain configuration.
pub chain_id: u64,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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")]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Next four methods are simply extracted existing logic so can be reused between the legacy and canonical block processing.

Ok(())
}

#[cfg(feature = "eip-8025")]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +380 to +381
let rpc_witness = canonical_execution_witness_to_rpc(stateless_input.witness);
let execution_witness = rpc_witness.into_execution_witness(chain_config, block_number)?;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@jsign jsign marked this pull request as ready for review April 30, 2026 23:58
@jsign jsign requested a review from a team as a code owner April 30, 2026 23:58
Copilot AI review requested due to automatic review settings April 30, 2026 23:58
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 1, 2026

Greptile Summary

This PR adds versioned EIP-8025 wire decoding to the L1 guest program, introducing a 0x01 canonical framing (Amsterdam StatelessInput SSZ + rkyv ChainConfig) alongside the existing 0x00 legacy framing, and extends ProgramOutput with chain_id (33 → 41 bytes). Note that the PR description body mentions 0x02 as the canonical version byte while the code and the PR summary both use 0x01 — external implementors should follow the code, not the prose description.

Confidence Score: 4/5

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

Important Files Changed

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
Loading

Comments Outside Diff (1)

  1. docs/eip-8025.md, line 403-416 (link)

    P2 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]
    
    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

Comment on lines 203 to 248
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")]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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 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 (0x00 legacy, 0x01 canonical) 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_id and 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.

Comment on lines +175 to 178
fn decode_eip8025_legacy(
bytes: &[u8],
) -> Result<
(
Comment on lines +216 to +236
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);
}
Comment on lines +31 to +37
/// 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;
Comment thread docs/eip-8025.md
Comment on lines 401 to +405
**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
Comment on lines 17 to +24
/// `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;
Comment on lines +73 to +109
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,
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants