Lab 1 — Build a Minimal MEV Searcher in Rust
Question
An MEV searcher monitors the mempool, simulates profitable bundles, and submits them to a builder. Build the minimum thing that does this end-to-end. Mempool subscription + revm simulation + bundle submission, ~400 lines of Rust.
Principle (minimum model)
- Three components. Mempool watcher (Alloy
Provider::subscribe_pending_transactions) + revm simulator (fork mainnet at the latest block, simulate the candidate bundle) + bundle submitter (signed JSON-RPC to a builder endpoint). - Mempool watcher.
provider.subscribe_pending_transactions().await?.into_stream()yields tx hashes. For each,provider.get_transaction(hash)fetches the full tx. - Revm simulator. Fork at the latest block (
AnvilForkBackendorAlloyDB); run the candidate bundle (your tx + the user's tx) through revm; compute profit by diffing your balance before and after. - Bundle submitter.
eth_sendBundleJSON-RPC to a builder like Flashbots (or a local builder for testing). The bundle is a list of signed txs with a target block number. - Test gate. Forked anvil at a block where a known profitable swap exists; simulate; assert profit > minimum threshold. Deterministic.
- Why this is the canonical lab. MEV searching is the most common pattern for "watch chain, simulate, submit" — it transfers directly to liquidation bots, oracle keepers, and arbitrage bots.
Worked example + steps
Build a Minimal MEV Searcher in Rust
A greenfield "you'd structure your bot like this" walkthrough lies about the shape of production. Real searchers don't start from main.rs. They start from a framework — and the one to read is Paradigm's artemis, the Rust MEV-bot framework Paradigm open-sourced and continues to dogfood.
Open the repo. Read it. This lesson walks you through it.
📌 Why this is the right starting point. Watching the public mempool, decoding a swap, fork-simulating in Revm, building a Flashbots bundle — every searcher does these things. The interesting question isn't "can you write them once?" It's "how do you organize them so the next strategy you ship isn't a rewrite?" That's exactly the question artemis answers. The MEV logic is yours; the orchestration is borrowed.
Acceptance criteria
The lesson is complete when these tests pass (full code at the end in §Test gate):
finds_known_arb_at_pinned_block— at a pinned mainnet block where a known arb existed, your strategy emits anActionwith positive expected P&L.retracts_action_on_reorg— on a syntheticChainReorgednotification, the strategy retracts any pendingActionthat depended on the reorged block.
Test-first reading. Skim these now. The walkthrough below explains the types (Strategy<E, A>, Action::SubmitBundle) and patterns (forked Revm, mempool collectors) you'll need to write tests against.
The artemis architecture, in one sentence
A searcher is an event-processing pipeline: external signals come in, MEV logic decides what to do, actions go out. Artemis splits that pipeline into three traits and an engine that wires them together.
| Component | Trait | What it does |
|---|---|---|
| Collector | Collector<E> | External world → internal event E. Pending tx, new block, marketplace order, MEV-Share hint — each gets its own collector. |
| Strategy | Strategy<E, A> | Event E → zero or more actions A. This is the MEV brain. The only file you actually write per opportunity. |
| Executor | Executor<A> | Action A → side effect. Flashbots bundle submit, public-mempool send, off-chain order post. |
Step 1: Open the traits
The whole core abstraction is one file, ~120 lines: crates/artemis-core/src/types.rs. Open it now.
#[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<()>;
}
That's the entire contract. Three methods. Two generic params (E for events, A for actions). Everything else in the framework — engine, channels, mappers — is plumbing around these three.
🔍 Find in repo. In that same file, find
CollectorMapandExecutorMap. Read 30 seconds. In your own words: what doesCollectorMapsolve that you'd otherwise have to solve by writing a new Collector?
Step 2: How events flow — read the engine
Open crates/artemis-core/src/engine.rs. The Engine<E, A> struct holds three Vec<Box<dyn …>> — one each for collectors, strategies, executors. The run method spawns one Tokio task per component and connects them with two tokio::sync::broadcast channels:
collectors -- events --> [event channel] -- events --> strategies
|
actions
v
executors <-- actions <-- [action channel] <-- actions <--+
Broadcast — so every strategy sees every event; every executor sees every action. Strategies that don't care for a given event return vec![]. Executors that don't care for a given action filter via ExecutorMap.
The whole point: you ship a new strategy by writing one impl Strategy and calling engine.add_strategy(...). Collectors and executors are reused.
Step 3: Find the real Collectors and Executors
Don't trust the abstraction until you've seen concrete implementors. Open these and skim:
crates/artemis-core/src/collectors/:mempool_collector.rs(subscribe to pending txs),block_collector.rs(new heads),mevshare_collector.rs(private hint stream),opensea_order_collector.rs(NFT marketplace),log_collector.rs(filtered log subscription).crates/artemis-core/src/executors/:mempool_executor.rs(public submit),flashbots_executor.rs(bundle to Flashbots relay),mev_share_executor.rs(MEV-Share submission).
Each file is small — ~50-100 lines. Open mempool_collector.rs specifically:
#[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))
}
}
That's the entire mempool collector. The subscribe_pending_txs().transactions_unordered(256) pattern materializes tx bodies in parallel — concurrency 256 — instead of one-by-one. Note what's not in this file: no MEV logic, no decoding, no strategy concerns. A collector's job is to push a typed stream upstream and shut up.
Step 4: A real Strategy — opensea-sudo-arb
Now the payoff. Open crates/strategies/opensea-sudo-arb/. This is the one strategy shipped in the artemis tree — atomic cross-market NFT arb between OpenSea (Seaport) and Sudoswap (LSSVM pools).
Two files matter:
src/types.rs— defines this strategy'sEventandActionenums.src/strategy.rs— theimpl Strategy<Event, Action> for OpenseaSudoArb.
The Event is just:
pub enum Event {
NewBlock(NewBlock),
OpenseaOrder(Box<OpenseaOrder>),
}
Two collectors feed this strategy: a block collector and an OpenSea order collector. That's it.
The process_event body is the whole MEV decision:
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: a new NFT listing arrived on OpenSea — is there a Sudoswap pool willing to pay more than the listing price for that NFT? If yes, return an Action::SubmitTx for an atomic arb contract that buys on OpenSea and sells into the Sudo pool in one tx.
process_new_block_event: scan the new block's logs for Sudo pool state changes (buys/sells/spot-price updates) and refresh the internal pool_bids map. No actions; just state hygiene.
🔍 Find in repo. In the same
strategy.rsfile, findsync_state. Read it. Predict: why does it need to enumerate every Sudo pool ever deployed before the strategy can start? What breaks if you skip it?
The arb contract itself is a separate piece of Solidity (contracts/src/SudoOpenseaArb.sol) — the strategy's job is to detect the opportunity and shape the calldata; the on-chain contract does the atomic buy-sell.
Step 5: Now answer the Step 1 predict
Why is Executor separate from Strategy? Because the same MEV opportunity can be submitted three different ways depending on chain conditions: public mempool (cheap, exposed), Flashbots bundle (private, atomic), MEV-Share (semi-private, partial-reveal). A strategy that emits a typed Action and lets the engine route to whichever executor is healthy today is resilient. A strategy that hardcodes flashbots_relay.send_bundle(...) dies the day Flashbots is degraded.
The trait split isn't theoretical cleanliness — it's about swapping submission paths without touching MEV logic.
(Answer: implement Collector<OpenseaOrder> — or Collector<Event> directly via CollectorMap — and register it on the engine. Zero changes to strategy.rs. That's the architecture working.)
Step 6: From reading to shipping — your own bot
If you wanted to ship a 2-hop Uniswap arb searcher (the classic) on Tempo or MegaETH using artemis:
- Reuse:
MempoolCollectorfor pending swaps,BlockCollectorfor new heads,FlashbotsExecutor(or your L1's equivalent bundle endpoint). All as-is. - Write: a single
UniArbStrategywithEvent = { NewBlock, PendingTx }andAction = { SubmitBundle }. Insideprocess_eventonPendingTx: decode the swap, fork-simulate in Revm, detect the cross-pool spread, build the bundle. Insideprocess_eventonNewBlock: refresh reserves cache, drop stale opportunities. - Plumb:
engine.add_collector(...)× 2,engine.add_strategy(UniArbStrategy::new(...)),engine.add_executor(...),engine.run().await.
The MEV-logic surface is one file. Everything else is borrowed.
The honest comparison — artemis vs subway
Artemis acknowledges its lineage. Read the README's Acknowledgements: subway, subway-rs, rusty-sando. These are fully-baked MEV bots — subway specifically is a TypeScript sandwich bot, shipped end to end, opinionated about what MEV you're doing.
Artemis is the opposite: a framework with one example strategy. Subway tells you what to run. Artemis tells you how to organize whatever you choose to run.
| subway | artemis | |
|---|---|---|
| Shape | Turnkey sandwich bot | Framework + one example strategy |
| Language | TypeScript | Rust |
| Customization | Fork and rewrite | Implement traits |
| What you supply | API keys, capital | MEV logic, capital |
| Right choice when | You want sandwiching, today, as a learning artifact | You're shipping a strategy that doesn't exist yet |
If you're reading this lesson, you're shipping the latter. Artemis is your scaffolding.
Recall checklist
Before moving on, confirm you can answer each of these without scrolling back:
- Name the three artemis traits and what each consumes/produces.
- Where in the codebase does cross-strategy coordination live? (Trap question — see Step 2.)
- Why is the Action enum strategy-local rather than framework-wide?
- In
opensea-sudo-arb, what doesprocess_new_block_eventdo thatprocess_order_eventdoesn't? - To swap submission from public mempool to Flashbots, what changes in the Strategy implementation? (Answer: nothing — you change the registered Executor.)
If you stumbled on 2 or 4, re-read Steps 2 and 4 before the next lesson.
Drill
- Map out a new strategy. Pick a real MEV opportunity (Uniswap V3 JIT liquidity, Curve cross-pool arb, perp-funding-rate arb). On paper: what
Eventvariants does it need? WhatActionvariants? Which existing collectors/executors can you reuse? (30 min) - Trace one event end-to-end. From a mainnet pending tx hitting
MempoolCollector::get_event_streamto a hypotheticalSubmitTxToMempoolaction being executed, list every.awaitpoint in the artemis codebase. There are fewer than you think. (45 min) - Backport a collector. Pick one collector in
crates/artemis-core/src/collectors/and translate it fromethers-rsto Alloy 1.x. The trait signature doesn't change; only the underlying provider does. (2 hours) - Read the run loop. Open
engine.rsagain. Therunmethod spawnscollectors.len() + strategies.len() + executors.len()tasks. Walk the message-flow: how does an event from collectorAreach strategyB'sprocess_event? Name the channel type and the receiver. (30 min) - Build a stub strategy on top. Clone artemis, add a new module under
crates/strategies/that implementsStrategy<Event, Action>whereprocess_eventjust logs the event. Wire it into a minimal binary that runsMempoolCollector+ your stub + a no-op executor.cargo runit against a free WS endpoint. (3 hours)
Finish drill 5 and you have a working artemis-based searcher skeleton ready for whatever MEV logic you want to ship into it.
Test gate
Per Test gate — every app in this tier ships with passing tests, this lesson's minimum gate is two tests in your stub strategy from drill 5:
- Forked-state opportunity replay. Pin a block where a known arb existed (Etherscan + EigenPhi will surface candidates). Build your strategy against an
AlloyDB-backed Revm at that block. Assert the strategy emits anActionwith positive expected P&L. - Reorg integrity. Feed a synthetic
ChainReorgednotification through your collector and assert the strategy retracts any pendingActionthat depended on the reorged block. (Ignoring reorgs is the most common production failure mode in MEV systems — you submit a bundle, the chain reorgs, your accounting still claims you won.)
Sketch:
// tests/integration.rs
const PINNED_BLOCK: u64 = 18_500_000; // a block with a known 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(), "should have found the known arb");
assert_pnl_positive(arb.unwrap());
}
#[tokio::test]
async fn retracts_action_on_reorg() {
// submit synthetic block N, then reorg over N; assert the action queue is empty
}
The lesson is not complete until both tests are green. If you can cargo run your searcher against mainnet but cannot cargo test it, you have a demo, not a deliverable.
📺 Further watching
vCCYFSAdCFo | Understanding MEV — Georgios Konstantopoulos, Dan Robinson, Hasu (Paradigm)
What comes next in this tier
The full Building with the Stack tier ships ten lessons end to end. From here:
- Lesson 2 — Reorg-aware Postgres indexer (ExEx-driven, in-process)
- Lesson 3 — Custom RPC endpoint via
extend_rpc_modules - Lesson 4 — Wallet backend (signer pool + nonce manager + replace-on-stuck)
- Lesson 5 — EIP-7702 sponsor service (Type 4 tx + paymaster pattern)
- Lesson 6 — Foundry-style cheatcode (custom precompile + minimal harness)
- Lesson 7 — Swap aggregator (Revm fork + cross-venue quotes)
- Lesson 8 (Capstone) — Frontrun-resistant order router that integrates everything above
- Lesson 9 — Validate-revm cross-client harness (compare your sim against a production provider)
- Lesson 10 — HTTP 402 / MPP machine-payments endpoint (Tempo's payments stack)
Each is a self-contained ~200–300 line build with the same predict / find-in-repo / anti-fluency style. Pick the one that maps to your target use case.
🧭 Where you are now in the stack: you've shipped a networking × concurrency application — event-driven pipeline (artemis collectors → strategies → executors) applied to MEV, with the test gate (forked-state arb replay + reorg integrity) locking in correctness. Same shape Kafka Streams and HFT order handlers ship. Next lesson moves to the database layer: a reorg-aware indexer driven by ExEx.
Summary (3 lines)
- Minimal MEV searcher = mempool watcher + revm simulator + bundle submitter, ~400 lines of Rust. End-to-end loop in three Alloy + revm calls.
- Test gate = forked anvil at a known-profitable block; assert profit > threshold. Deterministic.
- "Watch chain, simulate, submit" is the canonical pattern; transfers to liquidation bots / oracle keepers / arbitrage bots. Next: ExEx indexer.