Skip to content

feat(l2): add EIP-8079 native rollups#6418

Open
avilagaston9 wants to merge 66 commits into
mainfrom
feat/unify-stateless-validation-v2
Open

feat(l2): add EIP-8079 native rollups#6418
avilagaston9 wants to merge 66 commits into
mainfrom
feat/unify-stateless-validation-v2

Conversation

@avilagaston9

@avilagaston9 avilagaston9 commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Motivation

EIP-8079 (native rollups) and EIP-8025 (optional execution proofs) share the same infrastructure: stateless block validation, execution witnesses, SSZ encoding. EIP-8025 landed in main via #6427. This PR adds EIP-8079 on top of it and renames the cargo feature from eip-8025 to experimental-devnet so future experimental EIPs (next up: EIP-8142 block-in-blobs) can share the gate.

Description

Native rollup support behind the experimental-devnet cargo feature, reusing verify_stateless_new_payload for the EXECUTE precompile.

  • NativeRollup.sol added with spec-aligned storage and an SSZ-based advance().
  • L2Bridge uses EIP-4788 BEACON_ROOTS for the L1 messages Merkle root.
  • L2 actors: NativeBlockProducer, NativeL1Watcher, NativeL1Advancer (actor/#[protocol] API; watcher forwards parsed logs to the producer via GenServer-style EnqueueL1Messages).
  • EXECUTE precompile added in crates/vm/levm/src/execute_precompile.rs (~110 lines) — decodes the SSZ input, validates L2 constraints, and delegates to a StatelessValidator trait.
  • Advancer reuses the cached witness from --precompute-witnesses when available, falls back to on-demand generation otherwise.

StatelessValidator trait workaround — feedback welcome

The EXECUTE precompile needs to call verify_stateless_new_payload, which lives in ethrex-blockchain. But ethrex-blockchain depends on ethrex-vm → ethrex-levm, so a direct call from LEVM into blockchain would form a cycle.

The shape in this PR: ethrex-levm declares a StatelessValidator trait; ethrex-blockchain implements it (StatelessExecutor) and injects an Arc<dyn StatelessValidator> into Evm at construction via an attach_stateless_validator helper in crates/blockchain/blockchain.rs. The precompile calls back through the trait.

It's dependency-inversion for a single callback, every Evm carries an unused Option<&dyn …> slot, and the injection happens at each of the two L1 Evm construction sites. Open to suggestions.

How to Test

Native rollups end-to-end demo (deposit, withdrawal, counter, Blockscout verification): docs/l2/deployment/native_rollups.md.

# three terminals
make -C crates/l2 init-l1-native
make -C crates/l2 deploy-l1-native
make -C crates/l2 init-l2-native

Integration test:

cargo test -p ethrex-test --features experimental-devnet -- l2::native_rollup --nocapture

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

⚠️ Warning: Diff was truncated from 16151 to 10000 lines. Some changes were not reviewed.

This is a large PR introducing EIP-8025 (Execution Layer Triggerable Proofs) and Native Rollups support. The changes span the CLI, blockchain engine, L2 sequencer, RPC layer, and zkVM guest programs.

Critical Issues

1. SSH Git Dependency Blocks External Builds

File: Cargo.toml (multiple crates)

The SSZ library is referenced via SSH:

ssz = { git = "ssh://git@github.com/lambdaclass/libssz", optional = true }

Problem: This requires SSH authentication and will fail for external contributors or CI without configured SSH keys. If libssz is private, this is a complete blocker for open-source builds.

Fix: Change to HTTPS:

ssz = { git = "https://github.com/lambdaclass/libssz", optional = true }

Or publish the crate to crates.io.

2. Hardcoded Private Keys in Source

File: cmd/ethrex/l2/deployer.rs, lines 1780–1790

let test_private_keys = [
    "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
    // ...
];

Problem: Hardcoded test keys are a security anti-pattern. Even for testnets, these should be loaded from fixture files or environment variables.

Fix: Move to fixtures/keys/ and load at runtime, or generate deterministically from a seed phrase.

3. Unwrap/Expect in Critical Path

File: crates/l2/sequencer/native_rollup/l1_advancer.rs, line 394

let original_hash = block_header.compute_block_hash(&NativeCrypto);
// ...
let reconstructed_hash = reconstructed_block.hash();
assert_eq!(original_hash, reconstructed_hash); // In debug block

Problem: While this specific instance is in a debug-only block (lines 156–181), similar patterns exist in production code:

File: crates/guest-program/src/l1/program.rs, line 76

let base_fee_bytes = payload.base_fee_per_gas[..8]
    .try_into()
    .map_err(|_| "base_fee_per_gas conversion")?; // Should handle error properly

Fix: Use proper error propagation instead of expect/unwrap in guest program code.

Security & Correctness

4. Integer Overflow Risk in Gas Calculations

File: crates/l2/sequencer/native_rollup/block_producer.rs, line 320

let next_gas = cumulative_gas.saturating_add(msg.gas_limit);
if next_gas > self.config.block_gas_limit { ... }

Issue: saturating_add prevents overflow, but the comparison logic should ensure msg.gas_limit alone doesn't exceed the block limit before adding.

Fix: Add check:

if msg.gas_limit > self.config.block_gas_limit {
    warn!("L1 message gas limit exceeds block limit, skipping");
    continue;
}

5. TCP Server Without Connection Limits

File: crates/blockchain/proof_engine/coordinator.rs, lines 95–110

The L1 ProofCoordinator accepts TCP connections in an infinite loop without rate limiting or connection timeouts.

Fix: Add connection limits and timeouts:

tokio::time::timeout(Duration::from_secs(30), listener.accept())

6. SSZ Round-trip Verification in Production

File: crates/l2/sequencer/native_rollup/l1_advancer.rs, lines 156–181

The advance_next_block function performs full SSZ serialization, deserialization, and block reconstruction for debugging before submitting to L1.

Issue: This adds significant overhead (merkle tree operations, RLP encoding) to every block submission.

Fix: Gate behind #[cfg(debug_assertions)] or a verbose-validation feature flag.

Architecture & Maintainability

7. Feature Flag Inconsistency

The stateless-validation feature is added to many crates, but some dependencies aren't properly gated:

File: crates/l2/Cargo.toml

[dependencies]
ssz = { git = "...", optional = true }
# ...
k256 = { workspace = true, optional = true }

But in crates/l2/sequencer/native_rollup/types.rs, the k256 usage isn't feature-gated properly (though it compiles due to re-exports).

8. Duplicate Merkle Tree Implementation

File: crates/common/merkle_tree.rs (new)
File: crates/l2/common/src/merkle_tree.rs (now re-exports)

Good refactoring to move the merkle tree to common, but ensure the commutative hash property is documented:

/// Commutative Keccak256 hash: H(a, b) == H(b, a).
/// Sorts inputs so the smaller value comes first, matching OpenZeppelin's
/// `_hashPair` in MerkleProof.sol.

9. Guest Program Input Format Breaking Change

File: crates/guest-program/bin/*/src/main.rs

The guest programs now branch on stateless-validation feature to choose between rkyv and SSZ deserialization. This is a breaking change for existing proof infrastructure.

Suggestion: Document the input format change in the PR description and ensure the coordinator sends the correct format based on feature flags.

Minor Issues

10. Typo in Log Message

File: cmd/ethrex/l2/command.rs, line 300

"Succesfully unpaused contract"

Should be "Successfully".

11. Unused Import

File: crates/l2/sequencer/native_rollup/l1_watcher.rs, line 10

use ethrex_crypto::keccak::keccak_hash;

Imported but not used (the keccak function from ethrex_common is used instead).

12. Missing Documentation on SSZ Limits

File: crates/common/types/stateless_ssz.rs

The constants like MAX_TRANSACTIONS = 1_048_576 should reference the specific EIP or consensus spec section (Electra/Amsterdam).

Positive Notes

  1. Good separation of concerns: The native rollup logic is cleanly separated into block_producer, l1_watcher, and l1_advancer modules following the GenServer pattern.
  2. Comprehensive SSZ types: The stateless_ssz.rs module properly implements the consensus spec containers with correct hash tree root implementations.
  3. Round-trip testing: The inclusion of SSZ round-trip tests in l1_advancer.rs (even if it should be debug-only) shows attention to serialization correctness.
  4. Refactoring: Moving ethrex-prover to a shared crate and creating ethrex-l2-prover is a good architectural improvement.

Summary

Must fix before merge:

  1. Change SSH dependency to HTTPS
  2. Remove hardcoded private keys
  3. Gate expensive debug verification behind debug_assertions

Should fix:
4. Add connection limits to TCP coordinator
5. Fix unwrap/expect in guest programs
6. Add overflow checks for gas limit math

The stateless validation logic appears correct and follows the EIP-8025 specification, but the infrastructure changes (SSH deps, hardcoded keys) are blockers.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented Mar 28, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 2728
Total lines removed: 0
Total lines changed: 2728

Detailed view
+---------------------------------------------------------------+-------+------+
| File                                                          | Lines | Diff |
+---------------------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/build_l2.rs                                 | 470   | +23  |
+---------------------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/command.rs                               | 666   | +22  |
+---------------------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/deployer.rs                              | 1794  | +262 |
+---------------------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/initializers.rs                          | 452   | +75  |
+---------------------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/mod.rs                                   | 12    | +2   |
+---------------------------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/options.rs                               | 1232  | +128 |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs                        | 2506  | +16  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/stateless.rs                         | 103   | +103 |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/common/types/block_execution_witness.rs         | 641   | +71  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/common/types/stateless_ssz.rs                   | 374   | +374 |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/mod.rs                     | 12    | +2   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/common/src/messages.rs                       | 208   | +4   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/l2.rs                                        | 13    | +2   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/networking/rpc/l2/mod.rs                     | 7     | +2   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/networking/rpc/l2/native_withdrawal_proof.rs | 129   | +129 |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/networking/rpc/rpc.rs                        | 260   | +4   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sdk/src/sdk.rs                               | 1240  | +6   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/mod.rs                             | 279   | +2   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/native_rollup/block_producer.rs    | 364   | +364 |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/native_rollup/l1_advancer.rs       | 520   | +520 |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/native_rollup/l1_watcher.rs        | 239   | +239 |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/native_rollup/mod.rs               | 79    | +79  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/native_rollup/types.rs             | 25    | +25  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/mod.rs                         | 2264  | +54  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/tracing.rs                     | 78    | +13  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/mod.rs                              | 260   | +21  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/runner/src/main.rs                      | 280   | +1   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/db/guest_program_state_db.rs        | 67    | +67  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/db/mod.rs                           | 147   | +2   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/execute_precompile.rs               | 74    | +74  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/hooks/l2_hook.rs                    | 676   | +1   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/lib.rs                              | 25    | +5   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/system.rs           | 950   | +1   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/precompiles.rs                      | 1204  | +24  |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/vm.rs                               | 565   | +7   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/lib.rs                                       | 15    | +2   |
+---------------------------------------------------------------+-------+------+
| ethrex/crates/vm/tracing.rs                                   | 45    | +2   |
+---------------------------------------------------------------+-------+------+

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. High: the zkevm fixture detection is too broad and will also rewrite the normal Amsterdam EF tests. The check in test_runner.rs:42 keys off path.contains("for_amsterdam"), but both the standard Amsterdam target and the zkevm target unpack into that same directory in Makefile:47 and Makefile:53. That means ordinary Amsterdam fixtures will run under ZKEVM_AMSTERDAM_CONFIG, skip run_two_pass_parallel, and stop exercising the actual Amsterdam rules this harness is supposed to cover.

  2. High: zkevm_supplementary_validation() can turn false accepts into passing tests. In test_runner.rs:176, an accepted block with expectException is treated as “correctly rejected” if test_runner.rs:216 returns any error. But that helper currently rejects any non-empty block_access_list_hash, without checking the expected exception or the actual computed BAL. So if the Osaka pipeline wrongly accepts an invalid zkevm Amsterdam block for some unrelated reason, the harness can still pass the test just because the header carries a non-empty BAL hash.

  3. High: the stateless zkevm path re-enables Amsterdam rules even though the stateful path explicitly downgrades these fixtures to Osaka. test_runner.rs:666 passes test.network.chain_config() into witness conversion; that config is stored in the witness at execution_witness.rs:94 and block_execution_witness.rs:226, and then used for pre-execution validation in execution.rs:62. So test-stateless-zkevm is not validating under the same fork rules as the preceding run() path, which defeats the workaround this PR is adding.

  4. High: the new SSZ stateless validator claims to run at the “latest fork (Amsterdam)”, but the synthesized ChainConfig only activates through Prague. See stateless.rs:297 and stateless.rs:314. ChainConfig does have explicit Osaka/Amsterdam activation checks and fork selection in genesis.rs:356 and genesis.rs:439, so this path will silently skip Osaka/Amsterdam-era validation in EXECUTE/EIP-8025/guest mode.

Static review only; I did not run the test suite.


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

@github-actions

github-actions Bot commented Mar 28, 2026

Copy link
Copy Markdown

Benchmark Results Comparison

Benchmark Results: Factorial

Command Mean [s] Min [s] Max [s] Relative
main_revm_Factorial 225.1 ± 2.0 222.6 229.6 1.00
main_levm_Factorial 251.2 ± 8.9 246.0 275.7 1.12 ± 0.04
pr_levm_Factorial 279.0 ± 97.0 246.8 555.0 1.24 ± 0.43
Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 3.014 ± 0.033 2.971 3.098 1.12 ± 0.04
main_levm_BubbleSort 2.712 ± 0.042 2.658 2.812 1.00 ± 0.04
pr_revm_BubbleSort 3.059 ± 0.020 3.039 3.093 1.13 ± 0.04
pr_levm_BubbleSort 2.700 ± 0.092 2.656 2.962 1.00

Benchmark Results: ERC20Approval

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Approval 992.1 ± 6.3 985.1 1006.1 1.00
main_levm_ERC20Approval 1022.4 ± 14.0 1008.9 1055.6 1.03 ± 0.02
pr_revm_ERC20Approval 1000.1 ± 5.9 991.7 1011.3 1.01 ± 0.01
pr_levm_ERC20Approval 1024.7 ± 8.8 1017.3 1044.4 1.03 ± 0.01

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 133.9 ± 0.8 132.8 135.9 1.00
main_levm_ERC20Mint 150.6 ± 4.2 147.4 158.6 1.13 ± 0.03
pr_revm_ERC20Mint 136.9 ± 0.9 135.2 138.3 1.02 ± 0.01
pr_levm_ERC20Mint 150.9 ± 3.0 147.5 158.8 1.13 ± 0.02

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 233.1 ± 2.1 230.7 238.5 1.00
main_levm_ERC20Transfer 258.0 ± 13.5 247.0 292.9 1.11 ± 0.06
pr_revm_ERC20Transfer 235.3 ± 2.4 231.5 238.7 1.01 ± 0.01
pr_levm_ERC20Transfer 258.4 ± 12.4 248.3 287.3 1.11 ± 0.05

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 225.1 ± 2.0 222.6 229.6 1.00
main_levm_Factorial 251.2 ± 8.9 246.0 275.7 1.12 ± 0.04
pr_revm_Factorial 225.2 ± 2.0 222.7 229.3 1.00 ± 0.01
pr_levm_Factorial 279.0 ± 97.0 246.8 555.0 1.24 ± 0.43

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.666 ± 0.026 1.623 1.707 1.05 ± 0.02
main_levm_FactorialRecursive 1.582 ± 0.026 1.557 1.649 1.00
pr_revm_FactorialRecursive 1.674 ± 0.039 1.623 1.727 1.06 ± 0.03
pr_levm_FactorialRecursive 1.588 ± 0.112 1.509 1.813 1.00 ± 0.07

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 203.1 ± 2.6 199.6 209.5 1.01 ± 0.02
main_levm_Fibonacci 222.0 ± 2.5 219.7 227.3 1.11 ± 0.02
pr_revm_Fibonacci 200.1 ± 1.8 196.2 202.3 1.00
pr_levm_Fibonacci 223.5 ± 3.7 219.0 230.4 1.12 ± 0.02

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 872.8 ± 13.9 843.3 896.0 1.28 ± 0.02
main_levm_FibonacciRecursive 691.5 ± 13.5 680.3 716.0 1.01 ± 0.02
pr_revm_FibonacciRecursive 885.2 ± 23.0 864.8 925.4 1.29 ± 0.03
pr_levm_FibonacciRecursive 683.8 ± 4.3 676.7 689.6 1.00

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 9.1 ± 0.5 8.6 10.1 1.00
main_levm_ManyHashes 11.4 ± 1.3 10.0 14.0 1.25 ± 0.16
pr_revm_ManyHashes 9.3 ± 1.0 8.7 12.1 1.02 ± 0.13
pr_levm_ManyHashes 10.6 ± 0.8 10.1 12.3 1.17 ± 0.11

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 267.1 ± 2.6 262.5 271.3 1.15 ± 0.01
main_levm_MstoreBench 232.9 ± 2.1 230.3 236.9 1.01 ± 0.01
pr_revm_MstoreBench 262.9 ± 3.2 260.2 271.2 1.14 ± 0.01
pr_levm_MstoreBench 231.6 ± 1.1 230.4 233.8 1.00

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 289.7 ± 2.1 287.6 294.1 1.05 ± 0.01
main_levm_Push 277.2 ± 1.3 275.7 279.6 1.00
pr_revm_Push 290.9 ± 2.0 289.1 295.5 1.05 ± 0.01
pr_levm_Push 277.8 ± 1.5 276.0 280.4 1.00 ± 0.01

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 175.6 ± 11.3 167.6 206.5 1.74 ± 0.11
main_levm_SstoreBench_no_opt 101.8 ± 1.3 99.8 103.6 1.01 ± 0.02
pr_revm_SstoreBench_no_opt 175.0 ± 5.5 168.1 189.2 1.73 ± 0.06
pr_levm_SstoreBench_no_opt 101.2 ± 1.4 99.6 103.2 1.00

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review


PR #6418 Review — Unify stateless validation: EIP-8025 + native rollups

This is a large, well-scoped PR with solid architectural thinking (the StatelessValidator trait for cycle-breaking is a clean design). The review focuses on correctness and security issues. The PR is marked as DRAFT, which is appropriate given the open items below.


Critical Issues

1. chain_id verification disabled in NativeRollup.sol

advance() (line 151) has the chain ID check commented out with a TODO:

// TODO: proper SSZ uint64 little-endian decoding
// require(provenChainId == chainId, "chain_id mismatch");

This means the contract accepts EXECUTE precompile output from any chain's execution — a replay attack vector. If the SSZ decoding is wrong (wrong offset or endianness), fix the decoding; don't skip the check. The assembly at line 144 is also reading from offset 65 (add(result, 65)) which loads bytes starting at position 33 in the result — correct for little-endian uint64 at bytes [33..41], but mload returns 32 bytes and the high 24 bytes won't be zero for typical chain IDs, making the comparison to chainId (which is a uint256) always fail. The fix requires masking: provenChainId := and(shr(192, mload(add(result, 65))), 0xffffffffffffffff).

2. FINALITY_DELAY = 0 hardcoded as immutable

NativeRollup.sol constructor (line 78):

FINALITY_DELAY = 0;

Since FINALITY_DELAY is immutable, this can never be changed after deployment. Withdrawals are claimable immediately after advance() commits a state root. There is zero fraud-proof window. For a production system this is a critical vulnerability — a buggy or malicious advancer can drain the bridge instantly. Even for a PoC this should be either configurable via constructor parameter or set to a non-zero value with a comment explaining the intentional tradeoff.

3. advance() has no access control

Anyone can submit arbitrary SSZ input to advance(). If there is any precompile edge case that returns successful_validation = false but the overall call still succeeds (e.g., the precompile is not available at a given fork), the state is still updated via _newBlockHash/_newStateRoot parameters that are passed in untrusted by the caller. The contract trusts the precompile output only — but _newBlockHash and _newStateRoot come from the caller and are not verified against the precompile output. An attacker who gets require(uint8(result[32]) == 1) to pass can submit any block hash and state root.


High Issues

4. Double SSZ decode in execute_precompile.rs

run_execute calls extract_gas_used(calldata) and then validate_l2_constraints(calldata), each independently deserializing the full SSZ input. At ~tens of kilobytes for a real witness, this doubles deserialization cost. Decode once, pass the struct to both functions.

5. processL1Message silently swallows subcall failures

L2Bridge.sol processL1Message() does:

(bool innerSuccess, ) = _to.call{value: _value, gas: _gasLimit}(_data);
// (no check on innerSuccess)

If the subcall fails, l1MessageNonce is still incremented and the ETH (which was sent from L1 bridge escrow) is consumed but not delivered. The message cannot be replayed. Users lose their deposited ETH. At minimum, emit a failure event so off-chain indexers can detect it. The design doc should explicitly justify this behavior — it may be intentional for DOS-resistance, but it needs documentation.

6. EXECUTE precompile is_precompile ignores fork

precompiles.rs line 271:

#[cfg(feature = "stateless-validation")]
if *address == EXECUTE.address {
    return true;
}

The active_since_fork: Paris constant is set but never checked in the dispatch path. Standard precompiles use precompiles_for_fork(fork).any(...) which does filter by active_since_fork. The EXECUTE precompile skips this filter entirely, making it active on all forks whenever the feature flag is set. This should check fork >= EXECUTE.active_since_fork.


Medium Issues

7. Gas model: charging L2 gas_used as L1 gas

The EXECUTE precompile charges gas_used from the L2 execution payload directly against the L1 call's gas_remaining. A 30M-gas L2 block would require a caller to supply at least 30M gas for the L1 transaction. This might be intentional by the spec, but there's no cap or minimum — there's also no intrinsic cost for the precompile itself (e.g., calldata cost, decoding cost). Document this explicitly.

8. _burnGas uses a busy loop

function _burnGas(uint256 amount) private view {
    uint256 startingGas = gasleft();
    while (startingGas - gasleft() < amount) {}
}

Gas costs of individual EVM opcodes change with hard forks. This loop's cost per iteration is not stable. If a future EIP changes the cost of GAS/SUB/LT, the effective burn amount changes. Use a well-known assembly idiom or simply require that callers pass sufficient ETH payment; burning gas is an unusual cost model.

9. Inline extension nodes unsupported in MPTProof.sol

processInlineNode() (line ~118):

require(isLeaf, "MPT: inline ext not supported");

Inline extension nodes are valid in Ethereum's MPT and can appear in small tries. On a fresh L2 with few accounts (e.g., genesis state), this will cause proof verification to revert for valid state proofs. This needs to be implemented before the withdrawal path can be considered production-ready.

10. ssz_witness_to_internal forces all forks to timestamp/block 0

chain_config: ChainConfig {
    chain_id: chain_config.chain_id,
    homestead_block: Some(0),
    // ... all forks at 0

The comment says "stateless validation always runs at the latest fork." But the chain_config in the SSZ input only carries chain_id. This means the actual fork activation rules are silently ignored. If a block is validated that was produced under different fork rules (e.g., pre-Prague), the validation will run Prague rules regardless and may produce incorrect results. At minimum, this assumption should be validated against the block's timestamp/number.


Low Issues / Nitpicks

11. chainId: 1 in native_l2.json

fixtures/genesis/native_l2.json uses chainId: 1 (Ethereum mainnet). This will cause signed transactions with v-field EIP-155 protection to be replayable on mainnet if private keys are reused. Use a clearly test-only chain ID (e.g., 31337 or a value in the EIP-2294 reserved range).

12. StatelessValidator trait not feature-gated

The trait is defined unconditionally in levm/src/lib.rs but the execute_precompile module using it is gated on stateless-validation. This causes the vm.rs field stateless_validator: Option<&'a dyn StatelessValidator> to always exist, adding an extra pointer to VM even in non-rollup builds. Gate the trait and the field behind the same feature flag.

13. GuestProgramStateDb Mutex contention not needed

The EXECUTE precompile runs inside a staticcall — it's single-threaded by nature. Mutex<GuestProgramState> adds unnecessary overhead. Use RefCell if interior mutability is needed, or restructure GuestProgramState methods to take &mut self with a unique borrow.

14. validate_l2_constraints checks only the first byte for blob transactions

if let Some(&0x03) = tx_bytes.iter().next() {

This is correct for blob transactions (type byte 0x03) but doesn't guard against other future typed transactions that may also be forbidden on L2. More importantly, this check only looks at the first byte — an empty tx_bytes would pass silently. This should be defensive: require tx_bytes.len() > 0 or use tx_bytes.first().

15. SSZ round-trip test doesn't cover the witness conversion

ssz_round_trip.rs tests BlockHeader field preservation but not ssz_witness_to_internal / internal_witness_to_ssz. This is the most complex conversion path and most likely to have field-level bugs (e.g., base_fee_per_gas 8-of-32-byte encoding). A unit test round-tripping an ExecutionWitness with non-trivial account and storage state would provide much higher confidence.

16. _newBlockHash / _newStateRoot are caller-supplied in advance() but never verified against precompile output

The precompile returns new_payload_request_root (the SSZ hash tree root of the input, not the new block hash or state root). The contract uses _newBlockHash and _newStateRoot as passed by the caller, not as derived from the precompile response. An honest advancer supplies the correct values, but nothing enforces this on-chain. Either include the expected hash/root in the precompile output, or verify them independently.


Summary

The unification architecture and trait design are solid. The implementation needs hardening before this merges:

  • The disabled chain_id check (Point 1) and zero finality delay (Point 2) are blockers for any real deployment.
  • Points 3, 5, and 6 represent correctness gaps.
  • The double SSZ decode (Point 4) is a straightforward fix.
  • Points 9 and 16 are functional limitations that affect correctness of the withdrawal path.

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

@avilagaston9 avilagaston9 force-pushed the feat/unify-stateless-validation-v2 branch from cf68120 to c3a8ed3 Compare April 20, 2026 19:28
@avilagaston9 avilagaston9 changed the title Unify stateless validation: EIP-8025 + native rollups behind single feature flag feat(l2): add native rollups on top of EIP-8025 stateless validation Apr 22, 2026
@github-actions github-actions Bot added the L2 Rollup client label Apr 22, 2026
@avilagaston9 avilagaston9 changed the title feat(l2): add native rollups on top of EIP-8025 stateless validation feat(l2): add EIP-8079 native rollups under experimental-devnet feature Apr 22, 2026
…re flag.

Native rollups reuse the stateless validation infrastructure landed in main
via #6427 (EIP-8025, zkboost sidecar): verify_stateless_new_payload,
execution witnesses, and SSZ encoding. The feature flag is named
experimental-devnet because it will also gate EIP-8142 (block-in-blobs)
later — a devnet bundle rather than a commitment to any single EIP.

Native rollup changes:
- NativeRollup.sol rewritten with spec-aligned storage and SSZ-based advance()
- L2Bridge uses BEACON_ROOTS (EIP-4788) instead of L1Anchor for L1 message
  Merkle roots; L1Anchor.sol removed
- L2 actors: NativeBlockProducer, NativeL1Watcher, NativeL1Advancer
  (Actor/#[protocol] API)
- EXECUTE precompile delegates to StatelessValidator trait (~110 lines,
  down from 587)
- MPT proof verification handles inline nodes for small state tries
- Withdrawal claim path nibblization fix
- StatelessValidator threaded through tracing for Blockscout compatibility

Shared infrastructure:
- SSZ types for native rollups re-export common types (SszStatelessInput,
  SszChainConfig, SszExecutionWitness, SszStatelessValidationResult)
- verify_stateless_new_payload in crates/blockchain/stateless.rs shared
  between EXECUTE precompile, EIP-8025 engine, and zkVM guests

EF stateless tests: 6053/6158 passing.
@avilagaston9 avilagaston9 force-pushed the feat/unify-stateless-validation-v2 branch from 7451802 to 87396be Compare April 22, 2026 19:49
avilagaston9 added 2 commits April 22, 2026 16:54
flag and the correct --eip-8025 CLI flag names.

docs/l2/deployment/native_rollups.md used --stateless-validation both as
the cargo feature and as CLI flag names, which were stale from before
the merge with main. CLI flags are actually --eip-8025[.subfield] and
the cargo feature is now experimental-devnet, so the demo commands were
not runnable as written.

docs/vm/levm/native_rollups.md referenced stateless-validation as the
feature flag name and framed the unification as EIP-8025 + EIP-8079.
The flag is now experimental-devnet, named after the devnet because
EIP-8142 (block-in-blobs) is expected to ride the same bundle.
up the native rollup documentation.

The native rollup CLI flags (--eip-8025, --eip-8025.contract-address,
--eip-8025.relayer-pk, --eip-8025.l1-pk, --eip-8025.block-time,
--eip-8025.advance-interval) were misnamed: they configure native
rollup behavior, which is EIP-8079, not EIP-8025 (triggerable proofs).
Rename them to --eip-8079[.*] in options.rs, deployer.rs, command.rs,
initializers.rs, and the L2 Makefile.

Doc cleanups in docs/l2/deployment/native_rollups.md:
- Remove Components section (duplicated by Architecture overview)
- Drop L1Anchor predeploy note (referred to a design that was never
  merged)
- Drop the "uses the pre-built binary" clarification and the
  deployer/advancer key aside from the deposit step

Doc cleanups in docs/vm/levm/native_rollups.md:
- Retitle to "Native Rollups" — the doc only covers native rollups
- Fix EIP-8079 link to https://eips.ethereum.org/EIPS/eip-8079
- Drop L1Anchor removal note and the L2Beat apply_body/state_transition
  variants explanation (outdated)
- Clarify in Fee Market that the relayer-as-coinbase wiring is
  PoC-only, not mandatory
- Drop the "EIP-8025 integration" row from the alignment table and the
  devnet-bundle justification for the feature flag name
@avilagaston9 avilagaston9 changed the title feat(l2): add EIP-8079 native rollups under experimental-devnet feature feat(l2): add EIP-8079 native rollups Apr 22, 2026
avilagaston9 added 13 commits April 23, 2026 13:28
… a little-endian helper and enforce the provenChainId == chainId check in NativeRollup.advance(). The prior assembly `mload(add(result, 65))` loaded 32 big-endian bytes and truncated to uint64, which reads payload bytes 57..65 big-endian instead of the 8-byte little-endian field at 33..41. Because the value was wrong, the require was left commented out, so a StatelessValidationResult for any chain was silently accepted. With the decode corrected, the check is re-enabled.

Also remove the dated "March 2026 rewrite" note from the contract docstring and document that SENT_MESSAGES_SLOT mirrors L2Bridge.sentMessages's storage slot, so reordering L2Bridge storage does not silently break withdrawal proofs here.
…ts/ and into crates/l2/contracts/src/nativeRollup/{l1,l2}/, where the rest of the L2 contracts live. NativeRollup.sol and its MPTProof.sol helper go under nativeRollup/l1/ (L1-side contract and its library), and L2Bridge.sol moves under nativeRollup/l2/ (deployed on L2 at 0xFfFD).

LEVM is the EVM implementation and shouldn't own production rollup contracts — they were placed there only because the EXECUTE precompile tests sat nearby. The coupling between the precompile and L2Bridge is by runtime address and storage slot, not by source path, so the move is purely organizational. build_l2.rs is updated to point at the new L1 and L2 subdirs separately; no other code references the old location.
…ssz.rs and drop the separate eip8025_ssz module. The two files were already tightly coupled: stateless_ssz.rs opened with a pub-use re-export of every EIP-8025 type so callers could import both groups from a single path. Collapsing them removes that indirection and puts all SSZ containers used by the experimental-devnet feature in one place, split by a clearly labelled section divider so the EIP-8025 types and the native-rollup stateless types remain visually distinct.

Callers in crates/guest-program/src/l1/{input,program}.rs that referenced ethrex_common::types::eip8025_ssz::NewPayloadRequest are updated to use ethrex_common::types::stateless_ssz::NewPayloadRequest. The test module for both groups is merged — 8 tests still pass — and docs/eip-8025.md is updated to point at the new file.
…EIP title on eips.ethereum.org) in the stateless_ssz.rs module docstring and in docs/eip-8025.md.

Rewrite the stateless-types section comment to point at a permalink into execution-specs' projects/zkevm branch (forks/amsterdam/stateless.py) instead of enumerating each type. Call out honestly that only MAX_WITNESS_HEADERS = 256 has a spec assertion backing it; the rest of the MAX_WITNESS_* and MAX_PUBLIC_KEYS limits are ethrex-chosen bounds required to type the SSZ lists, since the reference stateless.py uses untyped Python lists without size limits.

Point the SszChainConfig doc at the specific conversion site where every prior fork is stamped at timestamp 0 (ssz_witness_to_internal in crates/blockchain/stateless.rs), so the "fork rules are implicit" claim is backed by a concrete code reference.
…ex-l2-common and drop the single-line re-export shim. The move to ethrex-common in 37aa493 existed to deduplicate an inline copy that lived in crates/vm/levm/src/execute_precompile.rs at the time; that file has since been rewritten (587 → 110 lines) to delegate to StatelessValidator and no longer imports the merkle tree at all. With every remaining consumer sitting on the L2 side (l1_committer, l2 RPC messages, guest-program l2 messages, native_rollup block_producer), an L2-specific home is the right level.

Also update the one consumer — crates/l2/sequencer/native_rollup/block_producer.rs — that was importing ethrex_common::merkle_tree directly so it goes through ethrex_l2_common::merkle_tree like its peers in the same crate. The three other consumers are unchanged since they already imported via the shim. Net effect on the PR diff is a ~154-line reduction.
…de that were dropped when the helper moved out of the RpcExecutionWitness impl block. They log the two silently-skipped cases during state-trie traversal (a leaf whose 32-byte path decodes to an account blob that fails RLP decoding, and a leaf whose full path is not 32 bytes long), both of which are useful breadcrumbs when debugging a malformed execution witness. With the function now called from two crates instead of one, losing these logs hurts observability in both callers.
…n/stateless.rs.

The docstring previously listed three entry points including "EIP-8025 RPC proof generation flow" — that flow no longer exists in the execution client (proof generation moved to the zkboost sidecar in #6427), so it is replaced with the two real entry points (EXECUTE precompile and zkVM guest) and the redundant repetition of the function name is removed.

On the tracing side: drop the 27-line block-header diagnostic dump that fired at error-level on every block_hash mismatch — the returned error string already carries expected vs computed hash, which is the only breadcrumb an operator needs. Also drop the per-call "validation succeeded" info log, the "deserializing N bytes" debug log, and the "decoded SSZ input" info dump that echoed six fields of every inbound stateless input — all of them fired once per block on the hot path. The remaining logs are the two genuine failure breadcrumbs (validation error, SSZ decode error).
…Validator trait exists at all. The dependency-inversion rationale — ethrex-levm owns the trait so the EXECUTE precompile can call into ethrex-blockchain logic without forming a cycle (blockchain → vm → levm) — is documented in the trait definition itself but is not obvious when reading this file. Calling it out here saves the next reader a trip through the dependency graph.
…ain/stateless.rs and have verify_stateless_new_payload return SszStatelessValidationResult directly. The wrapper carried the same three fields as its SSZ counterpart and existed only so the trait impl could copy them field-for-field before ssz_append — no other caller ever touched it. Collapsing to a single type removes that copy and the parallel definition, and the trait impl's Serialize block shrinks from seven lines to two.
…xecutionWitness::from_ssz method in crates/common/types/block_execution_witness.rs, and extract the trie-rebuild logic shared by that method and RpcExecutionWitness::into_execution_witness into a single rebuild_state_and_storage_tries helper.

Both callers previously ran the same three-step sequence on a flat list of trie-node preimages — decode into a keccak-indexed BTreeMap skipping the 0x80 null-node marker, resolve the embedded state root, and walk the state trie to collect per-account storage tries — differing only in the input byte container (Vec<Bytes> vs Vec<Vec<u8>>). The helper takes `impl IntoIterator<Item = impl AsRef<[u8]>>` so both Bytes and Vec<u8> fit without a copy.

As a side-effect the SSZ path now propagates the "storage trie node is not embedded" error instead of silently dropping it (the old `if let Ok(...)` arm in stateless.rs), which matches the L1 RPC path's behavior and is strictly safer for a stateless validator.

crates/blockchain/stateless.rs loses ~100 lines: the ssz_witness_to_internal function, the collect_account_storage_roots shim, and the String error type they carried. The trait impl now calls ExecutionWitness::from_ssz(...) directly and propagates GuestProgramStateError through the same map_err as before.
…caller in crates/blockchain/stateless.rs no longer has to decode a header to derive first_block_number and initial_state_root. from_ssz now takes an &SszStatelessInput and pulls first_block_number straight from input.new_payload_request.execution_payload.block_number, then finds the parent header (number == first_block_number - 1) among the witness headers — the same pattern RpcExecutionWitness::into_execution_witness already uses on the RPC side, so both witness-reconstruction entry points derive the parent consistently.

The StatelessExecutor trait impl shrinks: the 15-line header-extraction block and the separate `first_block_number + 1` / `last_header.state_root` derivation from the last witness header both disappear, replaced by a single ExecutionWitness::from_ssz(&stateless_input)? call. The old code also silently assumed the last witness header was the parent, which this change turns into an explicit "find the header whose number is first_block_number - 1" — the stricter check rejects witnesses where the parent is mis-ordered.
…ecute instead of twice. Previously extract_gas_used and validate_l2_constraints each called SszStatelessInput::from_ssz_bytes on the same calldata, paying the decode cost twice for every EXECUTE call. Fold both into a single decode, drop extract_gas_used, and change validate_l2_constraints to take a borrowed &SszStatelessInput. Also extend the execute_precompile wrapper's doc comment to explain that it exists to mimic the standard precompile signature (fork/crypto slots plus the threaded stateless_validator Option) and to collapse the Option into an error so run_execute can work with a guaranteed-Some validator.
…e Solidity sources that used to live under crates/vm/levm/contracts/ were moved to crates/l2/contracts/src/nativeRollup/ in 307e91d, so nothing in the levm crate ever writes to contracts/solc_out/ anymore and the gitignore line covers a path that no longer exists.
avilagaston9 added 2 commits April 27, 2026 11:17
Brings in main commits since the prior merge: #6516 EIP-8025 compliance
(Electra-aligned ExecutionRequests typed container in NewPayloadRequest,
MAX_CONSOLIDATION_REQUESTS_PER_PAYLOAD corrected from 1 to 2,
to_encoded_requests() helper for EIP-7685 bytes, removal of
ExecutionPayloadHeader/NewPayloadRequestHeader, new byte-oriented
execution_program entrypoint that decodes the wire format internally and
returns valid: false instead of erroring on post-decode failures), #6463
BAL withdrawal reverse check (DB->BAL direction so a malicious builder
can't omit a withdrawal recipient from the BAL), #6505 Kademlia k-bucket
revert (PeerTableServer::spawn no longer takes a node_id), plus snap-sync
observability + dashboards (#6470), pivot-update crash fix (#6475),
weighted peer selection (#6428), txpool_contentFrom/txpool_inspect RPC
(#6446), block-by-block exec fallback (#6464), Amsterdam EELS branch pin
(#6495), and rollup store SQLite v9->v10 migration (#6514).

Conflict resolutions:
- crates/common/types/stateless_ssz.rs: this branch had already moved
  the EIP-8025 SSZ types out of crates/common/types/eip8025_ssz.rs into
  stateless_ssz.rs and tucked the native-rollup containers below them.
  Kept that layout, applied #6516's content updates to the EIP-8025
  section (renamed spec-limit constants, ExecutionRequests typed
  container with to_encoded_requests, dropped header types and their
  tests), pulled in the EncodedRequests import, and kept both the new
  test_execution_requests_to_encoded_bytes and the branch's stateless
  round-trip tests.
- crates/guest-program/src/l1/program.rs: adopted #6516's new
  execution_program(bytes: &[u8], crypto) API with the internal
  decode_eip8025 call, the validate_eip8025_execution helper, and the
  decode-failure test. Rewrote all `eip-8025` feature gates as
  `experimental-devnet` and all `eip8025_ssz::` paths as
  `stateless_ssz::` to match this branch's renames.
- crates/guest-program/bin/{sp1,risc0,zisk,openvm}/src/main.rs: applied
  #6516's simplification (drop decode_eip8025 import, pass &input
  straight to execution_program) under the experimental-devnet feature
  gate. Also flipped the rkyv::rancor::Error import gate from the old
  `eip-8025` name to `experimental-devnet` so the non-devnet build still
  has the import it needs.
- crates/prover/src/backend/exec.rs: kept #6516's updated comment ("raw
  input bytes" instead of "(NewPayloadRequest, ExecutionWitness)") under
  the experimental-devnet feature gate.

Auto-merged regions checked: crates/vm/backends/levm/mod.rs picked up
all of #6463's Part B (DB->BAL) reverse check intact, and
cmd/ethrex/l2/initializers.rs picked up #6505's PeerTableServer::spawn
signature change. Verified cargo fmt --all clean, cargo check --workspace
clean, cargo check --workspace --tests clean, and cargo check -p
ethrex-guest-program --features experimental-devnet --tests clean.
crates/guest-program/src/l1/program.rs::execution_program ("Decode and
execute the L1 stateless validation program from EIP-8025 wire bytes.")
— the merge resolution kept only the wire-format paragraph and dropped
that opening sentence by accident, so the function lost the high-level
"what does this do" line and the doc started straight into a format
description.

Also trim the "Stateless validation limits" preamble in
crates/common/types/stateless_ssz.rs down to just the section divider.
The block underneath was editorializing about how stateless.py uses
untyped Python lists and how only MAX_WITNESS_HEADERS has spec backing
— useful context the day the constants were chosen, but it doesn't
help anyone reading the file now and the per-constant docstrings below
are sufficient on their own.
@avilagaston9 avilagaston9 marked this pull request as ready for review April 27, 2026 14:30
Copilot AI review requested due to automatic review settings April 27, 2026 14:30
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment: This is a substantial PR introducing Native Rollup (EIP-8079) support as an experimental feature. The code is generally well-structured, properly feature-gated, and includes comprehensive documentation and integration tests. However, there are a few security and correctness concerns that should be addressed before production use.

Critical Security Concerns

1. L1 Contract Missing State Root Verification (NativeRollup.sol)
The advance() function accepts _newBlockHash and _newStateRoot as separate parameters from the SSZ input, but does not verify they match the values inside the _sszStatelessInput payload:

// crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol:194-210
blockHash = _newBlockHash;  // Arbitrary value accepted!
stateRoot = _newStateRoot;  // Arbitrary value accepted!

While the EXECUTE precompile validates that the SSZ input produces a valid state transition, the contract does not constrain the claimed state root to match the one in the SSZ payload. An attacker could submit a valid proof for block N but claim the state root of block N+1.

Recommendation: Either extract and verify the state root from the SSZ input within the contract, or include the resulting state root in StatelessValidationResult and verify it on-chain.

2. MPT Proof Verification Edge Cases (MPTProof.sol)
The processInlineNode function uses inline assembly for memory operations:

// crates/l2/contracts/src/nativeRollup/l1/MPTProof.sol:130-132
assembly ("memory-safe") { 
    prefix := shr(252, mload(add(add(content, 32), epStart))) 
}

While marked memory-safe, the subsequent nibble extraction logic assumes epStart is within bounds. Ensure epLen validation occurs before this assembly block to prevent out-of-bounds reads.

3. Gas Charging Precondition
In the EXECUTE precompile, gas is charged based on the claimed gas_used before execution validates the witness:

// crates/vm/levm/src/execute_precompile.rs:62-65
increase_precompile_consumed_gas(
    input.new_payload_request.execution_payload.gas_used,
    gas_remaining,
)?;

This is acceptable since execution follows immediately and will fail if the claim is fraudulent, but ensure the gas charging cannot overflow gas_remaining (the helper appears to handle this).

Code Correctness & Rust Best Practices

4. SSZ Limits Enforcement
The SSZ types define limits (e.g., MAX_WITNESS_NODES = 1_048_576), but ensure these are enforced during deserialization:

// crates/common/types/stateless_ssz.rs:259-260
const MAX_W


---
*Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt*

Copilot AI left a comment

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.

Pull request overview

Adds experimental “native rollups” (EIP-8079 PoC) support on top of the existing stateless validation infrastructure (previously EIP-8025), behind a new unified experimental-devnet cargo feature. This introduces an EXECUTE precompile path in LEVM, L1/L2 Solidity contracts for message bridging + withdrawals, L2 native-rollup actors, and supporting tests/docs.

Changes:

  • Introduce experimental-devnet feature gate and rename/rewire prior eip-8025 feature usage across crates and docs.
  • Add EXECUTE (0x0101) precompile (LEVM) that decodes SSZ stateless input, enforces L2 constraints, and delegates to a dependency-inverted StatelessValidator.
  • Add native-rollup L2 mode (watcher/producer/advancer), native withdrawal proof RPC, contracts, genesis fixture, and integration tests/docs.

Reviewed changes

Copilot reviewed 84 out of 89 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tooling/l2/dev/blockscout_verify_native_rollup.py Adds a Blockscout verification helper script for NativeRollup.
tooling/ef_tests/state_v2/src/modules/runner.rs Updates VM constructor call to pass the new stateless validator argument.
tooling/ef_tests/state_v2/src/modules/block_runner.rs Updates VM constructor call to pass the new stateless validator argument.
tooling/ef_tests/state/runner/levm_runner.rs Updates VM constructor call to pass the new stateless validator argument.
test/tests/levm/l2_privileged_tx_tests.rs Updates VM constructor call to pass the new stateless validator argument.
test/tests/levm/l2_hook_tests.rs Updates VM constructor call to pass the new stateless validator argument.
test/tests/levm/l2_gas_reservation_tests.rs Updates VM constructor call to pass the new stateless validator argument.
test/tests/levm/l2_fee_token_tests.rs Updates VM constructor call to pass the new stateless validator argument.
test/tests/levm/l2_fee_token_ratio_tests.rs Updates VM constructor call to pass the new stateless validator argument.
test/tests/levm/eip7708_tests.rs Updates VM constructor call to pass the new stateless validator argument.
test/tests/levm/eip7702_tests.rs Updates VM constructor call to pass the new stateless validator argument.
test/tests/l2/utils.rs Gates .env reader behind features to avoid unused imports when L2 is disabled.
test/tests/l2/ssz_round_trip.rs Adds SSZ stateless-input round-trip test to detect encoding mismatches.
test/tests/l2/native_rollup.rs Adds native rollup end-to-end integration test (deposit/withdraw/counter).
test/tests/l2/mod.rs Registers new native-rollup tests behind experimental-devnet.
test/Cargo.toml Adds experimental-devnet feature wiring and dependencies for new tests.
fixtures/genesis/native_l2.json Adds native-rollup L2 genesis fixture (predeploys, balances, bytecode).
docs/vm/levm/native_rollups.md Adds detailed design/spec doc for EXECUTE/native-rollups integration.
docs/l2/deployment/native_rollups.md Adds deployment/demo guide for native rollups devnet.
docs/eip-8025.md Updates naming/feature flag references and SSZ type file path.
docs/eip-8025-zkboost-testnet.md Updates build instructions to use experimental-devnet.
docs/SUMMARY.md Adds new native rollups docs to mdBook summary.
crates/vm/tracing.rs Threads optional stateless validator into LEVM tracing entrypoints.
crates/vm/lib.rs Re-exports StatelessValidator + VM errors for consumers.
crates/vm/levm/src/vm.rs Adds optional stateless_validator field and threads it into precompile execution.
crates/vm/levm/src/precompiles.rs Defines EXECUTE precompile and dispatches it under feature gate.
crates/vm/levm/src/opcode_handlers/system.rs Threads stateless validator through CALL path that hits precompiles.
crates/vm/levm/src/lib.rs Exposes execute_precompile module and defines StatelessValidator trait.
crates/vm/levm/src/hooks/l2_hook.rs Updates internal VM construction to pass validator argument.
crates/vm/levm/src/execute_precompile.rs Implements the EXECUTE precompile wrapper/constraints/gas charging.
crates/vm/levm/src/db/mod.rs Feature-gates new GuestProgramState DB adapter module.
crates/vm/levm/src/db/guest_program_state_db.rs Adds Database adapter over GuestProgramState for stateless execution.
crates/vm/levm/runner/src/main.rs Updates runner VM construction with validator argument.
crates/vm/levm/bench/revm_comparison/src/levm_bench.rs Updates benchmark VM construction with validator argument.
crates/vm/levm/Cargo.toml Adds experimental-devnet feature and optional libssz dependency.
crates/vm/backends/mod.rs Carries stateless_validator in Evm and threads it into LEVM calls.
crates/vm/backends/levm/tracing.rs Threads optional stateless validator into tracing execution.
crates/vm/backends/levm/mod.rs Threads optional stateless validator through LEVM execution pipeline.
crates/vm/Cargo.toml Adds experimental-devnet feature pass-through to LEVM.
crates/prover/src/backend/exec.rs Renames eip-8025 cfg to experimental-devnet for exec backend path.
crates/prover/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/networking/rpc/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/l2/sequencer/native_rollup/types.rs Adds shared native-rollup L1 message type and hashing logic.
crates/l2/sequencer/native_rollup/mod.rs Adds native-rollup actor orchestration and config wiring.
crates/l2/sequencer/native_rollup/l1_watcher.rs Adds actor that polls L1 logs and forwards parsed L1 messages to producer.
crates/l2/sequencer/native_rollup/block_producer.rs Adds native-rollup block producer actor (L1-message relaying + merkle anchoring).
crates/l2/sequencer/mod.rs Exposes native-rollup module behind feature gate.
crates/l2/sdk/src/sdk.rs Adds helper to query native rollup blockNumber() from L1 contract.
crates/l2/networking/rpc/rpc.rs Adds ethrex_getNativeWithdrawalProof RPC under feature gate.
crates/l2/networking/rpc/l2/native_withdrawal_proof.rs Implements proof generation for L2 withdrawals via eth_getProof.
crates/l2/networking/rpc/l2/mod.rs Registers native withdrawal proof module behind feature gate.
crates/l2/networking/rpc/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/l2/l2.rs Re-exports native-rollup start/config behind feature gate.
crates/l2/contracts/src/nativeRollup/l2/L2Bridge.sol Adds L2 bridge predeploy contract (L1 message processing + withdrawals).
crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol Adds L1 rollup contract with EXECUTE-based advance() and withdrawal claiming.
crates/l2/contracts/src/nativeRollup/l1/MPTProof.sol Adds Solidity MPT proof verification library for withdrawal claims.
crates/l2/common/src/messages.rs Adds constant address for native-rollup L2Bridge predeploy.
crates/l2/Makefile Adds native-rollup init/deploy targets using experimental-devnet.
crates/l2/Cargo.toml Adds experimental-devnet feature and dependencies for native-rollup mode.
crates/guest-program/src/l1/program.rs Renames feature gating and exports SSZ payload→Block helper for reuse.
crates/guest-program/src/l1/output.rs Renames feature gating for EIP-8025 output format.
crates/guest-program/src/l1/mod.rs Re-exports new_payload_request_to_block under experimental-devnet.
crates/guest-program/src/l1/input.rs Renames feature gating and SSZ type module path.
crates/guest-program/bin/zisk/src/main.rs Renames feature gating for guest binary.
crates/guest-program/bin/zisk/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/guest-program/bin/sp1/src/main.rs Renames feature gating for guest binary.
crates/guest-program/bin/sp1/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/guest-program/bin/risc0/src/main.rs Renames feature gating for guest binary.
crates/guest-program/bin/risc0/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/guest-program/bin/openvm/src/main.rs Renames feature gating for guest binary.
crates/guest-program/bin/openvm/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/guest-program/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/common/types/stateless_ssz.rs Extends SSZ types for stateless validation/native-rollups containers.
crates/common/types/mod.rs Switches SSZ module export to stateless_ssz under new feature gate.
crates/common/types/block_execution_witness.rs Refactors trie reconstruction; adds SSZ witness conversion helpers.
crates/common/Cargo.toml Renames feature from eip-8025 to experimental-devnet.
crates/blockchain/stateless.rs Adds stateless validation implementation and StatelessExecutor for EXECUTE.
crates/blockchain/blockchain.rs Wires optional StatelessExecutor injection into EVM construction points.
crates/blockchain/Cargo.toml Adds experimental-devnet feature deps for stateless validation.
cmd/ethrex/l2/options.rs Adds CLI options for native-rollup L2 mode under feature gate.
cmd/ethrex/l2/mod.rs Exposes native-rollup initializer under feature gate.
cmd/ethrex/l2/initializers.rs Adds native-rollup L2 initializer that spawns watcher/producer/advancer and RPC.
cmd/ethrex/l2/command.rs Routes --native-rollups and deploy logic under experimental-devnet.
cmd/ethrex/build_l2.rs Compiles and emits native-rollup Solidity artifacts during build.
cmd/ethrex/Cargo.toml Renames and broadens feature gating to experimental-devnet.
Cargo.lock Updates lockfile for new/renamed dependencies and features.
.github/workflows/pr-main_mdbook.yml Excludes an additional docs domain from link checking.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +134 to +156
(bool success, bytes memory result) = EXECUTE_PRECOMPILE.staticcall(_sszStatelessInput);
require(success, "EXECUTE precompile failed");

// Decode SSZ StatelessValidationResult.
// Format: new_payload_request_root (32 bytes) + successful_validation (1 byte) + chain_config (8 bytes)
require(result.length >= 41, "Invalid result length");

// successful_validation is at byte 32 (1 byte, SSZ bool)
require(uint8(result[32]) == 1, "L2 validation failed");

// chain_id is at bytes 33..41 (SSZ uint64, little-endian).
uint64 provenChainId = _decodeSszUint64LE(result, 33);
require(provenChainId == chainId, "chain_id mismatch");

// Update onchain state
uint256 newBlockNumber = blockNumber + 1;
blockHash = _newBlockHash;
stateRoot = _newStateRoot;
blockNumber = newBlockNumber;
stateRootHistory[newBlockNumber] = _newStateRoot;
stateRootTimestamps[newBlockNumber] = block.timestamp;

emit StateAdvanced(newBlockNumber, _newStateRoot);

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

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

advance() updates blockHash and stateRoot using the caller-provided _newBlockHash / _newStateRoot, but those values are not cryptographically bound to the SSZ payload that the EXECUTE precompile actually verified. As a result, a caller can submit a valid _sszStatelessInput (so successful_validation == 1) while writing arbitrary stateRootHistory[newBlockNumber] / blockHash, which would break withdrawal proofs and can corrupt the on-chain view of the L2.

To fix this, ensure the contract derives (or receives from the precompile output) the canonical block_hash and state_root that were validated. A practical option is to extend the EXECUTE precompile output to include the validated block hash and post-state root (or include them in a commitment that the contract can compare against), and remove these as unchecked external parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +27
CONTRACTS_DIR = os.path.join(
os.path.dirname(__file__), "..", "crates", "vm", "levm", "contracts"
)


def flatten_source():
mpt_path = os.path.join(CONTRACTS_DIR, "MPTProof.sol")
nr_path = os.path.join(CONTRACTS_DIR, "NativeRollup.sol")

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

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

CONTRACTS_DIR points to .../tooling/l2/crates/vm/levm/contracts, but that directory doesn’t exist in this repo (the native rollup Solidity sources live under crates/l2/contracts/src/nativeRollup/...). As written, the script will fail with FileNotFoundError when trying to open MPTProof.sol / NativeRollup.sol.

Update the path to the actual contract locations (and ideally resolve it relative to the repo root) so the script works when invoked from any working directory.

Copilot uses AI. Check for mistakes.
Comment on lines +77 to +87
conn = http.client.HTTPConnection(host, port)
conn.request(
"POST",
f"/api/v2/smart-contracts/{address}/verification/via/standard-input",
body.encode(),
{"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
resp = conn.getresponse()
result = json.loads(resp.read())
conn.close()

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

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

submit_verification() always uses http.client.HTTPConnection even if the provided blockscout_url is https://... (the code strips the scheme but doesn’t switch to HTTPSConnection). This will break verification against Blockscout instances served over TLS.

Consider parsing the URL with urllib.parse.urlparse and choosing HTTPConnection vs HTTPSConnection based on the scheme (and preserving any path prefix if present).

Copilot uses AI. Check for mistakes.
Comment on lines 269 to 275
pub fn is_precompile(address: &Address, fork: Fork, vm_type: VMType) -> bool {
#[cfg(feature = "experimental-devnet")]
if *address == EXECUTE.address {
return true;
}
(matches!(vm_type, VMType::L2(_)) && *address == P256VERIFY.address)
|| precompiles_for_fork(fork).any(|precompile| precompile.address == *address)

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

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

is_precompile() unconditionally treats 0x0101 as a precompile when experimental-devnet is enabled, regardless of vm_type. This changes semantics for VMType::L2 executions built with this feature: calls to 0x0101 will be routed to the EXECUTE dispatcher (and likely error due to missing validator) instead of behaving like a regular address.

If EXECUTE is intended to be available only in the L1 execution environment, gate this branch on vm_type (e.g., matches!(vm_type, VMType::L1)) and/or on whether a stateless validator is actually attached.

Copilot uses AI. Check for mistakes.
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have a thorough picture of the entire PR. Let me produce the review.


PR #6418 Review: feat(l2): add EIP-8079 native rollups

Overview

This PR implements EIP-8079 (native rollups) on top of the EIP-8025 stateless-validation infrastructure. It adds:

  • The EXECUTE precompile (0x0101) in LEVM behind a experimental-devnet feature flag
  • NativeRollup.sol, L2Bridge.sol, MPTProof.sol contracts
  • Three L2 actors: NativeBlockProducer, NativeL1Watcher, NativeL1Advancer
  • A StatelessValidator trait in ethrex-levm with StatelessExecutor implemented in ethrex-blockchain to break the dependency cycle
  • SSZ-based witness encoding/decoding in ethrex-common

The dependency-inversion approach for the cycle is well-documented in the PR and in code comments. Overall the design is architecturally sound for a PoC, but there are several correctness and security issues.


Critical Issues

1. advance() stores caller-supplied hashes without validating them against the EXECUTE result

File: crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol:2789-2825

The EXECUTE precompile validates the SSZ input and returns:

new_payload_request_root (32 B) | successful_validation (1 B) | chain_config (8 B)

The contract checks successful_validation == 1 and chain_id matches — but then stores the caller-supplied _newBlockHash and _newStateRoot verbatim:

blockHash = _newBlockHash;   // ← taken from caller, never cross-validated
stateRoot = _newStateRoot;   // ← same
stateRootHistory[newBlockNumber] = _newStateRoot;

An attacker can submit a valid SSZ proof for a real block but supply an arbitrary _newStateRoot, which is then used to verify future withdrawal claims via stateRootHistory. This allows fake withdrawal proofs against a malicious state root.

Fix: The StatelessValidationResult must include the validated block_hash and state_root from the execution_payload, and the contract must cross-validate them:

pub struct SszStatelessValidationResult {
    pub new_payload_request_root: [u8; 32],
    pub block_hash: [u8; 32],   // ← add
    pub state_root: [u8; 32],   // ← add
    pub successful_validation: bool,
    pub chain_config: SszChainConfig,
}

2. advance() does not verify the SSZ block number or parent hash match on-chain state

File: NativeRollup.sol:2789-2825

The contract advances blockNumber → blockNumber + 1 and stores blockHash = _newBlockHash, but never checks:

  • That the SSZ execution_payload.block_number == blockNumber + 1
  • That the SSZ execution_payload.parent_hash == blockHash

A valid SSZ proof for a different block position (e.g., a block built on a stale parent, or a different chain fork) would be accepted. Combined with issue 1, this means a valid SSZ for block N can be submitted when the contract is at block M ≠ N−1, provided the _newBlockHash/_newStateRoot values satisfy later checks.

Fix: Include parent_hash and block_number in the result (or verify them from the SSZ input inline in the contract), and require them to match:

require(result_block_number == blockNumber + 1, "wrong block number");
require(result_parent_hash == blockHash, "wrong parent hash");

High Severity

3. Double SSZ decoding

Files: crates/vm/levm/src/execute_precompile.rs:run_execute, crates/blockchain/stateless.rs:StatelessExecutor::verify

run_execute decodes SszStatelessInput from calldata to charge gas and validate L2 constraints, then passes the original raw calldata bytes to validator.verify(calldata), which decodes the same SSZ a second time inside StatelessExecutor::verify. For large blocks with many transactions and witness nodes, this doubles the deserialization overhead.

Fix: Either thread the already-decoded struct through the trait, or restructure so decoding happens exactly once (e.g., split the trait method into decode + verify_decoded).

4. _burnGas has no cap — trivial DoS vector

File: NativeRollup.sol:2754-2278

function sendL1Message(address _to, uint256 _gasLimit, bytes calldata _data) external payable {
    _burnGas(_gasLimit);
    ...
}

There is no maximum on _gasLimit. A caller who passes _gasLimit = 25_000_000 burns 25M L1 gas in a single call. There is no economic disincentive beyond the ETH gas fee. This allows griefing of L1 block capacity or artificial inflation of relayer transaction costs. At minimum, add:

require(_gasLimit <= l2GasLimit, "gasLimit exceeds L2 block limit");

5. is_precompile returns true for EXECUTE regardless of VMType or fork

File: crates/vm/levm/src/precompiles.rs:5724-5728

#[cfg(feature = "experimental-devnet")]
if *address == EXECUTE.address {
    return true;  // ignores vm_type and fork
}

With experimental-devnet enabled, address 0x0101 is treated as a precompile in all VMs — including L2 VMs and the inner VM created inside verify_inner for stateless block execution. When the inner VM encounters 0x0101, it has stateless_validator = None and returns VMError::Internal(...). Depending on how the CALL opcode handler propagates VMError::Internal (non-recoverable vs. sub-call failure), this could cause the entire L2 block validation to abort with an internal error rather than gracefully reverting the transaction. If VMError::Internal is non-recoverable, any L2 transaction that calls 0x0101 makes the L2 block impossible to validate.

Fix: Restrict the EXECUTE precompile to VMType::L1 only:

if *address == EXECUTE.address && matches!(vm_type, VMType::L1) {
    return true;
}

Medium Severity

6. L1 watcher scans from block 0 on every restart

File: crates/l2/sequencer/native_rollup/l1_watcher.rs:4549

last_block_fetched: U256::zero(),

On every process restart, the watcher re-scans all L1 history from genesis looking for L1MessageRecorded events. For a long-running deployment this becomes increasingly expensive. The last_block_fetched cursor must be persisted (to the Store or a config file), or a configurable start_block parameter must be provided (e.g., the contract deployment block number).

7. FINALITY_DELAY = 0 — no reorg protection on withdrawals

File: NativeRollup.sol:2749

FINALITY_DELAY = 0;

Withdrawals can be claimed immediately with no finality buffer. In an L1 reorg scenario, a block that was used to generate a withdrawal proof could be reverted, while the L1 withdrawal has already succeeded. For a PoC this may be acceptable, but it should be explicitly documented as a TODO in the contract (not just the PR description) since the field infrastructure exists.

8. ABI offset in parse_l1_message_recorded is silently skipped

File: crates/l2/sequencer/native_rollup/l1_watcher.rs:4692-4698

// data[64..96] = ABI offset for `bytes data` (skip — we know it points to 96)
// data[96..128] = byte length of `data`

The ABI offset at bytes 64-96 is ignored without verification. If the event signature ever gains an additional non-indexed field (or if an RPC relay encodes the data differently), the offset could be non-96 and the watcher would silently parse garbage. Add at minimum a debug assertion:

let offset = U256::from_big_endian(&data[64..96]);
debug_assert_eq!(offset, U256::from(96), "unexpected ABI offset in L1MessageRecorded");

9. verify_stateless_new_payload swallows errors silently

File: crates/blockchain/stateless.rs:1122-1127

let successful = match verify_inner(...) {
    Ok(()) => true,
    Err(e) => {
        tracing::error!("stateless validation failed: {e}");
        false
    }
};

Internal errors (OOM, thread panics, serialization bugs) produce successful = false identically to genuine validation failures. A persistent internal bug would produce a stream of silent failures with no signal that they are software errors rather than genuinely invalid blocks. Consider adding a separate error variant for infrastructure failures vs. consensus failures.

10. MPTProof.sol does not support inline extension nodes

File: crates/l2/contracts/src/nativeRollup/l1/MPTProof.sol:2528

require(isLeaf, "MPT: inline ext not supported");

The Ethereum MPT specification allows inline extension nodes (nodes whose RLP encoding is ≤32 bytes, embedded directly in a parent branch). If a withdrawal proof passes through an inline extension node in the L2 state trie, claimWithdrawal will unconditionally revert with "MPT: inline ext not supported." This is a correctness limitation that could make specific withdrawals permanently unclaimable.


Low / Style

11. Duplicate step 7 comment in block_producer.rs

File: crates/l2/sequencer/native_rollup/block_producer.rs:3509, 3512

// 7. Finalize payload (compute state root, receipts root, etc.)
self.blockchain.finalize_payload(&mut context)?;

// 7. Store block      ← should be 8
let block = context.payload;

12. Hardcoded elasticity_multiplier: 2 should come from chain config

File: block_producer.rs:3489

elasticity_multiplier: 2, // EIP-1559 default

ELASTICITY_MULTIPLIER is already a constant in ethrex-common. Use it or derive it from chain_config for consistency with the rest of the codebase.

13. StatelessValidator trait is unconditional but its only use is feature-gated

File: crates/vm/levm/src/lib.rs:5679

The StatelessValidator trait declaration is outside any #[cfg(feature = "experimental-devnet")] gate, so it exists on all builds. The stateless_validator: Option<&'a dyn StatelessValidator> field also exists on every VM even in builds where no implementation exists. This adds a little unnecessary size/complexity. Unless you intend for downstream crates to implement the trait without the feature flag, move both behind the feature gate.

14. Gas charged before L2 constraint validation

File: crates/vm/levm/src/execute_precompile.rs:5599-5606

increase_precompile_consumed_gas(
    input.new_payload_request.execution_payload.gas_used,
    gas_remaining,
)?;
// Validate L2-specific constraints
validate_l2_constraints(&input)?;

Gas equal to the L2 gas_used is charged before constraint validation. If an L2 block fails the constraint check (e.g., blob transactions present), the L1 caller still pays gas_used gas units. This is likely intentional, but should be documented as a design decision.


Positive Notes

  • The StatelessValidator dependency-inversion approach is well-designed and clearly documented, with the trade-offs explicitly called out in the PR description and code comments.
  • The actor/GenServer model for NativeL1Watcher, NativeBlockProducer, and NativeL1Advancer is clean and consistent with the existing L2 sequencer architecture.
  • The internal_witness_to_ssz conversion and the build_ssz_stateless_input function are thorough in their handling of the trie-to-flat-bytes translation.
  • Feature-gating via experimental-devnet and the single-precompile early-dispatch in precompiles.rs is clean and avoids disrupting the existing constant precompile table.
  • parse_l1_message_recorded has thorough bounds checking and correct ABI head/tail decoding for the dynamic bytes field.

Summary

Issues 1 and 2 are the most critical: advance() accepts caller-supplied block hash and state root without binding them to what the EXECUTE precompile actually validated. These must be fixed before deploying to any shared test environment, as they allow arbitrary state root substitution. Issues 3–5 are important for correctness and safety. Issues 6–10 are worth fixing before production use.


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

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. NativeRollup.sol lines 120-156 do not anchor the proven block to the contract’s current head, and then write caller-supplied _newBlockHash / _newStateRoot directly into storage. Because advance() never checks that the validated payload extends the current blockHash and claimWithdrawal() later trusts stateRootHistory (same file), a caller can submit any self-consistent stateless proof from another parent and corrupt the withdrawal root on L1.

  2. NativeRollup.sol only range-checks _l1MessagesCount; it never binds that count to the payload’s parent_beacon_block_root. You already have _computeMerkleRoot() available (same file), but advance() does not compare it against the queued pendingL1Messages[startIdx..], so a valid block with zero or different bridge messages can still skip pending L1 messages by passing a larger count.

  3. block_producer.rs and block_producer.rs reuse msg.gas_limit as both the outer relayer transaction gas limit and the block-packing budget. But L2Bridge.processL1Message() documents that field as the forwarded subcall gas (L2Bridge.sol), so proof verification, nonce/storage writes, and log emission are unbudgeted; honest L1->L2 messages will start reverting once proofs or calldata are non-trivial.

  4. block_producer.rs still includes reverted relayer txs in the block, while l1_advancer.rs counts consumed L1 messages by tx.sender == relayer_address. That means an under-gassed / bad-proof / duplicate relayer tx still increments _l1MessagesCount on L1 and permanently advances l1MessageIndex past messages that were never processed on L2.

  5. The experimental-devnet path looks source-incompatible as written: l1_advancer.rs still initializes removed ExecutionPayload fields and treats execution_requests as SszList, execute_precompile.rs calls .is_empty() on it, but stateless_ssz.rs / stateless_ssz.rs define ExecutionPayload without those fields and NewPayloadRequest.execution_requests as an ExecutionRequests struct. This should be fixed before the feature can build.

Beyond those blockers, the new RPC proof helper and the stateless-validator plumbing look directionally reasonable.


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

@greptile-apps

greptile-apps Bot commented Apr 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds EIP-8079 native rollup support behind the experimental-devnet feature flag, introducing NativeRollup.sol, L2Bridge.sol, three new L2 actors (NativeBlockProducer, NativeL1Watcher, NativeL1Advancer), and an EXECUTE precompile in LEVM that delegates to verify_stateless_new_payload via a StatelessValidator trait to break a crate dependency cycle.

Two security issues in NativeRollup.advance() need attention before this is used beyond an isolated devnet:

  • _newBlockHash, _newStateRoot, and _l1MessagesCount are stored directly from caller arguments without being verified against the EXECUTE precompile result. The returned new_payload_request_root commits to the actual block hash and state root but is never cross-checked.
  • The submitted block's parent_hash (inside the SSZ input) is not compared against the on-chain blockHash, allowing non-sequential or forked block commitments.

Confidence Score: 3/5

Safe to merge as an experimental devnet PoC, but two structural security issues in NativeRollup.advance() must be resolved before the on-chain state commitments can be trusted.

Two P1 security findings in NativeRollup.sol — caller-supplied state root/block hash stored without verification against the EXECUTE proof, and no parent-hash continuity check — mean the on-chain L2 state can be set arbitrarily. Feature is gated behind experimental-devnet which limits blast radius, but the issues are structural.

crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol — the two P1 findings in advance() are the primary concern.

Security Review

  • NativeRollup.advance() — unbound state commitment (NativeRollup.sol): _newBlockHash, _newStateRoot, and _l1MessagesCount are accepted from the caller and stored without verifying they match the values committed to by the EXECUTE proof's new_payload_request_root. An adversary with any valid execution witness can commit arbitrary state to L1.
  • NativeRollup.advance() — no parent-hash continuity check (NativeRollup.sol): the execution_payload.parent_hash embedded in the SSZ input is never compared to the on-chain blockHash, allowing the sequencer to commit blocks that do not extend the current tip.

Important Files Changed

Filename Overview
crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol Core L1 contract for native rollup state management; advance() stores caller-supplied block hash, state root, and message count without verifying them against the EXECUTE proof result, and does not check parent hash continuity.
crates/vm/levm/src/execute_precompile.rs New EXECUTE precompile implementation; correctly gates L2 constraints and delegates to StatelessValidator, but SSZ input is decoded twice (once here, once inside the validator).
crates/blockchain/stateless.rs New stateless validation module; cleanly implements StatelessExecutor to break the levm↔blockchain dependency cycle; verify_inner correctly validates block hash and executes statelessly without recursion.
crates/l2/sequencer/native_rollup/l1_advancer.rs L1 advancer actor that generates SSZ witnesses and submits advance() transactions; l1_messages_count is computed by ECDSA-recovering each transaction's sender, which can silently undercount on recovery failure.
crates/l2/sequencer/native_rollup/block_producer.rs New NativeBlockProducer actor; correctly prioritizes relayer transactions over mempool txs and computes L1 message Merkle roots for parent_beacon_block_root.
crates/l2/contracts/src/nativeRollup/l2/L2Bridge.sol L2 bridge predeploy for message processing and withdrawals; processL1Message intentionally ignores subcall failures (documented as TBD for recovery); Merkle proof and EIP-4788 beacon root lookup look correct.
crates/l2/networking/rpc/l2/native_withdrawal_proof.rs New RPC endpoint for generating MPT withdrawal proofs; correctly decodes WithdrawalInitiated event topics and computes storage slot key matching NativeRollup.sol's SENT_MESSAGES_SLOT.
crates/l2/sequencer/native_rollup/l1_watcher.rs L1 event watcher; cleanly polls L1MessageRecorded logs and forwards parsed messages to the block producer.

Sequence Diagram

sequenceDiagram
    participant L1W as NativeL1Watcher
    participant BP as NativeBlockProducer
    participant Store as L2 Store
    participant ADV as NativeL1Advancer
    participant L1 as NativeRollup.sol (L1)
    participant EXEC as EXECUTE Precompile

    L1W->>L1: poll L1MessageRecorded events
    L1W->>BP: EnqueueL1Messages(msgs)
    BP->>BP: build relayer txs (Merkle proofs)
    BP->>Store: produce & store L2 block
    ADV->>Store: fetch next unadvanced block + witness
    ADV->>ADV: build_ssz_stateless_input()
    ADV->>L1: advance(l1MsgCount, sszInput, newBlockHash, newStateRoot)
    L1->>EXEC: staticcall(sszInput)
    EXEC->>EXEC: decode SSZ, validate L2 constraints, charge gas_used
    EXEC->>EXEC: StatelessValidator::verify()
    EXEC-->>L1: SszStatelessValidationResult
    L1->>L1: store blockHash/stateRoot from caller args (not bound to proof)
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol
Line: 153-179

Comment:
**Caller-supplied block hash, state root, and message count are not verified against the EXECUTE proof**

`advance()` accepts `_newBlockHash`, `_newStateRoot`, and `_l1MessagesCount` as untrusted caller arguments but stores them directly after only checking `successful_validation == 1` and `chain_id`. A caller who has a valid SSZ witness for any block can submit that witness but pass arbitrary `_newBlockHash`, `_newStateRoot`, or `_l1MessagesCount` values — the EXECUTE precompile validates execution correctness but the resulting values stored on-chain are never bound to the proof.

Concretely: the `result` returned by `EXECUTE_PRECOMPILE.staticcall` contains `new_payload_request_root` (a hash-tree-root that commits to `execution_payload.block_hash`, `execution_payload.state_root`, and the parent hash), but the contract never verifies that `_newBlockHash` or `_newStateRoot` match those committed values. Similarly, `_l1MessagesCount` has no cryptographic tie to the messages actually consumed inside the proven block.

The fix is to either extract `block_hash`/`state_root` from the SSZ input bytes inside the contract, or extend `SszStatelessValidationResult` to return them so the contract can assert `_newBlockHash == result.block_hash`.

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/l2/contracts/src/nativeRollup/l1/NativeRollup.sol
Line: 151-153

Comment:
**No check that the submitted block's parent hash matches the current on-chain `blockHash`**

`advance()` increments `blockNumber` and stores the new state, but nothing verifies that the block inside `_sszStatelessInput` descends from the current on-chain tip. The `execution_payload.parent_hash` in the SSZ input is never compared to `blockHash`. This allows a sequencer to commit an execution proof for an arbitrary block (any parent chain), forking the on-chain L2 view. Combined with the unverified `_newBlockHash` finding above, the on-chain chain state can be set to any value as long as some valid SSZ witness exists.

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/vm/levm/src/execute_precompile.rs
Line: 57-73

Comment:
**Double SSZ decode of the same input**

`run_execute` decodes `calldata` into `SszStatelessInput` to charge gas and validate L2 constraints, then passes the original `calldata` bytes to `validator.verify(calldata)`. Inside `StatelessExecutor::verify`, the bytes are decoded a second time from scratch. Both decodes operate on the same bytes so there's no correctness issue, but the gas-charged `gas_used` and the gas-executed `gas_used` are computed from independent decode instances. Consider passing the already-decoded `input` (or its `gas_used` / constraint data) into the validator to avoid the redundant work and make the coupling explicit.

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/l2/sequencer/native_rollup/l1_advancer.rs
Line: 153-161

Comment:
**`l1_messages_count` computed by recovering tx sender — silent undercount on recovery failure**

The count of L1 messages is derived by ECDSA-recovering the sender of every transaction in the block and comparing to `relayer_address`. If any relayer transaction's sender recovery fails (e.g. a malformed signature edge case), that transaction is silently excluded and the count passed to `advance()` will be lower than the number of messages actually processed in the block. Because `advance()` trusts this count to advance `l1MessageIndex`, the on-chain index would fall behind, making those messages re-eligible for future inclusion.

Consider deriving the count from the block's execution receipts or storing it as metadata during block production, rather than re-recovering senders at advancer time.

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

Reviews (1): Last reviewed commit: "Restore the doc comment first paragraph ..." | Re-trigger Greptile

Comment on lines +153 to +179
stateRootHistory[newBlockNumber] = _newStateRoot;
stateRootTimestamps[newBlockNumber] = block.timestamp;

emit StateAdvanced(newBlockNumber, _newStateRoot);
}

// ===== L1 Messages Merkle Tree =====

/// @notice Compute the commutative Merkle root over a range of pending L1 messages.
function computeMerkleRoot(uint256 startIdx, uint256 count) external view returns (bytes32) {
return _computeMerkleRoot(startIdx, count);
}

function _computeMerkleRoot(uint256 startIdx, uint256 count) internal view returns (bytes32) {
if (count == 0) return bytes32(0);
if (count == 1) return pendingL1Messages[startIdx];

bytes32[] memory layer = new bytes32[](count);
for (uint256 i = 0; i < count; i++) {
layer[i] = pendingL1Messages[startIdx + i];
}

while (layer.length > 1) {
uint256 newLen = (layer.length + 1) / 2;
bytes32[] memory newLayer = new bytes32[](newLen);
for (uint256 i = 0; i < newLen; i++) {
if (2 * i + 1 < layer.length) {

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 security Caller-supplied block hash, state root, and message count are not verified against the EXECUTE proof

advance() accepts _newBlockHash, _newStateRoot, and _l1MessagesCount as untrusted caller arguments but stores them directly after only checking successful_validation == 1 and chain_id. A caller who has a valid SSZ witness for any block can submit that witness but pass arbitrary _newBlockHash, _newStateRoot, or _l1MessagesCount values — the EXECUTE precompile validates execution correctness but the resulting values stored on-chain are never bound to the proof.

Concretely: the result returned by EXECUTE_PRECOMPILE.staticcall contains new_payload_request_root (a hash-tree-root that commits to execution_payload.block_hash, execution_payload.state_root, and the parent hash), but the contract never verifies that _newBlockHash or _newStateRoot match those committed values. Similarly, _l1MessagesCount has no cryptographic tie to the messages actually consumed inside the proven block.

The fix is to either extract block_hash/state_root from the SSZ input bytes inside the contract, or extend SszStatelessValidationResult to return them so the contract can assert _newBlockHash == result.block_hash.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol
Line: 153-179

Comment:
**Caller-supplied block hash, state root, and message count are not verified against the EXECUTE proof**

`advance()` accepts `_newBlockHash`, `_newStateRoot`, and `_l1MessagesCount` as untrusted caller arguments but stores them directly after only checking `successful_validation == 1` and `chain_id`. A caller who has a valid SSZ witness for any block can submit that witness but pass arbitrary `_newBlockHash`, `_newStateRoot`, or `_l1MessagesCount` values — the EXECUTE precompile validates execution correctness but the resulting values stored on-chain are never bound to the proof.

Concretely: the `result` returned by `EXECUTE_PRECOMPILE.staticcall` contains `new_payload_request_root` (a hash-tree-root that commits to `execution_payload.block_hash`, `execution_payload.state_root`, and the parent hash), but the contract never verifies that `_newBlockHash` or `_newStateRoot` match those committed values. Similarly, `_l1MessagesCount` has no cryptographic tie to the messages actually consumed inside the proven block.

The fix is to either extract `block_hash`/`state_root` from the SSZ input bytes inside the contract, or extend `SszStatelessValidationResult` to return them so the contract can assert `_newBlockHash == result.block_hash`.

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

Comment on lines +151 to +153
stateRoot = _newStateRoot;
blockNumber = newBlockNumber;
stateRootHistory[newBlockNumber] = _newStateRoot;

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 security No check that the submitted block's parent hash matches the current on-chain blockHash

advance() increments blockNumber and stores the new state, but nothing verifies that the block inside _sszStatelessInput descends from the current on-chain tip. The execution_payload.parent_hash in the SSZ input is never compared to blockHash. This allows a sequencer to commit an execution proof for an arbitrary block (any parent chain), forking the on-chain L2 view. Combined with the unverified _newBlockHash finding above, the on-chain chain state can be set to any value as long as some valid SSZ witness exists.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol
Line: 151-153

Comment:
**No check that the submitted block's parent hash matches the current on-chain `blockHash`**

`advance()` increments `blockNumber` and stores the new state, but nothing verifies that the block inside `_sszStatelessInput` descends from the current on-chain tip. The `execution_payload.parent_hash` in the SSZ input is never compared to `blockHash`. This allows a sequencer to commit an execution proof for an arbitrary block (any parent chain), forking the on-chain L2 view. Combined with the unverified `_newBlockHash` finding above, the on-chain chain state can be set to any value as long as some valid SSZ witness exists.

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

Comment on lines +57 to +73
})?;

// Charge gas based on the L2 block's gas_used
increase_precompile_consumed_gas(
input.new_payload_request.execution_payload.gas_used,
gas_remaining,
)?;

// Validate L2-specific constraints
validate_l2_constraints(&input)?;

// Delegate to verify_stateless_new_payload via the trait
let result = validator.verify(calldata)?;
Ok(Bytes::from(result))
}

/// Validate L2-specific constraints on the ExecutionPayload.

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 Double SSZ decode of the same input

run_execute decodes calldata into SszStatelessInput to charge gas and validate L2 constraints, then passes the original calldata bytes to validator.verify(calldata). Inside StatelessExecutor::verify, the bytes are decoded a second time from scratch. Both decodes operate on the same bytes so there's no correctness issue, but the gas-charged gas_used and the gas-executed gas_used are computed from independent decode instances. Consider passing the already-decoded input (or its gas_used / constraint data) into the validator to avoid the redundant work and make the coupling explicit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/execute_precompile.rs
Line: 57-73

Comment:
**Double SSZ decode of the same input**

`run_execute` decodes `calldata` into `SszStatelessInput` to charge gas and validate L2 constraints, then passes the original `calldata` bytes to `validator.verify(calldata)`. Inside `StatelessExecutor::verify`, the bytes are decoded a second time from scratch. Both decodes operate on the same bytes so there's no correctness issue, but the gas-charged `gas_used` and the gas-executed `gas_used` are computed from independent decode instances. Consider passing the already-decoded `input` (or its `gas_used` / constraint data) into the validator to avoid the redundant work and make the coupling explicit.

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

Comment on lines +153 to +161

// 4. Count L1 messages by counting relayer txs in the block
let l1_messages_count: u64 = block_body
.transactions
.iter()
.filter(|tx| tx.sender(&NativeCrypto).ok() == Some(self.relayer_address))
.count()
.try_into()
.map_err(|_| NativeL1AdvancerError::Encoding("l1 messages count overflow".into()))?;

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 l1_messages_count computed by recovering tx sender — silent undercount on recovery failure

The count of L1 messages is derived by ECDSA-recovering the sender of every transaction in the block and comparing to relayer_address. If any relayer transaction's sender recovery fails (e.g. a malformed signature edge case), that transaction is silently excluded and the count passed to advance() will be lower than the number of messages actually processed in the block. Because advance() trusts this count to advance l1MessageIndex, the on-chain index would fall behind, making those messages re-eligible for future inclusion.

Consider deriving the count from the block's execution receipts or storing it as metadata during block production, rather than re-recovering senders at advancer time.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/l2/sequencer/native_rollup/l1_advancer.rs
Line: 153-161

Comment:
**`l1_messages_count` computed by recovering tx sender — silent undercount on recovery failure**

The count of L1 messages is derived by ECDSA-recovering the sender of every transaction in the block and comparing to `relayer_address`. If any relayer transaction's sender recovery fails (e.g. a malformed signature edge case), that transaction is silently excluded and the count passed to `advance()` will be lower than the number of messages actually processed in the block. Because `advance()` trusts this count to advance `l1MessageIndex`, the on-chain index would fall behind, making those messages re-eligible for future inclusion.

Consider deriving the count from the block's execution receipts or storing it as metadata during block production, rather than re-recovering senders at advancer time.

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

avilagaston9 added 12 commits April 27, 2026 13:12
… no longer trusts caller-supplied roots, cap sendL1Message gasLimit at l2GasLimit, and unblock the experimental-devnet build.

advance() previously took bytes32 _newBlockHash / bytes32 _newStateRoot arguments and stored them verbatim, which let any caller submit a valid SSZ block alongside arbitrary roots and corrupt stateRootHistory[N] (used by claimWithdrawal). It also did not check parent_hash, gas_limit, or block_number sequentiality against the contract's own state, so a caller could replay a block from a different chain or leave gaps in the sequence.

Now the caller passes only the SSZ blob. After the EXECUTE precompile reports successful_validation == true the input bytes describe a correctly executed block, so the contract walks the SSZ offset chain (StatelessInput -> NewPayloadRequest -> ExecutionPayload) on the calldata buffer to read parent_hash, state_root, block_number, gas_limit, and block_hash directly from the validated bytes. The StatelessValidationResult struct stays spec-aligned with execution-specs/projects/zkevm (3 fields: new_payload_request_root, successful_validation, chain_config) — the binding happens via the input, not by extending the result.

Continuity is enforced explicitly:
  - block_number == blockNumber + 1
  - parent_hash   == blockHash
  - gas_limit     == l2GasLimit
  - chain_id      == chainId

sendL1Message now requires _gasLimit > 0 and _gasLimit <= l2GasLimit: a single message that asks for more gas than an entire L2 block can never be scheduled, so reject it up front instead of letting it sit in pendingL1Messages forever.

Also fix two pre-existing build errors that surfaced when running cargo check/clippy with --features experimental-devnet after the merge of main: ExecutionRequests is now a struct of three SszLists (not a single list) so execute_precompile.rs and l1_advancer.rs needed to be updated to match the new schema; ExecutionPayload no longer carries deposit_requests / withdrawal_requests / consolidation_requests fields (those moved into ExecutionRequests on NewPayloadRequest); and a clippy::for_kv_map warning in internal_witness_to_ssz that I touched.
…th OpenZeppelin's MerkleProof.verify, matching the OZ-compatible producer in crates/l2/common/src/merkle_tree.rs.

The L2 stack already uses OZ MerkleProof (CommonBridge.sol) and the Rust producer is documented as compliant with OpenZeppelin's MerkleProof.sol verify, so the native-rollup L2Bridge having its own _verifyMerkleProof + _hashPair was duplicate code that could drift silently. Drop the local helpers and import @openzeppelin/contracts/utils/cryptography/MerkleProof.sol.

NativeRollup.sol's computeMerkleRoot / _computeMerkleRoot / _hashPair were dead inspection helpers (external view, only useful off-chain) — remove them too.

Update build_l2.rs so native-rollup contracts compile with the same OpenZeppelin remappings the L1/L2 contracts use; extend allow_paths to cover the OZ checkout under out_dir/lib.
…age_recorded.

The previous parser hard-coded that data[64..96] points to 96 (the start of the bytes-data length word) without checking. If the L1MessageRecorded event ever gains another non-indexed parameter before `data` the offset moves and the parser silently emits garbage L1 messages. Read the offset explicitly and reject anything other than 96.
…() behind a constructor-set `advancer` (`onlyAdvancer` modifier), snapshot `lastFetchedL1Block = block.number` so the L2 watcher can seed its cursor without rescanning L1 history, comment the `+44` SSZ NewPayloadRequest fixed-prefix bound, support inline extension nodes in `processInlineNode` via a new `peekRlpItemKindMem` helper, and reject non-list first bytes in `rlpListHeader` with a clear error instead of a checked-arith underflow. Plumb the `advancer` constructor argument through `deploy_native_rollup_contracts` (defaults to the deployer signer) and add a `--native-rollups.advancer-address` CLI flag.
…ints in `execute_precompile` before charging gas so a malformed input doesn't burn the caller's budget; propagate `SszList::push` capacity errors from `build_ssz_stateless_input` and `internal_witness_to_ssz` instead of swallowing them with `let _ =`; seed `NativeL1Watcher.last_block_fetched` from `lastFetchedL1Block()` on first poll (reuses `ethrex_l2_sdk::get_last_fetched_l1_block`, mirrors the standard L2 watcher) instead of rescanning from L1 genesis on every restart; drop a no-op `H256::from_slice(...as_bytes())` wrapper around `keccak()` in native_withdrawal_proof.rs.
…e the EIP-8025 dummy-output branch in `ExecBackend::execute_core` on `not(feature = "l2")` so the L2 `ProgramOutput` shape resolves correctly when both features unify under workspace check. Add the workspace check and a `cargo test -p ethrex-l2 --features experimental-devnet --lib native_rollup` step (covers the SSZ Block↔StatelessInput round-trip in `l1_advancer::tests`) to the L2 lint job — without these, no CI surface compiles or runs the experimental-devnet code.
…llups.md to call l2GasLimit() instead of gasLimit(). NativeRollup.sol exposes the block gas limit through the public storage variable l2GasLimit (crates/l2/contracts/src/nativeRollup/l1/NativeRollup.sol), so the doc's gasLimit() call reverts with "execution reverted" when run against a deployed contract during the native-rollup demo.
….py at the actual NativeRollup.sol/MPTProof.sol location so the Blockscout verification script can find its sources to flatten. The script lives at tooling/l2/dev/, so a single ".." resolves to tooling/l2/, and the previous "../crates/vm/levm/contracts" path was doubly wrong: it had only one parent step (giving tooling/l2/crates/vm/levm/contracts, which doesn't exist) and pointed at the pre-PR-#6516 location for the L1 native-rollup contracts. After the contracts were moved to crates/l2/contracts/src/nativeRollup/l1/ during the experimental-devnet unification, the script raised FileNotFoundError on flatten_source(), so Step 6 of the native-rollup deployment demo (verifying the deployed NativeRollup against a local Blockscout) couldn't run. Update the path to "../../../crates/l2/contracts/src/nativeRollup/l1" so Step 6 succeeds.
…the Taiko-links docs fix from #6525. Conflict in .github/workflows/pr-main_mdbook.yml on the lychee exclude list resolved by unioning both sides: keep `--exclude 'stateless-validation.l2beat.com'` (added on this branch for the native rollup docs that link to the L2Beat native rollups page) and `--exclude 'mirror.xyz'` (added on main for taiko.mirror.xyz now linked from docs/l2/fundamentals/based.md). docs/l2/fundamentals/based.md auto-merged cleanly with main's link updates.
…in_l2_prover.yaml so a flaky SP1 toolchain install no longer cancels the risc0 and zisk lint jobs. The matrix had no fail-fast override, so it inherited the GitHub Actions default of fail-fast: true: when the SP1 install action panics with "No release found for the expected tag: succinct-1.<rust-ver>" (sp1up intermittently 404s on succinctlabs/rust release tags, observed across multiple branches in the same minute), the SP1 cell fails and GitHub immediately cancels the risc0 and zisk cells mid-build — even though risc0 and zisk are entirely independent installs and would have completed successfully on their own. With fail-fast: false each backend's lint stands on its own and a single transient SP1 outage costs us only the SP1 signal, not all three.
…prover.yaml's lint_zk matrix from 5c167ef. The change didn't address the actual failure — the SP1 install step itself failed because sp1up's `succinct-1.<rust-ver>` release tag was momentarily 404 from upstream — and `fail-fast: false` only changes whether risc0 and zisk get cancelled alongside it. With SP1's own install still failing, the all-tests `Lint` aggregator would still go red on the next push regardless. The original commit also pinned the rationale to a single transient incident, which would rot the moment SP1 fixes their installer and stop reflecting why the workflow setting is the way it is. Restore the matrix to its prior shape and let the SP1 install action's existing retry logic handle the transient.
…re tripping the L1 / L2 / L2-prover lint jobs on `cargo fmt --all -- --check`. In `crates/l2/sequencer/native_rollup/l1_advancer.rs`, the `.map(|(i, header_bytes)| { SszList::try_from(...).map_err(|e| { format!(...) }) })` closure had the inner `format!` wrapped in a block-bodied `map_err` even though the body was a single expression that fits on one line; rustfmt prefers `.try_from(...).map_err(|e| format!(...))` here, matching the style already used a few lines above for the `.codes` block. In `crates/l2/sequencer/native_rollup/l1_watcher.rs::poll_l1_messages`, the `error!("NativeL1Watcher: failed to read lastFetchedL1Block from contract: {e}")` call was split across three lines but the whole string literal fits on one within the line-width budget, so rustfmt collapses it to a single line. These were the only two diffs `cargo fmt --check` reported across the workspace and the tooling sub-workspace, and applying them clears the L1 `Lint` and L2 `Lint L2` jobs in one shot.

@ElFantasma ElFantasma left a comment

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.

Substantial PR adding EIP-8079 native rollups. Reviewed with focus on security (this is the consensus boundary) and the StatelessValidator trait you flagged.

Note on greptile/copilot P1 dismissals: both reviewers flagged advance() as accepting caller-supplied _newBlockHash / _newStateRoot. Re-reading the current code, those values come from _decodeProvenPayloadFields(_sszStatelessInput) — i.e., from in-Solidity SSZ-decoded fields of the very payload the EXECUTE precompile validated, not from caller params. And provenParentHash == blockHash is checked. So the block-hash and state-root binding concerns are not present in this code; both bots seem to be reasoning about an earlier shape. Worth dismissing with a note in the response so the trail is clear. The real P1 is the _l1MessagesCount parameter — see inline.

StatelessValidator trait (your feedback ask): the current shape (trait in ethrex-vm, impl in ethrex-blockchain, Arc<dyn> injected at VM::new) is the right pattern given the dep-graph constraint. The cycle levm → blockchain is real (blockchain → vm → levm); dep-inversion via a trait is the standard fix. Three small refinements:

  1. Make the trait method generic over input type so the precompile can pass the already-decoded SszStatelessInput (avoids the double-decode noted inline).
  2. Feature-gate the Option<Arc<dyn …>> slot on Evm with #[cfg(feature = "experimental-devnet")] so non-feature builds don't carry an unused slot.
  3. Document the seam in ethrex-vm with a # Why this is a trait block so future contributors don't have to reason about the cycle from scratch.

Cargo feature rename eip-8025experimental-devnet: makes sense as the umbrella for shared-infra EIPs. Worth noting in CHANGELOG, and grepping the workspace for stale eip-8025 strings (CI configs, docs, READMEs).

Tests gap: integration test exists. Two coverage gaps to add before merge:

  • _l1MessagesCount desync (caller claims N, payload consumes M, M < N) — should fail post-fix.
  • Malformed-offset SSZ payload rejected by precompile rather than silently producing wrong proven values.

/// precompile reports `successful_validation == true` those bytes
/// describe a correctly executed block, so the caller cannot
/// substitute the new block_hash, state_root, etc.
function advance(

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 SECURITY: _l1MessagesCount is caller-controlled and unverified. The precompile validates the SSZ stateless input but never returns the count of L1 messages the L2 block actually consumed (SszStatelessValidationResult carries only request_root, successful, chain_id). So a malicious or buggy advancer can claim 5 L1 messages were consumed when the L2 block consumed 0, and this contract advances l1MessageIndex by 5, silently dropping queued L1→L2 messages.

Fix shape: the L2 block's L1-messages consumption needs to be cryptographically committed inside the SSZ payload (e.g., a count field bound to BEACON_ROOTS-derived root, or a dedicated SSZ consumed_l1_message_count field that the precompile validates against the block's actual deposits). Then the contract reads that count out of the SSZ payload (the same way it reads provenBlockNumber), not as a separate parameter. Without this, onlyAdvancer is the entire defense, and a compromised advancer can DoS L1→L2 messaging.

uint256 constant EP_STATE_ROOT_OFFSET = 52;
uint256 constant EP_BLOCK_NUMBER_OFFSET = 404;
uint256 constant EP_GAS_LIMIT_OFFSET = 412;
uint256 constant EP_BLOCK_HASH_OFFSET = 472;

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.

Hardcoded SSZ field offsets (EP_PARENT_HASH_OFFSET = 0, EP_STATE_ROOT_OFFSET = 52, EP_BLOCK_NUMBER_OFFSET = 404, ..., EP_FIXED_PREFIX_LEN = 528) are computed against the current ExecutionPayload SSZ schema. If the schema ever grows a new fixed-position field (or an existing variable offset shifts), the contract reads garbage and the provenBlockHash/provenStateRoot you commit to storage become attacker-controlled bytes. Since the contract is L1-deployed and upgrade-constrained, this is a significant brittleness. Mitigations:

  • A schema version byte at the start of _sszStatelessInput, validated on entry.
  • A unit test that compares these offsets against SszEncode output of a sample payload, gated on merge.

@@ -255,6 +267,10 @@ pub fn precompiles_for_fork(fork: Fork) -> impl Iterator<Item = Precompile> {
}

pub fn is_precompile(address: &Address, fork: Fork, vm_type: VMType) -> bool {

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.

is_precompile() for VMType::L2: this unconditionally treats 0x0101 as a precompile when experimental-devnet is enabled, regardless of vm_type. For an L2 execution built with this feature, calls to 0x0101 route to the EXECUTE dispatcher and likely error since the validator isn't wired in the L2 path. Either gate the dispatch on vm_type == VMType::L1 (the EXECUTE precompile is L1-only by design — it's the L1 contract calling L2 validation) or document why L2 mode also accepts the address. (Copilot flagged.)

}

/// Validate L2 constraints, charge gas, delegate.
fn run_execute(

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.

Double SSZ decode: this decodes calldata into SszStatelessInput for L2 constraint validation, then StatelessExecutor::verify decodes the same bytes again. Two full SSZ decodes per precompile invocation. Pass the already-decoded SszStatelessInput through (or have verify accept &SszStatelessInput and re-serialize the result instead). (Greptile P2.)


COMPILER_VERSION = "v0.8.31+commit.fd3a2265"
CONTRACT_NAME = "NativeRollup"
CONTRACTS_DIR = os.path.join(

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.

Path is wrong (Copilot caught) — file lives under crates/l2/contracts/src/nativeRollup/, not tooling/l2/crates/vm/levm/contracts/.

)
body += f"--{boundary}--\r\n"

conn = http.client.HTTPConnection(host, port)

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.

Uses http.client.HTTPConnection even for https:// URLs (Copilot caught). Strip-scheme-then-pick-protocol pattern: parse with urllib.parse.urlparse and switch to HTTPSConnection when scheme == "https". Will break verification against any TLS Blockscout instance.

avilagaston9 added 2 commits May 7, 2026 11:31
…advancer

can't pick an arbitrary parent_beacon_block_root: decode it from the SSZ input,
recompute the Merkle root over pendingL1Messages[startIdx..+count] (Solidity port
of compute_merkle_root, same shape L2Bridge proofs use), and require equality.
Mirrors OnChainProposer.commitBatch's processedPrivilegedTransactionsRollingHash check.
….rs by

hoisting main's top-level `use`s while keeping this branch's broader cfg gate.
Auto-merge fix in test/tests/levm/l2_hook_tests.rs: new VM::new call needs None for the 7th arg.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L2 Rollup client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants