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 /configreturnslaunchPhaseequal to"dex"ANDaddresses.feeRouteris not the zero address. That isdexLiveabove. Run it on every start, and stop if either side fails. - How often to poll.
/configanswers withCache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=900and noETag, so there is no304to 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
FeeRouteris an upgradeable proxy (UUPS, owner-gated), and the address/configserves is the proxy. Readrouter(),protocolFeeBps()andcreatorFeeBps()live on every quote, as the quote recipe does. Do not copy the quote ranks either: readquoteRank(token)fromregistry(), or letfeeOnOutput(path)apply them.upgradesRenounced()tells you whether upgrades are still possible. - Split fills.
/config.splitSwapEnabledis 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.
| Surface | Access |
|---|---|
MotoSwapPair.swap | Requires 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 swaps | Closed for every caller. pair.swap requires empty data and reverts MotoSwap: FLASH_SWAPS_CLOSED |
FeeRouter.swapExact* | Public |
Core router liquidity functions, getAmountsOut, getAmountsIn, quote | Public |
Pair reads: getReserves, token0, token1, factory, kLast, cumulative prices | Public |
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.
| Fee | Live read | Value before configuration | Value at launch | Hard cap |
|---|---|---|---|---|
| Pair fee, per hop | factory.swapFeeBps() | initialize sets 100 | 30 | 200 |
| Protocol fee, once per trade | feeRouter.protocolFeeBps() | No default. It is an initialize argument | 70 | 100 |
| Creator fee, per registered endpoint | feeRouter.creatorFeeBps() | 0 until configured | 30 | 50 |
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
feeToto the zero address, and withfeeTounset_mintFeedoes nothing, so the whole pair fee accrues to LPs. - Protocol fee. Transferred to the
Collectorin the quote asset. It can be set to 0. TheFeeRouterstill emitsSwap, withfeeAmount == 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 theCreatorFeeVault. It is not part ofSwap.feeAmount. It has its ownCreatorFeeevent.
How they combine, at the launch values, for an exact-input swap:
| Route | Pair fee | Quote-leg skim | Multiplier, before price impact | All-in |
|---|---|---|---|---|
| One hop, quote asset and an unregistered token | 30 | 70 | (1 - 0.0070) * (1 - 0.0030) | 0.998% |
| One hop, quote asset and a registered token | 30 | 70 + 30 | (1 - 0.0100) * (1 - 0.0030) | 1.297% |
| Two hops, a quote asset at one end, an unregistered token at the other | 30 per hop | 70, once | (1 - 0.0070) * (1 - 0.0030)^2 | 1.295% |
| Two hops, a quote asset at one end, a registered token at the other | 30 per hop | 70 + 30, once | (1 - 0.0100) * (1 - 0.0030)^2 | 1.593% |
| Two hops, token to quote asset to token, one token registered | 30 per hop | 70 + 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 registered | 30 per hop | 70 + 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 * registeredEndpointsWhich 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 endpoints | Base the fees come off |
|---|---|
| Only the output is a quote asset | Gross output. Pools see the full input |
| Only the input is a quote asset | Input. Pools see the remainder |
| Both are quote assets | The side with the higher quoteRank. A tie goes to the output |
| Neither is a quote asset | The 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 path | Reverts 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:
- Read
protocolFeeBps,creatorFeeBps,creatorRegistry,registryandrouterfrom theFeeRouter. - Count the distinct path endpoints with
activeCreator(token) != 0. - Pick the fee leg from the table above.
- Call the core router's
getAmountsOut, which is public and reads the liveswapFeeBps, 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
| Topic | Behavior |
|---|---|
| Approval | Token-in entrypoints pull with transferFrom(msg.sender, feeRouter, amountIn). Approve the FeeRouter, not the core router and not the pair |
| Permit2 | Only 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() |
amountOutMin | Enforced on the net amount to receives, after the protocol fee and any creator fee |
| Which revert you get | Fee-on-output routes revert InsufficientOutput(). Fee-on-input routes enforce the floor inside the core router and revert the string MotoSwapRouter: INSUFFICIENT_OUTPUT_AMOUNT |
deadline | Passed through to the core router, which reverts MotoSwapRouter: EXPIRED when deadline < block.timestamp. The FeeRouter has no separate check |
to | Receives 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 paths | swapExactETHForTokens requires path[0] == WETH, the ETH-out entrypoints require the last element to be WETH, else InvalidPath(). WETH is feeRouter.WETH() |
Returned amounts | amounts[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 |
| Reentrancy | Every swap entrypoint is nonReentrant. The FeeRouter holds no balance between calls |
| Split fills | 1 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:
amountInis what leaves the caller. The swap runs on what arrived at theFeeRouter, and that received amount is theamountInin theSwapevent.amountOutMinis enforced on whattoactually 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
| Need | How |
|---|---|
| One pair | factory.getPair(tokenA, tokenB), either order |
| All pairs | factory.allPairsLength() and factory.allPairs(i) |
| New pairs | event PairCreated(address indexed token0, address indexed token1, address pair, uint256) on the factory. The last field is the pair count |
| Indexed list with TVL | GET /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:
| Revert | Condition |
|---|---|
MotoSwap: IDENTICAL_ADDRESSES | tokenA == tokenB |
MotoSwap: ZERO_ADDRESS | Either token is the zero address |
MotoSwap: TOKEN_NOT_DEPLOYED | Either token has no code at the time of the call |
MotoSwap: NO_QUOTE | The factory has a quote registry set and neither token is a quote asset. The launch configuration sets it |
MotoSwap: PAIR_EXISTS | The 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
- Listing moto.fun coins for the bonding-curve venue.
- Trading bots for the single-wallet loop with confirmation handling.
- Scanners and indexers for the event catalog and the decode recipes.
- Addresses for the
/configkey names. - Contracts inventory for full ABIs, events and errors.