11 min read
The Engineering Behind DEX Trade Routing

Every time you swap tokens through an aggregator, there’s a good chance your trade is being split across multiple liquidity sources. This isn’t a bug—it’s the only way to get competitive execution on anything beyond tiny trades.

After building routing systems at MetaMask, Odos, and now 0x, I’ve learned that trade routing is fundamentally an optimization problem fighting against the convexity of AMM pricing. Let’s break down why splitting matters, how to think about it mathematically, and what heuristics actually work in production.


The Problem: Convexity and Price Impact

AMMs don’t have order books. Instead, they use deterministic pricing functions that become increasingly punishing as trade size grows.

(All formulas and examples in this post ignore swap fees for clarity; production routers subtract the fee from input or output at each step.)

The Constant Product Formula

Uniswap V2-style pools use the constant product invariant:

xy=kx \cdot y = k

Where xx and yy are the reserves of each token. When you swap Δx\Delta x tokens in, you receive:

Δy=yΔxx+Δx\Delta y = \frac{y \cdot \Delta x}{x + \Delta x}

The effective price (output per unit input):

Effective Price=ΔyΔx=yx+Δx\text{Effective Price} = \frac{\Delta y}{\Delta x} = \frac{y}{x + \Delta x}

Notice that as Δx\Delta x increases, your effective price drops. This is price impact—the larger your trade, the worse your execution.

Quadratic Degradation

Here’s where it gets painful. The instantaneous marginal output rate rr after trading an input amount QQ into the pool is:

r(Q)=k(x+Q)2r(Q) = \frac{k}{(x + Q)^2}

Where QQ is the cumulative input traded so far (pool reserves shift from xx to x+Qx + Q). As you trade more, this rate drops quadratically—doubling the trade size more than doubles total slippage, and quadruples the marginal slippage at the end of the trade.

Concrete Example

Consider an ETH/USDC pool with:

  • 1,000 ETH reserve
  • 2,000,000 USDC reserve
  • Spot price: 2,000 USDC/ETH
Trade SizeOutput (USDC)Effective PricePrice Impact
1 ETH1,9981,9980.10%
10 ETH19,8021,9800.99%
100 ETH181,8181,8189.09%
500 ETH666,6671,33333.33%

(Values rounded for clarity)

That 500 ETH trade loses over $333,000 to price impact compared to the spot price. This is why splitting matters.


Why Splitting Works

Price impact is convex in trade size but additive across pools. Split a trade 50/50 across two identical pools: each half sees ~1/4 the impact, totaling ~half the single-pool impact.

More generally, splitting QQ across nn identical pools gives impact proportional to Q2/nQ^2/n instead of Q2Q^2. This is the fundamental arbitrage that routing exploits.

The Optimal Split

For two pools with reserves (x1,y1)(x_1, y_1) and (x2,y2)(x_2, y_2), the optimal allocation maximizes total output. At the optimum, marginal output rates are equal across pools:

Y1Q1=Y2Q2\frac{\partial Y_1}{\partial Q_1} = \frac{\partial Y_2}{\partial Q_2}

where YiY_i is the output amount and QiQ_i is the input amount for pool ii.

When pools have nearly identical spot prices and fee tiers, the optimal split is approximately proportional to the input-token reserves:

Q1Q2x1x2\frac{Q_1}{Q_2} \approx \frac{x_1}{x_2}

A pool with 2x the reserves should receive roughly 2x the flow. In practice, pools have slightly different prices and fees, so you iterate numerically to find the allocation that maximizes total output.


The Optimization Problem

Model the DEX landscape as a directed graph: tokens are nodes, pools are edges. Given input amount QQ of source token, maximize output of target token subject to flow conservation and liquidity constraints.

Single-Hop vs. Multi-Hop

In the single-hop case (direct swaps only), the objective function—the total output Outputi(Qi)\sum \text{Output}_i(Q_i)—is concave with respect to the allocation variables QiQ_i, and the flow conservation constraints are linear, defining a convex feasible set. This is a Concave Maximization Problem (a standard form of Convex Optimization), which is efficiently solvable. We can guarantee a globally optimal solution using methods like coordinate ascent, gradient-based methods, or the greedy marginal approach described below.

But multi-hop routing introduces path dependencies. The output of hop 1 becomes the input to hop 2. The search space explodes combinatorially: with nn intermediate tokens, mm pools per pair, and kk hops, paths grow as O((n×m)k)O((n \times m)^k). For realistic parameters (n=100n=100, m=10m=10, k=3k=3), that’s billions of candidate paths.

This is why we need heuristics.


Heuristics That Actually Work

After years of iteration, a few patterns have emerged as production-grade solutions.

1. Greedy Marginal Allocation

Allocate in small increments, always picking the pool with the best marginal rate. Simple but effective.

Goal: An iterative approach that always chooses the locally optimal pool for the next increment of the trade.

use alloy_primitives::U256;
use std::collections::HashMap;

fn greedy_split(pools: &[Pool], total_amount: U256) -> HashMap<PoolId, U256> {
    let mut allocation: HashMap<PoolId, U256> = pools.iter()
        .map(|p| (p.id, U256::ZERO))
        .collect();

    let mut remaining = total_amount;
    let step_size = total_amount / U256::from(100); // 1% increments for illustration

    while remaining > U256::ZERO {
        let step = step_size.min(remaining);

        // Find pool with best marginal price
        let best_pool = pools
            .iter()
            .max_by(|a, b| {
                let margin_a = marginal_output(a, allocation[&a.id], step);
                let margin_b = marginal_output(b, allocation[&b.id], step);
                margin_a.cmp(&margin_b)
            })
            .unwrap();

        *allocation.get_mut(&best_pool.id).unwrap() += step;
        remaining -= step;
    }

    allocation
}

/// Average output rate over the next step (not instantaneous marginal)
/// Returns output per unit input, scaled by 1e18 for precision
fn marginal_output(pool: &Pool, current_amount: U256, additional: U256) -> U256 {
    let base_output = pool.get_output(current_amount);
    let new_output = pool.get_output(current_amount + additional);
    ((new_output - base_output) * U256::from(10).pow(U256::from(18))) / additional
}

This is O(nQ/step)O(n \cdot Q/\text{step}) where nn is pool count. With 1% steps and 50 pools, that’s 5,000 iterations—fast enough for real-time quoting.

2. Dynamic Programming for Multi-Hop

For paths with intermediate tokens, we use DP with state pruning.

Goal: Use Dynamic Programming with beam search to find the top KK highest-output paths while managing combinatorial explosion.

use alloy_primitives::U256;
use std::collections::HashMap;

#[derive(Clone)]
struct PathState {
    amount: U256,
    path: Vec<PoolId>,
}

fn find_best_paths(
    graph: &Graph,
    source: TokenId,
    target: TokenId,
    amount: U256,
    max_hops: usize,
) -> Vec<PathState> {
    // dp[token] = best states reaching this token (across all hop counts)
    let mut dp: HashMap<TokenId, Vec<PathState>> = HashMap::new();
    let mut results: Vec<PathState> = Vec::new();

    dp.insert(source, vec![PathState { amount, path: vec![] }]);

    for _hop in 0..max_hops {
        let mut next_dp: HashMap<TokenId, Vec<PathState>> = HashMap::new();

        for (token, states) in &dp {
            for state in states {
                for pool in graph.pools_from(*token) {
                    let next_token = pool.other_token(*token);
                    let output = pool.get_output(state.amount);

                    let mut new_path = state.path.clone();
                    new_path.push(pool.id);
                    let new_state = PathState { amount: output, path: new_path };

                    // Collect paths that reach target
                    if next_token == target {
                        results.push(new_state.clone());
                    }

                    // Continue exploring from this token
                    next_dp.entry(next_token)
                        .or_default()
                        .push(new_state);
                }
            }
        }

        // Prune: keep top K states per token for next iteration
        for states in next_dp.values_mut() {
            *states = top_k(std::mem::take(states), 10);
        }
        dp = next_dp;
    }

    top_k(results, 10)
}

fn top_k(mut states: Vec<PathState>, k: usize) -> Vec<PathState> {
    states.sort_by(|a, b| b.amount.cmp(&a.amount));
    states.truncate(k);
    states
}

The pruning is critical. Without it, state explodes. With beam width k=10k=10, you keep at most kk states per token at each hop. Total work becomes O(k×num_tokens×max_hops×avg_pools_per_token)O(k \times \text{num\_tokens} \times \text{max\_hops} \times \text{avg\_pools\_per\_token})—polynomial instead of exponential.

3. The Trade-Off Triangle

Every routing decision balances three competing concerns:

        Execution Quality
              /\
             /  \
            /    \
           /      \
          /________\
    Gas Cost      Latency

Execution quality: More splits = better price, but diminishing returns.

Gas cost: On Ethereum L1, each additional pool/hop costs ~60-110k gas depending on the DEX. At 50 gwei and 2000ETH,thats2000 ETH, that's 6-11 per hop. L2s are orders of magnitude cheaper.

Latency: More paths to simulate = slower quotes. Users expect <200ms response times.

The sweet spot depends on trade size:

Trade SizeOptimal Strategy
< $1,000Single best pool, minimize gas
1k1k-100k2-4 way split across top pools
100k100k-1MFull optimization, 5-10 paths
> $1MSplit across time (TWAP) too

4. When NOT to Split

Splitting isn’t always optimal. Skip it when:

  1. Gas dominates: If gas cost > potential savings, use single path
  2. Concentrated liquidity: Uni V3 pools with tight ranges often beat splits
  3. RFQ availability: Market makers often beat any AMM routing
  4. Low liquidity: If one pool has 90%+ of liquidity, splitting adds cost without benefit

A good router checks these conditions first. This helper estimates whether the execution savings from splitting outweigh the additional gas costs (using a heuristic based on greedy allocation results):

use alloy_primitives::U256;

const GAS_PER_ADDITIONAL_POOL: u64 = 85_000; // ~60-110k range on L1

/// gas_price in wei, eth_price in USD scaled by 1e8 (e.g., $2000 = 200_000_000_000)
fn should_split(
    pools: &[Pool],
    amount: U256,
    gas_price: U256,
    eth_price: U256,
) -> bool {
    if pools.len() < 2 {
        return false;
    }

    // Estimate savings from splitting
    let single_output = pools
        .iter()
        .map(|p| p.get_output(amount))
        .max()
        .unwrap_or(U256::ZERO);

    // These helpers would implement the greedy split logic above
    let split_output = estimate_split_output(pools, amount);
    let savings = split_output.saturating_sub(single_output);

    // Estimate additional gas cost (in output token units)
    let extra_gas = U256::from(GAS_PER_ADDITIONAL_POOL)
        * U256::from(optimal_pool_count(pools, amount).saturating_sub(1));
    let gas_cost_wei = extra_gas * gas_price;
    let gas_cost_usd = (gas_cost_wei * eth_price) / U256::from(10).pow(U256::from(18));

    // 50% margin for uncertainty (multiply savings by 2/3 threshold)
    savings * U256::from(2) > gas_cost_usd * U256::from(3)
}

Real-World Complications

Stale Quotes and MEV

Pool states change between quote and execution. Strategies:

  1. Slippage tolerance: Typical ranges are 0.5-2% depending on asset volatility and user risk tolerance
  2. Private transaction services: Flashbots Protect, MEV Blocker, and others reduce sandwich risk (each with different fee/latency/trust tradeoffs)
  3. Pessimistic simulation: Quote assuming 1 block of price movement

Cross-Chain Routing

Bridging adds latency and trust assumptions. Approaches: intent-based solvers, atomic bridges where possible, or hybrid (bridge stablecoins, swap on destination).

RFQ Integration

Market makers providing firm quotes often beat AMMs. This example shows concurrent RFQ fetching with a timeout to optimize latency:

use alloy_primitives::U256;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;

async fn get_best_quote(
    pools: &[Pool],
    rfq_providers: &[Arc<RfqProvider>],
    amount: U256,
) -> Quote {
    let amm_output = optimize_split(pools, amount);

    // Fetch RFQ quotes concurrently with 100ms timeout
    let rfq_futures: Vec<_> = rfq_providers
        .iter()
        .map(|provider| {
            let provider = Arc::clone(provider);
            async move {
                timeout(Duration::from_millis(100), provider.get_quote(amount))
                    .await
                    .ok()
                    .flatten()
            }
        })
        .collect();

    let rfq_results = futures::future::join_all(rfq_futures).await;

    let best_rfq = rfq_results
        .into_iter()
        .flatten()
        .max_by(|a, b| a.output.cmp(&b.output));

    match best_rfq {
        Some(rfq) if rfq.output > amm_output.output => Quote::Rfq(rfq),
        _ => Quote::Amm(amm_output),
    }
}

RFQs are particularly valuable for:

  • Tokens with thin AMM liquidity
  • Large trades where market makers can source OTC
  • Cross-chain where MMs have inventory on both sides

Revert Risk

Split routes can partially fail. Mitigations:

  1. Atomic execution: All-or-nothing via smart contract
  2. Fallback paths: If primary route reverts, try secondary
  3. Conservative reserves: Don’t route to pools near 0 liquidity

Putting It Together

A production router combines all these elements. The following synthesizes the entire flow—gathering liquidity, checking split viability, optimizing routes, and comparing against RFQ quotes.

use alloy_primitives::U256;
use std::sync::Arc;

pub struct Router {
    index: PoolIndex,
    rfq_providers: Vec<Arc<RfqProvider>>,
    optimizer: RouteOptimizer,
    gas_oracle: GasOracle,
}

impl Router {
    pub async fn get_quote(
        &self,
        sell_token: TokenId,
        buy_token: TokenId,
        amount: U256,
    ) -> Quote {
        // 1. Gather liquidity sources
        let pools = self.index.get_pools(sell_token, buy_token);
        let (gas_price, eth_price) = self.gas_oracle.get_prices().await;

        // 2. Check if splitting is worthwhile
        if !should_split(&pools, amount, gas_price, eth_price) {
            return self.single_path_quote(&pools, amount);
        }

        // 3. Find optimal AMM routing (can run concurrently with RFQ)
        let amm_routes = self.optimizer.find_routes(&pools, amount, 3, 5);

        // 4. Fetch RFQ quotes concurrently
        let rfq_futures: Vec<_> = self.rfq_providers
            .iter()
            .map(|p| {
                let provider = Arc::clone(p);
                let (sell, buy) = (sell_token, buy_token); // Copy into closure
                async move { provider.request_quote(sell, buy, amount).await }
            })
            .collect();

        let rfq_quotes = futures::future::join_all(rfq_futures).await;
        let best_rfq = rfq_quotes
            .into_iter()
            .flatten()
            .max_by(|a, b| a.output.cmp(&b.output));

        // 5. Compare and potentially combine AMM + RFQ
        let Some(rfq) = best_rfq else {
            return amm_routes;
        };

        // Return whichever source (AMM, RFQ, or hybrid) gives best output
        let combined = self.combine_sources(&amm_routes, &rfq, amount);
        self.best_of(amm_routes, Quote::Rfq(rfq), combined)
    }
}

What’s Next

Future posts: Uni V3 tick math, MEV-aware execution, simulation infrastructure, and cross-chain intent systems.

The routing problem is far from solved—new AMM designs, L2 fragmentation, and intent architectures keep pushing the frontier. But the core insight remains: convexity is the enemy, and smart splitting is the cure.


Have questions or want to discuss routing? Drop me an email.