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

Getting started

Ethrex is a minimalist, stable, modular and fast implementation of the Ethereum protocol in Rust. The client supports running in two different modes:

  • As a regular Ethereum execution client
  • As a multi-prover ZK-Rollup (supporting SP1, RISC Zero and TEEs), where block execution is proven and the proof sent to an L1 network for verification, thus inheriting the L1's security. Support for based sequencing is currently in the works.

We call the first one "ethrex L1" and the second one "ethrex L2".

Where to start

To get started with ethrex, you can follow the installation guide to set up the client on your machine. Then, a good next step would be to run a node and see ethrex in action.

Installation Options

Ethrex is designed to run on Linux and macOS.

There are 4 supported methods to install ethrex:

After following the installation steps you should have a binary that can run an L1 client or a multi-prover ZK-rollup with support for SP1, RISC Zero and TEEs.

Install binary distribution

Download the binary

Download the latest ethrex release for your OS from the packaged binaries

For Linux x86_64:

curl -L https://github.com/lambdaclass/ethrex/releases/latest/download/ethrex-linux_x86_64 -o ethrex

For Linux ARM:

curl -L https://github.com/lambdaclass/ethrex/releases/latest/download/ethrex-linux_aarch64 -o ethrex

For MacOS (Apple Silicon):

curl -L https://github.com/lambdaclass/ethrex/releases/latest/download/ethrex-macos_aarch64 -o ethrex

Give execution permissions to the binary

chmod +x ethrex

Finally, you can verify the program is working by running:

./ethrex --version

tip

For convenience, you can move the ethrex binary to a directory in your $PATH, so you can run it from anywhere.

Package Manager

Coming soon.

Docker images

Prerequisites

Pull the docker image

To pull the latest stable docker image, run:

docker pull ghcr.io/lambdaclass/ethrex:latest

To pull the latest development docker image, run:

docker pull ghcr.io/lambdaclass/ethrex:unstable

To pull the image for a specific version, run:

docker pull ghcr.io/lambdaclass/ethrex:<version-tag>

Existing tags are available in the GitHub repo

Run the docker image

Verify the image is working

docker run --rm ghcr.io/lambdaclass/ethrex --version

Start the node

docker run \
    --rm \
    -d \
    -v ethrex:/root/.local/share/ethrex \
    -p 8545:8545 \
    -p 8551:8551 \
    -p 30303:30303 \
    -p 30303:30303/udp \
    -p 9090:9090 \
    --name ethrex \
    ghcr.io/lambdaclass/ethrex \
    --http.addr 0.0.0.0 \
    --authrpc.addr 0.0.0.0

This command will start a container called ethrex and publish the following ports

  • 8545: TCP port for the JSON-RPC server
  • 8551: TCP port for the auth JSON-RPC server
  • 30303: TCP/UDP port for p2p networking
  • 9090: TCP port metrics port

The command also mounts the docker volume ethrex to persist data.

If you want to follow the logs run

docker logs -f ethrex 

To stop the container run

docker stop ethrex

Building from source

Prerequisites

Installing using cargo install

To install the client simply run

cargo install --locked ethrex --git https://github.com/lambdaclass/ethrex.git

tip

You can add sp1 and risc0 features to the installation script to build with support for SP1 and/or RISC0 provers. gpu feature is also available for CUDA support.

To install a specifc version you can add the --tag <tag> flag. Existing tags are available in the GitHub repo

After that, you can verify the program is working by running:

ethrex --version

Building the binary with cargo build

You can download the source code of a release from the GitHub releases page, or by cloning the repository at that version:

git clone --branch <LATEST_VERSION_HERE> --depth 1 https://github.com/lambdaclass/ethrex.git

After that, you can run the following command inside the cloned repo to build the client:

cargo build --bin ethrex --release

tip

You can add sp1 and risc0 features to the installation script to build with support for SP1 and/or RISC0 provers. gpu feature is also available for CUDA support.

You can find the built binary inside target/release directory. After that, you can verify the program is working by running:

./target/release/ethrex --version

tip

For convenience, you can move the ethrex binary to a directory in your $PATH, so you can run it from anywhere.

Connecting to a consensus client

Ethrex is an execution client designed for post merge networks. Since Ethereum swapped from proof-of-work to proof-of-stake, ethrex needs to run alongside a consensus client.

Consensus clients

There are several consensus clients and all of them work with ethrex. When choosing a consensus client we suggest you keep in mind client diversity.

Configuring ethrex

JWT secret

Consensus clients and execution clients communicate through an authenticated JSON-RPC API. The authentication is done through a jwt secret. Ethrex automatically generates the jwt secret and saves it to the current working directory by default. You can also use your own previously generated jwt secret by using the --authrpc.jwtsecret flag or JWTSECRET_PATH environment variable. If the jwt secret at the specified path does not exist ethrex will create it.

Auth RPC server

By default the server is exposed at http://localhost:8551 but both the address and the port can be modified using the --authrpc.addr and --authrpc.port flags respectively.

Example

ethrex --authrpc.jwtsecret path/to/jwt.hex  --authrpc.addr localhost --authrpc.port 8551

Roadmap

This project is under active development. Over the next two months, our primary objective is to finalize and audit the first version of the stack. This means every component — from L1 syncing to L2 bridging and prover integration — must meet stability, performance, and security standards.

The roadmap below outlines the remaining work required to achieve this milestone, organized into three major areas: L2, DevOps & Performance, and L1.


L2 Roadmap

FeatureDescriptionStatus
Native BridgeSecure and trust-minimized ERC-20 bridge between Ethereum L1 and L2 using canonical messaging and smart contracts.In Progress
Based RollupLaunch the rollup as a based permissionless rollup. Leverages Ethereum for sequencing and DA. For more information check ethrex roadmap for becoming basedIn Progress
Aligned IntegrationOptimize integration with Aligned’s aggregation mode.In Progress
Risc0 SupportIntegrate RISC Zero as an alternative zkVM to SP1, enabling configurable proving backends.In Progress
Battle-Test the ProverEnsure the prover (e.g., SP1, Risc0) is robust, correct, and performant under production-level conditions.In Progress
One-Click L2 DeploymentDeploy a fully operational rollup with a single command. Includes TDX, Prover, integrated Grafana metrics, alerting system, block explorer, bridge hub, backups and default configuration for rapid developer spin-up.In Progress
Shared BridgeDirect bridging between multiple L2s to improve UX and avoid L1 costs.Planned
Custom Native TokenDefine a native token (non-ETH) for gas, staking, incentives, and governance. Fully integrated into fee mechanics and bridging.Planned
Validiums & DACsEnhance Validium mode with Data Availability Committees.Planned
Gas & FeesSet up a custom fee model to price deposits or any forced-included transaction, including data availability costs.Planned

DevOps & Performance

InitiativeDescriptionStatus
Performance BenchmarkingContinuous ggas/s measurement, client comparison, and reproducible load tests.In Progress
DB OptimizationsSnapshots, background trie commits, parallel Merkle root calculation, and exploratory DB design.In Progress
EVM ProfilingIdentify and optimize execution bottlenecks in the VM.In Progress
Deployment & Dev ExperienceOne-command L2 launch, localnet spam testing, and L1 syncing on any network.In Progress

L1 Roadmap

FeatureDescriptionStatus
P2P ImprovementsUse spawned to improve peer discovery, sync reliability, and connection handling.In Progress
Chain SyncingVerify the execution of all blocks across all chains. For Proof-of-Stake (PoS) chains (Holesky, Hoodi), verify all blocks since genesis. For chains with a pre-Merge genesis (Sepolia, Mainnet), verify all blocks after the Merge.In Progress
Snap SyncImprove Snap Sync implementation to make it more reliable and efficient.Planned
Client StabilityIncrease client resilience to adverse scenarios and network disruptions. Improve observability and logging.Planned

Running a node

Supported networks

Ethrex is designed to support Ethereum mainnet and its testnets

NetworkChain idSupported sync modes
mainnet1snap
sepolia11155111snap
holesky17000full, snap
hoodi560048full, snap

For more information about sync modes please read the sync modes document. Full syncing is the default, to switch to snap sync use the flag --syncmode snap

Syncing to an Ethreum network

This guide will assume that you already installed ethrex and you know how to set up a consensus client to communicate with ethrex.

To sync with mainnet

ethrex --syncmode snap

To sync with sepolia

ethrex --network sepolia --syncmode snap

To sync with holesky

ethrex --network holesky

To sync with hoodi

ethrex --network hoodi

Network

The network crate handles the ethereum networking protocols. This involves:

  • Discovery protocol: built on top of udp and it is how we discover new nodes.
  • devP2P: sits on top of tcp and is where the actual blockchain information exchange happens.

Implementation follows the official spec which can be found here. Also, we've inspired in some geth code.

Discovery protocol

In the next section, we'll be looking at the discovery protocol (discv4 to be more specific) and the way we have it set up. There are many points for improvement and here we discuss some possible solutions to them.

At startup, the discovery server launches three concurrent tokio tasks:

  • The listen loop for incoming requests.
  • A revalidation loop to ensure peers remain responsive.
  • A recursive lookup loop to request new peers and keep our table filled.

Before starting these tasks, we run a startup process to connect to an array of initial nodes.

Before diving into what each task does, first, we need to understand how we are storing our nodes. Nodes are stored in an in-memory matrix which we call a Kademlia table, though it isn't really a Kademlia table as we don't thoroughly follow the spec but we take it as a reference, you can read more here. This table holds:

  • Our node_id: The node's unique identifier computed by obtaining the keccak hash of the 64 bytes starting from index 1 of the encoded pub key.
  • A vector of 256 buckets which holds:
    • peers: a vector of 16 elements of type PeersData where we save the node record and other related data that we'll see later.
    • replacements: a vector of 16 elements of PeersData that are not connected to us, but we consider them as potential replacements for those nodes that have disconnected from us.

Peers are not assigned to any bucket but they are assigned based on its to our node_id. Distance is defined by:

#![allow(unused)]
fn main() {
pub fn distance(node_id_1: H512, node_id_2: H512) -> usize {
    let xor = node_id_1 ^ node_id_2;
    let distance = U256::from_big_endian(xor.as_bytes());
    distance.bits().saturating_sub(1)
}
}

Startup

Before starting the server, we do a startup where we connect to an array of seeders or bootnodes. This involves:

  • Receiving bootnodes via CLI params
  • Inserting them into our table
  • Pinging them to notify our presence, so they acknowledge us.

This startup is far from being completed. The current state allows us to do basic tests and connections. Later, we want to do a real startup by first trying to connect to those nodes we were previously connected. For that, we'd need to store nodes on the database. If those nodes aren't enough to fill our table, then we also ping some bootnodes, which could be hardcoded or received through the cli. Current issues are opened regarding startup and nodes db.

Listen loop

The listen loop handles messages sent to our socket. The spec defines 6 types of messages:

  • Ping: Responds with a pong message. If the peer is not in our table we add it, if the corresponding bucket is already filled then we add it as a replacement for that bucket. If it was inserted we send a `ping from our end to get an endpoint proof.
  • Pong: Verifies that the pong corresponds to a previously sent ping, if so we mark the peer as proven.
  • FindNodes: Responds with a neighbors message that contains as many as the 16 closest nodes from the given target. A target is a pubkey provided by the peer in the message. The response can't be sent in one packet as it might exceed the discv4 max packet size. So we split it into different packets.
  • Neighbors: First we verify that we have sent the corresponding find_node message. If so, we receive the peers, store them, and ping them. Also, every find_node request may have a tokio Sender attached, if that is the case, we forward the nodes from the message through the channel. This becomes useful when waiting for a find_node response, something we do in the lookups.
  • ENRRequest: currently not implemented see here.
  • ENRResponse: same as above.

Re-validations

Re-validations are tasks that are implemented as intervals, that is: they run an action every x wherever unit of time (currently configured to run every 30 seconds). The current flow of re-validation is as follows

  1. Every 30 seconds (by default) we ping the three least recently pinged peers: this may be fine now to keep simplicity, but we might prefer to choose three random peers instead to avoid the search which might become expensive as our buckets start to fill with more peers.
  2. In the next iteration we check if they have answered
    • if they have: we increment the liveness field by one.
    • otherwise: we decrement the liveness by a third of its value.
  3. If the liveness field is 0, we delete it and insert a new one from the replacements table.

Liveness checks are not part of the spec but are taken from geth, see here. This field is useful because it provides us with good criteria of which nodes are connected and we "trust" more. This trustiness is useful when deciding if we want to store this node in the database to use it as a future seeder or when establishing a connection in p2p.

Re-validations are another point of potential improvement. While it may be fine for now to keep simplicity at max, pinging the last recently pinged peers becomes quite expensive as the number of peers in the table increases. And it also isn't very "just" in selecting nodes so that they get their liveness increased so we trust them more and we might consider them as a seeder. A possible improvement could be:

  • Keep two lists: one for nodes that have already been pinged, and another one for nodes that have not yet been revalidated. Let's call the former "a" and the second "b".
  • In the beginning, all nodes would belong to "a" and whenever we insert a new node, they would be pushed to "a".
  • We would have two intervals: one for pinging "a" and another for pinging to nodes in "b". The "b" would be quicker, as no initial validation has been done.
  • When picking a node to ping, we would do it randomly, which is the best form of justice for a node to become trusted by us.
  • When a node from b responds successfully, we move it to a, and when one from a does not respond, we move it to b.

This improvement follows somewhat what geth does, see here.

Recursive Lookups

Recursive lookups are as with re-validations implemented as intervals. Their current flow is as follows:

  1. Every 30min we spawn three concurrent lookups: one closest to our pubkey and three others closest to randomly generated pubkeys.
  2. Every lookup starts with the closest nodes from our table. Each lookup keeps track of:
    • Peers that have already been asked for nodes
    • Peers that have been already seen
    • Potential peers to query for nodes: a vector of up to 16 entries holding the closest peers to the pubkey. This vector is initially filled with nodes from our table.
  3. We send a find_node to the closest 3 nodes (that we have not yet asked) from the pubkey.
  4. We wait for the neighbors' response and push or replace those who are closer to the potential peers.
  5. We select three other nodes from the potential peers vector and do the same until one lookup has no node to ask.

The way to do lookups aren't part of the spec. Our implementation aligns with geth approach, see here.

An example of how you might build a network

Finally, here is an example of how you could build a network and see how they connect each other:

We'll have three nodes: a, b, and c, we'll start a, then b setting a as a bootnode, and finally we'll start c with b as bootnode we should see that c connects to both a and b and so all the network should be connected.

node a:

cargo run --bin ethrex -- --network ./fixtures/genesis/kurtosis.json

We get the enode by querying the node_info and using jq:

curl -s http://localhost:8545 \
-X POST \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"admin_nodeInfo","params":[],"id":1}' \
| jq '.result.enode'

node b

We start a new server passing the enode from node a as an argument. Also changing the database dir and the ports is needed to avoid conflicts.

cargo run --bin ethrex -- --network ./fixtures/genesis/kurtosis.json --bootnodes=`NODE_A_ENODE` \
--datadir=ethrex_b --authrpc.port=8552 --http.port=8546 --p2p.port=30305 --discovery.port=30306

node c Finally, with node_c we connect to node_b. When the lookup runs, node_c should end up connecting to node_a:

cargo run --bin ethrex -- --network ./fixtures/genesis/kurtosis.json --bootnodes=`NODE_B_ENODE` \
--datadir=ethrex_c --authrpc.port=8553 --http.port=8547 --p2p.port=30308 --discovery.port=30310

We get the enode by querying the node_info and using jq:

curl -s http://localhost:8546 \
-X POST \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"admin_nodeInfo","params":[],"id":1}' \
| jq '.result.enode'

You could also spawn nodes from other clients and it should work as well.

Syncing

Snap Sync

A snap sync cycle begins by fetching all the block headers (via eth p2p) between the current head (latest canonical block) and the sync head (block hash sent by a forkChoiceUpdate).

We will then fetch the block bodies from each header and at the same time select a pivot block (sync head - 64) and start rebuilding its state via snap p2p requests, if the pivot were to become stale during this rebuild we will select a newer pivot (sync head) and restart it.

After we fully rebuilt the pivot state and fetched all the block bodies we will fetch and store the receipts for the range between the current head and the pivot (including it), and at the same time store all blocks in the same range and execute all blocks after the pivot (like in full sync).

Snap State Rebuild

During snap sync we need to fully rebuild the pivot block's state. We can divide snap sync into 3 core processes: State Sync, Trie Rebuild, and Healing. The State Sync consists of downloading the plain state of the pivot block, aka the values on the leafs of the state & storage tries. For this process we will divide the state trie into segments and fetch each segment in parallel. We will also be relying on two side processes, the bytecode_fetcher and the storage_fetcher which will both remain active throughout the state sync, and fetch the bytecodes and storages of each account downloaded during the state sync. The Trie Rebuild process works in the background while State Sync is active. It consists of two processes running in parallel, one to rebuild the state trie and one to rebuild the storage tries. Both will read the data downloaded by the State Sync but while the state rebuild works independently, the storage rebuild will wait for the storage_fetcher to advertise which storages have been fully downloaded before attempting to rebuild them. The Healing process consists of fixing any inconsistencies leftover from the State Sync & Trie Rebuild processes after they finish. As state sync can spawn across multiple cycles with different pivot blocks the state will not be consistent with the latest pivot block, so we need to fetch all the nodes that the pivot's tries have and ours don't. The bytecode_fetcher and storage_healer processes will be involved to heal the bytecodes & storages of each account healed by the main state heal process. Also, the storage_healer will be spawned earlier, during state sync so that it can begin healing the storages that couldn't be fetched due to pivot staleness.

This diagram illustrates all the processes involved in snap sync:

SnapSync.

And this diagram shows the interaction between the different processes involved in State Sync, Trie Rebuild and Healing: StateSyncAndHealing.

To exemplify how queue-like processes work we will explain how the bytecode_fetcher works:

The bytecode_fetcher has its own channel where it receives code hashes from an active rebuild_state_trie process. Once a code hash is received, it is added to a pending queue. When the queue has enough messages for a full batch it will request a batch of bytecodes via snap p2p and store them. If a bytecode could not be fetched by the request (aka, we reached the response limit) it is added back to the pending queue. After the whole state is synced fetch_snap_state will send an empty list to the bytecode_fetcher to signal the end of the requests so it can request the last (incomplete) bytecode batch and end gracefully.

This diagram illustrates the process described above:

snap_sync

Sync Modes

Full sync

Full syncing works by downloading and executing every block from genesis. This means that full syncing will only work for networks that started after The Merge, as ethrex only supports post merge execution.

Snap sync

Snap syncing is a much faster alternative to full sync that works by downloading and executing only the latest blocks from the network. For a much more in depth description on how snap sync works under the hood please read the snap networking documentation

Ethrex L2

In this mode, the ethrex code is repurposed to run a rollup that settles on Ethereum as the L1.

The main differences between this mode and regular ethrex are:

  • In regular rollup mode, there is no consensus; the node is turned into a sequencer that proposes blocks for the chain. In based rollup mode, consensus is achieved by a mechanism that rotates sequencers, enforced by the L1.
  • Block execution is proven using a RISC-V zkVM (or attested to using TDX, a Trusted Execution Environment) and its proofs (or signatures/attestations) are sent to L1 for verification.
  • A set of Solidity contracts to be deployed to the L1 are included as part of chain initialization.
  • Two new types of transactions are included: deposits (native token mints) and withdrawals.

At a high level, the following new parts are added to the node:

  • A proposer component, in charge of continually creating new blocks from the mempool transactions. This replaces the regular flow that an Ethereum L1 node has, where new blocks come from the consensus layer through the forkChoiceUpdate -> getPayload -> NewPayload Engine API flow in communication with the consensus layer.
  • A prover subsystem, which itself consists of two parts:
    • A proverClient that takes new blocks from the node, proves them, then sends the proof back to the node to send to the L1. This is a separate binary running outside the node, as proving has very different (and higher) hardware requirements than the sequencer.
    • A proverServer component inside the node that communicates with the prover, sending witness data for proving and receiving proofs for settlement on L1.
  • L1 contracts with functions to commit to new state and then verify the state transition function, only advancing the state of the L2 if the proof verifies. It also has functionality to process deposits and withdrawals to/from the L2.
  • The EVM is lightly modified with new features to process deposits and withdrawals accordingly.

Ethrex L2 documentation

For general documentation, see:

Deploying a node

Prerequisites

This guide assumes that you've deployed the contracts for the rollup to your chosen L1 network, and that you have a valid genesis.json. The contract's solidity code can be downloaded from the GitHub releases or by running:

curl -L https://github.com/lambdaclass/ethrex/releases/latest/download/ethrex-contracts.tar.gz

Starting the sequencer

First we need to set some environment variables.

Run the sequencer

    ethrex l2 \
	--network <path-to-your-genesis.json> \
	--on_chain_proposer_address <address> \
	--bridge_address <address> \
	--rpc_url <rpc-url> \
	--committer_l1_private_key <private-key> \
	--proof_coordinator_l1_private_key \
	--block-producer.coinbase-address <l2-coinbase-address> \

For further configuration take a look at the CLI document

This will start an ethrex l2 sequencer with the RPC server listening at http://localhost:1729 and the proof coordinator server listening at http://localhost:3900

Starting a prover server

ethrex l2 prover --proof-coordinator http://localhost:3900

For further configuration take a look at the CLI document

Guides

Here we have a collection of how-to guides for common user operations.

Depositing assets into the L2

To transfer ETH from Ethereum L1 to your L2 account, you need to use the CommonBridge as explained in this section.

Prerequisites for L1 deposit

  • An L1 account with sufficient ETH balance, for developing purposes you can use:
    • Address: 0x8943545177806ed17b9f23f0a21ee5948ecaa776
    • Private Key: 0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31
  • The address of the deployed CommonBridge contract.
  • An Ethereum utility tool like Rex

Making a deposit

Making a deposit in the Bridge, using Rex, is as simple as:

# Format: rex l2 deposit <AMOUNT> <PRIVATE_KEY> <BRIDGE_ADDRESS> [L1_RPC_URL]
rex l2 deposit 50000000 0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31 0x65dd6dc5df74b7e08e92c910122f91d7b2d5184f

Verifying the updated L2 balance

Once the deposit is made you can verify the balance has increase with:

# Format: rex l2 balance <ADDRESS> [RPC_URL]
rex l2 balance 0x8943545177806ed17b9f23f0a21ee5948ecaa776

For more information on what you can do with the CommonBridge see Ethrex L2 contracts.

Withdrawing assets from the L2

This section explains how to withdraw funds from the L2 through the native bridge.

Prerequisites for L2 withdrawal

  • An L2 account with sufficient ETH balance, for developing purpose you can use:
    • Address: 0x8943545177806ed17b9f23f0a21ee5948ecaa776
    • Private Key: 0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31
  • The address of the deployed CommonBridge L2 contract (note here that we are calling the L2 contract instead of the L1 as in the deposit case). If not specified, You can use:
    • CommonBridge L2: 0x000000000000000000000000000000000000ffff
  • An Ethereum utility tool like Rex.

Making a withdrawal

Using Rex, we simply run the rex l2 withdraw command, which uses the default CommonBridge address.

# Format: rex l2 withdraw <AMOUNT> <PRIVATE_KEY> [RPC_URL]
rex l2 withdraw 5000 0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31

If the withdrawal is successful, the hash will be printed like this:

Withdrawal sent: <L2_WITHDRAWAL_TX_HASH>
...

Claiming the withdrawal

After making a withdrawal, it has to be claimed in the L1, through the L1 CommonBridge contract. For that, we can use the Rex command rex l2 claim-withdraw, with the tx hash obtained in the previous step. But first, it is necessary to wait for the block that includes the withdraw to be verified.

# Format: rex l2 claim-withdraw <L2_WITHDRAWAL_TX_HASH> <PRIVATE_KEY> <BRIDGE_ADDRESS> [L1_RPC_URL] [RPC_URL]
rex l2 claim-withdraw <L2_WITHDRAWAL_TX_HASH> 0xbcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31 0x65dd6dc5df74b7e08e92c910122f91d7b2d5184f

Verifying the withdrawal

Once the withdrawal is made you can verify the balance has decreased in the L2 with:

rex l2 balance 0x8943545177806ed17b9f23f0a21ee5948ecaa776

And also increased in the L1:

rex balance 0x8943545177806ed17b9f23f0a21ee5948ecaa776

General overview of the ethrex L2 stack

This document aims to explain how the Lambda ethrex L2 and all its moving parts work.

Intro

At a high level, the way an L2 works is as follows:

  • There is a contract in L1 that tracks the current state of the L2. Anyone who wants to know the current state of the chain need only consult this contract.
  • Every once in a while, someone (usually the sequencer, but could be a decentralized network, or even anyone at all in the case of a based contestable rollup) builds a batch of new L2 blocks and publishes it to L1. We will call this the commit L1 transaction.
  • For L2 batches to be considered finalized, a zero-knowledge proof attesting to the validity of the batch needs to be sent to L1, and its verification needs to pass. If it does, everyone is assured that all blocks in the batch were valid and thus the new state is. We call this the verification L1 transaction.

We ommited a lot of details in this high level explanation. Some questions that arise are:

  • What does it mean for the L1 contract to track the state of L2? Is the entire L2 state kept on it? Isn't it really expensive to store a bunch of state on an Ethereum smart contract?
  • What does the ZK proof prove exactly?
  • How do we make sure that the sequencer can't do anything malicious if it's the one proposing blocks and running every transaction?
  • How does someone go in and out of the L2, i.e., how do you deposit money from L1 into L2 and then withdraw it? How do you ensure this can't be tampered with? Bridges are by far the most vulnerable part of blockchains today and going in and out of the L2 totally sounds like a bridge.

Below some answers to these questions, along with an overview of all the moving parts of the system.

How do you prove state?

Now that general purpose zkVMs exist, most people have little trouble with the idea that you can prove execution. Just take the usual EVM code you wrote in Rust, compile to some zkVM target instead and you're mostly done. You can now prove it.

What's usually less clear is how you prove state. Let's say we want to prove a new L2 batch of blocks that were just built. Running the ethrex execute_block function on a Rust zkVM for all the blocks in the batch does the trick, but that only proves that you ran the VM correctly on some previous state/batch. How do you know it was the actual previous state of the L2 and not some other, modified one?

In other words, how do you ensure that:

  • Every time the EVM reads from some storage slot (think an account balance, some contract's bytecode), the value returned matches the actual value present on the previous state of the chain.

For this, the VM needs to take as a public input the previous state of the L2, so the prover can show that every storage slot it reads is consistent with it, and the verifier contract on L1 can check that the given public input is the actual previous state it had stored. However, we can't send the entire previous state as public input because it would be too big; this input needs to be sent on the verification transaction, and the entire L2 state does not fit on it.

To solve this, we do what we always do: instead of having the actual previous state be the public input, we build a Merkle Tree of the state and use its root as the input. Now the state is compressed into a single 32-byte value, an unforgeable representation of it; if you try to change a single bit, the root will change. This means we now have, for every L2 batch, a single hash that we use to represent it, which we call the batch commitment (we call it "commitment" and not simply "state root" because, as we'll see later, this won't just be the state root, but rather the hash of a few different values including the state root).

The flow for the prover is then roughly as follows:

  • Take as public input the previous batch commitment and the next (output) batch commitment.
  • Execute all blocks in the batch to prove its execution is valid. Here "execution" means more than just transaction execution; there's also header validation, transaction validation, etc. (essentially all the logic ethrex needs to follow when executing and adding a new block to the chain).
  • For every storage slot read, present and verify a merkle path from it to the previous state root (i.e. previous batch commitment).
  • For every storage slot written, present and verify a merkle path from it to the next state root (i.e. next batch commitment).

As a final note, to keep the public input a 32 byte value, instead of passing the previous and next batch commitments separately, we hash the two of them and pass that. The L1 contract will then have an extra step of first taking both commitments and hashing them together to form the public input.

These two ideas will be used extensively throughout the rest of the documentation:

  • Whenever we need to add some state as input, we build a merkle tree and use its root instead. Whenever we use some part of that state in some way, the prover provides merkle paths to the values involved. Sometimes, if we don't care about efficient inclusion proofs of parts of the state, we just hash the data altogether and use that instead.
  • To keep the batch commitment (i.e. the value attesting to the entire state of the chain) a 32 byte value, we hash the different public inputs into one. The L1 contract is given all the public inputs on commit, checks their validity and then squashes them into one through hashing.

Reconstructing state/Data Availability

While using a merkle root as a public input for the proof works well, there is still a need to have the state on L1. If the only thing that's published to it is the state root, then the sequencer could withhold data on the state of the chain. Because it is the one proposing and executing blocks, if it refuses to deliver certain data (like a merkle path to prove a withdrawal on L1), people may not have any place to get it from and get locked out of the chain or some of their funds.

This is called the Data Availability problem. As discussed before, sending the entire state of the chain on every new L2 batch is impossible; state is too big. As a first next step, what we could do is:

  • For every new L2 batch, send as part of the commit transaction the list of transactions in the batch. Anyone who needs to access the state of the L2 at any point in time can track all commit transactions, start executing them from the beginning and recontruct the state.

This is now feasible; if we take 200 bytes as a rough estimate for the size of a single transfer between two users (see this post for the calculation on legacy transactions) and 128 KB as a reasonable transaction size limit we get around ~650 transactions at maximum per commit transaction (we are assuming we use calldata here, blobs can increase this limit as each one is 128 KB and we could use multiple per transaction).

Going a bit further, instead of posting the entire transaction, we could just post which accounts have been modified and their new values (this includes deployed contracts and their bytecode of course). This can reduce the size a lot for most cases; in the case of a regular transfer as above, we only need to record balance updates of two accounts, which requires sending just two (address, balance) pairs, so (20 + 32) * 2 = 104 bytes, or around half as before. Some other clever techniques and compression algorithms can push down the publishing cost of this and other transactions much further.

This is called state diffs. Instead of publishing entire transactions for data availability, we only publish whatever state they modified. This is enough for anyone to reconstruct the entire state of the chain.

Detailed documentation on the state diffs spec.

How do we prevent the sequencer from publishing the wrong state diffs?

Once again, state diffs have to be part of the public input. With them, the prover can show that they are equal to the ones returned by the VM after executing all blocks in the batch. As always, the actual state diffs are not part of the public input, but their hash is, so the size is a fixed 32 bytes. This hash is then part of the batch commitment. The prover then assures us that the given state diff hash is correct (i.e. it exactly corresponds to the changes in state of the executed blocks).

There's still a problem however: the L1 contract needs to have the actual state diff for data availability, not just the hash. This is sent as part of calldata of the commit transaction (actually later as a blob, we'll get to that), so the sequencer could in theory send the wrong state diff. To make sure this can't happen, the L1 contract hashes it to make sure that it matches the actual state diff hash that is included as part of the public input.

With that, we can be sure that state diffs are published and that they are correct. The sequencer cannot mess with them at all; either it publishes the correct state diffs or the L1 contract will reject its batch.

Compression

Because state diffs are compressed to save space on L1, this compression needs to be proven as well. Otherwise, once again, the sequencer could send the wrong (compressed) state diffs. This is easy though, we just make the prover run the compression and we're done.

EIP 4844 (a.k.a. Blobs)

While we could send state diffs through calldata, there is a (hopefully) cheaper way to do it: blobs. The Ethereum Cancun upgrade introduced a new type of transaction where users can submit a list of opaque blobs of data, each one of size at most 128 KB. The main purpose of this new type of transaction is precisely to be used by rollups for data availability; they are priced separately through a blob_gas market instead of the regular gas one and for all intents and purposes should be much cheaper than calldata.

Using EIP 4844, our state diffs would now be sent through blobs. While this is cheaper, there's a new problem to address with it. The whole point of blobs is that they're cheaper because they are only kept around for approximately two weeks and ONLY in the beacon chain, i.e. the consensus side. The execution side (and thus the EVM when running contracts) does not have access to the contents of a blob. Instead, the only thing it has access to is a KZG commitment of it.

This is important. If you recall, the way the L1 ensured that the state diff published by the sequencer was correct was by hashing its contents and ensuring that the hash matched the given state diff hash. With the contents of the state diff now no longer accesible by the contract, we can't do that anymore, so we need another way to ensure the correct contents of the state diff (i.e. the blob).

The solution is through a proof of equivalence between polynomial commitment schemes. The idea is as follows: proofs of equivalence allow you to show that two (polynomial) commitments point to the same underlying data. In our case, we have two commitments:

  • The state diff commitment calculated by the sequencer/prover.
  • The KZG commitment of the blob sent on the commit transaction (recall that the blob should just be the state diff).

If we turn the first one into a polynomial commitment, we can take a random evaluation point through Fiat Shamir and prove that it evaluates to the same value as the KZG blob commitment at that point. The commit transaction then sends the blob commitment and, through the point evaluation precompile, verifies that the given blob evaluates to that same value. If it does, the underlying blob is indeed the correct state diff.

Our proof of equivalence implementation follows Method 1 here. What we do is the following:

Prover side

  • Take the state diff being commited to as 4096 32-byte chunks (these will be interpreted as field elements later on, but for now we don't care). Call these chunks , with i ranging from 0 to 4095.

  • Build a merkle tree with the as leaves. Note that we can think of the merkle root as a polynomial commitment, where the i-th leaf is the evaluation of the polynomial on the i-th power of , the 4096-th root of unity on , the field modulus of the BLS12-381 curve. Call this polynomial . This is the same polynomial that the L1 KZG blob commits to (by definition). Call the L1 blob KZG commitment and the merkle root we just computed .

  • Choose x as keccak(, ) and calculate the evaluation ; call it y. To do this calculation, because we only have the , the easiest way to do it is through the barycentric formula. IMPORTANT: we are taking the , x, y, and as elements of , NOT the native field used by our prover. The evaluation thus is:

  • Set x and y as public inputs. All the above shows the verifier on L1 that we made a polynomial commitment to the state diff, that its evaluation on x is y, and that x was chosen through Fiat-Shamir by hashing the two commitments.

Verifier side

  • When commiting to the data on L1 send, as part of the calldata, a kzg blob commitment along with an opening proving that it evaluates to y on x. The contract, through the point evaluation precompile, checks that both:
    • The commitment's hash is equal to the versioned hash for that blob.
    • The evaluation is correct.

L1<->L2 communication

To communicate between L1 and L2, we use two mechanisms called Privileged transactions, and L1 messages. In this section we talk a bit about them, first going through the more specific use cases for Deposits and Withdrawals.

Deposits

The mechanism for depositing funds to L2 from L1 is explained in detail in "Deposits".

Withdrawals

The mechanism for withdrawing funds from L2 back to L1 is explained in detail in "Withdrawals".

Recap

Batch Commitment

An L2 batch commitment is the hash of the following things:

  • The new L2 state root.
  • The state diff hash or polynomial commitments, depending on whether we are using calldata or blobs.
  • The Withdrawal logs merkle root.

The public input to the proof is then the hash of the previous batch commitment and the new one.

L1 contract checks

Commit transaction

For the commit transaction, the L1 verifier contract receives the following things from the sequencer:

  • The L2 batch number to be commited.
  • The new L2 state root.
  • The Withdrawal logs merkle root.
  • The state diffs hash or polynomial commitment scheme accordingly.

The contract will then:

  • Check that the batch number is the immediate successor of the last batch processed.
  • Check that the state diffs are valid, either through hashing or the point evaluation precompile.
  • Calculate the new batch commitment and store it.

Verify transaction

On a verification transaction, the L1 contract receives the following:

  • The batch number.
  • The batch proof.

The contract will then:

  • Compute the proof public input from the new and previous batch commitments (both are already stored in the contract).
  • Pass the proof and public inputs to the verifier and assert the proof passes.
  • If the proof passes, finalize the L2 state, setting the latest batch as the given one and allowing any withdrawals for that batch to occur.

What the sequencer cannot do

  • Forge Transactions: Invalid transactions (e.g. sending money from someone who did not authorize it) are not possible, since part of transaction execution requires signature verification. Every transaction has to come along with a signature from the sender. That signature needs to be verified; the L1 verifier will reject any block containing a transaction whose signature is not valid.
  • Withhold State: Every L1 commit transaction needs to send the corresponding state diffs for it and the contract, along with the proof, make sure that they indeed correspond to the given batch. TODO: Expand with docs on how this works.
  • Mint money for itself or others: The only valid protocol transaction that can mint money for a user is an L1 deposit. Every one of these mint transactions is linked to exactly one deposit transaction on L1. TODO: Expand with some docs on the exact details of how this works.

What the sequencer can do

The main thing the sequencer can do is CENSOR transactions. Any transaction sent to the sequencer could be arbitrarily dropped and not included in blocks. This is not completely enforceable by the protocol, but there is a big mitigation in the form of an escape hatch.

TODO: Explain this in detail.

Components

Here we have documentation about each component of the ethrex L2:

  • Sequencer: Describes the components and configuration of the L2 sequencer node.
  • Contracts: Explains the L1 and L2 smart contracts used by the system.
  • Prover: Details how block execution proofs are generated and verified using zkVMs.
  • Aligned mode: Explains how to run an Ethrex L2 node in Aligned mode.
  • TDX execution module: Documentation related to proving ethrex blocks using TDX.

Ethrex L2 sequencer

Components

The L2 Proposer is composed of the following components:

Block Producer

Creates Blocks with a connection to the auth.rpc port.

L1 Watcher

This component monitors the L1 for new deposits made by users. For that, it queries the CommonBridge contract on L1 at regular intervals (defined by the config file) for new DepositInitiated() events. Once a new deposit event is detected, it creates the corresponding deposit transaction on the L2.

L1 Transaction Sender (a.k.a. L1 Committer)

As the name suggests, this component sends transactions to the L1. But not any transaction, only commit and verify transactions.

Commit transactions are sent when the Proposer wants to commit to a new batch of blocks. These transactions contain the batch data to be committed in the L1.

Verify transactions are sent by the Proposer after the prover has successfully generated a proof of block execution to verify it. These transactions contains the new state root of the L2, the hash of the state diffs produced in the block, the root of the withdrawals logs merkle tree and the hash of the processed deposits.

Proof Coordinator

The Proof Coordinator is a simple TCP server that manages communication with a component called the Prover. The Prover acts as a simple TCP client that makes requests to prove a block to the Coordinator. It responds with the proof input data required to generate the proof. Then, the Prover executes a zkVM, generates the Groth16 proof, and sends it back to the Coordinator.

The Proof Coordinator centralizes the responsibility of determining which block needs to be proven next and how to retrieve the necessary data for proving. This design simplifies the system by reducing the complexity of the Prover, it only makes requests and proves blocks.

For more information about the Proof Coordinator, the Prover, and the proving process itself, see the Prover Docs.

L1 Proof Sender

The L1 Proof Sender is responsible for interacting with Ethereum L1 to manage proof verification. Its key functionalities include:

  • Connecting to Ethereum L1 to send proofs for verification.
  • Dynamically determine required proof types based on active verifier contracts (PICOVERIFIER, R0VERIFIER, SP1VERIFIER).
  • Ensure blocks are verified in the correct order by invoking the verify(..) function in the OnChainProposer contract. Upon successful verification, an event is emitted to confirm the block's verification status.
  • Operating on a configured interval defined by proof_send_interval_ms.

Configuration

Configuration is done either by CLI flags or through environment variables. Run cargo run --release --bin ethrex -- l2 --help in the repository's root directory to see the available CLI flags and envs.

Ethrex L2 prover

note

The shipping/deploying process and the Prover itself are under development.

Intro

The prover consists of two main components: handling incoming proving data from the L2 proposer, specifically from the ProofCoordinator component, and the zkVM. The Prover is responsible for this first part, while the zkVM serves as a RISC-V emulator executing code specified in crates/l2/prover/zkvm/interface/guest/src. Before the zkVM code (or guest), there is a directory called interface, which indicates that we access the zkVM through the "interface" crate.

In summary, the Prover manages the inputs from the ProofCoordinator and then "calls" the zkVM to perform the proving process and generate the groth16 ZK proof.

Workflow

The ProofCoordinator monitors requests for new jobs from the Prover, which are sent when the prover is available. Upon receiving a new job, the Prover generates the proof, after which the Prover sends the proof back to the ProofCoordinator.

sequenceDiagram
    participant zkVM
    participant Prover
    participant ProofCoordinator
    Prover->>+ProofCoordinator: ProofData::Request
    ProofCoordinator-->>-Prover: ProofData::Response(batch_number, ProverInputs)
    Prover->>+zkVM: Prove(ProverInputs)
    zkVM-->>-Prover: Creates zkProof
    Prover->>+ProofCoordinator: ProofData::Submit(batch_number, zkProof)
    ProofCoordinator-->>-Prover: ProofData::SubmitAck(batch_number)

How

Dependencies:

  • RISC0
    1. curl -L https://risczero.com/install | bash
    2. rzup install cargo-risczero 2.3.1
  • SP1
    1. curl -L https://sp1up.succinct.xyz | bash
    2. sp1up --version 5.0.8
  • SOLC

After installing the toolchains, a quick test can be performed to check if we have everything installed correctly.

L1 block proving

ethrex-prover is able to generate execution proofs of Ethereum Mainnet/Testnet blocks. An example binary was created for this purpose in crates/l2/prover/bench. Refer to its README for usage.

Dev Mode

To run the blockchain (proposer) and prover in conjunction, start the Prover, use the following command:

make init-prover T="prover_type (risc0,sp1) G=true"

Run the whole system with the prover - In one Machine

note

Used for development purposes.

  1. cd crates/l2
  2. make rm-db-l2 && make down
    • It will remove any old database, if present, stored in your computer. The absolute path of libmdbx is defined by data_dir.
  3. make init
    • Make sure you have the solc compiler installed in your system.
    • Init the L1 in a docker container on port 8545.
    • Deploy the needed contracts for the L2 on the L1.
    • Start the L2 locally on port 1729.
  4. In a new terminal → make init-prover T="(sp1,risc0)".

After this initialization we should have the prover running in dev_mode → No real proofs.

GPU mode

Steps for Ubuntu 22.04 with Nvidia A4000:

  1. Install docker → using the Ubuntu apt repository
    • Add the user you are using to the docker group → command: sudo usermod -aG docker $USER. (needs reboot, doing it after CUDA installation)
    • id -nG after reboot to check if the user is in the group.
  2. Install Rust
  3. Install RISC0
  4. Install CUDA for Ubuntu
    • Install CUDA Toolkit Installer first. Then the nvidia-open drivers.
  5. Reboot
  6. Run the following commands:
sudo apt-get install libssl-dev pkg-config libclang-dev clang
echo 'export PATH=/usr/local/cuda/bin:$PATH' >> ~/.bashrc
echo 'export LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH' >> ~/.bashrc

Run the whole system with a GPU Prover

Two servers are required: one for the Prover and another for the sequencer. If you run both components on the same machine, the Prover may consume all available resources, leading to potential stuttering or performance issues for the sequencer/node.

  • The number 1 simbolizes a machine with GPU for the Prover.
  • The number 2 simbolizes a machine for the sequencer/L2 node itself.
  1. Prover/zkvm → prover with gpu, make sure to have all the required dependencies described at the beginning of Gpu Mode section.
    1. cd ethrex/crates/l2
    2. You can set the following environment variables to configure the prover:
      • PROVER_CLIENT_PROVER_SERVER_ENDPOINT: The address of the server where the client will request the proofs from
      • PROVER_CLIENT_PROVING_TIME_MS: The amount of time to wait before requesting new data to prove
  • Finally, to start the Prover/zkvm, run:
    • make init-prover T=(sp1,risc0) G=true
  1. ProofCoordinator/sequencer → this server just needs rust installed.
    1. cd ethrex/crates/l2

    2. Create a .env file with the following content:

      // Should be the same as ETHREX_COMMITTER_L1_PRIVATE_KEY and ETHREX_WATCHER_L2_PROPOSER_PRIVATE_KEY
      ETHREX_DEPLOYER_L1_PRIVATE_KEY=<private_key>
      // Should be the same as ETHREX_COMMITTER_L1_PRIVATE_KEY and ETHREX_DEPLOYER_L1_PRIVATE_KEY
      ETHREX_WATCHER_L2_PROPOSER_PRIVATE_KEY=<private_key>
      // Should be the same as ETHREX_WATCHER_L2_PROPOSER_PRIVATE_KEY and ETHREX_DEPLOYER_L1_PRIVATE_KEY
      ETHREX_COMMITTER_L1_PRIVATE_KEY=<private_key>
      // Should be different from ETHREX_COMMITTER_L1_PRIVATE_KEY and ETHREX_WATCHER_L2_PROPOSER_PRIVATE_KEY
      ETHREX_PROOF_COORDINATOR_L1_PRIVATE_KEY=<private_key>
      // Used to handle TCP communication with other servers from any network interface.
      ETHREX_PROOF_COORDINATOR_LISTEN_ADDRESS=0.0.0.0
      // Set to true to randomize the salt.
      ETHREX_DEPLOYER_RANDOMIZE_CONTRACT_DEPLOYMENT=true
      // Check if the contract is deployed in your preferred network or set to `true` to deploy it.
      ETHREX_DEPLOYER_SP1_DEPLOY_VERIFIER=true
      // Check the if the contract is present on your preferred network.
      ETHREX_DEPLOYER_RISC0_CONTRACT_VERIFIER=<address>
      // It can be deployed. Check the if the contract is present on your preferred network.
      ETHREX_DEPLOYER_SP1_CONTRACT_VERIFIER=<address>
      // Set to any L1 endpoint.
      ETHREX_ETH_RPC_URL=<url>
      
    3. source .env

note

Make sure to have funds, if you want to perform a quick test 0.2[ether] on each account should be enough.

  • Finally, to start the proposer/l2 node, run:

    • make rm-db-l2 && make down
    • make deploy-l1 && make init-l2 (if running a risc0 prover, see the next step before invoking the L1 contract deployer)
  • If running with a local L1 (for development), you will need to manually deploy the risc0 contracts by following the instructions here.

  • For a local L1 running with ethrex, we do the following:

    1. clone the risc0-ethereum repo
    2. edit the risc0-ethereum/contracts/deployment.toml file by adding
      [chains.ethrex]
      name = "Ethrex local devnet"
      id = 9
      
    3. export env. variables (we are using an ethrex's rich L1 account)
      export VERIFIER_ESTOP_OWNER="0x4417092b70a3e5f10dc504d0947dd256b965fc62"
      export DEPLOYER_PRIVATE_KEY="0x941e103320615d394a55708be13e45994c7d93b932b064dbcb2b511fe3254e2e"
      export DEPLOYER_ADDRESS="0x4417092b70a3e5f10dc504d0947dd256b965fc62"
      export CHAIN_KEY="ethrex"
      export RPC_URL="http://localhost:8545"
      
      export ETHERSCAN_URL="dummy"
      export ETHERSCAN_API_KEY="dummy"
      
      the last two variables need to be defined with some value even if not used, else the deployment script fails.
    4. cd into risc0-ethereum/
    5. run the deployment script
      bash contracts/script/manage DeployEstopGroth16Verifier --broadcast
      
    6. if the deployment was successful you should see the contract address in the output of the command, you will need to pass this as an argument to the L2 contract deployer, or via the ETHREX_DEPLOYER_RISC0_CONTRACT_VERIFIER=<address> env. variable. if you get an error like risc0-ethereum/contracts/../lib/forge-std/src/Script.sol": No such file or directory (os error 2), try to update the git submodules (foundry dependencies) with git submodule update --init --recursive.

Configuration

Configuration is done through environment variables or CLI flags. You can see a list of available flags by passing --help to the CLI.

The following environment variables are available to configure the Prover:

  • CONFIGS_PATH: The path where the PROVER_CLIENT_CONFIG_FILE is located at.
  • PROVER_CLIENT_CONFIG_FILE: The .toml that contains the config for the Prover.
  • PROVER_ENV_FILE: The name of the .env that has the parsed .toml configuration.
  • PROVER_CLIENT_PROVER_SERVER_ENDPOINT: Prover Server's Endpoint used to connect the Client to the Server.

The following environment variables are used by the ProverServer:

  • PROVER_SERVER_LISTEN_IP: IP used to start the Server.
  • PROVER_SERVER_LISTEN_PORT: Port used to start the Server.
  • PROVER_SERVER_VERIFIER_ADDRESS: The address of the account that sends the zkProofs on-chain and interacts with the OnChainProposer verify() function.
  • PROVER_SERVER_VERIFIER_PRIVATE_KEY: The private key of the account that sends the zkProofs on-chain and interacts with the OnChainProposer verify() function.

note

The PROVER_SERVER_VERIFIER account must differ from the COMMITTER_L1 account.

How it works

The prover's sole purpose is to generate a block (or batch of blocks) execution proof. For this, ethrex-prover implements a blocks execution program and generates a proof of it using different RISC-V zkVMs (SP1, Risc0).

The prover runs a process that polls another for new jobs. The job must provide the program inputs. A proof of the program's execution with the provided inputs is generated by the prover and sent back.

Program inputs

The inputs for the blocks execution program (also called program inputs or prover inputs) are:

  • the blocks to prove (header and body)
  • the first block's parent header
  • an execution witness
  • the blocks' deposits hash
  • the blocks' withdrawals Merkle root
  • the blocks' state diff hash

The last three inputs are L2 specific.

These inputs are required for proof generation, but not all of them are committed as public inputs, which are needed for proof verification. The proof's public inputs (also called program outputs) will be:

  • the initial state hash (from the first block's parent header)
  • the final state hash (from the last block's header)
  • the blocks' deposits hash
  • the blocks' withdrawals Merkle root
  • the blocks' state diff hash

Execution witness

The purpose of the execution witness is to allow executing the blocks without having access to the whole Ethereum state, as it wouldn't fit in a zkVM program. It contains only the state values needed during the execution.

An execution witness (represented by the ProverDB type) contains:

  1. all the initial state values (accounts, code, storage, block hashes) that will be read or written to during the blocks' execution.
  2. Merkle Patricia Trie (MPT) proofs that prove the inclusion or exclusion of each initial value in the initial world state trie.

An execution witness is created from a prior execution of the blocks. Before proving, we need to:

  1. execute the blocks (also called "pre-execution").
  2. log every initial state value accessed or updated during this execution.
  3. store each logged value in an in-memory key-value database (ProverDB, implemented just using hash maps).
  4. retrieve an MPT proof for each value, linking it (or its non-existence) to the initial state root hash.

Steps 1-3 are straightforward. Step 4 involves more complex logic due to potential issues when restructuring the pruned state trie after value removals. In sections initial state validation and final state validation we explain what are pruned tries and in which case they get restructured.

If a value is removed during block execution (meaning it existed initially but not finally), two pathological cases can occur where the witness lacks sufficient information to update the trie structure correctly:

Case 1

Image showing restructuration for case 1

Here, only leaf 1 is part of the execution witness, so we lack the proof (and thus the node data) for leaf 2. After removing leaf 1, branch 1 becomes redundant. During trie restructuring, it's replaced by leaf 3, whose path is the path of leaf 2 concatenated with a prefix nibble (k) representing the choice taken at the original branch 1, and keeping leaf 2's value.

branch1 = {c_1, c_2, ..., c_k, ..., c_16} # Only c_k = hash(leaf2) is non-empty
leaf2 = {value, path}
leaf3 = {value, concat(k, path)} # New leaf replacing branch1 and leaf2

Without leaf 2's data, we cannot construct leaf 3. The solution is to fetch the final state proof for the key of leaf 2. This yields an exclusion proof containing leaf 3. By removing the prefix nibble k, we can reconstruct the original path and value of leaf 2. This process might need to be repeated if similar restructuring occurred at higher levels of the trie.

Case 2

Image showing restructuration for case 2

In this case, restructuring requires information about branch/ext 2 (which could be a branch or extension node), but this node might not be in the witness. Checking the final extension node might seem sufficient to deduce branch/ext 2 in simple scenarios. However, this fails if similar restructuring occurred at higher trie levels involving more removals, as the final extension node might combine paths from multiple original branches, making it ambiguous to reconstruct the specific missing branch/ext 2 node.

The solution is to fetch the missing node directly using a debug JSON-RPC method, like debug_dbGet (or debug_accountRange and debug_storageRangeAt if using a Geth node).

note

These problems arise when creating the execution witness solely from state proofs fetched via standard JSON-RPC. In the L2 context, where we control the sequencer, we could develop a protocol to easily retrieve all necessary data more directly. However, the problems remain relevant when proving L1 blocks (e.g., for testing/benchmarking).

Blocks execution program

The program leverages ethrex-common primitives and ethrex-vm methods. ethrex-prover implements a program that uses the existing execution logic and generates a proof of its execution using a zkVM. Some L2-specific logic and input validation are added on top of the basic blocks execution.

The following sections outline the steps taken by the execution program.

Prelude 1: state trie basics

We recommend learning about Merkle Patricia Tries (MPTs) to better understand this section.

Each executed block transitions the Ethereum state from an initial state to a final state. State values are stored in MPTs:

  1. Each account has a Storage Trie containing its storage values.
  2. The World State Trie contains all account information, including each account's storage root hash (linking storage tries to the world trie).

Hashing the root node of the world state trie generates a unique identifier for a particular Ethereum state, known as the "state hash".

There are two kinds of MPT proofs:

  1. Inclusion proofs: Prove that key: value is a valid entry in the MPT with root hash h.
  2. Exclusion proofs: Prove that key does not exist in the MPT with root hash h. These proofs allow verifying that a value is included (or its key doesn't exist) in a specific state.

Prelude 2: deposits, withdrawals and state diffs

These three components are specific additions for ethrex's L2 protocol, layered on top of standard Ethereum execution logic. They each require specific validation steps within the program.

For more details, refer to Overview, Withdrawals, and State diffs.

Step 1: initial state validation

The program validates the ProverDB by iterating over each provided state value (stored in hash maps) and verifying its MPT proof against the initial state hash (obtained from the first block's parent block header input). This is the role of the verify_db() function (to link the values with the proofs). We could instead directly decode the data from the MPT proofs on each EVM read/write, although this would incur performance costs.

Having the initial state proofs (paths from the root to each relevant leaf) is equivalent to having a relevant subset of the world state trie and storage tries - a set of "pruned tries". This allows operating directly on these pruned tries (adding, removing, modifying values) during execution.

Step 2: blocks execution

After validating the initial state, the program executes the blocks. This leverages the existing ethrex execution logic used by the L2 client itself.

Step 3: final state validation

During execution, state values are updated (modified, created, or removed). After execution, the program calculates the final state by applying these state updates to the initial pruned tries.

Applying the updates results in a new world state root node for the pruned tries. Hashing this node yields the calculated final state hash. The program then verifies that this calculated hash matches the expected final state hash (from the last block header), thus validating the final state.

As mentioned earlier, removing values can sometimes require information not present in the initial witness to correctly restructure the pruned tries. The Execution witness section details this problem and its solution.

Step 4: deposit hash calculation

After execution and final state validation, the program calculates a hash encompassing all deposits made within the blocks (extracting deposit info from PrivilegedL2Transaction type transactions). This hash is committed as a public input, required for verification on the L1 bridge contract.

Step 5: withdrawals Merkle root calculation

Similarly, the program constructs a binary Merkle tree of all withdrawals initiated in the blocks and calculates its root hash. This hash is also committed as a public input. Later, L1 accounts can claim their withdrawals by providing a Merkle proof of inclusion that validates against this root hash on the L1 bridge contract.

Step 6: state diff calculation and commitment

Finally, the program calculates the state diffs (changes between initial and final state) intended for publication to L1 as blob data. It creates a commitment to this data (a Merkle root hash), which is committed as a public input. Using proof of equivalence logic within the L1 bridge contract, this Merkle commitment can be verified against the KZG commitment of the corresponding blob data.

Running Ethrex in Aligned Mode

This document explains how to run an Ethrex L2 node in Aligned mode and highlights the key differences in component behavior compared to the default mode.

How to Run

important

For this guide we assumed that there is an L1 running with all Aligned environment set.

1. Generate the SP1 ELF Program and Verification Key

Run:

cd ethrex/crates/l2
SP1_PROVER=cuda make build-prover PROVER=sp1 PROVER_CLIENT_ALIGNED=true

This will generate the SP1 ELF program and verification key under:

  • crates/l2/prover/zkvm/interface/sp1/out/riscv32im-succinct-zkvm-elf
  • crates/l2/prover/zkvm/interface/sp1/out/riscv32im-succinct-zkvm-vk

2. Deploying L1 Contracts

In a console with ethrex/crates/l2 as the current directory, run the following command:

COMPILE_CONTRACTS=true \ 
cargo run --release --bin ethrex_l2_l1_deployer --manifest-path contracts/Cargo.toml -- \
	--eth-rpc-url <L1_RPC_URL> \
	--private-key <L1_PRIVATE_KEY> \
	--genesis-l1-path <GENESIS_L1_PATH> \
	--genesis-l2-path <GENESIS_L2_PATH> \
	--contracts-path contracts \
	--sp1.verifier-address 0x00000000000000000000000000000000000000aa \
	--risc0.verifier-address 0x00000000000000000000000000000000000000aa \
	--tdx.verifier-address 0x00000000000000000000000000000000000000aa \
    --aligned.aggregator-address <ALIGNED_PROOF_AGGREGATOR_SERVICE_ADDRESS> \
    --bridge-owner <ADDRESS> \
    --on-chain-proposer-owner <ADDRESS> \
    --private-keys-file-path <PRIVATE_KEYS_FILE_PATH> \
    --sequencer-registry-owner <ADDRESS> \
    --sp1-vk-path <SP1_VERIFICATION_KEY_PATH>

note

This command requires the COMPILE_CONTRACTS env variable to be set, as the deployer needs the SDK to embed the proxy bytecode.
In this step we are initiallizing the OnChainProposer contract with the ALIGNED_PROOF_AGGREGATOR_SERVICE_ADDRESS and skipping the rest of verifiers.
Save the addresses of the deployed proxy contracts, as you will need them to run the L2 node.

3. Deposit funds to the AlignedBatcherPaymentService contract from the proof sender

aligned \
--network <NETWORK> \
--private_key <PROOF_SENDER_PRIVATE_KEY> \
--amount <DEPOSIT_AMOUNT>

important

Using the Aligned CLI

4. Running a node

In a console with ethrex/crates/l2 as the current directory, run the following command:

cargo run --release --manifest-path ../../Cargo.toml --bin ethrex --features "l2" -- \
	l2 \
	--watcher.block-delay <WATCHER_BLOCK_DELAY> \
	--network <L2_GENESIS_FILE_PATH> \
	--http.port <L2_PORT> \
	--http.addr <L2_RPC_ADDRESS> \
	--evm levm \
	--datadir <ethrex_L2_DEV_LIBMDBX> \
	--l1.bridge-address <BRIDGE_ADDRESS> \
	--l1.on-chain-proposer-address <ON_CHAIN_PROPOSER_ADDRESS> \
	--eth.rpc-url <L1_RPC_URL> \
	--block-producer.coinbase-address <BLOCK_PRODUCER_COINBASE_ADDRESS> \
	--committer.l1-private-key <COMMITTER_PRIVATE_KEY> \
	--proof-coordinator.l1-private-key <PROOF_COORDINATOR_PRIVATE_KEY> \
	--proof-coordinator.addr <PROOF_COORDINATOR_ADDRESS> \
	--aligned \
    --aligned-verifier-interval-ms <ETHREX_ALIGNED_VERIFIER_INTERVAL_MS> \
    --beacon_url <ETHREX_ALIGNED_BEACON_CLIENT_URL> \ 
    --aligned-network <ETHREX_ALIGNED_NETWORK> \
    --fee-estimate <ETHREX_ALIGNED_FEE_ESTIMATE> \
    --aligned-sp1-elf-path <ETHREX_ALIGNED_SP1_ELF_PATH>

Aligned params explanation:

  • --aligned: Enables aligned mode, enforcing all required parameters.
  • ETHREX_ALIGNED_VERIFIER_INTERVAL_MS: Interval in millisecs, that the proof_verifier will sleep between each proof aggregation check.
  • ETHREX_ALIGNED_BEACON_CLIENT_URL: URL of the beacon client used by the Aligned SDK to verify proof aggregations.
  • ETHREX_ALIGNED_SP1_ELF_PATH: Path to the SP1 ELF program. This is the same file used for SP1 verification outside of Aligned mode.
  • ETHREX_ALIGNED_NETWORK and ETHREX_ALIGNED_FEE_ESTIMATE: Parameters used by the Aligned SDK.

4. Running the Prover

In a console with ethrex/crates/l2 as the current directory, run the following command:

SP1_PROVER=cuda make init-prover PROVER=sp1 PROVER_CLIENT_ALIGNED=true

How to Run Using an Aligned Dev Environment

important

This guide asumes you have already generated the SP1 ELF Program and Verification Key. See: Generate the SP1 ELF Program and Verification Key

Set Up the Aligned Environment

  1. Clone the Aligned repository and checkout the currently supported release:
git clone git@github.com:yetanotherco/aligned_layer.git
cd aligned_layer
git checkout tags/v0.16.1
  1. Edit the aligned_layer/network_params.rs file to send some funds to the committer and integration_test addresses:
prefunded_accounts: '{
    "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266": { "balance": "100000000000000ETH" },
    "0x70997970C51812dc3A010C7d01b50e0d17dc79C8": { "balance": "100000000000000ETH" },
    
    ...
    "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720": { "balance": "100000000000000ETH" },
+   "0x4417092B70a3E5f10Dc504d0947DD256B965fc62": { "balance": "100000000000000ETH" },
+   "0x3d1e15a1a55578f7c920884a9943b3b35d0d885b": { "balance": "100000000000000ETH" },
     }'

You can also decrease the seconds per slot in aligned_layer/network_params.rs:

# Number of seconds per slot on the Beacon chain
  seconds_per_slot: 4
  1. Make sure you have the latest version of kurtosis installed and start the ethereum-package:
cd aligned_layer
make ethereum_package_start

To stop it run make ethereum_package_rm

  1. Start the batcher:

First, increase the max_proof_size in aligned_layer/config-files/config-batcher-ethereum-package.yaml max_proof_size: 41943040 for example.

cd aligned_layer
make batcher_start_ethereum_package

This is the Aligned component that receives the proofs before sending them in a batch.

warning

If you see the following error in the batcher: [ERROR aligned_batcher] Unexpected error: Space limit exceeded: Message too long: 16940713 > 16777216 modify the file aligned_layer/batcher/aligned-batcher/src/lib.rs at line 433 with the following code:

use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;

let mut stream_config = WebSocketConfig::default();
stream_config.max_frame_size = None;

let ws_stream_future =
    tokio_tungstenite::accept_async_with_config(raw_stream, Some(stream_config));

Initialize L2 node

  1. In another terminal, let's deploy the L1 contracts specifying the AlignedProofAggregatorService contract address:
cd ethrex/crates/l2
COMPILE_CONTRACTS=true \ 
cargo run --release --bin ethrex_l2_l1_deployer --manifest-path contracts/Cargo.toml -- \
	--eth-rpc-url http://localhost:8545 \
	--private-key 0x385c546456b6a603a1cfcaa9ec9494ba4832da08dd6bcf4de9a71e4a01b74924 \
	--contracts-path contracts \
	--risc0.verifier-address 0x00000000000000000000000000000000000000aa \
	--sp1.verifier-address 0x00000000000000000000000000000000000000aa \
	--tdx.verifier-address 0x00000000000000000000000000000000000000aa \
	--aligned.aggregator-address 0xFD471836031dc5108809D173A067e8486B9047A3 \
	--on-chain-proposer-owner 0x4417092b70a3e5f10dc504d0947dd256b965fc62 \
	--bridge-owner 0x4417092b70a3e5f10dc504d0947dd256b965fc62 \
	--deposit-rich \
	--private-keys-file-path ../../fixtures/keys/private_keys_l1.txt \
	--genesis-l1-path ../../fixtures/genesis/l1-dev.json \
	--genesis-l2-path ../../fixtures/genesis/l2.json

note

This command requires the COMPILE_CONTRACTS env variable to be set, as the deployer needs the SDK to embed the proxy bytecode.

You will see that some deposits fail with the following error:

2025-06-18T19:19:24.066126Z  WARN ethrex_l2_l1_deployer: Failed to make deposits: Deployer EthClient error: eth_estimateGas request error: execution reverted: CommonBridge: amount to deposit is zero: CommonBridge: amount to deposit is zero

This is because not all the accounts are pre-funded from the genesis.

  1. Send some funds to the Aligned batcher payment service contract from the proof sender:
cd aligned_layer/batcher/aligned
cargo run deposit-to-batcher \
--network devnet \
--private_key 0x39725efee3fb28614de3bacaffe4cc4bd8c436257e2c8bb887c4b5c4be45e76d \
--amount 1ether
  1. Start our l2 node:
cd ethrex/crates/l2
cargo run --release --manifest-path ../../Cargo.toml --bin ethrex --features "l2" -- l2 --watcher.block-delay 0 --network ../../fixtures/genesis/l2.json --http.port 1729 --http.addr 0.0.0.0 --evm levm --datadir dev_ethrex_l2 --l1.bridge-address <BRIDGE_ADDRESS> --l1.on-chain-proposer-address <ON_CHAIN_PROPOSER_ADDRESS> --eth.rpc-url http://localhost:8545 --block-producer.coinbase-address 0x0007a881CD95B1484fca47615B64803dad620C8d --committer.l1-private-key 0x385c546456b6a603a1cfcaa9ec9494ba4832da08dd6bcf4de9a71e4a01b74924 --proof-coordinator.l1-private-key 0x39725efee3fb28614de3bacaffe4cc4bd8c436257e2c8bb887c4b5c4be45e76d --proof-coordinator.addr 127.0.0.1 --aligned --aligned.beacon-url http://127.0.0.1:58801 --aligned-network devnet --aligned-sp1-elf-path prover/zkvm/interface/sp1/out/riscv32im-succinct-zkvm-elf

important

Set BRIDGE_ADDRESS and ON_CHAIN_PROPOSER_ADDRESS with the values printed in step 1.

Suggestion: When running the integration test, consider increasing the --committer.commit-time to 2 minutes. This helps avoid having to aggregate the proofs twice. You can do this by adding the following flag to the init-l2-no-metrics target:

--committer.commit-time 120000
  1. Start prover:
cd ethrex/crates/l2
SP1_PROVER=cuda make init-prover PROVER=sp1 PROVER_CLIENT_ALIGNED=true

Aggregate proofs:

After some time, you will see that the l1_proof_verifier is waiting for Aligned to aggregate the proofs:

2025-06-18T22:03:53.470356Z  INFO ethrex_l2::sequencer::l1_proof_verifier: Batch 1 has not yet been aggregated by Aligned. Waiting for 5 seconds

You can aggregate them by running:

cd aligned_layer
make start_proof_aggregator AGGREGATOR=sp1

If successful, the l1_proof_verifier will print the following logs:

INFO ethrex_l2::sequencer::l1_proof_verifier: Proof for batch 1 aggregated by Aligned with commitment 0xa9a0da5a70098b00f97d96cee43867c7aa8f5812ca5388da7378454580af2fb7 and Merkle root 0xa9a0da5a70098b00f97d96cee43867c7aa8f5812ca5388da7378454580af2fb7
INFO ethrex_l2::sequencer::l1_proof_verifier: Batches verified in OnChainProposer, with transaction hash 0x731d27d81b2e0f1bfc0f124fb2dd3f1a67110b7b69473cacb6a61dea95e63321

Behavioral Differences in Aligned Mode

Prover

  • Generates Compressed proofs instead of Groth16.
  • Required because Aligned currently only accepts SP1 compressed proofs.

Proof Sender

  • Sends proofs to the Aligned Batcher instead of the OnChainProposer contract.
  • Tracks the last proof sent using the rollup store.

Proof Sender Aligned Mode

Proof Verifier

  • Spawned only in Aligned mode.
  • Monitors whether the next proof has been aggregated by Aligned.
  • Once verified, collects all already aggregated proofs and triggers the advancement of the OnChainProposer contract by sending a single transaction.

Aligned Mode Proof Verifier

OnChainProposer

  • Uses verifyBatchesAligned() instead of verifyBatch().
  • Receives an array of proofs to verify.
  • Delegates proof verification to the AlignedProofAggregatorService contract.

TDX execution module

This document has documentation related to proving ethrex blocks using TDX.

Usage

note

  • Running the following without an L2 running will continuously throw the error: Error sending quote: Failed to get ProverSetupAck: Connection refused (os error 111). If you want to run this in a proper setup go to the Running section.
  • The quote generator runs in a QEMU, to quit it press CTRL+A X.

On a machine with TDX support with the required setup go to quote-gen and run

make run

What is TDX?

TDX is an Intel technology implementing a Trusted Execution Environment. Such an environment allows verifying certain code was executed without being tampered with or observed.

These verifications (attestations) are known as "quotes" and contain signatures verifying the attestation was generated by a genuine processor, the measurements at the time, and a user-provided piece of data binding the proof.

The measurements are saved to four Run Time Measurement Registers (RTMR), with each RTMR respresenting a boot stage. This is analogous to how PCRs work.

Usage considerations

Do not hardcode quote verification parameters as they might change.

It's easy to silently overlook non-verified areas such as accidentally leaving login enabled, or not verifying the integrity of the state.

Boot sequence

  • Firmware (OVMF here) is loaded (and hashed into RTMR[0])
  • UKI is loaded (and hashed into a RTMR)
  • kernel and initrd are extracted from the UKI and executed
  • root partition is verified using the roothash= value provided on the kernel cmdline and the hash partition with the dm-verity merkle tree
  • root partition is mounted read-only
  • (WIP) systemd executes the payload

Image build components

For reproducibility of images and hypervisor runtime we use Nix.

hypervisor.nix

This builds the modified (with patches for TDX support) qemu, and TDX-specific VBIOS (OVMF) and exports a script to run a given image (the parameters, specifically added devices, affect the measurements).

service.nix

This contains the quote-gen service. It's hash changes every time a non-gitignored file changes.

image.nix

Exports an image that uses UKI and dm-verity to generate an image where changing any component changes the hash of the bootloader (the UKI image), which is measured by the BIOS.

Running

You can enable the prover by setting ETHREX_DEPLOYER_TDX_DEPLOY_VERIFIER=true.

For development purposes, you can use the flag ETHREX_TDX_DEV_MODE=true to disable quote verification. This allows you to run the quote generator even without having TDX-capable hardware.

Ensure the proof coordinator is reachable at 172.17.0.1. You can bring up the network by first starting the L2 components:

// cd crates/l2
make init ETHREX_DEPLOYER_TDX_DEPLOY_VERIFIER=true PROOF_COORDINATOR_ADDRESS=0.0.0.0

And in another terminal, running the VM:

// cd crates/l2
make -C tee/quote-gen run

Troubleshooting

unshare: write failed /proc/self/uid_map: Operation not permitted

If you get this error when building the image, it's probably because your OS has unprivileged userns restricted by default. You can undo this by running the following commands as root, or running the build as root while disabling sandboxing.

sysctl kernel.unprivileged_userns_apparmor_policy=0
sysctl kernel.apparmor_restrict_unprivileged_userns=0

RTMR/MRTD mismatch

If any code or dependencies changed, the measurements will change.

To obtain the new measurements, first you obtain the quote by running the prover (you don't need to have the l2 running). It's output will contain Sending quote <very long hex string>.

This usually causes a RTMR1 mismatch. The easiest way to obtain the new RTMR values is by looking at the printed quote for the next 96 bytes after the RTMR0, corresponding to RTMR1||RTMR2 (48 bytes each).

More generally, you can generate a report with DCAP.verifyAndAttestOnChain(quote) which validates and extracts the report.

Look at bytes 341..485 of the output for RTMRs and bytes 149..197 for the MRTD.

For example, the file quote.example contains a quote, which can be turned into the following report:

00048100000000b0c06f000000060103000000000000000000000000005b38e33a6487958b72c3c12a938eaa5e3fd4510c51aeeab58c7d5ecee41d7c436489d6c8e4f92f160b7cad34207b00c100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000e702060000000000

91eb2b44d141d4ece09f0c75c2c53d247a3c68edd7fafe8a3520c942a604a407de03ae6dc5f87f27428b2538873118b7 # MRTD

000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

4f3d617a1c89bd9a89ea146c15b04383b7db7318f41a851802bba8eace5a6cf71050e65f65fd50176e4f006764a42643 # RTMR0
53827a034d1e4c7f13fd2a12aee4497e7097f15a04794553e12fe73e2ffb8bd57585e771951115a13ec4d7e6bc193038 # RTMR1
2ca1a728ff13c36195ad95e8f725bf00d7f9c5d6ed730fb8f50cccad692ab81aefc83d594819375649be934022573528 # RTMR2
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 # RTMR3

39618efd10b14136ab416d6acfff8e36b23533a90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

State diffs

This architecture was inspired by MatterLabs' ZKsync pubdata architecture.

To provide data availability for our blockchain, we need to publish enough information on every commit transaction to be able to reconstruct the entire state of the L2 from the beginning by querying the L1.

The data needed is:

  • The nonce and balance of every EOA.
  • The nonce, balance, and storage of every contract account. Note that storage here is a mapping (U256 → U256), so there are a lot of values inside it.
  • The bytecode of every contract deployed on the chain.
  • All withdrawal Logs.

After executing a batch of L2 blocks, the EVM will return the following data:

  • A list of every storage slot modified in the batch, with their previous and next values. A storage slot is a mapping (address, slot) -> value. Note that, in a batch, there could be repeated writes to the same slot. In that case, we keep only the latest write; all the others are discarded since they are not needed for state reconstruction.
  • The bytecode of every newly deployed contract. Every contract deployed is then a pair (address, bytecode).
  • A list of withdrawal logs (as explained in milestone 1 we already collect these and publish a merkle root of their values as calldata, but we still need to send them as the state diff).
  • A list of triples (address, nonce_increase, balance) for every modified account. The nonce_increase is a value that says by how much the nonce of the account was increased in the batch (this could be more than one as there can be multiple transactions for the account in the batch). The balance is just the new balance value for the account.

The full state diff sent for each batch will then be a sequence of bytes encoded as follows. We use the notation un for a sequence of n bits, so u16 is a 16-bit sequence and u96 a 96-bit one, we don't really care about signedness here; if we don't specify it, the value is of variable length and a field before it specifies it.

  • The first byte is a u8: the version header. For now it should always be one, but we reserve it for future changes to the encoding/compression format.
  • Next come the block header info of the last block in the batch:
    • The tx_root, receipts_root and parent_hash are u256 values.
    • The gas_limit, gas_used, timestamp, block_number and base_fee_per_gas are u64 values.
  • Next the ModifiedAccounts list. The first two bytes (u16) are the amount of element it has, followed by its entries. Each entry correspond to an altered address and has the form:
    • The first byte is the type of the modification. The value is a u8, constrained to the range [1; 23], computed by adding the following values:
      • 1 if the balance of the EOA/contract was modified.
      • 2 if the nonce of the EOA/contract was modified.
      • 4 if the storage of the contract was modified.
      • 8 if the contract was created and the bytecode is previously unknown.
      • 16 if the contract was created and the bytecode is previously known.
    • The next 20 bytes, a u160, is the address of the modified account.
    • If the balance was modified (i.e. type & 0x01 == 1), the next 32 bytes, a u256, is the new balance of the account.
    • If the nonce was modified (i.e. type & 0x02 == 2), the next 2 bytes, a u16, is the increase in the nonce.
    • If the storage was modified (i.e. type & 0x04 == 4), the next 2 bytes, a u16, is the number of storage slots modified. Then come the sequence of (key_u256, new_value_u256) key value pairs with the modified slots.
    • If the contract was created and the bytecode is previously unknown (i.e. type & 0x08 == 8), the next 2 bytes, a u16, is the length of the bytecode in bytes. Then come the bytecode itself.
    • If the contract was created and the bytecode is previously known (i.e. type & 0x10 == 16), the next 32 bytes, a u256, is the hash of the bytecode of the contract.
    • Note that values 8 and 16 are mutually exclusive, and if type is greater or equal to 4, then the address is a contract. Each address can only appear once in the list.
  • Next the WithdrawalLogs field:
    • First two bytes are the number of entries, then come the tuples (to_u160, amount_u256, tx_hash_u256).
  • Next the PrivilegedTransactionLogs field:
    • First two bytes are the number of entries, then come the tuples (to_u160, value_u256).
  • In case of the only changes on an account are produced by withdrawals, the ModifiedAccounts for that address field must be omitted. In this case, the state diff can be computed by incrementing the nonce in one unit and subtracting the amount from the balance.

To recap, using || for byte concatenation and [] for optional parameters, the full encoding for state diffs is:

version_header_u8 ||
// Last Block Header info
tx_root_u256 || receipts_root_u256 || parent_hash_u256 ||
gas_limit_u64 || gas_used_u64 || timestamp_u64 ||
block_number_u64 || base_fee_per_gas_u64
// Modified Accounts
number_of_modified_accounts_u16 ||
(
  type_u8 || address_u160 || [balance_u256] || [nonce_increase_u16] ||
  [number_of_modified_storage_slots_u16 || (key_u256 || value_u256)... ] ||
  [bytecode_len_u16 || bytecode ...] ||
  [code_hash_u256]
)...
// Withdraw Logs
number_of_withdraw_logs_u16 ||
(to_u160 || amount_u256 || tx_hash_u256) ...
// Privileged Transactions Logs
number_of_privileged_transaction_logs_u16 ||
(to_u160 || value_u256) ...

The sequencer will then make a commitment to this encoded state diff (explained in the EIP 4844 section how this is done) and send on the commit transaction:

  • Through calldata, the state diff commitment (which is part of the public input to the proof).
  • Through the blob, the encoded state diff.

note

As the blob is encoded as 4096 BLS12-381 field elements, every 32-bytes chunk cannot be greater than the subgroup r size: 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001. i.e., the most significant byte must be less than 0x73. To avoid conflicts, we insert a 0x00 byte before every 31-bytes chunk to ensure this condition is met.

Deposits

This document contains a detailed explanation of how asset deposits work.

Native ETH deposits

This section explains step by step how native ETH deposits work.

On L1:

  1. The user sends ETH to the CommonBridge contract. Alternatively, they can also call deposit and specify the address to receive the deposit in (the l2Recipient).

  2. The bridge adds the deposit's hash to the pendingTxHashes. We explain how to compute this hash in "Generic L1->L2 messaging"

  3. The bridge emits a PrivilegedTxSent event:

    bytes memory callData = abi.encodeCall(ICommonBridgeL2.mintETH, (l2Recipient));
    
    emit PrivilegedTxSent(
        0xffff,         // sender in L2 (the L2 bridge)
        0xffff,         // to (the L2 bridge)
        transactionId,
        msg.value,      // value
        gasLimit,
        callData
    );
    

Off-chain:

  1. On each L2 node, the L1 watcher processes PrivilegedTxSent events, each adding a PrivilegedL2Transaction to the L2 mempool.
  2. The privileged transaction is an EIP-2718 typed transaction, somewhat similar to an EIP-1559 transaction, but with some changes. For this case, the important difference is that the sender of the transaction is set by our L1 bridge. This enables our L1 bridge to "forge" transactions from any sender, even arbitrary addresses like the L2 bridge.
  3. Privileged transactions sent by the L2 bridge don't deduct from the bridge's balance their value. In practice, this means ETH equal to the transactions value is minted.

On L2:

  1. The privileged transaction calls mintETH on the CommonBridgeL2 with the intended recipient as parameter.
  2. The bridge verifies the sender is itself, which can only happen for deposits sent through the L1 bridge.
  3. The bridge sends the minted ETH to the recipient. In case of failure, it initiates an ETH withdrawal for the same amount.

Back on L1:

  1. A sequencer commits a batch on L1 including the privileged transaction.
  2. The OnChainProposer asserts the included privileged transactions exist and are included in order.
  3. The OnChainProposer notifies the bridge of the consumed privileged transactions and they are removed from pendingTxHashes.
---
title: User makes an ETH deposit
---
sequenceDiagram
    box rgb(33,66,99) L1
        actor L1Alice as Alice
        participant CommonBridge
        participant OnChainProposer
    end

    actor Sequencer

    box rgb(139, 63, 63) L2
        actor CommonBridgeL2
        actor L2Alice as Alice
    end

    L1Alice->>CommonBridge: sends 42 ETH
    CommonBridge->>CommonBridge: pendingTxHashes.push(txHash)
    CommonBridge->>CommonBridge: emit PrivilegedTxSent

    CommonBridge-->>Sequencer: receives event
    Sequencer-->>CommonBridgeL2: mints 42 ETH and<br>starts processing tx
    CommonBridgeL2->>CommonBridgeL2: calls mintETH
    CommonBridgeL2->>L2Alice: sends 42 ETH

    Sequencer->>OnChainProposer: publishes batch
    OnChainProposer->>CommonBridge: consumes pending deposits
    CommonBridge-->>CommonBridge: pendingTxHashes.pop()

ERC20 deposits through the native bridge

This section explains step by step how native ERC20 deposits work.

On L1:

  1. The user gives the CommonBridge allowance via an approve call to the L1 token contract.

  2. The user calls depositERC20 on the bridge, specifying the L1 and L2 token addresses, the amount to deposit, along with the intended L2 recipient.

  3. The bridge locks the specified L1 token amount in the bridge, updating the mapping with the amount locked for the L1 and L2 token pair. This ensures that L2 token withdrawals don't consume L1 tokens that weren't deposited into that L2 token (see "Why store the provenance of bridged tokens?" for more information).

  4. The bridge emits a PrivilegedTxSent event:

    emit PrivilegedTxSent(
        0,            // amount (unused)
        0xffff,       // to (the L2 bridge)
        depositId,
        0xffff,       // sender in L2 (the L2 bridge)
        gasLimit,
        callData
    );
    

Off-chain:

  1. On each L2 node, the L1 watcher processes PrivilegedTxSent events, each adding a PrivilegedL2Transaction to the L2 mempool.
  2. The privileged transaction is an EIP-2718 typed transaction, somewhat similar to an EIP-1559 transaction, but with some changes. For this case, the important differences is that the sender of the transaction is set by our L1 bridge. This enables our L1 bridge to "forge" transactions from any sender, even arbitrary addresses like the L2 bridge.

On L2:

  1. The privileged transaction performs a call to mintERC20 on the CommonBridgeL2 from the L2 bridge's address, specifying the address of the L1 and L2 tokens, along with the amount and recipient.
  2. The bridge verifies the sender is itself, which can only happen for deposits sent through the L1 bridge.
  3. The bridge calls l1Address() on the L2 token, to verify it matches the received L1 token address.
  4. The bridge calls crosschainMint on the L2 token, minting the specified amount of tokens and sending them to the L2 recipient. In case of failure, it initiates an ERC20 withdrawal for the same amount.

Back on L1:

  1. A sequencer commits a batch on L1 including the privileged transaction.
  2. The OnChainProposer asserts the included privileged transactions exist and are included in order.
  3. The OnChainProposer notifies the bridge of the consumed privileged transactions and they are removed from pendingTxHashes.
---
title: User makes an ERC20 deposit
---
sequenceDiagram
    box rgb(33,66,99) L1
        actor L1Alice as Alice
        participant L1Token
        participant CommonBridge
        participant OnChainProposer
    end

    actor Sequencer

    box rgb(139, 63, 63) L2
        participant CommonBridgeL2
        participant L2Token
        actor L2Alice as Alice
    end

    L1Alice->>L1Token: approves token transfer
    L1Alice->>CommonBridge: calls depositERC20
    CommonBridge->>CommonBridge: pendingTxHashes.push(txHash)
    CommonBridge->>CommonBridge: emit PrivilegedTxSent

    CommonBridge-->>Sequencer: receives event
    Sequencer-->>CommonBridgeL2: starts processing tx
    CommonBridgeL2->>CommonBridgeL2: calls mintERC20

    CommonBridgeL2->>L2Token: calls l1Address
    L2Token->>CommonBridgeL2: returns address of L1Token

    CommonBridgeL2->>L2Token: calls crosschainMint
    L2Token-->>L2Alice: mints 42 tokens

    Sequencer->>OnChainProposer: publishes batch
    OnChainProposer->>CommonBridge: consumes pending deposits
    CommonBridge-->>CommonBridge: pendingTxHashes.pop()

Why store the provenance of bridged tokens?

As said before, storing the provenance of bridged tokens or, in other words, how many tokens were sent from each L1 token to each L2 token, ensures that L2 token withdrawals don't unlock L1 tokens that weren't deposited into another L2 token.

This can be better understood with an example:

---
title: Attacker exploits alternative bridge without token provenance
---
sequenceDiagram
    box rgb(33,66,99) L1
        actor L1Eve as Eve
        actor L1Alice as Alice
        participant CommonBridge
    end

    box rgb(139, 63, 63) L2
        participant CommonBridgeL2
        actor L2Alice as Alice
        actor L2Eve as Eve
    end

    Note over L1Eve,L2Eve: Alice does a normal deposit
    L1Alice ->> CommonBridge: Deposits 100 Foo tokens into FooL2
    CommonBridge -->> CommonBridgeL2: Notifies deposit
    CommonBridgeL2 ->> L2Alice: Sends 100 FooL2 tokens

    Note over L1Eve,L2Eve: Eve does a deposit to ensure the L2 token they control is registered with the bridge
    L1Eve ->> CommonBridge: Deposits 1 Foo token into Bar
    CommonBridge -->> CommonBridgeL2: Notifies deposit
    CommonBridgeL2 ->> L2Eve: Sends 1 Bar token

    Note over L1Eve,L2Eve: Eve does a malicious withdawal of Alice's funds
    L2Eve ->> CommonBridgeL2: Withdraws 101 Bar tokens into Foo
    CommonBridgeL2 -->> CommonBridge: Notifies withdrawal
    CommonBridge ->> L1Eve: Sends 101 Foo tokens

Generic L1->L2 messaging

Privileged transactions are signaled by the L1 bridge through PrivilegedTxSent events. These events are emitted by the CommonBridge contract on L1 and processed by the L1 watcher on each L2 node.

event PrivilegedTxSent (
    address indexed from,
    address indexed to,
    uint256 indexed transactionId,
    uint256 value,
    uint256 gasLimit,
    bytes data
);

As seen before, this same event is used for native deposits, but with the from artificially set to the L2 bridge address, which is also the to address.

For tracking purposes, we might want to know the hash of the L2 transaction. We can compute it as follows:

keccak256(
    bytes.concat(
        bytes20(from),
        bytes20(to),
        bytes32(transactionId),
        bytes32(value),
        bytes32(gasLimit),
        keccak256(data)
    )
)

Address Aliasing

To prevent attacks where a L1 impersonates an L2 contract, we implement Address Aliasing like Optimism (albeit with we a different constant, to prevent confusion).

The attack prevented would've looked like this:

  • An L2 contract gets deployed at address A
  • Someone malicious deploys a contract at the same address (through deterministic deployments, etc)
  • The malicious contract sends a privileged transaction, which can steal A's resourced on the L2

By modifying the address of L1 contracts by adding a constant, we prevent this attack since both won't have the same address.

Forced Inclusion

Each transaction is given a deadline for processing. If the sequencer is unwilling to include a privileged transaction before this timer expires, batches stop being processed and the chain halts until the sequencer processes every expired transaction.

After an extended downtime, the sequencer can catch up by sending batches made solely out of privileged transactions.

---
title: Sequencer goes offline
---
sequenceDiagram
    box rgb(33,66,99) L1
        actor L1Alice
        actor Sequencer
        participant CommonBridge
        participant OnChainProposer
    end

    L1Alice ->> CommonBridge: Sends a privileged transaction

    Note over Sequencer: Sequencer goes offline for a long time
    Sequencer ->> OnChainProposer: Sends batch as usual
    OnChainProposer ->> Sequencer: Error
    Note over Sequencer: Operator configures the sequencer to catch up
    Sequencer ->> OnChainProposer: Sends batch of only privileged transactions
    OnChainProposer ->> Sequencer: OK
    Sequencer ->> OnChainProposer: Sends batch with remaining expired privileged transactions, along with other transactions
    OnChainProposer ->> Sequencer: OK
    Note over Sequencer: Sequencer is now catched up
    Sequencer ->> OnChainProposer: Sends batch as usual
    OnChainProposer ->> Sequencer: OK

Limitations

Due to the gas cost of computing the rolling hashes, there is a limit to how many deposits can be handled in a single batch.

To prevent creation of invalid batches, we save to the rollup store information about the deposits being included in the current batch.

Withdrawals

This document contains a detailed explanation of how asset withdrawals work.

Native ETH withdrawals

This section explains step by step how native ETH withdrawals work.

On L2:

  1. The user sends a transaction calling withdraw(address _receiverOnL1) on the CommonBridgeL2 contract, along with the amount of ETH to be withdrawn.

  2. The bridge sends the withdrawn amount to the burn address.

  3. The bridge calls sendMessageToL1(bytes32 data) on the L2ToL1Messenger contract, with data being:

    bytes32 data = keccak256(abi.encodePacked(ETH_ADDRESS, ETH_ADDRESS, _receiverOnL1, msg.value))
    

    The ETH_ADDRESS is an arbitrary address we use, meaning the "token" to transfer is ETH.

  4. L2ToL1Messenger emits an L1Message event, with the address of the L2 bridge contract and data as topics, along with a unique message ID.

Off-chain:

  1. On each L2 node, the L1 watcher extracts L1Message events, generating a merkle tree with the hashed messages as leaves. The merkle tree format is explained in the "L1Message Merkle tree" section below.

On L1:

  1. A sequencer commits the batch on L1, publishing the merkle tree's root with publishWithdrawals on the L1 CommonBridge.
  2. The user submits a withdrawal proof when calling claimWithdrawal on the L1 CommonBridge. The proof can be obtained by calling ethrex_getWithdrawalProof in any L2 node, after the batch containing the withdrawal transaction was verified in the L1.
  3. The bridge asserts the proof is valid and wasn't previously claimed.
  4. The bridge sends the locked funds specified in the L1Message to the user.
---
title: User makes an ETH withdrawal
---
sequenceDiagram
    box rgb(139, 63, 63) L2
        actor L2Alice as Alice
        participant CommonBridgeL2
        participant L2ToL1Messenger
    end

    actor Sequencer

    box rgb(33,66,99) L1
        participant OnChainProposer
        participant CommonBridge
        actor L1Alice as Alice
    end

    L2Alice->>CommonBridgeL2: withdraws 42 ETH
    CommonBridgeL2->>CommonBridgeL2: burns 42 ETH
    CommonBridgeL2->>L2ToL1Messenger: calls sendMessageToL1
    L2ToL1Messenger->>L2ToL1Messenger: emits L1Message event

    L2ToL1Messenger-->>Sequencer: receives event

    Sequencer->>OnChainProposer: publishes batch
    OnChainProposer->>CommonBridge: publishes L1 message root

    L1Alice->>CommonBridge: submits withdrawal proof
    CommonBridge-->>CommonBridge: asserts proof is valid
    CommonBridge->>L1Alice: sends 42 ETH

ERC20 withdrawals through the native bridge

This section explains step by step how native ERC20 withdrawals work.

On L2:

  1. The user calls approve on the L2 tokens to allow the bridge to transfer the asset.

  2. The user sends a transaction calling withdrawERC20(address _token, address _receiverOnL1, uint256 _value) on the CommonBridgeL2 contract.

  3. The bridge calls crosschainBurn on the L2 token, burning the amount to be withdrawn by the user.

  4. The bridge fetches the address of the L1 token by calling l1Address() on the L2 token contract.

  5. The bridge calls sendMessageToL1(bytes32 data) on the L2ToL1Messenger contract, with data being:

    bytes32 data = keccak256(abi.encodePacked(_token.l1Address(), _token, _receiverOnL1, _value))
    
  6. L2ToL1Messenger emits an L1Message event, with the address of the L2 bridge contract and data as topics, along with a unique message ID.

Off-chain:

  1. On each L2 node, the L1 watcher extracts L1Message events, generating a merkle tree with the hashed messages as leaves. The merkle tree format is explained in the "L1Message Merkle tree" section below.

On L1:

  1. A sequencer commits the batch on L1, publishing the L1Message with publishWithdrawals on the L1 CommonBridge.
  2. The user submits a withdrawal proof when calling claimWithdrawalERC20 on the L1 CommonBridge. The proof can be obtained by calling ethrex_getWithdrawalProof in any L2 node, after the batch containing the withdrawal transaction was verified in the L1.
  3. The bridge asserts the proof is valid and wasn't previously claimed, and that the locked tokens mapping contains enough balance for the L1 and L2 token pair to cover the transfer.
  4. The bridge transfers the locked tokens specified in the L1Message to the user and discounts the transferred amount from the L1 and L2 token pair in the mapping.
---
title: User makes an ERC20 withdrawal
---
sequenceDiagram
    box rgb(139, 63, 63) L2
        actor L2Alice as Alice
        participant L2Token
        participant CommonBridgeL2
        participant L2ToL1Messenger
    end

    actor Sequencer

    box rgb(33,66,99) L1
        participant OnChainProposer
        participant CommonBridge
        participant L1Token
        actor L1Alice as Alice
    end

    L2Alice->>L2Token: approves token transfer
    L2Alice->>CommonBridgeL2: withdraws 42 of L2Token
    CommonBridgeL2->>L2Token: burns the 42 tokens
    CommonBridgeL2->>L2ToL1Messenger: calls sendMessageToL1
    L2ToL1Messenger->>L2ToL1Messenger: emits L1Message event

    L2ToL1Messenger-->>Sequencer: receives event

    Sequencer->>OnChainProposer: publishes batch
    OnChainProposer->>CommonBridge: publishes L1 message root

    L1Alice->>CommonBridge: submits withdrawal proof
    CommonBridge->>L1Token: transfers tokens
    L1Token-->>L1Alice: sends 42 tokens

Generic L2->L1 messaging

First, we need to understand the generic mechanism behind it:

L1Message

To allow generic L2->L1 messages, a system contract is added which allows sending arbitrary data. This data is emitted as L1Message events, which nodes automatically extract from blocks.

#![allow(unused)]
fn main() {
struct L1Message {
    tx_hash: H256,    // L2 transaction where it was included
    from: Address,    // Who sent the message in L2
    data_hash: H256,  // Hashed payload
    message_id: U256, // Unique message ID
}
}

L1Message Merkle tree

When sequencers commit a new batch, they include the merkle root of all the L1Messages inside the batch. That way, L1 contracts can verify some data was sent from a specific L2 sender.

---
title: L1Message Merkle tree
---
flowchart TD
    
    Msg2[L1Message<sub>2</sub>]
    Root([Root])
    Node1([Node<sub>1</sub>])
    Node2([Node<sub>2</sub>])

    Root --- Node1
    Root --- Node2

    subgraph Msg1["L1Message<sub>1</sub>"]
        direction LR

        txHash1["txHash<sub>1</sub>"]
        from1["from<sub>1</sub>"]
        dataHash1["hash(data<sub>1</sub>)"]
        messageId1["messageId<sub>1</sub>"]

        txHash1 --- from1
        from1 --- dataHash1
        dataHash1 --- messageId1
    end

    Node1 --- Msg1
    Node2 --- Msg2

As shown in the diagram, the leaves of the tree are the hash of each encoded L1Message. Messages are encoded by packing, in order:

  • the transaction hash that generated it in the L2
  • the address of the L2 sender
  • the hashed data attached to the message
  • the unique message ID

Bridging

On the L2 side, for the case of asset bridging, a contract burns some assets. It then sends a message to the L1 containing the details of this operation:

  • From: L2 token address that was burnt
  • To: L1 token address that will be withdrawn
  • Destination: L1 address that can claim the deposit
  • Amount: how much was burnt

When the batch is committed on the L1, the OnChainProposer notifies the bridge which saves the message tree root. Once the batch containing this transaction is verified, the user can claim their funds on the L1. To do this, they compute a merkle proof for the included batch and call the L1 CommonBridge contract.

This contract then:

  • Checks that the batch is verified
  • Ensures the withdrawal wasn't already claimed
  • Computes the expected leaf
  • Validates that the proof leads from the leaf to the root of the message tree
  • Gives the funds to the user
  • Marks the withdrawal as claimed

Ethrex L2 contracts

There are two L1 contracts: OnChainProposer and CommonBridge. Both contracts are deployed using UUPS proxies, so they are upgradeables.

L1 side

CommonBridge

Allows L1<->L2 communication from L1. It both sends messages from L1 to L2 and receives messages from L2.

Deposit Functions

Simple Deposits
  • Send ETH directly to the contract address using a standard transfer
  • The contract's receive() function automatically forwards funds to your identical address on L2
  • No additional parameters needed
Deposits with Contract Interaction
function deposit(DepositValues calldata depositValues) public payable

Parameters:

  • to: Target address on L2
  • recipient: Address that will receive the ETH on L2 (can differ from sender)
  • gasLimit: Maximum gas for L2 execution
  • data: Calldata to execute on the target L2 contract

This method enables atomic operations like:

  • Depositing ETH while simultaneously interacting with L2 contracts
  • Funding another user's L2 account

OnChainOperator

Ensures the advancement of the L2. It is used by the operator to commit batches of blocks and verify batch proofs.

Verifier

TODO

L2 side

L1MessageSender

TODO

Upgrade the contracts

To upgrade a contract, you have to create the new contract and, as the original one, inherit from OpenZeppelin's UUPSUpgradeable. Make sure to implement the _authorizeUpgrade function and follow the proxy pattern restrictions.

Once you have the new contract, you need to do the following three steps:

  1. Deploy the new contract

    rex deploy <NEW_IMPLEMENTATION_BYTECODE> 0 <DEPLOYER_PRIVATE_KEY>
    
  2. Upgrade the proxy by calling the method upgradeToAndCall(address newImplementation, bytes memory data). The data parameter is the calldata to call on the new implementation as an initialization, you can pass an empty stream.

    rex send <PROXY_ADDRESS> 'upgradeToAndCall(address,bytes)' <NEW_IMPLEMENTATION_ADDRESS> <INITIALIZATION_CALLDATA> --private-key <PRIVATE_KEY>
    
  3. Check the proxy updated the pointed address to the new implementation. It should return the address of the new implementation:

    curl http://localhost:8545 -d '{"jsonrpc": "2.0", "id": "1", "method": "eth_getStorageAt", "params": [<PROXY_ADDRESS>, "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", "latest"]}'
    

Transfer ownership

The contracts are Ownable2Step, that means that whenever you want to transfer the ownership, the new owner have to accept it to effectively apply the change. This is an extra step of security, to avoid accidentally transfer ownership to a wrong account. You can make the transfer in these steps:

  1. Start the transfer:

    rex send <PROXY_ADDRESS> 'transferOwnership(address)' <NEW_OWNER_ADDRESS> --private-key <CURRENT_OWNER_PRIVATE_KEY>
    
  2. Accept the ownership:

    rex send <PROXY_ADDRESS> 'acceptOwnership()' --private-key <NEW_OWNER_PRIVATE_KEY>
    

[DRAFT] Ethrex roadmap for becoming based

Special thanks to Lorenzo and Kubi, George, and Louis from Gattaca, Jason from Fabric, and Matthew from Spire Labs for their feedback and suggestions.

note

This document is still under development, and everything stated in it is subject to change after feedback and iteration. Feedback is more than welcome.

important

We believe that Gattaca's model—permissionless with preconfs using L1 proposers (either directly or through delegations) as L2 sequencers—is the ideal approach. However, this model cannot achieve permissionlessness until the deterministic lookahead becomes available after Fusaka. In the meantime, we consider the Spire approach, based on a Dutch auction, to be the most suitable for our current needs. It is important to note that Rogue cannot implement a centralized mechanism for offering preconfs, so we have chosen to prioritize a permissionless structure before enabling preconfirmations. This initial approach is decentralized and permissionless but not based yet. Although sequencing rights aren't currently guaranteed to the L1 proposer, there will be incentives for L1 proposers to eventually participate in the L2, moving toward Justin Drake's definition.

From the beginning, ethrex was conceived not just as an Ethereum L1 client, but also as an L2 (ZK Rollup). This means anyone will be able to use ethrex to deploy an EVM-equivalent, multi-prover (supporting SP1, RISC Zero, and TEEs) based rollup with just one command. We recently wrote a blog post where we expand this idea more in depth.

The purpose of this document is to provide a high-level overview of how ethrex will implement its based rollup feature.

State of the art

Members of the Ethereum Foundation are actively discussing and proposing EIPs to integrate based sequencing into the Ethereum network. Efforts are also underway to coordinate and standardize the components required for these based rollups; one such initiative is FABRIC.

The following table provides a high-level comparison of different based sequencing approaches, setting the stage for our own proposal.

note

This table compares the different based rollups in the ecosystem based on their current development state, not their final form.

Based RollupProtocolSequencer ElectionProof SystemPreconfsAdditional Context
Taiko Alethia (Taiko Labs)PermissionedFixed Deterministic LookaheadMulti-proof (sgxGeth (TEE), and sgxReth (ZK/TEE))Yes-
Gattaca's Based OP (Gattaca + Lambdaclass)PermissionedRound RobinSingle Proof (optimistic)YesFor phase 1, the Sequencer/Gateway was centralized. For phase 2 (current phase) the Sequencer/Gateway is permissioned.
R1PermissionlessTotal AnarchyMulti-proof (ZK, TEE, Guardian)NoR1 is yet to be specified but plans are for it to be built on top of Surge and Taiko's Stack. They're waiting until Taiko is mature enough to have preconfs
Surge (Nethermind)PermissionlessTotal AnarchyMulti-proof (ZK, TEE, Guardian)NoSurge is built on top of Taiko Alethia but it's tuned enough to be a Stage 2 rollup. Surge is not designed to compete with existing rollups for users or market share. Instead, it serves as a technical showcase, experimentation platform, and reference implementation.
Spire (Spire Labs)PermissionlessDutch AuctionSingle Proof (optimistic)Yes-
Rogue (LambdaClass)PermissionlessDutch AuctionMulti-Proof (ZK + TEE)Not YetWe are prioritizing decentralization and permissionlessness at the expense of preconfirmations until the deterministic lookahead is available after Fusaka

Other based rollups not mentioned will be added later.

Ethrex proposal for based sequencing

According to Justin Drake's definition of "based", being "based" implies that the L1 proposers are the ones who, at the end of the day, sequence the L2, either directly or by delegating the responsibility to a third party.

However, today, the "based" ecosystem is very immature. Despite the constant efforts of various teams, no stack is fully prepared to meet this definition. Additionally, L1 proposers do not have sufficient economic incentives to be part of the protocol.

But there's a way out. As mentioned in Spire's "What is a based rollup?"

The key to this definition is that sequencing is "driven" by a base layer and not controlled by a completely external party.

Following this, our proposal's main focus is decentralization and low operation cost, and we don't want to sacrifice them in favor of preconfirmations or composability.

Considering this, after researching existing approaches, we concluded that a decentralized, permissionless ticket auction is the most practical first step for ethrex's based sequencing solution.

Ultimately, we aim to align with Gattaca's model for based sequencing and collaborate with FABRIC efforts to standardize based rollups and helping interoperability.

Rogue and many upcoming rollups will be following this approach.

Benefits of our approach

The key benefits of our approach to based sequencing are:

  • Decentralization and Permissionlessness from the Get-Go: We've decentralized ethrex L2 by allowing anyone to participate in the L2 block proposal; actors willing to participate on it can do this permissionlessly, as the execution ticket auction approach we are taking provides a governance free leader election mechanism.
  • Robust Censorship Resistance: By being decentralized and permissionless, and with the addition of Sequencer challenges, we increased the cost of censorship in the protocol.
  • Low Operational Cost: We strived to make the sequencer operating costs as low as possible by extending the sequencing window, allowing infrequent L1 finalization for low traffic periods.
  • Configurability: We intentionally designed our protocol to be configurable at its core. This allows different rollup setups to be tailored based on their unique needs, ensuring optimal performance, efficiency, and UX.

Key points

Terminology

  • Ticket: non-transferable right of a Sequencer to build and commit an L2 batch. One or more are auctioned during each auction period.
  • Sequencing Period: the period during which a ticket holder has sequencing rights.
  • Auction Period: the period during which the auction is performed.
  • Auction Challenge: instance within a sequencing period where lead Sequencer sequencing rights can be challenged.
  • Challenge Period: the period during which a lead sequencer can be challenged.
  • Allocated Period: the set of contiguous sequencing periods allocated among the winners of the corresponding auctioning period -during an auctioning period, multiple sequencing periods are auctioned, the set of these is the allocated period.
  • L2 batch: A collection of L2 blocks submitted to L1 in a single transaction.
  • Block/Batch Soft-commit Message: A signed P2P message from the Lead Sequencer publishing a new block or sealed batch.
  • Commit Transaction: An L1 transaction submitted by the Lead Sequencer to commit to an L2 batch execution. It is also called Batch Commitment.
  • Sequencer: An L2 node registered in the designated L1 contract.
  • Lead Sequencer: The Sequencer currently authorized to build L2 blocks and post L2 batches during a specific L1 block.
  • Follower: Non-Lead Sequencer nodes, which may be Sequencers awaiting leadership or passive nodes.

How it will work

As outlined earlier, sequencing rights for future blocks are allocated through periodic ticket auctions. To participate, sequencers must register and provide collateral. Each auction occurs during a designated auction period, which spans a defined range of L1 blocks. These auctions are held a certain number of blocks in advance of the allocated period.

During each auction period, a configurable number of tickets are auctioned off. Each ticket grants its holder the right to sequence transactions during one sequencing period within the allocated period. However, at the time of the auction, the specific sequencing period assigned to each ticket remains undetermined. Once the auction period ends, the sequencing periods are randomly assigned (shuffled) among the ticket holders, thereby determining which sequencing period each ticket corresponds to.

Parameters like the amount of tickets auctioned (i.e. amount of sequencing periods per allocated period), the duration of the auction periods, the duration of the sequencing periods, and more, are configurable. This configurability is not merely a feature but a deliberate and essential design choice. The complete list of all configurable parameters can be found under the “Protocol details” section.

Diagram showing leader election process

  1. Sequencers individually opt in before auction period n ends, providing collateral via an L1 contract. This registration is a one-time process per Sequencer.
  2. During the auction, registered Sequencers bid for sequencing rights for a yet-to-be-revealed sequencing period within the allocated period.
  3. At the auction's conclusion, sequencing rights for the sequencing periods within the allocated period are assigned among the ticket holders.
  4. Finally, Sequencers submit L2 batch transactions to L1 during their assigned sequencing period (note: this step does not immediately follow step 3, as additional auctions and sequencing might occur in-between).

In each sequencing period, the Lead Sequencer is initially determined through a bidding process. However, this position can be contested by other Sequencers who are willing to pay a higher price than the winning bid. The number of times such challenges can occur within a single sequencing period is configurable, allowing for control over the stability of the leadership. Should a challenge succeed, the challenging Sequencer takes over as the Lead Sequencer for the remainder of the period, and the original Lead Sequencer is refunded a portion of their bid corresponding to the time left in the period. For example, if a challenge is successful at the midpoint of the sequencing period, the original Lead Sequencer would be refunded half of their bid.

The following example assumes a sequencing period of 1 day, 1 auction challenge per hour with challenge periods of 1 hour.

Diagram showing how challenges work

  1. Auction winner (Sequencer green) starts as the lead Sequencer of the sequencing period.
  2. No one can challenge the lead in the first hour.
  3. During the second hour, the first auction challenge starts, and multiple Sequencers bid to challenge the lead. Finally, the lead Sequencer is overthrown and the new lead (Sequencer blue) starts sequencing.
  4. In the third hour a new auction challenge opens and the former lead Sequencer takes back the lead.
  5. Until the last hour of the sequencing period, the same cycle repeats having many leader changes.

To ensure L2 liveness in this decentralized protocol, Sequencers must participate in a peer-to-peer (P2P) network. The diagram below illustrates this process:

Diagram showing the end-to-end flow of a transaction in the ethrex L2 P2P layer

  1. A User: sends a transaction to the network.
  2. Any node: Gossips in the P2P a received transaction. So every transaction lives in a public distributed mempool
  3. The Lead Sequencer: Produces an L2 block including that transaction.
  4. The Lead Sequencer: Broadcasts the L2 block, including the transaction, to the network via P2P.
  5. Any node: Executes the block, gossips it, and keeps its state up to date.
  6. The Lead Sequencer: Seals the batch in L2.
  7. The Lead Sequencer: Posts the batch to the L1 in a single transaction.
  8. The Lead Sequencer: Broadcasts the "batch sealed" message to the network via P2P.
  9. Any node: Seals the batch locally and gossips the message.
  10. A User: Receives a non-null receipt for the transaction.

Protocol details

Additional Terminology

  • Next Batch: The L2 batch being built by the lead Sequencer.
  • Up-to-date Nodes: Nodes that have the last committed batch in their storage and only miss the next batch.
  • Following: We say that up-to-date nodes are following the lead Sequencer.
  • Syncing: Nodes are syncing if they are not up-to-date. They’ll stop syncing after they reach the following state.
  • Verify Transaction: An L1 transaction submitted by anyone to verify a ZK proof to an L2 batch execution.

Network participants

  • Sequencer Nodes: Nodes that have opted in to serve as Sequencers.
  • Follower Nodes: State or RPC Nodes.
  • Prover Nodes:

By default, every ethrex L2 node begins as a Follower Node. A process will periodically query the L1 smart contract registry for the Lead Sequencer's address and update each node's state accordingly.

Network parameters

A list of all the configurable parameters of the network.

  • Sequencing period duration
  • Auction period duration
  • Number of sequencing periods in an allocated period
  • Time between auction and allocated period
  • L2 block time
  • Minimum collateral in ETH for Sequencers registration
  • Withdrawal delay for Sequencers that quit the protocol
  • Initial ticket auction price multiplier
  • Batch verification time limit
  • Amount of auction challenges within a sequencing period
  • Challenge period duration
  • Time between auction challenges
  • Challenge price multiplier

Lead Sequencer election

  • Aspiring Lead Sequencers must secure sequencing rights through a Dutch auction in advance, enabling them to post L2 batches to L1.
  • Sequencing rights are tied to tickets: one ticket grants the right to sequence and post batches during a specific sequencing period.
  • For each sequencing period within an allocated period, sequencing rights are randomly assigned from the pool of ticket holders.
  • Each auction period determines tickets for the nth epoch ahead (configurable).
  • Once Ethereum incorporates deterministic lookahead (e.g., EIP-7917), the Lead Sequencer for a given L1 slot will be the current proposer, provided they hold a ticket.

Auction challenges

  • During a sequencing period, other Sequencers can pay a higher price than the winning bid to challenge the Lead Sequencer.
  • This can only happen a configurable number of times per sequencing period.
  • After a successful challenge, the current Lead Sequencer is replaced by the challenging sequencer for the rest of the Sequencing Period and is refunded the portion of its bid corresponding to the remaining sequencing period (e.g. half of its bid if it loses half of its sequencing period).

Sequencers registry

  • L1 contract that manages Sequencer registration and ticket auctions for sequencing rights.
  • Sequencers can register permissionlessly by providing a minimum collateral in ETH.
  • Sequencers may opt out of an allocated period by not purchasing tickets for that period.
  • Sequencers can unregister and withdraw their collateral after a delay.

Lead Sequencers role

  • Build L2 blocks and post L2 batches to the L1 within the sequencing period.
  • Broadcast to the network:
    • Transactions.
    • Sequenced blocks as they are built.
    • Batch seal messages to prompt the network to seal the batch locally.
  • Serve state.

Follower nodes role

  • Broadcast to the network:
    • Transactions.
    • Sequenced blocks.
    • Batch seal messages.
  • Store incoming blocks sequentially.
  • Seal batches upon receiving batch seal messages (after storing all batch blocks).
  • Serve state.
  • Monitor the L1 contract for batch updates and reorgs.

Prover nodes role

  • For this stage, it is the Sequencers' responsibility to prove their own batches.
  • The prover receives the proof generation inputs of a batch from another node and returns a proof.

Batch commitment/proposal

tip

To enrich the understanding of this part, we suggest reading ethrex L2 High-Level docs as this only details the diff with what we already have.

  • Only lead Sequencer can post batches.
  • Lead Sequencer batches are accepted during their sequencing period and rejected outside this period.
  • Batch commitment now includes posting the list of blocks in the batch to the L1 for data availability.

Batch verification

tip

To enrich the understanding of this part, we suggest reading ethrex L2 High-Level docs as this only details the diff with what we already have.

  • Anyone can verify batches.
  • Only one valid verification is required to advance the network.
  • Valid proofs include the blocks of the batch being verified.
  • In this initial version, the lead Sequencer is penalized if they fail to correctly verify the batches they post.

P2P

  • Ethrex's L1 P2P network will be used to gossip transactions and for out-of-date nodes to sync.
  • A new capability will be added for gossipping L2 blocks and batch seal messages (NewBlock and BatchSealed).
  • The NewBlock message includes an RLP-encoded list of transactions in the block, along with metadata for re-execution and validation. It is signed, and receivers must verify the signature (additional data may be required in practice).
  • The SealedBatch message specifies the batch number and the number of blocks it contains (additional data may be needed in practice).
  • Follower Nodes must validate all messages. They add NewBlocks to storage sequentially and seal the batch when the SealedBatch message arrives. If a node's current block is n and it receives block n + 2, it queues n + 2, waits for n + 1, adds it, then processes n + 2. Similarly, a SealedBatch message includes block numbers, and the node delays sealing until all listed blocks are stored.

Syncing

Nodes that join a live network will need to sync up to the latest state.

For this we'll divide nodes into two different states:

  • Following nodes: These will keep up-to-date via the based P2P.
  • Syncing nodes: These will sync via 2 different mechanisms:
    • P2P Syncing: This is the same as full-sync and snap-sync on L1, but with some changes.
    • L1 Syncing: Also used by provers to download batches from the L1.
    • In practice, these methods will compete to sync the node.

Downsides

Below we list some of the risks and known issues we are aware of that this protocol introduces. Some of them were highlighted thanks to the feedback of different teams that took the time to review our first draft.

  • Inconsistent UX: If a Sequencer fails to include its batch submit transaction in the L1, the blocks it contains will simply be reorged out once the first batch of the next sequencer is published. Honest sequencers can avoid this by not building new batches some slots before their turn ends. The next Sequencer can, in turn, start building their first batch earlier to avoid dead times. This is similar to Taiko’s permissioned network, where sequencers coordinate to stop proposing 4 slots before their turn ends to avoid reorgs.
  • Batch Stealing: Lead Sequencers that fail to publish their batches before their sequencing period ends might have their batches "stolen" by the next Lead Sequencer, which can republish those batches as their own. We can mitigate in the same way as the last point.
  • Long Finalization Times: Since publishing batches to L1 is infrequent, users might experience long finalization times during low traffic periods. We can solve this by assuming a transaction in an L2 block transmitted through P2P will eventually be published to L1, and punishing Sequencers that don't include some of their blocks in a batch.
  • Temporary Network Blinding: A dishonest Sequencer may blind the network if they don't gossip blocks nor publish the batches to the L1 as part of the commit transactions' calldata. While the first case alone is mitigated through an L1 syncing mechanism, if the necessary data to sync is not available we can't rely on it. In this case, the prover ensures this doesn't happen by requiring the batch as a public input to the proof verification. That way, the bad batch can't be verified, and will be reverted.
  • High-Fee Transactions Hoarding: A dishonest Sequencer might not share high-fee transactions with the Lead Sequencer with the hope of processing them once it's their turn to be Lead Sequencer. This is a non-issue, since transaction senders can simply propagate their transaction themselves, either by sending it to multiple RPC providers, or to their own node.
  • Front-running and Sandwiching Attacks: Lead Sequencers have the right to reorder transactions as they like and we expect they'll use this to extract MEV, including front-running and sandwiching attacks, which impact user experience. We don't have plans to address this at the protocol level, but we expect solutions to appear at the application level, same as in L1.
  • No Sequencers Scenario: If a sequencing period has no elected Lead Sequencer, we establish Full Anarchy during that period, so anyone can advance the chain. This is a last resort, and we don't expect this happening in practice.

Conclusion

To preserve decentralization and permissionlessness, we chose ticket auctions for leader election, at the expense of preconfirmations and composability.

As mentioned at the beginning, this approach does not fully align with Justin Drake's definition of "based" rollups but is "based enough" to serve as a starting point. Although the current design cannot guarantee that sequencing rights are assigned exclusively to the L1 proposer for each slot, we're interested in achieving this, and will do so once the conditions are met, namely, that L1 proposer lookahead is available.

So what about "based" Ethrex tomorrow? Eventually, there will be enough incentives for L1 proposers to either run their own L2 Sequencers or delegate their L1 rights to an external one. At that stage, the auction and assignment of L2 sequencing rights will be linked to the current L1 proposer or their delegated Sequencer. Periods may also adjust as lookahead tables, such as the Deterministic Lookahead Proposal or RAID, become viable.

This proposal is intentionally minimalistic and adaptable for future refinements. How this will change and adapt to future necessities is something we don't know right now, and we don't care about it until those necessities arrive; this is Lambda's engineering philosophy.

Further considerations

The following are things we are looking to tackle in the future, but which are not blockers for our current work.

  • Ticket Pricing Strategies.
  • Delegation Processes.
  • Preconfirmations.
  • Bonding.
  • L1 Reorgs Handling.

References and acknowledgements

The following links, repos, and projects have been important in the development of this document, we have learned a lot from them and want to thank and acknowledge them.

Context

Intro to based rollups

Based rollups benefits

Based rollups + extra steps

Misc

Execution tickets

Current based rollups

Educational sources

ethrex L2 Sequencer

important

This documentation is about the current state of the based feature development and not about the final implementation. It is subject to change as the feature evolves and their still could be unmitigated issues.

note

This is an extension of the ethrex-L2-Sequencer documentation and is intended to be merged with it in the future.

Components

In addition to the components outlined in the ethrex-L2-Sequencer documentation, the based feature introduces new components to enable decentralized L2 sequencing. These additions enhance the system's ability to operate across multiple nodes, ensuring resilience, scalability, and state consistency.

Sequencer State

note

While not a traditional component, the Sequencer State is a fundamental element of the based feature and deserves its own dedicated section.

The based feature decentralizes L2 sequencing, moving away from a single, centralized Sequencer to a model where multiple nodes can participate, with only one acting as the lead Sequencer at any time. This shift requires nodes to adapt their behavior depending on their role, leading to the introduction of the Sequencer State. The Sequencer State defines two possible modes:

  • Sequencing: The node is the lead Sequencer, responsible for proposing and committing new blocks to the L2 chain.
  • Following: The node is not the lead Sequencer and must synchronize with and follow the blocks proposed by the current lead Sequencer.

To keep the system simple and avoid intricate inter-process communication, the Sequencer State is implemented as a global state, accessible to all Sequencer components. This design allows each component to check the state and adjust its operations accordingly. The State Updater component manages this global state.

State Updater

The State Updater is a new component tasked with maintaining and updating the Sequencer State. It interacts with the Sequencer Registry contract on L1 to determine the current lead Sequencer and adjusts the node’s state based on this information and local conditions. Its responsibilities include:

  • Periodic Monitoring: The State Updater runs at regular intervals, querying the SequencerRegistry contract to identify the current lead Sequencer.
  • State Transitions: It manages transitions between Sequencing and Following states based on these rules:
    • If the node is designated as the lead Sequencer, it enters the Sequencing state.
    • If the node is not the lead Sequencer, it enters the Following state.
    • When a node ceases to be the lead Sequencer, it transitions to Following and reverts any uncommitted state to ensure consistency with the network.
    • When a node becomes the lead Sequencer, it transitions to Sequencing only if it is fully synced (i.e., has processed all blocks up to the last committed batch). If not, it remains in Following until it catches up.

This component ensures that the node’s behavior aligns with its role, preventing conflicts and maintaining the integrity of the L2 state across the network.

Block Fetcher

Decentralization poses a risk: a lead Sequencer could advance the L2 chain without sharing blocks, potentially isolating other nodes. To address this, the OnChainProposer contract (see ethrex-L2-Contracts documentation) has been updated to include an RLP-encoded list of blocks committed in each batch. This makes block data publicly available on L1, enabling nodes to reconstruct the L2 state if needed.

The Block Fetcher is a new component designed to retrieve these blocks from L1 when the node is in the Following state. Its responsibilities include:

  • Querying L1: It queries the OnChainProposer contract to identify the last committed batch.
  • Scouting Transactions: Similar to how the L1 Watcher monitors deposit transactions, the Block Fetcher scans L1 for commit transactions containing the RLP-encoded block list.
  • State Reconstruction: It uses the retrieved blocks to rebuild the L2 state, ensuring the node remains synchronized with the network.

note

Currently, the Block Fetcher is the primary mechanism for nodes to sync with the lead Sequencer. Future enhancements will introduce P2P gossiping to enable direct block sharing between nodes, improving efficiency.

ethrex L2 Contracts

important

This documentation is about the current state of the based feature development and not about the final implementation. It is subject to change as the feature evolves and their still could be unmitigated issues.

note

This is an extension of the ethrex-L2-Contracts documentation and is intended to be merged with it in the future.

L1 Side

In addition to the components described in the ethrex-L2-Contracts documentation, the based feature introduces new contracts and modifies existing ones to enhance decentralization, security, and transparency. Below are the key updates and additions:

OnChainProposer (Modified)

The OnChainProposer contract, which handles batch proposals and management on L1, has been updated with the following modifications:

  • New Constant: A public constant SEQUENCER_REGISTRY has been added. This constant holds the address of the SequencerRegistry contract, linking the two contracts for sequencer management.
  • Modifier Update: The onlySequencer modifier has been renamed to onlyLeadSequencer. It now checks whether the caller is the current lead Sequencer, as determined by the SequencerRegistry contract. This ensures that only the designated leader can commit batches.
  • Initialization: The initialize method now accepts the address of the SequencerRegistry contract as a parameter. During initialization, this address is set to the SEQUENCER_REGISTRY constant, establishing the connection between the contracts.
  • Batch Commitment: The commitBatch method has been revised to improve data availability and streamline sequencer validation:
    • It now requires an RLP-encoded list of blocks included in the batch. This list is published on L1 to ensure transparency and enable verification.
    • The list of sequencers has been removed from the method parameters. Instead, the SequencerRegistry contract is now responsible for tracking and validating sequencers.
  • Event Modification: The BatchCommitted event has been updated to include the batch number of the committed batch. This addition enhances traceability and allows external systems to monitor batch progression more effectively.
  • Batch Verification: The verifyBatch method has been made more flexible and decentralized:
    • The onlySequencer modifier has been removed, allowing anyone—not just the lead Sequencer—to verify batches.
    • The restriction preventing multiple verifications of the same batch has been lifted. While multiple verifications are now permitted, only one valid verification is required to advance the L2 state. This change improves resilience and reduces dependency on a single actor.

SequencerRegistry (New Contract)

The SequencerRegistry is a new contract designed to manage the pool of Sequencers and oversee the leader election process in a decentralized manner.

  • Registration:

    • Anyone can register as a Sequencer by calling the register method and depositing a minimum collateral of 1 ETH. This collateral serves as a Sybil resistance mechanism, ensuring that only committed participants join the network.
    • Sequencers can exit the registry by calling the unregister method, which refunds their 1 ETH collateral upon successful deregistration.
  • Leader Election: The leader election process operates on a round-robin basis to fairly distribute the lead Sequencer role:

    • Single Sequencer Case: If only one Sequencer is registered, it remains the lead Sequencer indefinitely.
    • Multiple Sequencers: When two or more Sequencers are registered, the lead Sequencer rotates every 32 batches. This ensures that no single Sequencer dominates the network for an extended period.
  • Future Leader Prediction: The futureLeaderSequencer method allows querying the lead Sequencer for a batch n batches in the future. The calculation is based on the following logic:

    Inputs:

    • sequencers: An array of registered Sequencer addresses.
    • currentBatch: The next batch to be committed, calculated as lastCommittedBatch() + 1 from the OnChainProposer contract.
    • nBatchesInTheFuture: A parameter specifying how many batches ahead to look.
    • targetBatch: Calculated as currentBatch + nBatchesInTheFuture.
    • BATCHES_PER_SEQUENCER: A constant set to 32, representing the number of batches each lead Sequencer gets to commit.

    Logic:

    uint256 _currentBatch = IOnChainProposer(ON_CHAIN_PROPOSER).lastCommittedBatch() + 1;
    uint256 _targetBatch = _currentBatch + nBatchesInTheFuture;
    uint256 _id = _targetBatch / BATCHES_PER_SEQUENCER;
    address _leader = sequencers[_id % sequencers.length];
    

    Example: Assume 3 Sequencers are registered: [S0, S1, S2], and the current committed batch is 0:

    • For batches 0–31: _id = 0 / 32 = 0, 0 % 3 = 0, lead Sequencer = S0.
    • For batches 32–63: _id = 32 / 32 = 1, 1 % 3 = 1, lead Sequencer = S1.
    • For batches 64–95: _id = 64 / 32 = 2, 2 % 3 = 2, lead Sequencer = S2.
    • For batches 96–127: _id = 96 / 32 = 3, 3 % 3 = 0, lead Sequencer = S0.

    This round-robin rotation repeats every 96 committed batches (32 committed batches per Sequencer × 3 Sequencers), ensuring equitable distribution of responsibilities.

Developer docs

Welcome to the ethrex developer docs!

This section contains documentation on the internals of the project.

To get started first, read the developer installation guide to learn about ethrex and its features. Then you can look into the L1 developer docs or the L2 developer docs

Setting up a development environment for ethrex

Prerequisites

Cloning the repo

The full code of ethrex is available at GitHub and can be cloned using git

git clone https://github.com/lambdaclass/ethrex && cd ethrex

Building the ethrex binary

Ethrex can be built using cargo

To build the client run

cargo build --release --bin ethrex

the following feature can be enable with --features <features>

FeatureDescription
defaultEnables "libmdbx", "c-kzg", "blst", "rollup_storage_sql", "dev", "metrics" features
debugEnables debug mode for LEVM
devMakes the --dev flag available
metricsEnables metrics gathering for use with a monitoring stack
c-kzgEnables the c-kzg crate instead of kzg-rs
blstEnables the blst crate
libmdbxEnables libmdbx as the database for the ethereum state
redbEnables redb as the database for the ethereum state
rollup_storage_libmdbxEnables libmdbx as the database for the L2 batch data
rollup_storage_redbEnables redb as the database for the L2 batch data
rollup_storage_sqlEnables sql as the database for the L2 batch data
sp1Enables the sp1 backend for the L2 prover
risc0Enables the risc0 backend for the L2 prover
gpuEnables CUDA support for the zk backends risc0 and sp1

Bolded are features enabled by default

Additionally the environment variable COMPILE_CONTRACTS can be set to true to enable embedding the solidity contracts used by the rollup, into the binary to enable the L2 dev mode.

Building the docker image

The Dockerfile is located at the root of the repository and can be built by running

docker build -t ethrex .

The BUILD_FLAGS argument can be used to pass flags to cargo, for example

docker build -t ethrex --build-arg BUILD_FLAGS="--features <features>" .

L1 Developer Docs

Welcome to the ethrex L1 developer documentation!

This section provides information about the internals of the L1 side of the project.

Table of contents

Ethrex as a local development node

Prerequisites

This guide assumes you've read the dev installation guide

Dev mode

In dev mode ethrex acts as a local Ethereum development node it can be run with the following command

ethrex --dev

Then you can use a tool like rex to make sure that the network is advancing

rex block-number

Rich account private keys are listed at the folder fixtures/keys/private_keys_l1.txt located at the root of the repo. You can then use these keys to deploy contracts and send transactions in the localnet.

Importing blocks

The simplest task a node can do is import blocks offline. We would do so like this:

Prerequisites

This guide assumes you've read the dev installation guide

Import blocks

# Execute the import
# Notice that the .rlp file is stored with Git LFS, it needs to be downloaded before importing
ethrex --network fixtures/genesis/perf-ci.json import  fixtures/blockchain/l2-1k-erc20.rlp
  • The network argument is common to all ethrex commands. It specifies the genesis file, or a public network like holesky. This is the starting state of the blockchain.
  • The import command means that this node will not start rpc endpoints or peer to peer communication. It will just read a file, parse the blocks, execute them, and save the EVM state (accounts info and storage) after each execution.
  • The file is an RLP encoded file with a list of blocks.

Block execution

The CLI import subcommand executes cmd/ethrex/cli.rs:import_blocks, which can be summarized as:

#![allow(unused)]
fn main() {
let store = init_store(&data_dir, network).await;
let blockchain = init_blockchain(evm, store.clone());
for block in parse(rlp_file) {
    blockchain.add_block(block)
}
}

The blockchain struct is our main point of interaction with our data. It contains references to key structures like our store (key-value db) and the EVM engine (knows how to execute transactions).

Adding a block is performed in crates/blockchain/blockchain.rs:add_block, and performs several tasks:

  1. Block execution (execute_block).
    1. Pre-validation. Checks that the block parent is present, that the base fee matches the parent's expectations, timestamps, header number, transaction root and withdrawals root.
    2. VM execution. The block contains all the transactions, which is all needed to perform a state transition. The VM has a reference to the store, so it can get the current state to apply transactions on top of it.
    3. Post execution validations: gas used, receipts root, requets hash.
    4. The VM execution does not mutate the store itself. It returns a list of all changes that happened in execution so they can be applied in any custom way.
  2. Post-state storage (store_block)
    1. apply_account_updates gets the pre-state from the store, applies the updates to get an updated post-transition-state, calculates the root and commits the new state to disk.
    2. The state root is a merkle root, a cryptographic summary of a state. The one we just calculated is compared with the one in the block header. If it matches, it proves that your node's post-state is the same as the one the block producer reached after executing that same block.
    3. The block and the receipts are saved to disk.

States

In ethereum the first state is determined by the genesis file. After that, each block represents a state transition. To be formal about it, if we have a state and a block , we can define as the application of a state transition function.

This means that a blockchain, internally, looks like this.

flowchart LR
    Sg["Sg (genesis)"]
    S1a["S1"]
    S2a["S2"]
    S3a["S3"]

    Sg -- "f(Sg, B1)" --> S1a
    S1a -- "f(S1, B2)" --> S2a
    S2a -- "f(S2, B3)" --> S3a

We start from a genesis state, and each time we add a block we generate a new state. We don't only save the current state (), we save all of them in the DB after execution. This seems wasteful, but the reason will become more obvious very soon. This means that we can get the state for any block number. We say that if we get the state for block number one, we actually are getting the state right after applying B1.

Due to the highly available nature of ethereum, sometimes multiple different blocks can be proposed for a single state. This creates what we call "soft forks".

flowchart LR
    Sg["Sg (genesis)"]
    S1a["S1"]
    S2a["S2"]
    S3a["S3"]
    S1b["S1'"]
    S2b["S2'"]
    S3b["S3'"]

    Sg -- "f(Sg, B1)" --> S1a
    S1a -- "f(S1, B2)" --> S2a
    S2a -- "f(S2, B3)" --> S3a

    Sg -- "f(Sg, B1')" --> S1b
    S1b -- "f(S1', B2')" --> S2b
    S2b -- "f(S2', B3')" --> S3b

This means that for a single block number we actually have different post-states, depending on which block we executed. In turn, this means that using a block number is not a reliable way of getting a state. To fix this, what we do is calculate the hash of a block, which is unique, and use that as an identifier for both the block and its corresponding block state. In that way, if I request the DB the state for hash(B1) it understands that I'm looking for S1, whereas if I request the DB the state for hash(B1') I'm looking for S1'.

How we determine which is the right fork is called Fork choice, which is not done by the execution client, but by the consensus client. What concerns to us is that if we currently think we are on S3 and the consensus client notifies us that actually S3' is the current fork, we need to change our current state to that one. That means that we need to save every post-state in case we need to change forks. This changing of the nodes perception of the correct soft fork to a different one is called reorg.

VM - State interaction

As mentioned in the previous point, the VM execution doesn't directly mutate the store. It just calculates all necessary updates. There's an important clarification we need to go through about the starting point for that calculation.

This is a key piece of code in Blockchain.execute_block:

#![allow(unused)]
fn main() {
let vm_db = StoreVmDatabase::new(self.storage.clone(), block.header.parent_hash);
let mut vm = Evm::new(self.evm_engine, vm_db);
let execution_result = vm.execute_block(block)?;
let account_updates = vm.get_state_transitions()?;
}

The VM is a transient object. It is created with an engine/backend (LEVM or REVM) and a db reference. It is discarded after executing each block.

The StoreVmDatabase is just an implementation of the VmDatabase trait, using our Store (reference to a key-value store). It's an adapter between the store and the vm and allows the VM to not depend on a concrete DB.

The main piece of context a VM DB needs to be created is the parent_hash, which is the hash of the parent's block. As we mentioned previously, this hash uniquely identifies an ethereum state, so we are basically telling the VM what it's pre-state is. If we give it that, plus the block, the VM can execute the state-transition function previously mentioned.

The VmDatabase context just requires the implementation of the following methods:

#![allow(unused)]
fn main() {
fn get_account_info(&self, address: Address) -> Result<Option<AccountInfo>, EvmError>;
fn get_storage_slot(&self, address: Address, key: H256) -> Result<Option<U256>, EvmError>;
fn get_block_hash(&self, block_number: u64) -> Result<H256, EvmError>;
fn get_chain_config(&self) -> Result<ChainConfig, EvmError>;
fn get_account_code(&self, code_hash: H256) -> Result<Bytes, EvmError>;
}

That is, it needs to know how to get information about accounts, about storage, get a block hash according to a specific number, get the config, and the account code for a specific hash.

Internally, the StoreVmDatabase implementation just calls the db for this. For example:

#![allow(unused)]
fn main() {
fn get_account_info(&self, address: Address) -> Result<Option<AccountInfo>, EvmError> {
    self.store
        .get_account_info_by_hash(self.block_hash, address)
        .map_err(|e| EvmError::DB(e.to_string()))
}
}

You may note that the get_account_info_by_hash receives not only the address, but also the block hash. That is because it doesn't get the account state for the "current" state, it gets it for the post-state of the parent block. That is, the pre-state for the state transition. And this makes sense: we don't want to apply a transaction anywhere, we want to apply it precisely on top of the parent's state, so that's where we'll be getting all of our state.

What is state anyway

The ethereum state is, logically, two things: accounts and their storage slots. If we were to represent them in memory, they would be something like:

#![allow(unused)]
fn main() {
pub struct VmState {
    accounts: HashMap<H256, Option<AccountState>>,
    storage: HashMap<H256, HashMap<H256, Option<U256>>>,
}
}

The accounts are indexed by the hash of their address. The storage has a two level lookup: an index by account address hash, and then an index by hashed slot. The reasons why we use hashes of the addresses and slots instead of using them directly is an implementation detail.

This flat key-value representation is what we usually call a snapshot. To write and get state, it would be enough and efficient to have a table in the db with some snapshot in the past and then the differences in each account and storage each block. This are precisely the account updates, and this is precisely what we do in our snapshots implementation.

However, we also need to be able to efficiently summarize a state, which is done using a structure called the Merkle Patricia Trie (MPT). This is a big topic, not covered by this document. A link to an in-detail document will be added soon. The most important part of it is that it's a merkle tree and we can calculate it's root/hash to summarize a whole state. When a node proposes a block, the root of the post-state is included as metadata in the header. That means that after executing a block, we can calculate the root of the resulting post-state MPT and compare it with the metadata. If it matches, we have a cryptographic proof that both nodes arrived at the same conclusion.

This means that we will need to maintain both a snapshot (for efficient reads) and a trie (for efficient summaries) for every state in the blockchain. Here's an interesting blogpost by the go ethereum (geth) team explaning this need in detail: https://blog.ethereum.org/2020/07/17/ask-about-geth-snapshot-acceleration

TODO

Imports

  • Add references to our code for MPT and snapshots.
  • What account updates are. What does it mean to apply them.

Live node block execution

  • Engine api endpoints (fork choice updated with no attrs, new payload).
  • applying fork choice and reorg.
  • JSON RPC endpoints to get state.

Block building

  • Mempool and P2P.
  • Fork choice updated with attributes and get_payload.
  • Payload building.

Syncing on node startup

  • Discovery.
  • Getting blocks and headers via p2p.
  • Snap sync.

Quick Start (L1 localnet)

This page will show you how to quickly spin up a local development network with ethrex.

Prerequisites

Starting a local devnet

make localnet

This make target will:

  1. Build our node inside a docker image.
  2. Fetch our fork ethereum package, a private testnet on which multiple ethereum clients can interact.
  3. Start the localnet with kurtosis.

If everything went well, you should be faced with our client's logs (ctrl-c to leave).

Stopping a local devnet

To stop everything, simply run:

make stop-localnet

Metrics

Ethereum Metrics Exporter

We use the Ethereum Metrics Exporter, a Prometheus metrics exporter for Ethereum execution and consensus nodes, to gather metrics during syncing for L1. The exporter uses the prometheus data source to create a Grafana dashboard and display the metrics. For the syncing to work there must be a consensus node running along with the execution node.

Currently we have two make targets to easily start an execution node and a consensus node on either hoodi or holesky, and display the syncing metrics. In both cases we use a lighthouse consensus node.

Quickstart guide

Make sure you have your docker daemon running.

  • Code Location: The targets are defined in tooling/sync/Makefile.

  • How to Run:

    # Navigate to tooling/sync directory
    cd tooling/sync
    
    # Run target for hoodi
    make start-hoodi-metrics-docker
    
     # Run target for holesky
    make start-holesky-metrics-docker
    

To see the dashboards go to http://localhost:3001. Use “admin” for user and password. Select the Dashboards menu and go to Ethereum Metrics Exporter (Single) to see the exported metrics.

To see the prometheus exported metrics and its respective requests with more detail in case you need to debug go to http://localhost:9093/metrics.

Running the execution node on other networks with metrics enabled

A docker-compose is used to bundle prometheus and grafana services, the *overrides files define the ports and mounts the prometheus' configuration file. If a new dashboard is designed, it can be mounted only in that *overrides file. A consensus node must be running for the syncing to work.

To run the execution node on any network with metrics, the next steps should be followed:

  1. Build the ethrex binary for the network you want (see node options in CLI Commands) with the metrics feature enabled.

  2. Enable metrics by using the --metrics flag when starting the node.

  3. Set the --metrics.port cli arg of the ethrex binary to match the port defined in metrics/provisioning/prometheus/prometheus_l1_sync_docker.yaml

  4. Run the docker containers:

    cd metrics
    
    docker compose -f docker-compose-metrics.yaml -f docker-compose-metrics-l1.overrides.yaml up
    

For more details on running a sync go to tooling/sync/readme.md.

Testing

The ethrex project runs several suites of tests to ensure proper protocol implementation

Table of contents

Ethereum foundation tests

These are the official execution spec tests there two kinds state tests and blockchain tests, you can execute them with:

State tests

The state tests are individual transactions not related one to each other that test particular behavior of the EVM. Tests are usually run for multiple forks and the result of execution may vary between forks. See docs.

To run the test first:

cd cmd/ef_tests/state

then download the test vectors:

make download-evm-ef-tests

then run the tests:

make run-evm-ef-tests

Blockchain tests

The blockchain tests test block validation and the consensus rules of the Ethereum blockchain. Tests are usually run for multiple forks. See docs.

To run the tests first:

cd cmd/ef_tests/blockchain

then run the tests:

make test-levm

Hive tests

End-to-End tests with hive. Hive is a system which simply sends RPC commands to our node, and expects a certain response. You can read more about it here.

Prereqs

We need to have go installed for the first time we run hive, an easy way to do this is adding the asdf go plugin:

asdf plugin add golang https://github.com/asdf-community/asdf-golang.git

# If you need to set GOROOT please follow: https://github.com/asdf-community/asdf-golang?tab=readme-ov-file#goroot

And uncommenting the golang line in the asdf .tool-versions file:

rust 1.87.0
golang 1.23.2

Running Simulations

Hive tests are categorized by "simulations', and test instances can be filtered with a regex:

make run-hive-debug SIMULATION=<simulation> TEST_PATTERN=<test-regex>

This is an example of a Hive simulation called ethereum/rpc-compat, which will specificaly run chain id and transaction by hash rpc tests:

make run-hive SIMULATION=ethereum/rpc-compat TEST_PATTERN="/eth_chainId|eth_getTransactionByHash"

If you want debug output from hive, use the run-hive-debug instead:

make run-hive-debug SIMULATION=ethereum/rpc-compat TEST_PATTERN="*"

This example runs every test under rpc, with debug output

Assertoor tests

We run some assertoor checks on our CI, to execute them locally you can run the following:

make localnet-assertoor-tx
# or
make localnet-assertoor-blob

Those are two different set of assertoor checks the details are as follows:

assertoor-tx

assertoor-blob

For reference on each individual check see the assertoor-wiki

Run

Example run:

cargo run --bin ethrex -- --network fixtures/genesis/kurtosis.json

The network argument is mandatory, as it defines the parameters of the chain. For more information about the different cli arguments check out the next section.

Rust tests

Crate Specific Tests

Rust unit tests that you can run like this:

make test CRATE=<crate>

For example:

make test CRATE="ethrex-blockchain"

Load tests

Before starting, consider increasing the maximum amount of open files for the current shell with the following command:

ulimit -n 65536

To run a load test, first run the node using a command like the following in the root folder:

cargo run --bin ethrex --release -- --network fixtures/genesis/load-test.json --dev

There are currently three different load tests you can run:

The first one sends regular transfers between accounts, the second runs an EVM-heavy contract that computes fibonacci numbers, the third a heavy IO contract that writes to 100 storage slots per transaction.

# Eth transfer load test
make load-test

# ERC 20 transfer load test
make load-test-erc20

# Tests a contract that executes fibonacci (high cpu)
make load-test-fibonacci

# Tests a contract that makes heavy access to storage slots
make load-test-io

L2 Developer Docs

Welcome to the ethrex L2 developer documentation!

This section provides information about the internals of the L2 side of the project.

Table of contents

Ethrex as a local L2 development node

Prerequisites

Dev mode

In dev mode ethrex acts as a local Ethereum development node and a local layer 2 rollup

ethrex l2 --dev

after running the command the ethrex monitor will open with information about the status of the local L2.

The default port of the L1 JSON-RPC is 8545 you can test it by running

rex block-number http://localhost:8545

The default port of the L2 JSON-RPC is 1729 you can test it by running

rex block-number http://localhost:1729

Guides

For more information on how to perform certain operations, go to Guides.

Debug Mode

Debug mode currently enables printing in solidity by using a print() function that does an MSTORE with a specific offset to toggle the "print mode". If the VM is in debug mode it will recognize the offset as the "key" for enabling/disabling print mode. If print mode is enabled, MSTORE opcode stores into a buffer the data that the user wants to print, and when there is no more data left to read it prints it and disables the print mode so that execution continues normally. You can find the solidity code in the fixtures of this repository. It can be tested with the PrintTest contract and it can be imported into another contracts.

ethrex-replay

A tool for executing and proving Ethereum blocks, transactions, and L2 batches — inspired by starknet-replay. Currently ethrex replay only works against ethrex nodes with the debug_executionWitness RPC endpoint.

Getting Started

Note: All commands must be run from the ethrex/cmd/ethrex_replay directory.

Dependencies

RISC0

curl -L https://risczero.com/install | bash
rzup install cargo-risczero 2.3.1
rzup install rust

SP1

curl -L https://sp1up.succinct.xyz | bash
sp1up --version 5.0.8

Environment Variables

Before running any command, set the following environment variables depending on the operation:

export RPC_URL=<RPC_URL>
export BLOCK_NUMBER=<BLOCK_NUMBER>
export BATCH_NUMBER=<BATCH_NUMBER>
export TX_HASH=<TRANSACTION_HASH>
export START_BLOCK=<START_BLOCK>
export END_BLOCK=<END_BLOCK>
export NETWORK=<mainnet|cancun|holesky|hoodi|sepolia|chainId>
export L2=true

Variable Descriptions

  • RPC_URL: Ethereum JSON-RPC endpoint used to fetch on-chain data.
  • BLOCK_NUMBER: Block number to replay. If unset, the latest block will be used.
  • BATCH_NUMBER: L2 batch number to execute or prove.
  • TX_HASH: Hash of the transaction to replay.
  • START_BLOCK / END_BLOCK: Defines the block range to analyze and plot.
  • NETWORK: Logical network name or chain ID. Defaults to mainnet.
  • L2: Set to true to run transactions in L2 mode.

You only need to set the variables required by the command you're running.


Running Examples

Execute a single block (no proving)

Required: RPC_URL. Optionally: BLOCK_NUMBER, NETWORK

make sp1           # SP1 (CPU)
make sp1-gpu       # SP1 (GPU)
make risc0         # RISC0 (CPU)
make risc0-gpu     # RISC0 (GPU)

Prove a single block

Required: RPC_URL. Optionally: BLOCK_NUMBER, NETWORK.

make prove-sp1
make prove-sp1-gpu
make prove-risc0
make prove-risc0-gpu

Execute an L2 batch (no proving)

Required: RPC_URL, BATCH_NUMBER, NETWORK.

make batch-sp1
make batch-sp1-gpu
make batch-risc0
make batch-risc0-gpu

Prove an L2 batch

Required: RPC_URL, BATCH_NUMBER, NETWORK.

make prove-batch-sp1
make prove-batch-sp1-gpu
make prove-batch-risc0
make prove-batch-risc0-gpu

Execute a transaction

Required: RPC_URL, TX_HASH, NETWORK. Optionally: L2=true (if the transaction is L2-specific)

make transaction

Plot block composition

Required: RPC_URL, START_BLOCK, END_BLOCK. Optionally: NETWORK

make plot

Check All Available Commands

Run:

make help

Profiling zkvms with ethrex-replay

Getting started

Before reading this document please take a look the general documentation for ethrex-replay

Dependencies

For SP1

The easiest way is to use cargo but other options are listed in the samply repo

cargo install --locked samply

For risc0

Install go by following the instructions from go install page

Generate a profile

For SP1

Profile a L1 block:

Required: RPC_URL. Optionally: BLOCK_NUMBER, NETWORK.

make profile-sp1

Profile a L2 batch:

Required: RPC_URL, BATCH_NUMBER, NETWORK.

make profile-batch-sp1

Open samply profile

samply load output.json

Then visit http://localhost:8000/ on your browser

For risc0

Profile a L1 block:

Required: RPC_URL. Optionally: BLOCK_NUMBER, NETWORK.

make profile-risc0

Profile a L2 batch:

Required: RPC_URL, BATCH_NUMBER, NETWORK.

make profile-batch-risc0

Open pprof profile

go tool pprof -http=127.0.0.1:8000 profile.pb

Then visit http://localhost:8000/ on your browser

CLI Commands

ethrex

ethrex Execution client

Usage: ethrex [OPTIONS] [COMMAND]

Commands:
  removedb            Remove the database
  import              Import blocks to the database
  export              Export blocks in the current chain into a file in rlp encoding
  compute-state-root  Compute the state root from a genesis file
  l2
  help                Print this message or the help of the given subcommand(s)

Options:
  -h, --help
          Print help (see a summary with '-h')

  -V, --version
          Print version

Node options:
      --network <GENESIS_FILE_PATH>
          Alternatively, the name of a known network can be provided instead to use its preset genesis file and include its preset bootnodes. The networks currently supported include holesky, sepolia, hoodi and mainnet. If not specified, defaults to mainnet.

          [env: ETHREX_NETWORK=]

      --datadir <DATABASE_DIRECTORY>
          If the datadir is the word `memory`, ethrex will use the `InMemory Engine`.

          [env: ETHREX_DATADIR=]
          [default: ethrex]

      --force
          Delete the database without confirmation.

      --metrics.addr <ADDRESS>
          [default: 0.0.0.0]

      --metrics.port <PROMETHEUS_METRICS_PORT>
          [env: ETHREX_METRICS_PORT=]
          [default: 9090]

      --metrics
          Enable metrics collection and exposition

      --dev
          If set it will be considered as `true`. If `--network` is not specified, it will default to a custom local devnet. The Binary has to be built with the `dev` feature enabled.

      --evm <EVM_BACKEND>
          Has to be `levm` or `revm`

          [env: ETHREX_EVM=]
          [default: levm]

      --log.level <LOG_LEVEL>
          Possible values: info, debug, trace, warn, error

          [default: INFO]

P2P options:
      --bootnodes <BOOTNODE_LIST>...
          Comma separated enode URLs for P2P discovery bootstrap.

      --syncmode <SYNC_MODE>
          Can be either "full" or "snap" with "full" as default value.

          [default: full]

      --p2p.enabled


      --p2p.addr <ADDRESS>
          [default: 0.0.0.0]

      --p2p.port <PORT>
          [default: 30303]

      --discovery.addr <ADDRESS>
          UDP address for P2P discovery.

          [default: 0.0.0.0]

      --discovery.port <PORT>
          UDP port for P2P discovery.

          [default: 30303]

RPC options:
      --http.addr <ADDRESS>
          Listening address for the http rpc server.

          [env: ETHREX_HTTP_ADDR=]
          [default: localhost]

      --http.port <PORT>
          Listening port for the http rpc server.

          [env: ETHREX_HTTP_PORT=]
          [default: 8545]

      --authrpc.addr <ADDRESS>
          Listening address for the authenticated rpc server.

          [default: localhost]

      --authrpc.port <PORT>
          Listening port for the authenticated rpc server.

          [default: 8551]

      --authrpc.jwtsecret <JWTSECRET_PATH>
          Receives the jwt secret used for authenticated rpc requests.

          [default: jwt.hex]

ethrex l2

Usage: ethrex l2 [OPTIONS]
       ethrex l2 <COMMAND>

Commands:
  prover        Initialize an ethrex prover [aliases: p]
  removedb      Remove the database [aliases: rm, clean]
  blobs-saver   Launch a server that listens for Blobs submissions and saves them offline.
  reconstruct   Reconstructs the L2 state from L1 blobs.
  revert-batch  Reverts unverified batches.
  deploy        Deploy in L1 all contracts needed by an L2.
  help          Print this message or the help of the given subcommand(s)

Options:
  -t, --tick-rate <TICK_RATE>
          time in ms between two ticks

          [default: 1000]

      --batch-widget-height <BATCH_WIDGET_HEIGHT>


  -h, --help
          Print help (see a summary with '-h')

Node options:
      --network <GENESIS_FILE_PATH>
          Alternatively, the name of a known network can be provided instead to use its preset genesis file and include its preset bootnodes. The networks currently supported include holesky, sepolia, hoodi and mainnet. If not specified, defaults to mainnet.

          [env: ETHREX_NETWORK=]

      --datadir <DATABASE_DIRECTORY>
          If the datadir is the word `memory`, ethrex will use the `InMemory Engine`.

          [env: ETHREX_DATADIR=]
          [default: ethrex]

      --force
          Delete the database without confirmation.

      --metrics.addr <ADDRESS>
          [default: 0.0.0.0]

      --metrics.port <PROMETHEUS_METRICS_PORT>
          [env: ETHREX_METRICS_PORT=]
          [default: 9090]

      --metrics
          Enable metrics collection and exposition

      --dev
          If set it will be considered as `true`. If `--network` is not specified, it will default to a custom local devnet. The Binary has to be built with the `dev` feature enabled.

      --evm <EVM_BACKEND>
          Has to be `levm` or `revm`

          [env: ETHREX_EVM=]
          [default: levm]

      --log.level <LOG_LEVEL>
          Possible values: info, debug, trace, warn, error

          [default: INFO]

P2P options:
      --bootnodes <BOOTNODE_LIST>...
          Comma separated enode URLs for P2P discovery bootstrap.

      --syncmode <SYNC_MODE>
          Can be either "full" or "snap" with "full" as default value.

          [default: full]

      --p2p.enabled


      --p2p.addr <ADDRESS>
          [default: 0.0.0.0]

      --p2p.port <PORT>
          [default: 30303]

      --discovery.addr <ADDRESS>
          UDP address for P2P discovery.

          [default: 0.0.0.0]

      --discovery.port <PORT>
          UDP port for P2P discovery.

          [default: 30303]

RPC options:
      --http.addr <ADDRESS>
          Listening address for the http rpc server.

          [env: ETHREX_HTTP_ADDR=]
          [default: localhost]

      --http.port <PORT>
          Listening port for the http rpc server.

          [env: ETHREX_HTTP_PORT=]
          [default: 8545]

      --authrpc.addr <ADDRESS>
          Listening address for the authenticated rpc server.

          [default: localhost]

      --authrpc.port <PORT>
          Listening port for the authenticated rpc server.

          [default: 8551]

      --authrpc.jwtsecret <JWTSECRET_PATH>
          Receives the jwt secret used for authenticated rpc requests.

          [default: jwt.hex]

Eth options:
      --eth.rpc-url <RPC_URL>...
          List of rpc urls to use.

          [env: ETHREX_ETH_RPC_URL=]

      --eth.maximum-allowed-max-fee-per-gas <UINT64>
          [env: ETHREX_MAXIMUM_ALLOWED_MAX_FEE_PER_GAS=]
          [default: 10000000000]

      --eth.maximum-allowed-max-fee-per-blob-gas <UINT64>
          [env: ETHREX_MAXIMUM_ALLOWED_MAX_FEE_PER_BLOB_GAS=]
          [default: 10000000000]

      --eth.max-number-of-retries <UINT64>
          [env: ETHREX_MAX_NUMBER_OF_RETRIES=]
          [default: 10]

      --eth.backoff-factor <UINT64>
          [env: ETHREX_BACKOFF_FACTOR=]
          [default: 2]

      --eth.min-retry-delay <UINT64>
          [env: ETHREX_MIN_RETRY_DELAY=]
          [default: 96]

      --eth.max-retry-delay <UINT64>
          [env: ETHREX_MAX_RETRY_DELAY=]
          [default: 1800]

L1 Watcher options:
      --l1.bridge-address <ADDRESS>
          [env: ETHREX_WATCHER_BRIDGE_ADDRESS=]

      --watcher.watch-interval <UINT64>
          How often the L1 watcher checks for new blocks in milliseconds.

          [env: ETHREX_WATCHER_WATCH_INTERVAL=]
          [default: 1000]

      --watcher.max-block-step <UINT64>
          [env: ETHREX_WATCHER_MAX_BLOCK_STEP=]
          [default: 5000]

      --watcher.block-delay <UINT64>
          Number of blocks the L1 watcher waits before trusting an L1 block.

          [env: ETHREX_WATCHER_BLOCK_DELAY=]
          [default: 10]

Block producer options:
      --block-producer.block-time <UINT64>
          How often does the sequencer produce new blocks to the L1 in milliseconds.

          [env: ETHREX_BLOCK_PRODUCER_BLOCK_TIME=]
          [default: 5000]

      --block-producer.coinbase-address <ADDRESS>
          [env: ETHREX_BLOCK_PRODUCER_COINBASE_ADDRESS=]

Proposer options:
      --elasticity-multiplier <UINT64>
          [env: ETHREX_PROPOSER_ELASTICITY_MULTIPLIER=]
          [default: 2]

L1 Committer options:
      --committer.l1-private-key <PRIVATE_KEY>
          Private key of a funded account that the sequencer will use to send commit txs to the L1.

          [env: ETHREX_COMMITTER_L1_PRIVATE_KEY=]

      --committer.remote-signer-url <URL>
          URL of a Web3Signer-compatible server to remote sign instead of a local private key.

          [env: ETHREX_COMMITTER_REMOTE_SIGNER_URL=]

      --committer.remote-signer-public-key <PUBLIC_KEY>
          Public key to request the remote signature from.

          [env: ETHREX_COMMITTER_REMOTE_SIGNER_PUBLIC_KEY=]

      --l1.on-chain-proposer-address <ADDRESS>
          [env: ETHREX_COMMITTER_ON_CHAIN_PROPOSER_ADDRESS=]

      --committer.commit-time <UINT64>
          How often does the sequencer commit new blocks to the L1 in milliseconds.

          [env: ETHREX_COMMITTER_COMMIT_TIME=]
          [default: 60000]

      --committer.arbitrary-base-blob-gas-price <UINT64>
          [env: ETHREX_COMMITTER_ARBITRARY_BASE_BLOB_GAS_PRICE=]
          [default: 1000000000]

Proof coordinator options:
      --proof-coordinator.l1-private-key <PRIVATE_KEY>
          Private key of of a funded account that the sequencer will use to send verify txs to the L1. Has to be a different account than --committer-l1-private-key.

          [env: ETHREX_PROOF_COORDINATOR_L1_PRIVATE_KEY=]

      --proof-coordinator.tdx-private-key <PRIVATE_KEY>
          Private key of of a funded account that the TDX tool that will use to send the tdx attestation to L1.

          [env: ETHREX_PROOF_COORDINATOR_TDX_PRIVATE_KEY=]

      --proof-coordinator.remote-signer-url <URL>
          URL of a Web3Signer-compatible server to remote sign instead of a local private key.

          [env: ETHREX_PROOF_COORDINATOR_REMOTE_SIGNER_URL=]

      --proof-coordinator.remote-signer-public-key <PUBLIC_KEY>
          Public key to request the remote signature from.

          [env: ETHREX_PROOF_COORDINATOR_REMOTE_SIGNER_PUBLIC_KEY=]

      --proof-coordinator.addr <IP_ADDRESS>
          Set it to 0.0.0.0 to allow connections from other machines.

          [env: ETHREX_PROOF_COORDINATOR_LISTEN_ADDRESS=]
          [default: 127.0.0.1]

      --proof-coordinator.port <UINT16>
          [env: ETHREX_PROOF_COORDINATOR_LISTEN_PORT=]
          [default: 3900]

      --proof-coordinator.send-interval <UINT64>
          How often does the proof coordinator send proofs to the L1 in milliseconds.

          [env: ETHREX_PROOF_COORDINATOR_SEND_INTERVAL=]
          [default: 5000]

      --proof-coordinator.dev-mode
          [env: ETHREX_PROOF_COORDINATOR_DEV_MODE=]

Based options:
      --state-updater.sequencer-registry <ADDRESS>
          [env: ETHREX_STATE_UPDATER_SEQUENCER_REGISTRY=]

      --state-updater.check-interval <UINT64>
          [env: ETHREX_STATE_UPDATER_CHECK_INTERVAL=]
          [default: 1000]

      --block-fetcher.fetch_interval_ms <UINT64>
          [env: ETHREX_BLOCK_FETCHER_FETCH_INTERVAL_MS=]
          [default: 5000]

      --fetch-block-step <UINT64>
          [env: ETHREX_BLOCK_FETCHER_FETCH_BLOCK_STEP=]
          [default: 5000]

      --based
          [env: ETHREX_BASED=]

Aligned options:
      --aligned
          [env: ETHREX_ALIGNED_MODE=]

      --aligned-verifier-interval-ms <ETHREX_ALIGNED_VERIFIER_INTERVAL_MS>
          [env: ETHREX_ALIGNED_VERIFIER_INTERVAL_MS=]
          [default: 5000]

      --aligned.beacon-url <BEACON_URL>...
          List of beacon urls to use.

          [env: ETHREX_ALIGNED_BEACON_URL=]

      --aligned-network <ETHREX_ALIGNED_NETWORK>
          L1 network name for Aligned sdk

          [env: ETHREX_ALIGNED_NETWORK=]
          [default: devnet]

      --aligned.fee-estimate <FEE_ESTIMATE>
          Fee estimate for Aligned sdk

          [env: ETHREX_ALIGNED_FEE_ESTIMATE=]
          [default: instant]

      --aligned-sp1-elf-path <ETHREX_ALIGNED_SP1_ELF_PATH>
          Path to the SP1 elf. This is used for proof verification.

          [env: ETHREX_ALIGNED_SP1_ELF_PATH=]

L2 options:
      --validium
          If true, L2 will run on validium mode as opposed to the default rollup mode, meaning it will not publish state diffs to the L1.

          [env: ETHREX_L2_VALIDIUM=]

      --sponsorable-addresses <SPONSORABLE_ADDRESSES_PATH>
          Path to a file containing addresses of contracts to which ethrex_SendTransaction should sponsor txs

      --sponsor-private-key <SPONSOR_PRIVATE_KEY>
          The private key of ethrex L2 transactions sponsor.

          [env: SPONSOR_PRIVATE_KEY=]
          [default: 0xffd790338a2798b648806fc8635ac7bf14af15425fed0c8f25bcc5febaa9b192]

Monitor options:
      --no-monitor
          [env: ETHREX_MONITOR=]

ethrex l2 prover

Initialize an ethrex prover

Usage: ethrex l2 prover [OPTIONS] --proof-coordinator <URL>

Options:
  -h, --help
          Print help (see a summary with '-h')

Prover client options:
      --backend <BACKEND>
          [env: PROVER_CLIENT_BACKEND=]
          [default: exec]
          [possible values: exec]

      --proof-coordinator <URL>
          URL of the sequencer's proof coordinator

          [env: PROVER_CLIENT_PROOF_COORDINATOR_URL=]

      --proving-time <PROVING_TIME>
          Time to wait before requesting new data to prove

          [env: PROVER_CLIENT_PROVING_TIME=]
          [default: 5000]

      --log.level <LOG_LEVEL>
          Possible values: info, debug, trace, warn, error

          [default: INFO]

      --aligned
          Activate aligned proving system

          [env: PROVER_CLIENT_ALIGNED=]

Contributing to the Documentation

We welcome contributions to the documentation! If you want to help improve or expand the docs, please follow these guidelines:

How to Edit the Docs

  • All documentation lives in this docs/ directory and its subfolders.

  • The documentation is written in Markdown and rendered using mdBook.

  • To preview your changes locally, install the dependencies and run:

    make docs-serve
    

    This will start a local server and open the docs in your browser.

Adding or Editing Content

  • To add a new page, create a new .md file in the appropriate subdirectory and add a link to it in SUMMARY.md.
  • To edit an existing page, simply modify the relevant .md file.
  • For style and formatting, try to keep a consistent tone and structure with the rest of the documentation.

Documentation dependencies

We use some mdBook preprocessors and backends for extra features:

You can install mdBook and all dependencies with:

make docs-deps

Submitting Changes

  • Please open a Pull Request with your proposed changes.
  • If you are adding new content, update SUMMARY.md so it appears in the navigation.
  • If you have questions, open an issue or ask in the community chat.

Thank you for helping improve the documentation!