Skip to content

fix(l1): align to latest EIP-8025 spec and unskip stateless validation tests#6740

Open
iovoid wants to merge 19 commits into
mainfrom
fix/eip8025-stateless-validation
Open

fix(l1): align to latest EIP-8025 spec and unskip stateless validation tests#6740
iovoid wants to merge 19 commits into
mainfrom
fix/eip8025-stateless-validation

Conversation

@iovoid
Copy link
Copy Markdown
Contributor

@iovoid iovoid commented May 27, 2026

Motivation

The stateless execution zkEVM fixtures have been updated to reflect bal-devnet-7 spec (which we currently implement) and some other minor changes to the wire format.

Description

Unskips all of the EIP-8025 tests, implements the latest changes to the ProgramInput, and closes validation gaps.

Since we were using the old witness format, a small refactor is needed to keep support.

In the process we found a divergence with the spec in how CALLs order gas checks and state reads. Fixing this was necessary since we were requiring a code to be in the witness when that wasn't needed.

@iovoid iovoid requested a review from a team as a code owner May 27, 2026 19:45
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 27, 2026

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Known Issues

Tests intentionally excluded from CI. Source of truth for the Known
Issues
section the L1 workflow appends to each ef-tests job summary
and posts as a sticky PR comment.

@github-actions github-actions Bot added the L1 Ethereum client label May 27, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 27, 2026

Lines of code report

Total lines added: 299
Total lines removed: 2
Total lines changed: 301

Detailed view
+-------------------------------------------------------+-------+------+
| File                                                  | Lines | Diff |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/types/block_execution_witness.rs | 581   | +11  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/input.rs           | 306   | +81  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/mod.rs             | 16    | +3   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/program.rs         | 523   | +130 |
+-------------------------------------------------------+-------+------+
| ethrex/crates/prover/src/backend/exec.rs              | 88    | -2   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/prover/src/lib.rs                       | 25    | +10  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/call_frame.rs               | 382   | +8   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/system.rs   | 1033  | +56  |
+-------------------------------------------------------+-------+------+

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have enough context for a thorough review.


PR #6740 Review — fix(l1): align to latest EIP-8025 spec and unskip stateless validation tests

Overall this is a well-executed spec-alignment PR. The structural changes are clean, the security posture is improved, and removing 38 test skips is strong evidence the fixes are correct. A few things warrant attention.


Critical / Correctness

1. CALL gas ordering — create_cost not included in the early check_gas for the non-delegation path

In the new OpCallHandler (system.rs):

vm.current_call_frame.check_gas(static_cost)?;         // line ~934

vm.substate.add_accessed_address(callee);
let address_is_empty = vm.db.get_account(callee)?.is_empty();
let (is_delegation_7702, eip7702_gas_consumed, code_address, bytecode) =
    eip7702_get_code(...)?;
let create_cost = if address_is_empty && !value.is_zero() {
    gas_cost::CALL_TO_EMPTY_ACCOUNT
} else { 0 };

if is_delegation_7702 {
    vm.current_call_frame.check_gas(
        static_cost.checked_add(create_cost)? .checked_add(eip7702_gas_consumed)?,
    )?;
}

When is_delegation_7702 == false, create_cost (25,000 gas for non-empty-account call with value) is never included in an early check_gas call. The state read vm.db.get_account(callee)?.is_empty() has already occurred at this point. EELS checks the full static gas — including the new-account premium — before any account read. Confirm that the downstream actual gas-deduction code handles the create_cost path correctly without this early-exit opportunity, or add a second check_gas(static_cost + create_cost) unconditionally after computing create_cost.


2. Silent node drop changes error semantics for all callers

into_execution_witness changed from:

.collect::<Result<_, RLPDecodeError>>()?

to:

let node = Node::decode(&b).ok()?;
Some((keccak(&b), node))

Malformed trie nodes are now silently dropped instead of failing. This follows EELS tolerance behaviour for extra nodes, as the test test_validation_state_extra_unused_trie_node requires. However, this change applies to all nodes, not just extra ones. A node that is actually needed for execution but happens to be malformed will be silently dropped and the error will surface only later as a RootNotFound — possibly with a less informative message. Since the stated contract is "producers include every byte of code the VM reads", silently dropping needed nodes and blaming a root mismatch can hamper debugging. Consider whether a log or distinct error path for decode failures on needed nodes would be worth adding.


Security

3. validate_canonical_chain_config validates blob schedule but not fork identity

// `fork` and `activation` are not compared: EELS and ethrex number forks differently,
// and the spec stores activation values for canonical-root determinism rather than
// verifier cross-checking. The blob-schedule check below is a partial proxy…

The comment is transparent about the gap, but it means a prover claiming a different fork (e.g. sending Prague-era data tagged as Cancun) would pass validation as long as the blob-schedule fields match. A block at the boundary where two forks happen to share a blob schedule would be accepted under either fork label. This is a spec-fidelity gap that should at minimum be a documented TODO tied to a tracking issue.

4. Public key zero-index access is safe but add a length assert

let pk_bytes: &[u8] = public_key;
if pk_bytes[0] != 0x04 {

SszVector<u8, 65> guarantees length 65, so the index is safe. A short comment (// SSZ guarantees len == 65) would make the safety argument explicit and prevent a future unsafe-free refactor from accidentally removing the guarantee. The validation itself (prefix byte + keccak address derivation) is correct.


Rust Correctness / Idiomatic

5. check_gas called twice for the delegation path — the first call is subsumed

For OpCallHandler (and the three other handlers), when is_delegation_7702 == true:

vm.current_call_frame.check_gas(static_cost)?;             // check 1
// ... state reads ...
if is_delegation_7702 {
    vm.current_call_frame.check_gas(static_cost + create + eip7702)?;  // check 2
}

The second check covers a strictly larger value, so the first check is redundant on that path. This is harmless but creates a mental mismatch when reading the code. The first call's stated purpose ("before any state read") is the real invariant — it could be annotated to make clear it's an early-exit guard, not the authoritative gas deduction check.

6. Unnecessary final hash in validate_witness_headers_chain

prev_hash = Some(header.compute_block_hash(crypto));

This is computed for every header including the last one, whose hash is never used. The last iteration's compute_block_hash call is dead work. It is a minor cost, but given this function runs inside validation, it could be restructured as a windows(2) iterator or break the hash computation out of the loop body (compute hash[i] before checking header[i+1].parent_hash, skipping the final element).

7. execute_decoded's Direct arm returns a sentinel zero new_payload_request_root

ProgramInput::Direct { blocks, execution_witness } => {
    // ...
    Ok(ProgramOutput {
        new_payload_request_root: [0u8; 32],  // sentinel
        valid: true,
        chain_id,
    })
}

This is documented in the function-level comment, but execute_decoded is pub and re-exported from mod.rs. Any caller that consumes the returned ProgramOutput::new_payload_request_root without knowing it might be a sentinel will silently operate on zeroed data. Either restrict the Direct arm to #[cfg(test)] / test-only gating, or add a None-able field or a distinct sentinel type to distinguish the zero root from a real all-zero hash.


Minor / Nits

  • decode_canonical_stateless_input_bytes duplicates the split_at + manual big-endian decode; a helper read_be_u16(bytes: &[u8]) -> u16 would make the pattern reusable and self-documenting.
  • The #[cfg(feature = "eip-8025")] on CanonicalChainConfig, CanonicalForkConfig, etc. and the impl Default for ProgramInput behind the same gate is consistent. Confirm that the non-eip-8025 ProgramInput is still exercised by CI (it's behind #[cfg(not(feature = "eip-8025"))]).
  • The compile_error! guard in crates/prover/src/lib.rs correctly prevents combining eip-8025 with sp1/risc0/openvm/zisk. Good defensive practice.

What's Good

  • Replacing println! + empty-code fallback in get_account_code / get_account_code_metadata with a proper MissingBytecode error is the right call — silent fallbacks in witness-backed execution are a correctness risk.
  • The SszList → SszVector change for public keys (SszList<SszList<u8, 65>>SszList<SszVector<u8, 65>>) is the correct SSZ representation for fixed-width data and was a spec deviation before.
  • Bytes::copy_from_slice / decode_canonical(tx_bytes) eliminating intermediate Vec<u8> allocations are clean wins.
  • find_parent_state_root using checked_sub correctly guards the first_block_number == 0 path.
  • The validate_witness_headers_chain "unanchored first header" design is correct for stateless execution — the absolute anchor is the post-execution state root in execute_blocks.

Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. tooling/ef_tests/blockchain/test_runner.rs hard-fails on decode_canonical_stateless_input_bytes() before it ever compares against expected_valid. That means any negative fixture whose statelessInputBytes is intentionally malformed at the top-level SSZ/schema layer will fail the test runner instead of being treated as the expected invalid case. If the goal is to exercise the production rejection path, this helper should either call the raw guest entrypoint on the bytes or map decode failures into the (false, Err(_)) branch.

  2. crates/common/types/block_execution_witness.rs does not actually validate header contiguity by block number; it only checks parent_hash == hash(previous_header). A sequence like block 100 followed by block 102 can pass if 102.parent_hash is wired to 100. The call site in crates/guest-program/src/l1/program.rs claims this covers the non-contiguous-chain case, but today it does not. Add a header.number == prev.number + 1 check in the helper if that is the intended invariant.

Otherwise, the stricter missing-bytecode handling and the CALL-family gas-ordering changes look directionally correct.

I couldn’t run cargo check here because rustup attempted to write under a read-only /home/runner/.rustup/tmp.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 27, 2026

Greptile Summary

This PR aligns ethrex's EIP-8025 stateless validation with the latest Amsterdam spec (bal-devnet-7 / zkevm@v0.4.1), fixes a CALL-opcode gas-check ordering divergence from EELS that was causing the witness to incorrectly require bytecode before confirming sufficient gas, and closes several validation gaps (header chain linkage, bytecode completeness, per-transaction public-key verification).

  • Witness handling hardened: get_account_code/get_code_metadata now return a hard MissingBytecode error instead of silently defaulting to empty code; malformed trie nodes are silently dropped (EELS tolerance semantics) instead of propagating an RLPDecodeError; decoded headers are reused to avoid double RLP decoding.
  • CALL opcodes reordered: All four CALL variants now check static gas before any state reads (eip7702_get_code, account emptiness), with an extra combined check inside the is_delegation_7702 path.
  • ProgramInput split into Direct/Wire variants (feature-gated), and a new decode_canonical_stateless_input_bytes entry point handles the production schema-ID-prefixed SSZ wire format; the stateless Cargo feature now correctly propagates eip-8025 to all dependent crates.

Confidence Score: 4/5

Safe to merge; the execution path is correct and the unskipped test suite provides substantial coverage of the new canonical wire format.

The CALL handler reordering is the riskiest change: it correctly moves the EIP-7702 code fetch after the static gas check, but the check_gas(static_cost + create_cost) for non-delegation calls to empty accounts with non-zero value is never called explicitly — that cost is only caught later by increase_consumed_gas. This is a minor EELS ordering gap, not a correctness regression, and the now-enabled test suite provides good coverage of the affected paths.

crates/vm/levm/src/opcode_handlers/system.rs — specifically the OpCallHandler non-delegation create_cost ordering

Important Files Changed

Filename Overview
crates/vm/levm/src/opcode_handlers/system.rs Reorders gas checks before state reads for all four CALL variants to align with EELS; introduces check_gas helper. Non-delegation CALL to empty accounts still defers the create_cost check to increase_consumed_gas rather than doing an explicit pre-read check.
crates/common/types/block_execution_witness.rs Extracts header decoding/validation helpers, adds validate_witness_headers_chain (input-order contiguity check), tightens get_account_code/get_code_metadata from silent-default to hard MissingBytecode error, and silently drops malformed trie nodes (.ok()?) instead of propagating RLPDecodeError — intentional EELS tolerance semantics but errors for needed malformed nodes now surface as the less-descriptive RootNotFound.
crates/guest-program/src/l1/input.rs Splits ProgramInput into Direct/Wire variants (feature-gated), adds canonical SSZ structs (CanonicalForkConfig, CanonicalBlobSchedule, CanonicalForkActivation, fixed-size PublicKeysList), and adds decode_canonical_stateless_input_bytes with schema-ID validation.
crates/guest-program/src/l1/program.rs Adds execute_decoded dispatcher, extracts execute_canonical_stateless_input_decoded (returning ProgramOutput with errors absorbed as valid=false), adds blob-schedule cross-check in validate_canonical_chain_config, and validates per-transaction public keys against recovered senders.
tooling/ef_tests/blockchain/test_runner.rs Routes fixtures with statelessInputBytes through the new canonical decode path; adds explicit header decode step before into_execution_witness; gates two-pass parallel test under not(stateless) to avoid missing merkleizer dependency.
crates/prover/src/lib.rs Adds a compile_error! guard preventing eip-8025 from being combined with ZK backends that require rkyv-serialisable ProgramInput.
crates/prover/src/backend/exec.rs Delegates to execute_decoded and surfaces valid=false as Err so test callers treat it as execution failure, matching legacy path semantics.
tooling/ef_tests/blockchain/tests/all.rs Removes all previously-skipped EIP-8025 tests now that fixtures are regenerated against bal-devnet-7 and validation gaps are closed; EXTRA_SKIPS for stateless is now empty.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Input bytes / ProgramInput] --> B{Feature: eip-8025?}

    B -- No --> C[ProgramInput struct\nblocks + ExecutionWitness]
    C --> D[execute_blocks]
    D --> E[ProgramOutput]

    B -- Yes --> F{ProgramInput variant}
    F -- Direct --> G[execute_blocks direct\nsentinel ProgramOutput]
    F -- Wire --> H{DecodedEip8025 variant}

    H -- Legacy --> I[decode_eip8025_legacy\nnew_payload_request + witness]
    H -- Canonical --> J[decode_canonical_stateless_input_bytes\nschema_id=0x0001 + SSZ]

    I --> K[validate_eip8025_execution\nhash_tree_root check]
    J --> L[validate_canonical_chain_config\nchain_id + blob_schedule]
    L --> M[decode_witness_headers]
    M --> N[validate_witness_headers_chain\ninput-order contiguity]
    N --> O[into_execution_witness\nsilent-drop malformed nodes]
    O --> P[validate_eip8025_amsterdam_execution\npublic_keys sender match]
    P --> Q[execute_blocks]
    Q --> R{valid?}
    R -- Yes --> S[ProgramOutput valid=true]
    R -- No --> T[ProgramOutput valid=false\nExecBackend → Err]

    K --> S
    G --> S
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
crates/vm/levm/src/opcode_handlers/system.rs:98-106
**Missing `check_gas(create_cost)` for non-EIP-7702 CALL to empty account**

The explicit gas check for `create_cost` (`CALL_TO_EMPTY_ACCOUNT`, 25,000 gas) only runs inside the `is_delegation_7702` branch. For a regular (non-delegation) CALL with a non-zero `value` to an empty account, `create_cost` is never `check_gas`-ed before proceeding — it is only deducted later at `increase_consumed_gas`. EELS has an explicit `check_gas(GAS_NEW_ACCOUNT)` step after the account read that is independent of the delegation path; this ordering is what allows validators to reject witnesses that don't include the callee account when there is insufficient gas. While this does not affect execution correctness (OOG is still caught at `increase_consumed_gas`), it diverges from the stated EELS alignment goal and is inconsistent with how the delegation case is handled.

Reviews (1): Last reviewed commit: "docs(l1): tighten verbose comments" | Re-trigger Greptile

Comment on lines +98 to +106
if is_delegation_7702 {
vm.current_call_frame.check_gas(
static_cost
.checked_add(create_cost)
.ok_or(ExceptionalHalt::OutOfGas)?
.checked_add(eip7702_gas_consumed)
.ok_or(ExceptionalHalt::OutOfGas)?,
)?;
}
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 Missing check_gas(create_cost) for non-EIP-7702 CALL to empty account

The explicit gas check for create_cost (CALL_TO_EMPTY_ACCOUNT, 25,000 gas) only runs inside the is_delegation_7702 branch. For a regular (non-delegation) CALL with a non-zero value to an empty account, create_cost is never check_gas-ed before proceeding — it is only deducted later at increase_consumed_gas. EELS has an explicit check_gas(GAS_NEW_ACCOUNT) step after the account read that is independent of the delegation path; this ordering is what allows validators to reject witnesses that don't include the callee account when there is insufficient gas. While this does not affect execution correctness (OOG is still caught at increase_consumed_gas), it diverges from the stated EELS alignment goal and is inconsistent with how the delegation case is handled.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/opcode_handlers/system.rs
Line: 98-106

Comment:
**Missing `check_gas(create_cost)` for non-EIP-7702 CALL to empty account**

The explicit gas check for `create_cost` (`CALL_TO_EMPTY_ACCOUNT`, 25,000 gas) only runs inside the `is_delegation_7702` branch. For a regular (non-delegation) CALL with a non-zero `value` to an empty account, `create_cost` is never `check_gas`-ed before proceeding — it is only deducted later at `increase_consumed_gas`. EELS has an explicit `check_gas(GAS_NEW_ACCOUNT)` step after the account read that is independent of the delegation path; this ordering is what allows validators to reject witnesses that don't include the callee account when there is insufficient gas. While this does not affect execution correctness (OOG is still caught at `increase_consumed_gas`), it diverges from the stated EELS alignment goal and is inconsistent with how the delegation case is handled.

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

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.

False for several reasons:

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 27, 2026

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 3.245 ± 0.020 3.218 3.278 1.09 ± 0.01
main_levm_BubbleSort 2.980 ± 0.031 2.954 3.051 1.00
pr_revm_BubbleSort 3.283 ± 0.022 3.244 3.316 1.10 ± 0.01
pr_levm_BubbleSort 2.993 ± 0.050 2.928 3.081 1.00 ± 0.02

Benchmark Results: ERC20Approval

Command Mean [s] Min [s] Max [s] Relative
main_revm_ERC20Approval 1.046 ± 0.010 1.037 1.067 1.00
main_levm_ERC20Approval 1.116 ± 0.013 1.100 1.143 1.07 ± 0.02
pr_revm_ERC20Approval 1.051 ± 0.019 1.038 1.101 1.00 ± 0.02
pr_levm_ERC20Approval 1.105 ± 0.010 1.097 1.131 1.06 ± 0.01

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 142.8 ± 1.3 141.1 145.5 1.00 ± 0.02
main_levm_ERC20Mint 162.0 ± 0.4 161.2 162.5 1.14 ± 0.02
pr_revm_ERC20Mint 142.6 ± 2.1 140.9 147.5 1.00
pr_levm_ERC20Mint 162.8 ± 1.0 161.6 164.7 1.14 ± 0.02

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 251.0 ± 3.2 247.6 257.8 1.00
main_levm_ERC20Transfer 274.9 ± 3.5 271.4 281.5 1.10 ± 0.02
pr_revm_ERC20Transfer 254.5 ± 7.2 247.3 265.4 1.01 ± 0.03
pr_levm_ERC20Transfer 274.3 ± 1.9 272.2 278.2 1.09 ± 0.02

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 224.5 ± 2.3 222.7 230.5 1.00
main_levm_Factorial 275.3 ± 3.6 271.8 283.0 1.23 ± 0.02
pr_revm_Factorial 224.6 ± 2.4 222.5 231.1 1.00 ± 0.01
pr_levm_Factorial 275.0 ± 2.7 272.1 280.5 1.23 ± 0.02

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.723 ± 0.028 1.690 1.776 1.06 ± 0.02
main_levm_FactorialRecursive 1.620 ± 0.011 1.604 1.637 1.00
pr_revm_FactorialRecursive 1.696 ± 0.041 1.633 1.765 1.05 ± 0.03
pr_levm_FactorialRecursive 1.629 ± 0.020 1.609 1.676 1.01 ± 0.01

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 212.1 ± 1.7 208.9 214.8 1.00
main_levm_Fibonacci 258.3 ± 9.1 249.3 278.8 1.22 ± 0.04
pr_revm_Fibonacci 212.6 ± 1.8 210.8 216.9 1.00 ± 0.01
pr_levm_Fibonacci 257.0 ± 5.3 249.7 264.4 1.21 ± 0.03

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 921.2 ± 14.6 903.1 953.0 1.24 ± 0.02
main_levm_FibonacciRecursive 745.5 ± 6.5 734.4 753.7 1.00
pr_revm_FibonacciRecursive 905.7 ± 10.5 894.1 921.7 1.21 ± 0.02
pr_levm_FibonacciRecursive 748.6 ± 7.5 740.4 766.6 1.00 ± 0.01

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 9.1 ± 0.1 9.0 9.3 1.01 ± 0.02
main_levm_ManyHashes 10.7 ± 0.1 10.6 11.0 1.18 ± 0.03
pr_revm_ManyHashes 9.1 ± 0.2 8.9 9.5 1.00
pr_levm_ManyHashes 10.7 ± 0.1 10.5 10.9 1.18 ± 0.02

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 288.2 ± 35.9 270.4 386.9 1.19 ± 0.15
main_levm_MstoreBench 242.7 ± 1.2 240.8 244.4 1.00 ± 0.01
pr_revm_MstoreBench 273.5 ± 2.1 271.1 277.8 1.13 ± 0.01
pr_levm_MstoreBench 242.3 ± 1.2 240.6 243.7 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 317.1 ± 7.9 313.2 339.3 1.05 ± 0.03
main_levm_Push 302.8 ± 6.2 297.1 313.7 1.00
pr_revm_Push 314.9 ± 1.0 313.4 316.8 1.04 ± 0.02
pr_levm_Push 307.0 ± 4.9 298.1 313.8 1.01 ± 0.03

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 182.4 ± 9.1 177.9 207.6 1.59 ± 0.08
main_levm_SstoreBench_no_opt 116.2 ± 4.0 114.2 127.3 1.01 ± 0.04
pr_revm_SstoreBench_no_opt 182.2 ± 7.8 177.7 200.9 1.58 ± 0.07
pr_levm_SstoreBench_no_opt 115.0 ± 1.2 114.2 118.3 1.00

);

if is_delegation_7702 {
vm.current_call_frame.check_gas(
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.

EELS Amsterdam call() L454 does check_gas(extra_gas + extend_memory.cost) where extra_gas = access + transfer + delegation_access_cost; create_cost is not included. It is charged later from the separate state-gas pool via charge_state_gas (L465-468), not regular gas.

Including create_cost in the regular-gas preview here can OOG on regular gas in the delegation + empty-account + value-transfer case where EELS would proceed and pay create_cost from the state-gas reservoir. CI is green so no v0.4.1 fixture triggers it, but the asymmetry vs the non-delegation branch (no second check_gas) is worth a comment or dropping create_cost from the preview. Intentional?

} else {
gas_cost::WARM_ADDRESS_ACCESS_COST
};
let static_cost = memory_expansion_cost
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.

Static-gas block (new_memory_size + address_was_cold + static_cost + check_gas(static_cost)) is duplicated across CALL/CALLCODE/DELEGATECALL/STATICCALL (also at lines 238, 356, 474); only value_cost varies. Extract a helper returning (new_memory_size, address_was_cold, static_cost) after check_gas so the static-then-state ordering invariant lives in one place. Can be a follow-up.

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 change was short, so done in bf87942

// same final state is reached.
// Skipped under `stateless`: `eip-8025` on `ethrex-blockchain` drops the merkleizer
// Sender the parallel path needs; the non-stateless runs still cover this.
#[cfg(not(feature = "stateless"))]
Copy link
Copy Markdown
Contributor

@edg-l edg-l May 28, 2026

Choose a reason for hiding this comment

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

The current wording suggests this is a temporary skip waiting on a fix, but I think it's actually a permanent design boundary: the stateless harness doesn't drive the blockchain.add_block pipeline that run_two_pass_parallel exercises, and parallel BAL-driven merkleization gives no benefit in zkVM guest builds (single-threaded proof targets). The parallel path is already covered by the non-stateless runs against the same fixtures.

Could you reword along the lines of "two-pass parallel exercises the blockchain pipeline's BAL-driven merkleization, which the stateless harness doesn't use; non-stateless runs already cover it on the same fixtures" so future readers don't treat this as a regression to chase?

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.

Improved in 79a8532

self.codes_hashed
.get(&code_hash)
.cloned()
.ok_or(GuestProgramStateError::MissingBytecode(code_hash))
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.

get_account_code now hard-errors with MissingBytecode instead of silently returning empty. Looks correct for stateless validation, but can you confirm no non-stateless caller relied on the prior silent-empty fallback? A quick rg get_account_code over the workspace would lock it down. Test matrix is green but worth eyeballing.

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.

Confirmed get_account_code isn't used elsewhere. Other methods (RPC, etc) use the Store and not the VM to access bytecode.

Copy link
Copy Markdown
Contributor

@edg-l edg-l left a comment

Choose a reason for hiding this comment

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

Reviewed against EELS tests-zkevm@v0.4.1 (forks/amsterdam/vm/instructions/system.py). Gas-charge ordering rewrite matches spec; raising a few items inline.

Verified separately:

  • .fixtures_url_amsterdam (bal@v7.2.0) untouched.
  • vectors_zkevm/ stays a separate extraction root from vectors/. This isolation is correct and worth keeping as a design invariant: zkevm and bal fixture bundles version on independent cadences, so when the zkevm bundle drifts out of date relative to the current bal spec (as happened with zkevm@v0.3.3 vs bal-devnet-7 on main) we need the two trees to stay disjoint. Without that separation, extracting one would clobber the other and the suite the maintainers haven't bumped yet would silently break.
  • Test - {ubuntu-22.04, ubuntu-22.04-arm, macos-15} (which runs make -C tooling/ef_tests/blockchain test) is green on all three runners; both bal@v7.2.0 (test-levm) and the re-broadened zkevm@v0.4.1 (test-stateless) are exercised.
  • Pre-Amsterdam forks unchanged: gas_cost::call still bakes CALL_TO_EMPTY_ACCOUNT into call_gas_costs when fork < Amsterdam (gas_cost.rs:748).

Approving once the delegation-branch check_gas question and the two-pass-parallel comment are addressed.

@iovoid
Copy link
Copy Markdown
Contributor Author

iovoid commented May 28, 2026

You are right about the gas, I was looking at the forks/amsterdam branch which I assumed was more up-to-date, but actually doesn't include EIP-8037.

@github-actions
Copy link
Copy Markdown

Lines of code report

Total lines added: 299
Total lines removed: 2
Total lines changed: 301

Detailed view
+-------------------------------------------------------+-------+------+
| File                                                  | Lines | Diff |
+-------------------------------------------------------+-------+------+
| ethrex/crates/common/types/block_execution_witness.rs | 581   | +11  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/input.rs           | 306   | +81  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/mod.rs             | 16    | +3   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/program.rs         | 523   | +130 |
+-------------------------------------------------------+-------+------+
| ethrex/crates/prover/src/backend/exec.rs              | 88    | -2   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/prover/src/lib.rs                       | 25    | +10  |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/call_frame.rs               | 382   | +8   |
+-------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/system.rs   | 1033  | +56  |
+-------------------------------------------------------+-------+------+

Copy link
Copy Markdown
Contributor

@ElFantasma ElFantasma left a comment

Choose a reason for hiding this comment

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

LGTM, but left some non-blocking comments. The get_account(callee) may be the more important one

)?;

vm.substate.add_accessed_address(callee);
let address_is_empty = vm.db.get_account(callee)?.is_empty();
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.

get_account(callee)? unconditionally reads the callee account. But address_is_empty is only consumed at lines 69 (create_cost, gated on !value.is_zero()) and 111 (needs_state_gas, same gate). So when value.is_zero(), the read result is computed and immediately thrown away.

That's the same class of issue this PR fixes for eip7702_get_code: a state read that EELS doesn't perform, which forces witnesses to include records that EELS-conformant witnesses can legitimately omit. EELS' spec only checks target.balance + value == 0 (the empty-via-create case) when value > 0; for value = 0 calls, the account is not loaded for this purpose.

Low-effort fix:

let address_is_empty = if value.is_zero() {
    false  // unused in either gated path
} else {
    vm.db.get_account(callee)?.is_empty()
};

Worth verifying against the unskipped EIP-8025 tests — if none of them currently catch this, it's because no witness fixture is sparse enough yet; that'll be a future witness regression.

Non-blocking but same family as the bug this PR fixes.

self.codes_hashed
.get(&code_hash)
.cloned()
.ok_or(GuestProgramStateError::MissingBytecode(code_hash))
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.

Removing the silent "default to empty Code" fallback is the right call — it was masking exactly the kind of bug the system.rs CALL-reorder is fixing (witness misses a code entry; we silently default to empty; downstream execution diverges from EELS).

Worth a sanity check that every code-hash path the VM walks during execution is now preceded by an addressed-cold check or the witness invariant guarantees inclusion. If get_account_code can be reached for a code hash that wasn't already established as touched, this change converts the previous silent-but-wrong behavior into a hard MissingBytecode failure — possibly during stateless validation of an otherwise-valid block.

Based on the PR description ("unskipping tests"), the unskip is the regression net here. If those tests cover the once-fallback cases, we're good. Non-blocking.

) -> Result<(), GuestProgramStateError> {
for pair in headers.windows(2) {
let (prev, next) = (&pair[0], &pair[1]);
if next.number != prev.number.saturating_add(1)
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.

nit: prev.number.saturating_add(1) silently lets a contiguous chain pass at u64::MAX → u64::MAX. Astronomically irrelevant, but the safer expression is prev.number.checked_add(1) which returns None and lets the comparison fail loudly — matches the spirit of the function ("validate chain linkage, surface violations").

/// `Direct` carries in-memory blocks + witness (test path). `Wire` carries an
/// already-decoded EIP-8025 stateless input from spec wire bytes.
#[cfg(feature = "eip-8025")]
pub enum ProgramInput {
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.

The same identifier ProgramInput is a struct (line 15) without the eip-8025 feature and an enum (here) with it. Every consumer of ProgramInput::new(blocks, witness) works under both because both have a matching new method — but ProgramInput::default() returns different shapes (struct-default vs Direct { ..Default::default() }), and any code that pattern-matches on ProgramInput::Wire(...) or ProgramInput::Direct { .. } only compiles under one cfg.

The shape is consistent enough that this is probably fine, but a one-line module-level doc note ("this type's kind is feature-gated; cross-feature destructuring will not compile in the disabled branch") would save the next contributor a confused 10 minutes. Non-blocking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants