Skip to main content
EngineeringMar 28, 2026

Deep EVM #15: MEV Simulation — Binary Search, State Forks, and the 12-Second Deadline

OS
Open Soft Team

Engineering Team

The Simulation Pipeline

Finding cycles is the easy part. The hard part is determining which cycles are actually profitable and with how much capital. The simulation pipeline answers three questions:

  1. Does this cycle produce profit at all? (Some cycles look profitable with static math but fail when simulated against real contract code.)
  2. What is the optimal input amount? (Too little capital = small profit. Too much = excessive price impact eats the profit.)
  3. Can we execute before the block deadline? (A profitable cycle found 11 seconds into a slot is worthless.)
Cycle → Fork State → Simulate → Binary Search → Profit/Loss → Bundle or Discard

State Forking

To simulate a swap, you need the full EVM state: contract bytecode, storage slots, balances. You cannot call a real node for every simulation — the latency would kill you. Instead, you fork the state locally.

Approach 1: Revm (Rust EVM)

Revm is an EVM implementation in Rust that can run against a forked state database:

use revm::{
    db::{CacheDB, EthersDB},
    primitives::{Address, ExecutionResult, Output, TransactTo, U256},
    Evm,
};

pub struct Simulator {
    db: CacheDB<EthersDB<Provider<Http>>>,
}

impl Simulator {
    pub fn new(provider: Provider<Http>, block_number: u64) -> Self {
        let ethers_db = EthersDB::new(provider, Some(block_number.into()));
        let cache_db = CacheDB::new(ethers_db);
        Self { db: cache_db }
    }

    pub fn simulate_swap(
        &mut self,
        from: Address,
        to: Address,     // pool or router
        calldata: Vec<u8>,
        value: U256,
    ) -> Result<SimResult, SimError> {
        let mut evm = Evm::builder()
            .with_db(&mut self.db)
            .modify_tx_env(|tx| {
                tx.caller = from;
                tx.transact_to = TransactTo::Call(to);
                tx.data = calldata.into();
                tx.value = value;
                tx.gas_limit = 500_000;
            })
            .build();

        let result = evm.transact()?;

        match result.result {
            ExecutionResult::Success { output, gas_used, .. } => {
                let return_data = match output {
                    Output::Call(data) => data,
                    Output::Create(data, _) => data,
                };
                Ok(SimResult {
                    success: true,
                    gas_used,
                    return_data: return_data.to_vec(),
                })
            }
            ExecutionResult::Revert { output, gas_used, .. } => {
                Ok(SimResult {
                    success: false,
                    gas_used,
                    return_data: output.to_vec(),
                })
            }
            ExecutionResult::Halt { reason, gas_used, .. } => {
                Err(SimError::Halt(reason, gas_used))
            }
        }
    }
}

The CacheDB layer intercepts storage reads: on the first read of a slot, it fetches from the remote provider and caches locally. Subsequent reads are instant. This means the first simulation of a cycle is slow (~50-100ms for cold slots) but repeated simulations with different amounts reuse the cache (~0.5-1ms).

Approach 2: Pre-Cached State

For maximum speed, pre-load all relevant storage slots at the start of each block:

pub async fn preload_pool_state(
    provider: &Provider<Http>,
    pools: &[Pool],
    db: &mut CacheDB<EthersDB<Provider<Http>>>,
) {
    // Batch eth_getStorageAt calls for all pool slots we'll need
    let mut futures = Vec::new();

    for pool in pools {
        // Uniswap V2: slot 8 = reserve0+reserve1 (packed)
        // Uniswap V3: slot 0 = sqrtPriceX96+tick (packed)
        futures.push(provider.get_storage_at(
            pool.address,
            H256::from_low_u64_be(8),
            None,
        ));
    }

    let results = futures::future::join_all(futures).await;
    // Insert into cache_db...
}

With pre-caching, every simulation runs against local memory. Simulation time drops to 0.1-0.5ms per cycle.

Binary Search for Optimal Input

The profit function for an arbitrage cycle is typically concave: it rises as input amount increases (more capital = more profit) until price impact exceeds the arbitrage spread, at which point it falls. The optimal input is at the peak.

Profit
  ^
  |      ****
  |    **    **
  |   *        **
  |  *           ***
  | *                ****
  |*                     *****
  +----------------------------> Input Amount
       ^-- optimal

Binary search finds this peak efficiently:

pub fn find_optimal_input(
    simulator: &mut Simulator,
    cycle: &Cycle,
    min_input: U256,
    max_input: U256,
    gas_price: U256,
) -> Option<(U256, U256)> {  // (optimal_input, profit)
    let mut lo = min_input;
    let mut hi = max_input;
    let mut best_input = U256::ZERO;
    let mut best_profit = U256::ZERO;

    // Binary search: ~20 iterations for convergence
    for _ in 0..20 {
        if hi <= lo + U256::from(1000) {
            break;
        }

        let mid = (lo + hi) / 2;
        let mid_left = (lo + mid) / 2;
        let mid_right = (mid + hi) / 2;

        let profit_left = simulate_cycle(simulator, cycle, mid_left);
        let profit_right = simulate_cycle(simulator, cycle, mid_right);

        match (profit_left, profit_right) {
            (Some(pl), Some(pr)) => {
                if pl > pr {
                    hi = mid_right;
                    if pl > best_profit {
                        best_profit = pl;
                        best_input = mid_left;
                    }
                } else {
                    lo = mid_left;
                    if pr > best_profit {
                        best_profit = pr;
                        best_input = mid_right;
                    }
                }
            }
            (Some(pl), None) => {
                hi = mid;
                if pl > best_profit {
                    best_profit = pl;
                    best_input = mid_left;
                }
            }
            (None, Some(pr)) => {
                lo = mid;
                if pr > best_profit {
                    best_profit = pr;
                    best_input = mid_right;
                }
            }
            (None, None) => {
                // Both sides revert — narrow the range
                lo = mid_left;
                hi = mid_right;
            }
        }
    }

    // Subtract gas cost
    let gas_cost = estimate_gas_cost(cycle) * gas_price;
    if best_profit > gas_cost {
        Some((best_input, best_profit - gas_cost))
    } else {
        None
    }
}

fn simulate_cycle(
    simulator: &mut Simulator,
    cycle: &Cycle,
    input_amount: U256,
) -> Option<U256> {
    // Simulate each swap in sequence
    let mut current_amount = input_amount;

    for (i, pool) in cycle.pools.iter().enumerate() {
        let token_in = cycle.tokens[i];
        let calldata = build_swap_calldata(
            pool,
            token_in,
            current_amount,
        );

        let result = simulator.simulate_swap(
            cycle.tokens[0],  // our bot address
            pool.address,
            calldata,
            U256::ZERO,
        ).ok()?;

        if !result.success {
            return None;
        }

        current_amount = decode_swap_output(&result.return_data)?;
    }

    if current_amount > input_amount {
        Some(current_amount - input_amount)
    } else {
        None
    }
}

This ternary-search variant (comparing two midpoints) converges faster for unimodal functions. 20 iterations gives precision to within 1/2^20 of the search range — more than sufficient.

The 12-Second Deadline

Ethereum produces a block every 12 seconds. When a new block arrives:

  1. t=0s: New block header received. Update state.
  2. t=0-2s: Process events, update pool reserves, identify changed cycles.
  3. t=2-6s: Simulate profitable cycles, binary search for optimal amounts.
  4. t=6-10s: Construct bundles, resolve conflicts, submit to builders.
  5. t=10-12s: Builders include bundles in the next block proposal.

If your simulation takes 8 seconds, you have missed the window. Speed is everything.

Deadline-Aware Cancellation

Every simulation loop should check the clock:

use std::time::{Duration, Instant};

pub struct DeadlineContext {
    pub start: Instant,
    pub deadline: Duration,
}

impl DeadlineContext {
    pub fn new(deadline_ms: u64) -> Self {
        Self {
            start: Instant::now(),
            deadline: Duration::from_millis(deadline_ms),
        }
    }

    pub fn remaining(&self) -> Duration {
        self.deadline.saturating_sub(self.start.elapsed())
    }

    pub fn is_expired(&self) -> bool {
        self.start.elapsed() >= self.deadline
    }
}

pub fn simulate_batch(
    simulator: &mut Simulator,
    cycles: &[Cycle],
    ctx: &DeadlineContext,
) -> Vec<ProfitableCycle> {
    let mut results = Vec::new();

    // Sort cycles by estimated profitability (highest first)
    let mut sorted_cycles = cycles.to_vec();
    sorted_cycles.sort_by(|a, b| {
        b.estimated_profit.cmp(&a.estimated_profit)
    });

    for cycle in &sorted_cycles {
        if ctx.is_expired() {
            tracing::warn!(
                "Deadline expired with {} cycles remaining",
                sorted_cycles.len() - results.len()
            );
            break;
        }

        if let Some((input, profit)) = find_optimal_input(
            simulator,
            cycle,
            U256::from(1_000_000_000_000_000u64),  // 0.001 ETH min
            U256::from(100_000_000_000_000_000_000u64), // 100 ETH max
            ctx.gas_price,
        ) {
            results.push(ProfitableCycle {
                cycle: cycle.clone(),
                optimal_input: input,
                expected_profit: profit,
            });
        }
    }

    results
}

By sorting cycles by estimated profitability before simulating, the deadline cut-off discards the least promising cycles first.

Batching Strategies

Not all cycles need simulation every block. Use tiered batching:

Tier 1: Event-Driven (Immediate)

When a Sync event fires on a pool, immediately re-simulate all cycles containing that pool. This catches fresh arbitrage created by new trades.

Tier 2: High-Value Rotation

Cycles that have been profitable in recent blocks are simulated every block, even if no event fired. Pool reserves might have changed via other mechanisms (e.g., direct transfers, rebasing tokens).

Tier 3: Background Scan

All remaining cycles are simulated in round-robin fashion across multiple blocks. Each block, simulate the next batch of 1,000 cycles.

pub struct TieredSimulator {
    tier1: Vec<CycleId>,   // event-triggered
    tier2: Vec<CycleId>,   // recently profitable
    tier3_cursor: usize,   // round-robin position
    tier3: Vec<CycleId>,   // all remaining cycles
}

impl TieredSimulator {
    pub fn get_simulation_batch(
        &mut self,
        events: &[PoolEvent],
        budget: usize,      // max cycles to simulate this block
    ) -> Vec<CycleId> {
        let mut batch = Vec::new();

        // Tier 1: all event-triggered cycles
        for event in events {
            if let Some(cycle_ids) = self.cycles_by_pool(event.pool) {
                batch.extend(cycle_ids);
            }
        }

        // Tier 2: recently profitable
        batch.extend(self.tier2.iter().cloned());

        // Tier 3: fill remaining budget
        let remaining = budget.saturating_sub(batch.len());
        for _ in 0..remaining {
            if self.tier3.is_empty() { break; }
            batch.push(self.tier3[self.tier3_cursor % self.tier3.len()]);
            self.tier3_cursor += 1;
        }

        batch
    }
}

Cache Invalidation

The CacheDB must be invalidated when a new block arrives. But a full cache wipe is expensive — you would re-fetch thousands of storage slots.

Smarter approach: only invalidate slots that changed. Subscribe to eth_subscribe("logs") and eth_subscribe("newHeads"). When you see a Sync event on a pool, invalidate only that pool’s storage slots:

pub fn invalidate_pool(
    cache: &mut CacheDB<EthersDB<Provider<Http>>>,
    pool: &Pool,
) {
    match pool.protocol {
        Protocol::UniswapV2 => {
            // Slot 8 contains packed reserves
            cache.remove_storage(pool.address, U256::from(8));
        }
        Protocol::UniswapV3 => {
            // Slot 0 contains sqrtPriceX96 and tick
            cache.remove_storage(pool.address, U256::ZERO);
            // Liquidity at slot 4
            cache.remove_storage(pool.address, U256::from(4));
        }
        _ => {
            // Conservative: wipe all cached slots for this address
            cache.remove_account(pool.address);
        }
    }
}

Summary

Simulation is where cycles become profit. Revm gives you a local EVM for sub-millisecond execution. Binary search finds optimal input amounts in ~20 iterations. Deadline-aware scheduling ensures you never waste time on cycles you cannot execute. Tiered batching focuses compute on the highest-probability opportunities. In the next article, we will take profitable cycles and pack them into bundles — resolving conflicts, managing MEV-Share requirements, and submitting to block builders.