Перейти к основному содержимому
ИнженерияMar 28, 2026

Deep EVM #15: Симуляция MEV — бинарный поиск, форки стейта и 12-секундный дедлайн

OS
Open Soft Team

Engineering Team

Зачем нужна симуляция

Найти арбитражный цикл — полдела. Нужно определить оптимальный размер входа (сколько WETH вложить), подтвердить прибыль на актуальном стейте и сделать это за миллисекунды, пока возможность не устарела.

Симуляция отвечает на три вопроса:

  1. Прибылен ли маршрут? — с учётом реальных резервов, комиссий, слиппеджа
  2. Какой оптимальный размер входа? — слишком мало = мало прибыли, слишком много = price impact съедает профит
  3. Сколько газа потратится? — для расчёта чистой прибыли

Форк стейта с revm

revm — библиотека на Rust, реализующая полный EVM. Она позволяет исполнять транзакции на локальной копии стейта без взаимодействия с реальной сетью:

use revm::{
    db::{CacheDB, EmptyDB, EthersDB},
    primitives::{AccountInfo, Address, Bytecode, U256, TransactTo},
    EVM,
};
use ethers::providers::{Provider, Http};

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

impl StateForker {
    pub async fn new(rpc_url: &str) -> Self {
        let provider = Provider::<Http>::try_from(rpc_url).unwrap();
        let ethers_db = EthersDB::new(provider, None).unwrap();
        let cache_db = CacheDB::new(ethers_db);
        Self { db: cache_db }
    }
    
    pub fn simulate_call(
        &mut self,
        from: Address,
        to: Address,
        calldata: Vec<u8>,
        value: U256,
    ) -> SimulationResult {
        let mut evm = EVM::new();
        evm.database(&mut self.db);
        
        evm.env.tx.caller = from;
        evm.env.tx.transact_to = TransactTo::Call(to);
        evm.env.tx.data = calldata.into();
        evm.env.tx.value = value;
        evm.env.tx.gas_limit = 500_000;
        
        match evm.transact_commit() {
            Ok(result) => SimulationResult {
                success: result.is_success(),
                gas_used: result.gas_used(),
                output: result.output().unwrap_or_default().to_vec(),
                logs: result.logs().to_vec(),
            },
            Err(e) => SimulationResult {
                success: false,
                gas_used: 0,
                output: vec![],
                logs: vec![],
            },
        }
    }
}

pub struct SimulationResult {
    pub success: bool,
    pub gas_used: u64,
    pub output: Vec<u8>,
    pub logs: Vec<revm::primitives::Log>,
}

Snapshot и Rollback

Ключевая возможность — snapshot/rollback. Вы сохраняете состояние, исполняете транзакцию, проверяете результат и откатываете если нужно:

impl StateForker {
    pub fn snapshot(&self) -> CacheDB<EthersDB<Provider<Http>>> {
        self.db.clone()
    }
    
    pub fn rollback(&mut self, snapshot: CacheDB<EthersDB<Provider<Http>>>) {
        self.db = snapshot;
    }
}

Это позволяет тестировать множество вариантов без повторной загрузки стейта.

Бинарный поиск оптимального размера

Функция прибыли от размера входа имеет один максимум — сначала растёт (больше объём = больше абсолютная прибыль), потом падает (price impact поглощает profit). Это позволяет использовать тернарный или бинарный поиск:

pub fn find_optimal_input(
    forker: &mut StateForker,
    route: &ArbitrageRoute,
    min_input: U256,
    max_input: U256,
    iterations: u32,
) -> (U256, U256) {
    let mut lo = min_input;
    let mut hi = max_input;
    let mut best_input = min_input;
    let mut best_profit = U256::ZERO;
    
    for _ in 0..iterations {
        let mid1 = lo + (hi - lo) / 3;
        let mid2 = hi - (hi - lo) / 3;
        
        let snapshot = forker.snapshot();
        let profit1 = simulate_route(forker, route, mid1);
        forker.rollback(snapshot.clone());
        
        let profit2 = simulate_route(forker, route, mid2);
        forker.rollback(snapshot);
        
        if profit1 < profit2 {
            lo = mid1;
            if profit2 > best_profit {
                best_profit = profit2;
                best_input = mid2;
            }
        } else {
            hi = mid2;
            if profit1 > best_profit {
                best_profit = profit1;
                best_input = mid1;
            }
        }
    }
    
    (best_input, best_profit)
}

fn simulate_route(
    forker: &mut StateForker,
    route: &ArbitrageRoute,
    input: U256,
) -> U256 {
    let calldata = encode_arbitrage_calldata(route, input);
    let result = forker.simulate_call(
        SEARCHER_ADDRESS,
        CONTRACT_ADDRESS,
        calldata,
        U256::ZERO,
    );
    
    if !result.success {
        return U256::ZERO;
    }
    
    // Декодируем прибыль из output
    decode_profit(&result.output)
}

Почему тернарный поиск

Функция прибыли — унимодальная (один пик). Тернарный поиск гарантированно находит максимум за O(log n) итераций. На практике 30-40 итераций достаточно для точности до 1 wei при диапазоне 0-1000 ETH.

Учёт газа и приоритетной комиссии

Чистая прибыль серчера:

pub fn calculate_net_profit(
    gross_profit: U256,
    gas_used: u64,
    base_fee: U256,
    priority_fee: U256,
) -> i128 {
    let gas_cost = U256::from(gas_used) * (base_fee + priority_fee);
    let builder_tip = gross_profit * 90 / 100;  // 90% отдаём билдеру
    
    let net = gross_profit.as_u128() as i128
        - gas_cost.as_u128() as i128
        - builder_tip.as_u128() as i128;
    net
}

На практике серчеры отдают 90-99% прибыли билдеру в виде приоритетной комиссии. Это потому что конкурирующие серчеры поднимают ставки.

12-секундный дедлайн

Ethereum производит блок каждые 12 секунд. Серчер должен:

  1. Получить новый блок/pending tx (0 мс)
  2. Обновить граф резервов (~5 мс)
  3. Найти арбитражные циклы (~50 мс)
  4. Симулировать топ-N маршрутов (~100-500 мс)
  5. Оптимизировать размер входа (~200 мс)
  6. Подписать и отправить бандл (~10 мс)

Итого: ~500-800 мс. Остальное время — запас на сетевую задержку и непредвиденности.

use tokio::time::{timeout, Duration};

pub async fn searcher_loop(mut forker: StateForker, graph: ArbitrageGraph) {
    let deadline = Duration::from_millis(800);
    
    loop {
        let new_block = wait_for_new_block().await;
        
        let result = timeout(deadline, async {
            // 1. Обновляем резервы
            update_reserves(&mut forker, &new_block).await;
            
            // 2. Ищем циклы
            let routes = graph.find_cycles(WETH, 3);
            
            // 3. Сортируем по потенциальной прибыли
            let mut sorted = routes;
            sorted.sort_by(|a, b| b.profit_ratio.partial_cmp(&a.profit_ratio).unwrap());
            
            // 4. Симулируем топ-10
            let mut best = None;
            for route in sorted.iter().take(10) {
                let snapshot = forker.snapshot();
                let (input, profit) = find_optimal_input(
                    &mut forker, route,
                    U256::from(1_000_000_000_000_000_u64),  // 0.001 ETH
                    U256::from(100_000_000_000_000_000_000_u128),  // 100 ETH
                    30,
                );
                forker.rollback(snapshot);
                
                if profit > U256::ZERO {
                    let net = calculate_net_profit(
                        profit, 300_000, new_block.base_fee, priority_fee,
                    );
                    if net > 0 {
                        best = Some((route.clone(), input, profit));
                        break;
                    }
                }
            }
            
            best
        }).await;
        
        match result {
            Ok(Some((route, input, profit))) => {
                submit_bundle(&route, input).await;
            }
            Ok(None) => { /* нет прибыльных маршрутов */ }
            Err(_) => { /* таймаут — пропускаем блок */ }
        }
    }
}

Оптимизация производительности симуляции

Предзагрузка стейта

revm с EthersDB загружает стейт лениво — при первом обращении к слоту хранилища делает RPC-запрос. Это медленно. Решение — предзагрузить стейт всех пулов при старте:

pub async fn preload_pool_state(
    forker: &mut StateForker,
    pools: &[Address],
) {
    for pool in pools {
        // Предзагружаем storage слоты reserve0, reserve1
        forker.db.storage(*pool, U256::from(8));  // slot 8 = reserves
    }
}

Параллельная симуляция

Каждый маршрут можно симулировать в отдельном потоке с собственным клоном стейта:

use rayon::prelude::*;

pub fn simulate_routes_parallel(
    base_state: &CacheDB<EthersDB<Provider<Http>>>,
    routes: &[ArbitrageRoute],
) -> Vec<(usize, U256, U256)> {
    routes
        .par_iter()
        .enumerate()
        .filter_map(|(i, route)| {
            let mut local_db = base_state.clone();
            let mut local_forker = StateForker { db: local_db };
            let (input, profit) = find_optimal_input(
                &mut local_forker, route,
                MIN_INPUT, MAX_INPUT, 30,
            );
            if profit > U256::ZERO {
                Some((i, input, profit))
            } else {
                None
            }
        })
        .collect()
}

Итоги

Симуляция — мост между обнаружением возможности и её реализацией. revm позволяет форкать стейт и исполнять транзакции локально за микросекунды. Тернарный поиск находит оптимальный размер входа за 30 итераций. Всё должно уложиться в 12-секундный дедлайн слота. В следующей статье — бандлинг: как упаковать множество прибыльных транзакций в один бандл и разрешить конфликты между ними.