Lab 8 — Capstone — Build a Frontrun-Resistant Order Router
Question
The Capstone combines everything: aggregator routing + MEV-aware private submission + sponsored execution + tested invariants. Build a production-shape order router in ~800 lines of Rust.
Principle (minimum model)
- Architecture. RPC frontend (accept user orders) + aggregator (best path via revm) + frontrun protection (private bundle via Flashbots) + sponsored execution (EIP-7702 + gas payment).
- Order intent. User submits
(token_in, token_out, amount_in, min_out, deadline)+ signature. Router enforces the signature and the slippage. - Aggregator integration. Reuse Lab 7's code; select the best path before bundle creation.
- Private submission. Sign the order tx → wrap in a Flashbots bundle → submit via
mev_sendBundle. Front-runner can't see the order. - Sponsored gas via EIP-7702. Reuse Lab 5's sponsor code; the router pays gas, charges the user in stable coin.
- Test gate. Forked anvil + mock Flashbots endpoint; assert the order executes at the best price; assert no front-run is possible (i.e., the order tx isn't in the public mempool before inclusion).
- Why this is the Capstone. Combines 4 prior labs; produces a working production-shape artifact; transferable directly to building real perp / DEX / payment routers.
Worked example + steps
Capstone — Build a Frontrun-Resistant Order Router
The capstone. Patterns from across the tier, integrated into one service. A user posts a swap intent (JSON). The router: quotes across DEXes (Lesson 7), watches the mempool for adversarial txs that would sandwich the swap (Lesson 1, inverted), simulates the threat in Revm to measure how much output the user would lose, sponsors gas via EIP-7702 (Lesson 5), and — when the threat score is high — submits through Flashbots Protect so the order never appears in the public mempool. When threat is low, public submission is fine and saves the bundler markup. One service, four earlier lessons stitched in (Lesson 1, Lesson 4, Lesson 5, Lesson 7), one new piece: the decision layer.
📌 Scope honesty. This capstone integrates patterns from Lesson 1 / Lesson 4 / Lesson 5 / Lesson 7 of this tier. The novel build is the frontrun-detection logic + the submission path that bypasses public mempool. We use Flashbots Protect as the private RPC; the same shape works with MEV-Share, Beaverbuild's private endpoint, or any other private orderflow auction.
Acceptance criteria
The lesson is complete when these tests pass (full code at the end in §Test gate):
benign_path_uses_public_mempool— no adversarial tx in the mempool; the router decides PUBLIC, the swap lands, output ≥min_out.detected_threat_routes_through_private_mempool— sandwich-setup tx in the mempool; the router decides PRIVATE and submits via Flashbots Protect.respects_min_out— slippage scenario; the router refuses to submit and returnsSlippageExceeded.
Test-first reading. The walkthrough below shows the decision layer (the only novel piece — the rest is Lesson 1/Lesson 4/Lesson 5/Lesson 7 stitched in) that these tests directly exercise.
What you'll build
$ 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"
}
flowchart TB
User["POST /route"] --> Router["Router service"]
Router -->|fork mainnet| Aggregator["Aggregator (Lesson 7)<br/>quotes + best venue"]
Router -->|scan pending txs| Detector["Frontrun detector<br/>(Lesson 1 mempool watch +<br/>Lesson 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 (Lesson 5)"]
PubPath --> Sponsor
Sponsor --> Wallet["Wallet backend (Lesson 4)<br/>nonce/gas/replace"]
Wallet --> Chain
What lessons feed in (and what's new)
| Component | From | What's new here |
|---|---|---|
| Quote across DEXes | Lesson 7 | Reused as-is |
| Mempool watching | Lesson 1 (the searcher's input!) | Repurposed as defense — find candidate adversaries instead of opportunities |
| Revm fork simulation | Lesson 1 | Used here to score "would this adversary tx hurt my user?" |
| EIP-7702 sponsorship | Lesson 5 | Lifted into the path so the user pays no gas |
| Wallet backend submission + replace | Lesson 4 | Used for the public-mempool path |
| Private orderflow submission | NEW | Flashbots Protect / MEV-Share integration |
| Decision logic (route + risk → submission path) | NEW | The capstone's contribution |
The novelty is the decision layer. Everything below it is patterns you've already built.
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"
Step 1: The decision struct (the architecture in one type)
The whole router is a function from RouteRequest to RouteDecision. Sketch the types first; the rest writes itself.
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-encoded
}
#[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, // serializable FrontrunRisk
pub tx_hash: Option<B256>,
pub submission: Option<&'static str>, // "flashbots-protect" | "public" | null
pub reason: Option<String>,
}
Walk:
- Three terminal states. Either we send privately, send publicly, or refuse. Refusal is a feature: if the slippage on the best public venue exceeds what we can offer privately, the right answer is to tell the user.
expected_outis from the aggregator (Lesson 7). Compared againstmin_outto decide if the user's slippage tolerance is met before we submit anything.submissionfield tells the user where their tx went. Useful for transparency — they can verify the Flashbots Protect endpoint received their bundle.
Step 2: Get the best quote (Lesson 7, reused)
// Pulled in directly from Lesson 7 — same code, no changes.
use crate::aggregator::{aggregate, pick_best, Quote};
async fn best_quote(
db: &mut ForkedDB,
req: &RouteRequest,
) -> eyre::Result<(Quote, &'static str)> {
let quotes = aggregate(db, req.in_token, req.out_token, req.amount_in).await?;
let best = pick_best("es).clone();
Ok((best.clone(), best.venue))
}
We don't even glance at the implementation — it's lesson 7. Importing your earlier lesson code is part of the capstone reading too. Keep things modular.
Step 3: Frontrun detection — the new bit
The MEV searcher in Lesson 1 watches mempool for opportunities. Inverted, the same scan finds threats against our user. Specifically: any pending tx that targets the same pool the router is about to use, in the same direction the router will move price.
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, // The pool our user is about to use
in_token: Address, // Direction matters: same direction = sandwich risk
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 candidates is enough to score
}
}
}
Ok(findings)
}
fn looks_like_swap_on(tx: &alloy::rpc::types::Transaction, pool: Address, in_token: Address) -> bool {
// Heuristic: tx targets a known router AND its calldata mentions our pool's tokens.
// Production routers would decode against router ABIs (UniV2 / V3 / Curve / Sushi)
// and check the path. We keep the heuristic for clarity.
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();
// Quick substring checks — cheap, false positives OK at this layer
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)
}
Walk:
subscribe_pending_transactions— the same Alloy subscription from Lesson 1. Verbatim re-use of the searcher's mempool input.- The heuristic is deliberately loose. Real production decodes router ABIs and reasons about the swap path. Loose heuristics over-flag (false positives = users routed privately when they didn't need to be), which is the safe failure mode.
durationis the look-ahead window. ~2 seconds is a sensible default — long enough to catch a slow human, short enough not to delay the user noticeably.
Step 4: Score the threat with Revm simulation
A list of suspicious txs isn't enough. We need to know: if these landed before our user, how much would the user's expected output drop?
async fn score_risk(
db: &mut ForkedDB, // fresh fork, will mutate
adversary_txs: &[alloy::rpc::types::Transaction],
quote_before: U256, // Output the user would get with no adversary
req: &RouteRequest,
) -> eyre::Result<FrontrunRisk> {
if adversary_txs.is_empty() { return Ok(FrontrunRisk::Low); }
// Apply each adversary tx to the fork.
// (In a real router, we'd snapshot+rollback per scenario. Here, sequential.)
for adv in adversary_txs {
apply_tx_to_fork(db, adv).await?;
}
// Re-quote in the post-adversary state.
let quote_after = aggregate(db, req.in_token, req.out_token, req.amount_in).await?;
let after_amount = pick_best("e_after).amount_out;
// What fraction of expected output would the user lose?
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 — noise
11..=50 => FrontrunRisk::Medium, // 0.10%–0.50% drop — worth defending
_ => FrontrunRisk::High, // >0.50% drop — definitely route 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(())
}
Walk:
- Quote-before vs. quote-after is the actual measure. Heuristic detection (Step 3) finds candidates; simulation tells us whether they hurt. Only the latter justifies routing privately.
- Sequential application is a simplification. A real implementation would simulate each adversary independently, take the worst case, and combine them. Drill 2.
- Thresholds in basis points are tunable per protocol. A USDC/USDT stable swap might tolerate 1bp; an exotic-pair swap might call 50bp acceptable.
Step 5: Submit
The decision tree:
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)),
});
}
// Build the EIP-7702 sponsored tx (Lesson 5, lifted directly)
let tx_request = build_sponsored_tx(
&state.public_provider,
&state.sponsor,
req.user,
&req.user_authorization,
vec![/* the swap call against the chosen venue's router */],
).await?;
let (submission, hash) = match risk {
FrontrunRisk::High | FrontrunRisk::Medium => {
// Submit through Flashbots Protect (or any private RPC)
let private = &state.private_provider;
let h = private.send_transaction(tx_request).await?;
("flashbots-protect", *h.tx_hash())
}
FrontrunRisk::Low => {
// Public mempool is fine — save the 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,
})
}
The two providers are the load-bearing piece. public_provider connects to a normal RPC (Infura, your own Reth); private_provider connects to https://rpc.flashbots.net/protect. Same Alloy code, different endpoint — that's the asymmetry that defeats sandwich attacks.
Step 6: Wire it together
async fn route_handler(
State(state): State<Arc<AppState>>,
Json(req): Json<RouteRequest>,
) -> Result<Json<RouteDecision>, (axum::http::StatusCode, String)> {
// 1. Quote across venues (Lesson 7 lifted)
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. Watch mempool for ~2s for adversarial txs (Lesson 1 inverted)
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. Score risk via simulation (Lesson 1 + Lesson 7 combined)
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. Execute on the matching submission path (Lesson 4 + Lesson 5 lifted)
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))
}
Total new code in this lesson: ~250 LOC. Total router code: this 250 + the lessons it lifts — a working frontrun-resistant order router that fits in one repo.
What's missing for production
| Gap | What real production routers do |
|---|---|
| MEV-Share / OFA integration | Privately auction your orderflow to the highest searcher bid; share rebates back to users. (Flashbots Protect is the simple version.) |
| Slippage budget per user | Don't quote past 5% slippage; tell the user to reduce amount_in. |
| Cancel + refund flow | If a private bundle doesn't land in 2 blocks, the EIP-7702 authorization is wasted. Need user-facing UI + refund logic. |
| Multi-region private RPCs | Submit to Flashbots, Beaverbuild, Titan, Rsync simultaneously; the first to land wins. |
| Per-user rate limiting | A bad actor with API access can spam quotes (cheap) but each quote consumes a fork; cap. |
| Observability | Log every (venue, risk, submission) decision. After 1000 routes, evaluate: when we routed private, was the simulated drop the actual on-chain outcome? Tune thresholds with real data. |
The architecture you wrote — quote → detect adversaries → score with sim → decide private vs public → submit via the right path — is the spine of every defensive routing service. CowSwap, MEV-Share consumers, retail wallet backends — they all do variations of this. You've now built one.
Drill (the longest in the curriculum, on purpose)
- Real router ABI decoding. Replace the loose substring heuristic in
looks_like_swap_onwith propersol!decoding for UniV2 / V3 / Sushi router calldata. Check whether the path includes(in_token, out_token)in either direction. (3 hours) - Independent simulation. Score each adversary independently (snapshot + rollback the fork between each). Take the worst-case drop. (2 hours)
- Cancel flow. Add
POST /cancel { tx_hash }that refunds the user's authorization (i.e., signs a no-op tx at the same nonce to invalidate the original). Wire to UI. (3 hours) - Multi-RPC private submission. Submit to two private endpoints (Flashbots Protect + Beaverbuild) simultaneously. Return the hash from whichever lands first. (1.5 hours)
- Threshold autotuning. Log every router decision + the actual on-chain outcome (was the drop bigger or smaller than predicted?). Build a small offline script that fits the bps thresholds to your historical data. (5 hours)
After drill 5 you have a tuned, observably-correct frontrun-resistant router. This is what you'd ship to production for a wallet team that takes user trust seriously.
Test gate
Per Test gate — every app in this tier ships with passing tests, the capstone's gate is end-to-end on a forked mainnet: a real swap intent goes in, the router decides PUBLIC vs PRIVATE based on a simulated threat, the right submission path is taken, and the user receives at least min_out. The decision layer is the novel piece — and the only thing the test can't lift from earlier lessons.
// tests/router_e2e.rs
#[tokio::test]
async fn benign_path_uses_public_mempool() {
// anvil --fork-url <RPC> --fork-block-number <PINNED>
// No adversarial tx in mempool
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; // simulate adversary
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() {
// Force a slippage scenario; assert the router refuses to submit, returns 422
let svc = test_router().await;
let resp = svc.route(intent_with_unrealistic_min_out(ALICE)).await;
assert!(matches!(resp, Err(RouteError::SlippageExceeded)));
}
The capstone is not complete until all three pass on a forked-mainnet anvil. The first proves the public path works end-to-end; the second proves the decision layer flips paths under threat; the third proves you don't lose user funds on bad slippage.
Capstone complete — two lessons remain
Recap of what you've built up to this capstone:
- Minimal MEV searcher (mempool → fork-sim → arb)
- Reorg-aware Postgres indexer (ExEx + reorg dispatch)
- Custom RPC endpoint (jsonrpsee + extend_rpc_modules)
- Wallet backend (signer pool + nonce mgr + replace-on-stuck)
- EIP-7702 sponsor (Type 4 tx + paymaster pattern)
- Foundry-style cheatcode (custom precompile + harness)
- Swap aggregator (Revm fork + cross-venue quotes)
- Frontrun-resistant order router (this lesson) — Lesson 1 / Lesson 4 / Lesson 5 / Lesson 7 integrated
Still ahead: Lesson 9 (validate-revm cross-client harness) and Lesson 10 (HTTP 402 / MPP machine-payments endpoint). They sit outside the swap-router arc but ship in the same tier.
Pick the one that interests your target employer / project most. Open the production gaps. Ship it as a small public repo. That's the artifact you bring to a Paradigm / Tempo / serious-team conversation.
🧭 Where you are now in the stack: you've shipped the networking + compiler + authentication layer integration — multi-source input → simulation → routing decision → submission channel, with benign / threat / slippage E2E tests as the gate. Same shape as HFT order routers and CDN edge routers, applied to EVM transaction routing under MEV. Next lesson moves to the VM layer's correctness verification: differential testing Revm against production providers.
Summary (3 lines)
- Capstone = aggregator (Lab 7) + MEV-resistant private submission (Lab 1) + sponsored execution (Lab 5) + tested invariants. ~800 lines of Rust.
- User signs intent → router routes via aggregator → private Flashbots bundle → sponsored gas. Invariants tested via forked anvil + mock Flashbots.
- Production-shape artifact, transferable to real perp / DEX / payment-rail routers. Next: validate revm against production provider.