Deep EVM #15: MEV 시뮬레이션 — 이진 검색, 상태 포크, 12초 마감 시간
Engineering Team
시뮬레이션 파이프라인
사이클 찾기는 쉬운 부분입니다. 어려운 부분은 어떤 사이클이 실제로 수익성이 있는지, 얼마의 자본으로 수익이 나는지를 결정하는 것입니다. 시뮬레이션 파이프라인은 세 가지 질문에 답합니다:
- 이 사이클이 이익을 생산하는가? (정적 수학으로는 수익성이 있어 보이지만 실제 컨트랙트 코드에 대해 시뮬레이션하면 실패하는 사이클도 있습니다.)
- 최적의 입력 금액은 얼마인가? (자본이 너무 적으면 = 작은 이익. 너무 많으면 = 과도한 가격 영향이 이익을 잠식.)
- 블록 마감 시간 전에 실행할 수 있는가? (슬롯의 11초에 발견된 수익성 있는 사이클은 무가치합니다.)
사이클 → 상태 포크 → 시뮬레이션 → 이진 검색 → 이익/손실 → 번들 또는 폐기
상태 포크
스왑을 시뮬레이션하려면 전체 EVM 상태가 필요합니다: 컨트랙트 바이트코드, 스토리지 슬롯, 잔액. 모든 시뮬레이션에 실제 노드를 호출할 수 없습니다 — 지연 시간이 치명적입니다. 대신 상태를 로컬로 포크합니다.
접근법 1: Revm (Rust EVM)
Revm은 포크된 상태 데이터베이스에 대해 실행할 수 있는 Rust의 EVM 구현입니다:
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, // 풀 또는 라우터
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))
}
}
}
}
CacheDB 레이어는 스토리지 읽기를 가로챕니다: 슬롯의 첫 읽기 시 원격 프로바이더에서 가져와 로컬에 캐시합니다. 이후 읽기는 즉시입니다. 사이클의 첫 시뮬레이션은 느리지만(cold 슬롯에 약 50-100ms) 다른 금액으로의 반복 시뮬레이션은 캐시를 재사용합니다(약 0.5-1ms).
접근법 2: 사전 캐시 상태
최대 속도를 위해, 각 블록 시작 시 모든 관련 스토리지 슬롯을 사전 로드합니다:
pub async fn preload_pool_state(
provider: &Provider<Http>,
pools: &[Pool],
db: &mut CacheDB<EthersDB<Provider<Http>>>,
) {
// 필요한 모든 풀 슬롯에 대해 eth_getStorageAt 호출을 일괄 처리
let mut futures = Vec::new();
for pool in pools {
// Uniswap V2: 슬롯 8 = reserve0+reserve1 (패킹)
// Uniswap V3: 슬롯 0 = sqrtPriceX96+tick (패킹)
futures.push(provider.get_storage_at(
pool.address,
H256::from_low_u64_be(8),
None,
));
}
let results = futures::future::join_all(futures).await;
// cache_db에 삽입...
}
사전 캐시를 사용하면 모든 시뮬레이션이 로컬 메모리에 대해 실행됩니다. 시뮬레이션 시간이 사이클당 0.1-0.5ms로 감소합니다.
최적 입력을 위한 이진 검색
차익거래 사이클의 이익 함수는 일반적으로 오목합니다: 입력 금액이 증가하면(더 많은 자본 = 더 많은 이익) 가격 영향이 차익거래 스프레드를 초과할 때까지 상승한 후 하락합니다. 최적 입력은 정점에 있습니다.
이익
^
| ****
| ** **
| * **
| * ***
| * ****
|* *****
+----------------------------> 입력 금액
^-- 최적
이진 검색이 이 정점을 효율적으로 찾습니다:
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;
// 이진 검색: 수렴에 약 20회 반복
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) => {
lo = mid_left;
hi = mid_right;
}
}
}
// 가스 비용 차감
let gas_cost = estimate_gas_cost(cycle) * gas_price;
if best_profit > gas_cost {
Some((best_input, best_profit - gas_cost))
} else {
None
}
}
이 삼진 검색 변형(두 중간점 비교)은 단봉 함수에 대해 더 빠르게 수렴합니다. 20회 반복이면 검색 범위의 1/2^20 이내의 정밀도를 제공합니다 — 충분합니다.
12초 마감 시간
이더리움은 12초마다 블록을 생산합니다. 새 블록이 도착하면:
- t=0초: 새 블록 헤더 수신. 상태 업데이트.
- t=0-2초: 이벤트 처리, 풀 준비금 업데이트, 변경된 사이클 식별.
- t=2-6초: 수익성 있는 사이클 시뮬레이션, 최적 금액 이진 검색.
- t=6-10초: 번들 구성, 충돌 해결, 빌더에 제출.
- t=10-12초: 빌더가 다음 블록 제안에 번들 포함.
시뮬레이션이 8초 걸리면 윈도우를 놓칩니다. 속도가 모든 것입니다.
마감 시간 인식 취소
모든 시뮬레이션 루프는 시계를 확인해야 합니다:
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
}
}
추정 수익성 순으로 사이클을 정렬하면, 마감 시간 컷오프가 가장 유망하지 않은 사이클을 먼저 폐기합니다.
계층화 배칭 전략
모든 사이클이 매 블록 시뮬레이션이 필요하지는 않습니다. 계층화 배칭을 사용합니다:
계층 1: 이벤트 기반 (즉시)
Sync 이벤트가 풀에서 발생하면, 해당 풀을 포함하는 모든 사이클을 즉시 재시뮬레이션합니다. 새로운 거래로 생성된 신선한 차익거래를 포착합니다.
계층 2: 고가치 순환
최근 블록에서 수익성이 있었던 사이클은 이벤트가 발생하지 않아도 매 블록 시뮬레이션됩니다. 풀 준비금이 다른 메커니즘(직접 전송, 리베이싱 토큰 등)을 통해 변경되었을 수 있습니다.
계층 3: 백그라운드 스캔
나머지 모든 사이클은 여러 블록에 걸쳐 라운드로빈 방식으로 시뮬레이션됩니다. 각 블록에서 다음 1,000개 사이클 배치를 시뮬레이션합니다.
캐시 무효화
새 블록이 도착하면 CacheDB를 무효화해야 합니다. 하지만 전체 캐시 삭제는 비용이 많이 듭니다 — 수천 개의 스토리지 슬롯을 다시 가져와야 합니다.
더 스마트한 접근법: 변경된 슬롯만 무효화합니다. eth_subscribe("logs")와 eth_subscribe("newHeads")를 구독합니다. 풀에서 Sync 이벤트를 보면, 해당 풀의 스토리지 슬롯만 무효화합니다:
pub fn invalidate_pool(
cache: &mut CacheDB<EthersDB<Provider<Http>>>,
pool: &Pool,
) {
match pool.protocol {
Protocol::UniswapV2 => {
// 슬롯 8에 패킹된 준비금 포함
cache.remove_storage(pool.address, U256::from(8));
}
Protocol::UniswapV3 => {
// 슬롯 0에 sqrtPriceX96와 tick 포함
cache.remove_storage(pool.address, U256::ZERO);
// 슬롯 4에 유동성
cache.remove_storage(pool.address, U256::from(4));
}
_ => {
// 보수적: 이 주소의 모든 캐시 슬롯 삭제
cache.remove_account(pool.address);
}
}
}
요약
시뮬레이션은 사이클이 이익이 되는 곳입니다. Revm은 서브밀리초 실행을 위한 로컬 EVM을 제공합니다. 이진 검색은 약 20회 반복으로 최적 입력 금액을 찾습니다. 마감 시간 인식 스케줄링은 실행할 수 없는 사이클에 시간을 낭비하지 않게 합니다. 계층화 배칭은 가장 높은 확률의 기회에 컴퓨팅을 집중합니다. 다음 기사에서는 수익성 있는 사이클을 번들로 패킹합니다 — 충돌 해결, MEV-Share 요구사항 관리, 블록 빌더에 제출.