Integrations

Aggregators

Quote and execute Motoswap swaps from a routing engine, with the swap perimeter, the FeeRouter interface, the fee model in basis points, and pair discovery.

Motoswap is Uniswap V2 in its math and its interfaces. It is not integrable with a stock V2 adapter, because the swap leg is gated. This page is what an adapter needs: why the stock path reverts, the FeeRouter interface, how to quote it, the fee model in basis points, and how to find pairs.

Every contract claim here was checked against the Solidity source, and signatures are copied from it.

Deployment status: read it from /config

Status is per contract, and GET https://api.motoswap.org/config is where you read it. The per-contract table with addresses is on addresses.

The DEX contracts and the moto.fun curve are deployed, and launchPhase reads "dex". It read "vamp" before the DEX opened, when the MOTO token and the launch farm were deployed and the DEX contracts were not. Read the pair, router and FeeRouter addresses from /config.

Do not integrate against a Motoswap pair, router or FeeRouter address from any source but /config, and treat a zero address there as unset for the current phase rather than as a target.

GET /dex/pools and GET /swap/candidates can return Uniswap V2 pairs alongside Motoswap pools: those trade through the Uniswap V2 router. Confirm any pool you route through with pair.factory() == cfg.addresses.factory.

const cfg = await fetch("https://api.motoswap.org/config").then((r) => r.json());

const dexLive =
  cfg.launchPhase === "dex" &&
  cfg.addresses.feeRouter !== "0x0000000000000000000000000000000000000000";

When can I integrate

  • The check. GET /config returns launchPhase equal to "dex" AND addresses.feeRouter is not the zero address. That is dexLive above. Run it on every start, and stop if either side fails.
  • How often to poll. /config answers with Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=900 and no ETag, so there is no 304 to ask for. The edge holds an answer for 300 seconds and may serve a stale one for up to 900 seconds more while it refreshes. Polling more than once every 5 minutes returns the same bytes.
  • Read, do not hardcode. The FeeRouter is an upgradeable proxy (UUPS, owner-gated), and the address /config serves is the proxy. Read router(), protocolFeeBps() and creatorFeeBps() live on every quote, as the quote recipe does. Do not copy the quote ranks either: read quoteRank(token) from registry(), or let feeOnOutput(path) apply them. upgradesRenounced() tells you whether upgrades are still possible.
  • Split fills. /config.splitSwapEnabled is a Motoswap app setting, not an on-chain switch. See the note under the interface.
  • No public test deployment. There is no public test deployment of the DEX contracts. Build against the interfaces and the quote recipe on this page, then verify against mainnet.

Why a stock V2 adapter reverts

A V2 adapter transfers tokens to the pair and calls pair.swap from its own executor, or calls the V2 router. Both revert on Motoswap.

SurfaceAccess
MotoSwapPair.swapRequires factory.swapAllowed(msg.sender). Reverts MotoSwap: SWAPPER_NOT_ALLOWED otherwise
Every swap entrypoint on MotoSwapRouter02 (the core router)Same requirement, onlyPerimeter. Reverts MotoSwapRouter: SWAPPER_NOT_ALLOWED
Flash swapsClosed for every caller. pair.swap requires empty data and reverts MotoSwap: FLASH_SWAPS_CLOSED
FeeRouter.swapExact*Public
Core router liquidity functions, getAmountsOut, getAmountsIn, quotePublic
Pair reads: getReserves, token0, token1, factory, kLast, cumulative pricesPublic

The perimeter holds exactly two addresses: the core router and the FeeRouter. The deployment seeds those two, and the deployment check asserts that both are admitted and that the protocol's own zap is not. The core router is in the set only because the FeeRouter reaches pairs through it.

So the integration path for every aggregator is the same: your route's Motoswap leg is a FeeRouter call, and it pays the full fee. Price the fee into the venue's effective rate.

The fee model in basis points

Three fees exist. One sits in the pair math. Two are skimmed by the FeeRouter from the quote leg of the trade.

FeeLive readValue before configurationValue at launchHard cap
Pair fee, per hopfactory.swapFeeBps()initialize sets 10030200
Protocol fee, once per tradefeeRouter.protocolFeeBps()No default. It is an initialize argument70100
Creator fee, per registered endpointfeeRouter.creatorFeeBps()0 until configured3050

Read all three from the chain, and keep reading them: each one has an owner-gated setter, and the pair reads swapFeeBps live on every swap. Hardcoding 997/1000 is a latent mispricing.

Where each fee goes:

  • Pair fee. Stays in the pool. The launch configuration sets feeTo to the zero address, and with feeTo unset _mintFee does nothing, so the whole pair fee accrues to LPs.
  • Protocol fee. Transferred to the Collector in the quote asset. It can be set to 0. The FeeRouter still emits Swap, with feeAmount == 0.
  • Creator fee. Charged only when a path endpoint has an active creator in the CreatorFeeRegistry (activeCreator(token) != address(0)), which moto.fun registers for its coins at launch. It is taken from the same quote leg and the same base as the protocol fee, and sent to the CreatorFeeVault. It is not part of Swap.feeAmount. It has its own CreatorFee event.

How they combine, at the launch values, for an exact-input swap:

RoutePair feeQuote-leg skimMultiplier, before price impactAll-in
One hop, quote asset and an unregistered token3070(1 - 0.0070) * (1 - 0.0030)0.998%
One hop, quote asset and a registered token3070 + 30(1 - 0.0100) * (1 - 0.0030)1.297%
Two hops, a quote asset at one end, an unregistered token at the other30 per hop70, once(1 - 0.0070) * (1 - 0.0030)^21.295%
Two hops, a quote asset at one end, a registered token at the other30 per hop70 + 30, once(1 - 0.0100) * (1 - 0.0030)^21.593%
Two hops, token to quote asset to token, one token registered30 per hop70 + 30, once, on the interior quote leg(1 - 0.0030) * (1 - 0.0100) * (1 - 0.0030)1.593%
Two hops, token to quote asset to token, both tokens registered30 per hop70 + 30 + 30, once, on the interior quote leg(1 - 0.0030) * (1 - 0.0130) * (1 - 0.0030)1.891%

All-in is one minus the multiplier. On an interior-quote route the skim comes off the first hop's output, which is why it sits between the two pair fees.

The protocol fee is charged once per trade, never per hop. The creator fee is charged once per endpoint with an active creator, so a route between two registered tokens pays it twice. Each skim is computed on the same base and rounded down on its own:

protocolFee = base * protocolFeeBps / 10_000
creatorCut  = base * creatorFeeBps  / 10_000      // per registered endpoint
remainder   = base - protocolFee - creatorCut * registeredEndpoints

Which leg pays

The quote assets are the tokens in the QuoteAssetRegistry (feeRouter.registry(), then quoteAssets(), isQuote(token), quoteRank(token)). The launch configuration registers WETH, USDT, USDC and MOTO with ranks 4, 3, 2 and 1. The set and the ranks are owner-settable, so read them.

Path endpointsBase the fees come off
Only the output is a quote assetGross output. Pools see the full input
Only the input is a quote assetInput. Pools see the remainder
Both are quote assetsThe side with the higher quoteRank. A tie goes to the output
Neither is a quote assetThe output of the hops up to the first interior quote asset in the path. The remainder continues through the rest of the path
No quote asset anywhere in the pathReverts NoQuoteSide()

feeOnOutput(address[] path) is a public view that returns true when the fees come off the output leg. It looks at path[0] and the last element only. For a path with no quote asset at either end it returns true, but the swap does not take the output branch: it takes the interior-quote branch above. Check isQuote on both endpoints first, then call feeOnOutput.

The ETH entrypoints require a quote asset at one end, which WETH always is while it is registered.

Quote

FeeRouter has no quote view. It is exact-input only, so there is no getAmountsIn equivalent to build either. The recipe:

  1. Read protocolFeeBps, creatorFeeBps, creatorRegistry, registry and router from the FeeRouter.
  2. Count the distinct path endpoints with activeCreator(token) != 0.
  3. Pick the fee leg from the table above.
  4. Call the core router's getAmountsOut, which is public and reads the live swapFeeBps, and apply the skims on the correct side of it.

Run every read at one block number.

import { parseAbi, zeroAddress } from "viem";

const feeRouterAbi = parseAbi([
  "function router() view returns (address)",
  "function registry() view returns (address)",
  "function creatorRegistry() view returns (address)",
  "function protocolFeeBps() view returns (uint256)",
  "function creatorFeeBps() view returns (uint256)",
  "function feeOnOutput(address[] path) view returns (bool)",
]);
const routerAbi = parseAbi([
  "function getAmountsOut(uint256 amountIn, address[] path) view returns (uint256[])",
]);
const registryAbi = parseAbi(["function isQuote(address token) view returns (bool)"]);
const creatorAbi = parseAbi(["function activeCreator(address token) view returns (address)"]);

// Net amount the recipient gets for an exact-input swap through FeeRouter.
export async function quoteExactIn(client, feeRouter, amountIn, path, blockNumber) {
  const at = { blockNumber };
  const fr = (functionName, args) =>
    client.readContract({ address: feeRouter, abi: feeRouterAbi, functionName, args, ...at });

  const [router, registry, creatorRegistry, protocolFeeBps, creatorFeeBps] = await Promise.all([
    fr("router"), fr("registry"), fr("creatorRegistry"), fr("protocolFeeBps"), fr("creatorFeeBps"),
  ]);

  const amountsOut = async (amount, p) =>
    (await client.readContract({
      address: router, abi: routerAbi, functionName: "getAmountsOut", args: [amount, p], ...at,
    })).at(-1);
  const isQuote = (token) =>
    client.readContract({ address: registry, abi: registryAbi, functionName: "isQuote", args: [token], ...at });

  // Creator fee: once per distinct endpoint that has an active creator.
  const tokenIn = path[0];
  const tokenOut = path.at(-1);
  let creators = 0n;
  if (creatorRegistry !== zeroAddress && creatorFeeBps > 0n) {
    const endpoints = tokenIn.toLowerCase() === tokenOut.toLowerCase() ? [tokenIn] : [tokenIn, tokenOut];
    for (const token of endpoints) {
      const creator = await client.readContract({
        address: creatorRegistry, abi: creatorAbi, functionName: "activeCreator", args: [token], ...at,
      });
      if (creator !== zeroAddress) creators += 1n;
    }
  }
  // Both fees are computed on the same base, each rounded down on its own.
  const afterFees = (base) =>
    base - (base * protocolFeeBps) / 10_000n - creators * ((base * creatorFeeBps) / 10_000n);

  const [inQuote, outQuote] = await Promise.all([isQuote(tokenIn), isQuote(tokenOut)]);

  if (!inQuote && !outQuote) {
    // Neither endpoint is a quote asset: the fee comes off the first interior quote leg.
    let j = 1;
    while (j < path.length - 1 && !(await isQuote(path[j]))) j++;
    if (j >= path.length - 1) throw new Error("NoQuoteSide: no quote asset anywhere in the path");
    const gross = await amountsOut(amountIn, path.slice(0, j + 1));
    return amountsOut(afterFees(gross), path.slice(j));
  }

  if (await fr("feeOnOutput", [path])) {
    // Pools see the full input. Fees come off the gross output.
    return afterFees(await amountsOut(amountIn, path));
  }
  // Fees come off the input. Pools see the remainder.
  return amountsOut(afterFees(amountIn), path);
}

Call it with a viem public client and cfg.addresses.feeRouter once dexLive is true. The function was tested with the FeeRouter reads stubbed and getAmountsOut served by Uniswap V2 reserves on Ethereum, against a separate calculation of each branch.

If you would rather price from reserves you index yourself, the pair math is V2 with the fee as a parameter (MotoSwapLibrary.getAmountOut):

function getAmountOut(amountIn, reserveIn, reserveOut, feeBps) {
  const amountInWithFee = amountIn * (10_000n - feeBps);
  return (amountInWithFee * reserveOut) / (reserveIn * 10_000n + amountInWithFee);
}

With feeBps = 30n this returns the same integer as Uniswap V2's getAmountOut for every input.

The common mistake: quoting the raw getAmountsOut output and using it as amountOutMin on a fee-on-output route. The skim happens after the pools fill, the delivery comes up short of the minimum, and the swap reverts every time.

The FeeRouter interface

Exact input only. There is no swapTokensForExactTokens and no other exact-output entrypoint on the FeeRouter.

// Plain ERC-20 paths. The caller approves FeeRouter for path[0].
function swapExactTokensForTokens(
    uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline
) external returns (uint256[] memory amounts);

function swapExactETHForTokens(
    uint256 amountOutMin, address[] calldata path, address to, uint256 deadline
) external payable returns (uint256[] memory amounts);

function swapExactTokensForETH(
    uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline
) external returns (uint256[] memory amounts);

// Same swaps, input pulled with a Permit2 SignatureTransfer instead of an approval.
// PermitTransferFrom is ((address token, uint256 amount) permitted, uint256 nonce, uint256 deadline).
function swapExactTokensForTokensPermit2(
    IPermit2.PermitTransferFrom calldata permit, bytes calldata signature,
    uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline
) external returns (uint256[] memory amounts);

function swapExactTokensForETHPermit2(
    IPermit2.PermitTransferFrom calldata permit, bytes calldata signature,
    uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline
) external returns (uint256[] memory amounts);

// Fee-on-transfer tokens. Each returns one number, not an array.
function swapExactTokensForTokensSupportingFeeOnTransferTokens(
    uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline
) external returns (uint256 delivered);

function swapExactETHForTokensSupportingFeeOnTransferTokens(
    uint256 amountOutMin, address[] calldata path, address to, uint256 deadline
) external payable returns (uint256 delivered);

function swapExactTokensForETHSupportingFeeOnTransferTokens(
    uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline
) external returns (uint256 net);

// Split fills. struct SwapLeg { uint256 amountIn; address[] path; }
function swapExactTokensForTokensSplit(
    SwapLeg[] calldata legs, uint256 minTotalOut, address to, uint256 deadline
) external returns (uint256[] memory legOuts, uint256 totalOut);

function swapExactETHForTokensSplit(
    SwapLeg[] calldata legs, uint256 minTotalOut, address to, uint256 deadline
) external payable returns (uint256[] memory legOuts, uint256 totalOut);

function swapExactTokensForETHSplit(
    SwapLeg[] calldata legs, uint256 minTotalOut, address to, uint256 deadline
) external returns (uint256[] memory legOuts, uint256 totalOut);

The full ABI is in the contracts inventory.

/config.splitSwapEnabled does not gate the three Split entrypoints. It is an API configuration flag that tells the Motoswap app whether to submit a split fill as one swapExact*Split transaction or as one ordinary swap per leg. The FeeRouter has no on-chain switch for them: in the contract source they are plain external nonReentrant functions, so they work whatever the flag says.

Semantics an adapter depends on

TopicBehavior
ApprovalToken-in entrypoints pull with transferFrom(msg.sender, feeRouter, amountIn). Approve the FeeRouter, not the core router and not the pair
Permit2Only the two entrypoints above have a Permit2 twin. permit.permitted.token must equal path[0], else PermitTokenMismatch(). If Permit2 is not configured they revert Permit2NotSet()
amountOutMinEnforced on the net amount to receives, after the protocol fee and any creator fee
Which revert you getFee-on-output routes revert InsufficientOutput(). Fee-on-input routes enforce the floor inside the core router and revert the string MotoSwapRouter: INSUFFICIENT_OUTPUT_AMOUNT
deadlinePassed through to the core router, which reverts MotoSwapRouter: EXPIRED when deadline < block.timestamp. The FeeRouter has no separate check
toReceives the net output. On fee-on-input routes the last pair pays to directly, so to cannot be either token of that pair (MotoSwap: INVALID_TO). On ETH-out routes to receives native ETH and must accept it
ETH pathsswapExactETHForTokens requires path[0] == WETH, the ETH-out entrypoints require the last element to be WETH, else InvalidPath(). WETH is feeRouter.WETH()
Returned amountsamounts[0] is the caller's gross input and the last element is the net delivery, on every branch. Interior elements do not chain across the fee hop on interior-quote routes. swapExactTokensForETH fills only the first and last elements and leaves the interior at zero
ReentrancyEvery swap entrypoint is nonReentrant. The FeeRouter holds no balance between calls
Split fills1 to 4 legs (MAX_SPLIT_LEGS). Every leg shares path[0] and the final token, else LegMismatch(). minTotalOut floors the sum, there are no per-leg floors. msg.value must equal the sum of leg inputs on the ETH-in form, else ValueMismatch()

Fee-on-transfer tokens

The plain entrypoints compute with nominal amounts and revert on a token that taxes its own transfers. The three SupportingFeeOnTransferTokens entrypoints measure balance deltas instead:

  • amountIn is what leaves the caller. The swap runs on what arrived at the FeeRouter, and that received amount is the amountIn in the Swap event.
  • amountOutMin is enforced on what to actually receives.
  • The fee model is unchanged: same protocol fee, same creator fee, same leg.
  • They support the interior-quote route, so a taxing token routes wherever a plain one does.
  • They have no Permit2 twin. The caller needs an ERC-20 approval to the FeeRouter.

getAmountsOut does not know about a token's tax, so your quote for a taxing token needs the tax applied on your side.

Events

// FeeRouter. One per swap, and one per leg on a split fill.
event Swap(
    address indexed user,      // msg.sender of the FeeRouter call. The payer, not `to`
    address indexed tokenIn,   // path[0]. WETH on the ETH-in entrypoints
    address indexed tokenOut,  // last path element. WETH on the ETH-out entrypoints
    uint256 amountIn,          // gross input (msg.value on ETH-in)
    uint256 amountOut,         // net delivered to `to`, after all fees
    address feeToken,          // the quote asset the protocol fee was taken in
    uint256 feeAmount          // the protocol fee only. Creator fees are not included
);

// FeeRouter. One per endpoint that has an active creator.
event CreatorFee(address indexed token, address indexed creator, address quoteAsset, uint256 amount);

user is the address that called the FeeRouter (msg.sender), not the recipient. If your settlement contract makes the call, your settlement contract is the user on every swap it routes.

Each hop also emits the standard pair Swap and Sync. The pair-level sender is the core router. The pair-level to is the next pair, the FeeRouter on fee-on-output routes, or the recipient. Reconcile fills against FeeRouter.Swap.amountOut, not against the pair legs.

Pair discovery

NeedHow
One pairfactory.getPair(tokenA, tokenB), either order
All pairsfactory.allPairsLength() and factory.allPairs(i)
New pairsevent PairCreated(address indexed token0, address indexed token1, address pair, uint256) on the factory. The last field is the pair count
Indexed list with TVLGET /dex/pools, cursor-paginated. See the API reference

Every pair is an ERC-1967 beacon proxy deployed with CREATE2 from the factory. The salt is keccak256(abi.encodePacked(token0, token1)) with the tokens sorted, as in V2. The init code hash is not the V2 constant: it depends on the deployment's beacon address. Read it from factory.pairCodeHash() (INIT_CODE_PAIR_HASH() returns the same value) and never hardcode it.

import { encodePacked, getCreate2Address, keccak256, parseAbi } from "viem";

const factory = cfg.addresses.factory;
const pairCodeHash = await client.readContract({
  address: factory,
  abi: parseAbi(["function pairCodeHash() view returns (bytes32)"]),
  functionName: "pairCodeHash",
});

const [token0, token1] =
  tokenA.toLowerCase() < tokenB.toLowerCase() ? [tokenA, tokenB] : [tokenB, tokenA];

const pair = getCreate2Address({
  from: factory,
  salt: keccak256(encodePacked(["address", "address"], [token0, token1])),
  bytecodeHash: pairCodeHash,
});

eth_getCode on a pair returns the 82-byte proxy, so bytecode matching against a V2 pair template fails. Identify pairs by the factory event or by pair.factory().

createPair(tokenA, tokenB) is permissionless, with these checks:

RevertCondition
MotoSwap: IDENTICAL_ADDRESSEStokenA == tokenB
MotoSwap: ZERO_ADDRESSEither token is the zero address
MotoSwap: TOKEN_NOT_DEPLOYEDEither token has no code at the time of the call
MotoSwap: NO_QUOTEThe factory has a quote registry set and neither token is a quote asset. The launch configuration sets it
MotoSwap: PAIR_EXISTSThe pair already exists

With the registry set, every pair has a quote asset on one side. That is why a route between two long-tail tokens always has an interior quote asset to take the fee from.

Indexing the launch farming pairs: the feeTo log order

The pools the farm accepts are Uniswap V2 pairs, and their Mint event has no recipient field. The recipient is the to of the last LP Transfer from the zero address that the same pair emitted before the Mint, in the same transaction. An indexer that takes the first one credits the deposit to the factory's feeTo, because _mintFee runs before _mint(to, liquidity). The Motoswap pair orders mint() the same way, so use the same rule there.

The rule, the reason, a worked mainnet transaction and a runnable sample are in the feeTo log order on the events guide.

A runnable decode is on scanners and indexers.

moto.fun coins

A coin launched on moto.fun is not routable while it is bonding. It has no pair, no reserves, and no path: it trades only against its own curve, in ETH. There is nothing for a routing engine to quote and nothing to settle against.

The moment that changes is graduation. A graduated coin has a TOKEN/WETH pool and a TOKEN/MOTO pool like any other Motoswap token, with the LP burned, and from then on it routes, quotes and settles exactly as this page describes.

For a router, that reduces to one predicate:

// Is this a curve coin, and can I route it yet?
const res = await fetch(`https://api.motoswap.org/motofun/coins/${token}`);
if (res.status === 404) {
  // Either moto.fun is off on this deploy ("moto.fun is not enabled on this deploy"), or the
  // token never launched on the curve ("this token never launched on the moto.fun curve").
  // The error body says which. Both mean: treat it as an ordinary ERC-20 and route normally.
} else {
  const coin = await res.json();
  if (coin.status !== "graduated") {
    // Not routable. No pair exists. Do not synthesize one.
  }
  // coin.pairWeth and coin.pairMoto are the pools to index once it has graduated.
}

This endpoint answers 404 for every token while motofunEnabled is false, so branch on that flag before you call it.

A graduated coin normally carries the creator fee on top of the protocol fee, because moto.fun registers its coins in the CreatorFeeRegistry at launch. That is the registered-token row in the fee table above. Do not assume it: activeCreator(token) is the check, and the quote function on this page already makes it.

Discovery, curve pricing, graduation detection and listing fields are on listing moto.fun coins.

Where to go next

On this page