VoidDex Router: Designing a Multi-DEX Swap Router with Split Routing

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](/images/menu/void-dex/swap.jpg)

## 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:

```solidity:src/VoidDexRouter.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract VoidDexRouter is AccessControl, Pausable, ReentrancyGuard {
using SafeERC20 for IERC20;

uint256 public constant PERCENTAGE_BASE = 10000; // 100% = 10000

// Role hierarchy
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
bytes32 public constant GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE");

// DEX adapter registry
mapping(bytes32 => address) public dexAdapters;
bytes32[] public dexIds;

// Fee configuration
uint256 public feeBps = 5; // 0.05% default
address public feeRecipient;
mapping(address => bool) public feeExempt;

// WETH for native token handling
IWETH public weth;
}
```

The role system provides granular permissions:

| Role | Capabilities |
|------|-------------|
| ADMIN | Register/remove adapters, set fees, grant roles |
| OPERATOR | 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:

```solidity:src/interfaces/IDexAdapter.sol
interface IDexAdapter {
function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minAmountOut,
bytes calldata data
) external returns (uint256 amountOut);

function getQuote(
address tokenIn,
address tokenOut,
uint256 amountIn
) external returns (uint256 amountOut, bytes memory extraData);
}
```

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:

```solidity:src/adapters/UniswapV3Adapter.sol
contract UniswapV3Adapter is IDexAdapter {
ISwapRouter public immutable swapRouter;
IQuoterV2 public immutable quoter;

// Fee tier constants
uint24 public constant FEE_LOW = 500; // 0.05%
uint24 public constant FEE_MEDIUM = 3000; // 0.3%
uint24 public constant FEE_HIGH = 10000; // 1%

function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minAmountOut,
bytes calldata data
) external override returns (uint256 amountOut) {
// Data contains the fee tier
uint24 fee = abi.decode(data, (uint24));

// Use forceApprove for USDT compatibility
IERC20(tokenIn).forceApprove(address(swapRouter), amountIn);

ISwapRouter.ExactInputSingleParams memory params =
ISwapRouter.ExactInputSingleParams({
tokenIn: tokenIn,
tokenOut: tokenOut,
fee: fee,
recipient: msg.sender, // Return tokens to router
deadline: block.timestamp,
amountIn: amountIn,
amountOutMinimum: minAmountOut,
sqrtPriceLimitX96: 0
});

amountOut = swapRouter.exactInputSingle(params);
}

function getQuote(
address tokenIn,
address tokenOut,
uint256 amountIn
) external override returns (uint256 amountOut, bytes memory extraData) {
// Try each fee tier, return the best
uint24[3] memory fees = [FEE_LOW, FEE_MEDIUM, FEE_HIGH];
uint256 bestOutput = 0;
uint24 bestFee = 0;

for (uint256 i = 0; i < fees.length; i++) {
try quoter.quoteExactInputSingle(
IQuoterV2.QuoteExactInputSingleParams({
tokenIn: tokenIn,
tokenOut: tokenOut,
amountIn: amountIn,
fee: fees[i],
sqrtPriceLimitX96: 0
})
) returns (uint256 output, uint160, uint32, uint256) {
if (output > bestOutput) {
bestOutput = output;
bestFee = fees[i];
}
} catch {}
}

amountOut = bestOutput;
extraData = abi.encode(bestFee);
}
}
```

### Curve Adapter

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:src/adapters/CurveAdapter.sol
contract CurveAdapter is IDexAdapter {
// Pool registry for finding the right pool
ICurveRegistry public immutable registry;

function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minAmountOut,
bytes calldata 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 compatibility
IERC20(tokenIn).forceApprove(pool, amountIn);

// Curve exchange returns the output amount directly
amountOut = ICurvePool(pool).exchange(i, j, amountIn, minAmountOut);

// Transfer output to caller
IERC20(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:

```solidity:src/VoidDexRouter.sol
function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minAmountOut,
bytes32 dexId,
bytes calldata dexData
) external nonReentrant whenNotPaused returns (uint256 amountOut) {
// Validate adapter exists
address adapter = dexAdapters[dexId];
if (adapter == address(0)) revert InvalidAdapter();

// Calculate fee
uint256 fee = 0;
uint256 amountAfterFee = amountIn;

if (!feeExempt[msg.sender] && feeBps > 0) {
fee = (amountIn * feeBps) / 10000;
amountAfterFee = amountIn - fee;
}

// Handle input token (including ETH wrapping and fee transfer)
address actualTokenIn = _handleInputToken(tokenIn, amountIn, fee);

// Approve adapter - use forceApprove for USDT compatibility
IERC20(actualTokenIn).forceApprove(adapter, amountAfterFee);

// Execute swap through adapter
amountOut = IDexAdapter(adapter).swap(
actualTokenIn,
tokenOut,
amountAfterFee,
minAmountOut,
dexData
);

if (amountOut < minAmountOut) revert InsufficientOutput();

// Handle output token (including ETH unwrapping)
_handleOutputToken(tokenOut, amountOut, msg.sender);

emit SwapExecuted(
_generateOperationId(),
msg.sender,
tokenIn,
tokenOut,
amountIn,
amountOut,
fee,
dexId
);
}
```

## Split Routing

![Transaction route showing split routing](/images/menu/void-dex/transaction-route.jpg)

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:src/VoidDexRouter.sol
struct RouteStep {
bytes32 dexId;
uint256 percentage; // 10000 = 100%
uint256 minAmountOut;
bytes dexData;
}

function swapMultiRoute(
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) revert InvalidPercentage();

// Transfer and collect fee
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
uint256 netAmount = _collectFee(tokenIn, amountIn);

// Execute each route
for (uint256 i = 0; i < routes.length; i++) {
RouteStep calldata route = routes[i];
address adapter = dexAdapters[route.dexId];
if (adapter == address(0)) revert InvalidAdapter();

// Calculate amount for this route
uint256 routeAmount = (netAmount * route.percentage) / PERCENTAGE_BASE;

// Approve and swap - use forceApprove for USDT compatibility
IERC20(tokenIn).forceApprove(adapter, routeAmount);
uint256 routeOutput = IDexAdapter(adapter).swap(
tokenIn,
tokenOut,
routeAmount,
route.minAmountOut,
route.dexData
);

totalAmountOut += routeOutput;
}

// Verify total output
if (totalAmountOut < totalMinAmountOut) revert InsufficientOutput();

// Transfer combined output
IERC20(tokenOut).safeTransfer(msg.sender, totalAmountOut);

emit MultiRouteSwapExecuted(
_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:src/VoidDexRouter.sol
struct SequentialStep {
bytes32 dexId;
address tokenOut;
uint256 minAmountOut;
bytes dexData;
}

function swapSequential(
address tokenIn,
uint256 amountIn,
uint256 finalMinAmountOut,
SequentialStep[] calldata hops
) external nonReentrant whenNotPaused returns (uint256 finalAmountOut) {
if (hops.length == 0) revert NoRoutes();

// Transfer initial tokens
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
uint256 currentAmount = _collectFee(tokenIn, amountIn);
address currentToken = tokenIn;

// Execute each hop
for (uint256 i = 0; i < hops.length; i++) {
SequentialStep calldata hop = hops[i];
address adapter = dexAdapters[hop.dexId];
if (adapter == address(0)) revert InvalidAdapter();

// Approve and swap - use forceApprove for USDT compatibility
IERC20(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) revert InsufficientOutput();

// Transfer final output
IERC20(currentToken).safeTransfer(msg.sender, finalAmountOut);

emit SequentialSwapExecuted(
_generateOperationId(),
msg.sender,
tokenIn,
currentToken,
amountIn,
finalAmountOut,
hops.length
);
}
```

## Fee Collection

![Swap settings showing fee configuration](/images/menu/void-dex/swap-settings.jpg)

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:

```solidity:src/VoidDexRouter.sol
function _collectFee(
address token,
uint256 amount
) internal returns (uint256 netAmount) {
if (feeBps == 0 || feeExempt[msg.sender]) {
return amount;
}

uint256 feeAmount = (amount * feeBps) / PERCENTAGE_BASE;
netAmount = amount - feeAmount;

if (feeAmount > 0 && feeRecipient != address(0)) {
IERC20(token).safeTransfer(feeRecipient, feeAmount);
}
}

// Admin function for fee management (combined for atomicity)
function setFeeConfig(uint256 _feeBps, address _feeRecipient) external onlyRole(ADMIN_ROLE) {
if (_feeBps > 100) revert FeeTooHigh(); // Max 1%
feeBps = _feeBps;
feeRecipient = _feeRecipient;
emit FeeConfigUpdated(_feeBps, _feeRecipient);
}
```

## Native Token Handling

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:src/VoidDexRouter.sol
function _handleInputToken(
address tokenIn,
uint256 amountIn,
uint256 fee
) internal returns (address actualTokenIn) {
if (tokenIn == address(0)) {
// ETH input
if (msg.value < amountIn) revert InsufficientValue();
weth.deposit{value: amountIn}();
actualTokenIn = address(weth);

// Transfer fee
if (fee > 0 && feeRecipient != address(0)) {
IERC20(address(weth)).safeTransfer(feeRecipient, fee);
}
} else {
// ERC20 input
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
actualTokenIn = tokenIn;

// Transfer fee
if (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) revert TransferFailed();
} else {
// ERC20 output
IERC20(tokenOut).safeTransfer(recipient, amountOut);
}
}

receive() external payable {
// Accept ETH from WETH unwrap
}
```

## Security Measures

**Reentrancy Protection**: All external-facing functions use `nonReentrant`.

**Pausability**: Guardian role can pause all operations in emergencies:

```solidity:src/VoidDexRouter.sol
function pause() external onlyRole(GUARDIAN_ROLE) {
_pause();
}

function unpause() external onlyRole(ADMIN_ROLE) {
_unpause();
}
```

**Slippage Protection**: Minimum output amounts are enforced on-chain, not just off-chain.

**SafeERC20**: Handles tokens that don't return booleans on transfer.