Skip to main content

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 pointUse when…Pool universePausable
executeAggregatorSwapYou already have aggregator calldata (1inch, 0x, KyberSwap, LiFi, …) and want fee + slippage enforcement on top.Whatever the aggregator routes through.No
executeRouteYou 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.
ChainChain IDRouter (proxy)Explorer
Ethereum10x0005ea683517b3a4463bfd798c9850Ad2d586795Etherscan
Optimism100x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5EfOptimistic Etherscan
BNB Chain560x0005ea70fa6dbd2efd17697d2351301adb6318b2BscScan
Gnosis1000x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19EDGnosisScan
Polygon1370x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5EfPolygonScan
Monad1430x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19EDMonvision
Mantle50000x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19EDMantle Explorer
MegaETH43260x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19EDMegaETH Blockscout
Base84530x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5EfBaseScan
Arbitrum One421610x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5EfArbiscan
Avalanche C431140x0005ea38EB0a69D1253508EBDBdB9eA8Cb26B5EfSnowTrace
Linea591440x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19EDLineaScan
Blast814570x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19EDBlastScan
Scroll5343520x40A9849E3bfcf0f3d3be9dfaE3C997aCa19c19EDScrollscan
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:
ArtifactHosted in docsHosted on GitHub Gist (raw, hot-linkable)
IMobulaRouter.sol — drop-in Solidity interfaceIMobulaRouter.solgist raw
MobulaRouter.abi.json — full ABI (functions + events + errors)MobulaRouter.abi.jsongist 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 sidesellToken valuemsg.value
Native (ETH/BNB/MATIC/AVAX)address(0)MUST equal sellAmount
ERC-20token addressMUST 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:
  1. block.timestamp <= deadline, else DeadlineExpired().
  2. isAllowedTarget[target] == true, else TargetNotAllowed().
  3. beneficiary != 0, else ZeroBeneficiary().
  4. Pull funds (msg.value for native, transferFrom + approve(target) for ERC-20).
  5. Snapshot buyToken balance, target.call{value: msg.value}(data).
  6. If the call reverts, bubble the message via CallFailed(string).
  7. Compute output delta. If 0 → NoOutputReceived().
  8. Apply feeBps (≤ 500), enforce output - fee >= minBuyAmountToUser, else NetOutputTooLow().
  9. Send fee to feeAddress (owner-controlled), remainder to beneficiary.

AggregatorSwapParams reference

FieldTypeNotes
targetaddressAggregator router. Must be in isAllowedTarget.
sellTokenaddressaddress(0) for native.
buyTokenaddressaddress(0) for native. Output is unwrapped from WETH if the aggregator returned WETH.
sellAmountuint256Amount of sellToken to spend.
minBuyAmountToUseruint256Post-fee minimum. See Slippage semantics.
beneficiaryaddressReceives the net output. Cannot be zero.
deadlineuint256Unix seconds.
feeBpsuint16MAX_FEE_BPS = 500 (5%).
wethOverrideaddressOptional — defaults to the stored WETH if zero.
databytesCalldata 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:
  1. Resolves the pool’s factory (via factory() for V2/V3/Solidly, getFactory() for LB, or factoryRegistry[step.pool] for V4 / PCSInfinityCL / FourMeme).
  2. Confirms the factory is registered with the matching ProtocolType.
  3. Confirms the pool’s tokens match tokenIn / tokenOut.
  4. 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

FieldTypeNotes
pooladdressPool / TokenManager / PoolManager.
tokenInaddressInput token.
tokenOutaddressOutput token.
protocolProtocolTypeSee enum below.
v3Feeuint24Multi-purpose: V3 fee tier; 1=stable for SolidlyV2; LB binStep; FourMeme 1=buy / 0=sell. Ignored for UniswapV2.
tickSpacingint24V4 / SolidlyV3 / PCSInfinityCL.
hooksaddressV4 / PCSInfinityCL hook contract.

SecureRoute reference

FieldTypeNotes
stepsRouteStep[]At least one. Empty → NoSteps().
minBuyAmountToUseruint256Post-fee minimum.
beneficiaryaddressReceives the net output.
deadlineuint256Unix seconds.
feeBpsuint16≤ 500.
wethOverrideaddressOptional WETH override.
unwrapOutputboolIf true and the final tokenOut is WETH, unwrap to native before sending.

ProtocolType enum

ValueIndexNotes
UniswapV20Standard 0.3% V2 (Uniswap, SushiSwap, PancakeSwap V2, …).
UniswapV31Concentrated liquidity. v3Fee = pool fee tier.
SolidlyV22Velodrome / Aerodrome / etc. v3Fee = stable flag (0/1).
LiquidityBookV23Trader Joe LB. v3Fee = binStep.
UniswapV44Singleton PoolManager. Requires registered StateView.
SolidlyV35Aerodrome CL / Slipstream. Same swap surface as V3.
FourMeme6BNB-chain bonding curve. pool is the TokenManager.
PCSInfinityCL7PancakeSwap Infinity CL on BSC. Requires registered Vault.

Fee model

FieldLimitWhere it comes from
feeBps (per call)0 – 500 (5%) — MAX_FEE_BPSCaller-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)).
ErrorSelectorWhen it reverts
DeadlineExpired()0x1ab7da6bblock.timestamp > deadline.
TargetNotAllowed()0x48cbf26dAggregator target not in whitelist.
ZeroBeneficiary()0x776cceebbeneficiary == address(0).
ZeroAmount()0x1f2a2005sellAmount == 0 or zero net output.
TransferFromFailed()0x7939f424transferFrom / transfer returned false or reverted.
ApprovalFailed()0x8164f842Internal approve to a spender failed.
NoOutputReceived()0xbc35435aSwap produced 0 of buyToken.
NetOutputTooLow()0x12091c42Post-fee output below minBuyAmountToUser.
FeeTooHigh()0xcd4e6167feeBps > 500.
NoSteps()0x8fd6f281route.steps.length == 0.
FirstSellMismatch()0xaf08899esteps[0].tokenIn doesn’t match sellToken (or WETH for native).
CallFailed(string)0xb5e1dc2dAggregator target reverted; reason bubbled.
ZeroAddress()0xd92e233dSetter / recovery received the zero address.
IncorrectETHAmount()0x201c04abmsg.value doesn’t match the rule above.
ETHTransferFailed()0xb12d13ebNative send to fee or beneficiary failed.
ArrayLengthMismatch()0xa24a13a6revokeApprovals length mismatch.
EmptyArray()0x521299a9revokeApprovals empty.
FactoryNotRegistered()0x6bf0bd6aPool’s factory (or pool itself for V4/PCS-CL/FourMeme) not registered.
PoolNotFromFactory()0x3a19268bFactory does not advertise this pool for the (tokens, fee/spacing) tuple.
InvalidProtocolType()0xa87da298Registered factory doesn’t match step.protocol.
PoolTokenMismatch()0x04a6a570Pool tokens don’t match tokenIn / tokenOut.
StepChainBroken()0xfc05df26steps[i].tokenOut != steps[i+1].tokenIn.
UnsupportedProtocol()0x743f26e1Protocol enum value not handled.
InvalidCallback()0xf7a632f5A swap callback came from an unexpected pool.
VaultNotRegistered()0xeeb4f612PCSInfinityCL 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:
  • Hit version() — confirm you’re on the expected major.
  • Read feeAddress() if you display fee disclosures.
  • For aggregator swaps: confirm isAllowedTarget(target) == true.
  • For routes: confirm every pool’s factory is registered (off-chain check via factoryRegistry).
  • Set deadline = block.timestamp + N — never far in the future.
  • Compute minBuyAmountToUser as a post-fee value.
  • Approve sellAmount (not max) if you do not want to leave a standing approval.

See also