본문으로 건너뛰기
엔지니어링Mar 28, 2026

Deep EVM #15: MEV 시뮬레이션 — 이진 검색, 상태 포크, 12초 마감 시간

OS
Open Soft Team

Engineering Team

시뮬레이션 파이프라인

사이클 찾기는 쉬운 부분입니다. 어려운 부분은 어떤 사이클이 실제로 수익성이 있는지, 얼마의 자본으로 수익이 나는지를 결정하는 것입니다. 시뮬레이션 파이프라인은 세 가지 질문에 답합니다:

  1. 이 사이클이 이익을 생산하는가? (정적 수학으로는 수익성이 있어 보이지만 실제 컨트랙트 코드에 대해 시뮬레이션하면 실패하는 사이클도 있습니다.)
  2. 최적의 입력 금액은 얼마인가? (자본이 너무 적으면 = 작은 이익. 너무 많으면 = 과도한 가격 영향이 이익을 잠식.)
  3. 블록 마감 시간 전에 실행할 수 있는가? (슬롯의 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초마다 블록을 생산합니다. 새 블록이 도착하면:

  1. t=0초: 새 블록 헤더 수신. 상태 업데이트.
  2. t=0-2초: 이벤트 처리, 풀 준비금 업데이트, 변경된 사이클 식별.
  3. t=2-6초: 수익성 있는 사이클 시뮬레이션, 최적 금액 이진 검색.
  4. t=6-10초: 번들 구성, 충돌 해결, 빌더에 제출.
  5. 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 요구사항 관리, 블록 빌더에 제출.