Integrations

Trading bots

The single-wallet swap loop for Telegram and trading bots. Discover, quote, submit and confirm, with the failure modes handled.

One loop, five steps, against the dex phase surface. This page assumes a bot that holds keys and trades one wallet at a time; if you are routing other people's flow at scale, read Aggregators instead, and if you only read data, AI agents is shorter.

Deployment status

Status is per contract, and the table is on addresses. The DEX contracts and the moto.fun curve are deployed, and GET /config reports launchPhase: "dex". The loop below needs that phase. Before the DEX opened the phase was "vamp", every DEX address in /config was the zero address, and MOTO traded on its Uniswap V2 pair (addresses.motoUniswapPair) through the Uniswap V2 router. Do not take a FeeRouter address from anywhere but /config.

The swap perimeter, for bots

Motoswap pairs use V2 math, but the swap leg is gated. This decides how a bot is built:

  • pair.swap reverts for your contract. The pair requires factory.swapAllowed(msg.sender), and only the core router and the FeeRouter are admitted. A bot contract that transfers tokens to the pair and calls swap directly gets MotoSwap: SWAPPER_NOT_ALLOWED.
  • The core router reverts too. Every swap function on MotoSwapRouter02 carries the same check on its caller, so an EOA or a bot contract calling it gets MotoSwapRouter: SWAPPER_NOT_ALLOWED. Its read functions (getAmountsOut, getAmountsIn) and its liquidity functions are public.
  • Every swap goes through the FeeRouter, from an EOA or from your own contract, and pays the full fee. There is no cheaper path to the pools.
  • There are no flash swaps. pair.swap reverts MotoSwap: FLASH_SWAPS_CLOSED on any non-empty data, for every caller.
  • The FeeRouter is exact-input only. There is no exact-output swap.

Arbitrage and backrun contracts written for V2 forks need their swap call changed to a FeeRouter call and their profit math changed to include the fee. The full table is on aggregators.

What you handle

Motoswap gives you quoting primitives, one public swap contract, and a REST API for everything indexed. Your bot owns the rest:

  1. Keys and nonces. Every write is a normal EVM transaction from your wallet. Concurrent trades from one wallet need your own nonce management.
  2. An RPC you trust. Quotes read reserves; a lagging RPC quotes a stale pool. /config does not give you one. Bring your own.
  3. Slippage policy. amountOutMin is your only price protection. It is one line of BigInt math; the tolerance you pick is your call.
  4. Confirmation. A submitted hash is not a fill. Read the receipt and the FeeRouter.Swap event before telling a user anything.

The loop

Discover the deployment

import { erc20Abi, parseAbi, parseEventLogs, zeroAddress } from "viem";
// `client` is a viem public client on cfg.chainId, `wallet` a viem wallet
// client holding the bot's key, `botWallet` its address.

const cfg = await fetch("https://api.motoswap.org/config")
  .then((r) => r.json());
if (cfg.launchPhase !== "dex") throw new Error("AMM not live in this phase");
const { feeRouter, weth, usdc, usdt, motoToken } = cfg.addresses;

Cache it, refresh every few minutes (Cache-Control is 300s). A zero address means unset for the current phase; never hardcode. The throw above is the correct behavior when the phase is anything but dex.

Build the candidate paths

With the factory's quote registry set, every Motoswap pair has a quote asset on one side, so the useful paths are the direct pair and one hop through each quote asset:

const same = (a, b) => a.toLowerCase() === b.toLowerCase();

const paths = [
  [tokenIn, tokenOut],
  ...[weth, usdc, usdt, motoToken]
    .filter((mid) => !same(mid, zeroAddress) && !same(mid, tokenIn) && !same(mid, tokenOut))
    .map((mid) => [tokenIn, mid, tokenOut]),
];

GET /swap/candidates?tokenIn=&tokenOut= returns the indexed pairs that connect two tokens if you would rather not probe the chain. Its response shape is in the API reference.

Quote with the fee on the right side

The FeeRouter has no quote function. The pair fee is inside the core router's getAmountsOut. The protocol fee (feeRouter.protocolFeeBps()) and any creator fee (feeRouter.creatorFeeBps(), on tokens with an active creator) come off the quote leg of the path, and which end that is varies by trade. Read all three from the chain. Which leg pays has the rule, and the quote recipe has quoteExactIn, a function that applies it. Copy it into your bot.

// quoteExactIn is the function from the aggregators page.
const blockNumber = await client.getBlockNumber();

let best = { path: null, expectedOut: 0n };
for (const path of paths) {
  try {
    const expectedOut = await quoteExactIn(client, feeRouter, amountIn, path, blockNumber);
    if (expectedOut > best.expectedOut) best = { path, expectedOut };
  } catch {
    // getAmountsOut reverts when a pair on the path does not exist. Skip it.
  }
}
if (!best.path) throw new Error("no route");

const toleranceBps = 100n; // 1%
const minOut = (best.expectedOut * (10_000n - toleranceBps)) / 10_000n;

amountOutMin is checked against the net amount your wallet receives, after every fee. A minOut built from the raw getAmountsOut number on a fee-on-output route is unfillable.

Quotes age. Re-quote if more than a few seconds pass between quote and submit; a Telegram confirmation step is more than a few seconds.

Approve once, then swap

ERC-20 input: approve FeeRouter (not the raw router, not the pair). ETH input: skip approval, use the payable entrypoint.

await wallet.writeContract({
  address: tokenIn,
  abi: erc20Abi,
  functionName: "approve",
  args: [feeRouter, amountIn], // or maxUint256 once, your policy
});

const hash = await wallet.writeContract({
  address: feeRouter,
  abi: parseAbi([
    "function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) returns (uint256[])",
  ]),
  functionName: "swapExactTokensForTokens",
  args: [amountIn, minOut, best.path, botWallet, BigInt(Math.floor(Date.now() / 1000) + 300)],
});

The deadline is a real protection for a bot: a transaction stuck in the mempool past it reverts MotoSwapRouter: EXPIRED instead of filling at a five-minute-old price. ETH in uses swapExactETHForTokens with path[0] set to WETH and the amount as value. A token that taxes its own transfers needs the SupportingFeeOnTransferTokens entrypoint of the same shape.

Confirm from the event, not the hash

const receipt = await client.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("swap reverted");

const swapLog = parseEventLogs({
  abi: parseAbi([
    "event Swap(address indexed user, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut, address feeToken, uint256 feeAmount)",
  ]),
  logs: receipt.logs,
})[0];

// swapLog.args.amountOut is the NET delivery, after all fees.
// That is the number you report to the user.
// swapLog.args.user is the caller of the FeeRouter (your bot wallet), not `to`.

The FeeRouter event and the pair event are both named Swap. Their signatures differ, so the ABI above only matches the FeeRouter one.

Failure modes worth coding for

SymptomCauseFix
InsufficientOutput() or MotoSwapRouter: INSUFFICIENT_OUTPUT_AMOUNTQuoted the wrong fee side, or the pool moved. The first comes from fee-on-output routes, the second from fee-on-input routesNet the quote per the fee-side rule; re-quote closer to submit
MotoSwap: SWAPPER_NOT_ALLOWED or MotoSwapRouter: SWAPPER_NOT_ALLOWEDYou called the pair or the core routerRoute through FeeRouter, always
MotoSwap: FLASH_SWAPS_CLOSEDA contract passed callback data to pair.swapThere are no flash swaps
MotoSwapRouter: EXPIREDThe deadline passed before inclusionRe-quote and resubmit
NoQuoteSideLong-tail to long-tail path with no quote leg anywhereRoute through WETH or another quote asset
Approval spent but swap failsApproved the core router or the pairApprovals go to FeeRouter
Everything reverts on a fresh deploymentPhase is not dex, addresses are zeroCheck launchPhase before trading

moto.fun curve trades

A bonding moto.fun coin is a different loop: no route, no approval, no deadline, and no pair. You call the curve directly with ETH.

// Buy. ETH in, coins out. minTokensOut is your only protection.
const [tokensOut] = await client.readContract({
  address: cfg.addresses.launchpadLens,
  abi: parseAbi([
    "function quoteBuy(address token, uint256 ethIn) view returns (uint256 tokensOut, uint256 grossUsed, uint256 refund)",
  ]),
  functionName: "quoteBuy",
  args: [token, ethIn],
});

const hash = await wallet.writeContract({
  address: cfg.addresses.launchpadCurve,
  abi: parseAbi(["function buy(address token, uint256 minTokensOut) payable returns (uint256)"]),
  functionName: "buy",
  args: [token, (tokensOut * 99n) / 100n], // 1% tolerance
  value: ethIn,
});
// Sell. No approval: the coin exempts the curve's own pull while it is unbonded.
const ethOut = await client.readContract({
  address: cfg.addresses.launchpadLens,
  abi: parseAbi(["function quoteSell(address token, uint256 tokensIn) view returns (uint256)"]),
  functionName: "quoteSell",
  args: [token, tokensIn],
});

await wallet.writeContract({
  address: cfg.addresses.launchpadCurve,
  abi: parseAbi([
    "function sell(address token, uint256 tokensIn, uint256 minEthOut) returns (uint256)",
  ]),
  functionName: "sell",
  args: [token, tokensIn, (ethOut * 99n) / 100n],
});

Confirm from LaunchpadCurve.Trade in the receipt, not from the hash. tokenAmount is what the wallet received on a buy; ethAmount is fee-inclusive on buys and pre-fee on sells.

What is different from a DEX swap

CurveDEX
Entry pointLaunchpadCurve.buy / .sellFeeRouter.swapExact*
ApprovalNone. The coin exempts the curve's own pull on a sell while it is unbondedFeeRouter
DeadlineNone. There is no parameterRequired
SlippageminTokensOut / minEthOutamountOutMin
FeeprotocolFeeBps plus creatorFeeBps on the curve, both sides, off the ETH leg. The contract initializes them to a 1.20% total. Both are owner-settable, so read them livePair fee plus protocol fee plus any creator fee, each read live
QuoteLaunchpadLens.quoteBuy / quoteSellCore router getAmountsOut plus the fee legs

Do not go looking for a deadline argument

Curve trades take no deadline, deliberately: there is no LP-side staleness to protect against, and the minimum-out is the whole price guard. A bot that expects one will not compile against the ABI, and a bot that leans on one for mempool protection needs to lean on minTokensOut instead.

Failure modes specific to the curve

RevertCauseFix
NotTrading()The coin is frozen, graduated, or does not existCheck curves(token).status first. 3 means trade the pools instead
Slippage()The minimum was not met, or the quote went staleRe-quote closer to submit
BuysArePaused()A global or per-coin buy pauseNothing to retry. Sells are unaffected
ZeroAmount()A buy with no value, or a sell of zeroCheck inputs
NotFrozen()You called graduate on a coin that is not frozenPoll LaunchpadLens.graduationReadiness

A buy that crosses the graduation line fills partially and refunds the rest in the same transaction. That is a success, not a failure. Read the Trade event rather than assuming you got what you paid for.

Sniping constraints, or the lack of them

There is no launch block delay, no cooldown, no max buy, and no per-wallet cap. The one thing that matters for a sniper is how a gasless launch materializes. It is a signed intent submitted with buyWithIntent, and the intent's submitter field decides who can do what:

  • Open intent (submitter is the zero address). Anyone can submit it, but only with zero value. That creates the coin and buys nothing. Sending ETH with an open intent reverts ValueNeedsSubmitter().
  • Bound intent (submitter is set). Only that address can submit it, with or without value. Anyone else reverts NotTheSubmitter().

So a funded first buy through buyWithIntent always comes from the named submitter. You cannot attach your own ETH to someone else's intent. Once the coin exists, buy is open to everyone. Rebuild the intent tuple verbatim from GET /intents/{address} on the app backend; any field you retype wrong fails signature recovery.

Graduating for the bounty

graduate(token) is permissionless and pays a bounty out of the curve's ETH. Poll LaunchpadLens.graduationReadiness(token), which never reverts and classifies every failure:

(Readiness regime, uint64 readyAt, uint256 minMotoOut, uint256 quotedOut,
 uint256 deviationBps, uint256 bountyNow, uint256 sellsOpenAt)

Ready means send it. TooFresh carries readyAt: sleep until then rather than retrying blind. OutOfBand means the MOTO price reference is outside its band and the transaction would revert. If your send loses the race, it reverts and the coin is graduated anyway, which costs you gas and nothing else. If the bounty push to your address fails, it is held and you take it with claimBounty(to).

Reading the account back

Balances are chain reads. Everything derived is one REST call each, ETag-cached so polling is nearly free: /points/{address} (Points and rank), /rakeback/{address} (the Rakeback state for that address), /token/{address}/price for price display. Send If-None-Match; expect 304. Conventions: caching guide.

Where to go next

On this page