Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Deploying a native rollups ethrex L2

This guide covers how to deploy a native rollup L2 using ethrex. Native rollups (based on EIP-8079) replace ZK proofs and fraud proofs with direct re-execution on L1: when the L2 submits a block, L1 re-executes it via the EXECUTE precompile and verifies the state transition is correct.

Note

This is a proof-of-concept. The native rollup L2 runs against a local ethrex L1 with the EXECUTE precompile enabled. It is not yet intended for public testnets or production.

Architecture overview

┌──────────────────────────────────┐
│            L1 (ethrex)           │
│                                  │
│  NativeRollup.sol                │
│    ├─ advance()                  │
│    │    └─ calls EXECUTE(0x0101) │
│    │         └─ re-executes L2   │
│    │            block in LEVM    │
│    ├─ sendL1Message()            │
│    └─ claimWithdrawal()          │
└──────────┬───────────────────────┘
           │ L1 RPC
           │
┌──────────┴───────────────────────┐
│         L2 (ethrex native)       │
│                                  │
│  NativeL1Watcher                 │
│    └─ polls L1 for new messages  │
│                                  │
│  NativeBlockProducer             │
│    ├─ builds relayer txs for     │
│    │  L1 messages                │
│    ├─ sets parent_beacon_block   │
│    │  _root = Merkle root        │
│    └─ produces L2 blocks         │
│                                  │
│  NativeL1Advancer               │
│    ├─ generates execution witness│
│    ├─ encodes SSZ StatelessInput │
│    └─ calls advance() on L1      │
│                                  │
│  Predeploys:                     │
│    L2Bridge  (0x...fffd)         │
└──────────────────────────────────┘

The three L2 GenServer actors run as concurrent tasks:

  • NativeL1Watcher — Polls the L1 NativeRollup.sol contract at regular intervals for L1MessageRecorded events and forwards them to the NativeBlockProducer via EnqueueL1Messages actor messages (there is no shared queue; the producer owns a private message queue). It scans L1 logs in configurable block ranges and parses the event data (sender, recipient, value, gas limit, calldata, nonce).

  • NativeBlockProducer — Produces L2 blocks every block_time_ms milliseconds. It first consumes pending L1 messages from its own queue, builds signed relayer transactions to execute those messages via the L2Bridge contract, then fills remaining block gas with regular mempool transactions. It sets parent_beacon_block_root in the block header to the L1 messages Merkle root, which the EIP-4788 system contract stores at BEACON_ROOTS_ADDRESS during block processing.

  • NativeL1Advancer — Reads produced L2 blocks from the Store, generates an execution witness, encodes it as SSZ StatelessInput, and submits it to NativeRollup.sol via advance(). The contract forwards the SSZ to the EXECUTE precompile which calls verify_stateless_new_payload.

Prerequisites

  • Rust toolchain (stable)
  • solc (Solidity compiler) — needed to compile the contracts during build
  • rex — run make cli in that branch to install the required version
  • Docker — needed for the Blockscout block explorer (Step 6)
  • Python 3 — needed for the contract verification script (Step 6)

Note

Rex CLI syntax quirks:

  • --value is always in wei (e.g., 1000000000000000000 for 1 ETH).
  • bytes arguments should be passed as hex without the 0x prefix (e.g., d09de08a), or as "" for empty bytes.

Verify solc is installed:

solc --version

Demo

What this demo shows: a native rollup L2 that settles blocks to L1 via direct re-execution (the EXECUTE precompile), with a live deposit (L1→L2) and withdrawal (L2→L1) roundtrip.

The native rollup L2 runs with two terminals: one for L1, one for L2. A third terminal is used for deploying, querying, and interacting.

All commands are run from the repository root.

Setup

Build the binary first (this compiles the Solidity contracts and embeds them):

COMPILE_CONTRACTS=true cargo build --release --features l2,l2-sql

Clean up any previous state:

rm -rf /tmp/ethrex_l1 /tmp/ethrex_l2

Terminal 1 — Start L1

Start a local ethrex L1 with the EXECUTE precompile enabled. This uses a dedicated genesis (l1_native.json) that activates the LStar fork, which is what turns address 0x0101 into the EXECUTE precompile:

./target/release/ethrex \
  --network fixtures/genesis/l1_native.json \
  --http.port 8545 --http.addr 0.0.0.0 --authrpc.port 8551 \
  --dev --datadir /tmp/ethrex_l1

Wait until you see L1 producing blocks (the --dev flag auto-mines).

Terminal 2 — Deploy contracts and start L2

Deploy NativeRollup.sol to L1 and generate the L2 genesis:

./target/release/ethrex l2 deploy \
  --eth-rpc-url http://localhost:8545 \
  --private-key 0x385c546456b6a603a1cfcaa9ec9494ba4832da08dd6bcf4de9a71e4a01b74924 \
  --native-rollups \
  --native-rollups.relayer-pk 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \
  --native-rollups.finality-delay 0 \
  --l2-gas-limit 15000000 \
  --genesis-l2-path fixtures/genesis/native_l2.json

--l2-gas-limit: advance() re-executes the whole L2 block inside a single L1 transaction, so this value must stay under the L1 per-transaction gas cap (EIP-7825, 16,777,216) with headroom for the precompile’s witness/bookkeeping gas. 15000000 is the tested value; the deployer rejects anything above the cap and the immutable contract enforces the same bound in its constructor.

--native-rollups.finality-delay 0: instant finality, local demo only. The value is baked immutably into the contract, has no default, and a production deployment must pass a reorg-safe delay in seconds.

You should see output like:

NativeRollup.sol deployed at: 0x...
Contract address written to cmd/.env

Load the contract address and start the L2 node:

source cmd/.env

./target/release/ethrex l2 \
  --native-rollups \
  --native-rollups.contract-address $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS \
  --native-rollups.relayer-pk 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \
  --native-rollups.l1-pk 0x385c546456b6a603a1cfcaa9ec9494ba4832da08dd6bcf4de9a71e4a01b74924 \
  --network fixtures/genesis/native_l2.json \
  --http.port 1729 --http.addr 0.0.0.0 \
  --datadir /tmp/ethrex_l2 \
  --eth.rpc-url http://localhost:8545 \
  --no-monitor

Step 1: Verify the L2 is advancing

Once the L2 is running, you should see log lines like:

NativeBlockProducer: produced block N (0x...) with M txs, gas_used=X
NativeL1Advancer: advanced block N on L1 (state_root=0x..., l1_msgs=0, tx=Some(0x...))

Query the L1 contract to verify:

source cmd/.env

# L2 block number committed to L1
rex call $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS "blockNumber()" --rpc-url http://localhost:8545

# L2 block number from the L2 RPC directly
rex block-number --rpc-url http://localhost:1729

The L1 value should trail the L2 value by a few blocks (the advancer runs on a configurable interval).

Step 2: Query contract state

The NativeRollup.sol contract exposes public getters for all its state:

# Current L2 state root
rex call $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS "stateRoot()" --rpc-url http://localhost:8545

# Current L2 block hash
rex call $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS "blockHash()" --rpc-url http://localhost:8545

# Block gas limit
rex call $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS "l2GasLimit()" --rpc-url http://localhost:8545

# Chain ID
rex call $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS "chainId()" --rpc-url http://localhost:8545

Step 3: Deposit ETH (L1 → L2)

Send ETH from L1 to a fresh account on L2.

# Pick a fresh address (not pre-funded on L2)
# Private key 0x42 → address 0x6f4c950442e1af093bcff730381e63ae9171b87a
DEPOSIT_TO=0x6f4c950442e1af093bcff730381e63ae9171b87a

# Check L2 balance is 0
rex balance $DEPOSIT_TO --rpc-url http://localhost:1729

# Deposit 1 ETH via sendL1Message(to, gasLimit, data)
# Note: --value is in wei (1 ETH = 1000000000000000000)
rex send $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS \
  "sendL1Message(address,uint256,bytes)" \
  $DEPOSIT_TO 105000 "" \
  --value 1000000000000000000 \
  --rpc-url http://localhost:8545 \
  -k 0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31

# Watch the L2 logs in Terminal 2 — the watcher picks up the message and
# the block producer includes a relayer tx for it.

# After ~10 seconds, check the L2 balance:
rex balance $DEPOSIT_TO --rpc-url http://localhost:1729
# Should show 1000000000000000000 (1 ETH in wei)

Step 4: Deploy a contract on L2 and call it from L1

This step demonstrates that L1→L2 messages can carry arbitrary calldata, not just ETH transfers. We deploy a Counter contract on L2, then increment it by sending a message from L1.

# Deploy Counter.sol on L2 (increment + get functions)
# Uses the deposit recipient account (funded with 1 ETH in Step 3)
rex deploy --contract-path crates/l2/contracts/src/example/Counter.sol \
  --remappings "" \
  --rpc-url http://localhost:1729 \
  --private-key 0x0000000000000000000000000000000000000000000000000000000000000042
# Note the deployed contract address from the output
COUNTER=<deployed_address>

# Verify counter starts at 0
rex call $COUNTER "count()" --rpc-url http://localhost:1729

# Send an L1 message that calls increment() on the counter
# increment() selector = 0xd09de08a (pass without 0x prefix as bytes arg)
rex send $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS \
  "sendL1Message(address,uint256,bytes)" \
  $COUNTER 105000 d09de08a \
  --value 0 \
  --rpc-url http://localhost:8545 \
  -k 0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31

# Wait ~10 seconds for the L2 to process the L1 message, then check:
rex call $COUNTER "count()" --rpc-url http://localhost:1729
# Should return 1

Step 5: Withdraw ETH (L2 → L1)

Send ETH from L2 back to an L1 address.

# L1 receiver address (the deployer account)
L1_RECEIVER=0xE25583099BA105D9ec0A67f5Ae86D90e50036425

# Record L1 balance before
rex balance $L1_RECEIVER --rpc-url http://localhost:8545

# Withdraw 0.5 ETH from L2 via L2Bridge.withdraw(receiver)
# Uses the test account's private key (0x42)
rex send 0x000000000000000000000000000000000000fffd \
  "withdraw(address)" \
  $L1_RECEIVER \
  --value 500000000000000000 \
  --rpc-url http://localhost:1729 \
  -k 0x0000000000000000000000000000000000000000000000000000000000000042

Wait for the L2 block containing the withdrawal to be advanced on L1 (watch the advancer logs in Terminal 2).

Then claim the withdrawal on L1 (replace TX_HASH with the L2 withdrawal tx hash from the output above):

# Fetches the proof from L2 and claims the withdrawal on L1 in one step
rex l2 claim-native-withdraw TX_HASH \
  0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31 \
  $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS \
  http://localhost:8545 \
  http://localhost:1729

# Verify the L1 balance increased
rex balance $L1_RECEIVER --rpc-url http://localhost:8545

Tip

The integration test at test/tests/l2/native_rollup.rs automates the full deposit/withdraw/counter roundtrip including proof fetching and claim submission. Run it with:

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

Step 6: Verify precompile usage with Blockscout

Run a Blockscout block explorer against the L1 to visually confirm that each advance() transaction triggers an internal CALL to the EXECUTE precompile at 0x0000...0101.

Requires Docker and a local clone of Blockscout.

Set up Blockscout

Clone Blockscout and enable the smart contract verifier (needed to decode method names):

git clone https://github.com/blockscout/blockscout.git ~/Documents/blockscout
cd ~/Documents/blockscout/docker-compose

Add the smart-contract-verifier service to geth.yml (after the sig-provider service):

  smart-contract-verifier:
    extends:
      file: ./services/smart-contract-verifier.yml
      service: smart-contract-verifier

Enable it in envs/common-blockscout.env — find the commented-out verifier lines and replace them with:

MICROSERVICE_SC_VERIFIER_ENABLED=true
MICROSERVICE_SC_VERIFIER_URL=http://smart-contract-verifier:8050/
MICROSERVICE_SC_VERIFIER_TYPE=sc_verifier

Start Blockscout

With L1 and L2 already running:

cd ~/Documents/blockscout/docker-compose
docker compose -f geth.yml up -d

Wait for Blockscout to finish indexing before proceeding. The more blocks L1 has already mined, the longer this takes (typically 1-2 minutes). You can check progress at http://localhost or via the API:

curl -s 'http://localhost/api/v2/main-page/indexing-status' | python3 -m json.tool
# Wait until "finished_indexing_blocks": true and "indexed_blocks_ratio": "1.00"

Then open http://localhost.

Verify the NativeRollup contract

Verifying the contract in Blockscout lets it decode function selectors (like 0x069c7eee) into human-readable method names (like advance). This uses Blockscout’s built-in verification API — it’s completely local and free.

Important

The contract must be fully indexed before verification works. If the script fails with Address is not a smart-contract, wait another 30 seconds and retry — Blockscout is still processing.

From the ethrex repo root, run:

python3 tooling/l2/dev/blockscout_verify_native_rollup.py $ETHREX_NATIVE_ROLLUP_CONTRACT_ADDRESS

You should see Contract verified successfully!. Now Blockscout will show decoded method names, event names, and parameter values for all interactions with the contract.

What to look for

On the homepage you should see advance transactions from the advancer to the NativeRollup contract. Click any of them and check:

  • Internal txns tab: shows Call | Success | 0xC5...7600 → 0x00...0101 — the NativeRollup contract calling the EXECUTE precompile.
  • Logs tab: shows the StateAdvanced event with the new L2 block number and state root.

Stop Blockscout

cd ~/Documents/blockscout/docker-compose
docker compose -f geth.yml down -v
rm -rf services/blockscout-db-data services/stats-db-data

Cleaning up

Remove the databases to start fresh:

rm -rf /tmp/ethrex_l1 /tmp/ethrex_l2

Further reading

  • EXECUTE precompile architecture — detailed specification of the precompile, contracts, gap analysis vs the L2Beat native rollups spec, and L1 message mechanism