DEX Aggregation in VoidDex: Building an Optimal Route Finder

How VoidDex's backend finds the best swap routes across multiple DEXes, covering the quote service architecture, route optimization strategies, and fee calculation system.

Finding the best swap rate isn't as simple as checking one DEX. VoidDex aggregates quotes from multiple protocols, evaluates split routes, considers multi-hop paths, and calculates the optimal execution strategy. This article covers the backend architecture that makes this work. ![VoidDex swap interface](/images/menu/void-dex/swap.jpg) ## Quote Service Architecture The NestJS backend organizes quote logic into specialized services: ```typescript:src/modules/quote/quote.module.ts @Module({ imports: [ConfigModule, ProtocolModule, PoolModule], controllers: [QuoteController], providers: [ QuoteService, // Main entry point DexQuoteService, // Fetches quotes from DEXes LiquidityGraphService, // Builds graph of available liquidity PathfinderService, // Finds optimal paths through the graph RouteQuoteService, // Gets quotes for discovered routes RouteOptimizerService, // Optimizes split routing FeeCalculatorService, // Calculates fees in WETH PriceService, // Token price lookups ], exports: [QuoteService], }) export class QuoteModule {} ``` The routing architecture uses a **dual-flow approach**: a primary pathfinder flow for discovering multi-hop routes, with a fallback to direct DEX quotes for simple pairs. ## Dual-Flow Quote Architecture VoidDex implements a sophisticated two-tier routing system: ```typescript:src/modules/quote/quote.service.ts @Injectable() export class QuoteService { constructor( private readonly dexQuoteService: DexQuoteService, private readonly routeOptimizer: RouteOptimizerService, private readonly feeCalculator: FeeCalculatorService, private readonly priceService: PriceService, private readonly pathfinderService: PathfinderService, private readonly routeQuoteService: RouteQuoteService, ) {} async getQuote(params: QuoteParams): Promise<QuoteResponse> { const { chainId, fromToken, toToken, amount, slippage = 0.5 } = params; // Resolve token addresses and get provider const provider = this.getProvider(chainId); const fromTokenAddress = this.resolveTokenAddress(chainId, fromToken); const toTokenAddress = this.resolveTokenAddress(chainId, toToken); const amountIn = parseUnits(amount, fromDecimals); // === PRIMARY FLOW: Dynamic Route Discovery === // 1. Discover all possible routes using pathfinder const discoveredRoutes = await this.pathfinderService.findRoutes( chainId, fromTokenAddress, toTokenAddress, ); // 2. If routes found, get quotes and optimize if (discoveredRoutes.length > 0) { const routeQuotes = await this.routeQuoteService.getQuotesForRoutes( chainId, provider, discoveredRoutes, amountIn, toDecimals, ); if (routeQuotes.length > 0) { // Try route-level split optimization const splitResult = this.optimizeRouteSplit(routeQuotes, amountIn, ...); if (splitResult) { return this.buildSplitRouteResponse(...); } } } // === FALLBACK: Direct DEX Quotes === const dexQuotes = await this.dexQuoteService.fetchAllQuotes( chainId, provider, fromTokenAddress, toTokenAddress, amountIn, ... ); // Find optimal route (single or split) const optimalRoute = this.routeOptimizer.findOptimalRoute(dexQuotes, ...); // Calculate fees and return response const fees = await this.feeCalculator.calculateFees({...}); return { ... }; } } ``` This dual-flow design ensures: - **Multi-hop discovery**: The pathfinder can find routes like WBTC -> WETH -> USDC when no direct pool exists - **Fallback reliability**: Direct DEX quotes work for simple pairs even if the graph is incomplete - **Split optimization**: Both flows support splitting across multiple routes for better execution ## RouteQuoteService: The Route Pricing Engine The `RouteQuoteService` is critical for getting accurate quotes on discovered routes. It simulates each hop in sequence: ```typescript:src/modules/quote/services/route-quote.service.ts @Injectable() export class RouteQuoteService { /** * Get quotes for all discovered routes */ async getQuotesForRoutes( chainId: number, provider: PublicClient, routes: DiscoveredRoute[], amountIn: bigint, toDecimals: number, ): Promise<RouteQuote[]> { const quotePromises = routes.map((route) => this.getQuoteForRoute(chainId, provider, route, amountIn, toDecimals) ); const results = await Promise.all(quotePromises); // Filter out failed quotes and sort by output (best first) const validQuotes = results .filter((q): q is RouteQuote => q !== null) .sort((a, b) => (b.amountOut > a.amountOut ? 1 : -1)); return validQuotes; } /** * Get quote for a single route by simulating each hop */ private async getQuoteForRoute( chainId: number, provider: PublicClient, route: DiscoveredRoute, amountIn: bigint, toDecimals: number, ): Promise<RouteQuote | null> { try { const hopsData: HopQuoteData[] = []; let currentAmount = amountIn; // Simulate each hop in sequence for (const hop of route.hops) { const hopQuote = await this.getHopQuote(chainId, provider, hop, currentAmount); if (!hopQuote) { return null; // Route fails if any hop fails } hopsData.push(hopQuote); currentAmount = hopQuote.amountOut; // Output becomes next hop's input } return { route, amountIn, amountOut: currentAmount, amountOutFormatted: formatUnits(currentAmount, toDecimals), priceImpact: this.estimatePriceImpact(route.totalHops, amountIn), estimatedGas: route.estimatedGas, hopsData, }; } catch (error) { return null; } } /** * Get quote for a single hop (V2 or V3) */ private async getHopQuote( chainId: number, provider: PublicClient, hop: RouteHop, amountIn: bigint, ): Promise<HopQuoteData | null> { const dexInfo = DEX_INFO[hop.dexId]; const dexContracts = DEX_CONTRACTS[chainId]?.[hop.dexId]; if (dexInfo.type === 'amm_v3') { return await this.getV3HopQuote(provider, dexContracts.quoter!, hop, amountIn); } else if (dexInfo.type === 'amm_v2') { return await this.getV2HopQuote(provider, dexContracts.router, hop, amountIn); } return null; } } ``` The hop-by-hop simulation ensures accurate output calculations for complex multi-hop routes, where each intermediate swap's output becomes the next swap's input ## DEX Quote Fetching Each DEX requires different query methods. The service abstracts these differences: ```typescript:src/quote/dex-quote.service.ts @Injectable() export class DexQuoteService { private readonly providers = new Map<number, PublicClient>(); async fetchAllQuotes( chainId: number, fromToken: string, toToken: string, amount: string, path?: OptimalPath ): Promise<QuoteResults> { const dexes = this.getAvailableDexes(chainId); const quotePromises = dexes.map(async dex => { try { return await this.getQuoteFromDex(dex, chainId, fromToken, toToken, amount); } catch (error) { // DEX might not support this pair this.logger.debug(`${dex.name} quote failed: ${error.message}`); return null; } }); const results = await Promise.allSettled(quotePromises); const quotes = results .filter((r): r is PromiseFulfilledResult<DexQuoteResult> => r.status === 'fulfilled' && r.value !== null ) .map(r => r.value) .sort((a, b) => BigInt(b.amountOut) - BigInt(a.amountOut) > 0n ? 1 : -1); return { quotes, bestOutput: quotes[0] ? BigInt(quotes[0].amountOut) : 0n, }; } private async getQuoteFromDex( dex: DexConfig, chainId: number, fromToken: string, toToken: string, amount: string ): Promise<DexQuoteResult> { switch (dex.type) { case 'uniswap-v3': return this.getUniswapV3Quote(chainId, fromToken, toToken, amount, dex); case 'uniswap-v2': return this.getUniswapV2Quote(chainId, fromToken, toToken, amount, dex); default: throw new Error(`Unsupported DEX type: ${dex.type}`); } } } ``` ### Uniswap V3 Quotes Uniswap V3 has multiple fee tiers. We query all and pick the best: ```typescript:src/quote/dex-quote.service.ts async getUniswapV3Quote( chainId: number, fromToken: string, toToken: string, amount: string, config: DexConfig ): Promise<DexQuoteResult> { const provider = this.providers.get(chainId); const quoter = new Contract(config.quoterAddress, QUOTER_V2_ABI, provider); const feeTiers = [100, 500, 3000, 10000]; // 0.01%, 0.05%, 0.3%, 1% let bestQuote: { amountOut: bigint; feeTier: number } | null = null; for (const feeTier of feeTiers) { try { const result = await quoter.callStatic.quoteExactInputSingle({ tokenIn: fromToken, tokenOut: toToken, fee: feeTier, amountIn: parseUnits(amount, await this.getDecimals(fromToken)), sqrtPriceLimitX96: 0, }); if (!bestQuote || result.amountOut > bestQuote.amountOut) { bestQuote = { amountOut: result.amountOut, feeTier }; } } catch { // Pool doesn't exist for this fee tier continue; } } if (!bestQuote) { throw new Error('No Uniswap V3 pool found'); } return { dexId: 'uniswap-v3', dexName: 'Uniswap V3', amountOut: bestQuote.amountOut.toString(), path: this.encodeUniswapV3Path(fromToken, toToken, bestQuote.feeTier), extraData: { feeTier: bestQuote.feeTier }, }; } private encodeUniswapV3Path( tokenIn: string, tokenOut: string, feeTier: number ): string { // Uniswap V3 path encoding: token + fee + token + fee + ... return encodePacked( ['address', 'uint24', 'address'], [tokenIn, feeTier, tokenOut] ); } ``` ## Multi-Hop Path Finding ![Transaction route visualization](/images/menu/void-dex/transaction-route.jpg) Sometimes the best rate requires intermediate tokens. The pathfinder discovers these: ```typescript:src/quote/pathfinder.service.ts @Injectable() export class PathfinderService { // Common bridge tokens for multi-hop private readonly bridgeTokens: Record<number, string[]> = { 1: ['WETH', 'USDC', 'USDT', 'DAI', 'WBTC'], // Ethereum 137: ['WMATIC', 'USDC', 'USDT', 'WETH'], // Polygon 42161: ['WETH', 'USDC', 'USDT', 'ARB'], // Arbitrum }; async findPaths( chainId: number, fromToken: string, toToken: string ): Promise<TokenPath[]> { const bridges = this.bridgeTokens[chainId] || []; const paths: TokenPath[] = []; for (const bridge of bridges) { const bridgeAddress = this.resolveToken(chainId, bridge); // Skip if bridge is one of the swap tokens if ( bridgeAddress.toLowerCase() === fromToken.toLowerCase() || bridgeAddress.toLowerCase() === toToken.toLowerCase() ) { continue; } // Check if path exists const hasFirstHop = await this.poolExists(chainId, fromToken, bridgeAddress); const hasSecondHop = await this.poolExists(chainId, bridgeAddress, toToken); if (hasFirstHop && hasSecondHop) { paths.push({ tokens: [fromToken, bridgeAddress, toToken], description: `${this.getSymbol(fromToken)} → ${bridge} → ${this.getSymbol(toToken)}`, }); } } return paths; } private async poolExists( chainId: number, tokenA: string, tokenB: string ): Promise<boolean> { // Check Uniswap V3 factory const factory = this.getUniswapFactory(chainId); for (const fee of [500, 3000, 10000]) { const pool = await factory.getPool(tokenA, tokenB, fee); if (pool !== ethers.constants.AddressZero) { return true; } } return false; } } ``` ## Route Optimization With all quotes collected, the optimizer finds the best execution strategy: ```typescript:src/quote/route-optimizer.service.ts @Injectable() export class RouteOptimizerService { optimize( quotes: DexQuoteResult[], options: OptimizeOptions ): OptimalRoute { // Strategy 1: Best single DEX const bestSingle = this.findBestSingle(quotes); // Strategy 2: Split across top DEXes const bestSplit = this.findBestSplit(quotes, options); // Strategy 3: Best multi-hop (already in quotes) const bestMultiHop = this.findBestMultiHop(quotes); // Compare and return best const candidates = [bestSingle, bestSplit, bestMultiHop].filter(Boolean); return this.selectBest(candidates); } private findBestSingle(quotes: DexQuoteResult[]): OptimalRoute | null { const directQuotes = quotes.filter(q => !q.isMultiHop); if (directQuotes.length === 0) return null; const best = directQuotes[0]; // Already sorted by output return { type: 'single', steps: [{ dexId: best.dexId, percentage: 10000, minAmountOut: best.amountOut, dexData: best.extraData, }], totalOutput: BigInt(best.amountOut), isSplit: false, }; } private findBestSplit( quotes: DexQuoteResult[], options: OptimizeOptions ): OptimalRoute | null { const directQuotes = quotes.filter(q => !q.isMultiHop); if (directQuotes.length < 2) return null; // Try different split ratios const splitRatios = this.generateSplitRatios(options.maxSplits); let bestSplit: OptimalRoute | null = null; for (const ratio of splitRatios) { if (ratio.length > directQuotes.length) continue; // Calculate output for this split let totalOutput = 0n; const steps = []; for (let i = 0; i < ratio.length; i++) { const quote = directQuotes[i]; const percentage = ratio[i]; // Estimate output for partial amount // This is simplified; real implementation would re-query with partial amounts const partialOutput = (BigInt(quote.amountOut) * BigInt(percentage)) / 10000n; totalOutput += partialOutput; steps.push({ dexId: quote.dexId, percentage, minAmountOut: partialOutput.toString(), dexData: quote.extraData, }); } if (!bestSplit || totalOutput > bestSplit.totalOutput) { bestSplit = { type: 'split', steps, totalOutput, isSplit: true, }; } } return bestSplit; } private generateSplitRatios(maxSplits: number): number[][] { // Common split patterns return [ [5000, 5000], // 50-50 [6000, 4000], // 60-40 [7000, 3000], // 70-30 [5000, 3000, 2000], // 50-30-20 [4000, 3000, 3000], // 40-30-30 ].filter(r => r.length <= maxSplits); } private findBestMultiHop(quotes: DexQuoteResult[]): OptimalRoute | null { const multiHopQuotes = quotes.filter(q => q.isMultiHop); if (multiHopQuotes.length === 0) return null; const best = multiHopQuotes[0]; return { type: 'multi-hop', steps: best.hops.map((hop, i) => ({ dexId: hop.dexId, tokenOut: hop.tokenOut, minAmountOut: hop.expectedOutput, dexData: hop.extraData, })), totalOutput: BigInt(best.amountOut), isSplit: false, isSequential: true, }; } private selectBest(candidates: OptimalRoute[]): OptimalRoute { // Simple: pick highest output // Could factor in gas costs, execution complexity return candidates.reduce((best, current) => current.totalOutput > best.totalOutput ? current : best ); } } ``` ## Fee Calculation All fees in VoidDex are denominated in WETH. The fee calculator handles two components: the protocol fee and the broadcaster fee. ```typescript:src/quote/fee-calculator.service.ts @Injectable() export class FeeCalculatorService { private readonly VOIDDEX_FEE_BPS = 5; // 0.05% of input amount private readonly BROADCASTER_PROFIT_MARGIN = 15; // 15% profit margin on gas constructor( private readonly priceService: PriceService, private readonly gasService: GasService, ) {} async calculate(inputAmount: string, chainId: number): Promise<FeeBreakdown> { // Protocol fee: 0.05% of INPUT amount (not output) const inputBigInt = BigInt(inputAmount); const voidDexFee = (inputBigInt * BigInt(this.VOIDDEX_FEE_BPS)) / 10000n; // Broadcaster fee: estimated gas cost + 15% profit margin const estimatedGas = await this.gasService.estimateSwapGas(chainId); const gasPrice = await this.gasService.getGasPrice(chainId); const gasCostWei = estimatedGas * gasPrice; const broadcasterFee = (gasCostWei * BigInt(100 + this.BROADCASTER_PROFIT_MARGIN)) / 100n; // Convert protocol fee to WETH if input token is not WETH const voidDexFeeWeth = await this.priceService.convertToWeth( voidDexFee, inputAmount, chainId ); const totalFeeWeth = voidDexFeeWeth + broadcasterFee; return { broadcasterFee: broadcasterFee.toString(), voidDexFee: voidDexFeeWeth.toString(), totalFeeWeth: totalFeeWeth.toString(), voidDexFeeBps: this.VOIDDEX_FEE_BPS, }; } } ``` Key points about the fee structure: - **VoidDex Protocol Fee**: 0.05% (5 basis points) calculated on the INPUT amount, not the output - **Broadcaster Fee**: Covers gas costs plus a 15% profit margin for the transaction broadcaster - **All fees are in WETH**: Regardless of the input/output tokens, fees are always denominated in WETH ## Price Impact Calculation Price impact is estimated using heuristics based on trade size in USD. The `RouteOptimizerService` uses a tiered linear interpolation approach: ```typescript:src/modules/quote/services/route-optimizer.service.ts /** * Estimate price impact based on trade size */ estimatePriceImpact(amountUsd: number): number { if (amountUsd < 1000) return 0.1; if (amountUsd < 10000) return 0.2 + (amountUsd - 1000) * 0.00001; if (amountUsd < 100000) return 0.3 + (amountUsd - 10000) * 0.000005; if (amountUsd < 1000000) return 0.8 + (amountUsd - 100000) * 0.000002; return 3; // Cap at 3% } ``` This provides graduated estimates: - **Under $1,000**: 0.1% (minimal impact) - **$1,000 - $10,000**: 0.2% - 0.29% (linear interpolation) - **$10,000 - $100,000**: 0.3% - 0.75% (slower growth) - **$100,000 - $1,000,000**: 0.8% - 2.6% (larger trades face more impact) - **Over $1,000,000**: Capped at 3% For multi-hop routes, the `RouteQuoteService` has a separate heuristic that factors in hop count: ```typescript:src/modules/quote/services/route-quote.service.ts /** * Estimate price impact based on hops and amount */ private estimatePriceImpact(hops: number, amountIn: bigint): number { // Base impact per hop const baseImpact = 0.3; // Additional impact for larger amounts (simplified) const amountFactor = Number(amountIn) / 1e18; const amountImpact = Math.min(amountFactor * 0.001, 1); return baseImpact * hops + amountImpact; } ``` This estimates 0.3% base impact per hop, plus an amount-based factor that scales with trade size (capped at 1% additional). ## Response Formatting ![Swap details showing quote response](/images/menu/void-dex/swap-details.jpg) The final quote response provides everything the frontend needs: ```typescript:src/quote/types.ts interface FeeBreakdown { broadcasterFee: string; // Gas cost + 15% margin (in WETH) voidDexFee: string; // 0.05% of input (in WETH) totalFeeWeth: string; // Sum of all fees (in WETH) voidDexFeeBps: number; // Fee rate in basis points (5) } interface QuoteResponse { fromToken: string; toToken: string; fromAmount: string; toAmount: string; route: { steps: RouteStep[]; totalSteps: number; isSplit: boolean; isSequential?: boolean; }; fees: FeeBreakdown; meta: { priceImpact: string; exchangeRate: string; minReceived: string; expiresAt: number; // Unix timestamp }; } // Example response { "fromToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "toToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "fromAmount": "1000000000000000000", "toAmount": "3245670000", "route": { "steps": [ { "dexId": "uniswap-v3", "percentage": 10000, "minAmountOut": "3245670000", "dexData": { "feeTier": 500 } } ], "totalSteps": 1, "isSplit": false }, "fees": { "broadcasterFee": "2300000000000000", "voidDexFee": "500000000000000", "totalFeeWeth": "2800000000000000", "voidDexFeeBps": 5 }, "meta": { "priceImpact": "0.05", "exchangeRate": "3245.67", "minReceived": "3229.45", "expiresAt": 1705430400000 } } ``` The example response shows a single-DEX route using Uniswap V3. The fees are denominated in WETH (wei units), with the broadcaster fee covering gas costs plus a 15% profit margin. The frontend uses this to display the route visualization, show the user what they'll receive, and encode the on-chain transaction.