Skip to content

feat(l1): add ssz encoding for witness in eip-8025#6524

Closed
benbencik wants to merge 1 commit into
lambdaclass:mainfrom
benbencik:ssz-deserialization
Closed

feat(l1): add ssz encoding for witness in eip-8025#6524
benbencik wants to merge 1 commit into
lambdaclass:mainfrom
benbencik:ssz-deserialization

Conversation

@benbencik
Copy link
Copy Markdown
Contributor

@benbencik benbencik commented Apr 24, 2026

Update: Let's wait until the changes are finalized in #6550

Motivation

Replace rkyv encoding of ExecutionWitness with SSZ. The structure of ExecutionWitness might change further, so this PR is a good middle step towards making EIP-8025 more spec-compliant.

Related to issue #6502, but this PR does not yet make decoding faster.

Description

Changes add SSZ encoding by using libssz

  • Added SszExecutionWitness as the SSZ representation for ExecutionWitness.
  • Implemented conversion between SSZ bytes and ExecutionWitness.
  • Integrated related changes from PR6516 feat(l1): fix EIP-8025 compliance #6516
  • Added byte encoding for ChainConfig

Can be (almost) directly plugged into ere-guests PR39

Copilot AI review requested due to automatic review settings April 24, 2026 15:39
@benbencik benbencik requested a review from a team as a code owner April 24, 2026 15:39
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 24, 2026

Greptile Summary

This PR replaces rkyv encoding of ExecutionWitness with SSZ, adds a typed ExecutionRequests struct replacing the raw byte-list in NewPayloadRequest, and introduces SSZ encode/decode for ChainConfig. The changes are a solid step toward EIP-8025 spec compliance.

  • P1to_ssz_bytes in block_execution_witness.rs uses .unwrap() on user-controlled trie node data (branch/leaf values, nibble prefixes) against hard-coded SSZ bounds; crafted witness data would cause a panic instead of returning an ExecutionWitnessSszError.
  • P1execution_program_eip8025_bytes calls validate_eip8025_execution(...).is_ok(), silently converting all internal errors (crypto failures, infra errors) into valid: false rather than propagating them as Err.

Confidence Score: 3/5

Not safe to merge as-is — two P1 issues could cause panics on untrusted witness data and silent error masking in the execution program entry point.

Two P1 findings: (1) .unwrap() panics on user-supplied trie node data exceeding SSZ list bounds in to_ssz_bytes, and (2) .is_ok() in execution_program_eip8025_bytes silently drops all internal errors as valid:false. Both are on the primary changed code paths.

crates/common/types/block_execution_witness.rs (unwrap panics), crates/guest-program/src/l1/program.rs (silent error swallowing)

Important Files Changed

Filename Overview
crates/common/types/block_execution_witness.rs Adds SSZ encoding/decoding for ExecutionWitness under feature eip-8025; contains multiple .unwrap() calls on user-controlled trie node data that will panic if SSZ bounds are exceeded
crates/guest-program/src/l1/program.rs Adds execution_program_eip8025_bytes helper; silently swallows all validation errors (including internal failures) via .is_ok(), and has a stale rkyv doc comment
crates/common/types/eip8025_ssz.rs Replaces flat execution_requests byte-list with typed ExecutionRequests struct; renames constants with full suffix; removes ExecutionPayloadHeader and header conversion helpers; adds to_encoded_requests() and tests
crates/common/types/genesis.rs Adds SSZ encode/decode for ChainConfig via SszChainConfig mirror struct; optional fields represented as SSZ union enums; straightforward mechanical mapping
crates/guest-program/src/l1/input.rs Replaces rkyv serialization of ExecutionWitness with SSZ in encode_eip8025/decode_eip8025; adds crypto parameter to decode_eip8025; error enums updated correctly
crates/guest-program/src/l1/mod.rs Exports new execution_program_eip8025_bytes under eip-8025 feature flag
crates/guest-program/src/lib.rs Re-exports execution_program_eip8025_bytes in the public execution module under the eip-8025 feature

Sequence Diagram

sequenceDiagram
    participant Caller
    participant input.rs as encode/decode_eip8025
    participant block_execution_witness.rs as ExecutionWitness
    participant program.rs as execution_program_eip8025_bytes
    participant eip8025_ssz.rs as NewPayloadRequest/ExecutionRequests

    Caller->>input.rs: encode_eip8025(new_payload_request, execution_witness)
    input.rs->>eip8025_ssz.rs: new_payload_request.to_ssz()
    input.rs->>block_execution_witness.rs: execution_witness.to_ssz_bytes()
    block_execution_witness.rs-->>input.rs: [ssz_len][ssz_bytes][witness_ssz_bytes]
    input.rs-->>Caller: wire bytes

    Caller->>program.rs: execution_program_eip8025_bytes(bytes, crypto)
    program.rs->>input.rs: decode_eip8025(bytes, crypto)
    input.rs->>eip8025_ssz.rs: NewPayloadRequest::from_ssz_bytes()
    input.rs->>block_execution_witness.rs: ExecutionWitness::from_ssz_bytes()
    input.rs-->>program.rs: (NewPayloadRequest, ExecutionWitness)
    program.rs->>eip8025_ssz.rs: hash_tree_root()
    program.rs->>program.rs: validate_eip8025_execution()
    note over program.rs: .is_ok() swallows internal errors as valid:false
    program.rs-->>Caller: ProgramOutput { request_root, valid }
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/guest-program/src/l1/program.rs
Line: 101

Comment:
**Silent error swallowing via `.is_ok()`**

`validate_eip8025_execution(...).is_ok()` converts every error — including internal infrastructure failures (OOM, crypto errors, panics caught upstream) — into `valid: false`, making them indistinguishable from legitimately invalid blocks. The caller receives `Ok(ProgramOutput { valid: false })` where it should receive an `Err`. By contrast, `execution_program` correctly propagates errors with `?`. This means real failures in `execution_program_eip8025_bytes` are silently misreported as invalid-but-successfully-processed blocks.

If returning `Ok` with `valid: false` for invalid blocks is intentional (e.g., for a proving circuit that needs to prove invalidity), then at minimum the `Internal` error variant should still be propagated, and only domain-level block-validity errors should become `valid: false`.

```suggestion
    let valid = validate_eip8025_execution(&new_payload_request, execution_witness, crypto)
        .map(|_| true)
        .map_err(|e| ExecutionError::Internal(format!("execution validation failed: {e}")))?;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/common/types/block_execution_witness.rs
Line: 213-234

Comment:
**Unwrap panics on trie node data that may exceed SSZ bounds**

Several `.unwrap()` calls inside `add_node` will panic if the underlying trie node data exceeds the hard-coded SSZ list bounds. The bounds are:

- `SszBranchNode.value`: max 128 bytes — a trie branch value can in theory be longer
- `SszExtensionNode.prefix` / `SszLeafNode.partial`: max 64 bytes
- `SszLeafNode.value`: max 128 bytes

Because `ExecutionWitness` is decoded from an untrusted wire input (the EIP-8025 byte stream), a crafted witness with oversized node values would cause a panic instead of a controlled `Err`. The outer helper `to_ssz_bytes` already returns a `Result` — these `unwrap()` calls should be replaced with `?` after mapping through `ExecutionWitnessSszError::InvalidSszType`, consistent with how other fields in the same function are handled.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/guest-program/src/l1/program.rs
Line: 86

Comment:
**Stale `[rkyv_bytes]` in doc comment**

The wire-format description still mentions `[rkyv_bytes]` even though the encoding was changed to SSZ in this PR. The corresponding `encode_eip8025` and `decode_eip8025` docs in `input.rs` were updated correctly.

```suggestion
/// The wire format is `[ssz_len: u32 LE][ssz_bytes][witness_ssz_bytes]`, matching
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: crates/common/types/block_execution_witness.rs
Line: 98

Comment:
**Unused constant `MAX_BYTES_PER_WITNESS_NODE`**

This constant is defined but never referenced anywhere in the module. It appears it should be used as the element-size bound when building the flattened `state_nodes` list, but the current implementation stores `SszNode` structs rather than raw bytes, so the constant is orphaned. If it's reserved for a future change, a `#[allow(dead_code)]` annotation or a comment explaining its intent would clarify the purpose. Otherwise it can be removed.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(l1): add ssz encoding for witness i..." | Re-trigger Greptile

})?;

let request_root = new_payload_request.hash_tree_root(&Sha2Hasher);
let valid = validate_eip8025_execution(&new_payload_request, execution_witness, crypto).is_ok();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Silent error swallowing via .is_ok()

validate_eip8025_execution(...).is_ok() converts every error — including internal infrastructure failures (OOM, crypto errors, panics caught upstream) — into valid: false, making them indistinguishable from legitimately invalid blocks. The caller receives Ok(ProgramOutput { valid: false }) where it should receive an Err. By contrast, execution_program correctly propagates errors with ?. This means real failures in execution_program_eip8025_bytes are silently misreported as invalid-but-successfully-processed blocks.

If returning Ok with valid: false for invalid blocks is intentional (e.g., for a proving circuit that needs to prove invalidity), then at minimum the Internal error variant should still be propagated, and only domain-level block-validity errors should become valid: false.

Suggested change
let valid = validate_eip8025_execution(&new_payload_request, execution_witness, crypto).is_ok();
let valid = validate_eip8025_execution(&new_payload_request, execution_witness, crypto)
.map(|_| true)
.map_err(|e| ExecutionError::Internal(format!("execution validation failed: {e}")))?;
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/guest-program/src/l1/program.rs
Line: 101

Comment:
**Silent error swallowing via `.is_ok()`**

`validate_eip8025_execution(...).is_ok()` converts every error — including internal infrastructure failures (OOM, crypto errors, panics caught upstream) — into `valid: false`, making them indistinguishable from legitimately invalid blocks. The caller receives `Ok(ProgramOutput { valid: false })` where it should receive an `Err`. By contrast, `execution_program` correctly propagates errors with `?`. This means real failures in `execution_program_eip8025_bytes` are silently misreported as invalid-but-successfully-processed blocks.

If returning `Ok` with `valid: false` for invalid blocks is intentional (e.g., for a proving circuit that needs to prove invalidity), then at minimum the `Internal` error variant should still be propagated, and only domain-level block-validity errors should become `valid: false`.

```suggestion
    let valid = validate_eip8025_execution(&new_payload_request, execution_witness, crypto)
        .map(|_| true)
        .map_err(|e| ExecutionError::Internal(format!("execution validation failed: {e}")))?;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +213 to +234
choices: SszList::try_from(choices).unwrap(),
value: SszList::try_from(branch.value.clone()).unwrap(),
})
}
Node::Extension(ext) => {
let ssz_child = match &ext.child {
NodeRef::Node(n, _) => add_node(n, state_nodes)?,
NodeRef::Hash(hash) => SszNodeRef::Hash(
SszList::try_from(hash.as_ref().to_vec()).map_err(|e| {
ExecutionWitnessSszError::InvalidSszType(e.to_string())
})?,
),
};
SszNode::Extension(SszExtensionNode {
prefix: SszList::try_from(ext.prefix.encode_compact()).unwrap(),
child: ssz_child,
})
}
Node::Leaf(leaf) => SszNode::Leaf(SszLeafNode {
partial: SszList::try_from(leaf.partial.encode_compact()).unwrap(),
value: SszList::try_from(leaf.value.clone()).unwrap(),
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Unwrap panics on trie node data that may exceed SSZ bounds

Several .unwrap() calls inside add_node will panic if the underlying trie node data exceeds the hard-coded SSZ list bounds. The bounds are:

  • SszBranchNode.value: max 128 bytes — a trie branch value can in theory be longer
  • SszExtensionNode.prefix / SszLeafNode.partial: max 64 bytes
  • SszLeafNode.value: max 128 bytes

Because ExecutionWitness is decoded from an untrusted wire input (the EIP-8025 byte stream), a crafted witness with oversized node values would cause a panic instead of a controlled Err. The outer helper to_ssz_bytes already returns a Result — these unwrap() calls should be replaced with ? after mapping through ExecutionWitnessSszError::InvalidSszType, consistent with how other fields in the same function are handled.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/types/block_execution_witness.rs
Line: 213-234

Comment:
**Unwrap panics on trie node data that may exceed SSZ bounds**

Several `.unwrap()` calls inside `add_node` will panic if the underlying trie node data exceeds the hard-coded SSZ list bounds. The bounds are:

- `SszBranchNode.value`: max 128 bytes — a trie branch value can in theory be longer
- `SszExtensionNode.prefix` / `SszLeafNode.partial`: max 64 bytes
- `SszLeafNode.value`: max 128 bytes

Because `ExecutionWitness` is decoded from an untrusted wire input (the EIP-8025 byte stream), a crafted witness with oversized node values would cause a panic instead of a controlled `Err`. The outer helper `to_ssz_bytes` already returns a `Result` — these `unwrap()` calls should be replaced with `?` after mapping through `ExecutionWitnessSszError::InvalidSszType`, consistent with how other fields in the same function are handled.

How can I resolve this? If you propose a fix, please make it concise.

/// Decode and execute the L1 stateless validation program from EIP-8025 wire
/// bytes.
///
/// The wire format is `[ssz_len: u32 LE][ssz_bytes][rkyv_bytes]`, matching
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 Stale [rkyv_bytes] in doc comment

The wire-format description still mentions [rkyv_bytes] even though the encoding was changed to SSZ in this PR. The corresponding encode_eip8025 and decode_eip8025 docs in input.rs were updated correctly.

Suggested change
/// The wire format is `[ssz_len: u32 LE][ssz_bytes][rkyv_bytes]`, matching
/// The wire format is `[ssz_len: u32 LE][ssz_bytes][witness_ssz_bytes]`, matching
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/guest-program/src/l1/program.rs
Line: 86

Comment:
**Stale `[rkyv_bytes]` in doc comment**

The wire-format description still mentions `[rkyv_bytes]` even though the encoding was changed to SSZ in this PR. The corresponding `encode_eip8025` and `decode_eip8025` docs in `input.rs` were updated correctly.

```suggestion
/// The wire format is `[ssz_len: u32 LE][ssz_bytes][witness_ssz_bytes]`, matching
```

How can I resolve this? If you propose a fix, please make it concise.

const MAX_WITNESS_NODES: usize = 1 << 20;
const MAX_WITNESS_CODES: usize = 1 << 16;
const MAX_WITNESS_HEADERS: usize = 256;
const MAX_BYTES_PER_WITNESS_NODE: usize = 1 << 20;
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 Unused constant MAX_BYTES_PER_WITNESS_NODE

This constant is defined but never referenced anywhere in the module. It appears it should be used as the element-size bound when building the flattened state_nodes list, but the current implementation stores SszNode structs rather than raw bytes, so the constant is orphaned. If it's reserved for a future change, a #[allow(dead_code)] annotation or a comment explaining its intent would clarify the purpose. Otherwise it can be removed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/types/block_execution_witness.rs
Line: 98

Comment:
**Unused constant `MAX_BYTES_PER_WITNESS_NODE`**

This constant is defined but never referenced anywhere in the module. It appears it should be used as the element-size bound when building the flattened `state_nodes` list, but the current implementation stores `SszNode` structs rather than raw bytes, so the constant is orphaned. If it's reserved for a future change, a `#[allow(dead_code)]` annotation or a comment explaining its intent would clarify the purpose. Otherwise it can be removed.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

})?;

let request_root = new_payload_request.hash_tree_root(&Sha2Hasher);
let valid = validate_eip8025_execution(&new_payload_request, execution_witness, crypto).is_ok();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: this collapses every error path — payload conversion, blob hash mismatch, witness conversion bugs, EVM internal errors, OOM-like infra failures — into a normal valid: false outcome. Wrong shape for a stateless verifier: it makes infra bugs look like consensus-relevant rejections. The earlier execution_program propagates Err(...) for those cases, which is correct. Either match it (only valid: false for the categories EIP-8025 §Tolerance permits) or document explicitly which errors map to valid: false and assert with a typed enum split. (Greptile flagged.)

choices.push(ssz_child);
}
SszNode::Branch(SszBranchNode {
choices: SszList::try_from(choices).unwrap(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: SszList::try_from(...).unwrap() here (and lines 141, 154, 159, 160, 178, 193, 194) panics on overflow. Bounds:

  • SszBranchNode.value: 128 — branch values for state trie can hit ~110 bytes (RLP-encoded AccountState), so 128 is on the edge.
  • SszLeafNode.value: 128 — same.
  • SszExtensionNode.prefix: 64 / SszLeafNode.partial: 64encode_compact() outputs of nibble paths up to 64 nibbles, worst case 33 bytes. Comfortable but un-asserted.

Replace each unwrap() with ?-propagating try_from and let the existing ExecutionWitnessSszError::InvalidSszType carry the error up. Encoder side, where input is locally trusted, but still a robustness-vs-spec-evolution bug. (Greptile flagged.)

}
}

fn rebuild_node_by_index(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Decoder DoS via cyclic references: this recurses into SszNodeRef::Index(i) without checking that i < current_index (or any forward-only invariant). The encoder is post-order so indices are forward-only by construction, but a malicious witness can craft nodes[0] referencing nodes[1] referencing nodes[0] → infinite recursion → stack overflow. For a verifier accepting bytes from untrusted sources (CL relaying execution proofs), that's a DoS vector. Add an explicit i as usize > current_node_index check, or a visited-set, or convert to iterative reconstruction with topological-order validation.

const MAX_WITNESS_CODES: usize = 1 << 16;
const MAX_WITNESS_HEADERS: usize = 256;
const MAX_BYTES_PER_WITNESS_NODE: usize = 1 << 20;
const MAX_BYTES_PER_CODE: usize = 1 << 24;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MAX_BYTES_PER_CODE = 1<<24 (16 MB) is much larger than EIP-170's 24576-byte cap and EIP-3860's 49152 init-code cap. Tighter bound = tighter spec contract = fewer witness shapes a verifier must accept. Tighten to 49_152 (covers init code) or document why the loose bound is intentional.

const MAX_BYTES_PER_HEADER: usize = 1 << 10;

// TODO: think about these constants
const MAX_CHAIN_CONFIG_BYTES: usize = 1 << 10;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TODO: think about these constants — worth resolving in this PR rather than carrying it forward. 1024 is fine for the current 30-ish-field config, but the TODO invites bit-rot.

const MAX_WITNESS_NODES: usize = 1 << 20;
const MAX_WITNESS_CODES: usize = 1 << 16;
const MAX_WITNESS_HEADERS: usize = 256;
const MAX_BYTES_PER_WITNESS_NODE: usize = 1 << 20;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Greptile P2) MAX_BYTES_PER_WITNESS_NODE declared but never referenced. Current encoding stores SszNode structs directly in SszList<SszNode, MAX_WITNESS_NODES>, so per-node byte bounds aren't enforceable at the SSZ-list level. Either remove the constant, or restructure to encode each node as SszList<u8, MAX_BYTES_PER_WITNESS_NODE> and parse on decode (latter buys a uniform bound but adds an indirection).

/// Decode and execute the L1 stateless validation program from EIP-8025 wire
/// bytes.
///
/// The wire format is `[ssz_len: u32 LE][ssz_bytes][rkyv_bytes]`, matching
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Greptile P2) Stale doc comment: still says [rkyv_bytes] even though the encoding flipped to SSZ in this PR. Wire format is now SSZ end-to-end.


pub fn from_ssz_bytes(
bytes: &[u8],
_crypto: &dyn Crypto,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_crypto: &dyn Crypto is unused. Either wire it through future signature recovery / hash verification or drop the parameter — keeping unused parameters in a feature-gated path tends to bit-rot.

@benbencik benbencik marked this pull request as draft May 1, 2026 11:45
@benbencik benbencik closed this May 12, 2026
jsign pushed a commit to jsign/ethrex that referenced this pull request May 12, 2026
…6549)

**Motivation**
Earlier work lambdaclass#6365 added `&dyn Crypto`, so zkVM can route hashing to
precompiles. However, it is not applied everywhere in the eip-8025
execution path.

**Description**
The
[execution_program](https://github.com/lambdaclass/ethrex/blob/main/crates/guest-program/src/l1/program.rs#L73)
is calling `hash_tree_root` with `libssz_merkle::Sha2Hasher`. This fix
implements `libssz_merkle::Sha256Hasher` trait for `&dyn Crypto` such
that hash precompiles can be used in computing the hash of the tree
root.

This fix **significantly reduces the AIR-costs** inside ZisK v0.16.1,
and it is the main reason for the previously observed inefficiency in
lambdaclass#6524. Profiling on mainnet blocks: 24949787 - 24949836:
- Average cost (main `1e85b1c36`): 57.6B
- Average cost (this change): 45.2B

---------

Co-authored-by: ElFantasma <estebandh@gmail.com>
Co-authored-by: Ivan Litteri <67517699+ilitteri@users.noreply.github.com>
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.

3 participants