レッスン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/protectvs 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("e_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/protect、SPONSOR_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
looks_like_swap_onの substring を UniV2/V3/Sushi router の正規sol!デコードに置換(3 時間)。- 各敵を独立に snapshot+rollback でスコア、worst-case を取る(2 時間)。
POST /cancel { tx_hash }を追加(authorization 払い戻し)(3 時間)。- Flashbots Protect + Beaverbuild に同時 submit、最初の着地を採用(1.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 する。