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

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.
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: Gist URL (browse both files): gist.github.com/NBMSacha/97628523b02dc9d4f05c0b79adf623a3. To wire the interface into a Foundry / Hardhat project:
To load the ABI in a TypeScript front-end with viem / ethers:
Highlights — full file in the linked artifacts:
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: 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

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

executeRoute — direct, factory-validated swaps

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

SecureRoute reference

ProtocolType enum

Fee model

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:
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)).

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

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.
Run it:
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