Listing moto.fun coins
Discover launches, price a bonding curve, detect graduation, and fill a listing card. The path for listing and scanner sites.
This page is for anyone building a listing, a chart site, or a scanner over moto.fun: how to find coins, how to price one that has no pool yet, how to notice the moment it gets pools, and what to put in each field of a listing card.
A curve coin is not a pool. Half of what a V2 indexer does has no equivalent here until graduation, and the half that does looks different. Read the moto.fun overview first if you have not.
Source
Contracts: LaunchpadCurve, LaunchpadToken, LaunchpadLens. Full surfaces:
contracts inventory. Endpoints:
API inventory.
The two-phase lifecycle
| Bonding | Graduated | |
|---|---|---|
| Venue | One curve contract | Two Motoswap V2 pools |
| Trade event | LaunchpadCurve.Trade | FeeRouter.Swap + pair Swap |
| Price source | Virtual reserves on the curve | Pair reserves |
| Liquidity | One-sided: ETH held by the curve | Two-sided, LP burned |
| Fee | 1.20% (0.70% protocol + 0.50% creator) | Pool fee + 0.70% protocol + 0.30% creator |
| Quote asset | ETH | WETH and MOTO, two pools |
Both phases belong to the same coin and the same address. A listing that treats graduation as a new asset restarts the chart and loses the history; the app joins them and so should you.
Discovery
// Cursor-paginated, newest first, ETag-cached, 25 per page.
let cursor: string | undefined;
do {
const url = new URL("https://api.motoswap.org/motofun/coins");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await fetch(url).then((r) => r.json());
for (const coin of page.items) index(coin);
cursor = page.hasMore ? (page.nextCursor ?? undefined) : undefined;
} while (cursor);items[] carries address, name, symbol, creator, timestamp, status, pairWeth,
pairMoto, volume24hUsd, volumeAllUsd, trades, lastPriceX18. Check hasMore, not an empty
items: a malformed cursor returns an empty page with 200.
A coin can exist as an address before it exists as code
A gasless launch is a signed intent: the address is fixed and the app lists it, but there is no
contract at it and no TokenCreated log until the first buy. If you list pending coins, get them
from GET <motofunUrl>/backend-fun/<chain-slug>/intents/{address} on the app backend and mark
them clearly. If you only list real
tokens, filter on TokenCreated and you will never see one.
One script, end to end
The snippets on this page assume a viem client and a cfg read from /config. This one assumes
nothing: it lists every launch, prints one coin's first trades, and either points you at the lens
or, if the coin has graduated, finds both pools and prices it from the WETH pool. It needs viem
and three environment variables, and it runs as pasted.
// Lists every moto.fun launch, then follows one coin to its pools.
// Run: RPC_URL=... CURVE=0x... START_BLOCK=... bun track.ts
// CURVE is addresses.launchpadCurve from GET /config. START_BLOCK is the block the curve was deployed in.
import { createPublicClient, http, parseAbi, parseAbiItem, formatUnits, type Address } from "viem";
const client = createPublicClient({ transport: http(process.env.RPC_URL) });
const curve = process.env.CURVE as Address;
const startBlock = BigInt(process.env.START_BLOCK ?? "0");
const PAGE = 2_000n; // blocks per eth_getLogs call. Lower it if your provider refuses the range.
const tokenCreated = parseAbiItem(
"event TokenCreated(address indexed token, address indexed creator, string name, string symbol, bytes32 metadataHash, address indexed quoteToken)",
);
const trade = parseAbiItem(
"event Trade(address indexed trader, address indexed token, bool isBuy, uint256 ethAmount, uint256 tokenAmount, uint256 priceX18, uint256 protocolFee, uint256 creatorFee)",
);
const graduated = parseAbiItem(
"event Graduated(address indexed token, address pairWeth, address pairMoto, uint256 wethLp, uint256 motoLp, uint256 tokensLpWeth, uint256 tokensLpMoto, uint256 dustBurned, address indexed keeper)",
);
const curveAbi = parseAbi([
"function curves(address) view returns (uint112 realEth, uint112 tokenReserve, uint8 status, bool buysPaused, uint16 paramsId)",
]);
const pairAbi = parseAbi([
"function token0() view returns (address)",
"function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)",
]);
// Before trusting an empty page: the deploy block always holds the proxy's own events.
const head = await client.getBlockNumber();
const sanity = await client.getLogs({ address: curve, fromBlock: startBlock, toBlock: startBlock });
if (sanity.length === 0) throw new Error("no logs in the curve's deploy block: wrong START_BLOCK, or this provider drops results");
async function paged<T>(fetchPage: (from: bigint, to: bigint) => Promise<T[]>): Promise<T[]> {
const out: T[] = [];
for (let from = startBlock; from <= head; from += PAGE) {
const to = from + PAGE - 1n > head ? head : from + PAGE - 1n;
out.push(...(await fetchPage(from, to)));
}
return out;
}
// 1. Every launch. viem decodes the indexed fields from the topics and the rest from the data.
const launches = await paged((fromBlock, toBlock) =>
client.getLogs({ address: curve, event: tokenCreated, fromBlock, toBlock }),
);
for (const l of launches) console.log("launch", l.args.token, l.args.symbol, "by", l.args.creator, "at block", l.blockNumber);
if (launches.length === 0) throw new Error("no launches on this curve yet");
// 2. One coin's trades. `token` is the SECOND indexed field of Trade, so filter on args.token.
const token = launches[0].args.token!;
const trades = await paged((fromBlock, toBlock) =>
client.getLogs({ address: curve, event: trade, args: { token }, fromBlock, toBlock }),
);
for (const t of trades.slice(0, 3)) {
const a = t.args;
console.log(a.isBuy ? "buy " : "sell", formatUnits(a.ethAmount!, 18), "ETH for", formatUnits(a.tokenAmount!, 18), "coins,",
"price", formatUnits(a.priceX18!, 18), "ETH per coin, fees", a.protocolFee, "+", a.creatorFee, "wei");
}
// 3. Status decides where the price comes from. 1 = Trading, 2 = Frozen, 3 = Graduated.
const [, , status] = await client.readContract({ address: curve, abi: curveAbi, functionName: "curves", args: [token] });
if (status !== 3) {
console.log("still on the curve, status", status, "- price it through LaunchpadLens.currentPrice");
} else {
const [g] = await paged((fromBlock, toBlock) =>
client.getLogs({ address: curve, event: graduated, args: { token }, fromBlock, toBlock }),
);
const { pairWeth, pairMoto } = g.args;
console.log("graduated. TOKEN/WETH pool", pairWeth, "TOKEN/MOTO pool", pairMoto);
// Price from the WETH pool. V2 orders reserves by token address, so find which side is the coin.
const [token0, [r0, r1]] = await Promise.all([
client.readContract({ address: pairWeth!, abi: pairAbi, functionName: "token0" }),
client.readContract({ address: pairWeth!, abi: pairAbi, functionName: "getReserves" }),
]);
const coinIs0 = token0.toLowerCase() === token.toLowerCase();
const [coinReserve, wethReserve] = coinIs0 ? [r0, r1] : [r1, r0];
const priceX18 = (wethReserve * 10n ** 18n) / coinReserve; // ETH per whole coin, 1e18 fixed point
console.log("pool price", formatUnits(priceX18, 18), "ETH per coin");
}It pages eth_getLogs because an unpaged backfill is the most common way to get a silently empty
result, and it refuses to start if the deploy block comes back empty, for the reason given under
Discovery.
Pricing a bonding curve
There is no pair, no reserves pair, and no getAmountsOut. Ask the lens. It reads the coin's own
curve parameters and the live fee rates at call time, which is why it is the recommended method:
import { parseAbi } from "viem";
const priceX18 = await client.readContract({
address: cfg.addresses.launchpadLens,
abi: parseAbi(["function currentPrice(address token) view returns (uint256)"]),
functionName: "currentPrice",
args: [token],
});currentPrice is the live price only while a coin is on the curve. It does not check status. For
a graduated coin it returns the graduation price, frozen: the price the coin had when its reserve
reached the floor, which is also the price its pools opened at. That number is correct for what it
is and goes stale against the market from the first pool trade on, with no revert and no zero to
warn you. The app backend serves the same value as curve.priceX18 on GET /tokens/{address}, for
graduated coins too, with status beside it. For an address the curve never launched, the call
returns a non-zero number as well. So read curves(token).status first (0 None, 1 Trading, 2
Frozen, 3 Graduated), and take a graduated coin's live price from its pools, as shown
below.
If you need the arithmetic itself, for a simulation or to price from an event stream without an RPC
round trip per coin, this is the whole model. The curve is constant product against
virtual reserves on both sides. The virtual reserves are per coin: read them with
paramsOf(token), never from the VIRTUAL_ETH and VIRTUAL_TOKENS constants, which describe only
coins stamped with parameter set 0. That is what a fresh deployment stamps, and it stops being
the whole story the first time the owner appends a set.
import { parseAbi } from "viem";
const curveAbi = parseAbi([
"function curves(address) view returns (uint112 realEth, uint112 tokenReserve, uint8 status, bool buysPaused, uint16 paramsId)",
"function paramsOf(address token) view returns (uint256 virtualEth, uint256 floorSupply, uint256 virtualTokens)",
]);
const [[realEth, tokenReserve], [virtualEth, , virtualTokens]] = await Promise.all([
client.readContract({ address: cfg.addresses.launchpadCurve, abi: curveAbi, functionName: "curves", args: [token] }),
client.readContract({ address: cfg.addresses.launchpadCurve, abi: curveAbi, functionName: "paramsOf", args: [token] }),
]);
// ETH per whole coin, 1e18 fixed point. Identical to Lens.currentPrice and to Trade.priceX18.
const priceX18 = ((virtualEth + realEth) * 10n ** 18n) / (virtualTokens + tokenReserve);curves returns five values. paramsOf can be cached per coin for as long as you like, because a
coin's parameters are fixed at launch. It cannot be shared between coins.
Curve prices are around 1e-9 ETH
A coin launched under parameter set 0 opens at priceX18 = 1249999999, which is about
1.25e-9 ETH per coin. Under any parameter set the opening price is virtualEth * 1e18 / (virtualTokens + 1e27), so it moves in proportion to that set's virtual ETH. A display pipeline
that converts wei to a float with a 1e-6 floor renders every live curve as zero. Convert from the 1e18
fixed-point value directly and format with enough significant figures, or your whole board reads
$0.00.
For quotes rather than spot, use the lens, which reads the live fee rates:
quoteBuy(address token, uint256 ethIn) -> (tokensOut, grossUsed, refund)
quoteSell(address token, uint256 tokensIn) -> ethOut // net of fees
bondingProgress(address token) -> uint256 // 0 to 1e18Pricing after graduation
A graduated coin is an ordinary Motoswap V2 token with two pools. Take both addresses from
Graduated (or from GET /motofun/coins/{address}) and price from reserves:
priceX18 = wethReserve * 1e18 / coinReserve // on pairWeth, ETH per whole coinUse pairWeth as the reference price. It is quoted in the same asset as the curve was, so the
series continues without a conversion, and it opens at exactly the last curve price. pairMoto
prices the coin in MOTO and needs a MOTO/WETH rate to compare. Both the coin and WETH have 18
decimals, so there is no decimals adjustment. V2 orders reserve0 and reserve1 by token address,
so read token0() to learn which side is the coin: the script above does exactly this.
Filling a listing card
| Field | Bonding | Graduated |
|---|---|---|
| Base token | The coin. 18 decimals, always | Same |
| Quote token | ETH | WETH, and MOTO on the second pool |
| Total supply | 1e27, always, fixed | Same. No mint, no burn except graduation dust |
| Price | priceX18 above, or Trade.priceX18 | Pair reserves |
| Market cap / FDV | priceX18 * 1e9 / 1e18 ETH (price per whole coin times 1 billion coins). They are equal: supply is fully issued at launch | Same formula |
| Liquidity | realEth from curves(token). One-sided | Both pools' reserves |
| Volume | Sum Trade.ethAmount, or read volume24hUsd | FeeRouter.Swap |
| Holders | GET <motofunUrl>/backend-fun/<chain-slug>/tokens/{address}/holders | Same, plus transfers |
| Pair address | None. Do not synthesize one | pairWeth, pairMoto from Graduated |
| Logo | GET /launchpad/token/{address}/logo | Same |
Two of those need care.
Liquidity while bonding is one-sided and is not withdrawable by anyone. realEth is the ETH the
curve holds against the coins it has not sold. There is no LP position, no LP holder, and no function
that lets anybody remove it, including the coin's creator and the protocol owner. If your listing has a
"LP burned" signal or any other liquidity-safety check, a bonding coin satisfies it structurally rather than by
having a burned LP token.
Market cap equals FDV on every coin, in both phases. The whole supply is minted at launch, so there is no unlock schedule, no vesting, no team allocation and no future emission to discount. A listing that shows an FDV different from market cap is showing a bug.
Detecting graduation
Watch one event:
event Graduated(
address indexed token,
address pairWeth, // NOT indexed
address pairMoto, // NOT indexed
uint256 wethLp,
uint256 motoLp,
uint256 tokensLpWeth,
uint256 tokensLpMoto,
uint256 dustBurned,
address indexed keeper
);The pair addresses are in the data, not the topics, so a topic-only filter finds the graduation but not the pools. Decode the payload.
Polling instead: GET /motofun/coins/{address} returns status and both pair addresses, ETag-cached
and versioned on the curve's own head block, so a poll costs a 304 until something actually
happens.
FloorReached fires earlier and is worth catching too: it marks the coin frozen and trading paused,
usually under a minute before Graduated. A listing that shows a frozen coin as tradeable will
collect failed transactions.
Freeze is reversible
If nobody graduates a frozen coin, selling reopens 15 minutes after the freeze and a sell walks it
back to Trading. Frozen is not a terminal state and not a one-way ratchet. Model the back edge
or your state machine will get stuck.
At graduation the pools are seeded at the final curve price and both LP positions are burned to
0x…dEaD, in the same transaction. There is no lock contract to inspect and no unlock date: the LP
supply is at the burn address, permanently. To verify it, look at the graduation
transaction: the pair's LP Transfer to 0x…dEaD is the whole of that mint. Right after graduation
totalSupply() is that amount plus the 1000 wei V2 minimum liquidity a pair mints to the zero
address on its first mint. Do not assert that equality later: other liquidity providers add to the
supply and not to the burn, a protocol fee mint can add to it, and if the pair existed before
graduation the curve's mint was not the first one.
Tracking one coin from launch to pools
Everything below is on the curve address, filtered by the coin in topics[1], except where noted.
| Step | Event | What you learn | What to do |
|---|---|---|---|
| 1 | TokenCreated | token, creator, name, symbol, metadataHash, quoteToken | Start tracking. Read paramsOf(token) once and keep it |
| 1a | FeeRecipientSet (only if redirected) | Where the creator fee goes | Absent means the creator is the recipient |
| 2 | Trade (many) | Side, amounts, the spot price after the trade, both fee amounts | token is topics[2] here, not topics[1]: trader comes first |
| 3 | FloorReached | The coin is Frozen. Trading is paused | Stop quoting buys. Expect Graduated shortly |
| 3a | Trade with isBuy == false after the reopen delay | The coin went back to Trading | Resume. This back edge is real, model it |
| 4 | Graduated | pairWeth and pairMoto in the data, plus what was seeded and burned | Switch the coin to its two pools. The curve series is now history |
Two ordering facts. In the transaction that freezes a coin, FloorReached is emitted before that
buy's own Trade, so a pipeline folding status in log order sees the freeze first. And Frozen is
not instantaneous: a keeper normally graduates the coin within about a minute, which is several
blocks in which the coin is frozen and visible as such.
Decoding the logs by hand
A library does this for you (the script above uses viem's). If you are decoding raw logs, an indexed
address is a 32-byte topic with the address in its last 20 bytes, and every data field below is
one 32-byte word unless marked.
TokenCreated has two strings, so its data uses the ABI head and tail layout:
| Where | Field |
|---|---|
topics[1] | token |
topics[2] | creator |
topics[3] | quoteToken |
| data word 0 | byte offset of name within the data (0x60 for this event) |
| data word 1 | byte offset of symbol |
| data word 2 | metadataHash |
at the name offset | one word holding the byte length, then the UTF-8 bytes padded to a word boundary |
at the symbol offset | the same shape |
Trade:
| Where | Field |
|---|---|
topics[1] | trader |
topics[2] | token |
| data word 0 | isBuy, a full word holding 0 or 1 |
| data word 1 | ethAmount in wei. Fee-inclusive on a buy, pre-fee on a sell |
| data word 2 | tokenAmount, 18 decimals |
| data word 3 | priceX18. ETH per whole coin is this divided by 1e18, nothing else |
| data word 4 | protocolFee in wei |
| data word 5 | creatorFee in wei |
Graduated: topics[1] is token, topics[2] is keeper, and the data words in order are
pairWeth, pairMoto, wethLp, motoLp, tokensLpWeth, tokensLpMoto, dustBurned. Despite
the names, wethLp and motoLp are the amounts of WETH and MOTO seeded into the two pools, not
LP token amounts, and tokensLpWeth and tokensLpMoto are the coin amounts seeded beside them.
After step 4 the curve emits no more trades for that coin. Trades are pair Swap events on the two
pool addresses you decoded, like any other Motoswap pool. The one thing the curve can still emit for
a graduated coin is its PvE credit: PveRecorded normally lands inside the graduation transaction,
and if that credit failed (PveRecordFailed) it lands later, whenever it is retried.
If you would rather poll than index: GET /motofun/coins/{address} on the Motoswap API returns
status, pairWeth and pairMoto, and it costs a 304 until something changes.
Trades, volume, and holders
One Trade event per curve trade. There is no pair Swap and no FeeRouter.Swap until graduation.
event Trade(
address indexed trader, address indexed token, bool isBuy,
uint256 ethAmount, uint256 tokenAmount, uint256 priceX18,
uint256 protocolFee, uint256 creatorFee
);isBuyis not indexed. You cannot topic-filter buys from sells. Decode and branch.ethAmountis fee-inclusive on buys and pre-fee on sells. Decide which notion of volume you are reporting before you sum it, and be consistent. The official venue stats sum both sides.protocolFeeandcreatorFeeare literal transfer amounts, not rates, so a fee change needs no handling on your side.priceX18is the spot after the trade.
Holders come from GET <motofunUrl>/backend-fun/<chain-slug>/tokens/{address}/holders, which gives
the top 10 by net position, the true
total count, and the PvE escrow balance separately. The escrow is netted out of the holder list so it
does not read as a whale. If you need the full holder set, derive it from Transfer logs like any
ERC-20.
Metadata and logos
Name and symbol are on the token contract and immutable. The description, image and links are
committed on chain as a metadataHash at launch, and both services re-verify against that hash before
serving, returning null rather than unverified content.
The logo endpoint to use is on the Motoswap API:
GET https://api.motoswap.org/launchpad/token/{address}/logoETag-cached, PNG or WebP, at most 256 KB and 512x512. 404 { "error": "no logo" } when the coin has
none. Do not scrape the data URL out of the app backend's JSON.
Venue-level numbers
GET /motofun/stats # coins by status, TVL, volume, fees, trades, launches
GET /motofun/revenue/series?range=30d&bucket=day # revenue, Collector leg vs creator leg
GET /analytics/overview # optional top-level "motofun" componentEvery USD field except tvlUsd is a frozen write-time snapshot and is never re-priced. tvlUsd
is a live gauge over non-graduated curves only; a graduated coin's liquidity is in pools and is
counted by the DEX side. The 24h window is anchored to the indexer head block timestamp, not to wall
clock.
Sniping, and why the constraints are not there
There is no block delay, no cooldown, no max buy, no per-wallet cap, and no anti-bot window on the curve. The first buyer can take everything above the coin's graduation floor in one transaction. The only bound on a single buy is the partial-fill clamp at the graduation floor, which refunds the excess rather than reverting.
What does exist:
- A launch intent's
submitterfield binds who may submit the first buy. When a creator takes a dev buy they must set it to themselves, or the signature is a bearer token in the mempool - The CREATE2 salt is
keccak256(abi.encode(creator, salt)), so a replayed salt from another wallet lands at a different address - A pre-bond transfer blocklist stops the coin being seeded into a pool before graduation, on the venues it enumerates. It is a list, not a rule: an unlisted quote asset, a V3 or V4 pool, or an unknown V2 fork sits outside it
Where to go next
- Contracts for the full call surface.
- Realtime and candles for the stream and the exact chart rules.
- Scanners for the indexing discipline.
- Trading bots to trade rather than list.