moto.fun

Contracts

The curve singleton, the token template, the read lens, and the shared creator-fee pair. Call surface, events, and the traps.

Five contracts on the trading path. Two of them (CreatorFeeRegistry, CreatorFeeVault) are the same deployments the DEX uses. A sixth, GasTank, funds the keeper and is never called by a user. Full per-contract surface with every function, event, error, constant and access rule, read from source at a named commit: moto.fun contracts inventory.

ContractWhat it doesUpgradeable
LaunchpadCurveThe singleton: launch, buy, sell, graduate, PvE escrow, fee routing, the MOTO price checkpointYes. A UUPS proxy. You call the proxy address, which is the one /config serves. See Who can change the curve
LaunchpadTokenThe ERC-20 template. One EIP-1167 clone per coinNo. No owner, no proxy, one-shot initialize
LaunchpadLensRead-only companion. Exists because the curve sits against the EIP-170 size limitNo
CreatorFeeRegistryWhich coins have a creator fee and whose it isNo. Ownable2Step
CreatorFeeVaultPer-coin, per-quote-asset claim escrowNo. Ownable2Step
GasTankHolds ETH for the keeper and refills it on demand. Ops onlyNo. Ownable2Step, renounce disabled

Three more pieces are deployed alongside the curve and are never called directly: GraduationSeeder, MotoFloorMath and LaunchpadTokenDeployer. They are linked libraries the curve runs by delegatecall, split out only to keep the curve under the contract size limit. Their code executes as the curve, so every event still comes from the curve's address and there is nothing extra to index. The errors they raise surface from the curve call and are declared on the curve's ABI as well, so one ABI decodes everything.

The curve also depends on PveVault, a canonical Motoswap contract, for PvE escrow. Its surface and the cat-stake mirror that will feed it on future L2 deployments are in the inventory too.

Who can change the curve

Two separate roles, and they are separate on purpose.

  • The owner holds the operational setters: fee rates inside their caps, the graduation bounty, the Collector, the PvE vault and keeper, the pause switches, and the launch parameters for future coins. It is Ownable2Step.
  • The upgrade authority is the only account that can replace the implementation, and it is a timelock, not the owner. Read it with upgradeAuthority(). The owner cannot reassign it: setUpgradeAuthority is gated on the current authority, so a change of authority is itself a delayed action. The timelock is a standard OpenZeppelin TimelockController, so you can check it yourself: getMinDelay() on that address returns the delay in seconds. renounceUpgradeability() is one-way and freezes the implementation for good while every operational setter keeps working.

What this means for an integration: the address you call never changes, because it is the proxy. An upgrade can change behaviour behind it, so watch Upgraded(address indexed implementation) on the curve address and UpgradeAuthoritySet(address indexed previous, address indexed next) if you want to know when that happens.

Nothing the owner can do stops a sell. The pause switches cover launches and buys only.

Curve state and constants

enum Status { None, Trading, Frozen, Graduated }
struct Curve {
  uint112 realEth;
  uint112 tokenReserve;
  Status  status;
  bool    buysPaused;
  uint16  paramsId;   // which launch parameter set prices this coin, fixed at launch
}

mapping(address => Curve)   public curves;      // keyed by coin address, returns all five members
mapping(address => uint256) public launchNonce; // per creator, for intents
mapping(address => address) public creatorOf;   // the signer, not the fee recipient
mapping(address => uint256) public pveAccrued;  // escrow awaiting the graduation credit
mapping(address => uint256) public frozenAt;

curves(token) returns five values. An ABI fragment written with four outputs predates paramsId and should be updated.

ConstantValueMeaning
SUPPLY1_000_000_000 etherFixed supply per coin. The one curve number that is the same for every coin
FLOOR200_000_000 etherParameter set 0. The graduation floor for a coin whose paramsId is 0
VIRTUAL_TOKENS66_666_667 etherParameter set 0. The virtual token reserve for paramsId 0
VIRTUAL_ETH4 ether / 3Parameter set 0. The virtual ETH reserve for paramsId 0
MAX_PROTOCOL_FEE_BPS100Cap on protocolFeeBps
MAX_CREATOR_FEE_BPS50Cap on creatorFeeBps
MAX_BOUNTY0.08 etherCap on graduationBounty
SELL_REOPEN_DELAY15 minutesHow long a Frozen coin waits before sell reopens
MIN_TWAP_WINDOW / MAX_TWAP_WINDOWnot publishedThe MOTO reference window

Live, owner-tunable, read them rather than pinning them: protocolFeeBps (ships at 70), creatorFeeBps (ships at 50, which is also its cap), graduationBounty (ships at 0.015 ether), maxTwapDeviationBps (the MOTO buyback tolerance band, whose value and bounds are deliberately not published here), collector, pveVault.

Each coin has its own curve parameters

FLOOR, VIRTUAL_TOKENS and VIRTUAL_ETH are parameter set 0: what a coin with paramsId == 0 resolves to. That is the set a fresh deployment launches coins under, because a launch is stamped with nextParamsId and that reads 0 until the owner first appends a set. So these are real, in-force numbers for every coin stamped 0. What they are not is a property of the contract: they belong to one parameter set. The owner can append a new parameter set, and every coin launched after that is stamped with the new id and priced by it for life. A coin already on the curve never moves: its id is written once at launch and the parameter table is append-only, so no later change can reach it.

The read is one call:

function paramsOf(address token) view
  returns (uint256 virtualEth, uint256 floorSupply, uint256 virtualTokens);

Use paramsOf(token) everywhere you would have used VIRTUAL_ETH, FLOOR or VIRTUAL_TOKENS. Different coins on the same curve contract can carry different values, so never cache one coin's answer for another.

Related reads: nextParamsId() is the id the next launch will be stamped with, and launchParams(uint16 id) returns one table entry. launchParams(0) returns zeros, not the set 0 constants, because id 0 is not a table entry. paramsOf handles that case for you, which is the reason to prefer it.

The owner moves the dial with setLaunchParams(uint128 virtualEth, uint128 floorSupply), which emits LaunchParamsSet(uint16 indexed id, uint128 virtualEth, uint128 floorSupply, uint256 virtualTokens, uint256 impliedRaise). The virtual token reserve is derived by the contract, never supplied. The implied raise must sit inside an owner-set band (raiseBand(), changed by setRaiseBand, which emits RaiseBandSet). The band's bounds are not published here.

What the dial changes in practice is how much ETH a coin raises before it graduates. Do not assume one figure for every coin.

The pricing math

Constant product against virtual reserves on both sides. There is no pair and no getAmountsOut; this is the whole model:

(uint256 vEth, uint256 floorSupply, uint256 vTok) = paramsOf(token);

// buy: fee off the input, then the swap
uint256 net     = gross - (gross * (protocolFeeBps + creatorFeeBps)) / 10_000;
uint256 ethVirt = vEth + c.realEth;
uint256 tokVirt = vTok + c.tokenReserve;
tokensOut = (tokVirt * net) / (ethVirt + net);

// sell: the swap, then the fee off the output
uint256 grossOut = (ethVirt * tokensIn) / (tokVirt + tokensIn);
ethOut = grossOut - (grossOut * (protocolFeeBps + creatorFeeBps)) / 10_000;

// spot, 1e18 fixed point, the same number Trade.priceX18 carries
price = ((vEth + c.realEth) * 1e18) / (vTok + c.tokenReserve);

Only the net ETH enters realEth. The fee is wrapped to WETH and routed out in the same call. A buy that would take the reserve below floorSupply is filled up to the floor, charged only for what it bought, refunded the rest, and freezes the coin.

Quote through the lens, not your own copy

Three inputs to a quote are live state: protocolFeeBps, creatorFeeBps, and the coin's own curve parameters. LaunchpadLens.quoteBuy, quoteSell and currentPrice read all three at call time, per coin. A local copy with the rates or the set 0 constants baked in is right only for coins stamped 0. For any other coin it is wrong, and by a lot: the error scales with the ratio between the two virtual ETH values.

Write surface

FunctionNotes
launch(string name, string symbol, bytes32 metadataHash, uint256 salt, uint256 pveEth, address feeRecipient) payable returns (address)Direct launch with an optional atomic dev buy. pveEth is the slice of msg.value donated to the PvE escrow. No minTokensOut
buyWithIntent(LaunchIntent intent, bytes signature, uint256 minTokensOut) payable returns (address)The gasless-launch path. Anyone submits an open intent, but only at zero value, which materializes without buying. A funded submission must name its submitter, else ValueNeedsSubmitter. When intent.submitter is set, only that address may submit
buy(address token, uint256 minTokensOut) payable returns (uint256)Ordinary buy
sell(address token, uint256 tokensIn, uint256 minEthOut) returns (uint256)Accepts a Frozen coin once SELL_REOPEN_DELAY has passed, and such a sell returns it to Trading
buyPve(address token, uint256 minTokensOut) payable returns (uint256)Buy at the going rate and donate every coin to the PvE escrow
graduate(address token)Permissionless, signerless, bounty-paid, and not pausable by anyone including the owner. Requires Status.Frozen
pokePriceCheckpoint()Permissionless. Advances the MOTO/WETH reference. Buys self-call it opportunistically
claimBounty(address to) returns (uint256)Withdraw a graduation bounty whose push failed
recordPveLate(address token)Owner or pveKeeper only. Retries a PvE credit that failed at graduation

No deadline on curve trades

buy, sell, buyPve and launch take no deadline, by design: there is no LP-side staleness, and minTokensOut / minEthOut are the whole price guard. The only deadline on the user path is intent.deadline inside buyWithIntent. Do not look for a deadline argument to pass.

The buy slippage check is price-equivalent rather than a raw amount, so a floor-crossing partial fill that refunds the remainder still satisfies a minTokensOut set for the full amount. On a full fill it reduces to tokensOut >= minTokensOut.

Read surface: LaunchpadLens

Every function is view and nothing in the contract can move a wei.

FunctionReturns
quoteBuy(address token, uint256 ethIn)(uint256 tokensOut, uint256 grossUsed, uint256 refund)
quoteLaunch(uint256 ethIn, uint256 pveEth)(uint256 devTokens, uint256 pveTokens, uint256 grossUsed, uint256 refund)
quoteSell(address token, uint256 tokensIn)uint256 ethOut, net of fees
currentPrice(address token)ETH per whole coin, 1e18 fixed point. Does not check status. For a Graduated coin it returns the coin's graduation price, frozen: its price when the reserve reached the floor. The live price is the pair's. For an address the curve never launched it returns a non-zero number too, so read curves(token).status first
bondingProgress(address token)0 to 1e18
predictToken(address creator, uint256 salt)The CREATE2 address launch will deploy
graduationReadiness(address token)(Readiness regime, uint64 readyAt, uint256 minMotoOut, uint256 quotedOut, uint256 deviationBps, uint256 bountyNow, uint256 sellsOpenAt)

Readiness is { Ready, TooFresh, Stale, Missing, OutOfBand, Upstream, NotFrozen }. It never reverts: every cross-domain read is guarded and every failure classified, so a keeper can branch on the regime instead of on a revert string.

quoteLaunch is not optional for a create form. quoteBuy answers a different question and overstates the creator's take by the PvE ratio on a split launch.

motoPoolDeviationBps is retired

LaunchpadLens.motoPoolDeviationBps is kept only for a dual-ABI migration window and is marked stale in the source. Do not build on it, and do not read its usable flag as "graduation will succeed". graduationReadiness is the replacement.

How a coin trades before graduation

Against the curve's own ETH, and nowhere else.

  • There is no pair and no LP while a coin is bonding. Every buy sends ETH to the curve and every sell is paid out of the ETH the curve holds for that coin (realEth). The curve holds the whole unsold supply.
  • The price is a function of that coin's realEth, its tokenReserve, and its own curve parameters. Nothing outside the curve contract can move it.
  • A pool cannot be opened early on the venues the curve covers. At launch the curve derives the coin's future pair addresses on the Motoswap, Uniswap V2 and Sushi factories, against WETH, MOTO and each entry of the owner's blockedQuoteAssets list, and writes them into the coin. Until the coin is bonded, a transfer to any of those addresses reverts BlockedUntilBonded. An external venue that is not configured on a given deployment is skipped.
  • That is a list of addresses, not a general rule. See The token template for exactly what it does not cover.

A router or aggregator should therefore treat a bonding coin as having one venue, the curve, and quote it through LaunchpadLens. Do not route it through a pair, and do not synthesize a pair address for it.

What graduation does

graduate(token) is one transaction, callable by anyone, and it either opens both pools or does nothing at all.

  1. The coin must be Frozen, which happens when a buy takes tokenReserve down to the coin's floorSupply. Otherwise NotFrozen.
  2. The pot is the coin's realEth. The caller's bounty comes off first: graduationBounty, capped at one fiftieth of the pot. The rest is split in half. One half is the WETH side of TOKEN/WETH. The other half buys MOTO through the FeeRouter and becomes the MOTO side of TOKEN/MOTO.
  3. Both pools get their token side at the curve's closing spot price, (virtualEth + pot) / (floorSupply + virtualTokens). That is what "seeded at the final curve price" means: the first pool price equals the last curve price.
  4. The MOTO buy happens before the coin is bonded, so the pre-bond blocklist still covers the TOKEN/MOTO address right up to the moment the curve seeds it. Then setBonded() clears the blocklist for good.
  5. Each pair is looked up with getPair and created only if it does not exist. If a pair already exists with reserves, the curve matches that pair's live ratio rather than minting blind.
  6. Both LP positions are minted to 0x000000000000000000000000000000000000dEaD. Nothing is kept by the curve, the caller or the protocol, and there is no lock contract or unlock date.
  7. Coins left over after both pools are seeded are sent to the same burn address and reported as dustBurned. Leftover MOTO or WETH goes to the Collector.
  8. The PvE escrow, if the coin has one, is credited to Motocat stakers in the same transaction. If the vault cannot take the credit, graduation still completes, PveRecordFailed is emitted, and the credit is retried later through recordPveLate. A holder's exit never depends on the vault.

Two pools or nothing. If the TOKEN/MOTO leg comes out too small to mint, the whole transaction reverts MotoLegTooSmall(). There is no one-pool graduation and Graduated is never emitted with a zero pairMoto. The other reverts on this path are just as clean: MotoOutBelowFloor(got, need) when the MOTO buy fills below its price floor, MotoPoolUnreadable() when the MOTO/WETH pool or a fee read cannot be used, and MotoRefMissing, MotoRefTooFresh(readyAt) or MotoRefStale when the price reference is not usable yet. In every case nothing is spent, nothing is owed, the coin stays Frozen, and the next attempt can succeed. Holders are not locked in while that happens: sell reopens SELL_REOPEN_DELAY after the freeze.

A keeper should call LaunchpadLens.graduationReadiness(token) first. It classifies all of the above without reverting.

Events

The full catalog with indexed fields is in the inventory. The five an indexer needs:

event TokenCreated(address indexed token, address indexed creator, string name, string symbol, bytes32 metadataHash, address indexed quoteToken);

event Trade(
  address indexed trader,
  address indexed token,
  bool    isBuy,
  uint256 ethAmount,    // curve-leg gross ETH: fee-inclusive on buys, pre-fee on sells
  uint256 tokenAmount,
  uint256 priceX18,     // spot after the trade, 1e18
  uint256 protocolFee,  // the literal WETH transfer to the Collector
  uint256 creatorFee    // the literal WETH deposit into the vault
);

event FloorReached(address indexed token, uint256 realEth);

event Graduated(
  address indexed token,
  address pairWeth, address pairMoto,
  uint256 wethLp, uint256 motoLp,
  uint256 tokensLpWeth, uint256 tokensLpMoto,
  uint256 dustBurned,
  address indexed keeper
);

event PveFill(address indexed token, address indexed contributor, uint256 tokens);

Note what is not indexed. Trade.isBuy is a data field, so you cannot topic-filter buys from sells. Graduated indexes token and keeper but not the pairs, so pair discovery means decoding the data.

Topic hashes, so you can filter without compiling anything. Each is keccak256 of the canonical signature shown:

EventCanonical signaturetopic0
TokenCreatedTokenCreated(address,address,string,string,bytes32,address)0x6223a619ba904a9914dac4461d25f8f27e372c0a030791c3a5c012768765ef45
TradeTrade(address,address,bool,uint256,uint256,uint256,uint256,uint256)0x2c76e7a47fd53e2854856ac3f0a5f3ee40d15cfaa82266357ea9779c486ab9c3
FloorReachedFloorReached(address,uint256)0xc1d2a70654f792a9ba0353d25aaa2722a5f588f85d4560d21324f4e2d3d730e6
GraduatedGraduated(address,address,address,uint256,uint256,uint256,uint256,uint256,address)0x79d3d3525cbe6d1919438aa800976255676f9242b78cb45587085d4a63a9ba31

TokenCreated has four topics: the hash, token, creator, quoteToken. name, symbol and metadataHash are in the data. Graduated has three topics: the hash, token, keeper. Its data is seven 32-byte words in declaration order, so pairWeth is word 0 and pairMoto is word 1.

Which asset a coin is quoted in

On the curve, always ETH. Buys are paid in ETH, sells pay out ETH, Trade.ethAmount and both fee fields are wei, and Trade.priceX18 is ETH per whole coin. There is no per-coin quote asset to look up while a coin is bonding, and TokenCreated names it: quoteToken is the curve's WETH address on every launch. After graduation a coin has two pools, TOKEN/WETH and TOKEN/MOTO, and both addresses are in Graduated.

Also worth catching: FeeRecipientSet (fires only when the recipient differs from the creator), CreatorFeeRoutedToCollector (the creator leg failed over to the protocol), PveRecorded / PveRecordFailed, BountyOwed / BountyClaimed, and LaunchParamsSet (new coins will be priced by a new parameter set from this block on).

The token template

Fixed 1e27 supply, 18 decimals, no owner, no mint, no tax, hand-written ERC-20. Two behaviors are active only while unbonded and are cleared permanently by setBonded() at graduation:

  • A pre-bond transfer blocklist. At launch the curve derives the coin's pair address on three venues against WETH, MOTO and any blocked quote assets, and writes them into the clone. Transfers to those addresses revert BlockedUntilBonded, so nobody can seed a pool before graduation does.
  • A narrow curve allowance exemption, only when msg.sender == curve && to == curve, which is the gasless sell pull.

The blocklist is a list, not a rule: an unlisted quote asset, a V3 or V4 pool, or an unknown V2 fork sits outside it. Treat pre-bond pool liquidity as possible but unsupported, not as impossible.

Errors

Decodable custom errors throughout. The full table is in the inventory. Two to special-case:

Two error traps

claimBounty reverts PveNothingToRecord() when there is no bounty owed. The name does not match the condition, so do not treat that selector as PvE-specific.

MotoRefTooFresh(uint64 readyAt) carries the timestamp the reference matures at. A keeper should sleep until readyAt rather than retrying blind.

A launch can also be refused by the creator-fee registration. Each of the three refusals below is a revert, so a coin never launches without its fee registered. The one benign case is not a refusal: if the registry rejects the call because the coin is already registered to this same creator, the launch simply proceeds.

ErrorMeaning
CreatorAlreadyClaimed()The registry already holds a different creator for this coin address. Coin addresses are predictable before launch, so this is treated as a hijack attempt
RegistryUnreadable()The registry could neither register the coin nor be read back
RegistryRefused()The registry refused and the coin's slot is still empty, which happens only when the curve is not a registrar on the registry. Nothing to fix on the caller's side: retry once that is restored

CreatorFeeUnregistered(address indexed token, address indexed creator) is still declared on the ABI, and nothing on the current implementation emits it. Keep it in your ABI only if you decode logs from before that change.

Fees, in one place

WhereRatePaid inGoes to
Curve trade, protocol legprotocolFeeBps, currently 70 (0.70%), capped at 100WETHThe Collector, the same one DEX fees reach
Curve trade, creator legcreatorFeeBps, currently 50 (0.50%), capped at 50WETHCreatorFeeVault, credited to the coin
After graduationThe normal Motoswap 1% swap fee, plus the DEX creator fee, currently 0.30% and read live from FeeRouter.creatorFeeBpsThe quote asset of the swapAs on any Motoswap pool with a creator fee

Both curve legs come off the ETH side, on buys and sells alike: off the input on a buy, off the output on a sell. All of these rates are live contract state, so read them rather than pinning them. Trade.protocolFee and Trade.creatorFee are the literal amounts moved, which means a rate change needs no handling in a pipeline that sums them.

If the registry reports no active creator for a coin (its fee is disabled), the whole fee goes to the Collector and the trader pays exactly the same rate. If the registry or vault cannot be read at all, the same thing happens and CreatorFeeRoutedToCollector(token, amount) is emitted. That event marks an outage, not a configuration.

Creator fees

CreatorFeeRegistry and CreatorFeeVault are shared with the DEX, so a moto.fun coin claims through exactly the same path as any other token with a creator fee. Two reads that must not be conflated:

  • activeCreator(token) is the hot-path read and returns zero for unregistered or disabled.
  • creatorOf(token) returns the creator regardless of the disabled flag, and it is what gates claim. Disabling a coin stops future accrual without freezing money already earned.

claim(address token, address[] quoteAssets, bool unwrapWeth) is caller-gated to registry.creatorOf(token). Zero balances are skipped rather than reverting. claimMany(address[] tokens, address[] quoteAssets, bool unwrapWeth) does the same across several coins in one transaction, and reverts the whole batch if any listed coin is not the caller's.

A creator can move their own fee stream with CreatorFeeRegistry.setCreator(token, newCreator), gated on creatorOf. Three consequences worth building around:

  • Claim before you rotate. The vault accrues per coin, not per creator, and claim pays whoever creatorOf(token) is at the moment of the claim. Everything unclaimed moves to the new creator with the rotation. If the old address is meant to keep what it earned, it has to claim first.
  • It works on a disabled coin too. setCreator checks only that the caller is the current creator. It is not admin-only, and a disabled fee does not freeze the recipient.
  • One rotation emits two events. The self path fires CreatorSet and CreatorSetBySelf together; the owner path (adminSetCreator) fires CreatorSet alone. Treat CreatorSet as the record of the change and CreatorSetBySelf as the label on it. Counting both double-counts every self-redirect.

A coin address can be registered once, ever. There is no unregister and no re-register.

After graduation the creator fee is an extra charge on the quote leg of a swap, on top of the protocol fee, not a share taken out of it. A swap between two coins that both have an active creator pays it twice, once to each creator.

Who registered a coin

The registry records it on chain, in an event whose three fields are all indexed:

event TokenRegistered(address indexed token, address indexed creator, address indexed registrar);

The registry's address is addresses.creatorFeeRegistry in /config, and the curve will also tell you on chain: LaunchpadCurve.registry() returns the registry and vault() the fee vault.

registrar is the address that made the registration. For a moto.fun coin it is the curve, because the curve registers every coin inside the launch. Filter TokenRegistered on the registry by token to read it for one coin, or by registrar equal to the curve address to list every coin the curve registered. creator here is the fee recipient the launch named, which is not always the signer: compare it with LaunchpadCurve.creatorOf(token) when you need the signer.

The app backend's GET /tokens/{address} serves this too: registeredBy (the registering address, or null for rows indexed before the field existed) and selfRegistered (true only when the registrar is the coin itself). The event above stays the canonical source. A coin can register its own address only while the registry's open registration is on (OpenRegistrationSet), so a registrar equal to the coin marks that path.

Where to go next

On this page