レッスン1 — 最小限の MEV Searcher を Rust で作る
問い
mempool 監視・swap デコード・fork シミュレーション・bundle 構築は、どの searcher でも必要になる。問いは「1 回書けるか」ではなく「次の strategy 追加時に再利用できるか」だ。本番の searcher はどう構成するのか?(読む対象は Paradigm の artemis。)
原理(最小モデル)
- searcher = イベント処理パイプライン。 外部シグナルが入り、MEV ロジックが何をするか決め、アクションが出ていく。artemis はこれを 3 trait + engine に分割する:
Collector<E>(外部→イベント)/Strategy<E,A>(イベント→0 個以上のアクション、MEV の脳、opportunity ごとに書く唯一のファイル)/Executor<A>(アクション→副作用)。 - broadcast channel で全 strategy が全イベント・全 executor が全アクションを受ける。 不要なものは strategy で
vec![]、executor でExecutorMapが捨てる。新 strategy の ship =impl Strategyを 1 つ書いてengine.add_strategy(...)だけ(collector/executor は再利用)。 - Executor を Strategy から分離する理由。 同じ opportunity でも提出先は複数ある(public mempool / Flashbots / MEV-Share)。
Actionを出して executor を差し替えられれば耐障害性が上がる。提出処理を strategy に直書きすると、提出先障害で全体が止まる。 - strategy 間の調整ロジックは存在しない。 engine は調整しない。必要なら collector を組み合わせ、単一 strategy 内で調整する。
具体例
中核の抽象は 1 ファイル ~120 行(crates/artemis-core/src/types.rs):
#[async_trait]
pub trait Collector<E>: Send + Sync {
async fn get_event_stream(&self) -> Result<CollectorStream<'_, E>>;
}
#[async_trait]
pub trait Strategy<E, A>: Send + Sync {
async fn sync_state(&mut self) -> Result<()>;
async fn process_event(&mut self, event: E) -> Vec<A>;
}
#[async_trait]
pub trait Executor<A>: Send + Sync {
async fn execute(&self, action: A) -> Result<()>;
}
engine がこの 3 系統を Tokio task として起動し、2 本の broadcast channel で接続する:
collectors -- events --> [event channel] -- events --> strategies
|
actions
v
executors <-- actions <-- [action channel] <-- actions <--+
mempool_collector.rs の全体(MEV ロジックを含まない — 型付きストリーム供給に限定):
#[async_trait]
impl<M> Collector<Transaction> for MempoolCollector<M>
where
M: Middleware,
M::Provider: PubsubClient,
{
async fn get_event_stream(&self) -> Result<CollectorStream<'_, Transaction>> {
let stream = self.provider.subscribe_pending_txs().await?;
let stream = stream.transactions_unordered(256);
let stream = stream.filter_map(|res| async move { res.ok() });
Ok(Box::pin(stream))
}
}
同梱唯一の strategy opensea-sudo-arb の process_event が MEV 判断のすべて:
async fn process_event(&mut self, event: Event) -> Vec<Action> {
match event {
Event::OpenseaOrder(order) => self
.process_order_event(*order).await
.map_or(vec![], |a| vec![a]),
Event::NewBlock(block) => match self.process_new_block_event(block).await {
Ok(_) => vec![],
Err(e) => { panic!("Strategy is out of sync {}", e); }
},
}
}
process_order_event は新規出品に Sudoswap の有利価格を探し、成立すれば Action::SubmitTx を返す。process_new_block_event はブロックログから Sudo プール状態を更新する(アクションを出さず内部 state を保守)。アービ本体は別 Solidity(SudoOpenseaArb.sol)— strategy は機会検出と calldata 構築、原子的実行はコントラクト側。
失敗例(誤解)
「1 本の main.rs で書けばいい」は誤り — 一発書きは本番構造を隠し、次の strategy 追加で再利用できない。artemis(framework)は再利用性に答える: MEV ロジックは自作し、オーケストレーションは借りる。turnkey bot の subway が「何を走らせるか」を示すのに対し、artemis は「どう構成するか」を示す(subway=fork して書き換え、artemis=trait を実装)。世にない strategy を ship したいなら後者。
ここまでで「searcher = collector → strategy → executor のパイプライン」は着地した。ここから artemis を読み、自分の bot に落とす。コードは抜粋(実行時は repo 全体を参照)。
🛑 予測。 Executor と Strategy を統合した場合に壊れる点を一文で。(答え: 提出を strategy に直書きすると、提出先(Flashbots relay 等)の障害で strategy 全体が止まる。分離していれば同じ
Actionを別 executor に流せ、提出経路を MEV ロジックに触れず入れ替えられる — trait の分割は理論的綺麗さでなく耐障害性のため。)
ステップで組み立てる
Step 1-5: artemis を読む
- trait を開く(
types.rs、上の 3 trait)。同ファイルのCollectorMap/ExecutorMapを 30 秒読む — 新しい Collector を書かずに型変換で解ける問題 を解いてくれる。 - engine を読む(
engine.rs、上の broadcast 図)。要点: 新 strategy の ship はimpl Strategy1 つ +add_strategyだけ。 - 実物の collector/executor を読む(
collectors/: mempool / block / mevshare / opensea / log、executors/: mempool / flashbots / mev_share)。各 ~50-100 行。 - 実物の strategy を読む(
opensea-sudo-arb、Event = { NewBlock, OpenseaOrder }の 2 入力源)。sync_stateがなぜ「deploy 済み全 Sudo プール」を列挙するか考える。 - 分離の効果を確認(private mempool collector を足すなら
Collectorを実装して engine 登録、strategy.rsの変更は不要)。
Step 6: 読みから出荷へ — 自分の bot
2-hop Uniswap アービ searcher の最小手順:
- 再利用: pending swap に
MempoolCollector、新 head にBlockCollector、FlashbotsExecutor。全部そのまま。 - 書く:
Event = { NewBlock, PendingTx }/Action = { SubmitBundle }を持つUniArbStrategyを 1 つ。PendingTx分岐: swap をデコード → Revm で fork シミュレート → クロスプール spread を検出 → bundle 構築。NewBlock分岐: reserve cache をリフレッシュ、古い opportunity を捨てる。 - 配線:
add_collector×2、add_strategy(UniArbStrategy::new(...))、add_executor、run().await。
答え合わせ(Test gate)
最低ラインは 2 テスト:
// tests/integration.rs
const PINNED_BLOCK: u64 = 18_500_000; // 既知の Uniswap V3 / Curve arb があったブロック
const FORK_RPC: &str = "https://eth.merkle.io";
#[tokio::test]
async fn finds_known_arb_at_pinned_block() {
let provider = forked_provider_at(FORK_RPC, PINNED_BLOCK).await;
let strategy = MyArbStrategy::new(provider);
let event = Event::NewBlock { number: PINNED_BLOCK };
let actions = strategy.process_event(event).await;
let arb = actions.iter().find(|a| matches!(a, Action::SubmitBundle { .. }));
assert!(arb.is_some(), "既知の arb を検出するはず");
assert_pnl_positive(arb.unwrap());
}
#[tokio::test]
async fn retracts_action_on_reorg() {
// 合成ブロック N を提出、N を reorg、action queue が空になることを assert
}
finds_known_arb_at_pinned_block(forked-state で実機会を再現し正の期待 P&L を assert)+ retracts_action_on_reorg(合成 ChainReorged で reorg 依存の pending Action を取り下げ — reorg 無視は典型的本番障害)。両方 green まで未完了。mainnet で cargo run できても cargo test が通らなければ deliverable でない。
合格基準
- 上記 2 テストが green。
- artemis の 3 trait の名前と各入出力を言える。
- 提出を public mempool→Flashbots に切り替えるとき Strategy 実装の何を変えるか即答できる(答え: 何も変えない、登録する Executor を切り替える)。
Drill
- 実在の MEV opportunity を 1 つ選び、必要な
Event/Actionと再利用する collector/executor を列挙(30 分)。 - pending tx 受信から
SubmitTxToMempool実行までの.awaitポイントを列挙(45 分)。 collectors/から 1 つ選びethers-rs版を Alloy 1.x へ置換(trait シグネチャは維持)(2 時間)。engine.rsのrunを再読し、collector のイベントが strategy に届く経路(channel 型と receiver)を答える(30 分)。Strategy<Event, Action>を実装した最小モジュールをMempoolCollector+ no-op executor に配線してcargo run(3 時間)。
📺 関連動画
vCCYFSAdCFo | Understanding MEV — Georgios Konstantopoulos, Dan Robinson, Hasu (Paradigm)
まとめ(3行)
- searcher = イベント処理パイプライン。artemis が collector(外部→イベント)/ strategy(イベント→アクション、MEV の脳)/ executor(アクション→副作用)の 3 trait + engine に分割する。
- 新 strategy の ship は
impl Strategy1 つ +add_strategyだけ。collector/executor は再利用、strategy 間調整は engine になく単一 strategy 内で。Executor 分離が提出経路の差し替えと耐障害性を生む。 - Test gate: forked-state での既知 arb 再現(正の P&L)+ reorg 整合性。
cargo testgreen まで未完了。次は DB 層の reorg-aware インデクサ。
次のレッスン(レッスン2)
ExEx 駆動の reorg-aware な Postgres インデクサ(Tempo の tidx を読む)。Notification::ChainCommitted/ChainReverted を fixture replay で流し、導出状態が golden reference と一致することを assert する。