tesseract

Technical guide

Building a Multi-Rollup Arbitrage Bot With Tesseract (Without Going Broke on MEV)

A practical walkthrough of using Tesseract's atomic swap groups to capture price differences across L2s — and why naive implementations get picked apart by searchers.

April 18, 2026 · The Tesseract Team ·
arbitragetutorialdefimev

Cross-rollup arbitrage is in a weird spot in 2026. Everyone knows there are price gaps between, say, ETH/USDC on Arbitrum versus Base. The gaps are usually tiny, occasionally meaningful, and almost always closed within a block by someone — but that someone is rarely a small team running a hobby bot. The reason is structural: the people who actually capture cross-rollup MEV have private mempool access, dedicated relayer infrastructure, and inventory pre-positioned on every chain.

You’re not going to beat Jump Trading at their game. But you can run a profitable cross-rollup bot today without their stack, if you use a protocol that gives you atomicity and MEV protection by default. This is a walkthrough of doing exactly that with Tesseract.

We will cover: the high-level architecture, the specific contracts you call, what the relayer does for you and what you still have to do yourself, where the actual edge comes from, and the operational gotchas that will eat your lunch if you skip them.

What problem are we solving

Concrete example. At time T:

  • On Arbitrum, the Uniswap v3 ETH/USDC pool is trading ETH at $3,420.
  • On Base, the Aerodrome ETH/USDC pool is trading ETH at $3,427.

That’s a 0.2% gap. If you could simultaneously buy ETH on Arbitrum and sell it on Base, you’d capture roughly $7 per ETH (minus fees, minus slippage, minus gas). On a 100 ETH trade that’s $700; on a 10 ETH trade it’s $70.

The naive implementation: open two RPC connections, fire a buy on Arbitrum, fire a sell on Base, hope both land. The actual outcome: in the time between your transactions, a searcher on either chain notices the imbalance and arbs it away, or the pool you tried to buy from has already moved by the time you actually swap. You bought high and sold lower than you wanted.

What you need is atomicity: either both legs execute at the prices you observed, or neither does.

The Tesseract architecture for this

Tesseract gives you exactly two primitives you need:

  1. Atomic swap groups. Bind both legs to a single swap_group_id. If either leg fails to resolve before the deadline, both refund.
  2. Commit-reveal. Searchers reading the mempool can’t see what you’re swapping until the commitment is already on-chain.

The lifecycle:

  1. Observe price gap.
  2. Compute payloads for both legs.
  3. Generate swap_group_id and per-leg secrets.
  4. Submit commitments to TesseractBuffer on Arbitrum and Base simultaneously.
  5. Wait one block on each chain.
  6. Reveal payloads on each chain.
  7. Wait 2 more blocks (the MIN_RESOLUTION_DELAY).
  8. A relayer (yours or someone else’s) calls resolve_dependency. Group atomicity ensures either both legs settle or both refund.

Steps 4–6 should fit inside the deadline window (5–300 seconds, your call). For a 2-leg arb across L2s with 2-second block times, 60 seconds is plenty.

Code: setting it up

I’m going to use Python and web3.py because it’s the path of least resistance. Production bots are written in Rust or Go, but the logic is identical.

import os, time
from web3 import Web3
from eth_utils import keccak

# RPC endpoints — use your own, not public ones, for latency
ARBITRUM = Web3(Web3.HTTPProvider(os.environ["ARBITRUM_RPC"]))
BASE     = Web3(Web3.HTTPProvider(os.environ["BASE_RPC"]))

# Tesseract contract addresses (placeholder — see deployments doc for real ones)
BUFFER_ARB  = "0x..."  # TesseractBuffer on Arbitrum
BUFFER_BASE = "0x..."  # TesseractBuffer on Base

# Your trading address (same EOA on both chains is convenient but not required)
TRADER = os.environ["TRADER_ADDRESS"]
PRIV   = os.environ["TRADER_PRIVATE_KEY"]

buffer_arb  = ARBITRUM.eth.contract(address=BUFFER_ARB,  abi=BUFFER_ABI)
buffer_base = BASE.eth.contract(address=BUFFER_BASE,     abi=BUFFER_ABI)

Code: observing the gap

The cheapest way to detect a gap is to subscribe to swap events on both pools and recompute the marginal price after each swap. The more reliable way is to query slot0 (Uniswap v3) or the equivalent for whatever pool style you’re targeting on a tight interval.

def get_mid_price(pool_contract):
    slot0 = pool_contract.functions.slot0().call()
    sqrt_price_x96 = slot0[0]
    price = (sqrt_price_x96 / (2**96)) ** 2
    return price  # token0 / token1, adjust decimals as needed

def find_arb():
    p_arb  = get_mid_price(arb_pool)
    p_base = get_mid_price(base_pool)
    gap_bps = (p_base - p_arb) / p_arb * 10_000
    if abs(gap_bps) > MIN_GAP_BPS:
        return ("buy_arb_sell_base" if gap_bps > 0 else "buy_base_sell_arb", abs(gap_bps))
    return None

A practical MIN_GAP_BPS for L2 arb in 2026 is somewhere between 8 and 25, depending on trade size and gas conditions. Lower than that and the gas costs eat the edge.

Code: building the commitments

def build_commitment(payload: bytes) -> tuple[bytes, bytes]:
    """Returns (commitment, secret)."""
    secret = os.urandom(32)
    commitment = keccak(payload + secret)
    return commitment, secret

# Payloads are opaque to TesseractBuffer; they're interpreted by your
# downstream execution contract (e.g. a router you've registered).
buy_payload  = encode_buy(amount_in=10 * 10**6, min_out=int(0.0029 * 10**18))
sell_payload = encode_sell(amount_in=int(0.0029 * 10**18), min_out=10_020 * 10**6)

buy_commitment,  buy_secret  = build_commitment(buy_payload)
sell_commitment, sell_secret = build_commitment(sell_payload)

swap_group_id = keccak(os.urandom(32))
deadline      = int(time.time()) + 60  # 60-second window

Code: submitting the buffered transactions

def submit_buffer(w3, buffer_contract, tx_id, commitment):
    nonce = w3.eth.get_transaction_count(TRADER)
    tx = buffer_contract.functions.buffer_transaction_with_commitment(
        tx_id,
        TRADER,
        TRADER,  # target — usually a router controlled by your trading address
        commitment,
        bytes(32),       # no per-leg dependency; group atomicity covers it
        deadline,
        swap_group_id,
        TRADER,          # refund recipient
    ).build_transaction({
        "from": TRADER,
        "nonce": nonce,
        "gas": 200_000,
        "maxFeePerGas": w3.eth.gas_price * 2,
        "maxPriorityFeePerGas": w3.eth.gas_price,
    })
    signed = w3.eth.account.sign_transaction(tx, PRIV)
    return w3.eth.send_raw_transaction(signed.rawTransaction)

buy_tx_id  = keccak(b"arb_buy_"  + swap_group_id)
sell_tx_id = keccak(b"base_sell_" + swap_group_id)

h1 = submit_buffer(ARBITRUM, buffer_arb,  buy_tx_id,  buy_commitment)
h2 = submit_buffer(BASE,     buffer_base, sell_tx_id, sell_commitment)

# Wait for both to confirm
ARBITRUM.eth.wait_for_transaction_receipt(h1, timeout=20)
BASE.eth.wait_for_transaction_receipt(h2, timeout=20)

The two submit_buffer calls fire essentially in parallel; whichever lands second still has plenty of deadline budget left.

Code: reveal phase

def reveal(w3, buffer_contract, tx_id, payload, secret):
    nonce = w3.eth.get_transaction_count(TRADER)
    tx = buffer_contract.functions.reveal_transaction(
        tx_id, payload, secret
    ).build_transaction({
        "from": TRADER,
        "nonce": nonce,
        "gas": 120_000,
        "maxFeePerGas": w3.eth.gas_price * 2,
        "maxPriorityFeePerGas": w3.eth.gas_price,
    })
    signed = w3.eth.account.sign_transaction(tx, PRIV)
    return w3.eth.send_raw_transaction(signed.rawTransaction)

reveal(ARBITRUM, buffer_arb,  buy_tx_id,  buy_payload,  buy_secret)
reveal(BASE,     buffer_base, sell_tx_id, sell_payload, sell_secret)

After this, the relayer takes over. You don’t need to do anything; any registered relayer can call resolve_dependency on either chain once the delay elapses. Your job is to confirm the outcome.

Code: confirming the outcome

def check_resolved(buffer_contract, tx_id) -> bool:
    state = buffer_contract.functions.get_transaction_state(tx_id).call()
    return state == RESOLVED_STATE  # see TesseractBuffer constants

deadline_buffer = 30
while time.time() < deadline + deadline_buffer:
    if check_resolved(buffer_arb, buy_tx_id) and check_resolved(buffer_base, sell_tx_id):
        print("Arb closed successfully.")
        break
    time.sleep(2)
else:
    # If we get here, the swap group didn't resolve in time. Tesseract will
    # auto-refund; we can also explicitly trigger it.
    print("Group expired; awaiting refund.")

What you still have to think about

Tesseract handles atomicity and MEV. It does not handle:

  • Inventory. You need ETH on Arbitrum to buy with, and ETH on Base to sell. The swap doesn’t conjure them. Pre-position inventory on each chain.
  • Price re-quote between observe and commit. If the gap closes between your find_arb call and your submit_buffer calls, you’ll execute at the new prices. Set min_out aggressively in your payload encoding so the leg simply reverts (and triggers a group refund) if the gap is gone.
  • Gas spikes. L2 gas prices can spike 10x during congestion. Build a gas budget into your MIN_GAP_BPS.
  • Pool depth. The 0.2% mid-price gap might be only 2 ETH deep before slippage eats it. Always check the depth of the side you’re crossing.
  • Relayer fee. Resolvers earn a small fee for closing the swap. Make sure your edge survives that.

Where the actual edge comes from in 2026

Honest answer: not from “I can spot a 0.2% gap before anyone else.” Searchers with co-located infrastructure see those gaps first. Your edge comes from:

  1. Atomicity. You’re willing to take both legs or none. The searcher who tries to one-leg you and runs the other side themselves is exposed to inventory risk you aren’t.
  2. Latency tolerance. You’re not racing to the front of the block. You’re committing a sealed envelope and waiting 60 seconds. Plenty of edges survive 60 seconds because the searchers chasing them are racing each other to the front of the block, not waiting patiently.
  3. Smaller-than-pro size. The professional firms can’t justify a 5-ETH trade because their infra cost amortises across volume. You can.
  4. Less-watched pairs. Everyone is watching ETH/USDC. Almost nobody is watching DAI/FRAX or yield-bearing-token pairs across L2s. Most of those are MEV deserts.

Operational checklist

Before you ship a bot:

  • Run it on testnets first. Sepolia, Amoy, and the various L2 Sepolias all have Tesseract deployments.
  • Instrument every leg with metrics: time-to-commit, time-to-reveal, time-to-resolve, realised slippage vs expected, gas cost per leg.
  • Set hard per-trade size limits and an aggregate daily loss limit. Cross-rollup bots can burn money fast when something subtle is wrong.
  • Subscribe to Tesseract’s RelayerRegistry events so you know when relayers come online or offline. If the registry thins out, increase your deadline windows.
  • Have a kill switch. If your monitoring sees three consecutive group refunds, pause and investigate.

Closing thought

The point of building on a protocol like Tesseract is that you stop having to write the parts of an arb bot that are easy to get wrong (atomicity, MEV protection, refund logic) and you get to focus on the parts that are hard and where your edge lives (signal detection, inventory management, risk limits). Most teams that try to build cross-rollup arb without a primitive like this end up writing a worse version of it themselves under time pressure, and the worse version usually has at least one subtle bug that costs them more than the bot ever earns.

For the full contract reference, see tesseract on GitHub. For a deeper architectural comparison, see Tesseract vs LayerZero.