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

レッスン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-arbprocess_eventMEV 判断のすべて:

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 を読む

  1. trait を開くtypes.rs、上の 3 trait)。同ファイルの CollectorMap/ExecutorMap を 30 秒読む — 新しい Collector を書かずに型変換で解ける問題 を解いてくれる。
  2. engine を読むengine.rs、上の broadcast 図)。要点: 新 strategy の ship は impl Strategy 1 つ + add_strategy だけ。
  3. 実物の collector/executor を読むcollectors/: mempool / block / mevshare / opensea / log、executors/: mempool / flashbots / mev_share)。各 ~50-100 行。
  4. 実物の strategy を読むopensea-sudo-arbEvent = { NewBlock, OpenseaOrder } の 2 入力源)。sync_state がなぜ「deploy 済み全 Sudo プール」を列挙するか考える。
  5. 分離の効果を確認(private mempool collector を足すなら Collector を実装して engine 登録、strategy.rs の変更は不要)。

Step 6: 読みから出荷へ — 自分の bot

2-hop Uniswap アービ searcher の最小手順:

  1. 再利用: pending swap に MempoolCollector、新 head に BlockCollectorFlashbotsExecutor。全部そのまま。
  2. 書く: Event = { NewBlock, PendingTx } / Action = { SubmitBundle } を持つ UniArbStrategy を 1 つ。PendingTx 分岐: swap をデコード → Revm で fork シミュレート → クロスプール spread を検出 → bundle 構築。NewBlock 分岐: reserve cache をリフレッシュ、古い opportunity を捨てる。
  3. 配線: add_collector ×2、add_strategy(UniArbStrategy::new(...))add_executorrun().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

  1. 実在の MEV opportunity を 1 つ選び、必要な Event/Action と再利用する collector/executor を列挙(30 分)。
  2. pending tx 受信から SubmitTxToMempool 実行までの .await ポイントを列挙(45 分)。
  3. collectors/ から 1 つ選び ethers-rs 版を Alloy 1.x へ置換(trait シグネチャは維持)(2 時間)。
  4. engine.rsrun を再読し、collector のイベントが strategy に届く経路(channel 型と receiver)を答える(30 分)。
  5. 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 Strategy 1 つ + add_strategy だけ。collector/executor は再利用、strategy 間調整は engine になく単一 strategy 内で。Executor 分離が提出経路の差し替えと耐障害性を生む。
  • Test gate: forked-state での既知 arb 再現(正の P&L)+ reorg 整合性。cargo test green まで未完了。次は DB 層の reorg-aware インデクサ。

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

ExEx 駆動の reorg-aware な Postgres インデクサ(Tempo の tidx を読む)。Notification::ChainCommitted/ChainReverted を fixture replay で流し、導出状態が golden reference と一致することを assert する。