Overview
MobulaRouter is the EVM smart contract that powers every swap returned by the Swap Quoting API. Most integrators sign and send the calldata that the API returns and never touch the contract directly.
This page is for the smaller set of integrators that build directly on top of the router from Solidity — market makers, MEV/arb teams, custom routing front-ends, on-chain bots. It documents the ABI, struct layout, error codes, and operational rules required to call the contract safely without reading the implementation.
If you only sign-and-send what the API returns, you do not need this page — the API encodes the calldata for you. Read Swap Quoting instead.
Two entry points
| Entry point | Use when… | Pool universe | Pausable |
|---|
executeAggregatorSwap | You already have aggregator calldata (1inch, 0x, KyberSwap, LiFi, …) and want fee + slippage enforcement on top. | Whatever the aggregator routes through. | No |
executeRoute | You build the route yourself and want explicit, factory-validated pools with no third-party code. | UniswapV2, UniswapV3, SolidlyV2, LiquidityBookV2, UniswapV4, SolidlyV3, FourMeme, PCSInfinityCL. | Yes |
Both functions are nonReentrant and payable. executeAggregatorSwap forwards calldata to a target address that must be in the owner-managed whitelist (isAllowedTarget). executeRoute validates every pool through its factory before swapping — no arbitrary code execution.
Deployed addresses
The router is a UUPS-upgradeable proxy. Always interact with the proxy address — implementation addresses change across upgrades.
| Chain | Chain ID | Router (proxy) | Explorer |
|---|
| Ethereum | 1 | 0x0005ea683517b3a4463bfd798c9850Ad2d586795 | Etherscan |
| Optimism | 10 | 0x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5Ef | Optimistic Etherscan |
| BNB Chain | 56 | 0x0005ea70fa6dbd2efd17697d2351301adb6318b2 | BscScan |
| Gnosis | 100 | 0x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19ED | GnosisScan |
| Polygon | 137 | 0x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5Ef | PolygonScan |
| Monad | 143 | 0x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19ED | Monvision |
| Mantle | 5000 | 0x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19ED | Mantle Explorer |
| MegaETH | 4326 | 0x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19ED | MegaETH Blockscout |
| Base | 8453 | 0x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5Ef | BaseScan |
| Arbitrum One | 42161 | 0x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5Ef | Arbiscan |
| Avalanche C | 43114 | 0x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5Ef | SnowTrace |
| Linea | 59144 | 0x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19ED | LineaScan |
| Blast | 81457 | 0x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19ED | BlastScan |
| Scroll | 534352 | 0x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19ED | Scrollscan |
The router proxy address differs across chains. Don’t hardcode a single address — always look it up from the table above, from getMobulaRouterAddress(chainId) in @mobula/execution-engine, or from the evm.transaction.to field of every quote response.
Solidity interface & ABI
Two artifacts are published — pick whichever your toolchain expects:
| Artifact | Hosted in docs | Hosted on GitHub Gist (raw, hot-linkable) |
|---|
IMobulaRouter.sol — drop-in Solidity interface | IMobulaRouter.sol | gist raw |
MobulaRouter.abi.json — full ABI (functions + events + errors) | MobulaRouter.abi.json | gist raw |
Gist URL (browse both files): gist.github.com/NBMSacha/97628523b02dc9d4f05c0b79adf623a3.
To wire the interface into a Foundry / Hardhat project:
import { IMobulaRouter } from "./IMobulaRouter.sol";
To load the ABI in a TypeScript front-end with viem / ethers:
import abi from "./MobulaRouter.abi.json" assert { type: "json" };
// or fetch from the gist:
// const abi = await fetch("https://gist.githubusercontent.com/NBMSacha/97628523b02dc9d4f05c0b79adf623a3/raw/MobulaRouter.abi.json").then(r => r.json());
Highlights — full file in the linked artifacts:
interface IMobulaRouter {
enum ProtocolType {
UniswapV2, UniswapV3, SolidlyV2, LiquidityBookV2,
UniswapV4, SolidlyV3, FourMeme, PCSInfinityCL
}
struct AggregatorSwapParams {
address target;
address sellToken;
address buyToken;
uint256 sellAmount;
uint256 minBuyAmountToUser;
address beneficiary;
uint256 deadline;
uint16 feeBps;
address wethOverride;
bytes data;
}
struct RouteStep {
address pool;
address tokenIn;
address tokenOut;
ProtocolType protocol;
uint24 v3Fee;
int24 tickSpacing;
address hooks;
}
struct SecureRoute {
RouteStep[] steps;
uint256 minBuyAmountToUser;
address beneficiary;
uint256 deadline;
uint16 feeBps;
address wethOverride;
bool unwrapOutput;
}
function executeAggregatorSwap(AggregatorSwapParams calldata params) external payable;
function executeRoute(address sellToken, uint256 sellAmount, SecureRoute calldata route) external payable;
}
The runtime ABI JSON is committed in the contract artifacts at contracts/evm/out/MobulaRouter.sol/MobulaRouter.json after running forge build. Copy the abi field directly into your client.
Approvals and msg.value
There is exactly one rule for every entry point:
| Sell side | sellToken value | msg.value |
|---|
| Native (ETH/BNB/MATIC/AVAX) | address(0) | MUST equal sellAmount |
| ERC-20 | token address | MUST be 0 |
ERC-20 sellers MUST approve(router, sellAmount) (or type(uint256).max) on sellToken before calling. The router uses a SafeERC20-style transferFrom (_pullTokenFromUser) that handles non-standard tokens (USDT, etc.) and supports fee-on-transfer (the actual received amount is used downstream).
Mismatches revert with IncorrectETHAmount(). Insufficient approvals revert with TransferFromFailed().
Pre-approval pattern. Approve once for type(uint256).max and reuse — the router itself never persists approvals to external targets without zeroing first (see _ensureAllowance), so a stale aggregator approval can’t be exploited.
executeAggregatorSwap — call an aggregator with safety rails
function executeAggregatorSwap(AggregatorSwapParams calldata params) external payable;
What the contract does, in order:
block.timestamp <= deadline, else DeadlineExpired().
isAllowedTarget[target] == true, else TargetNotAllowed().
beneficiary != 0, else ZeroBeneficiary().
- Pull funds (
msg.value for native, transferFrom + approve(target) for ERC-20).
- Snapshot
buyToken balance, target.call{value: msg.value}(data).
- If the call reverts, bubble the message via
CallFailed(string).
- Compute output delta. If 0 →
NoOutputReceived().
- Apply
feeBps (≤ 500), enforce output - fee >= minBuyAmountToUser, else NetOutputTooLow().
- Send fee to
feeAddress (owner-controlled), remainder to beneficiary.
AggregatorSwapParams reference
| Field | Type | Notes |
|---|
target | address | Aggregator router. Must be in isAllowedTarget. |
sellToken | address | address(0) for native. |
buyToken | address | address(0) for native. Output is unwrapped from WETH if the aggregator returned WETH. |
sellAmount | uint256 | Amount of sellToken to spend. |
minBuyAmountToUser | uint256 | Post-fee minimum. See Slippage semantics. |
beneficiary | address | Receives the net output. Cannot be zero. |
deadline | uint256 | Unix seconds. |
feeBps | uint16 | ≤ MAX_FEE_BPS = 500 (5%). |
wethOverride | address | Optional — defaults to the stored WETH if zero. |
data | bytes | Calldata forwarded to target. |
executeRoute — direct, factory-validated swaps
function executeRoute(
address sellToken,
uint256 sellAmount,
SecureRoute calldata route
) external payable;
Each RouteStep references a pool. Before swapping, the router:
- Resolves the pool’s factory (via
factory() for V2/V3/Solidly, getFactory() for LB, or factoryRegistry[step.pool] for V4 / PCSInfinityCL / FourMeme).
- Confirms the factory is registered with the matching
ProtocolType.
- Confirms the pool’s tokens match
tokenIn / tokenOut.
- Confirms the factory advertises this exact pool for the (tokenIn, tokenOut, fee/stable/tickSpacing) tuple.
Any mismatch reverts with PoolNotFromFactory(), FactoryNotRegistered(), InvalidProtocolType() or PoolTokenMismatch(). Steps must chain — route.steps[i].tokenOut == route.steps[i+1].tokenIn, else StepChainBroken().
RouteStep reference
| Field | Type | Notes |
|---|
pool | address | Pool / TokenManager / PoolManager. |
tokenIn | address | Input token. |
tokenOut | address | Output token. |
protocol | ProtocolType | See enum below. |
v3Fee | uint24 | Multi-purpose: V3 fee tier; 1=stable for SolidlyV2; LB binStep; FourMeme 1=buy / 0=sell. Ignored for UniswapV2. |
tickSpacing | int24 | V4 / SolidlyV3 / PCSInfinityCL. |
hooks | address | V4 / PCSInfinityCL hook contract. |
SecureRoute reference
| Field | Type | Notes |
|---|
steps | RouteStep[] | At least one. Empty → NoSteps(). |
minBuyAmountToUser | uint256 | Post-fee minimum. |
beneficiary | address | Receives the net output. |
deadline | uint256 | Unix seconds. |
feeBps | uint16 | ≤ 500. |
wethOverride | address | Optional WETH override. |
unwrapOutput | bool | If true and the final tokenOut is WETH, unwrap to native before sending. |
ProtocolType enum
| Value | Index | Notes |
|---|
UniswapV2 | 0 | Standard 0.3% V2 (Uniswap, SushiSwap, PancakeSwap V2, …). |
UniswapV3 | 1 | Concentrated liquidity. v3Fee = pool fee tier. |
SolidlyV2 | 2 | Velodrome / Aerodrome / etc. v3Fee = stable flag (0/1). |
LiquidityBookV2 | 3 | Trader Joe LB. v3Fee = binStep. |
UniswapV4 | 4 | Singleton PoolManager. Requires registered StateView. |
SolidlyV3 | 5 | Aerodrome CL / Slipstream. Same swap surface as V3. |
FourMeme | 6 | BNB-chain bonding curve. pool is the TokenManager. |
PCSInfinityCL | 7 | PancakeSwap Infinity CL on BSC. Requires registered Vault. |
Fee model
| Field | Limit | Where it comes from |
|---|
feeBps (per call) | 0 – 500 (5%) — MAX_FEE_BPS | Caller-supplied. |
feeAddress (recipient) | Owner-controlled. | Stored on the contract; updated only by setFeeAddress(address). |
uint256 fee = (amountOut * feeBps) / 10_000;
uint256 toUser = amountOut - fee;
require(toUser >= minBuyAmountToUser, NetOutputTooLow());
Third parties cannot route fees to their own wallet on-chain. feeAddress is a single owner-controlled storage slot. If you need partner-revenue routing on-chain, you build it on top — typically by post-processing the output yourself, or by using the API’s higher-level fee plumbing. The router only knows one recipient.
FeeTooHigh() reverts when feeBps > 500.
Slippage semantics (post-fee minimum)
minBuyAmountToUser is the amount the beneficiary actually receives, after the fee is taken — not the gross output of the swap. From _distributeFeesAndSend:
uint256 fee = (amountOut * feeBps) / BPS_DENOMINATOR;
uint256 toUser = amountOut - fee;
if (toUser < minToUser) revert NetOutputTooLow();
Set minBuyAmountToUser = expectedOut * (1 - slippageTolerance) * (1 - feeBps / 10_000), not just expectedOut * (1 - slippage).
Custom error reference
Custom error names live in MobulaRouter.sol:168-192. Selectors are bytes4(keccak256(signature)).
| Error | Selector | When it reverts |
|---|
DeadlineExpired() | 0x1ab7da6b | block.timestamp > deadline. |
TargetNotAllowed() | 0x48cbf26d | Aggregator target not in whitelist. |
ZeroBeneficiary() | 0x776cceeb | beneficiary == address(0). |
ZeroAmount() | 0x1f2a2005 | sellAmount == 0 or zero net output. |
TransferFromFailed() | 0x7939f424 | transferFrom / transfer returned false or reverted. |
ApprovalFailed() | 0x8164f842 | Internal approve to a spender failed. |
NoOutputReceived() | 0xbc35435a | Swap produced 0 of buyToken. |
NetOutputTooLow() | 0x12091c42 | Post-fee output below minBuyAmountToUser. |
FeeTooHigh() | 0xcd4e6167 | feeBps > 500. |
NoSteps() | 0x8fd6f281 | route.steps.length == 0. |
FirstSellMismatch() | 0xaf08899e | steps[0].tokenIn doesn’t match sellToken (or WETH for native). |
CallFailed(string) | 0xb5e1dc2d | Aggregator target reverted; reason bubbled. |
ZeroAddress() | 0xd92e233d | Setter / recovery received the zero address. |
IncorrectETHAmount() | 0x201c04ab | msg.value doesn’t match the rule above. |
ETHTransferFailed() | 0xb12d13eb | Native send to fee or beneficiary failed. |
ArrayLengthMismatch() | 0xa24a13a6 | revokeApprovals length mismatch. |
EmptyArray() | 0x521299a9 | revokeApprovals empty. |
FactoryNotRegistered() | 0x6bf0bd6a | Pool’s factory (or pool itself for V4/PCS-CL/FourMeme) not registered. |
PoolNotFromFactory() | 0x3a19268b | Factory does not advertise this pool for the (tokens, fee/spacing) tuple. |
InvalidProtocolType() | 0xa87da298 | Registered factory doesn’t match step.protocol. |
PoolTokenMismatch() | 0x04a6a570 | Pool tokens don’t match tokenIn / tokenOut. |
StepChainBroken() | 0xfc05df26 | steps[i].tokenOut != steps[i+1].tokenIn. |
UnsupportedProtocol() | 0x743f26e1 | Protocol enum value not handled. |
InvalidCallback() | 0xf7a632f5 | A swap callback came from an unexpected pool. |
VaultNotRegistered() | 0xeeb4f612 | PCSInfinityCL pool with no vault registered. |
Pause and upgrade policy
executeRoute is wrapped in whenNotPaused. The owner can call pauseExecuteRoute() / unpauseExecuteRoute() and emits ExecuteRoutePaused / ExecuteRouteUnpaused. While paused, every call reverts with the standard OpenZeppelin EnforcedPause() error.
executeAggregatorSwap is NOT behind the pause modifier. It only checks the per-call deadline + target whitelist. If you want to coordinate a stop, the owner removes target from isAllowedTarget (one-call kill switch).
- Upgradeability: UUPS proxy (
UUPSUpgradeable + owner-gated _authorizeUpgrade). Storage layout is documented in the implementation header. New chains and new protocol types ship as upgrades — always check version() (currently "2.15.0") before relying on a feature in production.
Reentrancy and callback guarantees
Both entry points hold a nonReentrant guard for the entire swap lifecycle. Pool callbacks (uniswapV3SwapCallback, pancakeV3SwapCallback, unlockCallback for V4, lockAcquired for PCS Infinity) check msg.sender == _activeCallbackPool and revert with InvalidCallback() otherwise — random pools cannot trigger them.
If you fork or wrap the router in your own contract, do not call back into the router from your own callbacks; you’ll hit the reentrancy guard.
Solidity caller example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import { IMobulaRouter } from "./IMobulaRouter.sol";
interface IERC20 {
function approve(address, uint256) external returns (bool);
}
contract Caller {
IMobulaRouter immutable ROUTER;
constructor(address router) { ROUTER = IMobulaRouter(router); }
/// Native ETH → ERC-20 via a pre-built aggregator quote.
function buy(
address aggregator, // 1inch / 0x / KyberSwap router
address buyToken,
uint256 minOut, // post-fee minimum
bytes calldata aggData
) external payable {
IMobulaRouter.AggregatorSwapParams memory p = IMobulaRouter.AggregatorSwapParams({
target: aggregator,
sellToken: address(0), // native
buyToken: buyToken,
sellAmount: msg.value,
minBuyAmountToUser: minOut,
beneficiary: msg.sender,
deadline: block.timestamp + 300,
feeBps: 0, // no partner fee
wethOverride: address(0), // default WETH
data: aggData
});
ROUTER.executeAggregatorSwap{ value: msg.value }(p);
}
/// ERC-20 → ERC-20 single-hop V3 via executeRoute.
/// Caller MUST approve ROUTER for `amountIn` of `tokenIn` first.
function swapV3(
address pool,
address tokenIn,
address tokenOut,
uint24 feeTier,
uint256 amountIn,
uint256 minOut
) external {
IMobulaRouter.RouteStep[] memory steps = new IMobulaRouter.RouteStep[](1);
steps[0] = IMobulaRouter.RouteStep({
pool: pool,
tokenIn: tokenIn,
tokenOut: tokenOut,
protocol: IMobulaRouter.ProtocolType.UniswapV3,
v3Fee: feeTier,
tickSpacing: int24(0),
hooks: address(0)
});
IMobulaRouter.SecureRoute memory route = IMobulaRouter.SecureRoute({
steps: steps,
minBuyAmountToUser: minOut,
beneficiary: msg.sender,
deadline: block.timestamp + 300,
feeBps: 0,
wethOverride: address(0),
unwrapOutput: false
});
ROUTER.executeRoute(tokenIn, amountIn, route);
}
}
Foundry test snippet
A self-contained fork test, mirroring contracts/evm/test/MobulaRouter.fork.t.sol. Deploys a fresh proxy on an Arbitrum fork, whitelists Uniswap V3, and runs a native → USDC swap end-to-end.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import "forge-std/Test.sol";
import { MobulaRouter } from "contracts/evm/src/MobulaRouter.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
interface IERC20 { function balanceOf(address) external view returns (uint256); }
contract MobulaRouterIntegrationFork is Test {
MobulaRouter router;
address constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
address constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831;
address constant UNI_V3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
address user = makeAddr("user");
address feeRecipient = makeAddr("fees");
function setUp() public {
vm.createSelectFork(vm.envString("ARBITRUM_RPC"));
MobulaRouter impl = new MobulaRouter();
bytes memory init = abi.encodeWithSelector(
MobulaRouter.initialize.selector, feeRecipient, address(this), WETH
);
router = MobulaRouter(payable(address(new ERC1967Proxy(address(impl), init))));
router.setAllowedTarget(UNI_V3_ROUTER, true);
}
function test_AggregatorSwap_ETH_to_USDC() public {
uint256 ethIn = 0.1 ether;
vm.deal(user, ethIn);
bytes memory data = abi.encodeWithSignature(
"exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))",
WETH, USDC, uint24(500), address(router),
block.timestamp, ethIn, uint256(0), uint160(0)
);
MobulaRouter.AggregatorSwapParams memory p = MobulaRouter.AggregatorSwapParams({
target: UNI_V3_ROUTER,
sellToken: address(0),
buyToken: USDC,
sellAmount: ethIn,
minBuyAmountToUser: 0,
beneficiary: user,
deadline: block.timestamp + 300,
feeBps: 50, // 0.5%
wethOverride: address(0),
data: data
});
vm.prank(user);
router.executeAggregatorSwap{ value: ethIn }(p);
assertGt(IERC20(USDC).balanceOf(user), 0, "user got USDC");
assertGt(IERC20(USDC).balanceOf(feeRecipient), 0, "fees collected");
}
}
Run it:
forge test --match-contract MobulaRouterIntegrationFork --fork-url $ARBITRUM_RPC -vvv
For the full set of fork tests (V2, V3, Solidly, LB, V4, multi-hop, fee-on-transfer) see contracts/evm/test/MobulaRouter.fork.t.sol in the monorepo.
Operational checklist
Before sending a transaction in production:
See also