DEX Aggregation in VoidDex: Building an Optimal Route Finder
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.
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],})exportclassQuoteModule{}
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.
@Injectable()exportclassQuoteService{constructor(privatereadonly dexQuoteService: DexQuoteService,privatereadonly routeOptimizer: RouteOptimizerService,privatereadonly feeCalculator: FeeCalculatorService,privatereadonly priceService: PriceService,privatereadonly pathfinderService: PathfinderService,privatereadonly routeQuoteService: RouteQuoteService,){}asyncgetQuote(params: QuoteParams):Promise<QuoteResponse>{const{ chainId, fromToken, toToken, amount, slippage =0.5}= params;// Resolve token addresses and get providerconst 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 pathfinderconst discoveredRoutes =awaitthis.pathfinderService.findRoutes( chainId, fromTokenAddress, toTokenAddress,);// 2. If routes found, get quotes and optimizeif(discoveredRoutes.length >0){const routeQuotes =awaitthis.routeQuoteService.getQuotesForRoutes( chainId, provider, discoveredRoutes, amountIn, toDecimals,);if(routeQuotes.length >0){// Try route-level split optimizationconst splitResult =this.optimizeRouteSplit(routeQuotes, amountIn,...);if(splitResult){returnthis.buildSplitRouteResponse(...);}}}// === FALLBACK: Direct DEX Quotes ===const dexQuotes =awaitthis.dexQuoteService.fetchAllQuotes( chainId, provider, fromTokenAddress, toTokenAddress, amountIn,...);// Find optimal route (single or split)const optimalRoute =this.routeOptimizer.findOptimalRoute(dexQuotes,...);// Calculate fees and return responseconst fees =awaitthis.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.
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()exportclassRouteQuoteService{/**
* Get quotes for all discovered routes
*/asyncgetQuotesForRoutes( 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 =awaitPromise.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
*/privateasyncgetQuoteForRoute( chainId:number, provider: PublicClient, route: DiscoveredRoute, amountIn: bigint, toDecimals:number,):Promise<RouteQuote |null>{try{const hopsData: HopQuoteData[]=[];let currentAmount = amountIn;// Simulate each hop in sequencefor(const hop of route.hops){const hopQuote =awaitthis.getHopQuote(chainId, provider, hop, currentAmount);if(!hopQuote){returnnull;// 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){returnnull;}}/**
* Get quote for a single hop (V2 or V3)
*/privateasyncgetHopQuote( 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'){returnawaitthis.getV3HopQuote(provider, dexContracts.quoter!, hop, amountIn);}elseif(dexInfo.type ==='amm_v2'){returnawaitthis.getV2HopQuote(provider, dexContracts.router, hop, amountIn);}returnnull;}}
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.
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()exportclassPathfinderService{// Common bridge tokens for multi-hopprivatereadonly bridgeTokens: Record<number,string[]>={1:['WETH','USDC','USDT','DAI','WBTC'],// Ethereum137:['WMATIC','USDC','USDT','WETH'],// Polygon42161:['WETH','USDC','USDT','ARB'],// Arbitrum};asyncfindPaths( 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 tokensif( bridgeAddress.toLowerCase()=== fromToken.toLowerCase()|| bridgeAddress.toLowerCase()=== toToken.toLowerCase()){continue;}// Check if path existsconst hasFirstHop =awaitthis.poolExists(chainId, fromToken, bridgeAddress);const hasSecondHop =awaitthis.poolExists(chainId, bridgeAddress, toToken);if(hasFirstHop && hasSecondHop){ paths.push({ tokens:[fromToken, bridgeAddress, toToken], description:`${this.getSymbol(fromToken)} → ${bridge} → ${this.getSymbol(toToken)}`,});}}return paths;}privateasyncpoolExists( chainId:number, tokenA:string, tokenB:string):Promise<boolean>{// Check Uniswap V3 factoryconst factory =this.getUniswapFactory(chainId);for(const fee of[500,3000,10000]){const pool =await factory.getPool(tokenA, tokenB, fee);if(pool !== ethers.constants.AddressZero){returntrue;}}returnfalse;}}
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()exportclassRouteOptimizerService{optimize( quotes: DexQuoteResult[], options: OptimizeOptions
): OptimalRoute {// Strategy 1: Best single DEXconst bestSingle =this.findBestSingle(quotes);// Strategy 2: Split across top DEXesconst bestSplit =this.findBestSplit(quotes, options);// Strategy 3: Best multi-hop (already in quotes)const bestMultiHop =this.findBestMultiHop(quotes);// Compare and return bestconst candidates =[bestSingle, bestSplit, bestMultiHop].filter(Boolean);returnthis.selectBest(candidates);}privatefindBestSingle(quotes: DexQuoteResult[]): OptimalRoute |null{const directQuotes = quotes.filter(q =>!q.isMultiHop);if(directQuotes.length ===0)returnnull;const best = directQuotes[0];// Already sorted by outputreturn{ type:'single', steps:[{ dexId: best.dexId, percentage:10000, minAmountOut: best.amountOut, dexData: best.extraData,}], totalOutput:BigInt(best.amountOut), isSplit:false,};}privatefindBestSplit( quotes: DexQuoteResult[], options: OptimizeOptions
): OptimalRoute |null{const directQuotes = quotes.filter(q =>!q.isMultiHop);if(directQuotes.length <2)returnnull;// Try different split ratiosconst 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 splitlet 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 amountsconst 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;}privategenerateSplitRatios(maxSplits:number):number[][]{// Common split patternsreturn[[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);}privatefindBestMultiHop(quotes: DexQuoteResult[]): OptimalRoute |null{const multiHopQuotes = quotes.filter(q => q.isMultiHop);if(multiHopQuotes.length ===0)returnnull;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,};}privateselectBest(candidates: OptimalRoute[]): OptimalRoute {// Simple: pick highest output// Could factor in gas costs, execution complexityreturn candidates.reduce((best, current)=> current.totalOutput > best.totalOutput ? current : best
);}}
Every fee in VoidDex is denominated in WETH. The fee calculator adds up two components, the protocol fee and the broadcaster fee.
TypeScript
@Injectable()exportclassFeeCalculatorService{privatereadonlyVOIDDEX_FEE_BPS=5;// 0.05% of input amountprivatereadonlyBROADCASTER_PROFIT_MARGIN=15;// 15% profit margin on gasconstructor(privatereadonly priceService: PriceService,privatereadonly gasService: GasService,){}asynccalculate(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 marginconst estimatedGas =awaitthis.gasService.estimateSwapGas(chainId);const gasPrice =awaitthis.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 WETHconst voidDexFeeWeth =awaitthis.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 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)return0.1;if(amountUsd <10000)return0.2+(amountUsd -1000)*0.00001;if(amountUsd <100000)return0.3+(amountUsd -10000)*0.000005;if(amountUsd <1000000)return0.8+(amountUsd -100000)*0.000002;return3;// 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
*/privateestimatePriceImpact(hops:number, amountIn: bigint):number{// Base impact per hopconst 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%.
The final quote response carries everything the frontend needs:
TypeScript
interfaceFeeBreakdown{ 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)}interfaceQuoteResponse{ 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.
How VoidDex implements private token swaps using Railgun's zero-knowledge proof system, covering the shield/unshield flow, client-side proof generation, and the Waku P2P broadcaster network.
Every swap you make on a decentralized exchange is public. VoidDex is my attempt at building a DEX aggregator that maintains the benefits of decentralized trading while adding a cryptographic privacy layer using zkSNARK proofs.