feat(levm): native rollups EXECUTE precompile + L2 PoC (EIP-8079)#6186
feat(levm): native rollups EXECUTE precompile + L2 PoC (EIP-8079)#6186avilagaston9 wants to merge 90 commits into
Conversation
Add a minimal EXECUTE precompile to LEVM that verifies L2 state transitions by re-executing them inside the L1 EVM. This is a Phase 1 proof-of-concept behind the `native-rollups` feature flag. The precompile receives an execution witness, blocks, and deposit data, re-executes the blocks using LEVM, applies deposits via a simple anchor mechanism, and verifies the resulting state root matches the expected post-state root. New files: - execute_precompile.rs: core precompile logic (execute_inner, block execution, deposit application, gas price calculation, withdrawals) - guest_program_state_db.rs: thin Database adapter bridging GuestProgramState to LEVM's Database trait - tests/native_rollups.rs: integration test demonstrating a transfer (Alice sends 1 ETH to Bob) + deposit (Charlie receives 5 ETH) - docs/vm/levm/native_rollups.md: architecture and usage documentation The precompile is registered at address 0x0101 and dispatched at runtime before the standard const precompile table. Calldata deserialization is stubbed out — tests call execute_inner() directly with structured input.
🤖 Kimi Code ReviewReview SummaryThis PR implements a proof-of-concept for the Native Rollups EXECUTE precompile (EIP-8079). The implementation is well-structured and follows good practices, but there are several areas that need attention. Critical Issues1. Security: Mutex Poisoning Handling (Critical)File: All database methods use 2. Overflow Handling (High)File: Multiple unchecked arithmetic operations could overflow:
Use 3. State Consistency Risk (High)File: The state transitions are applied back to the Code Quality Issues4. Error Handling Inconsistency (Medium)File: The 5. Gas Cost Underestimation (Medium)File:
6. Unnecessary Mutex Usage (Low)File: Consider if Minor Issues7. Code DuplicationFile: The 8. Feature Flag InconsistencyFile: The EXECUTE precompile is marked as active since 9. DocumentationFile: The documentation is excellent, but should mention the security considerations around mutex poisoning and overflow handling. Recommendations
The implementation is solid for a PoC, but these issues should be addressed before production use. Automated review by Kimi (Moonshot AI) · custom prompt |
🤖 Codex Code ReviewHere are the key findings from the diff, ordered by severity, with file/line references. Findings
Lower‑risk / maintainability
If you want, I can suggest concrete patch changes for the missing‑account behavior and header root validation. Automated review by OpenAI Codex · custom prompt |
🤖 Claude Code ReviewBased on my review of PR #6186, here is my comprehensive code review: PR Review: EXECUTE Precompile for Native Rollups (EIP-8079 PoC)SummaryThis PR implements a proof-of-concept EXECUTE precompile for Native Rollups (EIP-8079) that verifies L2 state transitions by re-executing them inside L1's EVM. The implementation is well-structured for a PoC and demonstrates the core concept effectively. Critical Issues1. Mutex Poisoning Not Properly Handled
|
Lines of code reportTotal lines added: Detailed view |
Benchmark Results ComparisonNo significant difference was registered for any benchmark run. 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
|
…ub auto-links as issue references (#6187) ## Motivation The Claude AI code reviewer (`.github/workflows/pr_ai_review.yaml`) enumerates findings using `#1`, `#2`, etc., which GitHub auto-links as references to issues/PRs. This clutters PR activity feeds and confuses readers. Example: #6186 (comment) ## Description Add formatting rules to the AI review prompt (`.github/prompts/ai-review.md`) instructing the model to use `1.`, `2.` or bullet points instead of `#N`, and to refer back to items as "Item 1", "Point 2" rather than "Issue #1". ## Checklist - [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR includes breaking changes to the `Store` requiring a re-sync.
…calldata deserialization in the EXECUTE precompile. Blob transactions (EIP-4844), privileged L2 transactions, and fee token transactions are now rejected before sender recovery (cheap check first). The withdrawal processing was replaced with validation that rejects non-empty withdrawals since native rollup L2 blocks don't have validator withdrawals. The execute_precompile() entrypoint now deserializes structured input from JSON calldata via serde_json instead of returning a stub error.
…ompile. The demo test (test_execute_precompile_via_contract) deploys a proxy contract on L1 that forwards calldata to the EXECUTE precompile at 0x0101, serializing a full L2 state transition (Alice→Bob transfer + Charlie deposit) as JSON. This demonstrates the end-to-end flow: L1 tx → contract → CALL → precompile → re-execute L2 block → verify state roots. Also adds rejection tests for blob transactions (EIP-4844) and non-empty withdrawals, plus a build_l2_state_transition() helper that extracts the common L2 state setup into a reusable function.
…ation, withdrawal rejection, JSON calldata deserialization, and the new contract demo and rejection tests
…lock>, add NativeRollup.sol contract that simulates an L2 on-chain (maintains stateRoot and blockNumber in storage, calls EXECUTE precompile via advance()), and update the in-process test to use the NativeRollup contract instead of a dumb proxy. The single-block API simplifies the interface — callers advance one block at a time. The NativeRollup contract demonstrates how an L1 contract would track and verify L2 state transitions by calling the EXECUTE precompile at 0x0101.
advance() updates contract storage correctly. Propagate native-rollups feature flag to ethrex binary (cmd/ethrex/Cargo.toml) and test crate (test/Cargo.toml), update Makefile init-l1 to support NATIVE_ROLLUPS=1 env var, and update documentation to reflect single-block API, NativeRollup contract, and integration test instructions.
…native-rollups, matching the same pattern used for the GPU variable in prover targets.
…d add a deposit mechanism to NativeRollup.sol. The precompile now parses a hybrid binary format: state roots (32+32 bytes), deposit count (4 bytes), deposits (20 address + 32 amount each), block RLP length (4 bytes), RLP-encoded block, and remaining JSON-serialized ExecutionWitness. Block uses RLP (already implemented), while ExecutionWitness stays JSON because it lacks RLP support (uses serde/rkyv). NativeRollup.sol gains deposit(address) payable to record pending deposits and advance() now takes disaggregated parameters (newStateRoot, newBlockNumber, depositsCount, block, witness) instead of an opaque precompileInput blob. The contract builds the binary calldata on-chain from its stored deposits and the opaque block/witness bytes passed by the caller. Tests updated: the direct precompile test now exercises the binary parsing path via execute_precompile(), and the contract test runs a deposit TX before advance to verify the full deposit-to-advance-to-precompile flow including depositIndex.
EXECUTE precompile and simplify the NativeRollup contract interface. The precompile now parses abi.encode(bytes32 preStateRoot, bytes blockRlp, bytes witnessJson, bytes deposits) and returns abi.encode(bytes32 postStateRoot, uint256 blockNumber) instead of a single 0x01 byte. The post-state root and block number are extracted from the block header rather than being passed as separate parameters. NativeRollup.sol advance() signature changes from advance(bytes32, uint256, uint256, bytes, bytes) to advance(uint256, bytes, bytes), removing the redundant _newStateRoot and _newBlockNumber params that are now decoded from the precompile return value. Also adds receive() for convenient ETH deposits.
… native rollups tests to the shared test crate. Since blob transactions are explicitly rejected, blob gas fields are set to defaults instead of being computed. Unit tests (precompile transfer, blob rejection, withdrawal rejection) and the contract-based test move to test/tests/levm/native_rollups.rs. The L1 integration test moves to test/tests/l2/native_rollups.rs and now uses SDK helpers (compile_contract, encode_calldata) instead of duplicated calldata encoding and hardcoded deploy bytecode.
…move of tests from crates/vm/levm/tests/ and test/tests/levm/ to the shared test crate (test/tests/levm/native_rollups.rs and test/tests/l2/native_rollups.rs).
… Test guide with real expected output, explanations of what each test does (precompile flow, contract interaction diagram, integration test steps), prerequisites, and the exact commands to run at each level.
…on test instead of hardcoding a gas limit that may exceed the block gas limit. Add the integration test expected output to the How to Test guide.
…ontract. The EXECUTE precompile now extracts WithdrawalInitiated events from L2 block execution logs, computes a commutative Keccak256 Merkle root (OpenZeppelin- compatible), and returns it as a third field: abi.encode(postStateRoot, blockNumber, withdrawalRoot) — 96 bytes instead of the previous 64. New L2WithdrawalBridge.sol contract at address 0x00...fffd where L2 users call withdraw(receiver) with ETH to initiate withdrawals. The contract emits WithdrawalInitiated(from, receiver, amount, messageId) events that the precompile scans for. NativeRollup.sol now stores withdrawalRoots[blockNumber] after each advance() and exposes claimWithdrawal() where users verify a Merkle proof against the stored root to claim their ETH on L1. Includes reentrancy guard and checks-effects-interactions pattern. Tests updated to expect 96-byte precompile return with zero withdrawal root (existing test blocks have no withdrawal events). Integration test verifies withdrawalRoots mapping storage. Documentation updated with withdrawal flow diagram, new contract descriptions, and updated return value format.
advances two L2 blocks: block 1 (transfer + deposit) and block 2 (withdrawal via L2WithdrawalBridge), then calls claimWithdrawal() on L1 with a Merkle proof and verifies the receiver gets the ETH. The L2 genesis state now includes the L2WithdrawalBridge contract at the canonical address (0x00...fffd). A new build_l2_withdrawal_block() helper uses LEVM to execute the withdrawal transaction and compute exact gas_used and post-state root for the block, matching what the EXECUTE precompile will verify on L1.
…w in the integration test. The test now covers two L2 blocks (transfer+deposit and withdrawal) plus claimWithdrawal on L1. Updated expected output, test description, and the limitation about L2 bridge deployment.
instead of leaving it in the contract. This matches the existing CommonBridgeL2 pattern and ensures the ETH actually leaves circulation on L2 when a withdrawal is initiated.
feature flag already gates compilation. Move contract compilation before L2 state building so the test doesn't depend on pre-compiled binaries. Compile L2WithdrawalBridge with --bin-runtime (needed for L2 genesis state). Add address(0) to test state tries since the bridge now burns ETH by sending to address(0). Simplify the docs limitations section to a single line about the L2 production stack being unchanged.
…ayer-based flow instead of direct state manipulation. Replace L2WithdrawalBridge with a unified L2Bridge predeploy at 0x00...fffd that handles both deposits (processDeposit) and withdrawals (withdraw). The sequencer/relayer now sends real L2 transactions to process deposits, pulling ETH from the preminted bridge contract. NativeRollup.sol on L1 computes a rolling hash over deposit hashes and passes it to the EXECUTE precompile, which verifies it by scanning DepositProcessed events from the L2Bridge after block re-execution. Simplify the precompile ABI to receive depositsRollingHash as a static bytes32 slot instead of encoding deposit data in the block. Rewrite both unit and integration tests to use LEVM-based execution with the relayer pattern. Add gap analysis document tracking remaining spec gaps (burned fees, finality delay, individual field inputs). Gitignore solc_out/ under levm and clean up compiled artifacts at the end of the integration test.
01fc945 to
d3285c8
Compare
…nt doc Fix the EIP-8079 link (was pointing to a random PR, now points to the EIP page). Remove the undefined "Phase 1" qualifier from the PoC note. Update the block producer log example to match the actual format.
…rollups.md. The Merkle tree uses commutative Keccak256 hashing that matches OpenZeppelin's _hashPair but is our own implementation with no dependency on OpenZeppelin. Remove "OpenZeppelin-compatible" from merkle_tree.rs module/function docs and all references in native_rollups.md. Also remove from native_rollups.md: - "We're ahead on the first deposit problem" (self-congratulatory, not documentation) - "Configurable finality delay" from the limitations list (it is not a limitation) - "These are all Phase 2+ concerns." (vague, adds nothing) - The files table at the end (stale and redundant with the code) - Incorrect receive() description: the fallback just accepts ETH silently, it does not record an L1 message
…both from deployer. Two separate actors now have explicit addresses in the contract: - relayer: the account that submits L1→L2 message transactions on L2 - advancer: the account that calls advance() on L1 and receives burned fees Previously burned fees were sent to msg.sender (whoever called advance()). Now they go to the stored advancer address, making both roles configurable independently. In the current deployment both are set from the same keys, but the contract supports them being different accounts. Constructor changes from (bytes32, uint256, uint256, uint64, uint256) to (bytes32, uint256, uint256, address, address) — CHAIN_ID and FINALITY_DELAY were never passed by the deployer anyway (defaulted to 0) and are now hardcoded to 0.
…t instead of embedding hardcoded initcode hex. Follows the same pattern used by the shared_bridge integration test.
…atching the integration_tests cleanup pattern.
The mdbook linkcheck requires all docs files to be listed in SUMMARY.md. The ZisK Cargo.lock was out of date (lambdaworks-crypto was removed as a dependency).
…unreachable from CI.
… from lychee link check. peer_table.rs moved from crates/networking/p2p/discv4/ to crates/networking/p2p/. native-rollup.l2beat.com is unreachable from CI (connection refused).
… lychee link check. SP1, RISC0, and OpenVM Cargo.lock files had lambdaworks-crypto as a stale dependency (same issue as ZisK, fixed previously). Regenerated all three. Accept 5xx HTTP responses in lychee to avoid failing on transient server errors (e.g. the 502 from github.com/ethereum/execution-specs/issues/1940). Genuine broken links (4xx) still fail.
…ch issue, not ours to fix.
…lup.rs to fix CI failures (Cargo.lock check and fmt check).
…behind feature flags to fix dead-code lint error in CI. The callers of read_env_file_by_config are all behind #[cfg(feature = "l2")] or #[cfg(feature = "native-rollup")], so the function must be gated too.
…g 0. The EXECUTE precompile validates that the chain_id in the calldata matches the execution witness, but the constructor was setting CHAIN_ID = 0 while the L2 genesis uses chainId = 1. This caused every advance() call to fail with "Chain ID mismatch: input=0, witness=1". Now the deployer reads the chain_id from the L2 genesis config and passes it as a constructor argument.
9b07ff2 to
7d415f9
Compare
… counter, claim)
in the native rollups deployment doc. The deployer key (0x385c...) is also used by
the L1 advancer for advance() calls, so sending transactions from the same account
causes nonce conflicts ("underpriced transaction" error). Now the demo uses key
0xbcdf... (address 0x8943...), which is pre-funded in the L1 genesis and not used
by any system component.
…e* RPC methods for Blockscout compatibility, add Step 6 with Blockscout setup/verification instructions, and add a contract verification script. RPC fixes in tracing.rs: - debug_traceTransaction now returns a single CallTraceFrame instead of an array, matching geth's callTracer format that Blockscout expects for internal transaction indexing. - debug_traceBlockByNumber now parses hex block numbers (e.g. "0x13E") since Blockscout sends them in hex format. - Increase DEFAULT_REEXEC from 128 to 10000 to handle longer chain histories. Other changes: - Add scripts/blockscout_verify_native_rollup.py for verifying the NativeRollup contract via Blockscout's V2 standard-input API. - Fix terminalTotalDifficulty format in native_l2.json (0 → "0x0"). - Document correct Blockscout cleanup: rm -rf services/blockscout-db-data (the bind mount lives under services/, not the docker-compose root).
…xecute-precompile # Conflicts: # crates/vm/levm/src/precompiles.rs
…sambiguation for lambdaworks-math.
…ch: restore DEFAULT_REEXEC to 128 (was bumped to 10000), remove native-rollup.l2beat.com link checker exclusions from lychee CI action and book.toml to match main.
…er merging main to pass the check-cargo-lock CI check.
…site is unreachable from CI.
…the Crypto trait refactor — sender() now requires a &dyn Crypto argument for signature recovery.
…xecute-precompile Resolve conflict in merkle_tree.rs (kept re-export approach since logic was moved to ethrex-common). Thread &dyn Crypto through GuestProgramStateDb and execute_precompile.rs to match main's Crypto trait refactor.
|
Superseded by #6418 |
Motivation
EIP-8079 proposes "native rollups" where L1 verifies L2 state transitions by re-executing them via an EXECUTE precompile, replacing zkVM/fraud proofs with direct execution. This PR implements a Phase 1 PoC: the EXECUTE precompile on L1 and a working L2 that settles blocks through it.
Description
EXECUTE precompile (
execute_precompile.rs) — implements theapply_bodyvariant from the native rollups spec. Receives 15 ABI-encoded parameters (13 static block fields + 2 dynamic byte arrays for RLP transactions and JSON execution witness), re-executes the L2 block using LEVM, verifies state root and receipts root, and returnsabi.encode(postStateRoot, blockNumber, gasUsed, burnedFees, baseFeePerGas)(160 bytes). Registered at address0x0101behind thenative-rollupsfeature flag.Contracts:
NativeRollup.sol— L1 contract managing L2 state: tracks state root, block number, parent gas parameters on-chain; records L1 messages viasendL1Message(); calls EXECUTE viaadvance(); supportsclaimWithdrawal()with MPT state root proofs and configurable finality delayL2Bridge.sol— L2 predeploy at0x...fffdhandling L1→L2 message processing (Merkle proof verification) and L2→L1 withdrawals (state root provable)L1Anchor.sol— L2 predeploy at0x...fffestoring L1 messages Merkle root anchored by EXECUTEMPTProof.sol— Solidity library for MPT trie traversal and account/storage proof verificationL2 GenServer actors:
NativeL1Watcher— polls L1 forL1MessageRecordedevents, queues messagesNativeBlockProducer— produces L2 blocks with relayer transactions for L1 messages + mempool transactionsNativeL1Advancer— submits produced blocks to L1 viaadvance()Deployer and CLI —
--native-rollupsflag generates L2 genesis with predeploys, deploys NativeRollup.sol to L1, and boots the L2 node.How to Test
The integration test requires a live L1 + L2 (see
docs/l2/deployment/native_rollups.mdfor full setup):Checklist
STORE_SCHEMA_VERSION(crates/storage/lib.rs) if the PR includes breaking changes to theStorerequiring a re-sync.