March 25, 202643 min read

arbx: i built an mev bot because a company told me i was too young

this blog is written for engineers who want to understand MEV and on-chain arbitrage from first principles. not for traders looking for a quick strategy. this will take a while to read, but it will be worth every minute.


TL;DR

  • built a flash-loan MEV arbitrage bot on Arbitrum in ~3 weeks
  • Rust engine + Solidity contract, 186 tests, 5 benchmarks
  • 17ns AMM calculation, 467µs full market scan
  • every line written by Claude Sonnet 4.6, zero by hand
  • mainnet launch: april 2026

prologue: the rejection that started all of this

a few months ago, i got rejected from a job.

not because i was bad. not because i failed the interview. but because they looked at my CV, saw no college degree yet, and decided i was "too junior" for a senior rust engineer role.

the role was building an internal MEV bot on Ethereum.

i was (and still am) a first-year college student.

and like any other person, i replayed that rejection in my head quite a few times. then i opened my laptop, opened the terminal, and typed cargo new arbx.

not out of spite. not really. it was more like... fine. if i am not experienced enough to build this professionally, i will just build it myself, from scratch, until i am.

that is the honest reason this project exists.

the company was sigma prime. they are GREAT people. cracked as hell too. they were hiring a senior engineer for real production MEV infra. i was a first-year college student with a portfolio. the mismatch was (and for that matter, still is) very real. i don't hold it against them.

but the problem was interesting. and i had time. and rust is my language.

so i built arbx.


what you are reading

this is a deep dive into how arbx works: how i built it, what i got wrong, what i had to re-learn, and what the whole system looks like under the hood.

it is written to be beginner-friendly. if you have never heard of MEV before, you will understand it by the end. if you already know the space, the engineering sections will have enough depth to be worth your time.

in short, arbx is:

  • a flash-loan arbitrage engine for Arbitrum
  • written in rust and solidity
  • budgeted at $31 total for the mainnet run (not yet deployed. that's april 2026)
  • fully tested, benchmarked, documented, and open source

one thing i want to be upfront about before you read further:
every single line of code in this project was written by claude sonnet 4.6.
not suggested, not autocompleted, written. all 10,550 lines of rust and all 1,916 lines of solidity.

the architectural decisions, the system design, what crates to use, how to structure the pipeline, what to build first; those were all my calls. but i brainstormed most of them with claude too.

some people call this "vibecoding". i'll own up to that label. what i'd push back on though, is the implication that it means you don't understand the thing you're building. i understand every part of this system. i can explain every design choice. the fact that claude wrote the actual syntax doesn't change that.

anyways. this is not a tutorial for copy-pasting. it is a walkthrough of how the whole thing was designed and why.

so sit back, and buckle up, because this is gonna be a LONG one.


part i: the theory

chapter 1: what even is a blockchain (for real this time)

i know. we've all heard the word "blockchain" get thrown around a lot. bear with me for two minutes, because if you skip this, the rest won't make sense. at all.

to keep it simple, a blockchain is a shared database. a database where no single person controls it, and every record is public.

think of it like a giant public spreadsheet that every node on the internet has a copy of.
when you want to update it, say, transfer 100 USDC to someone, you send a request called a transaction.

but this database is a little different from the ones you may be used to. transactions don't land instantly.

they get ordered in a queue, bundled into what we call blocks, and committed roughly every few seconds.

here's the important part though: the person (or system) who decides what order transactions land in is called the sequencer (on Arbitrum) or the block proposer (on Ethereum mainnet).

and that ordering power? that is where MEV lives.


chapter 2: the market gap nobody fixed fast enough

to understand MEV, we must understand DeFi markets first.

on a traditional stock exchange like NYSE, a central order book matches buyers and sellers. if Apple stock is $150 on NYSE, it's also $150 everywhere else within milliseconds, because professional arbitrageurs enforce that.

DeFi markets (DEXes) work a little differently.

instead of an order book, they use Automated Market Makers (AMMs).
imagine two bins of tokens. one bin has 1000 ETH, the other has 2,000,000 USDC. the ratio between them determines the price. if you dump 100 ETH into the bin, you shift the ratio, and the price moves.

now imagine: there are hundreds of these bins (what we call as pools) across dozens of DEXes. Uniswap has a bin. Camelot has a bin. SushiSwap has another bin. when a large trade hits one pool, it moves that pool's price. the other pools don't know this happened yet. for a brief window, sometimes just one or two blocks, prices are out of sync.

that gap is the arbitrage opportunity.

and the bot that reacts fastest, captures it.

that is the entire game.


chapter 3: mev, the short version

MEV stands for Maximal Extractable Value.

fancy name. simple idea.

it is money that can be extracted from a blockchain by reacting to known, pending transaction activity faster or smarter than other participants.

the three most common MEV types:

  1. Frontrunning: see someone's big trade about to land. copy it, and submit yours first at a slightly higher fee. profit from the price move you caused. (evil. possible on Ethereum mainnet where there is a public mempool. but not possible on Arbitrum.)

  2. Sandwiching: buy before someone's big trade, sell after it. squeeze them for slippage. (evil. requires public mempool. also not possible on Arbitrum.)

  3. Backrunning / Atomic Arbitrage: a big trade just landed. it shifted one pool's price. before the rest of the market adjusts, trade the price gap. (somewhat neutral. arguably good for markets, it forces price convergence. this is what arbx does.)

we build the third one.


chapter 4: why arbitrum

if MEV exists on all blockchains, why only Arbitrum specifically?

let me explain by comparison.

Ethereum Mainnet: this right here is the "Times Square of DeFi". every professional searcher (the MEV industry term for "bots") has dedicated servers co-located near major node operators. they literally have custom TCP relay networks. some pay millions of dollars per year just to submit blocks. on a $31 budget, it's like bringing a butter knife to a gunfight.

Base (OP Stack): Base uses a centralized sequencer that operates as a black box. there is no public pending mempool. no way to see what is coming. you are flying completely blind.

Arbitrum: this is the interesting one.

Arbitrum has something called the Sequencer Feed, which is basically a public WebSocket endpoint at wss://arb1.arbitrum.io/feed that broadcasts transactions the instant they are ordered. not mined. Ordered.

that means you can see what is about to hit the blockchain before it's fully confirmed. THAT is your signal. it is not a perfect crystal ball, but it is real, free, public information. for a small independent bot, that matters enormously.

Arbitrum also uses a First-Come-First-Served (FCFS) model within its transaction ordering. that means the game is at least partially deterministic, if you react fast enough and submit first, you get the trade.

not guaranteed to win. but at least it is possible to play.


chapter 5: flash loans, borrow, trade, repay in one breath

here is the part that sounds like magic but is actually just brilliant contract design.

a flash loan is a loan that must be repaid within the same transaction.

let's think about this for a second.

if you borrow 10,000 USDC via a flash loan, and you don't repay it by the end of your transaction, the entire transaction reverts as if it never happened. the blockchain does not let you keep money you did not repay.

so:

  • if the trade is profitable: you borrow, trade, profit, repay, keep the difference.
  • if the trade fails: everything undoes itself automatically. you never held the money. you lose nothing except the gas fee for the failed attempt.

this is profound. it means a bot with almost no starting capital can act on large opportunities. you do not need $10,000 in your wallet to run a $10,000 trade. you borrow it for one transaction, use it, and return it.

But why Balancer V2 for flash loans?

because Balancer V2 charges zero percent flash loan fees. permanently. by design.

aave V3 (the other major flash loan provider) charges 0.09% (9 basis points) per loan.

9 basis points sounds tiny. i'll tell you; it is not. here's the math:

$10,000 flash loan, $15 gross profit opportunity

With Aave:     fee = $9.00   ->  net = $6.00   ->  marginal, risky
With Balancer: fee = $0.00   ->  net = $15.00  ->  clear winner

on razor-thin arbitrage margins, a free flash loan is a structural edge. competitors paying Aave fees mathematically cannot take opportunities that a Balancer-powered bot can. that is not a small thing.

arbx uses Balancer V2. always.


chapter 6: atomic transactions, all or nothing

let's talk about atomicity, because this is the safety property that makes the whole system viable on a small budget.

the classic analogy is a vending machine.

either:

  • you insert money, press the button, and receive the item

or:

  • the purchase fails and the machine gives back your money

you do not end up in a half-finished state where you paid but got nothing.

that is what an atomic transaction guarantees in DeFi. either the full arbitrage sequence completes:

  1. borrow flash loan
  2. swap token A for token B on Pool 1
  3. swap token B back to token A on Pool 2
  4. repay flash loan
  5. keep profit

...or none of it counts. the chain state resets. you only spent gas. nothing else.

this is the most important safety property of arbx. the bot cannot "get stuck" holding volatile tokens. it cannot be left with an incomplete position. the worst outcome on a failed attempt is a small gas fee, which currently is fractions of a cent on Arbitrum.


part ii: the architecture

chapter 7: the three layers

arbx is organized into three layers.

Layer 1, Eyes     (Ingestion)
Layer 2, Brain    (Opportunity Detection + Simulation)
Layer 3, Hands    (Execution + Submission)

let me be concrete about what this actually means.

Layer 1 (Eyes) watches Arbitrum. it listens to the sequencer feed. it maintains an in-memory map of all known DEX pool states, their reserve balances, token pairs, and fees. it also refreshes that map every block from RPC calls to catch anything the feed missed.

Layer 2 (Brain) is given a "something just happened in pool X" signal. it asks: "is there now a price gap between pool X and any other pool that forms a profitable round-trip?" if yes, it runs the full math, including the real gas cost on Arbitrum, not just the fake estimate, and only passes it forward if the numbers work.

Layer 3 (Hands) is given a verified opportunity. it runs a full local simulation using a fork of the actual Arbitrum blockchain state. if the simulation confirms profit, it encodes the transaction calldata, submits it directly to the Arbitrum sequencer, and records the result.

the pipeline looks like this:

arbx pipeline architecture

every stage is its own Tokio task. they communicate via bounded mpsc channels. if one stage is slow, the channel fills up and the upstream stage knows to back off. that is backpressure, and it is free with channels.


chapter 8: layer 1, the eyes (ingestion)

the ingestion layer is where the bot sees the world.

the sequencer feed

arbitrum makes its feed available at:

wss://arb1.arbitrum.io/feed

this is a WebSocket endpoint. connect to it, and you receive every transaction the Arbitrum sequencer orders, in real time. again, not mined. ordered.

the feed delivers BroadcastFeedMessage structures. these contain compressed batches of transactions. decoding them manually is genuinely painful, the format involves Arbitrum-specific batch compression, L1 rollup message decoding, and custom type structures.

Do not write a manual parser for this.

there is a crate called sequencer_client that handles all of this and returns clean, alloy-typed transaction objects. it saves you literally days of debugging. arbx uses it.

the feed manager does three things with each transaction it receives:

  1. does the calldata begin with a known swap selector?

    • 0x128acb08 = Uniswap V3 swap(address,bool,int256,uint160,bytes)
    • 0x022c0d9f = Uniswap V2 swap(uint256,uint256,address,bytes)
  2. does the target address match a known pool in our store?

  3. if yes to both: emit a DetectedSwap event downstream.

/// A swap transaction detected on the Arbitrum sequencer feed.
pub struct DetectedSwap {
    pub tx_hash:        TxHash,
    pub pool_address:   Address,
    pub selector:       [u8; 4],
    pub calldata:       Bytes,
    pub sequenced_at_ms: u64,
    pub is_large:       bool,  // > 0.1% of pool reserves
}

the reconnect problem

WebSocket connections drop. network hiccups happen. the Arbitrum sequencer occasionally restarts.

if your feed listener panics on disconnect, your bot is dead.

arbx uses an exponential backoff reconnect strategy:

1s → 2s → 4s → 8s → 16s → 32s (cap)

if the feed disconnects, the SequencerFeedManager waits, then tries again. it does not give up. it does not crash the process. it just waits and retries.

this is implemented via a BackoffCalculator struct that is tested in isolation. the backoff logic itself has unit tests that inject mock connection errors and verify the sleep durations. no network connection needed.

the pool state store

every pool's current reserve state lives in a DashMap.

pub struct PoolStateStore {
    inner: Arc<DashMap<Address, PoolState>>,
}

pub struct PoolState {
    pub address:             Address,
    pub token0:              Address,
    pub token1:              Address,
    pub reserve0:            U256,
    pub reserve1:            U256,
    pub fee_tier:            u32,  // bps, e.g. 3000 = 0.3%
    pub last_updated_block:  u64,
    pub dex:                 DexKind,  // UniswapV3 | CamelotV2 | SushiSwap | TraderJoeV1
}

why DashMap and not Arc<Mutex<HashMap>>?

the detection loop, the reconciler, and the feed listener all access pool state simultaneously. a global mutex means every reader waits for every writer.

DashMap uses sharded locking, it splits the map into 64 independent shards. reads and writes on different pools never contend. that matters a lot in a system where the pool store might be accessed thousands of times per second.


chapter 9: the discovery problem, how the bot finds pools

the first time i thought about this, i assumed it would be trivial. "just hardcode the pool addresses."

that works for maybe 10 pools. but here's the catch: Arbitrum has thousands of active liquidity pools across four DEXes.

solution 1: factory scan (seeding)

on startup, arbx queries each DEX factory contract for all deployed pools. this is called the "seed" phase. the pool_seeder module scans factory events back N blocks and registers every pool it finds into the PoolStateStore.

this gives the bot a complete initial picture of the market.

solution 2: feed-first discovery (probe_pool)

what about pools that were deployed after the seed scan? or pools the initial scan missed?

when the feed delivers a transaction whose target address is unknown, the bot does something clever. it calls probe_pool(), a function that sends two quick on-chain calls to the unknown address:

  1. try getReserves(), the V2 interface.
  2. if that fails, try slot0(), the V3 interface.

if either returns valid data, it auto-registers the address as a new pool.

this means the pool store grows organically. the bot discovers new pools as they transact, without requiring any manual curation.

// Simplified: probe an unknown address to determine if it's a DEX pool
pub async fn probe_pool(
    address: Address,
    provider: &RootProvider<Ethereum>,
) -> Option<PoolState> {
    // Try V2 first (simpler)
    if let Ok(reserves) = try_v2_probe(address, provider).await {
        return Some(PoolState::from_v2(address, reserves));
    }
    // Try V3 if V2 fails
    if let Ok(slot0) = try_v3_probe(address, provider).await {
        return Some(PoolState::from_v3(address, slot0));
    }
    None
}

chapter 10: the ghost in the feed, a problem i had no clue about

this one took quite a while to figure out. and it broke the entire validation plan before i discovered it.

i assumed the Arbitrum sequencer feed would show me pool swap transactions. when a user swaps ETH for USDC on Uniswap, i expected to see the pool.swap() call in the feed.

but boy was i wrong.

the Arbitrum sequencer feed shows only the top-level call in a transaction's call stack. when a user swaps via the Uniswap Router, the call stack looks like:

User → UniswapV3Router.exactInputSingle()
           ↓
       UniswapV3Pool.swap()   ← this is the economically important call

the feed only delivers the router call. the internal pool.swap() is invisible.

this matters because 99% of user DEX trades go through a router. direct pool calls are rare, mostly from other MEV bots.

so my feed-based detection was seeing practically nothing.

the fix: block reconciler as ground truth

the BlockReconciler is the actual workhorse of the ingestion layer. every time a new block is mined, it calls getReserves() on every tracked pool via RPC, compares the new reserves to what is stored, and updates any that changed.

if reserves changed, it means a swap happened in that pool. that triggers the detection path.

the feed is still valuable, it delivers transactions before they are mined, giving the bot an early warning signal. but the reconciler is the reliable ground truth. think of them as two complementary data sources:

  • feed: fast, incomplete. good for early detection.
  • reconciler: slow, complete. good for correctness.

the local fork problem (Anvil BlockPoller)

during validation, i ran the bot against a local Anvil fork. the live sequencer feed connected to the real Arbitrum network, but the blockchain i was simulating against was my local fork.

problem: the live feed delivered real Arbitrum transactions, but my local fork wasn't advancing in real time. blocks only mine when explicitly triggered.

solution: BlockPoller, a separate ingestion component that polls the local Anvil fork every 250ms, looks for new blocks, and extracts direct pool swap transactions from them by matching function selectors against the pool store.

BlockPoller is only enabled when the rpc_url config value contains 127.0.0.1. on mainnet, it stays off.


chapter 11: layer 2, the brain (path scanning)

when the ingestion layer says "something happened in pool P", the detection layer wakes up and asks one question:

is there now a price gap that forms a profitable circular route through pool P?

two-hop paths

the current strategy uses two-hop circular paths:

Token A → Pool 1 → Token B → Pool 2 → Token A

you start with Token A. you trade through Pool 1 to get Token B. then you trade Token B through Pool 2 to get back Token A. if you end up with more Token A than you started with, there is an arbitrage.

real example:

start with 10,000 USDC
→ buy 5 ETH on Uniswap V3 (pool1, price: 1980 USDC/ETH)
→ sell 5 ETH on Camelot V2 (pool2, price: 2010 USDC/ETH)
→ end with 10,050 USDC
→ gross profit: 50 USDC

the PathScanner iterates over all pool pairs in the store and builds candidate two-hop routes. for each candidate, it runs the AMM math to compute estimated profit.

v2 vs v3 price math

there are two DEX price models in the codebase.

V2 (Uniswap V2-style: Camelot V2, SushiSwap, Trader Joe V1):

output = (reserveOut * amountIn * 997) / (reserveIn * 1000 + amountIn * 997)

this is the constant-product formula with a 0.3% fee. straightforward arithmetic.

V3 (Uniswap V3-style):

V3 uses concentrated liquidity. the reserves are not evenly distributed across all prices, they are concentrated in specific price ranges called "ticks". the pricing formula involves sqrtPriceX96, a square root of the price encoded as a fixed-point Q64.96 number.

accurate V3 simulation requires simulating through active tick ranges. for Phase 1, arbx uses a simplified approximation for the threshold check, then lets the full revm simulation (see Layer 2.5) be the authoritative answer before any submission.


chapter 12: the hidden tax, arbitrum's 2d gas model

this is the most important implementation detail in the entire project. and i honestly cannot stress this enough.

if you are building anything on Arbitrum, PLEASE read this chapter twice.

what eth_estimategas does not tell you

on Ethereum mainnet, eth_estimateGas gives you the total gas cost of a transaction. that is the full picture.

on Arbitrum, it gives you only half the picture.

Arbitrum is a Layer 2 rollup. every transaction on Arbitrum eventually gets posted back to Ethereum mainnet as calldata, for data availability. that posting costs real ETH on mainnet.

so every Arbitrum transaction actually has two costs:

  1. L2 execution gas, what eth_estimateGas tells you. usually fractions of a cent.
  2. L1 calldata gas, the cost of posting the transaction data to Ethereum. this can spike to $1.50+ per transaction when Ethereum mainnet is congested.

here's what happens if you ignore the L1 cost:

estimated cost via eth_estimateGas: $0.05
actual total cost:                  $1.55
your simulated profit:              $1.20
actual net:                         -$0.35  ← you just lost money

on a $31 budget, a few of these wipe you out before you ever see a profitable execution.

the nodeinterface precompile

arbitrum provides a special contract at:

0x00000000000000000000000000000000000000C8

this is called the NodeInterface precompile. it exposes a function called gasEstimateL1Component() that returns the L1 calldata cost for a given transaction.

arbx calls this before every profitability decision.

const NODE_INTERFACE: Address =
    address!("00000000000000000000000000000000000000C8");

// Returns the L1 calldata gas cost component
// Total true gas cost = L2 execution gas + L1 calldata gas
l1_gas_cost = node_interface.gasEstimateL1Component(to, calldata)

the true profit formula is:

gross_profit   = output_amount - input_amount
flash_loan_fee = 0              (Balancer V2 always charges 0%)
l2_gas_cost    = eth_estimateGas() * l2_gas_price
l1_gas_cost    = gasEstimateL1Component() * l1_base_fee
total_gas_cost = l2_gas_cost + l1_gas_cost
net_profit     = gross_profit - total_gas_cost

the GasFetcher trait abstracts all of this so it can be mocked in tests:

#[async_trait]
pub trait GasFetcher: Send + Sync {
    async fn l2_gas_price_wei(&self)                           -> anyhow::Result<u128>;
    async fn estimate_l2_gas(&self, to: Address, data: Bytes)  -> anyhow::Result<u64>;
    async fn estimate_l1_gas(&self, to: Address, data: Bytes)  -> anyhow::Result<u64>;
    async fn l1_base_fee_wei(&self)                            -> anyhow::Result<u128>;
    fn     eth_price_usd(&self)                                -> f64;
}

every test that exercises profit calculation uses MockGasFetcher, generated by the mockall crate. no live network required.

the dynamic threshold

do not hardcode a minimum profit floor. a hardcoded "$2 minimum" is right when gas is cheap and dangerously wrong when gas is expensive.

arbx uses a dynamic threshold:

min_profit = total_gas_cost * 1.1 + $0.50
  • * 1.1 provides a 10% buffer above the true gas cost.
  • + $0.50 covers slippage variance and estimation error.

this adapts automatically. when mainnet gas spikes and L1 calldata costs jump, the threshold rises proportionally. the bot skips opportunities that would be marginal. when gas is cheap, the threshold falls and more opportunities clear.


chapter 13: layer 2.5, the simulator (revm)

even after the profit threshold check, arbx does not submit anything.

it simulates first.

why though?

the profit threshold check uses a simplified AMM formula and estimated reserves. it is fast but approximate. the real trade involves:

  • Balancer V2's flash loan callback flow
  • two pool swaps with real storage slot reads
  • a require profit check at the contract level
  • ERC-20 transfer calls

any of these can fail or produce different numbers than the estimate predicted. state could have changed between when reserves were last refreshed and now. another bot could have already taken the opportunity.

revm, a rust evm

revm is a Rust implementation of the Ethereum Virtual Machine. you can give it a fork of real chain state, a transaction, and it runs the EVM locally, completely in-process.

no external call. no network round trip. just Rust code executing bytecode.

but why revm instead of eth_call?

eth_call would work, but it makes an RPC call for every simulation. at 100ms latency per RPC call, that is 10 simulations per second. that is too slow for a reactive MEV system.

revm runs in roughly 2-5ms per simulation in practice, because the state is cached locally. that is 200-500 simulations per second. a very different performance tier.

// Simplified simulation flow
pub async fn simulate_opportunity(
    &self,
    opportunity: &Opportunity,
    executor_address: Address,
) -> anyhow::Result<SimulationResult> {
    // 1. Fork current Arbitrum state via RPC
    let mut db = self.fork_at_latest().await?;

    // 2. Encode the exact calldata that would be submitted on-chain
    let calldata = CallDataEncoder::encode_execute_arb(
        &opportunity.path,
        opportunity.net_profit_wei,
    );

    // 3. Build and run the EVM transaction
    let result = self.run_call(&mut db, executor_address, calldata).await?;

    // 4. Check the execution result
    match result {
        ExecutionResult::Success { .. } => Ok(SimulationResult::Success { .. }),
        ExecutionResult::Revert { output, .. } => Ok(SimulationResult::Failure {
            reason: CallDataEncoder::decode_revert_reason(&output),
        }),
        _ => Ok(SimulationResult::Failure { reason: "halt".into() }),
    }
}

the ArbDB type alias captures the full state fork setup:

type ArbDB = CacheDB<WrapDatabaseAsync<AlloyDB<Ethereum, Arc<RootProvider<Ethereum>>>>>;

read that from inside out:

  • AlloyDB, lazily fetches missing Arbitrum state slots from the RPC provider on first access.
  • WrapDatabaseAsync, wraps the async AlloyDB for use in a sync EVM context.
  • CacheDB, caches every fetched slot in memory, so each slot costs at most one RPC call per fork instance.

every simulation gets a fresh ArbDB. no shared state between concurrent simulations. completely safe for parallel execution.


chapter 14: layer 3, the hands (smart contract)

let's talk about ArbExecutor.sol.

this is the on-chain counterpart to the Rust engine. when the bot is ready to execute a trade, it calls this contract, which orchestrates the entire flash-loan-swap-repay sequence.

the balancer v2 flash loan flow

balancer V2 uses a callback pattern. you don't just "take" a flash loan. you call the Vault, and the Vault calls you back with the funds.

here's the full flow:

Bot wallet
    ↓ calls executeArb()
ArbExecutor.sol
    ↓ calls balancerVault.flashLoan(...)
Balancer V2 Vault
    ↓ transfers tokens to ArbExecutor
    ↓ calls receiveFlashLoan() on ArbExecutor
ArbExecutor.sol (inside the callback)
    ↓ calls Pool 1 swap
    ↓ calls Pool 2 swap
    ↓ checks: final_balance >= flash_loan_principal + min_profit
    ↓ transfers principal back to Balancer Vault
    ↓ transfers profit to owner wallet
Balancer V2 Vault
    ↓ confirms repayment, closes loan

the critical line is the profit check:

require(
    tokens[0].balanceOf(address(this)) >= amounts[0] + minProfitWei,
    "No profit"
);

if the final balance is not enough, the entire transaction reverts. no money moves. the only cost is gas.

this is the double protection system. the Rust engine simulates before submitting. the Solidity contract enforces on-chain. you cannot lose money from a bad trade. you can only lose gas.

four dex swap paths in one contract

the contract supports four DEX families. but actually, from the contract's perspective, there are really only two swap models:

V2 model (Camelot V2, SushiSwap, Trader Joe V1):

reserve-based. direct-output oriented. you tell the pool exactly how many tokens you want out and how many you're putting in.

IUniswapV2Pair(pool).swap(amount0Out, amount1Out, address(this), "");

V3 model (Uniswap V3):

concentrated liquidity. callback-driven. you initiate the swap and the pool calls you back to deliver the owed tokens. the swap is settled inside the callback.

IUniswapV3Pool(pool).swap(
    address(this),
    zeroForOne,
    amountSpecified,
    sqrtPriceLimitX96,
    abi.encode(pool, tokenIn)
);

both models are supported in ArbExecutor.sol. the bot encodes which model to use per pool in the pool_a_kind and pool_b_kind fields of the ArbParams struct.


chapter 15: the full data flow

let me walk through a complete cycle, start to finish. here it is as a sequence diagram first, then i'll explain each step:

arbx full data flow

(if this is too small for you, here's an external link to view the diagram)

t=0ms: large swap is ordered on the Arbitrum sequencer. a whale just sold 500 ETH into the Camelot V2 WETH/USDT pool, moving the price down.

t=~1ms: SequencerFeedManager receives the transaction from the feed WebSocket. it matches the V2 swap selector and a known pool address. emits a DetectedSwap event.

t=~2ms: PathScanner wakes up. it queries the pool store for all pools that share a token with the affected Camelot pool. it finds a Uniswap V3 WETH/USDT pool that still has the pre-trade price. builds a candidate two-hop path: USDT → Camelot → WETH → UniswapV3 → USDT.

t=~3ms: ProfitCalculator runs the AMM math. computes gross profit. queries NodeInterface for L1 calldata cost. computes net profit. checks against dynamic threshold. it clears.

t=~5ms: ArbSimulator forks current Arbitrum state. runs the full ArbExecutor.executeArb() call through revm. confirms profitable outcome. simulation succeeds.

t=~8ms: TransactionSubmitter encodes calldata with the ArbParams struct, estimates gas with a 1.2x buffer, signs the transaction, submits directly to the Arbitrum sequencer RPC.

t=~10ms: transaction is sequenced. either it confirms (profit captured) or it reverts with "No profit" (another bot was faster, state changed).

the whole thing from feed event to submission in under 10 milliseconds.


chapter 16: the tokio pipeline, how all of this runs concurrently

all of this needs to happen at the same time. feed listening. block reconciliation. detection. simulation. submission. budget watching.

arbx uses Tokio, Rust's async runtime, and organizes everything as supervised tasks.

the main loop looks roughly like this:

// Two mpsc channels connecting the layers
let (swap_tx, swap_rx) = mpsc::channel::<DetectedSwap>(1024);
let (opp_tx, opp_rx)   = mpsc::channel::<Opportunity>(256);

// Boot all tasks
let feed_task      = tokio::spawn(sequencer_feed.run(swap_tx));
let reconcile_task = tokio::spawn(reconciler.run());
let detect_task    = tokio::spawn(detection_loop(swap_rx, opp_tx, ...));
let execute_task   = tokio::spawn(execution_loop(opp_rx, ...));
let watchdog_task  = tokio::spawn(budget_watchdog(pnl_tracker.clone()));

// Supervisor: if ANY task exits, shut everything else down
tokio::select! {
    _ = feed_task      => error!("feed task exited"),
    _ = reconcile_task => error!("reconciler exited"),
    _ = detect_task    => error!("detection loop exited"),
    _ = execute_task   => error!("execution loop exited"),
    e = watchdog_task  => {
        // If budget is exhausted, watchdog returns Err → clean shutdown
        info!("budget watchdog triggered shutdown: {:?}", e);
    }
    _ = shutdown_signal() => info!("shutdown signal received"),
}

the channel capacities are not arbitrary:

  • DetectedSwap(1024): swaps arrive fast, detection should have headroom.
  • Opportunity(256): simulations are expensive; a smaller buffer prevents the execution loop from getting backlogged.

if detection is slower than the feed, the swap channel fills and the feed listener naturally slows. backpressure, for free.

the budget_watchdog runs every 60 seconds. it checks the PnlTracker to see how much budget remains. if the bot drops below the kill threshold ($2.00 remaining), the watchdog returns an Err, which causes the supervisor's select! to trigger, and everything shuts down cleanly.


chapter 17: observability, the funnel you must track

how do you know if the bot is healthy? how do you know where it is losing opportunities?

arbx uses 8 Prometheus metrics organized as a funnel.

each stage of the pipeline increments a counter. reading the funnel tells you exactly where things are going.

MetricWhat it tracks
opportunities_detectedSwaps seen by the feed or reconciler
opportunities_cleared_thresholdOpportunities that passed the profit check
opportunities_cleared_simulationOpportunities that passed revm simulation
transactions_submittedActual transactions sent on-chain
transactions_succeededOn-chain successes
transactions_reverted{reason=...}On-chain failures, labelled by revert reason
net_pnl_weiRunning profit and loss in wei
gas_spent_weiTotal gas consumed

reading the funnel:

SymptomLikely cause
High detections, few threshold clearsProfit floor too high, or gas is expensive
High threshold clears, few sim clearsReserve model is stale, or pool math is wrong
High sim clears, high revert rateState races, you are being beaten by faster bots
Low revert rate, negative PnLGas eating all profit, need bigger opportunities

the metrics are exposed via a hand-rolled HTTP server on port 9090. standard Prometheus-compatible format. scrape it with Grafana or just curl http://localhost:9090/metrics.

all 8 metrics live in an isolated Registry, not the default global Prometheus registry. this means test instances are completely independent of each other. no shared state between tests.


part iii: the build

chapter 18: the toolchain

let me be concrete about the actual tech stack and most importantly, why each choice was made.

Rust for the core engine. no garbage collector. zero-cost abstractions. the borrow checker catches whole classes of concurrency bugs at compile time. hot-path latency is deterministic, not dependent on GC pauses.

Solidity + Foundry for the smart contract. foundry is the modern Rust-based Ethereum development framework. it runs tests written in Solidity itself, against real fork state. the test suite uses Foundry's fork_test annotation to run against live Arbitrum mainnet state.

alloy-rs for all Ethereum interaction. alloy is the modern replacement for the older ethers crate in Rust. it handles RPC calls, ABI encoding, signing, address types, and type-safe transaction construction.

revm for simulation. pure-Rust EVM implementation. can fork any chain state via an RPC provider. used for in-process simulation before any on-chain submission.

Tokio for async. the de facto async runtime for Rust. everything in the pipeline runs as Tokio tasks communicating via mpsc channels.

DashMap for the pool store. concurrent HashMap with sharded locking. lock-free reads under low contention. drop-in replacement for Arc<Mutex<HashMap>> with much better throughput under concurrent access.

proptest for property tests. the property test suite runs each mathematical formula against 10,000+ random inputs to prove correctness across the full input space, not just cherry-picked cases.

mockall for unit testability. every trait that makes an RPC call (GasFetcher, ReserveFetcher, TransactionSender) has a mock generated by mockall. tests never touch real network.


chapter 19: building the smart contract first

this is counterintuitive advice: build the Solidity contract and its tests before touching the Rust engine.

why?

because the contract defines the interface that the Rust encoder must match exactly. if you build the Rust encoder first and then discover the contract ABI is different, you are rewriting code in both directions.

the ArbExecutor.sol contract was built with full Foundry TDD:

  1. write the test first: "given a Balancer flash loan, when i swap ETH → USDC and USDC → ETH in two pools, i should net a profit."
  2. run the test against a forked Arbitrum mainnet state.
  3. watch it fail.
  4. write the contract code to make it pass.

the full test suite for the contract covers:

  • fork tests (23): real Balancer V2 Vault, real Uniswap V3 pools, real Camelot V2 pools, real chain state at a pinned block.
  • fuzz tests (3): random flash loan amounts and pool parameters. prove no edge case breaks the profit enforcement.
  • invariant tests (3): core guarantees hold under arbitrary state. the require("No profit") check must always fire when the trade is losing.

running these with forge test --fork-url $ARBITRUM_RPC_URL executes real token transfers against real deployed contracts, using a snapshot of the real chain state. no mocking. no stubs. real money behavior, safely in a sandboxed fork.


chapter 20: the config system

configuration is loaded from TOML files in the config/ directory.

the innovation here is ${VAR} expansion. any TOML value can reference an environment variable:

[network]
rpc_url            = "${ARBITRUM_RPC_URL}"
sequencer_feed_url = "wss://arb1.arbitrum.io/feed"
chain_id           = 42161

[execution]
contract_address   = "${ARB_EXECUTOR_ADDRESS}"
private_key        = "${PRIVATE_KEY}"
dry_run            = false

[budget]
total_usd          = 31.0
kill_at_usd        = 2.0
warn_at_usd        = 5.0

private keys, RPC URLs, and contract addresses never live in tracked files. they stay in .env, which is gitignored.

Config::load_str() is used in tests to construct configs from inline TOML strings without any filesystem access. every config test works fully offline.


chapter 21: the pnl tracker

the PnlTracker is the bot's financial conscience.

it records every submission: gas spent, profit or loss, cumulative totals. it persists state to disk atomically (write to .tmp, then rename, preventing corrupted state files on crash). when the bot restarts, it reloads from disk and continues from where it left off.

it also enforces the budget limits:

pub fn is_budget_exhausted(&self) -> bool {
    self.remaining_usd() <= self.kill_at_usd
}

pub fn is_budget_low(&self) -> bool {
    self.remaining_usd() <= self.warn_at_usd
}

the budget_watchdog task polls these methods every 60 seconds. if is_budget_exhausted() returns true, it returns an error, which triggers the supervisor to shut down everything cleanly.

this means the bot cannot spend more than the configured budget. even if something goes catastrophically wrong, the kill switch fires before the account is drained.


part iv: the "ah fuck" moments

chapter 22: the ghost chain problem

the issue: my phase 9 plan was to validate the bot on Arbitrum Sepolia, the public testnet. it has real infrastructure, real contracts, a real sequencer feed. it seemed logical.

the reality: Arbitrum Sepolia has virtually zero real DEX activity. the Uniswap V3 pools exist in name only. no one is trading. the Balancer V2 Vault is deployed but unused. the sequencer feed is alive but delivers nothing economically interesting.

so i deployed the bot to Sepolia and waited.
nothing.
no swaps. no opportunities. no data.
the bot was working perfectly. there was just nothing to detect.

the fix:

instead of validating against a dead testnet, i pivoted to an Anvil mainnet fork, a local fork of real Arbitrum mainnet at a specific historical block (block 105,949,098). this gives:

  • real pool state (real reserves, real prices)
  • real contract addresses (Uniswap, Camelot, Balancer)
  • full local EVM control

the live sequencer feed still connects to real Arbitrum and delivers real event data. the simulation runs against the pinned local fork. dry_run = true means no real money is ever spent.

this is the closest safe approximation to mainnet behavior before a live run.

i also added a --self-test flag to the binary that injects synthetic pool states and a fake DetectedSwap event into the pipeline, then verifies the detection logic fires correctly. this runs in under a second and requires no network connection at all. useful for CI and smoke testing.

lesson: public testnets are useless for MEV validation. mainnet forks are the real tool.


chapter 23: the invisible swaps problem

the issue: during the Anvil fork validation, i connected the bot, watched the logs, and saw... nothing. the feed was connected. blocks were mining. but the pool store was reporting zero detections.

i stared at this for a while.

the feed was producing transactions. the pool store had pools. but then why was nothing matching?

the root cause: i described this in chapter 10, but i want to give the full story here.

i was filtering the feed for transactions targeting known pool addresses with known swap selectors. the logic was sound.

what i did not account for: most swaps hit a router, not a pool directly.

when a user swaps on Uniswap, the actual calldata target is 0xE592427A... (the Uniswap Universal Router), not 0x88e6A0c2... (the WETH/USDC pool). the router's internal call to the pool is never delivered by the feed.

every hardcoded pool address filter i had was matching the wrong thing.

the fix, part 1: BlockPoller

i added the BlockPoller component. instead of relying only on the feed, it polls the local Anvil fork every 250ms for new blocks. it extracts transactions from those blocks by matching function selectors against the pool store's addresses.

this works because the full call stack is visible in confirmed blocks, not just the top-level call.

the fix, part 2: probe_pool

for production mainnet, where BlockPoller is not used, the real solution is block reconciliation. the BlockReconciler calls getReserves() on every tracked pool every block and detects any changes. any pool that shows a reserve change gets flagged for detection.

the feed is still connected and still useful for latency (it can trigger detection before the block is mined), but the reconciler is the reliable signal.

lesson: read the Arbitrum documentation VERY carefully before assuming feed behavior. the sequencer feed is not a full call trace; it is a top-level transaction stream.


chapter 24: the 2d gas blind spot (the expensive lesson)

the issue: during early profit calculation testing, i wrote a test that simulated a small WETH/USDC trade. the math said $0.80 gross profit, $0.05 gas cost, $0.75 net. excellent.

so i shipped that code.

then during Anvil fork validation, i added logging for actual gas costs. i was seeing gas costs of $0.40-$1.20 per simulated submission, not $0.05.

so what was i missing?

the root cause: i was using eth_estimateGas for the gas estimate.
on Arbitrum, that only returns the L2 execution gas component. i was not querying the NodeInterface precompile for the L1 calldata cost.

the L1 calldata cost for a typical arbx transaction can be $0.30-$1.50, depending on mainnet congestion. on a busy mainnet day, that completely consumes the profit from a small arbitrage.

the fix: implement AlloyGasFetcher::estimate_l1_gas() using the NodeInterface precompile and make both components mandatory inputs to ProfitCalculator::is_profitable().

the signature makes it impossible to forget:

pub fn is_profitable(
    &self,
    gross_profit_wei: U256,
    l2_gas_cost_wei:  U256,  // required explicitly
    l1_gas_cost_wei:  U256,  // required explicitly
    eth_price_usd:    f64,
) -> bool { ... }

you cannot call this function without providing both components. the type system enforces correctness.

lesson: on Arbitrum, eth_estimateGas is a lie by omission. always query the NodeInterface. always.


chapter 25: the dashmap trap (a race condition that wasn't)

the non-issue: early on, i debated whether DashMap was safe to use from multiple tasks simultaneously without any additional locks.

my key concern: "if the reconciler is updating pool reserves while the path scanner is reading them, can i get a torn read? can i see a partial update?"

the reality: DashMap guarantees atomicity at the entry level. a read of a PoolState struct returns a consistent snapshot, you either see the old value or the new value, never a partially written one. the sharded locking prevents torn reads within a single entry.

the system is safe as designed. but this is worth understanding, not just trusting.

the real DashMap consideration: clone your PoolState before doing expensive calculations on it. the DashMap entry holds a reference guard, and holding that guard during a multi-millisecond computation will unnecessarily block writers on that shard.

// Good: clone, release the guard, then compute
let state: PoolState = store.get(&pool_address)?.clone();
let profit = expensive_calculation(&state);

// Bad: hold the guard during expensive computation
let guard = store.get(&pool_address)?;
let profit = expensive_calculation(&*guard);  // blocks shard for whole computation

lesson: DashMap is safe. but understand its locking semantics before writing hot-path code that relies on guard lifetimes.


chapter 26: the sepolia config that silently did nothing

the issue: i had a config/sepolia.toml with this:

[strategy]
min_profit_floor_usd = 2.00

and zero pool addresses configured. the bot connected to Sepolia, received zero swap events, and logged nothing suspicious. it just... ran quietly, doing nothing.

no errors. no warnings. just pure and awkward silence.

the problem: an empty pool list is not invalid configuration. the bot starts, connects to the feed, and waits for swaps on pools it knows about. if it knows about zero pools, it matches nothing.

the fix: add a startup validation check: if the pool store is empty after seeding, log a prominent WARN and optionally fail fast. empty pool stores are almost always a configuration error, not an intentional state.

also: add an integration test that validates the config file itself against a schema. config errors that are silent at startup are the worst kind.

lesson: validate your configuration inputs thoroughly. silent empty states are harder to debug than loud startup failures.


part v: testing, validation, and going live

chapter 27: the testing strategy (heavy tdd)

every line of arbx is written test-first, or at least test-alongside.

the rule is simple: cargo test --workspace must pass at zero failures at every commit. no exceptions. the pre-commit hook enforces this.

the test types and what each proves:

unit tests (186): every component in isolation. feed parsing, AMM math, profit formulas, config loading, PnL accounting. none of these require a network connection.

property tests (30+): using proptest, the mathematical formulas are verified against 10,000+ randomly generated inputs. the AMM output formula, profit threshold checks, 2d gas calculations. if there is an edge case in the math, a property test will usually find it.

fork tests (23 in Solidity): using Foundry's fork_test against pinned Arbitrum mainnet state. real pools, real Balancer vault, real token transfers. tests the contract's behavior under real chain conditions.

fuzz tests (3 in Solidity): random inputs into the contract. prove that the require("No profit") check cannot be bypassed.

invariant tests (3 in Solidity): core properties that must hold under any sequence of operations. the contract's profit requirement is an invariant.

chaos tests (9): fault injection into the sequencer feed connection (drops, corrupted data, timeouts). verify the reconnect logic works. Uses mock WebSocket servers.

integration tests: the full Rust pipeline with real components, injected synthetic swap data. verify the end-to-end flow without a live network.

the full suite of 186+ Rust tests runs in under 30 seconds on a laptop. fork tests require a live RPC URL and are skipped in CI without one.


chapter 28: the anvil fork validation

before spending a single cent on mainnet, run the full bot against a local Anvil fork.

here's what the validation proves:

  1. the bot can seed real pool state from factory events.
  2. the live feed connects and delivers events.
  3. the detection pipeline triggers on real swap activity.
  4. the simulator runs against real chain state.
  5. the execution path (dry-run mode) completes without errors.

the setup:

# terminal 1: start Anvil fork of Arbitrum mainnet
anvil --fork-url $ARBITRUM_RPC_URL \
      --fork-block-number 105949098 \
      --block-time 2

# terminal 2: boot arbx against the local fork, dry-run mode
cargo run --release --bin arbx -- --config config/anvil_fork.toml

# terminal 3: check the metrics funnel
curl http://localhost:9090/metrics | grep opportunities

the synthetic swap injection: the run_anvil_fork.sh script injects a cast send transaction directly into the local Anvil fork 12 seconds after startup, a synthetic direct pool swap that guarantees the BlockPoller detection fires on every test run. this ensures the validation always produces at least one detection event, proving the pipeline is wired correctly.

the definition of done for Phase 9.2:

smoke test PASS
opportunities_detected > 0
opportunities_cleared_simulation > 0  (ideally)
No panics or unexpected errors in logs

result: smoke test PASS, opportunities_detected = 4, all 186 tests green.


chapter 29: the $31 mainnet plan (april 2026)

as of march 2026, arbx has not been deployed to mainnet yet. the code is ready. the scripts are ready. the kill switch is armed. i'm just waiting for april, when i'll have money again to fund the run.

the planned budget:

CategoryAmount
Total budget$31 USD
Deploy cost (estimate)~$4 USD
Execution budget~$27 USD
Warning threshold$5 remaining
Kill threshold$2 remaining

the deployment script requires an explicit typed confirmation before broadcasting. not a "are you sure? (y/n)" prompt. a specific string you must type:

Type "deploy to mainnet" to confirm:

same for the launch script:

Type "run on mainnet" to confirm:

both are intentional. nothing deploys to mainnet by accident.

when the run happens, the monitoring workflow will be:

# Real-time budget report
./scripts/pnl_report.sh

# Watch for warning signs
grep "budget_warn\|budget_exhausted\|revert_reason\|l1_gas_cost" \
     logs/mainnet_*.log | tail -20

the target pairs are mid-tier (ARB/USDT, WBTC/ETH) with strict profit floors and the kill switch armed. the plan is to let it run until either the budget is spent or a bug forces a stop, whichever comes first.

phase 10 starts in april.


chapter 30: performance, the benchmarks

the hot paths are benchmarked with Criterion, the standard Rust benchmarking library.

five benchmarks, measured against established baselines:

BenchmarkResultBaselineStatus
AMM price calculation (V2)17 ns1000 ns58x under target
Profit threshold check21 ns10000 ns476x under target
Calldata encoding133 ns100000 ns750x under target
Full market scan (100 pools)467 µs1000 µs2x under target
Pool state lookup51 ns100 ns2x under target

to give these numbers context: 17 nanoseconds for an AMM price calculation means the bot can compute the price on roughly 58 million pools per second if that were the bottleneck. it is not. the real bottleneck is network latency and simulation time.

for comparison, a TypeScript implementation of the same V2 AMM formula through the Uniswap SDK typically lands in the 200,000–500,000 ns range. arbx runs the same math in 17 ns. that is the advantage of compiled Rust with no allocations on the hot path.

the full market scan at 467 µs for 100 pools means that even at peak load, the detection layer is spending under half a millisecond deciding which opportunities to pursue. that is well within the target latency for a reactive MEV system.


part vi: what is and isn't implemented

chapter 31: the honest inventory

let me be clear about what is production-quality code and what is not yet done.

what is fully implemented and tested:

  • sequencer feed listener with exponential backoff reconnect
  • pool state store with concurrent DashMap
  • block reconciler with RPC reserve refresh
  • feed-first pool discovery via probe_pool
  • two-hop path scanner
  • profit calculator with full 2d gas model (L2 + L1 components)
  • revm fork simulation infrastructure
  • calldata encoder for ArbExecutor.sol
  • ArbExecutor.sol, flash loan, V2 swaps, V3 swaps, profit enforcement
  • PnL tracker with atomic disk persistence
  • budget watchdog with configurable kill switch
  • all 8 Prometheus metrics
  • full test suite: unit, property, integration, chaos, fork, fuzz, invariant
  • 5 Criterion benchmarks
  • mainnet deploy and launch scripts with explicit confirmation
  • TOML config with ${VAR} env expansion

what is not yet implemented (phase 11 and beyond):

  • full V3 concentrated-liquidity tick simulation (currently simplified)
  • three-hop and longer arbitrage paths (phase 11 adds petgraph)
  • Camelot V3 and Trader Joe V2 support
  • Timeboost express lane participation
  • liquidation detection (Aave V3 on Arbitrum has large positions)
  • VPS co-location (currently running from local machine, adds ~200ms latency)
  • Flamegraph-driven profiling and hot-path optimization
  • dedicated price feed for ETH/USD (currently polled, not streamed)

chapter 32: the known risks

state races:
between simulation and submission, another bot may take the same opportunity. the contract's require("No profit") catches this, you lose only the gas cost of the revert. high "No profit" revert rate means you are being consistently beaten on speed.
fix: reduce simulation-to-submission latency, or focus on less competitive pairs.

Timeboost structural disadvantage:
Arbitrum's Timeboost auction gives winning searchers a 200ms express lane advantage per round. on a $31 budget, you cannot bid for that advantage.
mitigation: focus on opportunities that persist for multiple blocks (mid-tier pairs) rather than single-block races (USDC/ETH).

reserve staleness:
the reconciler runs every block. between reconciliation windows, reserves may change. simulation will reject stale opportunities, but you may be wasting simulation time on routes that were invalidated. tighter reconciliation intervals help but increase RPC call volume.

VPS latency:
running from India to the Arbitrum sequencer (in a US data center) adds ~150-200ms round-trip latency. that is an enormous disadvantage in a speed-sensitive game. phase 11 priority: co-locate on Hetzner Frankfurt.


part vii: what i learned

chapter 33: the technical lessons

1. test contracts against real forks from day one.

the moment you run forge test --fork-url $ARBITRUM_RPC_URL, you are testing against real Balancer V2 behavior, real Uniswap V3 behavior, real token transfer semantics. there is no substitute. mock environments hide bugs that only surface when you interact with the actual deployed contracts.

2. read the source code, not just the docs.

the Arbitrum sequencer feed behavior, "delivers only top-level calls", is documented, but only if you know to look for it. i found it by reading the sequencer_client crate source and the Arbitrum node source code. relying only on high-level documentation cost me a day of confusion.

3. the trait abstraction pattern is worth the boilerplate.

every interface that makes a network call is defined as a Rust trait: GasFetcher, ReserveFetcher, TransactionSender. the production implementation uses real alloy/RPC calls. the test implementation uses mockall mocks.

this felt like overkill at the start. by the end, i had hundreds of tests that run in milliseconds without a network connection, and i could change any implementation detail without rewriting the test suite. absolutely worth it.

4. bounded channels are architecture, not a detail.

the mpsc::channel<DetectedSwap>(1024) and mpsc::channel<Opportunity>(256) capacities encode architectural constraints. a full detection channel tells the feed listener the system is overloaded. a full opportunity channel tells the detector to slow down. backpressure is the system telling itself its own limits.

5. the kill switch is not a safety net. it is a core design element.

i added the budget watchdog as almost an afterthought, late in phase 6. in retrospect, it should have been phase 1. any system that can spend real money must have an automated, tested, auditable spending limit. code the kill switch before you code anything that spends.


chapter 34: the non-technical lessons

on scope:

i originally planned to build a three-hop arbitrage bot with Timeboost participation on a co-located VPS. phase 1 was supposed to take a few days.

it took three weeks. march 2 to march 23.

i am not complaining. three weeks for a fully tested, benchmarked, production-ready MEV engine with a custom Solidity contract and 186 passing tests is, honestly, kind of wild. that is entirely a consequence of the vibecoding approach. claude writes fast. really fast.

but every phase still revealed new complexities. the sequencer feed edge case. the 2d gas model. the Anvil fork validation path. the budget infrastructure. each of those was a real problem that needed real thinking to solve, even if the implementation itself was written in minutes.

the lesson is not "scope correctly." the lesson is "be honest when you discover scope you missed, and adjust rather than pretend it was always there."

on the $31 budget:

building a trading bot that can only afford to make maybe 100-200 trade attempts before the budget runs out sounds like a constraint. funnily enough though, it turned out to be the best feature.

a tight budget forces discipline. every decision, which pairs to trade, what profit floor to set, how aggressive to be, has a real cost. there is no tolerance for "i'll tune this later." when you have $27 to work with, "later" is never.

on the MEV ecosystem:

this is a brutally competitive space. professional searchers run dedicated hardware in data centers co-located with sequencer nodes. they have access to Timeboost. they have teams.

a solo developer with a $31 budget and a Rust bot is not going to outcompete them on USDC/ETH.

but here is the thing: that is not the point.

the goal was to understand how the system works, build something real, prove the architecture, and leave a foundation that can be improved. that goal is achieved.

the profitable production engine is phase 11, 12, and 13.
the current state is: validated infrastructure, proven correctness, documented everything.


chapter 35: the code (what you actually get)

let's talk about what arbx actually implements.

the core api (rust)

// Start the full pipeline
run(config: Config, dry_run: bool) -> anyhow::Result<()>

// Run a self-test with synthetic data (no network required)
self_test(config: &Config) -> anyhow::Result<()>

// Core layer operations
SequencerFeedManager::run(tx: mpsc::Sender<DetectedSwap>)
BlockReconciler::run_once() -> anyhow::Result<()>
PathScanner::scan_from_pool(pool_address: Address) -> Vec<ArbPath>
ProfitCalculator::is_profitable(gross_profit, l2_gas, l1_gas, eth_price) -> bool
ArbSimulator::simulate(opportunity: &Opportunity) -> SimulationResult
TransactionSubmitter::submit(opportunity: &Opportunity) -> SubmissionResult
PnlTracker::record_submission(result: &SubmissionResult)

the smart contract api (solidity)

// Execute an arbitrage
function executeArb(
    address[] memory tokens,
    uint256[] memory amounts,
    ArbParams memory params
) external onlyOwner;

// Receive the flash loan callback from Balancer V2
function receiveFlashLoan(
    IERC20[] memory tokens,
    uint256[] memory amounts,
    uint256[] memory feeAmounts,  // always [0] on Balancer V2
    bytes memory userData
) external override;

the metrics endpoint

curl http://localhost:9090/metrics

# Sample output:
# HELP opportunities_detected Total arb opportunities detected
# TYPE opportunities_detected counter
opportunities_detected 47

# HELP opportunities_cleared_simulation Opportunities that passed revm simulation
# TYPE opportunities_cleared_simulation counter
opportunities_cleared_simulation 3

# HELP net_pnl_wei Running net PnL in wei
# TYPE net_pnl_wei gauge
net_pnl_wei 125000000000000

epilogue: why you should build this

i could have forked a TypeScript MEV bot template. i could have bought a "MEV strategies" course.

but by building arbx from scratch, i replaced what many call "magic" with understanding.

i know exactly why Balancer V2 flash loans are structurally superior to Aave for arbitrage.
i know exactly what the Arbitrum sequencer feed delivers and what it doesn't.
i know exactly why eth_estimateGas alone is dangerous on L2 rollups.
i know exactly how a revm fork gives you a local EVM with real chain state.
i know exactly how to write a kill switch that cannot be bypassed.

if you are a systems engineer who wants to understand DeFi from first principles, stop reading tutorials.
stop using SDKs you don't understand.
build a bot.

it will hurt. you will encounter silent bugs. you will get transactions reverted. you will stare at the sequencer feed for hours trying to understand why it doesn't show what you expect.

yes, it will be agonizing. but on the other side, you will have a real understanding of how DeFi markets work, how MEV works, and how to build production-quality systems that interact with them.

arbx is open source. read the code. break it. fork it.

github.com/bit2swaz/arbx

codebase stats (march 2026, pre-launch):

  • Rust: 10,550 lines across 27 files
  • Solidity: 1,916 lines across 5 files
  • tests: 186 passing, 0 failing
  • benchmarks: 5 hot-path benchmarks (Criterion)
  • lines written by human hand: 0

if you read this far, thank you. i hope it was worth the time. ^^