FABRKNT
Building with the Stack — Real-World Rust EVM Apps
Application Patterns
Lesson 8 of 11·CONTENT45 min80 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
8 / 11

Lab 7 — Build a Swap Aggregator: DEX State, Forked, in Rust

Question

A swap aggregator reads DEX pool state, simulates trades across multiple DEXes, and routes to the best price. Build the minimum aggregator — Uniswap V2 + V3 pool reads + revm simulation + best-path selection, ~500 lines of Rust.

Principle (minimum model)

  • Three components. Pool-state fetcher (read reserves / sqrt-price / ticks from each DEX) + simulator (revm fork, try each path) + best-path selector (pick max output).
  • Pool state via Alloy contract bindings. sol! { contract IUniswapV2Pair { ... } } generates type-safe bindings; contract.getReserves().call().await? reads state.
  • Multicall for batched pool reads. Read 20+ pools in one RPC call via Multicall3. Reduces latency from N round trips to 1.
  • Path simulation. For each candidate path (e.g. USDC → ETH → DAI vs USDC → DAI direct), simulate in revm; compute output amount. Pick max.
  • Slippage handling. Add a min_out parameter; reject paths whose output is below threshold.
  • MEV-aware variant. Submit the winning path as a private bundle (via Flashbots) to avoid front-running.
  • Test gate. Forked anvil at a known-state block; simulate a swap; assert the output matches the expected best path.

Worked example + steps

Build a Swap Aggregator: DEX State, Forked, in Rust

A user wants to swap 10,000 USDC for ETH. Uniswap V2 will give them 2.948 WETH. Sushi gives 2.946. Uniswap V3 gives 2.951. The aggregator's job: fan out the same quote to every venue at the same instant, compare, pick the winner. That's what 1inch, Paraswap, and 0x do under the hood. ~250 lines of Rust below: fork mainnet locally with Revm (so every quote reads the same atomic state), pull reserves from Uniswap V2 + Sushi + Uniswap V3, compute the output, pick the best.

📌 Scope honesty. We compute quotes across two V2-style pools (Uniswap V2 + Sushi) and one V3 pool (Uniswap V3) for a single hop. Real aggregators add: split routing (send 30% through Uniswap, 70% through Curve), multi-hop (A → WETH → B), CFMMs with custom math (Curve's stableswap, Balancer's weighted pools), gas-aware routing. Each is a one-loop extension of the kernel here.

Acceptance criteria

The lesson is complete when these tests pass (full code at the end in §Test gate):

  1. matches_quoter_for_known_input — for one fixed input at a pinned mainnet block, your computed V3 quote matches Uniswap's official QuoterV2 within 5 bps.
  2. picks_best_when_v3_dominates — at a block where V3 has the best price, pick_best returns the V3 quote.

Test-first reading. The walkthrough below shows how to fork mainnet, read pool reserves, compute quotes per venue, and pick the winner — exactly the pieces these tests measure.

What you'll build

$ cargo run -- quote \
    --in-token  0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 \  # USDC
    --out-token 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 \  # WETH
    --amount-in 10000000000                                       # 10,000 USDC

Quotes (10000 USDC -> WETH):
  Uniswap V2:    2.94821 WETH  (price 3393.08 USDC/WETH)
  Sushi V2:      2.94619 WETH  (price 3395.41 USDC/WETH)
  Uniswap V3:    2.95104 WETH  (price 3389.84 USDC/WETH)  ← BEST
flowchart TB
    User["CLI: in/out token, amount"] --> Fork["Revm fork<br/>at latest block"]
    Fork -->|getReserves| V2A["Uniswap V2 pool"]
    Fork -->|getReserves| V2B["Sushi V2 pool"]
    Fork -->|simulate swap| V3["Uniswap V3 pool<br/>(more complex math)"]
    V2A --> Quote["Quote calculator"]
    V2B --> Quote
    V3 --> Quote
    Quote --> Pick["Pick best (post-fee, post-gas)"]

Why fork (vs direct RPC)

ApproachLatency for N quotesGas-cost simulation?Multi-pool atomic view?
N eth_calls over RPCN × ~50 ms = seconds for 10 poolsNo (you'd need separate eth_estimateGas)No — each call is a separate state read; pool A and pool B might be from slightly different blocks
Fork once, read N timesfirst ~50 ms (block fetch), then ~200 µs/poolYes — same Revm fork lets you measure gas of a hypothetical swapYes — every read is from the same atomic snapshot

For aggregation specifically, atomicity matters: if pool A's reserves moved between your read of pool A and pool B, your "best route" math is comparing apples and oranges. Fork gives you a single view of the world.

Cargo.toml

[package]
name = "swap-aggregator"
version = "0.1.0"
edition = "2021"

[dependencies]
alloy-eips         = "1.0"
alloy-primitives   = "1.5"
alloy-provider     = "1.0"
alloy-network      = "1.0"
alloy-sol-types    = "1.5"
revm               = { version = "38", features = ["alloydb"] }
clap               = { version = "4", features = ["derive"] }
tokio              = { version = "1", features = ["full"] }
eyre               = "0.6"

Step 1: Fork mainnet (same pattern as Lesson 1)

use alloy_eips::BlockId;
use alloy_provider::{network::Ethereum, DynProvider, ProviderBuilder};
use revm::{
    context::TxEnv,
    context_interface::result::{ExecutionResult, Output},
    database::{AlloyDB, CacheDB},
    database_interface::WrapDatabaseAsync,
    primitives::{Address, TxKind, U256},
    Context, ExecuteEvm, MainBuilder, MainContext,
};

type ForkedDB = CacheDB<WrapDatabaseAsync<AlloyDB<Ethereum, DynProvider>>>;

async fn build_fork() -> eyre::Result<ForkedDB> {
    let provider = ProviderBuilder::new()
            // Provider examples: QuickNode, Alchemy, Infura, or your own Reth node.
.connect(&std::env::var("ETH_RPC_URL")?)
        .await?
        .erased();
    let alloy_db = WrapDatabaseAsync::new(AlloyDB::new(provider, BlockId::latest()))
        .ok_or_else(|| eyre::eyre!("AlloyDB init failed"))?;
    Ok(CacheDB::new(alloy_db))
}

Identical to Lesson 1 (MEV searcher) — and that's the point. The same fork pattern shows up everywhere; if you can build one, you can build them all.

Step 2: Read V2 pool reserves

Uniswap V2 / Sushi / any V2 fork: same ABI, same constant-product math.

use alloy_sol_types::{sol, SolCall};

sol! {
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
    function token0() external view returns (address);
    function token1() external view returns (address);
}

#[derive(Debug, Clone, Copy)]
pub struct V2Pool {
    pub address: Address,
    pub reserve_in: U256,
    pub reserve_out: U256,
    pub fee_bps: u32, // Uniswap V2: 30 (= 0.3%)
}

async fn read_v2_pool(
    db: &mut ForkedDB,
    pool: Address,
    in_token: Address,
    fee_bps: u32,
) -> eyre::Result<V2Pool> {
    let mut evm = Context::mainnet().with_db(db).build_mainnet();

    // 1. Find which side of the pool is in_token (token0 or token1)
    let token0 = call_view::<token0Call>(&mut evm, pool, token0Call {})?;
    let in_is_zero = token0._0 == in_token;

    // 2. Read reserves
    let r = call_view::<getReservesCall>(&mut evm, pool, getReservesCall {})?;

    let (reserve_in, reserve_out) = if in_is_zero {
        (U256::from(r.reserve0), U256::from(r.reserve1))
    } else {
        (U256::from(r.reserve1), U256::from(r.reserve0))
    };

    Ok(V2Pool { address: pool, reserve_in, reserve_out, fee_bps })
}

fn call_view<C: SolCall>(
    evm: &mut impl ExecuteEvm<Tx = TxEnv>,
    target: Address,
    call: C,
) -> eyre::Result<C::Return> {
    let result = evm.transact_one(
        TxEnv::builder()
            .caller(Address::ZERO)
            .kind(TxKind::Call(target))
            .data(call.abi_encode().into())
            .gas_limit(1_000_000)
            .build()?,
    )?;

    match result.result {
        ExecutionResult::Success { output: Output::Call(out), .. } => {
            Ok(C::abi_decode_returns(&out, true)?)
        }
        _ => eyre::bail!("view call failed"),
    }
}

Walk:

  • The same EVM call we made in Lesson 1 (MEV searcher)'s read_reserves — generalized into a call_view helper that works for any SolCall. Re-use accumulates as you build.
  • token0 lookup is necessary because we don't know which side is which. Pools are sorted by address; depending on which token is which, "reserve_in" maps to reserve0 or reserve1. Skip this and your quote math is upside-down half the time.
  • fee_bps parameterizes the V2 family. Uniswap V2: 30 bps (0.3%). Sushi: also 30 bps. Older Mooniswap, custom forks: anywhere from 5 to 100 bps. Same code, different parameter.

🔍 Find in repo. Open the Uniswap V2 router source. Find getAmountOut. That's the math the next step implements. Compare your Rust to the reference Solidity, line by line.

Step 3: V2 quote math (constant product)

fn quote_v2(pool: V2Pool, amount_in: U256) -> U256 {
    // Uniswap V2 formula: amount_in_with_fee = amount_in * (10000 - fee_bps)
    //                     numerator   = amount_in_with_fee * reserve_out
    //                     denominator = reserve_in * 10000 + amount_in_with_fee
    //                     amount_out  = numerator / denominator
    let amount_in_with_fee = amount_in * U256::from(10_000 - pool.fee_bps);
    let numerator   = amount_in_with_fee * pool.reserve_out;
    let denominator = pool.reserve_in * U256::from(10_000) + amount_in_with_fee;
    numerator / denominator
}

Walk:

  • 9 lines of math = the entire AMM for V2-style pools. Constant product (x · y = k), held under a fee discount.
  • Integer-only — no floats, no panics. U256 arithmetic carries the precision the EVM uses on-chain. Your quote will match the on-chain swap to the wei.
  • Fee in basis points lets you support Uniswap, Sushi, custom-fee forks with the same code.

Step 4: V3 quote (more complex math, simpler approach)

Uniswap V3 prices liquidity in ticks and concentrated ranges. The quote formula is non-trivial. The shortcut: don't reimplement V3 math; ask the on-chain Quoter to give you the answer, but do it via Revm so you don't pay an RPC roundtrip:

sol! {
    interface IQuoterV2 {
        function quoteExactInputSingle(
            address tokenIn,
            address tokenOut,
            uint24  fee,
            uint256 amountIn,
            uint160 sqrtPriceLimitX96
        ) external returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate);
    }
}

const UNI_V3_QUOTER: Address = alloy_primitives::address!("61fFE014bA17989E743c5F6cB21bF9697530B21e");

fn quote_v3(
    db: &mut ForkedDB,
    in_token: Address,
    out_token: Address,
    fee: u32,  // 100 / 500 / 3000 / 10000
    amount_in: U256,
) -> eyre::Result<U256> {
    let mut evm = Context::mainnet().with_db(db).build_mainnet();
    let call = IQuoterV2::quoteExactInputSingleCall {
        tokenIn:               in_token,
        tokenOut:              out_token,
        fee:                   fee.into(),
        amountIn:              amount_in,
        sqrtPriceLimitX96:     U256::ZERO,
    };

    let result = evm.transact_one(
        TxEnv::builder()
            .caller(Address::ZERO)
            .kind(TxKind::Call(UNI_V3_QUOTER))
            .data(call.abi_encode().into())
            .gas_limit(10_000_000)
            .build()?,
    )?;

    match result.result {
        ExecutionResult::Success { output: Output::Call(out), .. } => {
            let decoded = IQuoterV2::quoteExactInputSingleCall::abi_decode_returns(&out, true)?;
            Ok(decoded.amountOut)
        }
        _ => eyre::bail!("V3 quote failed"),
    }
}

Walk:

  • UNI_V3_QUOTER is a deployed contract. Its job is exactly this — answer "how much would I get out?" without doing an actual swap. Free for us to call because we're calling it in-Revm, not on-chain.
  • sqrtPriceLimitX96 = 0 disables price limit (i.e., "any price is fine"). For real routing you'd set it to bound slippage.
  • The fee parameter selects the pool tier. V3 has 1bp (stable pairs), 5bp (stable pools), 30bp (most pairs), 100bp (exotic pairs). Production aggregators query all four and pick the best.

The same call_view pattern would also work — we wrote it inline here so the V3 call's specifics are visible.

Step 5: Aggregate and pick

#[derive(Debug)]
struct Quote {
    venue: &'static str,
    amount_out: U256,
}

async fn aggregate(
    db: &mut ForkedDB,
    in_token: Address,
    out_token: Address,
    amount_in: U256,
) -> eyre::Result<Vec<Quote>> {
    let uni_v2_pool   = address!("0d4a11d5EEaaC28EC3F61d100daF4d40471f1852"); // USDC/WETH on Uniswap V2 (example)
    let sushi_pool    = address!("397FF1542f962076d0BFE58eA045FfA2d347ACa0"); // USDC/WETH on Sushi (example)

    let v2 = read_v2_pool(db, uni_v2_pool, in_token, 30).await?;
    let sushi = read_v2_pool(db, sushi_pool, in_token, 30).await?;
    let v3 = quote_v3(db, in_token, out_token, 500, amount_in)?;

    Ok(vec![
        Quote { venue: "Uniswap V2", amount_out: quote_v2(v2, amount_in) },
        Quote { venue: "Sushi V2",   amount_out: quote_v2(sushi, amount_in) },
        Quote { venue: "Uniswap V3", amount_out: v3 },
    ])
}

fn pick_best(quotes: &[Quote]) -> &Quote {
    quotes.iter().max_by_key(|q| q.amount_out).expect("non-empty quotes")
}

#[tokio::main]
async fn main() -> eyre::Result<()> {
    let args = Args::parse();
    let mut db = build_fork().await?;
    let quotes = aggregate(&mut db, args.in_token, args.out_token, args.amount_in).await?;
    let best = pick_best(&quotes);

    println!("Quotes ({} {} -> {}):", args.amount_in, args.in_token, args.out_token);
    for q in &quotes {
        let marker = if std::ptr::eq(q, best) { "  ← BEST" } else { "" };
        println!("  {:<14} {:>20}{}", q.venue, q.amount_out, marker);
    }
    Ok(())
}

Whole binary: ~250 LOC including imports + CLI parsing.

What's missing for production

GapWhat real aggregators do
Multi-hop routingA → WETH → B routing across pools. Build a graph, run Bellman-Ford weighted by output amount.
Split routingSend 40% through V3, 60% through V2 if the combined output exceeds either alone. Convex optimization on the weights.
Curve / Balancer / etc.Each CFMM has its own quote function. Curve uses stableswap (Newton's method); Balancer uses weighted pools. Same fork, different math per venue.
Gas-awareSubtract estimated gas cost (in out-token terms) from each quote. A 0.1% better price isn't worth 50¢ extra gas on a $100 swap.
Price-impact thresholdsReject routes that move the pool >X% — protects against MEV sandwich attacks on low-liquidity venues.
Re-quote at submissionThe fork was at block N; the swap lands at block N+k. Re-quote right before submission to catch state drift.
MEV protectionSubmit through Flashbots Protect / MEV-Share so frontrunners don't see the route ahead of time. (Lesson 8 — Capstone does this.)

The architecture you wrote — fork once, read reserves atomically, compute quotes per venue, pick the winner — is exactly how 1inch and Paraswap shape their internal pricing layer. They add scale, more venues, better routing optimization. The kernel is identical.

Drill

  1. Add Curve. Pick a Curve pool (e.g., 3pool). Read its state, implement its quote (stableswap) using get_dy(int128 i, int128 j, uint256 dx) via the same call_view pattern. (1.5 hours)
  2. Add gas accounting. Subtract estimated gas cost from each quote (use evm.estimate_gas on a hypothetical swap). The "best" route should now be the one that maximizes amount_out − gas_cost_in_out_token. (2 hours)
  3. Multi-hop search. Build a 2-hop search: A → WETH → B. For each candidate via WETH, compute the chained quote and compare to the direct route. (3 hours)
  4. Split routing. Implement a 50/50 split between the top two venues; check whether the combined output beats either alone. (2 hours)
  5. Cross-tier: Wire the aggregator into the wallet backend (Lesson 4) as a POST /quote-and-swap that returns a signed tx ready for submission. (3 hours)

Finish drill 5 and you have, structurally, an aggregator-as-a-service. Plug in MEV protection (Lesson 8) and you're at parity with what shipped in 2023.

Test gate

Per Test gate — every app in this tier ships with passing tests, this lesson's minimum gate is a forked-state quote test against a known-good reference quote at a pinned mainnet block.

The aggregator's correctness is binary: either your computed output matches what Quoter (Uniswap's official off-chain quoter contract, deployed at 0x...3258) returns for the same input at the same block, or you have a bug in your CFMM math. Reading reserves correctly is necessary; doing the math correctly is what the test enforces.

// tests/aggregator_quote_diff.rs
use alloy::primitives::{address, U256};

const PINNED_BLOCK: u64 = 18_500_000;
const FORK_RPC: &str = "https://eth.merkle.io";
const QUOTER_V2: Address = address!("61fFE014bA17989E743c5F6cB21bF9697530B21e");

#[tokio::test]
async fn matches_quoter_for_known_input() {
    let mut db = build_fork_at(FORK_RPC, PINNED_BLOCK).await;

    // 10,000 USDC -> WETH via Uniswap V3 0.3% pool
    let amount_in = U256::from(10_000) * U256::from(10).pow(U256::from(6));

    // Our aggregator's path (V3 only, single hop)
    let our_quote = quote_v3(&mut db, USDC, WETH, 3000, amount_in).await.unwrap();

    // Reference: Uniswap's QuoterV2 in the SAME forked state
    let reference_quote = call_quoter_v2(&mut db, QUOTER_V2, USDC, WETH, 3000, amount_in).await.unwrap();

    // Allow ε for fee accounting precision (basis-point level)
    let diff_bps = (our_quote.abs_diff(reference_quote) * U256::from(10_000)) / reference_quote;
    assert!(diff_bps < U256::from(5), "must match QuoterV2 within 5 bps; got {} bps", diff_bps);
}

#[tokio::test]
async fn picks_best_when_v3_dominates() {
    // Construct a scenario where V3 has best price; assert pick_best returns V3
    // Use a real block where this holds (look up via block explorer)
}

The lesson is not complete until the QuoterV2 differential passes. If your math is off by 50 bps, you're recommending suboptimal routes — silently — to every user.

🧭 Where you are now in the stack: you've shipped an aggregator that applies database-layer consistent-snapshot read to DEX state — every quote runs against the same Revm-forked snapshot at a pinned block, with QuoterV2 differential locking in precision to 5 bps. Same shape as MVCC databases' atomic multi-key read. Next lesson is the capstone: integrate networking + compiler + authentication layers into a frontrun-resistant order router.

Summary (3 lines)

  • Swap aggregator = pool fetcher (multicall) + revm simulator (multi-path) + best-path selector. ~500 lines of Rust.
  • sol! contract bindings make pool reads type-safe; Multicall3 batches reads; revm fork simulates each path; pick max output.
  • Slippage via min_out; MEV-resistance via private bundle submission. Test gate at a pinned forked block. Next: Capstone — frontrun-resistant router.