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

レッスン8 — Capstone — Frontrun-Resistant Order Router を作る

問い

ユーザの swap intent(JSON)を受け、ベスト venue を選び、mempool に sandwich 仕掛けの敵 tx がいるか検出し、シミュレーションで脅威スコアを出し、脅威が高ければ private mempool(Flashbots Protect)へ、低ければ public mempool へ送る。L1(searcher pipeline)+ L4(wallet)+ L5(sponsor)+ L7(aggregator)を統合し、追加実装は 決定レイヤー だけに絞る — どう組むか?

公開する HTTP API はこの形:

$ curl -X POST http://localhost:9000/route \
    -d '{
      "user":      "0xAlice...",
      "in_token":  "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
      "out_token": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
      "amount_in": "10000000000",
      "min_out":   "2900000000000000000",
      "user_authorization": "0x04f8..."
    }'

{
  "decision": "EXECUTE_PRIVATE",
  "venue": "Uniswap V3",
  "expected_out": "2951042818093142817",
  "frontrun_risk": "LOW",
  "tx_hash": "0xabc...",
  "submission": "flashbots-protect"
}

原理(最小モデル)

  • 決定レイヤーが本キャップストーンの新規。 下のすべては L1/L4/L5/L7 を再利用(quote → 敵検出 → simulation でスコア → private vs public)。RouteRequest → RouteDecision の関数として書き、終端状態は 3 つ(EXECUTE_PRIVATE / EXECUTE_PUBLIC / REJECT_TOO_RISKY)。
  • L1 mempool 監視を防御に転用。 searcher の入力(pending tx の購読)を「ユーザが使う pool を同じ方向に動かそうとする tx」の検出に転用する。緩い判定(substring or 既知 router へのターゲット)で false positive を許容 → 安全側(private へ逃がす)に倒す。
  • 「敵が先に着地したら出力はどれだけ落ちる?」を fork で測る。 候補検出だけでは判定できない。敵 tx を fork に適用してから re-quote、差分 bps((before - after) × 10000 / before)が脅威スコア。0-10 bps = Low、11-50 = Medium、51+ = High。
  • 2 つの provider(public + private)が設計の核。 同じ Alloy コードに違うエンドポイント(rpc.flashbots.net/protect vs Infura/自前 Reth)— sandwich を打ち破る非対称性。

データ経路を 1 枚で(前レッスンの再利用部 + 新規部):

flowchart TB
    User["POST /route"] --> Router["Router service"]
    Router -->|fork mainnet| Aggregator["Aggregator (レッスン7)<br/>quotes + best venue"]
    Router -->|scan pending txs| Detector["Frontrun detector<br/>(レッスン 1 mempool watch +<br/>レッスン 7 simulation)"]
    Detector -->|adversarial tx found?| Decide{"Risk?"}
    Aggregator --> Decide
    Decide -->|HIGH| PrivPath["Private mempool<br/>(Flashbots Protect)"]
    Decide -->|LOW| PubPath["Public mempool"]
    PrivPath --> Sponsor["EIP-7702 sponsor (レッスン5)"]
    PubPath --> Sponsor
    Sponsor --> Wallet["Wallet backend (レッスン4)<br/>nonce/gas/replace"]
    Wallet --> Chain

具体例

決定 struct(アーキテクチャを 1 つの型で):

use alloy::primitives::{Address, B256, U256};

#[derive(serde::Deserialize)]
pub struct RouteRequest {
    pub user: Address,
    pub in_token: Address,
    pub out_token: Address,
    pub amount_in: U256,
    pub min_out: U256,
    pub user_authorization: String,  // EIP-7702 SignedAuthorization、hex エンコード
}

#[derive(Debug, Clone, Copy)]
pub enum FrontrunRisk { Low, Medium, High }

#[derive(serde::Serialize)]
pub struct RouteDecision {
    pub decision:        &'static str,    // "EXECUTE_PRIVATE" | "EXECUTE_PUBLIC" | "REJECT_TOO_RISKY"
    pub venue:           Option<&'static str>,
    pub expected_out:    Option<U256>,
    pub frontrun_risk:   String,
    pub tx_hash:         Option<B256>,
    pub submission:      Option<&'static str>,  // "flashbots-protect" | "public" | null
    pub reason:          Option<String>,
}

Frontrun 検出(L1 を視点反転、searcher の機会監視を脅威監視に):

use alloy::providers::{Provider, ProviderBuilder, WsConnect};
use futures::StreamExt;
use std::time::Duration;

async fn scan_for_adversaries(
    provider: &(impl Provider + Clone),
    target_pool: Address,  // ユーザがこれから使う pool
    in_token:    Address,  // 方向が重要: 同じ方向 = sandwich リスク
    duration:    Duration,
) -> eyre::Result<Vec<alloy::rpc::types::Transaction>> {
    let mut sub = provider.subscribe_pending_transactions().await?.into_stream();
    let mut findings = Vec::new();
    let deadline = tokio::time::Instant::now() + duration;

    loop {
        tokio::select! {
            _ = tokio::time::sleep_until(deadline) => break,
            tx_hash = sub.next() => {
                let Some(tx_hash) = tx_hash else { break; };
                let Ok(Some(tx)) = provider.get_transaction_by_hash(tx_hash).await else { continue };

                if !looks_like_swap_on(&tx, target_pool, in_token) { continue }
                findings.push(tx);
                if findings.len() >= 5 { break }  // 5 候補でスコアには十分
            }
        }
    }
    Ok(findings)
}

fn looks_like_swap_on(tx: &alloy::rpc::types::Transaction, pool: Address, in_token: Address) -> bool {
    use alloy::primitives::address;
    const KNOWN_ROUTERS: &[Address] = &[
        address!("7a250d5630B4cF539739dF2C5dAcb4c659F2488D"), // UniV2
        address!("d9e1cE17f2641f24aE83637ab66a2cca9C378B9F"), // Sushi V2
        address!("E592427A0AEce92De3Edee1F18E0157C05861564"), // UniV3
    ];
    if !KNOWN_ROUTERS.contains(&tx.to().unwrap_or_default()) { return false; }
    let input = tx.input();
    let pool_bytes = pool.as_slice();
    let in_token_bytes = in_token.as_slice();
    // 簡易 substring check — 安価、このレイヤーでは false positive OK
    has_subseq(input, pool_bytes) || has_subseq(input, in_token_bytes)
}

fn has_subseq(haystack: &[u8], needle: &[u8]) -> bool {
    haystack.windows(needle.len()).any(|w| w == needle)
}

Revm シミュレーションで脅威をスコアリング:

async fn score_risk(
    db: &mut ForkedDB,                            // fresh fork、mutate される
    adversary_txs: &[alloy::rpc::types::Transaction],
    quote_before: U256,                           // 敵がいない場合のユーザの output
    req: &RouteRequest,
) -> eyre::Result<FrontrunRisk> {
    if adversary_txs.is_empty() { return Ok(FrontrunRisk::Low); }

    // 各敵 tx を fork に適用
    // (本物の router はシナリオごとに snapshot+rollback。ここでは sequential)
    for adv in adversary_txs {
        apply_tx_to_fork(db, adv).await?;
    }

    // post-敵 state で re-quote
    let quote_after = aggregate(db, req.in_token, req.out_token, req.amount_in).await?;
    let after_amount = pick_best(&quote_after).amount_out;

    // ユーザは期待 output の何分の何を失うか?
    let lost_bps = if quote_before > after_amount {
        ((quote_before - after_amount) * U256::from(10_000) / quote_before)
            .to_string().parse::<u64>().unwrap_or(0)
    } else { 0 };

    Ok(match lost_bps {
        0..=10   => FrontrunRisk::Low,    // <0.10% drop — ノイズ
        11..=50  => FrontrunRisk::Medium, // 0.10%〜0.50% drop — 防御の価値あり
        _        => FrontrunRisk::High,   // >0.50% drop — 必ず private にルート
    })
}

async fn apply_tx_to_fork(
    db: &mut ForkedDB,
    tx: &alloy::rpc::types::Transaction,
) -> eyre::Result<()> {
    use revm::context::TxEnv;
    use revm::primitives::TxKind;
    let mut evm = revm::Context::mainnet().with_db(db).build_mainnet();
    let tx_env = TxEnv::builder()
        .caller(tx.from())
        .kind(if let Some(to) = tx.to() { TxKind::Call(to) } else { TxKind::Create })
        .data(tx.input().clone())
        .value(tx.value())
        .gas_limit(tx.gas_limit())
        .build()?;
    let _ = evm.transact_one(tx_env)?;
    Ok(())
}

決定木 + 提出(2 provider の切り替え):

async fn execute_decision(
    state: &AppState,
    req: &RouteRequest,
    venue: &'static str,
    expected_out: U256,
    risk: FrontrunRisk,
) -> eyre::Result<RouteDecision> {
    if expected_out < req.min_out {
        return Ok(RouteDecision {
            decision:      "REJECT_TOO_RISKY",
            venue:         Some(venue),
            expected_out:  Some(expected_out),
            frontrun_risk: format!("{risk:?}"),
            tx_hash:       None,
            submission:    None,
            reason:        Some(format!("expected_out {} < min_out {}", expected_out, req.min_out)),
        });
    }

    // EIP-7702 sponsored tx を構築 (L5 持ち込み)
    let tx_request = build_sponsored_tx(
        &state.public_provider,
        &state.sponsor,
        req.user,
        &req.user_authorization,
        vec![/* 選んだ venue の router への swap call */],
    ).await?;

    let (submission, hash) = match risk {
        FrontrunRisk::High | FrontrunRisk::Medium => {
            // Flashbots Protect (or 任意の private RPC) 経由で提出
            let private = &state.private_provider;
            let h = private.send_transaction(tx_request).await?;
            ("flashbots-protect", *h.tx_hash())
        }
        FrontrunRisk::Low => {
            // public mempool で OK — bundler markup を節約
            let h = state.public_provider.send_transaction(tx_request).await?;
            ("public", *h.tx_hash())
        }
    };

    Ok(RouteDecision {
        decision:      if submission == "flashbots-protect" { "EXECUTE_PRIVATE" } else { "EXECUTE_PUBLIC" },
        venue:         Some(venue),
        expected_out:  Some(expected_out),
        frontrun_risk: format!("{risk:?}"),
        tx_hash:       Some(hash),
        submission:    Some(submission),
        reason:        None,
    })
}

統合(4 ステップ: quote → adversary scan → risk score → execute):

async fn route_handler(
    State(state): State<Arc<AppState>>,
    Json(req): Json<RouteRequest>,
) -> Result<Json<RouteDecision>, (axum::http::StatusCode, String)> {
    // 1. venue 横断 quote (レッスン7 持ち込み)
    let mut db = build_fork().await
        .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    let (best, venue) = best_quote(&mut db, &req).await
        .map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?;

    // 2. ~2 秒間 mempool で adversarial tx を監視 (レッスン1 反転)
    let pool_for_route = address_for_venue(venue, req.in_token, req.out_token);
    let adversaries = scan_for_adversaries(&state.public_provider, pool_for_route, req.in_token, Duration::from_secs(2)).await
        .unwrap_or_default();

    // 3. シミュレーションでリスクをスコア (レッスン1 + レッスン7 結合)
    let mut risk_db = build_fork().await
        .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    let risk = score_risk(&mut risk_db, &adversaries, best.amount_out, &req).await
        .unwrap_or(FrontrunRisk::Low);

    // 4. 一致する submission パスで実行 (レッスン4 + レッスン5 持ち込み)
    let decision = execute_decision(&state, &req, venue, best.amount_out, risk).await
        .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(decision))
}

新規コード合計 ~250 LOC(持ち込み込みで動く router 全体)。

失敗例(誤解)

「sandwich 検出は候補 tx を見つければ十分」は誤り — 「ユーザの output がどれだけ落ちるか」を fork で測らなければ防御するか決められない(候補ありで Low、候補なしで High もある)。「private mempool は常に安いから常に使う」も誤り — Flashbots は bundler markup を払う。Low risk は public で十分。risk-aware な切り替えが本筋。


ここまでで「decision layer + 2 provider 非対称」は着地した。ここから 6 ステップで組み立てる。コードは抜粋(実行時は補助コードが必要)。

🛑 予測。 L1 の MEV searcher は、この router の脅威モデルそのもの。searcher の行動と router が防ぐ対象を一文で。(答え: searcher は pending tx に sandwich を仕掛けて利益を得る。router はその検出器を 防御側に転用 し、敵が先に着地したらユーザの output がどれだけ落ちるかを fork で測り、Medium/High なら private(Flashbots Protect)に逃がす。同じ mempool 監視・同じ fork simulation、視点だけが反転する。)

ステップで組み立てる

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

# Cargo.toml
[package]
name = "frontrun-resistant-router"
version = "0.1.0"
edition = "2021"

[dependencies]
alloy = { version = "1.0", features = [
  "providers", "signer-local", "rpc-types", "network",
  "consensus", "eips", "sol-types"
] }
revm     = { version = "38", features = ["alloydb"] }
axum     = "0.7"
tokio    = { version = "1", features = ["full"] }
serde    = { version = "1", features = ["derive"] }
serde_json = "1"
futures  = "0.3"
eyre     = "0.6"

env: PUBLIC_RPC=...PRIVATE_RPC=https://rpc.flashbots.net/protectSPONSOR_KEY=0x...

Step 1-6

上の 6 ブロック: ① 型 sketch(RouteRequest/Decision、3 終端状態)→ ② best quote(L7 aggregate + pick_best を再利用)→ ③ adversary scan(L1 の pending tx 購読を防御側に)→ ④ Revm simulation で risk score(敵 tx を fork に適用 → re-quote → 差分 bps)→ ⑤ execute decision(min_out チェック → L5 sponsored tx → risk で public/private 切り替え)→ ⑥ route_handler で統合。

Step 7: 起動と疎通

$ PUBLIC_RPC=... PRIVATE_RPC=https://rpc.flashbots.net/protect \
  SPONSOR_KEY=0x... cargo run --release

# 別ターミナル
$ curl -s -X POST http://localhost:9000/route -d @sample-route-body.json
{ "decision": "EXECUTE_PRIVATE", "venue": "Uniswap V3", ... }

production gap: MEV-Share/OFA(private auction + リベート)/ user 単位スリッページ予算 / キャンセル + 返金 / マルチリージョン private RPC(Flashbots + Beaverbuild + Titan)/ 観測・閾値自動チューニング(drop の実測 vs 予測)。

答え合わせ(Test gate — E2E)

forked mainnet で benign / threat / slippage の 3 ケース:

// tests/router_e2e.rs
#[tokio::test]
async fn benign_path_uses_public_mempool() {
    // anvil --fork-url <RPC> --fork-block-number <PINNED>
    // mempool に敵対的 tx なし
    let svc = test_router().await;
    let resp = svc.route(stub_intent(ALICE)).await.unwrap();
    assert_eq!(resp.decision, Decision::ExecutePublic);
    let receipt = wait_for_receipt(resp.tx_hash).await;
    assert!(out_amount(&receipt) >= MIN_OUT);
}

#[tokio::test]
async fn detected_threat_routes_through_private_mempool() {
    let svc = test_router().await;
    seed_mempool_with_sandwich_setup(&svc).await;       // 敵対者をシミュレート
    let resp = svc.route(stub_intent(ALICE)).await.unwrap();
    assert_eq!(resp.decision, Decision::ExecutePrivate);
    assert!(resp.submission_url.contains("flashbots") || resp.submission_url.contains("protect"));
}

#[tokio::test]
async fn respects_min_out() {
    // スリッページシナリオを強制し、router が submit を拒否し 422 を返すことを assert
    let svc = test_router().await;
    let resp = svc.route(intent_with_unrealistic_min_out(ALICE)).await;
    assert!(matches!(resp, Err(RouteError::SlippageExceeded)));
}

3 つすべて forked-mainnet の anvil で pass まで未完了。

合格基準

  • 上記 3 E2E テスト(public パス end-to-end / 脅威下の private 切り替え / slippage 拒否)が green。
  • なぜ capstone が他のどのコンポーネントよりも simulation(L1)に依存するか(ユーザ損失と同じ単位で脅威を測らずに防御を決められない)を 1 文で言える。
  • 2 provider 非対称(同じ Alloy code に違うエンドポイント)の効果を即答できる。

Drill

  1. looks_like_swap_on の substring を UniV2/V3/Sushi router の正規 sol! デコードに置換(3 時間)。
  2. 各敵を独立に snapshot+rollback でスコア、worst-case を取る(2 時間)。
  3. POST /cancel { tx_hash } を追加(authorization 払い戻し)(3 時間)。
  4. Flashbots Protect + Beaverbuild に同時 submit、最初の着地を採用(1.5 時間)。
  5. 決定 + 実 drop をログ、historical data に bps 閾値を fit するオフラインスクリプト(5 時間)。

まとめ(3行)

  • decision layer が capstone の新規 — quote (L7) → adversary scan (L1 反転) → fork simulation で risk score → public/private 切り替え (L4+L5)。3 終端状態(EXECUTE_PRIVATE / PUBLIC / REJECT_TOO_RISKY)。
  • 脅威スコアは「敵が先に着地したら output bps drop」— 候補検出だけでは判定不能、fork で測る。0-10/11-50/51+ で Low/Medium/High。
  • 2 provider 非対称(public + Flashbots Protect、同じ Alloy code に違う URL)が sandwich を打ち破る。E2E test gate: benign/threat/slippage の 3 シナリオを forked mainnet で。

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

Revm シミュレーションを Production Provider で検証する(differential testing)。L1/L7/L8 すべてが Revm に依存しているので、Revm と mainnet 多数派 client(Geth/Erigon)の挙動がずれると全部に伝播する。debug_traceTransaction と一致を assert する。