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.swapreverts for your contract. The pair requiresfactory.swapAllowed(msg.sender), and only the core router and theFeeRouterare admitted. A bot contract that transfers tokens to the pair and callsswapdirectly getsMotoSwap: SWAPPER_NOT_ALLOWED.- The core router reverts too. Every swap function on
MotoSwapRouter02carries the same check on its caller, so an EOA or a bot contract calling it getsMotoSwapRouter: 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.swaprevertsMotoSwap: FLASH_SWAPS_CLOSEDon any non-emptydata, for every caller. - The
FeeRouteris 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:
- Keys and nonces. Every write is a normal EVM transaction from your wallet. Concurrent trades from one wallet need your own nonce management.
- An RPC you trust. Quotes read reserves; a lagging RPC quotes a stale
pool.
/configdoes not give you one. Bring your own. - Slippage policy.
amountOutMinis your only price protection. It is one line of BigInt math; the tolerance you pick is your call. - Confirmation. A submitted hash is not a fill. Read the receipt and the
FeeRouter.Swapevent 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
| Symptom | Cause | Fix |
|---|---|---|
InsufficientOutput() or MotoSwapRouter: INSUFFICIENT_OUTPUT_AMOUNT | Quoted the wrong fee side, or the pool moved. The first comes from fee-on-output routes, the second from fee-on-input routes | Net the quote per the fee-side rule; re-quote closer to submit |
MotoSwap: SWAPPER_NOT_ALLOWED or MotoSwapRouter: SWAPPER_NOT_ALLOWED | You called the pair or the core router | Route through FeeRouter, always |
MotoSwap: FLASH_SWAPS_CLOSED | A contract passed callback data to pair.swap | There are no flash swaps |
MotoSwapRouter: EXPIRED | The deadline passed before inclusion | Re-quote and resubmit |
NoQuoteSide | Long-tail to long-tail path with no quote leg anywhere | Route through WETH or another quote asset |
| Approval spent but swap fails | Approved the core router or the pair | Approvals go to FeeRouter |
| Everything reverts on a fresh deployment | Phase is not dex, addresses are zero | Check 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
| Curve | DEX | |
|---|---|---|
| Entry point | LaunchpadCurve.buy / .sell | FeeRouter.swapExact* |
| Approval | None. The coin exempts the curve's own pull on a sell while it is unbonded | FeeRouter |
| Deadline | None. There is no parameter | Required |
| Slippage | minTokensOut / minEthOut | amountOutMin |
| Fee | protocolFeeBps 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 live | Pair fee plus protocol fee plus any creator fee, each read live |
| Quote | LaunchpadLens.quoteBuy / quoteSell | Core 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
| Revert | Cause | Fix |
|---|---|---|
NotTrading() | The coin is frozen, graduated, or does not exist | Check curves(token).status first. 3 means trade the pools instead |
Slippage() | The minimum was not met, or the quote went stale | Re-quote closer to submit |
BuysArePaused() | A global or per-coin buy pause | Nothing to retry. Sells are unaffected |
ZeroAmount() | A buy with no value, or a sell of zero | Check inputs |
NotFrozen() | You called graduate on a coin that is not frozen | Poll 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 (
submitteris 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 revertsValueNeedsSubmitter(). - Bound intent (
submitteris set). Only that address can submit it, with or without value. Anyone else revertsNotTheSubmitter().
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
- Aggregators for the full
FeeRouterinterface, the fee model in basis points and the quote recipe. - Scanners and indexers for decoding pair logs and BigInt price math.
- Claim Rakeback for how an address reads and claims what it is owed.
- API inventory for every read endpoint.