feat(l2): add EIP-8079 native rollups#6418
Conversation
🤖 Kimi Code Review
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 Issues1. SSH Git Dependency Blocks External BuildsFile: 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 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 SourceFile: 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 3. Unwrap/Expect in Critical PathFile: let original_hash = block_header.compute_block_hash(&NativeCrypto);
// ...
let reconstructed_hash = reconstructed_block.hash();
assert_eq!(original_hash, reconstructed_hash); // In debug blockProblem: While this specific instance is in a debug-only block (lines 156–181), similar patterns exist in production code: File: let base_fee_bytes = payload.base_fee_per_gas[..8]
.try_into()
.map_err(|_| "base_fee_per_gas conversion")?; // Should handle error properlyFix: Use proper error propagation instead of Security & Correctness4. Integer Overflow Risk in Gas CalculationsFile: let next_gas = cumulative_gas.saturating_add(msg.gas_limit);
if next_gas > self.config.block_gas_limit { ... }Issue: 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 LimitsFile: 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 ProductionFile: The Issue: This adds significant overhead (merkle tree operations, RLP encoding) to every block submission. Fix: Gate behind Architecture & Maintainability7. Feature Flag InconsistencyThe File: [dependencies]
ssz = { git = "...", optional = true }
# ...
k256 = { workspace = true, optional = true }But in 8. Duplicate Merkle Tree ImplementationFile: 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 ChangeFile: The guest programs now branch on Suggestion: Document the input format change in the PR description and ensure the coordinator sends the correct format based on feature flags. Minor Issues10. Typo in Log MessageFile: "Succesfully unpaused contract"Should be "Successfully". 11. Unused ImportFile: use ethrex_crypto::keccak::keccak_hash;Imported but not used (the 12. Missing Documentation on SSZ LimitsFile: The constants like Positive Notes
SummaryMust fix before merge:
Should fix: 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 |
Lines of code reportTotal lines added: Detailed view |
🤖 Codex Code Review
Static review only; I did not run the test suite. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Benchmark Results ComparisonBenchmark Results: Factorial
Detailed ResultsBenchmark Results: BubbleSort
Benchmark Results: ERC20Approval
Benchmark Results: ERC20Mint
Benchmark Results: ERC20Transfer
Benchmark Results: Factorial
Benchmark Results: FactorialRecursive
Benchmark Results: Fibonacci
Benchmark Results: FibonacciRecursive
Benchmark Results: ManyHashes
Benchmark Results: MstoreBench
Benchmark Results: Push
Benchmark Results: SstoreBench_no_opt
|
🤖 Claude Code ReviewPR #6418 Review — Unify stateless validation: EIP-8025 + native rollupsThis is a large, well-scoped PR with solid architectural thinking (the Critical Issues1. chain_id verification disabled in
// 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 ( 2.
FINALITY_DELAY = 0;Since 3. Anyone can submit arbitrary SSZ input to High Issues4. Double SSZ decode in
5.
(bool innerSuccess, ) = _to.call{value: _value, gas: _gasLimit}(_data);
// (no check on innerSuccess)If the subcall fails, 6. EXECUTE precompile
#[cfg(feature = "stateless-validation")]
if *address == EXECUTE.address {
return true;
}The Medium Issues7. Gas model: charging L2 The EXECUTE precompile charges 8. 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 9. Inline extension nodes unsupported in
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. chain_config: ChainConfig {
chain_id: chain_config.chain_id,
homestead_block: Some(0),
// ... all forks at 0The comment says "stateless validation always runs at the latest fork." But the Low Issues / Nitpicks11.
12. The trait is defined unconditionally in 13. The EXECUTE precompile runs inside a 14. 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 15. SSZ round-trip test doesn't cover the witness conversion
16. The precompile returns SummaryThe unification architecture and trait design are solid. The implementation needs hardening before this merges:
Automated review by Claude (Anthropic) · sonnet · custom prompt |
cf68120 to
c3a8ed3
Compare
…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.
7451802 to
87396be
Compare
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
… 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.
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.
🤖 Kimi Code ReviewOverall 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) // 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 2. MPT Proof Verification Edge Cases (MPTProof.sol) // crates/l2/contracts/src/nativeRollup/l1/MPTProof.sol:130-132
assembly ("memory-safe") {
prefix := shr(252, mload(add(add(content, 32), epStart)))
}While marked 3. Gas Charging Precondition // 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 Code Correctness & Rust Best Practices 4. SSZ Limits Enforcement // crates/common/types/stateless_ssz.rs:259-260
const MAX_W
---
*Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt* |
There was a problem hiding this comment.
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-devnetfeature gate and rename/rewire prioreip-8025feature usage across crates and docs. - Add
EXECUTE (0x0101)precompile (LEVM) that decodes SSZ stateless input, enforces L2 constraints, and delegates to a dependency-invertedStatelessValidator. - 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.
| (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); |
There was a problem hiding this comment.
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.
| 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") |
There was a problem hiding this comment.
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.
| 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() | ||
|
|
There was a problem hiding this comment.
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).
| 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) |
There was a problem hiding this comment.
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.
🤖 Claude Code ReviewNow I have a thorough picture of the entire PR. Let me produce the review. PR #6418 Review:
|
🤖 Codex Code Review
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 SummaryThis PR adds EIP-8079 native rollup support behind the Two security issues in
Confidence Score: 3/5Safe 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.
|
| 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)
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
| 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) { |
There was a problem hiding this 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.
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.| stateRoot = _newStateRoot; | ||
| blockNumber = newBlockNumber; | ||
| stateRootHistory[newBlockNumber] = _newStateRoot; |
There was a problem hiding this 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.
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.| })?; | ||
|
|
||
| // 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. |
There was a problem hiding this 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.
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.|
|
||
| // 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()))?; |
There was a problem hiding this 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.
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.… 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
left a comment
There was a problem hiding this comment.
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:
- Make the trait method generic over input type so the precompile can pass the already-decoded
SszStatelessInput(avoids the double-decode noted inline). - Feature-gate the
Option<Arc<dyn …>>slot onEvmwith#[cfg(feature = "experimental-devnet")]so non-feature builds don't carry an unused slot. - Document the seam in
ethrex-vmwith a# Why this is a traitblock so future contributors don't have to reason about the cycle from scratch.
Cargo feature rename eip-8025 → experimental-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:
_l1MessagesCountdesync (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( |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
SszEncodeoutput 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 { | |||
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
…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.
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-8025toexperimental-devnetso future experimental EIPs (next up: EIP-8142 block-in-blobs) can share the gate.Description
Native rollup support behind the
experimental-devnetcargo feature, reusingverify_stateless_new_payloadfor theEXECUTEprecompile.NativeRollup.soladded with spec-aligned storage and an SSZ-basedadvance().L2Bridgeuses EIP-4788BEACON_ROOTSfor the L1 messages Merkle root.NativeBlockProducer,NativeL1Watcher,NativeL1Advancer(actor/#[protocol]API; watcher forwards parsed logs to the producer via GenServer-styleEnqueueL1Messages).EXECUTEprecompile added incrates/vm/levm/src/execute_precompile.rs(~110 lines) — decodes the SSZ input, validates L2 constraints, and delegates to aStatelessValidatortrait.--precompute-witnesseswhen available, falls back to on-demand generation otherwise.StatelessValidatortrait workaround — feedback welcomeThe
EXECUTEprecompile needs to callverify_stateless_new_payload, which lives inethrex-blockchain. Butethrex-blockchaindepends onethrex-vm → ethrex-levm, so a direct call from LEVM into blockchain would form a cycle.The shape in this PR:
ethrex-levmdeclares aStatelessValidatortrait;ethrex-blockchainimplements it (StatelessExecutor) and injects anArc<dyn StatelessValidator>intoEvmat construction via anattach_stateless_validatorhelper incrates/blockchain/blockchain.rs. The precompile calls back through the trait.It's dependency-inversion for a single callback, every
Evmcarries an unusedOption<&dyn …>slot, and the injection happens at each of the two L1Evmconstruction 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-nativeIntegration test:
cargo test -p ethrex-test --features experimental-devnet -- l2::native_rollup --nocapture