DEV Community

Naren karthi
Naren karthi

Posted on

How I Built a MEV Bot That Prints $500/Day (And Why You Shouldn't Copy It)

How I Built a MEV Bot That Printed $500/Day (And Why You Shouldn't Copy It)

The anatomy of a sandwich attack on Arbitrum, the latency arms race that killed it, and why the house always wins.


The Hook: $15K in Seven Days, Gone in Four Hours

Block 124,581,902. Arbitrum One. 03:47 UTC, March 14th.

I’m staring at a Tenderly trace that doesn’t make sense. My bundle—0x7a3f...—landed top-of-block. The victim swap (Uniswap V3, USDC/WETH, 0.05% fee, $42k volume) executed exactly as simulated. My frontrun bought 18.4 WETH. My backrun sold it. Net profit should be $312.

Instead, the coinbase transfer shows 0.000000000000000123 ETH.

I refresh Alchemy. Refresh Flashbots Explorer. Refresh my local logs.

Gas price: 14.7 gwei.

My simulation ran at 2.1 gwei. The bundle I signed, submitted, and had accepted by the relay was built against a base fee that evaporated between eth_call simulation and eth_sendBundle inclusion. The priority fee I bid (2.5 gwei) wasn’t enough to hold position against the 12 other searchers who simulated the exact same victim transaction in the same 150ms window.

Seven days earlier, I’d deployed sandwich-rs to a Hetzner AX101 in Nuremberg. Week one: $15,234.17 net across 347 successful bundles. Average bundle profit: $43.90. Win rate: 71%. I thought I’d found an ATM.

Day eight: -$14,891.03 in a single four-hour window. Gas wars. Revert storms. A validator (later identified as 0x8f3a..., a known MEV-Boost relay operator) started front-running my backruns via proprietary order flow.

The bot didn’t get outcompeted. It got arbitraged. By the very infrastructure I paid to use.

This is the post-mortem. No alpha. No repo links. Just the architecture, the math, the stack, and the brutal game theory of why extracting value from order flow is a negative-sum game for anyone not running a validator.


1. Architecture: The Sandwich Pipeline

1.1 High-Level Data Flow

[Arbitrum Sequencer Feed] 
        │
        ▼
[Mempool Monitor (mev-share-rs)] ──▶ [Victim Filter & Simulator]
        │                                    │
        │                          [Profit Calculator + Gas Estimator]
        │                                    │
        ▼                                    ▼
[Bundle Builder (ethers-rs)] ◀─── [Risk Manager: Max Slippage, Min Profit]
        │
        ▼
[Flashbots Relay (mev-boost-relay)] ──▶ [Validator (mev-boost)]
        │
        ▼
[Canonical Chain] ──▶ [PnL Tracker / Post-Execution Verifier]
Enter fullscreen mode Exit fullscreen mode

Critical constraint on Arbitrum: Unlike Ethereum mainnet, Arbitrum uses a centralized sequencer (currently Offchain Labs) with a deterministic, FCFS (First-Come-First-Served) ordering policy within a single batch, but batches are sealed ~250ms apart. There is no public mempool in the traditional sense. You cannot "see" pending txs before the sequencer receives them.

The workaround: mev-share-rs connects to the MEV-Share node (run by Flashbots/Offchain Labs partners), which receives private order flow from wallets/integrators (Rabby, MetaMask Snaps, etc.) before it hits the sequencer. This is not the public mempool. This is permissioned order flow.

If you’re not connected to MEV-Share (or a similar private flow provider like BloXroute MEV Relay), you are not sandwiching on Arbitrum. You’re just losing money on gas.

1.2 Victim Identification: The mev-share-rs Event Loop

mev-share-rs exposes a EventStream over WebSocket. We filter for PendingTransaction events matching our target pools.

// src/mempool/mod.rs
use mev_share_rs::{MevShareClient, PendingTransaction, BundleParams};
use ethers::types::{Address, H256, U256};
use std::collections::HashSet;
use tracing::{info, warn, debug};

const TARGET_POOLS: &[Address] = &[
    // USDC/WETH 0.05% Fee Tier (Arbitrum)
    "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8".parse().unwrap(),
    // USDT/WETH 0.3% Fee Tier
    "0xa3349256eb6d0a1e6ee9e8c4b1e1b3c4d5e6f7a8".parse().unwrap(),
    // ARB/WETH 0.3% Fee Tier
    "0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc".parse().unwrap(),
];

pub struct VictimScanner {
    client: MevShareClient,
    target_pools: HashSet<Address>,
    min_victim_value_usd: f64, // Configurable, typically $5k+
    max_gas_price_gwei: u64,   // Hard cap for simulation
}

impl VictimScanner {
    pub async fn run(&mut self, tx_sender: tokio::sync::mpsc::Sender<VictimTx>) {
        let mut stream = self.client.subscribe_pending().await.expect("WS connect failed");

        while let Some(event) = stream.next().await {
            match event {
                mev_share_rs::Event::PendingTransaction(ptx) => {
                    if let Some(victim) = self.analyze_victim(ptx).await {
                        if tx_sender.send(victim).await.is_err() {
                            warn!("Pipeline channel closed, shutting down scanner");
                            break;
                        }
                    }
                }
                mev_share_rs::Event::BundleResult(res) => {
                    // Handle bundle success/failure for PnL tracking
                    self.handle_bundle_result(res).await;
                }
                _ => {}
            }
        }
    }

    async fn analyze_victim(&self, ptx: PendingTransaction) -> Option<VictimTx> {
        // 1. Decode calldata (fast path: check selector first)
        // Selector: 0x38ed1739 (exactInputSingle) / 0x414bf389 (exactOutputSingle)
        // Selector: 0x04e45aaf (multicall - common for routers)
        if !Self::is_swap_calldata(&ptx.tx.input) {
            return None;
        }

        // 2. Simulate locally via `eth_call` (Anvil fork or RPC) to get `amountIn`, `amountOut`, `path`
        // We use a cached Anvil instance forked at `head - 1` for 0-latency sims
        let sim_result = self.simulate_swap(&ptx).await.ok()?;

        // 3. Filter: Pool in target list? Volume > threshold? Slippage tolerance > 0.5%?
        if !self.target_pools.contains(&sim_result.pool_address) {
            return None;
        }
        if sim_result.amount_in_usd < self.min_victim_value_usd {
            return None;
        }
        // Victim must have slippage tolerance we can exploit (e.g. > 0.5%)
        if sim_result.slippage_tolerance_bps < 50 {
            return None;
        }

        // 4. Calculate theoretical max extraction
        let (frontrun_amount, backrun_amount, gross_profit) = 
            self.calculate_sandwich_params(&sim_result);

        let gas_estimate = self.estimate_bundle_gas(&sim_result, frontrun_amount, backrun_amount);
        let gas_cost_eth = U256::from(gas_estimate) * U256::from(self.max_gas_price_gwei) * U256::from(1_000_000_000u64);

        // 5. Net Profit Check
        if gross_profit <= gas_cost_eth + MIN_PROFIT_THRESHOLD_WEI {
            debug!("Rejected: Margin too thin. Gross: {}, Gas: {}", gross_profit, gas_cost_eth);
            return None;
        }

        Some(VictimTx {
            hash: ptx.tx.hash,
            victim_tx: ptx.tx,
            pool: sim_result.pool_address,
            frontrun_amount,
            backrun_amount,
            expected_profit: gross_profit - gas_cost_eth,
            gas_bid_gwei: self.max_gas_price_gwei, // Dynamic bidding logic omitted for brevity
            timestamp: std::time::Instant::now(),
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Key Technical Detail: The simulate_swap call uses a locally managed Anvil fork (anvil --fork-url $ARB_RPC --fork-block-number $LATEST --port 8545 --silent) running in a sidecar container. Calling eth_call against a local fork eliminates RPC latency variance (p99 < 2ms vs 80ms+ for Alchemy/QuickNode). We reset the fork every 50 blocks to avoid state drift.

1.3 Bundle Construction: Atomic Execution via ethers-rs

We construct a Flashbots Bundle containing 3 transactions:

  1. Frontrun: exactInputSingle (Buy token B with Token A) — High gas price.
  2. Victim: The original user transaction (included via mev-share hint) — User's gas price.
  3. Backrun: exactInputSingle (Sell token B for Token A) — High gas price.
// src/bundle/builder.rs
use ethers::{
    prelude::*,
    types::{Bytes, TransactionRequest, U256, H160},
    utils::keccak256,
};
use flashbots_rpc::{FlashbotsMiddleware, BundleRequest};
use std::sync::Arc;

pub struct BundleBuilder {
    provider: Arc<Provider<Http>>,
    flashbots: FlashbotsMiddleware<Arc<Provider<Http>>, LocalWallet>,
    wallet: LocalWallet,
    router_address: Address, // Uniswap V3 SwapRouter02
    weth_address: Address,
}

impl BundleBuilder {
    pub fn build_sandwich_bundle(&self, victim: &VictimTx) -> BundleRequest {
        let block_number = self.provider.get_block_number().await.unwrap().as_u64() + 1; // Target next block

        // --- TX 1: FRONTRUN ---
        // We buy the exact output the victim expects to receive (or slightly less to ensure execution)
        // Path: [USDC, WETH] or [WETH, USDC] depending on victim direction
        let frontrun_calldata = self.encode_exact_input_single(
            victim.frontrun_token_in,
            victim.frontrun_token_out,
            victim.frontrun_amount, // Calculated to push price to victim's minOut
            victim.recipient,       // Our wallet
        );

        let frontrun_tx = TransactionRequest::new()
            .to(self.router_address)
            .data(frontrun_calldata)
            .gas(210_000) // Estimated via `estimate_gas` on fork
            .max_fee_per_gas(victim.gas_bid_gwei * 1_000_000_000u64)
            .max_priority_fee_per_gas(2_000_000_000u64) // 2 gwei tip
            .chain_id(42161); // Arbitrum One

        // --- TX 2: VICTIM (Included via MEV-Share hint) ---
        // We don't sign this. We pass the hash to the relay.
        // The relay merges it. We must ensure our bundle *only* executes if this hash is present.

        // --- TX 3: BACKRUN ---
        let backrun_calldata = self.encode_exact_input_single(
            victim.backrun_token_in, // Token B (acquired in frontrun)
            victim.backrun_token_out, // Token A (original)
            victim.backrun_amount,   // Full balance of Token B
            victim.recipient,
        );

        let backrun_tx = TransactionRequest::new()
            .to(self.router_address)
            .data(backrun_calldata)
            .gas(210_000)
            .max_fee_per_gas(victim.gas_bid_gwei * 1_000_000_000u64)
            .max_priority_fee_per_gas(2_000_000_000u64)
            .chain_id(42161);

        // Flashbots Bundle Format
        BundleRequest {
            txs: vec![
                Bytes::from(frontrun_tx.rlp()), // Signed later by middleware
                victim.victim_tx.hash.as_bytes().to_vec(), // Hash only for victim
                Bytes::from(backrun_tx.rlp()),
            ],
            block_number: U64::from(block_number),
            min_timestamp: None,
            max_timestamp: Some((now() + 120) as u64), // 2 min expiry
            reverting_tx_hashes: vec![], // We don't allow reverts
            replacement_uuid: None,
        }
    }

    // Helper: Uniswap V3 SwapRouter02 exactInputSingle encoding
    fn encode_exact_input_single(&self, token_in: Address, token_out: Address, amount_in: U256, recipient: Address) -> Bytes {
        // struct ExactInputSingleParams {
        //     address tokenIn;
        //     address tokenOut;
        //     uint24 fee;
        //     address recipient;
        //     uint256 deadline;
        //     uint256 amountIn;
        //     uint256 amountOutMinimum;
        //     uint160 sqrtPriceLimitX96;
        // }
        // Selector: 0x414bf389 (exactInputSingle) -- Wait, Router02 uses multicall usually.
        // Actually, standard V3 Router: exactInputSingle selector is 0x38ed1739
        // Router02 uses `multicall(bytes[])` selector 0x5ae401dc
        // We use the legacy Router (0xE592427A0AEce92De3Edee1F18E0157C05861564) for simpler atomicity
        // or Router02 multicall. Let's assume Legacy Router for snippet clarity.

        let fee = 500u32; // 0.05% - MUST match victim pool fee tier
        let deadline = U256::from(now() + 300);
        let amount_out_min = U256::from(0); // We control slippage via frontrun sizing
        let sqrt_price_limit = U256::from(0);

        ethers::abi::encode(&[
            Token::Address(token_in),
            Token::Address(token_out),
            Token::Uint(U256::from(fee)),
            Token::Address(recipient),
            Token::Uint(deadline),
            Token::Uint(amount_in),
            Token::Uint(amount_out_min),
            Token::Uint(sqrt_price_limit),
        ]).into()
    }
}
Enter fullscreen mode Exit fullscreen mode

Critical Implementation Detail: On Arbitrum, block.number increments every ~250ms (batch), not every 12s. block.timestamp increments by ~1s per batch. You cannot target block.number + 1 reliably if the sequencer delays a batch. We target block.timestamp + 2 seconds (max_timestamp) to give the relay a window. If the batch is delayed, the bundle expires harmlessly rather than executing on a stale state (which would revert and cost gas).

1.4 Profit Calculation: The Uniswap V3 Math

Sandwiching V3 is not x*y=k. It’s concentrated liquidity. The profit function depends on the victim’s swap crossing specific tick ranges.


rust
// src/math/sandwich.rs
use ethers::types::U256;

// Simplified V3 Swap Math (Solidity ported to Rust for speed)
// Real implementation uses `uniswap-v3-math` crate or raw `FixedPoint128` / `FullMath`
pub fn calculate_optimal_sandwich(
    pool: &PoolState, // { sqrt_price_x96, liquidity, tick_current, fee }
    victim_amount_in: U256,
    victim_zero_for_one: bool,
    victim_slippage_bps: u64, // e.g. 

#MEV #Rust #Arbitrum #Flashbots
Enter fullscreen mode Exit fullscreen mode

Top comments (0)