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.
| Contract | What it does | Upgradeable |
|---|---|---|
LaunchpadCurve | The singleton: launch, buy, sell, graduate, PvE escrow, fee routing, the MOTO price checkpoint | Yes. A UUPS proxy. You call the proxy address, which is the one /config serves. See Who can change the curve |
LaunchpadToken | The ERC-20 template. One EIP-1167 clone per coin | No. No owner, no proxy, one-shot initialize |
LaunchpadLens | Read-only companion. Exists because the curve sits against the EIP-170 size limit | No |
CreatorFeeRegistry | Which coins have a creator fee and whose it is | No. Ownable2Step |
CreatorFeeVault | Per-coin, per-quote-asset claim escrow | No. Ownable2Step |
GasTank | Holds ETH for the keeper and refills it on demand. Ops only | No. 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:setUpgradeAuthorityis gated on the current authority, so a change of authority is itself a delayed action. The timelock is a standard OpenZeppelinTimelockController, 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.
| Constant | Value | Meaning |
|---|---|---|
SUPPLY | 1_000_000_000 ether | Fixed supply per coin. The one curve number that is the same for every coin |
FLOOR | 200_000_000 ether | Parameter set 0. The graduation floor for a coin whose paramsId is 0 |
VIRTUAL_TOKENS | 66_666_667 ether | Parameter set 0. The virtual token reserve for paramsId 0 |
VIRTUAL_ETH | 4 ether / 3 | Parameter set 0. The virtual ETH reserve for paramsId 0 |
MAX_PROTOCOL_FEE_BPS | 100 | Cap on protocolFeeBps |
MAX_CREATOR_FEE_BPS | 50 | Cap on creatorFeeBps |
MAX_BOUNTY | 0.08 ether | Cap on graduationBounty |
SELL_REOPEN_DELAY | 15 minutes | How long a Frozen coin waits before sell reopens |
MIN_TWAP_WINDOW / MAX_TWAP_WINDOW | not published | The 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
| Function | Notes |
|---|---|
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.
| Function | Returns |
|---|---|
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, itstokenReserve, 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
blockedQuoteAssetslist, and writes them into the coin. Until the coin is bonded, a transfer to any of those addresses revertsBlockedUntilBonded. 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.
- The coin must be
Frozen, which happens when a buy takestokenReservedown to the coin'sfloorSupply. OtherwiseNotFrozen. - 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 theFeeRouterand becomes the MOTO side of TOKEN/MOTO. - 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. - 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. - Each pair is looked up with
getPairand 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. - 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. - 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. - 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,
PveRecordFailedis emitted, and the credit is retried later throughrecordPveLate. 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:
| Event | Canonical signature | topic0 |
|---|---|---|
TokenCreated | TokenCreated(address,address,string,string,bytes32,address) | 0x6223a619ba904a9914dac4461d25f8f27e372c0a030791c3a5c012768765ef45 |
Trade | Trade(address,address,bool,uint256,uint256,uint256,uint256,uint256) | 0x2c76e7a47fd53e2854856ac3f0a5f3ee40d15cfaa82266357ea9779c486ab9c3 |
FloorReached | FloorReached(address,uint256) | 0xc1d2a70654f792a9ba0353d25aaa2722a5f588f85d4560d21324f4e2d3d730e6 |
Graduated | Graduated(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.
| Error | Meaning |
|---|---|
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
| Where | Rate | Paid in | Goes to |
|---|---|---|---|
| Curve trade, protocol leg | protocolFeeBps, currently 70 (0.70%), capped at 100 | WETH | The Collector, the same one DEX fees reach |
| Curve trade, creator leg | creatorFeeBps, currently 50 (0.50%), capped at 50 | WETH | CreatorFeeVault, credited to the coin |
| After graduation | The normal Motoswap 1% swap fee, plus the DEX creator fee, currently 0.30% and read live from FeeRouter.creatorFeeBps | The quote asset of the swap | As 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 gatesclaim. 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
claimpays whoevercreatorOf(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.
setCreatorchecks 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
CreatorSetandCreatorSetBySelftogether; the owner path (adminSetCreator) firesCreatorSetalone. TreatCreatorSetas the record of the change andCreatorSetBySelfas 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
- Full contracts inventory for every signature.
- Addresses for how to resolve the curve and lens at runtime.
- Trading bots for the buy and sell loop.
- Scanners for the indexing rules.