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:
Where and are the reserves of each token. When you swap tokens in, you receive:
The effective price (output per unit input):
Notice that as 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 after trading an input amount into the pool is:
Where is the cumulative input traded so far (pool reserves shift from to ). 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 Size | Output (USDC) | Effective Price | Price Impact |
|---|---|---|---|
| 1 ETH | 1,998 | 1,998 | 0.10% |
| 10 ETH | 19,802 | 1,980 | 0.99% |
| 100 ETH | 181,818 | 1,818 | 9.09% |
| 500 ETH | 666,667 | 1,333 | 33.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 across identical pools gives impact proportional to instead of . This is the fundamental arbitrage that routing exploits.
The Optimal Split
For two pools with reserves and , the optimal allocation maximizes total output. At the optimum, marginal output rates are equal across pools:
where is the output amount and is the input amount for pool .
When pools have nearly identical spot prices and fee tiers, the optimal split is approximately proportional to the input-token reserves:
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 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 —is concave with respect to the allocation variables , 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 intermediate tokens, pools per pair, and hops, paths grow as . For realistic parameters (, , ), 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 where 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 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 , you keep at most states per token at each hop. Total work becomes —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 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 Size | Optimal Strategy |
|---|---|
| < $1,000 | Single best pool, minimize gas |
| 100k | 2-4 way split across top pools |
| 1M | Full optimization, 5-10 paths |
| > $1M | Split across time (TWAP) too |
4. When NOT to Split
Splitting isn’t always optimal. Skip it when:
- Gas dominates: If gas cost > potential savings, use single path
- Concentrated liquidity: Uni V3 pools with tight ranges often beat splits
- RFQ availability: Market makers often beat any AMM routing
- 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:
- Slippage tolerance: Typical ranges are 0.5-2% depending on asset volatility and user risk tolerance
- Private transaction services: Flashbots Protect, MEV Blocker, and others reduce sandwich risk (each with different fee/latency/trust tradeoffs)
- 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:
- Atomic execution: All-or-nothing via smart contract
- Fallback paths: If primary route reverts, try secondary
- 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.