Deep EVM #15: Симуляция MEV — бинарный поиск, форки стейта и 12-секундный дедлайн
Engineering Team
Зачем нужна симуляция
Найти арбитражный цикл — полдела. Нужно определить оптимальный размер входа (сколько WETH вложить), подтвердить прибыль на актуальном стейте и сделать это за миллисекунды, пока возможность не устарела.
Симуляция отвечает на три вопроса:
- Прибылен ли маршрут? — с учётом реальных резервов, комиссий, слиппеджа
- Какой оптимальный размер входа? — слишком мало = мало прибыли, слишком много = price impact съедает профит
- Сколько газа потратится? — для расчёта чистой прибыли
Форк стейта с 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 секунд. Серчер должен:
- Получить новый блок/pending tx (0 мс)
- Обновить граф резервов (~5 мс)
- Найти арбитражные циклы (~50 мс)
- Симулировать топ-N маршрутов (~100-500 мс)
- Оптимизировать размер входа (~200 мс)
- Подписать и отправить бандл (~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-секундный дедлайн слота. В следующей статье — бандлинг: как упаковать множество прибыльных транзакций в один бандл и разрешить конфликты между ними.