Integrations

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

BondingGraduated
VenueOne curve contractTwo Motoswap V2 pools
Trade eventLaunchpadCurve.TradeFeeRouter.Swap + pair Swap
Price sourceVirtual reserves on the curvePair reserves
LiquidityOne-sided: ETH held by the curveTwo-sided, LP burned
Fee1.20% (0.70% protocol + 0.50% creator)Pool fee + 0.70% protocol + 0.30% creator
Quote assetETHWETH 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 1e18

Pricing 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 coin

Use 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

FieldBondingGraduated
Base tokenThe coin. 18 decimals, alwaysSame
Quote tokenETHWETH, and MOTO on the second pool
Total supply1e27, always, fixedSame. No mint, no burn except graduation dust
PricepriceX18 above, or Trade.priceX18Pair reserves
Market cap / FDVpriceX18 * 1e9 / 1e18 ETH (price per whole coin times 1 billion coins). They are equal: supply is fully issued at launchSame formula
LiquidityrealEth from curves(token). One-sidedBoth pools' reserves
VolumeSum Trade.ethAmount, or read volume24hUsdFeeRouter.Swap
HoldersGET <motofunUrl>/backend-fun/<chain-slug>/tokens/{address}/holdersSame, plus transfers
Pair addressNone. Do not synthesize onepairWeth, pairMoto from Graduated
LogoGET /launchpad/token/{address}/logoSame

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.

StepEventWhat you learnWhat to do
1TokenCreatedtoken, creator, name, symbol, metadataHash, quoteTokenStart tracking. Read paramsOf(token) once and keep it
1aFeeRecipientSet (only if redirected)Where the creator fee goesAbsent means the creator is the recipient
2Trade (many)Side, amounts, the spot price after the trade, both fee amountstoken is topics[2] here, not topics[1]: trader comes first
3FloorReachedThe coin is Frozen. Trading is pausedStop quoting buys. Expect Graduated shortly
3aTrade with isBuy == false after the reopen delayThe coin went back to TradingResume. This back edge is real, model it
4GraduatedpairWeth and pairMoto in the data, plus what was seeded and burnedSwitch 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:

WhereField
topics[1]token
topics[2]creator
topics[3]quoteToken
data word 0byte offset of name within the data (0x60 for this event)
data word 1byte offset of symbol
data word 2metadataHash
at the name offsetone word holding the byte length, then the UTF-8 bytes padded to a word boundary
at the symbol offsetthe same shape

Trade:

WhereField
topics[1]trader
topics[2]token
data word 0isBuy, a full word holding 0 or 1
data word 1ethAmount in wei. Fee-inclusive on a buy, pre-fee on a sell
data word 2tokenAmount, 18 decimals
data word 3priceX18. ETH per whole coin is this divided by 1e18, nothing else
data word 4protocolFee in wei
data word 5creatorFee 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
);
  • isBuy is not indexed. You cannot topic-filter buys from sells. Decode and branch.
  • ethAmount is 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.
  • protocolFee and creatorFee are literal transfer amounts, not rates, so a fee change needs no handling on your side.
  • priceX18 is 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}/logo

ETag-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" component

Every 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 submitter field 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

On this page