レッスン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_inがreserve0かreserve1かは都度判定。
データ経路を 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("es);
println!("Quotes ({} {} -> {}):", args.amount_in, args.in_token, args.out_token);
for q in "es {
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_fork(AlloyDB + CacheDB、最新ブロック)→ ② read_v2_pool(token0 判定 + getReserves の call_view)→ ③ quote_v2(constant product + fee、U256 のみ)→ ④ quote_v3(IQuoterV2 を 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
- Curve 3pool を追加(stableswap
get_dy)(1.5 時間)。 - ガス会計(推定ガスを out-token で引く)(2 時間)。
- 2-hop 探索(A → WETH → B、直接と比較)(3 時間)。
- Split routing(top 2 venue 50/50、合計 > 単独 をチェック)(2 時間)。
- 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 報告までを観察する。