FABRKNT
Sequencer & Rollup Architecture — From Centralized Block Producer to Shared Sequencers
Building & Decentralization
Lesson 5 of 7·CONTENT20 min55 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Sequencer & Rollup Architecture — From Centralized Block Producer to Shared Sequencers
Lesson role
CONTENT
Sequence
5 / 7

Lesson 5 — Building a minimal sequencer on Reth

Question

You want to build your own rollup sequencer on Reth. A minimal sequencer is ~250 lines of Rust — mempool + block builder + L1 batcher. Build the smallest thing that produces L2 blocks and posts batches to L1, then layer in optimisations.

Principle (minimum model)

  • Minimal sequencer has three components. Mempool (~50 lines) accepts and validates tx; Builder (~80 lines) orders + executes them; L1InboxWatcher + Batcher (~50 lines each) interact with L1.
  • Mempool. Vec<Transaction> + accept-by-signature + sort by priority fee. Production mempools add nonce ordering, replacement rules, sub-pools (pending vs queued).
  • Builder. build_block(prev_state, mempool) -> Block — iterate mempool, execute with revm, check gas limit + state validity, include or skip. Use Reth's Database trait + EvmConfig::evm_with_env.
  • L1 batcher. Compress txs (zstd) → encode in OP's channel format → submit to L1 via eth_sendTransactionOptimismPortal records the batch root. Off-chain Rust process using Alloy.
  • L1 inbox watcher. Listen for eth_subscribe to L1 blocks → look for deposit / force-include transactions → prepend them to the next L2 block. Maintains L1 → L2 message ordering.
  • Where this diverges from production. No MEV-aware bundle handling; no L1 cost calculation; no decentralised gas accounting. All can be layered in (Lesson 3 covered op-rbuilder which has these).
  • ~80-line builder loop. Mempool fetch → revm execute → state-root update → repeat until gas limit. Then commit + return ExecutionPayload. The whole block-building skeleton fits on one screen.

Worked example + steps

Building a minimal sequencer on Reth

A working L2 sequencer is ~270 lines of Rust. That's the entire orchestration layer: block production loop, mempool, L1 inbox watcher, batcher. The reason it's so small is that Reth does everything that's actually hard — revm execution, MDBX storage, state management, P2P. The sequencer's job is to drive Reth via Engine API and post the results to L1.

That ~270-line number is the actual launch architecture of most production L2s. This lesson is the walk-through.

1. The architecture

flowchart TB
    Users["L2 Users (HTTP RPC)"] -->|tx| Mempool["Mempool"]
    L1Sub["L1 Inbox Subscription"] -->|deposit events| Mempool
    Mempool -->|pending txs| Loop["Sequencer Loop<br/>(produce block every 2s)"]
    Loop -->|forkchoiceUpdated + getPayload| Reth["Reth EL"]
    Loop -->|sign block| Signer
    Loop -->|new payload| Reth
    Loop -->|broadcast| P2P["P2P Network"]
    Loop -->|every 60s| Batcher["Batcher"]
    Batcher -->|blob tx| L1["L1 (Ethereum)"]

Four components in one process:

  1. Sequencer loop — drives block production via Engine API
  2. Mempool — accepts user txs, prioritizes by fee
  3. L1 inbox watcher — subscribes to L1 deposit events and feeds them into the mempool as force-included txs
  4. Batcher — periodically posts to L1

For minimal MVP, run them all in one binary. Production scales them separately.

2. The sequencer loop

The core production loop:

use alloy_provider::{Provider, ProviderBuilder};
use alloy_signer_local::PrivateKeySigner;
use reth_rpc_engine_api::EngineApiClient;
use std::time::Duration;
use tokio::time::interval;

pub struct MinimalSequencer {
    signer: PrivateKeySigner,
    engine: EngineApiClient,
    mempool: Arc<Mempool>,
    chain_id: u64,
    block_period: Duration,
}

impl MinimalSequencer {
    pub async fn run(self) -> eyre::Result<()> {
        let mut ticker = interval(self.block_period);
        loop {
            ticker.tick().await;
            if let Err(e) = self.produce_block().await {
                tracing::error!(?e, "block production failed");
            }
        }
    }

    async fn produce_block(&self) -> eyre::Result<()> {
        // 1. Get current head from Reth
        let parent_hash = self.current_head().await?;

        // 2. Compute payload attributes
        let attrs = PayloadAttributes {
            timestamp: now_seconds(),
            prev_randao: B256::random(),
            suggested_fee_recipient: self.signer.address(),
            withdrawals: vec![],
            parent_beacon_block_root: None,
        };

        // 3. Tell Reth to start building
        let forkchoice = ForkchoiceState {
            head_block_hash: parent_hash,
            safe_block_hash: parent_hash,
            finalized_block_hash: parent_hash,
        };
        let resp = self.engine
            .fork_choice_updated_v4(forkchoice, Some(attrs))
            .await?;
        let payload_id = resp.payload_id.ok_or_else(|| eyre!("no payload id"))?;

        // 4. Wait briefly for Reth to build
        tokio::time::sleep(Duration::from_millis(500)).await;

        // 5. Fetch the built payload
        let payload = self.engine
            .get_payload_v4(payload_id)
            .await?;

        // 6. Sign the payload hash
        let signature = self.signer
            .sign_hash(&payload.execution_payload.block_hash())
            .await?;

        // 7. Submit signed payload back to Reth
        self.engine
            .new_payload_v4(payload.execution_payload.clone())
            .await?;

        // 8. Update forkchoice (mark new head as final)
        let new_head = payload.execution_payload.block_hash();
        self.engine
            .fork_choice_updated_v4(
                ForkchoiceState {
                    head_block_hash: new_head,
                    safe_block_hash: new_head,
                    finalized_block_hash: new_head,
                },
                None,
            )
            .await?;

        // 9. Broadcast to peers (P2P, omitted)

        tracing::info!(
            block = new_head.to_string(),
            "produced block"
        );
        Ok(())
    }

    async fn current_head(&self) -> eyre::Result<B256> {
        // Track locally or query EL
        todo!()
    }
}

That's ~80 lines. It produces blocks every 2 seconds, signed with the sequencer's authority.

3. The mempool

Even simpler:

use alloy_consensus::TxEnvelope;
use std::sync::{Arc, RwLock};

pub struct Mempool {
    pending: Arc<RwLock<Vec<TxEnvelope>>>,
}

impl Mempool {
    pub fn submit(&self, tx: TxEnvelope) -> eyre::Result<TxHash> {
        // Validate signature, nonce, gas, etc.
        validate_tx(&tx)?;

        let hash = tx.hash();
        self.pending.write().unwrap().push(tx);

        Ok(hash)
    }

    pub fn drain_pending(&self, limit: usize) -> Vec<TxEnvelope> {
        let mut pending = self.pending.write().unwrap();
        let len = pending.len().min(limit);
        pending.drain(..len).collect()
    }
}

In practice, your mempool needs:

  • Priority queue (sort by gas tip)
  • Eviction (timeout, full mempool)
  • Reorg handling (return txs to pool on reorg)
  • Sanity validation

But the data structure is straightforward.

4. The L1 inbox watcher

For L1→L2 deposits, watch the L1 inbox contract:

pub struct L1InboxWatcher {
    l1_provider: Box<dyn Provider>,
    inbox_address: Address,
    mempool: Arc<Mempool>,
}

impl L1InboxWatcher {
    pub async fn run(self) -> eyre::Result<()> {
        let mut stream = self.l1_provider
            .subscribe_logs(&Filter::new()
                .address(self.inbox_address)
                .event("DepositInitiated(...)"))
            .await?;

        while let Some(log) = stream.next().await {
            let deposit_tx = self.encode_l2_deposit_tx(&log)?;
            self.mempool.submit_deposit(deposit_tx)?;
        }
        Ok(())
    }
}

Whenever a deposit event is emitted on L1, encode an equivalent L2 transaction and force-include it via the mempool. The mempool gives deposits priority order in block building.

5. The batcher

Periodically post L2 blocks to L1:

pub struct Batcher {
    l1_provider: Box<dyn Provider>,
    l2_provider: Box<dyn Provider>,
    batcher_signer: PrivateKeySigner,
    last_batch_block: AtomicU64,
}

impl Batcher {
    pub async fn run(self) -> eyre::Result<()> {
        let mut ticker = interval(Duration::from_secs(60));
        loop {
            ticker.tick().await;
            if let Err(e) = self.post_batch().await {
                tracing::error!(?e, "batch posting failed");
            }
        }
    }

    async fn post_batch(&self) -> eyre::Result<()> {
        let from = self.last_batch_block.load(Ordering::SeqCst);
        let to = self.l2_provider.get_block_number().await?;

        // 1. Fetch all L2 blocks from "from" to "to"
        let blocks = self.fetch_blocks(from, to).await?;

        // 2. Compress
        let data = zlib_compress(rlp_encode(&blocks))?;

        // 3. Split into blob-sized chunks
        let chunks = chunk(data, MAX_BLOB_SIZE);

        // 4. Submit each as a blob tx
        for chunk in chunks {
            let blob_tx = build_blob_tx(chunk, self.batcher_signer.address());
            self.l1_provider.send_raw_transaction(blob_tx).await?;
        }

        // 5. Update last batch
        self.last_batch_block.store(to, Ordering::SeqCst);
        Ok(())
    }
}

~50 lines. Every 60 seconds, all L2 blocks since last batch get compressed and submitted to L1 as blobs.

6. The total system

~/my-sequencer/
├── src/
│   ├── main.rs           ← 20 lines (wire it all)
│   ├── sequencer.rs      ← 80 lines
│   ├── mempool.rs        ← 50 lines
│   ├── l1_watcher.rs     ← 40 lines
│   ├── batcher.rs        ← 50 lines
│   └── rpc.rs            ← 30 lines (HTTP server for user submission)
├── Cargo.toml
└── README.md

~270 lines of Rust for a working sequencer that produces blocks, accepts txs, watches L1 deposits, and batches to L1. This is the actual MVP architecture used by most chains at launch.

7. Production gotchas

What this minimal version glosses over:

GotchaReality
Liveness alarmNeed monitoring + automatic failover (heartbeat to ops team)
L1 reorg handlingIf L1 reorgs, you must re-batch any txs that got orphaned
L2 reorg handlingShould be rare (single sequencer = deterministic) but possible
Pre-confirmationsSequencer commits before L1 finality; if it lies, contract handles it
Mempool DOSAttackers spam mempool; need rate limits + fee escalation
Database growthTrack every block ever; eventually need pruning

Each is its own engineering problem. Start with the 270-line MVP; add as needed.

8. For Tempo Moderato

Tempo's sequencer (operated by Paradigm) likely:

  • 300-500 lines of Rust over Reth
  • Same architecture as above
  • Plus: merchant authorization, regulatory monitoring, payment-priority ordering

The novel parts are in the application logic, not the consensus mechanics.

9. Practice

  1. Write the fetch_blocks function — query Reth for L2 blocks in a range
  2. Sketch the blob tx construction (EIP-4844 type 3)
  3. Identify: when would the batcher fail to post? What's the retry strategy?
  4. Calculate: at 2-second block time and 1MB compressed per batch, daily L1 cost?

10. Reading list

Final check: in one sentence, why does ~300 lines of Rust suffice for a working L2 sequencer? If your answer doesn't reference "Reth handles the hard parts," you haven't internalized the architectural separation.

Summary (3 lines)

  • Minimal sequencer = ~250 lines of Rust. Mempool (~50) + Builder (~80, the revm-execute loop) + L1InboxWatcher + Batcher (~50 each).
  • Mempool = Vec + signature accept + priority-fee sort. Builder = iterate mempool, revm-execute, include or skip. Batcher = compress (zstd) + submit to L1 inbox.
  • Divergence from production: no MEV-bundle handling, no L1-cost calculation, no decentralised gas accounting. All layerable; op-rbuilder shows the next steps. Final lesson: decentralisation paths.