Multi-Chain DEX Aggregation: How Lunark Finds the Best Swap Rates
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.
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:
| Protocol | Type | Networks | Fee Model |
|----------|------|----------|----------|
| Uniswap V3 | Concentrated liquidity | All 8 | Dynamic (0.01-1%) |
| SushiSwap | Constant product (V2) | 6 | Fixed 0.3% |
| Curve | StableSwap | 5 (Ethereum, Arbitrum, Polygon, Optimism, Avalanche) | Variable low fees |
| PancakeSwap | Constant product (V2) | 3 | Fixed 0.25% |
| TraderJoe | Constant product (V2) | 2 | Fixed 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:src/tools/swap/quotes.ts
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:src/tools/swap/uniswap.ts
// 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:src/tools/swap/v2.ts
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:src/utils/tokens.ts
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:src/tools/swap/transaction.ts
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:src/tools/swap/prepare.ts
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:src/tools/swap/format.ts
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:src/tools/swap/slippage.ts
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:src/config/dex.ts
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:src/tools/swap/quotes.ts
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;
}
}
```