FABRKNT
Building with the Stack — 実アプリを作る
アプリケーションパターン
レッスン 8 / 11·CONTENT45 分80 XP
コース
Building with the Stack — 実アプリを作る
レッスンの役割
CONTENT
順序
8 / 11

レッスン7 — Swap Aggregator を作る — DEX state を fork して、Rust で

問い

ユーザが 10K USDC を ETH に swap したい。Uniswap V2 / Sushi / Uniswap V3 のどれがベスト? 各 pool を直接 RPC で叩くと、pool A の reserve がリードと pool B のリードの間に動いてしまい、「リンゴと梨」を比較することになる。全 quote が同じ瞬間の state を読む atomic な aggregation はどう組むか?

CLI 出力イメージ:

$ cargo run -- quote \
    --in-token  0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 \
    --out-token 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 \
    --amount-in 10000000000

Quotes (10000 USDC -> WETH):
  Uniswap V2:    2.94821 WETH  (price 3393.08 USDC/WETH)
  Sushi V2:      2.94619 WETH  (price 3395.41 USDC/WETH)
  Uniswap V3:    2.95104 WETH  (price 3389.84 USDC/WETH)  ← BEST

原理(最小モデル)

  • 1 回 fork = N quote の atomic snapshot。 N 並列 eth_call は各 read が別 state(pool A と pool B が微妙に違うブロック)から来うる。fork は MVCC データベースが「N 個の値を atomic に同じ時点から read する」を解いてきたのと同じ構造を DEX に持ち込む。初回 ~50ms(block fetch)、以降 ~200µs/pool、ガスコストも fork 内で測れる。
  • V2 系は constant product + fee 調整、共通実装。 amount_in_with_fee = amount_in × (10000 - fee_bps)amount_out = (amount_in_with_fee × reserve_out) / (reserve_in × 10000 + amount_in_with_fee)。bps を差し替えるだけで Uniswap V2 / Sushi 等の V2 fork を共通実装で扱える。
  • V3 数式は非自明 → on-chain Quoter を fork 内で呼ぶ。 tick + concentrated range の数式を再実装せず、デプロイ済み IQuoterV2 を Revm 経由で叩く(RPC ラウンドトリップなし)。
  • token0 判定が要る。 pool はアドレス順で並ぶので、reserve_inreserve0reserve1 かは都度判定。

データ経路を 1 枚で:

flowchart TB
    User["CLI: in/out token, amount"] --> Fork["Revm fork<br/>at latest block"]
    Fork -->|getReserves| V2A["Uniswap V2 pool"]
    Fork -->|getReserves| V2B["Sushi V2 pool"]
    Fork -->|simulate swap| V3["Uniswap V3 pool<br/>(より複雑な数学)"]
    V2A --> Quote["Quote calculator"]
    V2B --> Quote
    V3 --> Quote
    Quote --> Pick["Pick best (post-fee, post-gas)"]

具体例

mainnet を fork(レッスン1 の MEV searcher と同じパターン):

use alloy_eips::BlockId;
use alloy_provider::{network::Ethereum, DynProvider, ProviderBuilder};
use revm::{
    context::TxEnv,
    context_interface::result::{ExecutionResult, Output},
    database::{AlloyDB, CacheDB},
    database_interface::WrapDatabaseAsync,
    primitives::{Address, TxKind, U256},
    Context, ExecuteEvm, MainBuilder, MainContext,
};

type ForkedDB = CacheDB<WrapDatabaseAsync<AlloyDB<Ethereum, DynProvider>>>;

async fn build_fork() -> eyre::Result<ForkedDB> {
    // Provider 例: QuickNode、Alchemy、Infura、または自前 Reth ノード。
    let provider = ProviderBuilder::new()
        .connect(&std::env::var("ETH_RPC_URL")?)
        .await?
        .erased();
    let alloy_db = WrapDatabaseAsync::new(AlloyDB::new(provider, BlockId::latest()))
        .ok_or_else(|| eyre::eyre!("AlloyDB init failed"))?;
    Ok(CacheDB::new(alloy_db))
}

V2 reserve を読む(汎用 call_view パターン):

use alloy_sol_types::{sol, SolCall};

sol! {
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
    function token0() external view returns (address);
    function token1() external view returns (address);
}

#[derive(Debug, Clone, Copy)]
pub struct V2Pool {
    pub address: Address,
    pub reserve_in: U256,
    pub reserve_out: U256,
    pub fee_bps: u32, // Uniswap V2: 30 (= 0.3%)
}

async fn read_v2_pool(
    db: &mut ForkedDB,
    pool: Address,
    in_token: Address,
    fee_bps: u32,
) -> eyre::Result<V2Pool> {
    let mut evm = Context::mainnet().with_db(db).build_mainnet();

    // 1. pool のどちら側が in_token か (token0 or token1) を見つける
    let token0 = call_view::<token0Call>(&mut evm, pool, token0Call {})?;
    let in_is_zero = token0._0 == in_token;

    // 2. reserve を読む
    let r = call_view::<getReservesCall>(&mut evm, pool, getReservesCall {})?;

    let (reserve_in, reserve_out) = if in_is_zero {
        (U256::from(r.reserve0), U256::from(r.reserve1))
    } else {
        (U256::from(r.reserve1), U256::from(r.reserve0))
    };

    Ok(V2Pool { address: pool, reserve_in, reserve_out, fee_bps })
}

fn call_view<C: SolCall>(
    evm: &mut impl ExecuteEvm<Tx = TxEnv>,
    target: Address,
    call: C,
) -> eyre::Result<C::Return> {
    let result = evm.transact_one(
        TxEnv::builder()
            .caller(Address::ZERO)
            .kind(TxKind::Call(target))
            .data(call.abi_encode().into())
            .gas_limit(1_000_000)
            .build()?,
    )?;

    match result.result {
        ExecutionResult::Success { output: Output::Call(out), .. } => {
            Ok(C::abi_decode_returns(&out, true)?)
        }
        _ => eyre::bail!("view call failed"),
    }
}

V2 quote 数学(constant product + fee)— Uniswap V2 router の getAmountOut と行単位で同じ:

fn quote_v2(pool: V2Pool, amount_in: U256) -> U256 {
    // Uniswap V2 公式: amount_in_with_fee = amount_in * (10000 - fee_bps)
    //                  numerator   = amount_in_with_fee * reserve_out
    //                  denominator = reserve_in * 10000 + amount_in_with_fee
    //                  amount_out  = numerator / denominator
    let amount_in_with_fee = amount_in * U256::from(10_000 - pool.fee_bps);
    let numerator   = amount_in_with_fee * pool.reserve_out;
    let denominator = pool.reserve_in * U256::from(10_000) + amount_in_with_fee;
    numerator / denominator
}

V3 quote(on-chain Quoter を fork 内で呼ぶ、数式を再実装しない):

sol! {
    interface IQuoterV2 {
        function quoteExactInputSingle(
            address tokenIn,
            address tokenOut,
            uint24  fee,
            uint256 amountIn,
            uint160 sqrtPriceLimitX96
        ) external returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate);
    }
}

const UNI_V3_QUOTER: Address = alloy_primitives::address!("61fFE014bA17989E743c5F6cB21bF9697530B21e");

fn quote_v3(
    db: &mut ForkedDB,
    in_token: Address,
    out_token: Address,
    fee: u32,  // 100 / 500 / 3000 / 10000
    amount_in: U256,
) -> eyre::Result<U256> {
    let mut evm = Context::mainnet().with_db(db).build_mainnet();
    let call = IQuoterV2::quoteExactInputSingleCall {
        tokenIn:               in_token,
        tokenOut:              out_token,
        fee:                   fee.into(),
        amountIn:              amount_in,
        sqrtPriceLimitX96:     U256::ZERO,
    };

    let result = evm.transact_one(
        TxEnv::builder()
            .caller(Address::ZERO)
            .kind(TxKind::Call(UNI_V3_QUOTER))
            .data(call.abi_encode().into())
            .gas_limit(10_000_000)
            .build()?,
    )?;

    match result.result {
        ExecutionResult::Success { output: Output::Call(out), .. } => {
            let decoded = IQuoterV2::quoteExactInputSingleCall::abi_decode_returns(&out, true)?;
            Ok(decoded.amountOut)
        }
        _ => eyre::bail!("V3 quote failed"),
    }
}

aggregate + best 選択(~250 LOC バイナリ全体):

#[derive(Debug)]
struct Quote {
    venue: &'static str,
    amount_out: U256,
}

async fn aggregate(
    db: &mut ForkedDB,
    in_token: Address,
    out_token: Address,
    amount_in: U256,
) -> eyre::Result<Vec<Quote>> {
    let uni_v2_pool   = address!("0d4a11d5EEaaC28EC3F61d100daF4d40471f1852"); // USDC/WETH on Uniswap V2 (例)
    let sushi_pool    = address!("397FF1542f962076d0BFE58eA045FfA2d347ACa0"); // USDC/WETH on Sushi (例)

    let v2 = read_v2_pool(db, uni_v2_pool, in_token, 30).await?;
    let sushi = read_v2_pool(db, sushi_pool, in_token, 30).await?;
    let v3 = quote_v3(db, in_token, out_token, 500, amount_in)?;

    Ok(vec![
        Quote { venue: "Uniswap V2", amount_out: quote_v2(v2, amount_in) },
        Quote { venue: "Sushi V2",   amount_out: quote_v2(sushi, amount_in) },
        Quote { venue: "Uniswap V3", amount_out: v3 },
    ])
}

fn pick_best(quotes: &[Quote]) -> &Quote {
    quotes.iter().max_by_key(|q| q.amount_out).expect("non-empty quotes")
}

#[tokio::main]
async fn main() -> eyre::Result<()> {
    let args = Args::parse();
    let mut db = build_fork().await?;
    let quotes = aggregate(&mut db, args.in_token, args.out_token, args.amount_in).await?;
    let best = pick_best(&quotes);

    println!("Quotes ({} {} -> {}):", args.amount_in, args.in_token, args.out_token);
    for q in &quotes {
        let marker = if std::ptr::eq(q, best) { "  ← BEST" } else { "" };
        println!("  {:<14} {:>20}{}", q.venue, q.amount_out, marker);
    }
    Ok(())
}

失敗例(誤解)

「各 pool を直接 RPC で叩けばいい」は誤り — N 並列 eth_call では各 read が別 state(pool A read と pool B read の間に reserve が動く)から来うる。「ベストルート」の計算がリンゴと梨の比較になる。fork は世界の単一ビューを与え、aggregation を健全にする — atomicity こそが本質。


ここまでで「1 fork = atomic snapshot、V2 = constant product、V3 = Quoter 呼び」は着地した。ここから 5 ステップで組み立てる。コードは抜粋(実行時は補助コードが必要)。

🛑 予測。 各 pool を直接 RPC で叩かず fork で state を読む理由は?(答え: 全 read が同じ atomic snapshot から得られる + 仮想 swap のガスも fork 内で測れる + 初回 fetch 後は ~200µs/pool で N 並列 RPC より遥かに速い。atomicity は「ベストルート」を健全にする最低条件 — pool 間 state drift を抑える唯一の道。)

ステップで組み立てる

Step 0: プロジェクトと依存

# Cargo.toml
[package]
name = "swap-aggregator"
version = "0.1.0"
edition = "2021"

[dependencies]
alloy-eips         = "1.0"
alloy-primitives   = "1.5"
alloy-provider     = "1.0"
alloy-network      = "1.0"
alloy-sol-types    = "1.5"
revm               = { version = "38", features = ["alloydb"] }
clap               = { version = "4", features = ["derive"] }
tokio              = { version = "1", features = ["full"] }
eyre               = "0.6"

ETH_RPC_URL を env に設定(QuickNode / Alchemy / Infura / 自前 Reth)。

Step 1-5

上の 5 ブロック: ① build_forkAlloyDB + CacheDB、最新ブロック)→ ② read_v2_pooltoken0 判定 + getReservescall_view)→ ③ quote_v2(constant product + fee、U256 のみ)→ ④ quote_v3IQuoterV2 を Revm 経由で呼ぶ、sqrtPriceLimitX96=0 で価格制限無効)→ ⑤ aggregate + pick_best

production gap: マルチホップ(A → WETH → B のグラフ + 重み付き Bellman-Ford)/ split routing(40% V3、60% V2 の凸最適化)/ Curve(stableswap Newton 法)/ Balancer(weighted pool)/ ガス考慮(推定ガスを out-token で引く)/ price-impact 閾値(X% 超え動かすルートを却下、MEV sandwich 対策)/ submission 時の再 quote(state drift)/ MEV 保護(Flashbots Protect / MEV-Share、L8 capstone)。

Step 6: 実行

$ ETH_RPC_URL=https://mainnet.infura.io/v3/$KEY cargo run --release -- quote \
    --in-token  0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 \
    --out-token 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 \
    --amount-in 10000000000
Quotes (10000 USDC -> WETH):
  Uniswap V2:    ... WETH
  Sushi V2:      ... WETH
  Uniswap V3:    ... WETH  ← BEST

答え合わせ(Test gate)

pin した mainnet で QuoterV2 differential(aggregator の正しさは「Quoter と同じ出力を返すか」の二値):

// tests/aggregator_quote_diff.rs
use alloy::primitives::{address, U256};

const PINNED_BLOCK: u64 = 18_500_000;
const FORK_RPC: &str = "https://eth.merkle.io";
const QUOTER_V2: Address = address!("61fFE014bA17989E743c5F6cB21bF9697530B21e");

#[tokio::test]
async fn matches_quoter_for_known_input() {
    let mut db = build_fork_at(FORK_RPC, PINNED_BLOCK).await;

    // 10,000 USDC -> WETH (Uniswap V3 0.3% pool)
    let amount_in = U256::from(10_000) * U256::from(10).pow(U256::from(6));

    // 自前 aggregator のパス(V3 のみ、シングルホップ)
    let our_quote = quote_v3(&mut db, USDC, WETH, 3000, amount_in).await.unwrap();

    // 参照: 同じ forked state での Uniswap QuoterV2
    let reference_quote = call_quoter_v2(&mut db, QUOTER_V2, USDC, WETH, 3000, amount_in).await.unwrap();

    // fee 会計の精度のため ε を許容(basis-point 級)
    let diff_bps = (our_quote.abs_diff(reference_quote) * U256::from(10_000)) / reference_quote;
    assert!(diff_bps < U256::from(5), "QuoterV2 と 5 bps 以内で一致するはず; got {} bps", diff_bps);
}

#[tokio::test]
async fn picks_best_when_v3_dominates() {
    // V3 が最良価格となる状況を構築し、pick_best が V3 を返すことを assert
    // ブロックエクスプローラで実際に成立するブロックを引いて使う
}

QuoterV2 differential が pass まで未完了。数学が 50 bps 狂っていれば、全ユーザに静かに最適でないルートを推薦している。

合格基準

  • 上記 2 テスト(QuoterV2 と 5 bps 以内 + V3 が dominant な block で pick_best が V3 を返す)が green。
  • なぜ fork が N 並列 RPC より厳密に優れているか(atomic state を貫く + ガス測れる)を 1 文で言える。
  • V2 の amount_in_with_fee × reserve_out が分子に来る理由(次元)を即答できる。

Drill

  1. Curve 3pool を追加(stableswap get_dy)(1.5 時間)。
  2. ガス会計(推定ガスを out-token で引く)(2 時間)。
  3. 2-hop 探索(A → WETH → B、直接と比較)(3 時間)。
  4. Split routing(top 2 venue 50/50、合計 > 単独 をチェック)(2 時間)。
  5. L4 wallet backend に POST /quote-and-swap として組み込み、署名済み tx を返す(3 時間)。

まとめ(3行)

  • 1 fork = 全 quote が同じ atomic state を読む(MVCC の N キー atomic read と同じ)— N 並列 RPC では pool 間 state drift で「リンゴと梨」になる。
  • V2 = constant product + fee の共通実装で fork 横断(bps を差し替えるだけ)、V3 = on-chain IQuoterV2 を fork 内で呼んで数式の再実装を避ける(sqrtPriceLimitX96=0)。
  • Test gate: pin block で QuoterV2 differential を 5 bps 以内、V3 dominant な block で pick_best 検証。次は capstone の frontrun-resistant order router。

次のレッスン(レッスン8)

Capstone — Frontrun-Resistant Order Router を作る。L1/L2/L3 / L4-7 で築いた pattern を統合(searcher pipeline + custom RPC + wallet backend + 7702 sponsor + aggregator)し、end-to-end fork テストで order 投入から split routing → 着地 → fill 報告までを観察する。