pzza.works

DEX Aggregation in VoidDex: Building an Optimal Route Finder

Berke (pzzaworks)Berke (pzzaworks)

January 5th, 2026

Finding the best swap rate takes more than checking one DEX. VoidDex pulls quotes from multiple protocols, evaluates split routes, considers multi-hop paths, and works out the best way to execute the trade. This post walks through the backend that does it.

Quote Service Architecture

The backend is NestJS, and the quote logic is split into small services that each do one job:

TypeScript
@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 {}

Routing follows a dual-flow approach. The primary flow runs the pathfinder to discover multi-hop routes, and if that comes up empty, it falls back to direct DEX quotes for simple pairs.

Dual-Flow Quote Architecture

Both tiers live in QuoteService.getQuote:

TypeScript
@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 { ... }; } }

The two flows cover for each other. The pathfinder can find a route like WBTC -> WETH -> USDC when no direct pool exists, which a plain quote fetch would never see. The direct DEX quotes still work for simple pairs when the liquidity graph is incomplete, so a gap in the graph does not mean no quote. And both flows can split the trade across multiple routes when that executes better.

RouteQuoteService: The Route Pricing Engine

Discovering a route is only half the job; you also need an accurate price for it. That is what RouteQuoteService does, by simulating each hop in sequence:

TypeScript
@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; } }

Simulating hop by hop is what keeps multi-hop quotes honest. Each intermediate swap's output becomes the next swap's input, so the final number reflects the whole chain, and a single failed hop drops the route entirely.

DEX Quote Fetching

Every DEX wants to be queried a little differently. DexQuoteService hides those differences behind one call and asks all of them in parallel:

TypeScript
@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 can have a pool for the same pair at several fee tiers. The service queries all four and keeps whichever returns the most:

TypeScript
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

Sometimes the best rate goes through an intermediate token. The pathfinder looks for these by trying a short list of common bridge tokens per chain and keeping a bridge only when a pool exists on both sides of it:

TypeScript
@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

Once the quotes are in, the optimizer builds three candidates, the best single DEX, the best split across the top DEXes, and the best multi-hop route, and keeps whichever produces the most output:

TypeScript
@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

Every fee in VoidDex is denominated in WETH. The fee calculator adds up two components, the protocol fee and the broadcaster fee.

TypeScript
@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, }; } }

The protocol fee is 0.05%, or 5 basis points, and it is calculated on the input amount, not the output. The broadcaster fee covers the gas cost plus a 15% profit margin for whoever broadcasts the transaction. Whatever tokens go in and come out, both fees are expressed in WETH, which is why the protocol fee gets converted when the input token is something else.

Price Impact Calculation

Price impact is estimated with heuristics based on trade size in USD. RouteOptimizerService uses tiered linear interpolation:

TypeScript
/** * 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% }

The result is a graduated estimate. Anything under $1,000 gets a flat 0.1%, which is minimal impact. From $1,000 to $10,000 it rises linearly from 0.2% to 0.29%. From $10,000 to $100,000 it grows more slowly per dollar, from 0.3% to 0.75%. From $100,000 to $1,000,000 larger trades face more impact, from 0.8% to 2.6%. Above $1,000,000 it is capped at 3%.

Multi-hop routes get a separate heuristic in RouteQuoteService that factors in hop count:

TypeScript
/** * 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; }

That is 0.3% of base impact per hop, plus an amount-based factor that scales with trade size and adds at most 1%.

Response Formatting

The final quote response carries everything the frontend needs:

TypeScript
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 is a single-DEX route through Uniswap V3. The fees are in WETH, in wei units, and the broadcaster fee is the gas cost plus the 15% margin.

The frontend uses this one object three ways: to draw the route visualization, to show the user what they will receive, and to encode the on-chain transaction.

Keep reading