Guides

Swap via FeeRouter

The only way for outside integrators to swap. It takes the protocol fee on the quote side, then routes through Router02.

FeeRouter is the swap entrypoint for every outside integrator. It takes the protocol fee (protocolFeeBps, read it live) from the quote-asset leg, sends it to the Collector, and routes the rest through MotoSwapRouter02.

Read the addresses from /config

feeRouter, router and factory come from GET /config at runtime, and the deployment status table tracks each contract. Treat a zero address there as unset for the current phase and stop rather than sending.

What the FeeRouter is and is not

PropertyValue
Swap shapeExact input only. There is no swapTokensForExactTokens or any other exact-output entrypoint.
Quote viewNone. feeOnOutput(path) is the only swap-related view.
Flash swapsClosed. MotoSwapPair.swap reverts MotoSwap: FLASH_SWAPS_CLOSED on non-empty data.
Protocol feeprotocolFeeBps, owner-set, hard cap MAX_PROTOCOL_FEE_BPS = 100. The launch value is LAUNCH_PROTOCOL_FEE_BPS = 70 (0.70%). With the 0.30% pair fee, a swap pays 1% in total at launch.
Creator feecreatorFeeBps, owner-set, hard cap MAX_CREATOR_FEE_BPS = 50. Charged on top, only when a path endpoint has an active creator.
Pair feefactory.swapFeeBps(), read live by every pair. Already inside getAmountsOut.

The swap perimeter

MotoSwapPair.swap and every swap entrypoint on MotoSwapRouter02 require factory.swapAllowed(msg.sender). The deploy admits exactly two addresses: the core router and the FeeRouter. The core router is on the list only because the FeeRouter reaches pairs through it.

So for an outside integrator:

  • Calling MotoSwapRouter02.swap* directly reverts MotoSwapRouter: SWAPPER_NOT_ALLOWED.
  • Calling MotoSwapPair.swap directly reverts MotoSwap: SWAPPER_NOT_ALLOWED.
  • Every swap goes through the FeeRouter and pays the full fee. There is no integrator tier, no fee exemption and no allowlist to apply for.

Adding and removing liquidity on MotoSwapRouter02 is permissionless. Only the swap leg is gated.

Choosing the right entrypoint

Trade shapeEntrypointReturns
Token → tokenswapExactTokensForTokens(amountIn, amountOutMin, path, to, deadline)uint256[] amounts
Token → token, Permit2 signatureswapExactTokensForTokensPermit2(permit, signature, amountIn, amountOutMin, path, to, deadline)uint256[] amounts
Token → token, fee-on-transferswapExactTokensForTokensSupportingFeeOnTransferTokens(amountIn, amountOutMin, path, to, deadline)uint256 delivered
ETH → tokenswapExactETHForTokens(amountOutMin, path, to, deadline) payableuint256[] amounts
ETH → token, fee-on-transferswapExactETHForTokensSupportingFeeOnTransferTokens(amountOutMin, path, to, deadline) payableuint256 delivered
Token → ETHswapExactTokensForETH(amountIn, amountOutMin, path, to, deadline)uint256[] amounts
Token → ETH, Permit2 signatureswapExactTokensForETHPermit2(permit, signature, amountIn, amountOutMin, path, to, deadline)uint256[] amounts
Token → ETH, fee-on-transferswapExactTokensForETHSupportingFeeOnTransferTokens(amountIn, amountOutMin, path, to, deadline)uint256 net
Split fill, up to 4 legs, atomicswapExactTokensForTokensSplit, swapExactETHForTokensSplit, swapExactTokensForETHSplit(uint256[] legOuts, uint256 totalOut)

Rules that hold for all of them:

  • Approvals. Token-in entrypoints pull with transferFrom, so approve the FeeRouter, not MotoSwapRouter02. Only the two *Permit2 entrypoints accept a signature. The fee-on-transfer and split entrypoints have no Permit2 twin and need a normal allowance.
  • Permit2 availability. Take the Permit2 address from /config.addresses.permit2, or read permit2() on the FeeRouter. A zero address means the signature entrypoints are not available, they revert Permit2NotSet(), and the plain approve path applies.
  • ETH in. msg.value is the input amount. path[0] must be WETH or the call reverts InvalidPath(). For the ETH split, msg.value must equal the sum of the leg amounts or it reverts ValueMismatch().
  • ETH out. path[path.length - 1] must be WETH, else InvalidPath(). ETH is sent to to with a plain call, so to must be able to receive it.
  • Deadline. The FeeRouter does not check it. MotoSwapRouter02 does (deadline >= block.timestamp) and reverts MotoSwapRouter: EXPIRED.
  • amountOutMin is enforced on the net amount to receives, after the protocol fee and any creator fee.

Fee placement rule

The protocol fee is charged once per swap, always in a quote asset. The quote assets are the entries of QuoteAssetRegistry (WETH, USDT, USDC, MOTO). Exactly one side is charged:

PathWhere the fee is takenfeeOnOutput(path)
Only the output is a quote assetOutput: swap first, skim the gross outputtrue
Only the input is a quote assetInput: skim first, swap the remainderfalse
Both endpoints are quote assetsThe side with the higher quoteRank. A tie, or an unranked registry, charges the output. The deploy ranks WETH > USDT > USDC > MOTO.rank based
Neither endpoint is a quote assetAt the first quote asset inside the path: leg A swaps into it, the fee is skimmed there, leg B swaps the restnot used (returns true)
No quote asset anywhere in the pathReverts NoQuoteSide()-

The ETH entrypoints require a quote endpoint and do not take the mid-path route. That is always satisfied while WETH is a registered quote asset.

feeOnOutput(path) only looks at path[0] and the last element. For a path with no quote endpoint it returns true and means nothing. Check the endpoints with registry.isQuote(token) first.

The creator fee is a second skim from the same quote leg, on the same base amount. For each path endpoint with a non-zero creatorRegistry.activeCreator(token), the router sends base * creatorFeeBps / 10_000 to the CreatorFeeVault. Two registered endpoints pay it twice. Quote assets are never registered.

Quoting

The FeeRouter has no getAmountsOut. Build the quote from MotoSwapRouter02.getAmountsOut, which already includes the pair fee, and apply the FeeRouter legs in the same order the contract does.

Netting the fee matters. A router quote used as-is is high by the protocol fee, so any slippage tolerance tighter than the fee reverts every time with no market movement at all.

The quote function is quoteExactIn, in the quote recipe on the aggregators page. That is the only copy in these docs. It reads router(), both fee rates and both registries from the FeeRouter at one block number, counts the path endpoints with an active creator, picks the fee leg from the table above, and applies the skims on the correct side of getAmountsOut. Save that code block as a module, for example quote-exact-in.ts, and import it:

import { quoteExactIn } from "./quote-exact-in"; // the code block from the aggregators page

const blockNumber = await publicClient.getBlockNumber();
const netOut = await quoteExactIn(publicClient, feeRouter, amountIn, path, blockNumber);

Then apply slippage to the net quote:

const minOut = (netOut * (10_000n - slippageBps)) / 10_000n;

Read protocolFeeBps and creatorFeeBps live on every quote. Both are owner-settable state, not constants.

For the fee-on-transfer entrypoints the token's own transfer tax comes off as well, and no on-chain view can tell you what it is. Quote as above, then lower amountOutMin by the tax you expect.

Full example: ETH → MOTO

import { createPublicClient, createWalletClient, defineChain, http, parseAbi, parseEther, zeroAddress } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { quoteExactIn } from "./quote-exact-in"; // the code block from the aggregators page, saved as a module

const RPC = process.env.RPC_URL!;
const CONFIG = await fetch("https://api.motoswap.org/config").then((r) => r.json());
const { feeRouter, weth, motoToken } = CONFIG.addresses;
if (feeRouter === zeroAddress) throw new Error("/config serves no FeeRouter address for this phase.");

const chain = defineChain({
  id: CONFIG.chainId,
  name: `chain-${CONFIG.chainId}`,
  nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
  rpcUrls: { default: { http: [RPC] } },
});
const acct = privateKeyToAccount(process.env.PK as `0x${string}`);
const publicClient = createPublicClient({ chain, transport: http(RPC) });
const wallet = createWalletClient({ chain, account: acct, transport: http(RPC) });

const amountIn = parseEther("0.01");
const path = [weth, motoToken];

// 1. Net quote: router getAmountsOut, then the FeeRouter fee legs.
const blockNumber = await publicClient.getBlockNumber();
const netOut = await quoteExactIn(publicClient, feeRouter, amountIn, path, blockNumber);

// 2. Slippage on the NET quote. 50 bps here.
const minOut = (netOut * (10_000n - 50n)) / 10_000n;

// 3. Submit. msg.value is the input amount; path[0] must be WETH.
const deadline = BigInt(Math.floor(Date.now() / 1000) + 600);
const hash = await wallet.writeContract({
  address: feeRouter,
  abi: parseAbi([
    "function swapExactETHForTokens(uint256 amountOutMin, address[] path, address to, uint256 deadline) payable returns (uint256[])",
  ]),
  functionName: "swapExactETHForTokens",
  args: [minOut, path, acct.address, deadline],
  value: amountIn,
});

A token-in swap is the same, plus one approve(feeRouter, amountIn) on path[0] before the call.

The guide's fetch calls are simple GETs, which work from any origin. A request that triggers a CORS preflight (a script-set If-None-Match, a JSON POST) only passes for the Motoswap app origin, so make those server-side.

What the returned amounts array means

Rely on two elements only: amounts[0] is the caller's gross input, and the last element is the net amount to received. The exact fee is on the Swap event.

  • On a path with a mid-path fee, the array does not chain across the fee hop. If j is the index of the first quote asset inside the path, the pair (amounts[j-1], amounts[j]) is not a swap that happened: amounts[j] is leg B's input, after the fee.
  • swapExactTokensForETH and its Permit2 twin fill only amounts[0] and the last element. On a path of three or more tokens the interior elements are zero. Do not derive a rate from consecutive entries.
  • The fee-on-transfer entrypoints return one number: what to actually received, measured as a balance delta.

Split fills

The Split entrypoints fill one order across up to MAX_SPLIT_LEGS = 4 paths in one transaction, all or nothing. Every leg must share path[0] and the final token. minTotalOut floors the sum of the legs' net outputs. There are no per-leg floors.

/config.splitSwapEnabled is a Motoswap app setting, not an on-chain switch. It tells the app whether to send a split fill as one Split transaction or as one swap per leg. The contract entrypoints have no enable flag and work whatever it says. See the FeeRouter interface.

await wallet.writeContract({
  address: feeRouter,
  abi: parseAbi([
    "struct SwapLeg { uint256 amountIn; address[] path; }",
    "function swapExactTokensForTokensSplit(SwapLeg[] legs, uint256 minTotalOut, address to, uint256 deadline) returns (uint256[] legOuts, uint256 totalOut)",
  ]),
  functionName: "swapExactTokensForTokensSplit",
  args: [
    [
      { amountIn: half, path: [A, WETH, B] }, // route 1
      { amountIn: half, path: [A, USDC, B] }, // route 2
    ],
    minTotalOut,
    acct.address,
    deadline,
  ],
});

The token-in splits pull the summed input in one transferFrom. Quote each leg with quoteExactIn and add the results. Each leg pays exactly the fee the same amount would pay as a single swap.

RevertCause
NoLegs()Empty legs, or a leg with amountIn == 0.
TooManyLegs()More than 4 legs.
LegMismatch()A leg's first or last token differs from leg 0.
ValueMismatch()ETH split: msg.value is not the sum of the leg amounts.
InsufficientOutput()The summed net output is below minTotalOut.

Events

Each swap emits one FeeRouter.Swap and one MotoSwapPair.Swap per hop. A split emits one FeeRouter.Swap per leg, each with that leg's own fee token, because two legs of the same order can pay in different quote assets.

// FeeRouter - one per swap, one per leg on a split
event Swap(
    address indexed user,      // msg.sender of the FeeRouter call: the PAYER, never `to`
    address indexed tokenIn,   // WETH for the ETH-in entrypoints
    address indexed tokenOut,  // WETH for the ETH-out entrypoints
    uint256 amountIn,          // gross input (realised amount on the fee-on-transfer entrypoints)
    uint256 amountOut,         // net amount delivered to `to`
    address feeToken,          // the quote asset the protocol fee was taken in
    uint256 feeAmount          // protocol fee only; 0 when protocolFeeBps is 0
);

// A creator skim, when a path endpoint has an active creator
event CreatorFee(address indexed token, address indexed creator, address quoteAsset, uint256 amount);

// MotoSwapPair - one per hop
event Swap(
    address indexed sender,    // MotoSwapRouter02
    uint256 amount0In,
    uint256 amount1In,
    uint256 amount0Out,
    uint256 amount1Out,
    address indexed to         // next pair, the FeeRouter, or the final recipient
);

user is msg.sender. For a wallet swapping for itself that is the trader. For a contract integration it is your contract, whatever to you pass. Points, Rakeback and referral attribution are all derived from this field, so a contract that swaps on behalf of its users earns those under its own address. feeAmount never includes the creator fee.

Common errors

ErrorCauseFix
MotoSwapRouter: INSUFFICIENT_OUTPUT_AMOUNTInput-fee path: router output below amountOutMin. On a first try with a fresh quote, you almost certainly did not net the fee.Quote with quoteExactIn.
InsufficientOutput()Output-fee path, the fee-on-transfer entrypoints, and the split minTotalOut check. Same cause.Same.
NoQuoteSide()No quote asset anywhere in the path (or no quote endpoint on an ETH entrypoint).Route through WETH, USDC, USDT or MOTO.
InvalidPath()ETH-in path does not start at WETH, or ETH-out path does not end at WETH.Fix the path.
MotoSwapRouter: EXPIREDdeadline < block.timestamp. Checked by MotoSwapRouter02, not the FeeRouter.Use a later deadline.
EthTransferFailed()ETH-out: to rejected the ETH.Send to an address that can receive ETH.
PermitTokenMismatch()permit.permitted.token != path[0].Sign the permit for path[0].
Permit2NotSet()A *Permit2 entrypoint was called while permit2() is the zero address.Use the approve path.
MotoSwapRouter: SWAPPER_NOT_ALLOWEDYou called MotoSwapRouter02.swap* directly.Call the FeeRouter.
MotoSwap: SWAPPER_NOT_ALLOWEDYou called MotoSwapPair.swap directly.Call the FeeRouter.
MotoSwap: FLASH_SWAPS_CLOSEDMotoSwapPair.swap received non-empty data.Flash swaps do not exist on Motoswap.
NotDepositor() (from CreatorFeeVault)A path endpoint is a creator-fee token and the vault does not admit this router. Operator misconfiguration, not a caller error.Nothing the caller can fix. Do not retry. Route around that token until the call stops reverting.
MotoSwap: KConstant-product check failed after the pair fee.Refresh reserves and quote again.

On this page