feat(l2): add native rollup L2 PoC — block production and L1 commitment via EXECUTE precompile#6248
Conversation
compatible with the EXECUTE precompile and commit them to L1 via advance(). NativeL1Watcher polls L1 for L1MessageRecorded events from NativeRollup.sol and queues them for the block producer. NativeBlockProducer drains pending L1 messages, computes the Merkle root, writes the L1Anchor system slot, builds signed relayer transactions calling L2Bridge.processL1Message(), executes them through LEVM with VMType::L1 semantics, and stores the resulting block in the Store. NativeL1Committer picks produced blocks from the shared queue and submits them to L1 via the advance() function with the witness, transactions RLP, and block params. The block producer mirrors the EXECUTE precompile exactly: failed transactions get empty receipt logs, gas price is computed from actual transaction fields (min(max_priority + base_fee, max_fee)), pre-state root is verified against the parent header, and initialize_block_header_hashes is called before execution. All three actors communicate through Arc<Mutex<VecDeque>> shared queues and are gated behind the native-rollups feature flag. The feature adds k256, ethrex-crypto, and rustc-hash as optional dependencies to ethrex-l2.
🤖 Kimi Code ReviewReview SummaryThis PR introduces a native rollup L2 implementation with three main components: block producer, L1 committer, and L1 watcher. The code is well-structured and follows good patterns, but there are several security and correctness issues that need attention. Critical Issues1. Private Key Handling (Security)
2. Integer Overflow in Message Count
3. Missing Input Validation
Medium Issues4. Error Handling in Async Context
5. Gas Limit Hardcoding
6. Race Condition in Nonce Management
Minor Issues7. Code Duplication
8. Inefficient Data Structures
9. Missing Constants
10. Incomplete Error Context
Recommendations
The overall architecture is sound, but these security and correctness issues should be addressed before production use. Automated review by Kimi (Moonshot AI) · custom prompt |
🤖 Codex Code ReviewSummary Findings
If you want, I can draft targeted fixes for Items 1–4. Suggested next steps
Automated review by OpenAI Codex · custom prompt |
Lines of code reportTotal lines added: Detailed view |
🤖 Claude Code ReviewBased on my full review of the PR, here is the analysis: PR Review: Native Rollup L2 PoCThis is a well-structured PoC that faithfully mirrors the EXECUTE precompile's logic. The module gating and actor isolation are clean. The following findings range from bugs-that-will-bite-immediately to security concerns. Critical / Blocking1. Partial state trie will always fail pre-state root verification
let mut touched_addresses = vec![coinbase, relayer, L2_BRIDGE, L1_ANCHOR];
for msg in &l1_messages {
if !touched_addresses.contains(&msg.to) {
touched_addresses.push(msg.to);
}
}
// ...
let pre_state_root = state_trie.hash_no_commit();
if pre_state_root != parent_header.state_root {
return Err(...);
}A Merkle Patricia Trie root hash is computed over all accounts in the state, not a subset. Building a trie from a handful of known-touched addresses and then comparing it against the full world state root will always produce a mismatch on any non-trivial chain. The verification will unconditionally fail unless the only accounts in the entire chain happen to be the four pre-seeded ones (e.g., a freshly initialised devnet with no other accounts). Either populate the full state trie or drop the verification check for the PoC and add a comment explaining why. 2. State updates not persisted back to the Store The account updates returned by
This is acknowledged in the PR description as a limitation, but it means the code as written only produces a correct first block. A 3. Private key exposed via
#[derive(Clone, Debug)]
pub struct NativeBlockProducerConfig {
...
pub relayer_key: [u8; 32],
}Both High Severity4. Double-fetch of
let current_block_number = self.store.get_latest_block_number().await?;
// ... 30+ lines later ...
let current_block_number_for_nonce = self.store.get_latest_block_number().await?;Two separate async calls. In the single-actor model this is unlikely to race, but if a background process updates the store between calls the relayer nonce would be read from a different block than the one used as parent. Use 5. Integer overflow in
let max_priority_fee = 1_000_000_000u64; // 1 gwei
let max_fee = base_fee + max_priority_fee; // unchecked u64 additionIf The same pattern at line 521 ( 6.
last_block_fetched: U256::zero(),Every time the process restarts, the watcher re-scans from block 1. Any L1 messages already included in prior L2 blocks will be re-queued and re-executed, leading to duplicate transactions with failing nonces or duplicate fund transfers. The last processed block must be persisted (to the Store or a file) and restored on startup. 7. Silent gas price fallback in committer
let gas_price = self.eth_client.get_gas_price_with_extra(20)
.await?
.try_into()
.unwrap_or(20_000_000_000u64);If the actual gas price exceeds 8. Unbounded
If the L1 committer falls behind (RPC down, L1 congestion), the block producer keeps appending to the queue indefinitely. Each Medium9. Dead code: unused code-hash bindings
let _bridge_code_hash = H256(keccak_hash(&self.bridge_runtime));
let _anchor_code_hash = H256(keccak_hash(&self.anchor_runtime));These are computed and immediately discarded. Remove them or put them to use. 10.
static L1_MESSAGE_RECORDED_TOPIC: LazyLock<H256> = LazyLock::new(|| {
keccak(b"L1MessageRecorded(address,address,uint256,uint256,bytes32,uint256)")
});11. The 12. if !touched_addresses.contains(&msg.to) {
touched_addresses.push(msg.to);
}Use a 13. Two separate Mutex lock acquisitions for consecutive state operations
Minor / Style14. The 15.
16.
Correctness Notes (Positive)
Automated review by Claude (Anthropic) · custom prompt |
…r.rs pattern (create_payload, PayloadBuildContext, apply_plain_transaction, finalize_payload, store_block, apply_fork_choice) instead of manual LEVM execution. Relayer txs for L1 messages are built as HeadTransactions and executed first in the fill_transactions loop before mempool txs, ensuring L1 messages always get priority. If a relayer tx fails any validation (gas, nonce, replay protection, execution), the block producer returns an error immediately. The committer now fetches blocks from the Store and calls generate_witness_for_blocks instead of receiving pre-computed witnesses, and counts relayer txs directly from the block body instead of using a shared L1MessagesCounts map. Removes ProducedBlocks, NativeBlockWitness, and L1MessagesCounts shared state — only PendingL1Messages remains for watcher-to-producer communication.
the Merkle root in L1Anchor before execution. take_l1_messages_for_block replaces drain_l1_messages: pops messages one by one from the shared queue, summing each message's gas_limit. Once the next message would exceed block_gas_limit the remaining messages stay in the queue for the next block. The relayer tx gas_limit now uses the message's own gas_limit instead of a hardcoded 200k constant. anchor_l1_messages writes the L1 messages Merkle root to the L1Anchor predeploy's storage slot 0 in the VM cache before any transaction executes, mirroring what the EXECUTE precompile does as a system transaction. This lets L2Bridge.processL1Message verify individual messages via Merkle proofs. The anchor runs every block — when there are no messages it writes the zero hash, matching NativeRollup.sol's _computeMerkleRoot(_, 0) == bytes32(0). Also fixes a pre-existing bug: let is_relayer_tx was immutable but reassigned inside the if-let arm (now let mut).
…nces. The standalone native rollups integration guide is removed since the implementation details now live in the open PRs (#6186, #6248). The L2 roadmap is updated to reflect current progress: distributed proving is done (#6158), SP1 Hypercube upgrade is in progress (#6188), native rollups EXECUTE precompile and L2 PoC are in progress (#6186, #6248), and custom contract errors are in progress (#6206).
…n test, and fix base_fee_per_gas mismatch between EXECUTE precompile and L2 block producer. Deployment: deploy_native_rollup_contracts() in deployer.rs handles the full flow — builds L2 genesis with L2Bridge/L1Anchor predeploys, computes the genesis state root via a temp in-memory store, deploys NativeRollup.sol to L1 with CREATE2, funds the contract with 100 ETH for withdrawal claims, and writes the contract address to .env. The build system (build_l2.rs) now compiles NativeRollup.sol, L2Bridge.sol, and L1Anchor.sol from levm/contracts. CLI routing in command.rs dispatches to native rollup deploy/init when the flag is set. Withdrawal proof RPC: new eth_getWithdrawalProof endpoint in native_withdrawal_proof.rs returns MPT account + storage proofs against the committed L2 state root, enabling users to claim withdrawals on L1 via claimWithdrawal() in NativeRollup.sol. Base fee fix: NativeRollup.sol constructor was setting lastGasUsed to blockGasLimit/2 (the EIP-1559 gas target), which caused the base fee to stay at 1 gwei for block 1. The L2 block producer correctly computed 875M (12.5% decrease from target) because the actual genesis has gas_used=0. Changed to lastGasUsed = 0 so both paths agree. Bridge integration test: native_rollup_bridge_roundtrip exercises the full deposit → L2 withdrawal → proof generation → L1 claim flow end-to-end.
…nfo! to debug!, and consolidating the committer's two info! per block into a single message emitted after the L1 transaction is sent. The aggregate-level info! messages (watcher "found N events", producer "produced block", committer "committed block") remain as the only info-level output per cycle.
required_unless_present = "native_rollups" (via cfg_attr) to the 7 fields that had required_unless_present = "dev", so that running `ethrex l2 --native-rollups` no longer demands --l1.bridge-address, --block-producer.coinbase-address, committer keys, proposer address, or proof-coordinator keys. The native rollup code path returns early before these fields are ever read. Also adds id = "native_rollups" to the --native-rollups flag for a stable clap argument ID.
demo to the integration test. The L1MessageRecorded event in NativeRollup.sol now emits the raw bytes data instead of bytes32 dataHash, so the L1 watcher can read the actual calldata from the event log. The watcher's ABI decoder is updated for the dynamic bytes encoding (offset + length + content), and the block producer forwards msg.data to L2Bridge.processL1Message() instead of passing empty bytes. The integration test gains a Phase 6 that deploys a Counter contract on L2, sends sendL1Message from L1 with the increment() selector as calldata, and polls counter.count() until it confirms the relay landed.
Replace Makefile-based setup with direct binary commands to avoid stale database issues. Fix rex CLI syntax throughout: use positional RPC_URL for balance/block-number, -k for private key, wei for --value, hex without 0x prefix for bytes args, drop unsupported return type annotations from call signatures. Add Step 4 (Counter contract deploy on L2 + increment via L1 message) demonstrating L1→L2 calldata relay. Add rex CLI syntax quirks reference note.
…sh demo guide. The deployer's ETH transfer to NativeRollup.sol used gas_limit=21000 which is only enough for EOA transfers — sending to a contract needs extra gas to run the receive() dispatcher. Increased to 30000. Changed default native rollup intervals so the committer keeps pace with the block producer: block_time_ms 5000→10000, commit_interval_ms 10000→3000. Updated the demo guide to add --no-monitor (required for the L2 to start without the TUI monitor), and replaced the raw Counter bytecode deploy with rex deploy --contract-path which compiles and deploys in one step.
the actor calls advance() on NativeRollup.sol, which re-executes the block and advances L2 state on L1 — not just committing data. Renames: l1_committer.rs → l1_advancer.rs, NativeL1Committer → NativeL1Advancer, commit_next_block → advance_next_block, CastMsg::Commit → CastMsg::Advance, commit_interval_ms → advance_interval_ms, CLI flag --native-rollups.commit-interval → --native-rollups.advance-interval, env var ETHREX_NATIVE_ROLLUPS_COMMIT_INTERVAL → ETHREX_NATIVE_ROLLUPS_ADVANCE_INTERVAL. Updates docs, logs, and test assertions accordingly. Only native rollup code is affected; the standard L2 committer is untouched.
88fe57c
into
feat/native-rollups-execute-precompile
|
Merged into #6186 ( |
Motivation
Phase 2 of the native rollups PoC. Phase 1 (the base branch) added the EXECUTE precompile on L1 and the NativeRollup.sol contract. This PR adds the L2 side: three GenServer actors that produce blocks matching the EXECUTE precompile's semantics and commit them to L1.
Description
Three GenServer actors implement the native rollup L2 lifecycle, communicating through
Arc<Mutex<VecDeque>>shared queues:L1MessageRecordedevents from NativeRollup.sol and queuesL1Messagestructs for the block producerL2Bridge.processL1Message(), executes them via LEVM withVMType::L1, computes post-state root and receipts root, stores the block, and pushes it to the committer queueadvance()with the witness JSON, transactions RLP, and block paramsThe block producer mirrors the EXECUTE precompile exactly:
min(max_priority + base_fee, max_fee)state_rootinitialize_block_header_hashescalled before executionAll code gated behind the
native-rollupsfeature flag — zero impact on the default build.New files:
crates/l2/sequencer/native_rollup/types.rs— shared typescrates/l2/sequencer/native_rollup/l1_watcher.rs— L1 event watchercrates/l2/sequencer/native_rollup/block_producer.rs— block producer (~700 lines)crates/l2/sequencer/native_rollup/l1_committer.rs— L1 committercrates/l2/sequencer/native_rollup/mod.rs— config andstart_native_rollup_l2()entry pointModified files:
crates/l2/Cargo.toml— added optional deps (k256, ethrex-crypto, rustc-hash) and native-rollups featurecmd/ethrex/Cargo.toml— propagated native-rollups feature to ethrex-l2crates/l2/l2.rs— re-exported NativeRollupConfig and start functioncrates/l2/sequencer/mod.rs— added native_rollup moduleHow to Test
Limitations (PoC)
start_native_rollup_l2()is exported and ready to be connected