FABRKNT
Building with the Stack — Real-World Rust EVM Apps
Application Patterns
Lesson 9 of 11·CONTENT60 min100 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Building with the Stack — Real-World Rust EVM Apps
Lesson role
CONTENT
Sequence
9 / 11

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):

  1. benign_path_uses_public_mempool — no adversarial tx in the mempool; the router decides PUBLIC, the swap lands, output ≥ min_out.
  2. detected_threat_routes_through_private_mempool — sandwich-setup tx in the mempool; the router decides PRIVATE and submits via Flashbots Protect.
  3. respects_min_out — slippage scenario; the router refuses to submit and returns SlippageExceeded.

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)

ComponentFromWhat's new here
Quote across DEXesLesson 7Reused as-is
Mempool watchingLesson 1 (the searcher's input!)Repurposed as defense — find candidate adversaries instead of opportunities
Revm fork simulationLesson 1Used here to score "would this adversary tx hurt my user?"
EIP-7702 sponsorshipLesson 5Lifted into the path so the user pays no gas
Wallet backend submission + replaceLesson 4Used for the public-mempool path
Private orderflow submissionNEWFlashbots Protect / MEV-Share integration
Decision logic (route + risk → submission path)NEWThe 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_out is from the aggregator (Lesson 7). Compared against min_out to decide if the user's slippage tolerance is met before we submit anything.
  • submission field 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(&quotes).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.
  • duration is 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(&quote_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

GapWhat real production routers do
MEV-Share / OFA integrationPrivately auction your orderflow to the highest searcher bid; share rebates back to users. (Flashbots Protect is the simple version.)
Slippage budget per userDon't quote past 5% slippage; tell the user to reduce amount_in.
Cancel + refund flowIf a private bundle doesn't land in 2 blocks, the EIP-7702 authorization is wasted. Need user-facing UI + refund logic.
Multi-region private RPCsSubmit to Flashbots, Beaverbuild, Titan, Rsync simultaneously; the first to land wins.
Per-user rate limitingA bad actor with API access can spam quotes (cheap) but each quote consumes a fork; cap.
ObservabilityLog 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)

  1. Real router ABI decoding. Replace the loose substring heuristic in looks_like_swap_on with proper sol! decoding for UniV2 / V3 / Sushi router calldata. Check whether the path includes (in_token, out_token) in either direction. (3 hours)
  2. Independent simulation. Score each adversary independently (snapshot + rollback the fork between each). Take the worst-case drop. (2 hours)
  3. 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)
  4. Multi-RPC private submission. Submit to two private endpoints (Flashbots Protect + Beaverbuild) simultaneously. Return the hash from whichever lands first. (1.5 hours)
  5. 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:

  1. Minimal MEV searcher (mempool → fork-sim → arb)
  2. Reorg-aware Postgres indexer (ExEx + reorg dispatch)
  3. Custom RPC endpoint (jsonrpsee + extend_rpc_modules)
  4. Wallet backend (signer pool + nonce mgr + replace-on-stuck)
  5. EIP-7702 sponsor (Type 4 tx + paymaster pattern)
  6. Foundry-style cheatcode (custom precompile + harness)
  7. Swap aggregator (Revm fork + cross-venue quotes)
  8. 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.