Build Your Own Rollup

Mateusz Radomski
L2BEAT
Published in
7 min readOct 12, 2023

--

Have you ever wanted to understand in depth how rollups operate? The theory is nice, but getting hands-on experience is always preferable. Unfortunately, existing projects don’t always make it easy to look under the hood. That’s why we’ve created BYOR (Build Your Own Rollup). It is a Sovereign rollup with minimal features focused on making the code easy to read and understand.

Our motivation with this project was to enable people, both externally and internally, to better understand what rollups around us are actually doing. You can play around with the Holesky deployment or read the source code on github.

What Is It?

The BYOR project is a simplified version of a Sovereign rollup. In contrast to Optimistic and ZK rollups a Sovereign rollup doesn’t verify state roots on Ethereum and only relies on it for data availability and consensus. This prevents trust-minimized bridging between L1 and BYOR, but greatly simplifies the code which is great for educational purposes.

The codebase consists of three programs: the smart contract, the node, and the wallet. When deployed together they allow the end user to interact with the network. Interestingly the state of the network is entirely determined by the on-chain data, meaning that it is actually possible to run multiple nodes. Every node can also act as a sequencer and post data independently.

Below is a complete list of features implemented in BYOR:

  • Fee sorting
  • Posting and fetching state to L1
  • Dropping invalid transactions
  • Viewing account balance
  • Sending transactions
  • Viewing transaction status

Using the wallet

In the wallet app, which acts as the frontend for the network, users can submit transactions, and check the state of an account, or the status of a transaction. On the landing page, you’ll see an overview that gives some statistics about the current state of the rollup, followed by your account status. Most likely, there is just a button to connect your wallet of choice and a message about the token faucet. Under that, there is a search bar where you can paste someone’s address or a transaction hash to explore the current state of the L2. Lastly, there are two lists of transactions: the first one is a list of transactions in the L2 mempool, and the second one is a list of transactions that are posted to L1.

To get started, connect your wallet using the WalletConnect button. Once connected, you may receive a notification that your wallet application is connected to the wrong network. If your application supports network switching, click the “Switch Network” button to switch to the Holesky test network. Otherwise, switch manually.

Now, you can send tokens to someone by providing their address, the amount of tokens to send, and the desired fee. After sending, the wallet application prompts you to sign the message. If successfully signed, the message is sent to the mempool of the L2 Node, where it waits to be posted to L1. The time it takes for a transaction to be bundled in the batch post can vary. Every 10 seconds, the L2 node checks if it has anything to post. Transactions with higher fees are sent first, so if you specify a low fee and there is heavy traffic, you may experience longer wait times.

How it works

Architecture diagram

Technology Stack

We used the following technologies to build each component:

  • Node: Node.js, TypeScript, tRPC, Postgres, viem, drizzle-orm
  • Wallet: TypeScript, tRPC, Next.js, WalletConnect

A deep dive into the code

The BYOR code was specifically crafted to be easy to understand just by looking around the repository. Feel free to explore our codebase by yourself! Start by reading the README.md and to understand the project structure read the ARCHITECTURE.md file.

Below we present interesting highlights from the code:

Smart Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Inputs {
event BatchAppended(address sender);
function appendBatch(bytes calldata) external {
require(msg.sender == tx.origin);
emit BatchAppended(msg.sender);
}
}

This is the only smart contract that is needed. Its name is derived from the fact that it stored inputs to the state transition function. The sole purpose of this contract is to have a convenient place to keep all the transactions. Serialized batches are posted as calldata to this smart contract, and it emits a BatchAppended event with the address of the batch poster. While we could have designed the system so that it simply posts the transactions to an EOA instead of a contract the emitting of an event makes it easy to get the data through JSON-RPC. The only requirement of this smart contract is that it should not be called from another smart contract, but should be directly called from an EOA.

DB Schema

CREATE TABLE `accounts` (
`address` text PRIMARY KEY NOT NULL,
`balance` integer DEFAULT 0 NOT NULL,
`nonce` integer DEFAULT 0 NOT NULL
);

CREATE TABLE `transactions` (
`id` integer,
`from` text NOT NULL,
`to` text NOT NULL,
`value` integer NOT NULL,
`nonce` integer NOT NULL,
`fee` integer NOT NULL,
`feeReceipent` text NOT NULL,
`l1SubmittedDate` integer NOT NULL,
`hash` text NOT NULL
PRIMARY KEY(`from`, `nonce`)
);
-- This table has a single row
CREATE TABLE `fetcherStates` (
`chainId` integer PRIMARY KEY NOT NULL,
`lastFetchedBlock` integer DEFAULT 0 NOT NULL
);

This is the entire database schema used to store information about the rollup. You may wonder why we need a database when all necessary data is stored on L1. While this is true, having the data locally can save time and resources by avoiding repeated fetching. Think of all data stored in this schema as a memoization of the state, transaction hashes, and other computed information.

The fetcherStates table keeps track of the last block we fetched in search of the BatchAppended event. This is useful when the node goes down and later restarts; it knows where to resume searching.

State Transition Function

const DEFAULT_ACCOUNT = { balance: 0, nonce: 0 }
function executeTransaction(state, tx, feeRecipient) {
const fromAccount = getAccount(state, tx.from, DEFAULT_ACCOUNT)
const toAccount = getAccount(state, tx.to, DEFAULT_ACCOUNT)
// Step 1. Update nonce
fromAccount.nonce = tx.nonce
// Step 2. Transfer value
fromAccount.balance -= tx.value
toAccount.balance += tx.value
// Step 3. Pay fee
fromAccount.balance -= tx.fee
feeRecipientAccount.balance += tx.fee
}

The function shown above is the core of the state transition mechanism in BYOR. It assumes that the transaction can be safely executed, with the correct nonce and sufficient balance for the defined spending. Due to this assumption, there are no error handling or validation steps within this function. Instead, these steps are carried out prior to calling the function. Each account state is stored in a map. If an account does not yet exist in this map, it gets set to the default value that you can see at the top of the code listing. Using three accounts, nonces get updated and the balances get distributed.

Transaction Signing

We utilize the EIP-712 standard to sign typed data. This enables us to display clearly to the user what they are signing. As shown above, when sending a transaction, we can display the recipient, amount, and fee in a user-friendly way.

L1 Event Fetching

function getNewStates() {
const lastBatchBlock = getLastBatchBlock()
const events = getLogs(lastBatchBlock)
const calldata = getCalldata(events)
const timestamps = getTimestamps(events)
const posters = getTransactionPosters(events)
updateLastFetchedBlock(lastBatchBlock)
return zip(posters, timestamps, calldata)
}

To fetch new events, we retrieve all BatchAppended events from the Inputs contract starting from the last fetched block. We retrieve events up to the newest block or the last fetched block plus the batch size limit. After retrieving all the events, we extract the calldata, timestamp, and poster address from each transaction. Update the last block we fetched to the last block up to which we were fetching. The extracted calldata, timestamp, and poster are then packaged together and returned from the function for further processing.

Mempool and its fee sorting

function popNHighestFee(txPool, n) {
txPool.sort((a, b) => b.fee - a.fee))
return txPool.splice(0, n)
}

The mempool is simply an object that manages an array of signed transactions. The most interesting aspect is how it determines the order in which transactions are posted to L1. As shown in the code above, the transactions are sorted based on their fees. This results in a system where the median fee price fluctuates depending on the activity on the chain. Even if you specify a high fee, transactions must still produce a valid state if they are appended to the current state. Therefore, you cannot submit invalid transactions simply because of a high fee.

Does BYOR actually scale Ethereum?

Optimistic and ZK rollups have systems in place to prove that the posted state root is consistent with the state transition function and the data they submitted, but the Sovereign rollup does not. As a result, this type of rollup cannot scale Ethereum, which may seem counterintuitive at first. However, it makes sense when we realize that other types of rollups can prove that the posted state root is correct using only the L1. To distinguish if the data is correct for a Sovereign rollup, we would need to run an L1 Node as well as additional software in the form of an L2 Node to execute the state transition function ourselves, increasing the computational load.

The future

It has been a great learning experience for us to build this and we hope that you’ll also find our efforts valuable. We hope to return to BYOR in the future to add a fraud proof system to it. This will make it an actual optimistic rollup and again serve as a lesson in the inner workings of the systems we all use on a daily basis. In the meantime, if you want to contribute feel free to hack away at any of the suggested improvements we’ve described in the GitHub issues.

Follow us on Twitter and join our Discord to stay up to date on any new developments in this project.

--

--