Multi-Chain DEX Aggregation: How Lunark Finds the Best Swap Rates

March 27, 2025

A technical walkthrough of Lunark's DEX aggregation system, covering quote fetching across Uniswap, SushiSwap, Curve, PancakeSwap, and TraderJoe, fee tier optimization, and the challenges of building a multi-protocol swap system.

ListenReady
0:00
4:10

When users ask Lunark to swap tokens, they expect the best rate. But "best rate" is surprisingly complex. Different DEXes have different liquidity, fee structures, and routing algorithms. Uniswap V3 has multiple fee tiers. Some pairs only exist on certain protocols. And all of this varies by network.

Lunark's swap system handles this complexity by aggregating quotes from five DEX protocols across eight networks, then selecting the optimal route automatically.

The DEX Landscape

Lunark integrates with five DEX protocols:

ProtocolTypeNetworksFee Model
Uniswap V3Concentrated liquidityAll 8Dynamic (0.01-1%)
SushiSwapConstant product (V2)6Fixed 0.3%
CurveStableSwap5 (Ethereum, Arbitrum, Polygon, Optimism, Avalanche)Variable low fees
PancakeSwapConstant product (V2)3Fixed 0.25%
TraderJoeConstant product (V2)2Fixed 0.3%

Each protocol has different strengths. Uniswap V3's concentrated liquidity often provides better rates for popular pairs. Curve excels at stablecoin swaps. SushiSwap might have better liquidity for long-tail tokens.

Quote Aggregation

When the swap tool is invoked, it fetches quotes from available DEXes sequentially:

TypeScript
async function getSwapQuotes( fromToken: string, toToken: string, amount: bigint, chainId: number ): Promise<SwapQuote[]> { const availableDexes = getDexesForChain(chainId); const quotes: SwapQuote[] = []; for (const dex of availableDexes) { try { const quote = await getQuoteFromDex(dex, fromToken, toToken, amount, chainId); if (quote && quote.outputAmount > 0n) { quotes.push(quote); } } catch (error) { // DEX might not support this pair, continue to next } } return quotes.sort((a, b) => Number(b.outputAmount - a.outputAmount)); }

Each DEX is queried one at a time. If one fails, the loop continues to the next DEX.

Uniswap V3 Fee Tier Handling

Uniswap V3 is unique because it has multiple fee tiers for each pair. While the protocol supports four tiers (0.01%, 0.05%, 0.3%, and 1%), the system tries three of them in a specific order: 0.3% (medium) first, then 0.05% (low), then 1% (high). It takes the first successful quote rather than comparing all options:

TypeScript
// Trial order: MEDIUM -> LOW -> HIGH (0.01% tier is not tried) const FEE_TIERS = [3000, 500, 10000]; // 0.3%, 0.05%, 1% async function getUniswapV3Quote( fromToken: string, toToken: string, amount: bigint, chainId: number ): Promise<SwapQuote | null> { const quoter = getQuoterContract(chainId); for (const feeTier of FEE_TIERS) { try { const output = await quoter.quoteExactInputSingle({ tokenIn: fromToken, tokenOut: toToken, amountIn: amount, fee: feeTier, sqrtPriceLimitX96: 0, }); // Return first successful quote return { dex: 'uniswap-v3', outputAmount: output.amountOut, feeTier, path: encodePath([fromToken, toToken], [feeTier]), }; } catch { // Pool doesn't exist for this fee tier, try next continue; } } return null; }

The 0.3% tier is tried first since it's the most common for typical trading pairs. The 0.01% tier is not checked as it's primarily used for stablecoin pairs with very high volume.

V2-Style DEX Quotes

SushiSwap, PancakeSwap, and TraderJoe use the simpler constant product formula. Quotes are straightforward:

TypeScript
async function getV2Quote( router: Contract, fromToken: string, toToken: string, amount: bigint ): Promise<bigint> { const amounts = await router.getAmountsOut(amount, [fromToken, toToken]); return amounts[1]; }

For multi-hop routes (e.g., TOKEN → WETH → USDC), the path array would include the intermediate token:

TypeScript
const amounts = await router.getAmountsOut(amount, [ fromToken, WETH_ADDRESS, toToken, ]);

Native Token Handling

EVM DEXes work with ERC20 tokens, not native ETH/MATIC/etc. When users want to swap native tokens, the system wraps/unwraps automatically:

TypeScript
function getTokenAddress(symbol: string, chainId: number): string { if (isNativeToken(symbol, chainId)) { return getWrappedNativeAddress(chainId); } return resolveToken(symbol, chainId).address; } // Wrapped native addresses per chain const WRAPPED_NATIVE: Record<number, string> = { 1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', // WETH (Ethereum) 137: '0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', // WMATIC (Polygon) 56: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', // WBNB (BSC) 43114: '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7', // WAVAX (Avalanche) // ... };

Building the Swap Transaction

Once we have the best quote, we need to build the actual swap transaction. This varies by DEX:

TypeScript
function buildSwapTransaction( quote: SwapQuote, recipient: string, deadline: number, slippage: number ): TransactionRequest { const minOutput = quote.outputAmount * BigInt(10000 - slippage) / 10000n; if (quote.dex === 'uniswap-v3') { return buildUniswapV3Swap(quote, recipient, deadline, minOutput); } return buildV2Swap(quote, recipient, deadline, minOutput); } function buildUniswapV3Swap( quote: SwapQuote, recipient: string, deadline: number, minOutput: bigint ): TransactionRequest { const router = getSwapRouter(quote.chainId); return { to: router.address, data: router.interface.encodeFunctionData('exactInputSingle', [{ tokenIn: quote.fromToken, tokenOut: quote.toToken, fee: quote.feeTier, recipient, deadline, amountIn: quote.inputAmount, amountOutMinimum: minOutput, sqrtPriceLimitX96: 0, }]), }; }

Approval Handling

Before swapping, the router contract needs approval to spend the user's tokens. The system checks allowance and prepares an approval transaction if needed:

TypeScript
async function prepareSwap( fromToken: string, toToken: string, amount: string, context: ToolContext ): Promise<SwapResult> { const quotes = await getSwapQuotes( fromToken, toToken, parseUnits(amount, fromDecimals), context.chainId ); if (quotes.length === 0) { return { error: 'No liquidity found for this pair' }; } const bestQuote = quotes[0]; const routerAddress = getRouterAddress(bestQuote.dex, context.chainId); // Check if approval is needed const tokenContract = new Contract(fromToken, ERC20_ABI, provider); const allowance = await tokenContract.allowance(context.userAddress, routerAddress); let approvalTx = null; if (allowance < bestQuote.inputAmount) { approvalTx = { to: fromToken, data: tokenContract.interface.encodeFunctionData('approve', [ routerAddress, MaxUint256, // Unlimited approval ]), }; } const swapTx = buildSwapTransaction( bestQuote, context.userAddress, Math.floor(Date.now() / 1000) + 20 * 60, // 20 min deadline 50, // 0.5% slippage in basis points ); return { quote: bestQuote, approvalTx, swapTx, alternatives: quotes.slice(1, 4), // Show top 3 alternatives }; }

Presenting Options to Users

The AI agent doesn't just execute the best quote blindly. It presents the options and explains the tradeoffs:

TypeScript
function formatQuoteResponse(result: SwapResult): string { const { quote, alternatives } = result; let response = `Best rate found on ${quote.dex}: `; response += `${formatAmount(quote.inputAmount)}${formatAmount(quote.outputAmount)}\n`; response += `Rate: 1 ${fromSymbol} = ${calculateRate(quote)} ${toSymbol}\n\n`; if (alternatives.length > 0) { response += 'Other options:\n'; for (const alt of alternatives) { const diff = ((Number(quote.outputAmount) - Number(alt.outputAmount)) / Number(quote.outputAmount) * 100).toFixed(2); response += `${alt.dex}: ${formatAmount(alt.outputAmount)} (-${diff}%)\n`; } } if (result.approvalTx) { response += '\nToken approval required before swap.'; } return response; }

This gives users visibility into what they're getting and why.

Slippage Protection

Slippage is the difference between expected and actual output. In volatile markets, prices can move between quote and execution. The system uses a default 0.5% slippage tolerance:

TypeScript
const DEFAULT_SLIPPAGE_BPS = 50; // 0.5% const MAX_SLIPPAGE_BPS = 500; // 5% function calculateMinOutput(quote: bigint, slippageBps: number): bigint { return quote * BigInt(10000 - slippageBps) / 10000n; }

If the actual output would be less than the minimum, the transaction reverts, protecting users from sandwich attacks and price manipulation.

Network-Specific Configuration

Each network has different DEXes available and different router addresses:

TypeScript
const DEX_CONFIG: Record<number, DexConfig[]> = { 1: [ // Ethereum { name: 'uniswap-v3', router: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45' }, { name: 'sushiswap', router: '0xd9e1cE17f2641f24aE83637ab66a2cca9C378B9F' }, { name: 'curve', router: '0x99a58482BD75cbab83b27EC03CA68fF489b5788f' }, ], 137: [ // Polygon { name: 'uniswap-v3', router: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45' }, { name: 'sushiswap', router: '0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506' }, { name: 'quickswap', router: '0xa5E0829CaCEd8fFDD4De3c43696c57F7D7A678ff' }, ], // ... }; function getDexesForChain(chainId: number): DexConfig[] { return DEX_CONFIG[chainId] || []; }

Error Handling

DEX queries can fail for many reasons: pair doesn't exist, insufficient liquidity, network issues. The system handles each gracefully:

TypeScript
async function safeGetQuote( dex: DexConfig, fromToken: string, toToken: string, amount: bigint, chainId: number ): Promise<SwapQuote | null> { try { const quote = await getQuoteWithTimeout( dex, fromToken, toToken, amount, chainId, 2000 // 2 second timeout ); if (quote.outputAmount === 0n) { return null; // No liquidity } return quote; } catch (error) { if (error.code === 'CALL_EXCEPTION') { // Pair doesn't exist or other contract error return null; } if (error.code === 'TIMEOUT') { // RPC too slow, skip this DEX return null; } // Log unexpected errors but don't fail the whole operation console.error(`Quote failed for ${dex.name}:`, error); return null; } }