[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-deep-evm-15-mev-simulation-binary-search-state-forks":3},{"article":4,"author":59},{"id":5,"category_id":6,"title":7,"slug":8,"excerpt":9,"content_md":10,"content_html":11,"locale":12,"author_id":13,"published":14,"published_at":15,"meta_title":16,"meta_description":17,"focus_keyword":18,"og_image":19,"canonical_url":19,"robots_meta":20,"created_at":15,"updated_at":15,"tags":21,"category_name":39,"related_articles":40},"d0000000-0000-0000-0000-000000000115","a0000000-0000-0000-0000-000000000006","Deep EVM #15: MEV Simulation — Binary Search, State Forks, and the 12-Second Deadline","deep-evm-15-mev-simulation-binary-search-state-forks","Build the MEV simulation pipeline: fork EVM state, simulate arbitrage executions, binary search for optimal borrow amounts, and manage the 12-second block deadline.","## The Simulation Pipeline\n\nFinding 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:\n\n1. **Does this cycle produce profit at all?** (Some cycles look profitable with static math but fail when simulated against real contract code.)\n2. **What is the optimal input amount?** (Too little capital = small profit. Too much = excessive price impact eats the profit.)\n3. **Can we execute before the block deadline?** (A profitable cycle found 11 seconds into a slot is worthless.)\n\n```\nCycle → Fork State → Simulate → Binary Search → Profit\u002FLoss → Bundle or Discard\n```\n\n## State Forking\n\nTo 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.\n\n### Approach 1: Revm (Rust EVM)\n\nRevm is an EVM implementation in Rust that can run against a forked state database:\n\n```rust\nuse revm::{\n    db::{CacheDB, EthersDB},\n    primitives::{Address, ExecutionResult, Output, TransactTo, U256},\n    Evm,\n};\n\npub struct Simulator {\n    db: CacheDB\u003CEthersDB\u003CProvider\u003CHttp>>>,\n}\n\nimpl Simulator {\n    pub fn new(provider: Provider\u003CHttp>, block_number: u64) -> Self {\n        let ethers_db = EthersDB::new(provider, Some(block_number.into()));\n        let cache_db = CacheDB::new(ethers_db);\n        Self { db: cache_db }\n    }\n\n    pub fn simulate_swap(\n        &mut self,\n        from: Address,\n        to: Address,     \u002F\u002F pool or router\n        calldata: Vec\u003Cu8>,\n        value: U256,\n    ) -> Result\u003CSimResult, SimError> {\n        let mut evm = Evm::builder()\n            .with_db(&mut self.db)\n            .modify_tx_env(|tx| {\n                tx.caller = from;\n                tx.transact_to = TransactTo::Call(to);\n                tx.data = calldata.into();\n                tx.value = value;\n                tx.gas_limit = 500_000;\n            })\n            .build();\n\n        let result = evm.transact()?;\n\n        match result.result {\n            ExecutionResult::Success { output, gas_used, .. } => {\n                let return_data = match output {\n                    Output::Call(data) => data,\n                    Output::Create(data, _) => data,\n                };\n                Ok(SimResult {\n                    success: true,\n                    gas_used,\n                    return_data: return_data.to_vec(),\n                })\n            }\n            ExecutionResult::Revert { output, gas_used, .. } => {\n                Ok(SimResult {\n                    success: false,\n                    gas_used,\n                    return_data: output.to_vec(),\n                })\n            }\n            ExecutionResult::Halt { reason, gas_used, .. } => {\n                Err(SimError::Halt(reason, gas_used))\n            }\n        }\n    }\n}\n```\n\nThe `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).\n\n### Approach 2: Pre-Cached State\n\nFor maximum speed, pre-load all relevant storage slots at the start of each block:\n\n```rust\npub async fn preload_pool_state(\n    provider: &Provider\u003CHttp>,\n    pools: &[Pool],\n    db: &mut CacheDB\u003CEthersDB\u003CProvider\u003CHttp>>>,\n) {\n    \u002F\u002F Batch eth_getStorageAt calls for all pool slots we'll need\n    let mut futures = Vec::new();\n\n    for pool in pools {\n        \u002F\u002F Uniswap V2: slot 8 = reserve0+reserve1 (packed)\n        \u002F\u002F Uniswap V3: slot 0 = sqrtPriceX96+tick (packed)\n        futures.push(provider.get_storage_at(\n            pool.address,\n            H256::from_low_u64_be(8),\n            None,\n        ));\n    }\n\n    let results = futures::future::join_all(futures).await;\n    \u002F\u002F Insert into cache_db...\n}\n```\n\nWith pre-caching, every simulation runs against local memory. Simulation time drops to 0.1-0.5ms per cycle.\n\n## Binary Search for Optimal Input\n\nThe 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.\n\n```\nProfit\n  ^\n  |      ****\n  |    **    **\n  |   *        **\n  |  *           ***\n  | *                ****\n  |*                     *****\n  +----------------------------> Input Amount\n       ^-- optimal\n```\n\nBinary search finds this peak efficiently:\n\n```rust\npub fn find_optimal_input(\n    simulator: &mut Simulator,\n    cycle: &Cycle,\n    min_input: U256,\n    max_input: U256,\n    gas_price: U256,\n) -> Option\u003C(U256, U256)> {  \u002F\u002F (optimal_input, profit)\n    let mut lo = min_input;\n    let mut hi = max_input;\n    let mut best_input = U256::ZERO;\n    let mut best_profit = U256::ZERO;\n\n    \u002F\u002F Binary search: ~20 iterations for convergence\n    for _ in 0..20 {\n        if hi \u003C= lo + U256::from(1000) {\n            break;\n        }\n\n        let mid = (lo + hi) \u002F 2;\n        let mid_left = (lo + mid) \u002F 2;\n        let mid_right = (mid + hi) \u002F 2;\n\n        let profit_left = simulate_cycle(simulator, cycle, mid_left);\n        let profit_right = simulate_cycle(simulator, cycle, mid_right);\n\n        match (profit_left, profit_right) {\n            (Some(pl), Some(pr)) => {\n                if pl > pr {\n                    hi = mid_right;\n                    if pl > best_profit {\n                        best_profit = pl;\n                        best_input = mid_left;\n                    }\n                } else {\n                    lo = mid_left;\n                    if pr > best_profit {\n                        best_profit = pr;\n                        best_input = mid_right;\n                    }\n                }\n            }\n            (Some(pl), None) => {\n                hi = mid;\n                if pl > best_profit {\n                    best_profit = pl;\n                    best_input = mid_left;\n                }\n            }\n            (None, Some(pr)) => {\n                lo = mid;\n                if pr > best_profit {\n                    best_profit = pr;\n                    best_input = mid_right;\n                }\n            }\n            (None, None) => {\n                \u002F\u002F Both sides revert — narrow the range\n                lo = mid_left;\n                hi = mid_right;\n            }\n        }\n    }\n\n    \u002F\u002F Subtract gas cost\n    let gas_cost = estimate_gas_cost(cycle) * gas_price;\n    if best_profit > gas_cost {\n        Some((best_input, best_profit - gas_cost))\n    } else {\n        None\n    }\n}\n\nfn simulate_cycle(\n    simulator: &mut Simulator,\n    cycle: &Cycle,\n    input_amount: U256,\n) -> Option\u003CU256> {\n    \u002F\u002F Simulate each swap in sequence\n    let mut current_amount = input_amount;\n\n    for (i, pool) in cycle.pools.iter().enumerate() {\n        let token_in = cycle.tokens[i];\n        let calldata = build_swap_calldata(\n            pool,\n            token_in,\n            current_amount,\n        );\n\n        let result = simulator.simulate_swap(\n            cycle.tokens[0],  \u002F\u002F our bot address\n            pool.address,\n            calldata,\n            U256::ZERO,\n        ).ok()?;\n\n        if !result.success {\n            return None;\n        }\n\n        current_amount = decode_swap_output(&result.return_data)?;\n    }\n\n    if current_amount > input_amount {\n        Some(current_amount - input_amount)\n    } else {\n        None\n    }\n}\n```\n\nThis ternary-search variant (comparing two midpoints) converges faster for unimodal functions. 20 iterations gives precision to within 1\u002F2^20 of the search range — more than sufficient.\n\n## The 12-Second Deadline\n\nEthereum produces a block every 12 seconds. When a new block arrives:\n\n1. **t=0s:** New block header received. Update state.\n2. **t=0-2s:** Process events, update pool reserves, identify changed cycles.\n3. **t=2-6s:** Simulate profitable cycles, binary search for optimal amounts.\n4. **t=6-10s:** Construct bundles, resolve conflicts, submit to builders.\n5. **t=10-12s:** Builders include bundles in the next block proposal.\n\nIf your simulation takes 8 seconds, you have missed the window. Speed is everything.\n\n### Deadline-Aware Cancellation\n\nEvery simulation loop should check the clock:\n\n```rust\nuse std::time::{Duration, Instant};\n\npub struct DeadlineContext {\n    pub start: Instant,\n    pub deadline: Duration,\n}\n\nimpl DeadlineContext {\n    pub fn new(deadline_ms: u64) -> Self {\n        Self {\n            start: Instant::now(),\n            deadline: Duration::from_millis(deadline_ms),\n        }\n    }\n\n    pub fn remaining(&self) -> Duration {\n        self.deadline.saturating_sub(self.start.elapsed())\n    }\n\n    pub fn is_expired(&self) -> bool {\n        self.start.elapsed() >= self.deadline\n    }\n}\n\npub fn simulate_batch(\n    simulator: &mut Simulator,\n    cycles: &[Cycle],\n    ctx: &DeadlineContext,\n) -> Vec\u003CProfitableCycle> {\n    let mut results = Vec::new();\n\n    \u002F\u002F Sort cycles by estimated profitability (highest first)\n    let mut sorted_cycles = cycles.to_vec();\n    sorted_cycles.sort_by(|a, b| {\n        b.estimated_profit.cmp(&a.estimated_profit)\n    });\n\n    for cycle in &sorted_cycles {\n        if ctx.is_expired() {\n            tracing::warn!(\n                \"Deadline expired with {} cycles remaining\",\n                sorted_cycles.len() - results.len()\n            );\n            break;\n        }\n\n        if let Some((input, profit)) = find_optimal_input(\n            simulator,\n            cycle,\n            U256::from(1_000_000_000_000_000u64),  \u002F\u002F 0.001 ETH min\n            U256::from(100_000_000_000_000_000_000u64), \u002F\u002F 100 ETH max\n            ctx.gas_price,\n        ) {\n            results.push(ProfitableCycle {\n                cycle: cycle.clone(),\n                optimal_input: input,\n                expected_profit: profit,\n            });\n        }\n    }\n\n    results\n}\n```\n\nBy sorting cycles by estimated profitability before simulating, the deadline cut-off discards the least promising cycles first.\n\n## Batching Strategies\n\nNot all cycles need simulation every block. Use tiered batching:\n\n### Tier 1: Event-Driven (Immediate)\n\nWhen a `Sync` event fires on a pool, immediately re-simulate all cycles containing that pool. This catches fresh arbitrage created by new trades.\n\n### Tier 2: High-Value Rotation\n\nCycles 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).\n\n### Tier 3: Background Scan\n\nAll remaining cycles are simulated in round-robin fashion across multiple blocks. Each block, simulate the next batch of 1,000 cycles.\n\n```rust\npub struct TieredSimulator {\n    tier1: Vec\u003CCycleId>,   \u002F\u002F event-triggered\n    tier2: Vec\u003CCycleId>,   \u002F\u002F recently profitable\n    tier3_cursor: usize,   \u002F\u002F round-robin position\n    tier3: Vec\u003CCycleId>,   \u002F\u002F all remaining cycles\n}\n\nimpl TieredSimulator {\n    pub fn get_simulation_batch(\n        &mut self,\n        events: &[PoolEvent],\n        budget: usize,      \u002F\u002F max cycles to simulate this block\n    ) -> Vec\u003CCycleId> {\n        let mut batch = Vec::new();\n\n        \u002F\u002F Tier 1: all event-triggered cycles\n        for event in events {\n            if let Some(cycle_ids) = self.cycles_by_pool(event.pool) {\n                batch.extend(cycle_ids);\n            }\n        }\n\n        \u002F\u002F Tier 2: recently profitable\n        batch.extend(self.tier2.iter().cloned());\n\n        \u002F\u002F Tier 3: fill remaining budget\n        let remaining = budget.saturating_sub(batch.len());\n        for _ in 0..remaining {\n            if self.tier3.is_empty() { break; }\n            batch.push(self.tier3[self.tier3_cursor % self.tier3.len()]);\n            self.tier3_cursor += 1;\n        }\n\n        batch\n    }\n}\n```\n\n## Cache Invalidation\n\nThe CacheDB must be invalidated when a new block arrives. But a full cache wipe is expensive — you would re-fetch thousands of storage slots.\n\nSmarter 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:\n\n```rust\npub fn invalidate_pool(\n    cache: &mut CacheDB\u003CEthersDB\u003CProvider\u003CHttp>>>,\n    pool: &Pool,\n) {\n    match pool.protocol {\n        Protocol::UniswapV2 => {\n            \u002F\u002F Slot 8 contains packed reserves\n            cache.remove_storage(pool.address, U256::from(8));\n        }\n        Protocol::UniswapV3 => {\n            \u002F\u002F Slot 0 contains sqrtPriceX96 and tick\n            cache.remove_storage(pool.address, U256::ZERO);\n            \u002F\u002F Liquidity at slot 4\n            cache.remove_storage(pool.address, U256::from(4));\n        }\n        _ => {\n            \u002F\u002F Conservative: wipe all cached slots for this address\n            cache.remove_account(pool.address);\n        }\n    }\n}\n```\n\n## Summary\n\nSimulation 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.","\u003Ch2 id=\"the-simulation-pipeline\">The Simulation Pipeline\u003C\u002Fh2>\n\u003Cp>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:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>Does this cycle produce profit at all?\u003C\u002Fstrong> (Some cycles look profitable with static math but fail when simulated against real contract code.)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>What is the optimal input amount?\u003C\u002Fstrong> (Too little capital = small profit. Too much = excessive price impact eats the profit.)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Can we execute before the block deadline?\u003C\u002Fstrong> (A profitable cycle found 11 seconds into a slot is worthless.)\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cpre>\u003Ccode>Cycle → Fork State → Simulate → Binary Search → Profit\u002FLoss → Bundle or Discard\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"state-forking\">State Forking\u003C\u002Fh2>\n\u003Cp>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.\u003C\u002Fp>\n\u003Ch3>Approach 1: Revm (Rust EVM)\u003C\u002Fh3>\n\u003Cp>Revm is an EVM implementation in Rust that can run against a forked state database:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use revm::{\n    db::{CacheDB, EthersDB},\n    primitives::{Address, ExecutionResult, Output, TransactTo, U256},\n    Evm,\n};\n\npub struct Simulator {\n    db: CacheDB&lt;EthersDB&lt;Provider&lt;Http&gt;&gt;&gt;,\n}\n\nimpl Simulator {\n    pub fn new(provider: Provider&lt;Http&gt;, block_number: u64) -&gt; Self {\n        let ethers_db = EthersDB::new(provider, Some(block_number.into()));\n        let cache_db = CacheDB::new(ethers_db);\n        Self { db: cache_db }\n    }\n\n    pub fn simulate_swap(\n        &amp;mut self,\n        from: Address,\n        to: Address,     \u002F\u002F pool or router\n        calldata: Vec&lt;u8&gt;,\n        value: U256,\n    ) -&gt; Result&lt;SimResult, SimError&gt; {\n        let mut evm = Evm::builder()\n            .with_db(&amp;mut self.db)\n            .modify_tx_env(|tx| {\n                tx.caller = from;\n                tx.transact_to = TransactTo::Call(to);\n                tx.data = calldata.into();\n                tx.value = value;\n                tx.gas_limit = 500_000;\n            })\n            .build();\n\n        let result = evm.transact()?;\n\n        match result.result {\n            ExecutionResult::Success { output, gas_used, .. } =&gt; {\n                let return_data = match output {\n                    Output::Call(data) =&gt; data,\n                    Output::Create(data, _) =&gt; data,\n                };\n                Ok(SimResult {\n                    success: true,\n                    gas_used,\n                    return_data: return_data.to_vec(),\n                })\n            }\n            ExecutionResult::Revert { output, gas_used, .. } =&gt; {\n                Ok(SimResult {\n                    success: false,\n                    gas_used,\n                    return_data: output.to_vec(),\n                })\n            }\n            ExecutionResult::Halt { reason, gas_used, .. } =&gt; {\n                Err(SimError::Halt(reason, gas_used))\n            }\n        }\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The \u003Ccode>CacheDB\u003C\u002Fcode> 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).\u003C\u002Fp>\n\u003Ch3>Approach 2: Pre-Cached State\u003C\u002Fh3>\n\u003Cp>For maximum speed, pre-load all relevant storage slots at the start of each block:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">pub async fn preload_pool_state(\n    provider: &amp;Provider&lt;Http&gt;,\n    pools: &amp;[Pool],\n    db: &amp;mut CacheDB&lt;EthersDB&lt;Provider&lt;Http&gt;&gt;&gt;,\n) {\n    \u002F\u002F Batch eth_getStorageAt calls for all pool slots we'll need\n    let mut futures = Vec::new();\n\n    for pool in pools {\n        \u002F\u002F Uniswap V2: slot 8 = reserve0+reserve1 (packed)\n        \u002F\u002F Uniswap V3: slot 0 = sqrtPriceX96+tick (packed)\n        futures.push(provider.get_storage_at(\n            pool.address,\n            H256::from_low_u64_be(8),\n            None,\n        ));\n    }\n\n    let results = futures::future::join_all(futures).await;\n    \u002F\u002F Insert into cache_db...\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>With pre-caching, every simulation runs against local memory. Simulation time drops to 0.1-0.5ms per cycle.\u003C\u002Fp>\n\u003Ch2 id=\"binary-search-for-optimal-input\">Binary Search for Optimal Input\u003C\u002Fh2>\n\u003Cp>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.\u003C\u002Fp>\n\u003Cpre>\u003Ccode>Profit\n  ^\n  |      ****\n  |    **    **\n  |   *        **\n  |  *           ***\n  | *                ****\n  |*                     *****\n  +----------------------------&gt; Input Amount\n       ^-- optimal\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Binary search finds this peak efficiently:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">pub fn find_optimal_input(\n    simulator: &amp;mut Simulator,\n    cycle: &amp;Cycle,\n    min_input: U256,\n    max_input: U256,\n    gas_price: U256,\n) -&gt; Option&lt;(U256, U256)&gt; {  \u002F\u002F (optimal_input, profit)\n    let mut lo = min_input;\n    let mut hi = max_input;\n    let mut best_input = U256::ZERO;\n    let mut best_profit = U256::ZERO;\n\n    \u002F\u002F Binary search: ~20 iterations for convergence\n    for _ in 0..20 {\n        if hi &lt;= lo + U256::from(1000) {\n            break;\n        }\n\n        let mid = (lo + hi) \u002F 2;\n        let mid_left = (lo + mid) \u002F 2;\n        let mid_right = (mid + hi) \u002F 2;\n\n        let profit_left = simulate_cycle(simulator, cycle, mid_left);\n        let profit_right = simulate_cycle(simulator, cycle, mid_right);\n\n        match (profit_left, profit_right) {\n            (Some(pl), Some(pr)) =&gt; {\n                if pl &gt; pr {\n                    hi = mid_right;\n                    if pl &gt; best_profit {\n                        best_profit = pl;\n                        best_input = mid_left;\n                    }\n                } else {\n                    lo = mid_left;\n                    if pr &gt; best_profit {\n                        best_profit = pr;\n                        best_input = mid_right;\n                    }\n                }\n            }\n            (Some(pl), None) =&gt; {\n                hi = mid;\n                if pl &gt; best_profit {\n                    best_profit = pl;\n                    best_input = mid_left;\n                }\n            }\n            (None, Some(pr)) =&gt; {\n                lo = mid;\n                if pr &gt; best_profit {\n                    best_profit = pr;\n                    best_input = mid_right;\n                }\n            }\n            (None, None) =&gt; {\n                \u002F\u002F Both sides revert — narrow the range\n                lo = mid_left;\n                hi = mid_right;\n            }\n        }\n    }\n\n    \u002F\u002F Subtract gas cost\n    let gas_cost = estimate_gas_cost(cycle) * gas_price;\n    if best_profit &gt; gas_cost {\n        Some((best_input, best_profit - gas_cost))\n    } else {\n        None\n    }\n}\n\nfn simulate_cycle(\n    simulator: &amp;mut Simulator,\n    cycle: &amp;Cycle,\n    input_amount: U256,\n) -&gt; Option&lt;U256&gt; {\n    \u002F\u002F Simulate each swap in sequence\n    let mut current_amount = input_amount;\n\n    for (i, pool) in cycle.pools.iter().enumerate() {\n        let token_in = cycle.tokens[i];\n        let calldata = build_swap_calldata(\n            pool,\n            token_in,\n            current_amount,\n        );\n\n        let result = simulator.simulate_swap(\n            cycle.tokens[0],  \u002F\u002F our bot address\n            pool.address,\n            calldata,\n            U256::ZERO,\n        ).ok()?;\n\n        if !result.success {\n            return None;\n        }\n\n        current_amount = decode_swap_output(&amp;result.return_data)?;\n    }\n\n    if current_amount &gt; input_amount {\n        Some(current_amount - input_amount)\n    } else {\n        None\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>This ternary-search variant (comparing two midpoints) converges faster for unimodal functions. 20 iterations gives precision to within 1\u002F2^20 of the search range — more than sufficient.\u003C\u002Fp>\n\u003Ch2 id=\"the-12-second-deadline\">The 12-Second Deadline\u003C\u002Fh2>\n\u003Cp>Ethereum produces a block every 12 seconds. When a new block arrives:\u003C\u002Fp>\n\u003Col>\n\u003Cli>\u003Cstrong>t=0s:\u003C\u002Fstrong> New block header received. Update state.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>t=0-2s:\u003C\u002Fstrong> Process events, update pool reserves, identify changed cycles.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>t=2-6s:\u003C\u002Fstrong> Simulate profitable cycles, binary search for optimal amounts.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>t=6-10s:\u003C\u002Fstrong> Construct bundles, resolve conflicts, submit to builders.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>t=10-12s:\u003C\u002Fstrong> Builders include bundles in the next block proposal.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>If your simulation takes 8 seconds, you have missed the window. Speed is everything.\u003C\u002Fp>\n\u003Ch3>Deadline-Aware Cancellation\u003C\u002Fh3>\n\u003Cp>Every simulation loop should check the clock:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">use std::time::{Duration, Instant};\n\npub struct DeadlineContext {\n    pub start: Instant,\n    pub deadline: Duration,\n}\n\nimpl DeadlineContext {\n    pub fn new(deadline_ms: u64) -&gt; Self {\n        Self {\n            start: Instant::now(),\n            deadline: Duration::from_millis(deadline_ms),\n        }\n    }\n\n    pub fn remaining(&amp;self) -&gt; Duration {\n        self.deadline.saturating_sub(self.start.elapsed())\n    }\n\n    pub fn is_expired(&amp;self) -&gt; bool {\n        self.start.elapsed() &gt;= self.deadline\n    }\n}\n\npub fn simulate_batch(\n    simulator: &amp;mut Simulator,\n    cycles: &amp;[Cycle],\n    ctx: &amp;DeadlineContext,\n) -&gt; Vec&lt;ProfitableCycle&gt; {\n    let mut results = Vec::new();\n\n    \u002F\u002F Sort cycles by estimated profitability (highest first)\n    let mut sorted_cycles = cycles.to_vec();\n    sorted_cycles.sort_by(|a, b| {\n        b.estimated_profit.cmp(&amp;a.estimated_profit)\n    });\n\n    for cycle in &amp;sorted_cycles {\n        if ctx.is_expired() {\n            tracing::warn!(\n                \"Deadline expired with {} cycles remaining\",\n                sorted_cycles.len() - results.len()\n            );\n            break;\n        }\n\n        if let Some((input, profit)) = find_optimal_input(\n            simulator,\n            cycle,\n            U256::from(1_000_000_000_000_000u64),  \u002F\u002F 0.001 ETH min\n            U256::from(100_000_000_000_000_000_000u64), \u002F\u002F 100 ETH max\n            ctx.gas_price,\n        ) {\n            results.push(ProfitableCycle {\n                cycle: cycle.clone(),\n                optimal_input: input,\n                expected_profit: profit,\n            });\n        }\n    }\n\n    results\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>By sorting cycles by estimated profitability before simulating, the deadline cut-off discards the least promising cycles first.\u003C\u002Fp>\n\u003Ch2 id=\"batching-strategies\">Batching Strategies\u003C\u002Fh2>\n\u003Cp>Not all cycles need simulation every block. Use tiered batching:\u003C\u002Fp>\n\u003Ch3>Tier 1: Event-Driven (Immediate)\u003C\u002Fh3>\n\u003Cp>When a \u003Ccode>Sync\u003C\u002Fcode> event fires on a pool, immediately re-simulate all cycles containing that pool. This catches fresh arbitrage created by new trades.\u003C\u002Fp>\n\u003Ch3>Tier 2: High-Value Rotation\u003C\u002Fh3>\n\u003Cp>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).\u003C\u002Fp>\n\u003Ch3>Tier 3: Background Scan\u003C\u002Fh3>\n\u003Cp>All remaining cycles are simulated in round-robin fashion across multiple blocks. Each block, simulate the next batch of 1,000 cycles.\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">pub struct TieredSimulator {\n    tier1: Vec&lt;CycleId&gt;,   \u002F\u002F event-triggered\n    tier2: Vec&lt;CycleId&gt;,   \u002F\u002F recently profitable\n    tier3_cursor: usize,   \u002F\u002F round-robin position\n    tier3: Vec&lt;CycleId&gt;,   \u002F\u002F all remaining cycles\n}\n\nimpl TieredSimulator {\n    pub fn get_simulation_batch(\n        &amp;mut self,\n        events: &amp;[PoolEvent],\n        budget: usize,      \u002F\u002F max cycles to simulate this block\n    ) -&gt; Vec&lt;CycleId&gt; {\n        let mut batch = Vec::new();\n\n        \u002F\u002F Tier 1: all event-triggered cycles\n        for event in events {\n            if let Some(cycle_ids) = self.cycles_by_pool(event.pool) {\n                batch.extend(cycle_ids);\n            }\n        }\n\n        \u002F\u002F Tier 2: recently profitable\n        batch.extend(self.tier2.iter().cloned());\n\n        \u002F\u002F Tier 3: fill remaining budget\n        let remaining = budget.saturating_sub(batch.len());\n        for _ in 0..remaining {\n            if self.tier3.is_empty() { break; }\n            batch.push(self.tier3[self.tier3_cursor % self.tier3.len()]);\n            self.tier3_cursor += 1;\n        }\n\n        batch\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"cache-invalidation\">Cache Invalidation\u003C\u002Fh2>\n\u003Cp>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.\u003C\u002Fp>\n\u003Cp>Smarter approach: only invalidate slots that changed. Subscribe to \u003Ccode>eth_subscribe(\"logs\")\u003C\u002Fcode> and \u003Ccode>eth_subscribe(\"newHeads\")\u003C\u002Fcode>. When you see a \u003Ccode>Sync\u003C\u002Fcode> event on a pool, invalidate only that pool’s storage slots:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">pub fn invalidate_pool(\n    cache: &amp;mut CacheDB&lt;EthersDB&lt;Provider&lt;Http&gt;&gt;&gt;,\n    pool: &amp;Pool,\n) {\n    match pool.protocol {\n        Protocol::UniswapV2 =&gt; {\n            \u002F\u002F Slot 8 contains packed reserves\n            cache.remove_storage(pool.address, U256::from(8));\n        }\n        Protocol::UniswapV3 =&gt; {\n            \u002F\u002F Slot 0 contains sqrtPriceX96 and tick\n            cache.remove_storage(pool.address, U256::ZERO);\n            \u002F\u002F Liquidity at slot 4\n            cache.remove_storage(pool.address, U256::from(4));\n        }\n        _ =&gt; {\n            \u002F\u002F Conservative: wipe all cached slots for this address\n            cache.remove_account(pool.address);\n        }\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"summary\">Summary\u003C\u002Fh2>\n\u003Cp>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.\u003C\u002Fp>\n","en","b0000000-0000-0000-0000-000000000001",true,"2026-03-28T10:44:22.928034Z","Deep EVM #15: MEV Simulation — Binary Search, State Forks, and Deadlines","Build the MEV simulation pipeline: Revm state forks, binary search for optimal arbitrage input, deadline-aware scheduling, and tiered batching.","mev simulation binary search",null,"index, follow",[22,27,31,35],{"id":23,"name":24,"slug":25,"created_at":26},"c0000000-0000-0000-0000-000000000016","EVM","evm","2026-03-28T10:44:21.513630Z",{"id":28,"name":29,"slug":30,"created_at":26},"c0000000-0000-0000-0000-000000000020","Gas Optimization","gas-optimization",{"id":32,"name":33,"slug":34,"created_at":26},"c0000000-0000-0000-0000-000000000019","MEV","mev",{"id":36,"name":37,"slug":38,"created_at":26},"c0000000-0000-0000-0000-000000000001","Rust","rust","Engineering",[41,47,53],{"id":42,"title":43,"slug":44,"excerpt":45,"locale":12,"category_name":39,"published_at":46},"d0200000-0000-0000-0000-000000000003","Why Bali Is Becoming Southeast Asia's Impact-Tech Hub in 2026","why-bali-becoming-southeast-asia-impact-tech-hub-2026","Bali ranks #16 among Southeast Asian startup ecosystems. With a growing concentration of Web3 builders, AI sustainability startups, and eco-travel tech companies, the island is carving a niche as the region's impact-tech capital.","2026-03-28T10:44:37.748283Z",{"id":48,"title":49,"slug":50,"excerpt":51,"locale":12,"category_name":39,"published_at":52},"d0200000-0000-0000-0000-000000000002","ASEAN Data Protection Patchwork: A Developer's Compliance Checklist","asean-data-protection-patchwork-developer-compliance-checklist","Seven ASEAN countries now have comprehensive data protection laws, each with different consent models, localization requirements, and penalty structures. Here is a practical compliance checklist for developers building multi-country applications.","2026-03-28T10:44:37.374741Z",{"id":54,"title":55,"slug":56,"excerpt":57,"locale":12,"category_name":39,"published_at":58},"d0200000-0000-0000-0000-000000000001","Indonesia's $29 Billion Digital Transformation: Opportunities for Software Companies","indonesia-29-billion-digital-transformation-opportunities-software-companies","Indonesia's IT services market is projected to reach $29.03 billion in 2026, up from $24.37 billion in 2025. Cloud infrastructure, AI, e-commerce, and data centers are driving the fastest growth in Southeast Asia.","2026-03-28T10:44:37.349311Z",{"id":13,"name":60,"slug":61,"bio":62,"photo_url":19,"linkedin":19,"role":63,"created_at":64,"updated_at":64},"Open Soft Team","open-soft-team","The engineering team at Open Soft, building premium software solutions from Bali, Indonesia.","Engineering Team","2026-03-28T08:31:22.226811Z"]