VoidDex Router: Designing a Multi-DEX Swap Router with Split Routing
January 4, 2026
Deep dive into VoidDex's smart contract architecture, covering the router design, DEX adapter system, and split routing for optimal execution.
ListenReady
0:00
0:00
6:44
The VoidDexRouter is the on-chain heart of the system. It needs to route swaps through multiple DEXes and handle split execution across protocols for optimal trade execution. This article covers the design decisions and implementation.
VoidDex swap execution
Router Architecture
Building a DEX aggregator router requires careful consideration of security, upgradability, and gas efficiency. The VoidDexRouter inherits from three OpenZeppelin contracts that provide battle-tested implementations of common security patterns. AccessControl enables role-based permissions for administrative functions, Pausable allows emergency stops when vulnerabilities are discovered, and ReentrancyGuard prevents the classic reentrancy attack vector that has drained millions from DeFi protocols. The router also uses SafeERC20 to handle the many non-standard ERC20 implementations in the wild, tokens that don't return booleans or revert on failure:
Reserved for future use (no functions currently assigned)
GUARDIAN
Pause/unpause in emergencies
DEX Adapter Pattern
Different DEX protocols have wildly different interfaces. Uniswap V3 uses tick-based concentrated liquidity with fee tiers, Curve uses StableSwap pools with dynamic fees, and Balancer uses weighted pools with custom math. Rather than hardcoding each protocol's logic into the router, we use the adapter pattern to abstract these differences behind a common interface. Each adapter knows how to talk to its specific DEX and translates between VoidDex's unified interface and the protocol's native format:
This abstraction lets the router interact uniformly with different protocols. The adapter translates between VoidDex's interface and the DEX's native format.
Uniswap V3 Adapter
Uniswap V3 introduced concentrated liquidity, where liquidity providers can specify price ranges for their capital. This means different token pairs have pools with different fee tiers: 0.05% for correlated pairs, 0.3% for most pairs, and 1% for exotic tokens. When swapping, you need to specify which fee tier's pool to use, and the best tier varies by pair and trade size. The adapter tries all fee tiers when quoting and passes the optimal one in the swap data:
Curve specializes in stablecoin and similar-asset swaps using the StableSwap invariant, which provides much lower slippage than constant-product AMMs for correlated assets. Unlike Uniswap where each pair has one pool, Curve pools can contain 2-4 tokens, and you swap by specifying token indices within the pool. The adapter needs to find the right pool through Curve's registry and determine which index each token occupies. The exchange call returns the output amount directly:
Solidity
contractCurveAdapteris IDexAdapter {// Pool registry for finding the right pool ICurveRegistry public immutable registry;functionswap(address tokenIn,address tokenOut,uint256 amountIn,uint256 minAmountOut,bytescalldata data
)external override returns(uint256 amountOut){// Data contains pool address and indices(address pool,int128 i,int128 j)= abi.decode( data,(address,int128,int128));// Use forceApprove for USDT compatibilityIERC20(tokenIn).forceApprove(pool, amountIn);// Curve exchange returns the output amount directly amountOut =ICurvePool(pool).exchange(i, j, amountIn, minAmountOut);// Transfer output to callerIERC20(tokenOut).safeTransfer(msg.sender, amountOut);}}
Single Swap Execution
Most swaps take the straightforward path: pick the best DEX and execute. The swap function handles the complete flow in a single transaction, transferring tokens from the user, collecting the protocol fee, delegating to the appropriate adapter, verifying the output meets the minimum, and returning tokens to the user. The nonReentrant modifier prevents callbacks from malicious tokens, and whenNotPaused allows emergency stops:
When you swap a large amount through a single DEX, you move the price against yourself. This is called price impact, and it grows quadratically with trade size due to the constant-product formula most AMMs use. By splitting a trade across multiple DEXes, each portion experiences less price impact, and the combined output is often better than routing everything through the single best DEX. The router accepts an array of route steps, each specifying a DEX and percentage of the total input to route through it:
Solidity
structRouteStep{bytes32 dexId;uint256 percentage;// 10000 = 100%uint256 minAmountOut;bytes dexData;}functionswapMultiRoute(address tokenIn,address tokenOut,uint256 amountIn,uint256 totalMinAmountOut, RouteStep[]calldata routes
)external nonReentrant whenNotPaused returns(uint256 totalAmountOut){// Validate percentages sum to 100%uint256 totalPercentage;for(uint256 i =0; i < routes.length; i++){ totalPercentage += routes[i].percentage;}if(totalPercentage != PERCENTAGE_BASE)revertInvalidPercentage();// Transfer and collect feeIERC20(tokenIn).safeTransferFrom(msg.sender,address(this), amountIn);uint256 netAmount =_collectFee(tokenIn, amountIn);// Execute each routefor(uint256 i =0; i < routes.length; i++){ RouteStep calldata route = routes[i];address adapter = dexAdapters[route.dexId];if(adapter ==address(0))revertInvalidAdapter();// Calculate amount for this routeuint256 routeAmount =(netAmount * route.percentage)/ PERCENTAGE_BASE;// Approve and swap - use forceApprove for USDT compatibilityIERC20(tokenIn).forceApprove(adapter, routeAmount);uint256 routeOutput =IDexAdapter(adapter).swap( tokenIn, tokenOut, routeAmount, route.minAmountOut, route.dexData
); totalAmountOut += routeOutput;}// Verify total outputif(totalAmountOut < totalMinAmountOut)revertInsufficientOutput();// Transfer combined outputIERC20(tokenOut).safeTransfer(msg.sender, totalAmountOut);emitMultiRouteSwapExecuted(_generateOperationId(), msg.sender, tokenIn, tokenOut, amountIn, totalAmountOut, routes.length
);}
Example: Swapping 100 ETH for USDC might split 60% through Uniswap V3 (best for large amounts) and 40% through Curve (better for the remaining portion).
Sequential Multi-Hop Routing
Direct liquidity doesn't always exist between token pairs, or when it does, it might be thin. Sometimes routing through an intermediate token with deep liquidity on both sides yields a better rate. For example, swapping a small-cap token to USDC might be better as TOKEN -> WETH -> USDC, where both hops have liquid pools. The sequential swap function chains these hops together, using the output of each step as input for the next:
Solidity
structSequentialStep{bytes32 dexId;address tokenOut;uint256 minAmountOut;bytes dexData;}functionswapSequential(address tokenIn,uint256 amountIn,uint256 finalMinAmountOut, SequentialStep[]calldata hops
)external nonReentrant whenNotPaused returns(uint256 finalAmountOut){if(hops.length ==0)revertNoRoutes();// Transfer initial tokensIERC20(tokenIn).safeTransferFrom(msg.sender,address(this), amountIn);uint256 currentAmount =_collectFee(tokenIn, amountIn);address currentToken = tokenIn;// Execute each hopfor(uint256 i =0; i < hops.length; i++){ SequentialStep calldata hop = hops[i];address adapter = dexAdapters[hop.dexId];if(adapter ==address(0))revertInvalidAdapter();// Approve and swap - use forceApprove for USDT compatibilityIERC20(currentToken).forceApprove(adapter, currentAmount); currentAmount =IDexAdapter(adapter).swap( currentToken, hop.tokenOut, currentAmount, hop.minAmountOut, hop.dexData
);// Update for next hop currentToken = hop.tokenOut;} finalAmountOut = currentAmount;if(finalAmountOut < finalMinAmountOut)revertInsufficientOutput();// Transfer final outputIERC20(currentToken).safeTransfer(msg.sender, finalAmountOut);emitSequentialSwapExecuted(_generateOperationId(), msg.sender, tokenIn, currentToken, amountIn, finalAmountOut, hops.length
);}
Fee Collection
Swap settings showing fee configuration
The protocol takes a small fee on each swap, defaulting to 0.05% (5 basis points). This fee is collected from the input token before the swap executes, ensuring users always know the exact amount that will be swapped. Certain addresses can be exempted from fees, which is useful for integrators or promotional periods. The fee is capped at 1% in the contract to prevent accidental or malicious fee increases:
DEX protocols work with ERC20 tokens, not native ETH. To support ETH swaps, the router handles wrapping (ETH -> WETH) and unwrapping (WETH -> ETH) automatically through internal helper functions. When users send ETH to swap for tokens, the router first deposits it into the WETH contract via _handleInputToken, then proceeds with the swap. When swapping to ETH, the router receives WETH from the swap, and _handleOutputToken unwraps it and sends native ETH to the user. The receive function allows the contract to accept ETH from the WETH unwrap operation:
Solidity
function_handleInputToken(address tokenIn,uint256 amountIn,uint256 fee
)internalreturns(address actualTokenIn){if(tokenIn ==address(0)){// ETH inputif(msg.value < amountIn)revertInsufficientValue(); weth.deposit{value: amountIn}(); actualTokenIn =address(weth);// Transfer feeif(fee >0&& feeRecipient !=address(0)){IERC20(address(weth)).safeTransfer(feeRecipient, fee);}}else{// ERC20 inputIERC20(tokenIn).safeTransferFrom(msg.sender,address(this), amountIn); actualTokenIn = tokenIn;// Transfer feeif(fee >0&& feeRecipient !=address(0)){IERC20(tokenIn).safeTransfer(feeRecipient, fee);}}}function_handleOutputToken(address tokenOut,uint256 amountOut,address recipient
)internal{if(tokenOut ==address(0)){// ETH output - unwrap WETH and send ETH weth.withdraw(amountOut);(bool sent,)= recipient.call{value: amountOut}("");if(!sent)revertTransferFailed();}else{// ERC20 outputIERC20(tokenOut).safeTransfer(recipient, amountOut);}}receive()externalpayable{// Accept ETH from WETH unwrap}
Security Measures
Reentrancy Protection: All external-facing functions use nonReentrant.
Pausability: Guardian role can pause all operations in emergencies: