moto.fun Contracts - Full Inventory
Complete API reference for external integrators - every moto.fun Solidity contract, function, event, error, constant, access rule and invariant, plus the canonical contracts the curve depends on.
Every signature on this page was read from the contract source, not paraphrased from a design document, and where the source and an older description of it disagree, the source wins. It describes what the deployed contracts do.
The moto.fun contracts are LaunchpadCurve with its three linked libraries (GraduationSeeder,
MotoFloorMath, LaunchpadTokenDeployer), LaunchpadToken, LaunchpadLens and GasTank. The
canonical Motoswap contracts the curve depends on follow in Part 2: CreatorFeeRegistry,
CreatorFeeVault, PveVault, MirrorRegistry, the three L1 outboxes, the two L2 receivers and the
broadcast surface of MotocatStakingV3. The moto.fun repo carries its own copy of the creator-fee
pair, identical to the canonical pair apart from import paths and a header comment, so one
description serves both.
Two rules that apply to the whole page:
- Resolve every address at runtime.
GET /configon the Motoswap API and/configon the moto.fun backend are the sources. See addresses. Nothing here is a value to hardcode. - Some parameter values are deliberately withheld. The MOTO reference window bounds, the buyback tolerance band and its bounds, and the blocked-quote cap are named but never printed on this site. Read them from the deployed contract. Every place they would appear says so.
Each contract section follows the same shape: purpose, constructor, constants, state, every external and public function with its signature, meaning and access rule, every event with its fields, every custom error with the condition that raises it, and the invariants the contract keeps. Access is stated per function. Where a function carries no access note it is callable by anyone.
Part 1: the moto.fun repo
LaunchpadCurve (contracts/src/LaunchpadCurve.sol)
The singleton. Every coin's bonding curve lives in this one contract: launch, buy, sell, graduate,
PvE escrow, fee routing, and the MOTO price checkpoint. Constant product against virtual reserves on
both sides. Launching is an EIP-712 signature or a direct call; the coin materializes as an EIP-1167
clone inside the first transaction that names it. When a coin's reserve reaches its floor supply the
curve freezes it and a permissionless, bounty-paid graduate buys MOTO and seeds two Motoswap pools
with the LP burned.
A UUPS proxy. Inherits Initializable, Ownable2StepUpgradeable, UUPSRenounceable,
ReentrancyGuardTransient and EIP712, with the domain ("MotoswapLaunchpad", "1"). Solidity
0.8.26. EIP712 is deliberately the non-upgradeable base: under delegatecall it rebuilds the
domain separator against the proxy address, which is the address signatures must verify against.
The implementation's constructor only disables initializers. Everything a deploy configures is set
once by initialize, in the proxy's context, in the same transaction that deploys the proxy.
Three linked libraries carry code that would not fit under the EIP-170 size limit otherwise:
GraduationSeeder (the committed half of graduation), MotoFloorMath (the graduation split and the
launch-parameter derivation) and LaunchpadTokenDeployer (deploys the token implementation). The
curve runs them by delegatecall, so they act on the curve's balances and emit nothing of their
own. Their errors are declared on the curve as well, so the curve's ABI decodes them.
There is no receive() and no fallback(). ETH enters only through the payable functions. There is
deliberately no withdraw and no sweep: curve ETH is never sweepable, by anyone.
Initializer
struct Config {
address owner;
address registry; // CreatorFeeRegistry
address vault; // CreatorFeeVault
address weth;
address moto;
address motoFactory;
address feeRouter;
address uniFactory; // zero disables the venue
bytes32 uniPairInitCodeHash;
address sushiFactory; // zero disables the venue
bytes32 sushiPairInitCodeHash;
address collector;
address pveVault; // zero disables PvE fills
address upgradeAuthority; // the timelock. Not the owner. Zero is refused
address[] blockedQuoteAssets; // extra quotes blocked pre-bond alongside WETH and MOTO
}
constructor() EIP712("MotoswapLaunchpad", "1") // disables initializers, nothing else
function initialize(Config calldata c) external initializerinitialize refuses, in order: a zero registry, vault, weth, moto, motoFactory,
feeRouter or collector (ZeroAddress); a codeless motoFactory, feeRouter, weth or moto
(NotAContract); a venue with a factory but no init-code hash or the reverse (VenueHalfConfigured);
a non-zero codeless pveVault (NotAContract); moto == weth (ZeroAddress); a codeless registry
or vault (NotAContract); a zero upgradeAuthority (ZeroAddress); and a blockedQuoteAssets
list above MAX_BLOCKED_QUOTES (TooManyQuotes). It emits UpgradeAuthoritySet(0, authority) and
then deploys LaunchpadToken through LaunchpadTokenDeployer, from the proxy's context, so
tokenImplementation is always the curve's own and every clone's curve is the proxy.
The code-length checks matter more than they look. Solidity's extcodesize check before a high-level
call runs in the caller's frame, outside any try/catch, so a codeless dependency would revert every
graduation forever with no lever on the curve's side. Failing at deploy is the only cheap place.
Constants
SUPPLY = 1_000_000_000 ether(1e27) - total supply per coin. Must equalLaunchpadToken.LAUNCH_SUPPLYFLOOR = 200_000_000 ether(2e26) - parameter set0: the graduation floor of a coin whoseparamsIdis0, which is 80% sold. A coin's own floor is the second member ofparamsOf(token)VIRTUAL_TOKENS = 66_666_667 ether- parameter set0: virtual token reserve, the zero-burn conditionFLOOR^2 / (SUPPLY - 2 * FLOOR)rounded up to a whole token. The sub-token remainder becomes graduation dustVIRTUAL_ETH = uint256(4 ether) / 3- parameter set0: virtual ETH reserve. Under these three values the raise to graduation is 3x this, about 4 ETH, and the graduation market cap is 15x this, about 20 ETH. A coin's own is the first member ofparamsOf(token). Set0is what a fresh deployment launches coins under, since a launch is stamped withnextParamsIdand that is zero until the firstsetLaunchParams. The source calls these three the legacy values because a later set can supersede them for new coins, not because they are unused; they keep their names so the ABI did not move. Building on them is correct only for coins stamped0BPS_DENOM = 10_000MAX_PROTOCOL_FEE_BPS = 100- cap onprotocolFeeBps(1.00%)MAX_CREATOR_FEE_BPS = 50- cap oncreatorFeeBps(0.50%)MAX_BOUNTY = 0.08 ether- cap ongraduationBounty. A constant, not a gas-scaled formula: a bounty that readtx.gaspriceorblock.basefeewould be a lever an attacker sets, carved from the coin's own raiseSELL_REOPEN_DELAY = 15 minutes- how long a coin sitsFrozenbeforesellreopens. A dead-man's switch for a graduation that is broken upstream, not a wait anyone sees in ordinary operation. Not zero, so a 1-wei sell cannot knock a coin out ofFrozenin the block before its graduation lands; not unbounded, so a competitor cannot hold every frozen coin hostage by keeping graduation brokenMIN_TWAP_WINDOW: uint32- the shortest window the MOTO reference is judged over. Below it the reference is too fresh to price against. Not published here. Read it from the contractMAX_TWAP_WINDOW: uint32- the longest window the reference is judged over. Past it the average is too old to stand for the market. A fixed multiple ofMIN_TWAP_WINDOW, and the multiple is load-bearing: the two-slot ring floats between one and two minimums in ordinary operation, so a ceiling at twice the minimum would be struck routinely. Not published here. Read it from the contractTAIL_ANCHOR_MAX: uint32- how recently the MOTO/WETH pair must have written its own cumulative for a checkpoint to anchor on that write rather than on an extrapolation from current spot. Capped so the anchor can never be older than the shortest window the floor will measure over. Not published hereMAX_TWAP_DEVIATION_CAP_BPS/MIN_TWAP_DEVIATION_BPS- the ceiling and floor onmaxTwapDeviationBps. There is no "off" setting: with an atomic buyback, zero would not defer the MOTO leg, it would stop every graduation. Deliberately not published hereMAX_BLOCKED_QUOTES- cap onblockedQuoteAssets. Every entry rides all three venues, so each costs three pair derivations and three cold stores per launch. Not published here. Read it from the contractLAUNCH_INTENT_TYPEHASH = keccak256("LaunchIntent(address creator,address submitter,string name,string symbol,bytes32 metadataHash,uint256 salt,uint256 nonce,uint256 deadline,address feeRecipient)")
Types
enum Status { None, Trading, Frozen, Graduated }
/// One slot: 112 + 112 + 8 + 8 + 16 = 256 bits.
struct Curve { uint112 realEth; uint112 tokenReserve; Status status; bool buysPaused; uint16 paramsId; }
/// One launch parameter set. Written once when appended, never edited.
struct LaunchParams { uint128 virtualEth; uint128 floorSupply; uint256 virtualTokens; }
struct RaiseBand { uint128 minRaise; uint128 maxRaise; }
struct LaunchIntent {
address creator;
address submitter; // zero means anyone may submit, at zero value only
string name;
string symbol;
bytes32 metadataHash;
uint256 salt;
uint256 nonce;
uint256 deadline;
address feeRecipient; // zero means the creator
}Status.Frozen means the floor was reached and graduation is pending; all trading is paused in that
window, and graduate is the only way out of it besides the SELL_REOPEN_DELAY timer. A Frozen
curve always has tokenReserve == FLOOR, because the partial-fill clamp is the only way in.
LaunchIntent.submitter == address(0) means anyone may materialize the intent, but only with zero
value. A creator who intends to send their own first buy through an intent must set submitter to
themselves; see ValueNeedsSubmitter. feeRecipient is covered by the signature, so it cannot be
swapped in transit; the registry owner is the only lever afterwards.
State variables (public getters)
curves(address token) -> (uint112 realEth, uint112 tokenReserve, Status status, bool buysPaused, uint16 paramsId)- five values.paramsIdis stamped at launch fromnextParamsIdand never written againlaunchNonce(address creator) -> uint256- the per-creator EIP-712 nonce. Consumed bybuyWithIntentonly;launchnever reads itfrozenAt(address token) -> uint256- when the coin froze. Starts the sell-reopen clockpveAccrued(address token) -> uint256- running PvE escrow. Every fill adds here; cleared only by a successful credit, so a failed one stays retryablepveVaultOf(address token) -> address- the vault a coin's PvE fills were paid into, pinned at the first fill and never changedcreatorOf(address token) -> address- the wallet that signed the launch. The coin's identity and the PvE credit's beneficiary. Distinct from the fee recipient, which lives in the registrybountyOwed(address keeper) -> uint256- graduation bounty that could not be pushed to its keepermotoPriceCumulativeLast: uint256/motoPriceTimestampLast: uint32- the newer checkpoint slotmotoPriceCumulativePrev: uint256/motoPriceTimestampPrev: uint32- the older checkpoint slot, kept so the measured window never collapses on a refreshcollector: address- protocol fee destinationmaxTwapDeviationBps: uint256- the buyback's tolerance band. How far below the reconstructed floor the graduation swap may fill, and equally how far above it seeded MOTO may run before the surplus goes to the Collector. Its value is deliberately not published here; read it from the contractblockedQuoteAssets(uint256 i) -> address- the extra quote assets whose pairs are blocked pre-bond. Owner-maintained; a coin keeps whatever list existed at its own launchpveVault: address- the PvE escrow. Zero disables fills. Only steers coins that have not escrowed yetpveKeeper: address- the second address, besides the owner, allowed to callrecordPveLate. Zero means owner-onlyprotocolFeeBps: uint256- ships at70creatorFeeBps: uint256- ships at50, which is also its capgraduationBounty: uint256- ships at0.015 etherlaunchesPaused: bool,allBuysPaused: boolupgradeAuthority: address- the only account that may upgrade the implementation. A timelock, separate fromowner, on a storage slot of its ownnextParamsId: uint16- the id the next launch is stamped with, which is also the newest entry in the parameter table. Zero until the firstsetLaunchParams, and zero means parameter set0, the three constants aboveraiseBand() -> (uint128 minRaise, uint128 maxRaise)- the band a new parameter set's implied raise must sit inside. Unset (minRaise == 0) until the owner sets it, and unset refuses everysetLaunchParams. Its bounds are not published here
Set once by initialize (public getters)
These were immutables before the proxy and are storage now, because an immutable lives in the
implementation's code and would carry the wrong value under delegatecall. None has a setter.
tokenImplementation: LaunchpadToken- deployed insideinitializeregistry: CreatorFeeRegistry,vault: CreatorFeeVaultWETH: IWETH,MOTO: addressmotoFactory: IMotoSwapFactory,feeRouter: IFeeRouteruniFactory: address/uniPairInitCodeHash: bytes32sushiFactory: address/sushiPairInitCodeHash: bytes32
External and public functions: the user path
launch(string name, string symbol, bytes32 metadataHash, uint256 salt, uint256 pveEth, address feeRecipient) payable nonReentrant returns (address token)- anyone. The direct launch, with an optional atomic dev buy ofmsg.value.pveEthis the slice of that value donated to the PvE escrow:0is a plain dev buy,msg.valueis an all-PvE launch, anything between is both at once from one curve fill at one price. RevertsPveExceedsValueifpveEth > msg.value, andPveUnavailableifpveEth > 0whilepveVaultis zero. The internal buy runs withminTokensOut = 0, which is safe here and only here: the curve was created in the same call and nobody else can be party to it. The split is measured againstgrossUsed, notmsg.value, so a floor-hit partial fill fills the donation first and the creator's own slice absorbs the shortfall. PvE tokens go straight to the vault, never through the creator's balancebuyWithIntent(LaunchIntent intent, bytes signature, uint256 minTokensOut) payable nonReentrant returns (address token)- anyone, subject to the intent. The offline-deploy path. Checks in order:IntentExpiredpastintent.deadline;NotTheSubmitterifintent.submitteris set and is notmsg.sender;ValueNeedsSubmitterifintent.submitteris zero andmsg.value > 0;BadNonceifintent.nonce != launchNonce[intent.creator];BadIntentSignatureif ECDSA recovery oflaunchIntentDigest(intent)is notintent.creator. Then bumps the nonce, materializes the coin bound to the signer, and if value was sent runs an ordinary buy credited tomsg.sender. Zero value materializes without buying; that is the relayer path and it stays open to anyonebuy(address token, uint256 minTokensOut) payable nonReentrant returns (uint256 tokensOut)- anyone. Ordinary buy; tokens land withmsg.sendersell(address token, uint256 tokensIn, uint256 minEthOut) nonReentrant returns (uint256 ethOut)- anyone. PullstokensInwithsafeTransferFrom(the token's narrow curve exemption means no approval is needed while unbonded), pays the net ETH. Accepts aFrozencoin onceSELL_REOPEN_DELAYhas passed sincefrozenAt, and such a sell first flips the coin back toTrading, because it is no longer at the floor and no longer a graduation candidatebuyPve(address token, uint256 minTokensOut) payable nonReentrant returns (uint256 tokensOut)- anyone. Buy the curve at the going rate and donate every coin to the PvE escrow the coin is pinned to. The buyer pays normal fees, gets aTradeunder their address, and keeps nothing. Only while the coin isTrading: aFrozencoin is before graduation but revertsNotTrading, like any other buy
External and public functions: the keeper path
graduate(address token) nonReentrant- anyone. Permissionless, signerless, bounty-paid, atomic, and not pausable by anyone including the owner. RequiresStatus.Frozen, elseNotFrozen. One transaction buys the coin's MOTO and opens both pools, or the whole thing reverts at a few staticcalls' worth of gas and the next block retries. The sequence is in the graduation section belowpokePriceCheckpoint()- anyone. Deliberately notnonReentrant, because every buy re-enters it through a gas-capped external self-call. Seeds or advances the MOTO/WETH price checkpoint. RevertsNoMotoPoolif the pair cannot be read, andCheckpointFreshif the last roll is younger thanMIN_TWAP_WINDOW. The rate limit is not conditioned on whether a usable reference exists, on purpose: rolling is what advances the older slot, so an unthrottled poke would pin the measured window one block wide foreverrecordPveLate(address token) nonReentrant- owner orpveKeeperonly, elseNotAuthorized. Retries a PvE credit that failed at graduation. RequiresStatus.Graduated(NotGraduated) and a non-zeropveAccrued(PveNothingToRecord). Gated because the vault sizes the credit at the caller's block, so a permissionless caller could stake just in time and take a share the graduation-time stakers earnedclaimBounty(address to) nonReentrant returns (uint256 amount)- anyone with a balance inbountyOwed.tois explicit because a credit exists precisely when the keeper could not receive ETH. RevertsZeroAddresson a zerotoandPveNothingToRecordon a zero balance
External and public functions: views
paramsOf(address token) view returns (uint256 virtualEth, uint256 floorSupply, uint256 virtualTokens)- the curve parameters in force for one coin. This is the read, not the three set0constants. An address with no curve answers the set0triple rather than reverting, which is not what a fresh launch would get once a parameter set exists: a preview of an unlaunched coin must resolvenextParamsId, asLaunchpadLens.quoteLaunchdoeslaunchParams(uint16 id) view returns (uint128 virtualEth, uint128 floorSupply, uint256 virtualTokens)- one entry of the parameter table. Id0is not an entry and reads as zeros, not as the set0constants. UseparamsOftwapRef() view returns (bool ok, uint256 refCum, uint32 elapsed)andtwapRefAt(uint32 nowTs) view returns (bool ok, uint256 refCum, uint32 elapsed)- which checkpoint slot the MOTO reference would be measured from, at the current block or at a given timestamp.okdescribes the checkpoint ring alone. It is not a prediction thatgraduatewill succeed;LaunchpadLens.graduationReadinessanswers thatupgradesRenounced() view returns (bool)- inherited fromUUPSRenounceablelaunchIntentDigest(LaunchIntent intent) view returns (bytes32)- the exact EIP-712 digest a creator signs. Hashesnameandsymbolaskeccak256(bytes(...))per the EIP-712 string rule. Public so the backend, tests and frontend derive it from one implementation- Every public state variable listed above
- Inherited:
owner(),pendingOwner(),eip712Domain(),proxiableUUID()
External functions: admin (all onlyOwner)
setFees(uint256 protocolBps, uint256 creatorBps)- revertsFeeAboveCapabove either cap. EmitsFeesSetsetGraduationBounty(uint256 bounty)- revertsBountyAboveCapaboveMAX_BOUNTY. EmitsGraduationBountySetsetCollector(address collector_)- zero revertsZeroAddress. EmitsCollectorSetsetPveVault(address escrow)- zero disables PvE fills for coins that have not escrowed yet.address(this)revertsZeroAddress(every fill would be a no-op transfer that still creditspveAccrued, stranding the coins with no sweep). A non-zero codeless address revertsNotAContract. EmitsPveVaultSet. Never moves a coin that already haspveVaultOfsetsetMaxTwapDeviationBps(uint256 bps)- bounded in both directions byMIN_TWAP_DEVIATION_BPSandMAX_TWAP_DEVIATION_CAP_BPS, elseDeviationOutOfRange. The only owner lever over the buyback. EmitsMaxTwapDeviationBpsSetsetRaiseBand(uint128 minRaise, uint128 maxRaise)- a zero floor or a floor above the ceiling revertsRaiseBandOutOfRange. Gates what the nextsetLaunchParamsmay append and never touches a coin. EmitsRaiseBandSetsetLaunchParams(uint128 virtualEth, uint128 floorSupply) returns (uint16 id)- appends a parameter set. Takes no id, so there is no path that edits an existing entry. RevertsRaiseBandUnsetbefore a band exists,FloorSupplyOutOfRangefor afloorSupplyof zero or too close to half ofSUPPLY, andRaiseOutOfBandwhen the implied raise falls outsideraiseBand.virtualTokensis derived fromfloorSupplyby the zero-burn relation and never supplied. Every coin launched after the call is priced by the new entry; every coin already launched keeps its own. EmitsLaunchParamsSetsetPveKeeper(address keeper)- any address including zero. No code check, because it authorises a caller rather than being escrowed into. EmitsPveKeeperSetsetBlockedQuoteAssets(address[] quotes)- replaces the list. AboveMAX_BLOCKED_QUOTESrevertsTooManyQuotes; a zero entry revertsZeroAddress. Affects only coins launched after the call. EmitsBlockedQuoteAssetsSetsetLaunchesPaused(bool paused),setAllBuysPaused(bool paused),setTokenBuysPaused(address token, bool paused)- the three pause levers. Sells are never pausable by the owner; they pause only in theFrozenwindow, and that window is bounded bySELL_REOPEN_DELAYrenounceOwnership()- overridden. ClearslaunchesPausedandallBuysPausedfirst, emitting the matching events, so a renounce cannot latch a global pause forever. Per-coinbuysPausedis not cleared because the set is not enumerable- Inherited:
transferOwnership(address)(owner only),acceptOwnership()(pending owner only)
External functions: upgrade authority only
All three revert NotUpgradeAuthority for any other caller, the owner included.
upgradeToAndCall(address newImplementation, bytes data) payable- inherited UUPS. Also revertsUpgradesAreRenouncedonce renouncedsetUpgradeAuthority(address next)- zero revertsZeroAddress. Gated on the current authority, not onowner, so a change of authority inherits the timelock's delay. There is no owner override: lose the authority and upgrades are gone. EmitsUpgradeAuthoritySetrenounceUpgradeability()- one-way. Freezes the implementation for good while ownership and every operational setter keep working. EmitsUpgradesRenounced
Events
event TokenCreated(address indexed token, address indexed creator, string name, string symbol, bytes32 metadataHash, address indexed quoteToken);
event FeeRecipientSet(address indexed token, address indexed recipient);
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 ETH per whole coin after the trade, 1e18 fixed point
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 FeesSet(uint256 protocolFeeBps, uint256 creatorFeeBps);
event GraduationBountySet(uint256 bounty);
event CollectorSet(address collector);
event MaxTwapDeviationBpsSet(uint256 bps);
event RaiseBandSet(uint128 minRaise, uint128 maxRaise);
event LaunchParamsSet(uint16 indexed id, uint128 virtualEth, uint128 floorSupply, uint256 virtualTokens, uint256 impliedRaise);
event PriceCheckpointed(uint256 cumulative, uint32 timestamp);
event BountyOwed(address indexed keeper, uint256 amount);
event BountyClaimed(address indexed keeper, uint256 amount);
event CreatorFeeRoutedToCollector(address indexed token, uint256 amount);
event CreatorFeeUnregistered(address indexed token, address indexed creator);
event BlockedQuoteAssetsSet(address[] quotes);
event LaunchesPausedSet(bool paused);
event AllBuysPausedSet(bool paused);
event TokenBuysPausedSet(address indexed token, bool paused);
event PveVaultSet(address vault);
event PveKeeperSet(address keeper);
event PveFill(address indexed token, address indexed contributor, uint256 tokens);
event PveRecorded(address indexed token, uint256 tokens);
event PveRecordFailed(address indexed token, uint256 tokens);
event UpgradeAuthoritySet(address indexed previous, address indexed next);
// inherited
event Upgraded(address indexed implementation); // ERC-1967, on every implementation change
event UpgradesRenounced(); // UUPSRenounceable
event Initialized(uint64 version);When each fires:
TokenCreatedfires at materialization, on every launch, whichever pathFeeRecipientSetfires only when the recipient differs from the creator. Its absence means the creator is the recipient. Additive, so every existingTokenCreateddecoder keeps workingTradefires on every buy (isBuy = true) and every sell.ethAmountisgrossUsedon a buy andgrossOuton a sell.isBuyis not indexed, so buys cannot be topic-filtered from sells.traderis alwaysmsg.sender, including onbuyPveand on the launch fillFloorReachedfires inside the buy that clamps atFLOORand freezes the coinGraduatedindexestokenandkeeperbut not the pair addresses; pair discovery means decoding the data.motoLpandtokensLpMotoare the amounts actually seeded after price matching, which may be less than the leg's allotmentPriceCheckpointedfires on every successful roll, whether from a poke, a buy's self-call or a deploy script.timestampis the anchor timestamp, which is the pair's own last write when that write was recentBountyOwedfires when the bounty push failed;BountyClaimedwhen it was later pulledCreatorFeeRoutedToCollectorfires when the registry or vault could not be read, or the curve is no longer an authorised depositor, and the creator cut was non-zero. Operators should treat it as an outage. A merely disabled creator fee is silent, because that is a configured stateCreatorFeeUnregisteredis declared and nothing on this implementation emits it. The branch it belonged to, a registry refusal with the coin's slot still empty, now revertsRegistryRefused. The declaration stays so that logs written before that change still decode against the deployed ABILaunchParamsSetfires when the owner appends a parameter set.idis what every launch after that block is stamped with.impliedRaiseis emitted although it is derivable, because it is the number a reviewer of a timelocked call can check by eye, andvirtualTokensbecause it is derived by the contract rather than suppliedRaiseBandSetfires with both halves of the band, since one without the other tells an indexer nothingUpgradeAuthoritySetfires once ininitializewith a zeroprevious, and on everysetUpgradeAuthorityPveFillfires on every escrow fill.contributoris whoever paid the ETH: the creator on a launch slice, anyone onbuyPvePveRecorded/PveRecordFailedfire from the graduation-time credit and fromrecordPveLate. A failure keeps the accrual
Custom errors
ZeroAddress()- constructor zero dependency,moto == weth,setCollector(0),setPveVault(address(this)), a zero entry insetBlockedQuoteAssets,claimBounty(address(0))BadFeeRecipient()- the launch names the curve, the registry, the vault, the PvE vault, the coin itself, WETH, MOTO or the Collector as fee recipient. Fees registered to a protocol contract could never be claimedLaunchesArePaused()- any launch whilelaunchesPausedBuysArePaused()- a buy whileallBuysPausedor the coin'sbuysPausedNotTrading()- a buy on a non-Tradingcurve, or a sell on a curve that is neitherTradingnor aFrozencoin pastSELL_REOPEN_DELAYNotFrozen()-graduateon a non-FrozencurveBadSymbol()- symbol empty or over 16 bytes.BadName()- name empty or over 48 bytesBadIntentSignature()- ECDSA recovery is notintent.creatorNotTheSubmitter()-intent.submitteris set and is notmsg.senderValueNeedsSubmitter()-buyWithIntentcalled with value on an intent whosesubmitteris zero. An open intent's signature is a bearer token the backend publishes; funding it from the creator's own wallet would let a mempool copier take the whole dev buy at the launch price and leave the creator with a spent nonce and a taken address. The zero-value relayer path stays openBadNonce()-intent.nonce != launchNonce[creator]IntentExpired()-block.timestamp > intent.deadlineZeroAmount()- a buy with no value, or a sell of zeroSlippage()-tokensOut == 0, the price-equivalent buy guard, orethOut < minEthOutFeeAboveCap(),BountyAboveCap(),DeviationOutOfRange()- the admin setter boundsNoMotoPool()-pokePriceCheckpointcould not read the MOTO/WETH cumulativeCheckpointFresh()-pokePriceCheckpointinsideMIN_TWAP_WINDOWof the last roll. Costs a buy about 2k gas inside its self-call and nothing elseNotAContract()- a codeless dependency in the constructor, or a codelesssetPveVaultNotAuthorized()-recordPveLatefrom neither owner norpveKeeperVenueHalfConfigured()- a venue factory set without its init-code hash, or the reverseTooManyQuotes()-blockedQuoteAssetsaboveMAX_BLOCKED_QUOTES, in the constructor or the setterEthTransferFailed()- a buy refund, a sell payout or a bounty claim transfer failedPveUnavailable()- a PvE fill whilepveVaultis zero, from either entry pointPveExceedsValue()-launchwithpveEth > msg.valuePveNothingToRecord()-recordPveLatewith zero accrual, and alsoclaimBountywith zero owed. The name does not match the second condition; decode by selectorNotGraduated()-recordPveLatebefore graduationCurveInsolvent()- a sell whose curve-implied gross output exceeds the real ETH on hand. A named invariant break rather than aPanic(0x01), so monitoring can tell this invariant brokeCreatorAlreadyClaimed()- the registry already holds a different creator for this address. A launch that would land a stranger on the fee stream is a hijack and fails closedRegistryUnreadable()- the registry could neither register nor be read back, so the launch's creator-fee claim cannot be verified either wayRegistryRefused()- the registry refused to register the coin and its creator slot is still empty. Reachable from one state only: the curve is not a registrar onCreatorFeeRegistry. Fixed byregistry.setRegistrar(curve, true)from the registry owner, then re-sending the launchRaiseBandOutOfRange()-setRaiseBandwith a zero floor or a floor above the ceilingRaiseBandUnset()-setLaunchParamsbefore anysetRaiseBandFloorSupplyOutOfRange()-setLaunchParamswith afloorSupplyof zero, at or above half ofSUPPLY, or so close to it that the derived virtual token reserve no longer fits. Raised insideMotoFloorMathRaiseOutOfBand()-setLaunchParamswhose implied raise falls outsideraiseBand. Raised insideMotoFloorMathNotUpgradeAuthority()- an upgrade,setUpgradeAuthorityorrenounceUpgradeabilityfrom any account other thanupgradeAuthorityUpgradesAreRenounced()- an upgrade afterrenounceUpgradeability. Inherited fromUUPSRenounceableMotoRefMissing()- no checkpoint has ever been taken. Anyone may fix it withpokePriceCheckpointMotoRefTooFresh(uint64 readyAt)- a checkpoint exists but no slot has servedMIN_TWAP_WINDOWyet. Carries the maturity timestamp of the soonest slot: sleep untilreadyAtrather than retrying blindMotoRefStale()- both slots are pastMAX_TWAP_WINDOW. A permissionless poke re-anchors, and one window later this clearsMotoPoolUnreadable()- the pool, the factory's live swap fee or the FeeRouter's protocol fee could not be read, or answered something no floor can be built from: a zero reserve, a reserve wider thanuint112, a zero average, a fee at or above 100%, an overflow in the reconstruction, or a zero-sized pool leg. It also covers the post-match WETH leg check. Several unrelated conditions share this selectorMotoOutBelowFloor(uint256 got, uint256 need)- the MOTO buyback filled below its floor, measured on the received balance delta. The whole graduation reverts; nothing is spent, nothing is owed, the next block retriesMotoLegTooSmall()- the TOKEN/MOTO leg would be dust (useMoto * useTokens <= 1e6, or either side zero). Raised insideGraduationSeeder.seed. Unreachable at real parameters; it exists so the two-pool guarantee is enforced by a revert-and-retry rather than by a one-pool graduation
The six MotoRef*, MotoPool*, MotoOut* and MotoLeg* errors are expected traffic, not alerts.
Graduation is permissionless and retried every block, so a keeper decodes them and waits.
Access control
| Function | Who | Guard |
|---|---|---|
launch, buy, sell, buyPve | anyone | nonReentrant, pause flags on launches and buys |
buyWithIntent | anyone for a zero-value open intent; intent.submitter when set; a funded call must name a submitter | nonReentrant, nonce, deadline, signature |
graduate | anyone | nonReentrant. Not pausable |
pokePriceCheckpoint | anyone | rate limit only. Not nonReentrant |
claimBounty | anyone with a bountyOwed balance | nonReentrant |
recordPveLate | owner or pveKeeper | nonReentrant |
set* except setUpgradeAuthority, renounceOwnership, transferOwnership | owner | onlyOwner |
upgradeToAndCall, setUpgradeAuthority, renounceUpgradeability | the upgrade authority (a timelock), never the owner | NotUpgradeAuthority |
acceptOwnership | pending owner | Ownable2Step |
No roles and no AccessControl. graduate is explicitly outside the owner's reach, and so is every
sell outside the bounded Frozen window.
Invariants
- Only the net, post-fee ETH enters
realEth; the fee is wrapped and routed out in the same call. SorealEthis always covered by the contract's own ETH balance, and a sell can never draw more than the curve-implied gross output. Buy rounding favours the curve, which is what makesCurveInsolventunreachable in ordinary operation - A
Frozencurve hastokenReserve == FLOORexactly. A sell afterSELL_REOPEN_DELAYtakes it back toTrading, and a re-buy re-freezes it at exactlyFLOORthrough the same clamp, sograduatenever sees any other reserve - A coin's PvE fills and its PvE credit always address the same vault (
pveVaultOf), so a vault repoint can never strand a coin's escrow pveAccruedis cleared only by a successfulrecordPve, never by a failure and never by the owner- The curve is never a fee recipient, never an LP holder, and never holds MOTO or WETH between transactions on the normal path: LP mints to
0xdead, leftovers go to the Collector, the coin-side remainder burns - The graduation MOTO swap routes through the public
FeeRouter, so the protocol fee applies and the volume is visible to Points and Rakeback. The curve never asks for aswapAllowedgrant; the deployment's verification invariant is that only the router and the FeeRouter sit on the swap perimeter - Every cross-domain read on a path that must not revert (
_routeFees, the price reference, the launch-time hijack check) goes through a guarded staticcall that treats short returndata as a failure, becausetry/catchdoes not cover the ABI decode. The PvE credit is a state-changing call, so it is wrapped intry/catchinstead, behind an explicit code-length check on the vault for the same reason
LaunchpadToken (contracts/src/LaunchpadToken.sol)
The ERC-20 template, deployed once by the curve's constructor and cloned per coin as an EIP-1167
minimal proxy. 1e27 fixed supply, 18 decimals, no tax, no mint, no owner, no permit. Hand-written
rather than OpenZeppelin because the OZ base takes name and symbol in a constructor, which clones cannot
use. The implementation bricks itself in its constructor by setting initialized = true, so only
clones can ever be initialized.
Two behaviours are active only while unbonded, and both are permanently cleared when the curve calls
setBonded at graduation. After that the coin is indistinguishable from a vanilla ERC-20.
Constructor
constructor(address curve_)- revertsZeroAddresson a zero curve. Sets thecurveimmutable, which every clone reads through delegatecall semantics
Constants and immutables
LAUNCH_SUPPLY = 1_000_000_000 ethercurve: address- the curve singleton, shared by every clone
State variables (public getters)
name: string,symbol: string,totalSupply: uint256initialized: bool- one-shot init flag.trueon the implementation from birthbonded: bool- settrueexactly once, by the curve, at graduationbalanceOf(address) -> uint256,allowance(address owner, address spender) -> uint256blockedWhileUnbonded(address) -> bool- the pre-bond transfer blocklist: the precomputed pair addresses
External and public functions
decimals() pure returns (uint8)- returns18initialize(string name_, string symbol_, address[] blocked)- curve only (OnlyCurve), one-shot (AlreadyInitialized). Sets name and symbol, writes every entry ofblockedinto the blocklist, mintsLAUNCH_SUPPLYto the curve, emitsTransfer(address(0), curve, LAUNCH_SUPPLY)setBonded()- curve only (OnlyCurve). Setsbonded = true. Permanently lifts both unbonded behaviours. No way back, and no event of its own; the curve'sGraduatedis the signalapprove(address spender, uint256 value) returns (bool)- standard. EmitsApprovaltransfer(address to, uint256 value) returns (bool)- standard, through the blocklist checktransferFrom(address from, address to, uint256 value) returns (bool)- standard, with one narrow exemption: the allowance check is skipped only whenmsg.sender == curve && !bonded && to == curve. That is the gasless sell pull, and nothing else. An allowance oftype(uint256).maxis not decremented. RevertsInsufficientAllowanceotherwise
Events
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);Custom errors
OnlyCurve()-initializeorsetBondedfrom anyone but the curveAlreadyInitialized()- a secondinitialize, or anyinitializeon the implementationBlockedUntilBonded()- a transfer to a blocklisted address while unbondedInsufficientBalance(),InsufficientAllowance()ZeroAddress()- a zero curve in the constructor, or a transfer toaddress(0)
Access control: initialize and setBonded are curve-only. Everything else is the ERC-20
surface, callable by anyone.
Invariants
totalSupplyisLAUNCH_SUPPLYfrominitializeonward and never changes. There is no mint and no burn function; the graduation "burn" is a transfer to0xdead- The blocklist is written once, at
initialize, and never edited. A coin keeps the list derived at its own launch even if the curve'sblockedQuoteAssetschanges later - The curve's allowance exemption can only ever move tokens into the curve. The old blanket exemption, which let the curve move any holder's tokens anywhere pre-bond, is gone
Known gap, stated in the source: the blocklist enumerates pool addresses on three venues against a
known quote set, but the set is not enumerable. A TOKEN/<unlisted quote> pair on Uniswap or Sushi,
a V3 or V4 pool, or an unknown V2 fork sits outside it. Treat pre-bond pool liquidity as possible but
unsupported. Closing the class needs a rule rather than a longer list, and every candidate rule costs
some pre-bond transfer freedom.
LaunchpadLens (contracts/src/LaunchpadLens.sol)
The external signatures below are stable. Inside, each quote, price and progress read now resolves the coin's own parameter set from its paramsId instead of assuming set 0, quoteLaunch resolves nextParamsId because the coin it previews does not exist yet, and the MOTO reference is read through the curve's twapRef and twapRefAt rather than recomputed here. bondingProgress is measured against the coin's own floor supply.
Read-only companion to LaunchpadCurve. Every function is a view and nothing in the contract can
move a wei. It exists because the curve compiles to just under the EIP-170 limit with these views
removed; with them inlined the runtime bytecode was over the ceiling, which forge test never reports
because Foundry disables the size limit for test deployments. Everything here mirrors the curve's own
arithmetic line for line, and test/Lens.t.sol pins the two against each other.
The constructor rejects a zero (ZeroAddress) or codeless (NotAContract) target: a lens pointed at
an EOA would answer every query with zeros and look like a dead market rather than a misconfiguration.
Constructor
constructor(address curve_)
State variables
curve: LaunchpadCurve(immutable) - the only public state
The curve constants (SUPPLY, FLOOR, VIRTUAL_TOKENS, VIRTUAL_ETH, BPS_DENOM) are mirrored as
private constants and are not readable through the lens. Read them from the curve.
Types
enum Readiness { Ready, TooFresh, Stale, Missing, OutOfBand, Upstream, NotFrozen }The first six match the six regimes the graduation design names; NotFrozen is appended rather than
inserted, so a consumer written against the six-value list decodes 0..5 identically. It exists so a
keeper does not read a finished job as an outage.
| Regime | Curve error it predicts | What to do |
|---|---|---|
Ready | none | send graduate |
TooFresh | MotoRefTooFresh | wait until readyAt. A poke would make it worse |
Stale | MotoRefStale | poke, then wait one window. readyAt assumes the poke is sent now |
Missing | MotoRefMissing | poke, then wait one window |
OutOfBand | MotoOutBelowFloor | wait, and poll. readyAt is 0 because no timestamp can be promised: the pool moved or someone is standing on it, and both resolve on their own |
Upstream | MotoPoolUnreadable | the only regime that means something is broken |
NotFrozen | NotFrozen | not a candidate: None, Trading or already Graduated |
External functions (all view, callable by anyone)
quoteBuy(address token, uint256 ethIn) returns (uint256 tokensOut, uint256 grossUsed, uint256 refund)- whatbuywithmsg.value == ethInwould deliver, the ETH it would actually charge, and the refund if the buy crosses the floor. Returns zeros if the coin is notTradingorethIn == 0quoteLaunch(uint256 ethIn, uint256 pveEth) returns (uint256 devTokens, uint256 pveTokens, uint256 grossUsed, uint256 refund)- what a splitlaunchactually delivers. Exact and token-free, because a launch always fills a fresh curve. A create form must use this, notquoteBuy:quoteBuyanswers the whole-curve-fill question and overstates the creator's take by the PvE ratio. Returns zeros ifethIn == 0orpveEth > ethIn. Same clamp and rounding as the split inlaunch: the donation is measured against what is actually spent and floors toward the creatorquoteSell(address token, uint256 tokensIn) returns (uint256 ethOut)- net of fees. Returns0if notTradingortokensIn == 0currentPrice(address token) returns (uint256)- spot in ETH per whole coin, 1e18 fixed point. The same numberTrade.priceX18carries- It does not check the coin's status. Graduation zeroes
realEthandtokenReserve, so for aGraduatedcoin this returnsvirtualEth * 1e18 / virtualTokens. That is the coin's graduation price, frozen: the zero-burn relation that fixes the virtual token reserve is exactly the condition under which the price at the floor equalsvirtualEth / virtualTokens, for any parameter set, up to rounding. It is correct for what it is and stale against the market from the first pool trade on, with no revert and no zero to warn you. For an address the curve never launched (Status.None) the same read answers the set0ratio, a non-zero price for something that is not a coin.quoteBuyandquoteSellreturn zeros outsideTrading, andbondingProgresspins at1e18, butcurrentPricekeeps answering. Readcurves(token).statusfirst, and take a graduated coin's live price from its pools
- It does not check the coin's status. Graduation zeroes
bondingProgress(address token) returns (uint256)-0to1e18.Noneis0; anything pastTradingis1e18predictToken(address creator, uint256 salt) returns (address)- the CREATE2 addresslaunchwill deploy for this creator and salt:Clones.predictDeterministicAddress(curve.tokenImplementation(), keccak256(abi.encode(creator, salt)), address(curve)). Must stay identical to the curve's own derivation, andLens.t.solasserts it against a real launchgraduationReadiness(address token) returns (Readiness regime, uint64 readyAt, uint256 minMotoOut, uint256 quotedOut, uint256 deviationBps, uint256 bountyNow, uint256 sellsOpenAt)- everything a keeper needs to decide whether to sendgraduate, and when to try again if not. Never reverts: every cross-domain read is guarded and every failure classified.readyAtis the earliest timestamp the coin could becomeReady: now forReady, the soonest slot's maturity forTooFresh, one window from now forStaleandMissing, and0forOutOfBand,UpstreamandNotFrozen, where no timestamp can be promised.minMotoOutis the floor the swap must clear;quotedOutis what the swap would return at the pool's current reserves, andquotedOut < minMotoOutis exactlyOutOfBand.deviationBpsis how farquotedOutsits from the honest expectation the floor was built from, unsigned. All three are zero unless the regime isReadyorOutOfBand.bountyNowis whatgraduatewould pay after thepot / 50clamp, andsellsOpenAtisfrozenAt + SELL_REOPEN_DELAY; both are filled for everyFrozencoin regardless of regime. Mirrors the curve's tail preference too: when the pair's own last write is recent, the window ends there rather than at an extrapolated tailmotoPoolDeviationBps() returns (uint256 deviationBps, bool usable)- retired. Marked stale in the source and kept only so the indexer, keeper and frontend can migrate behind a dual-ABI window. It still measures spot against the average andusablestill reports whether a reference exists inside the window bounds, but atruehere no longer implies the buyback will clear its floor, and the deviation itself no longer gates anything. Do not build on it. UsegraduationReadiness
Events: none.
Custom errors: ZeroAddress() and NotAContract(), both constructor-only.
Access control: none needed. Every function is a view.
Invariants
- The lens holds no state beyond the
curvepointer and can move no value - Every quote reads
protocolFeeBpsandcreatorFeeBpsfrom the curve at call time, so a fee change is reflected immediately. A local reimplementation with the rates baked in is a latent mispricing the day they move graduationReadinessclassifies rather than inherits a failure: short returndata from the factory, the pair, the FeeRouter or the curve reads asUpstream, never as a revert
GasTank (contracts/src/GasTank.sol)
Holds ETH for the moto.fun keeper and the other gas keys on one chain and tops them up on demand, so nobody has to send gas by hand. One deployment per chain, shared by every payee on it. An operations contract: no user ever calls it, and nothing in the trading path depends on it.
The design is one trade. refill is permissionless and every other lever is onlyOwner. The
destination set is fixed by the owner, the amount is fixed by the contract (floor, target, per-call
cap, per-window cap), and the precondition is that the payee is already below its floor. The only
thing a caller chooses is when a warranted refill happens, so there is nothing for a privileged caller
to protect. There is no refiller key to steal, and if every service is down a human can still push the
button from a block explorer. The automated refiller is a liveness guarantee, not a permission.
Inherits Ownable2Step, Pausable, ReentrancyGuardTransient. It has one owner, and only the owner can change its settings or withdraw.
Constructor
constructor(address initialOwner, uint256 lowWatermark, uint32 initialSendGas)-initialSendGasmust sit inside[MIN_SEND_GAS, MAX_SEND_GAS], elseBadSendGasLimit. EmitsLowWatermarkSet(0, lowWatermark)andSendGasLimitSet(0, initialSendGas). Deploy scripts set themselves as owner, write the payee rows, then hand over through the two-step transfer
Constants
WINDOW = 1 days(uint64) - the spend window forperDayCapWei. Tumbling, not rolling, and not a UTC calendar day. A payee's window opens on its first refill and shuts exactlyWINDOWseconds later. That bounds spend atperDayCapWeiper window but at twiceperDayCapWeiacross an arbitrary 24 hours: open a window with one small refill, drain the remainder one second before it shuts, drain a fresh allowance one second later. The honest fix is free: setperDayCapWeito half the 24-hour number you mean, which is what the deploy script doesMIN_SEND_GAS = 2_300(uint32) - floor onsendGasLimit, the classic transfer stipendMAX_SEND_GAS = 100_000(uint32) - ceiling onsendGasLimit, so a contract payee cannot do real work inside the tank's call frame
Types
struct Payee {
uint128 floorWei; // refill only when payee.balance is strictly below this
uint128 targetWei; // refill aims at this balance; must exceed floorWei
uint128 perCallCapWei; // most one refill can send
uint128 perDayCapWei; // most this payee can receive inside one WINDOW
uint128 spentInWindow; // sent since windowStart; can sit above perDayCapWei after the owner lowers the cap mid-window
uint64 windowStart; // unix time the current window opened; 0 = never refilled
bool enabled; // owner switch
bool registered; // set once by setPayee; distinguishes "not a payee" from "paused payee"
}targetWei is the on-chain ceiling on hot exposure: no refill can take a payee above it, so it also
bounds what a revoke-first key rotation strands.
State variables (public getters)
payees(address) -> Payee- the allowlist. Only the owner writes itlowWatermarkWei: uint256- tank balance below whichTankLowis emitted after every outflow. An alarm backstop, not a trigger. Zero disables the eventsendGasLimit: uint32- gas forwarded with a refill send. Payees are plain accounts, which need none of it
External and public functions
receive() payable- anyone. Funds the tank and emitsFunded. Nobody needs a key to add runwayrefill(address payee) nonReentrant whenNotPaused returns (uint256 amount)- anyone. Checks in order:UnknownPayeeif not registered,PayeeDisabledif disabled,AboveFloorifpayee.balance >= floorWei. Opens a fresh window ifwindowStart == 0or the current one has shut.DailyCapReachedif nothing remains in the window (saturating, so an owner lowering the cap below what is already spent closes the window rather than panicking). Thenamount = targetWei - balance, capped byperCallCapWei, by the window remainder, and by the tank's own balance;TankEmptyif that leaves zero. WriteswindowStartandspentInWindowbefore the send, sends withgas: sendGasLimit, revertsRefillFailedif the send failed. EmitsRefilled, thenTankLowif the balance fell under the watermark. A partial refill is deliberate: when the tank holds less than the shortfall it sends what it has, because some gas beats nonequoteRefill(address payee) view returns (uint256 amount)- anyone. The read-only twin ofrefill, for the cron and for/health. Returns zero instead of reverting in every caserefillwould revert, including while paused, so a caller never has to decode errors or reimplement the arithmeticsetPayee(address payee, uint128 floorWei, uint128 targetWei, uint128 perCallCapWei, uint128 perDayCapWei, bool enabled)- owner only. Registers or reconfigures a payee. RevertsZeroAddresson a zero payee andInvalidConfigiffloorWei == 0,targetWei <= floorWei,perCallCapWei == 0orperDayCapWei < perCallCapWei. Setsregistered = true. Never resetswindowStartorspentInWindow: if it did, an owner (or whoever took the owner key) could hand out unlimited daily allowances by rewriting the row between refills. EmitsPayeeSetsetPayeeEnabled(address payee, bool enabled)- owner only. Turns a registered payee off or on without losing its sizing.UnknownPayeeif not registered. EmitsPayeeSetwith the stored sizing. Killing one payee is one transaction, so a suspected key leak needs neither a full reconfiguration nor a pause of the whole tanksetLowWatermark(uint256 newWatermarkWei)- owner only. EmitsLowWatermarkSet(previous, new). Zero disablesTankLowsetSendGasLimit(uint32 newLimit)- owner only. Must sit inside[MIN_SEND_GAS, MAX_SEND_GAS], elseBadSendGasLimit. EmitsSendGasLimitSet(previous, new)pause()/unpause()- owner only.pausestops every refill in one transaction.withdrawstays open while paused on purpose, because pausing is what you do on the way to emptying the tank. Inheritedpaused() view returns (bool)withdraw(address to, uint256 amount)- owner only, paused or not.ZeroAddresson a zeroto,ZeroAmounton zero,InsufficientTankBalance(requested, available)above the balance. Forwards full gas, because the destination is the owner's chosen account, which may be a contract, rather than a keeper account. RevertsWithdrawFailedif the send failed. EmitsWithdrawn, thenTankLowif applicable. Automation is never a one-way doorrenounceOwnership() pure- disabled. Always revertsRenounceDisabled.Ownable2Stepmakes transfer two-step but leaves renounce as a single unguarded owner call, and one mistaken owner call would zero the owner while the tank still holds a runway andrefillstill pays out. Hand it to a new owner withtransferOwnershipplusacceptOwnership- Inherited:
owner(),pendingOwner(),transferOwnership(address)(owner only),acceptOwnership()(pending owner only)
Events
event PayeeSet(address indexed payee, uint128 floorWei, uint128 targetWei, uint128 perCallCapWei, uint128 perDayCapWei, bool enabled);
event Refilled(address indexed payee, address indexed caller, uint256 amount, uint256 payeeBalance, uint256 tankBalance);
event Funded(address indexed from, uint256 amount, uint256 tankBalance);
event Withdrawn(address indexed to, uint256 amount, uint256 tankBalance);
event TankLow(uint256 tankBalance, uint256 lowWatermarkWei);
event LowWatermarkSet(uint256 previousWei, uint256 newWei);
event SendGasLimitSet(uint32 previous, uint32 current);Inherited from Pausable: Paused(address account), Unpaused(address account). From
Ownable2Step: OwnershipTransferStarted, OwnershipTransferred.
Custom errors
ZeroAddress()-setPayeeorwithdrawwith a zero addressZeroAmount()-withdraw(_, 0)InvalidConfig()- asetPayeerow that breaks0 < floorWei < targetWei,perCallCapWei > 0orperDayCapWei >= perCallCapWeiUnknownPayee(address payee)-refillorsetPayeeEnabledon an address never registeredPayeeDisabled(address payee)-refillon a registered but disabled payeeAboveFloor(address payee, uint256 balance, uint256 floorWei)-refillwhen the payee does not need one yet. The normal steady stateDailyCapReached(address payee, uint256 perDayCapWei)- the payee has taken its whole per-window allowanceTankEmpty()- the computed refill amount is zero because the tank holds nothingRefillFailed(address payee, uint256 amount)- the refill send reverted. Most likely a payee with code that cannot receive insidesendGasLimitWithdrawFailed(address to, uint256 amount)- the withdraw send revertedInsufficientTankBalance(uint256 requested, uint256 available)-withdrawabove the balanceBadSendGasLimit(uint32 sendGasLimit)- constructor orsetSendGasLimitoutside the boundsRenounceDisabled()- anyrenounceOwnership- Inherited from
Pausable:EnforcedPause()onrefillwhile paused,ExpectedPause()onunpausewhile not paused
Access control
| Function | Who |
|---|---|
receive, refill, quoteRefill | anyone |
setPayee, setPayeeEnabled, setLowWatermark, setSendGasLimit, pause, unpause, withdraw, transferOwnership | owner |
acceptOwnership | pending owner |
renounceOwnership | nobody; always reverts |
Invariants
- Only a registered, enabled payee can ever receive ETH through
refill, and only when its balance is strictly below its floor - One refill never takes a payee above
targetWei, never sends more thanperCallCapWei, and never sends more than remains in the window.spentInWindowis not bounded byperDayCapWeifrom above:setPayeerewrites the cap and never touches the spend, so lowering the cap below what is already spent leaves the spend above it, the remainder saturates to zero, andrefillrevertsDailyCapReacheduntil the window rolls - Window accounting is written before the send, so a reentrant caller sees the spend already booked;
nonReentrantand the send-gas cap close the same door twice - A row rewrite never resets the window, so the per-window cap cannot be reissued by the owner
- The owner can always withdraw, paused or not, and can never be removed by renounce
- Guarantee, stated plainly: set
perDayCapWeito X and a payee receives at most X per window and at most 2X in any 24 hours
Sizing (floors, targets, caps, watermark) lives in script/DeployGasTank.s.sol, not in the contract,
because it is a per-chain, per-gas-price judgement that changes. The deploy section below has the
shape.
CreatorFeeRegistry (contracts/src/CreatorFeeRegistry.sol, a copy of the canonical Motoswap contract)
Which coins have a creator fee and who receives it. Populated only by authorised registrar contracts,
never by end users. Shared with the DEX: the FeeRouter reads activeCreator on every swap that
touches a registered token, and the curve is a registrar on it. Plain Ownable2Step, not a proxy; it
holds no funds and its shape is fixed. Implements ICreatorFeeRegistry.
In production the curve points at the canonical deployment, not at the vendored copy. The copy exists so the moto-fun repo builds and tests standalone.
Constructor
constructor(address initialOwner)
Types
struct TokenConfig { address creator; bool disabled; } // creator == 0 means not registeredState variables (public getters)
tokens(address token) -> (address creator, bool disabled)isRegistrar(address) -> bool- who may callregister
External and public functions
register(address token, address creator)- registrar only (NotRegistrar). Sets the initial creator. RevertsZeroAddresson a zero token or creator andAlreadyRegisteredif the slot is taken, whoever holds it. EmitsTokenRegisteredsetCreator(address token, address creator)- the current creator only (NotCreator), gated oncreatorOf, notactiveCreator. Redirects the token's fee stream without going through an admin. RevertsZeroAddresson a zero creator andNotRegisteredon an unregistered token. One step, not an accept-handshake, because a two-step would strand the balance of anyone who set a recipient that cannot call back. Moves the unclaimed balance with the address, because the vault keys accrual per token. Emits bothCreatorSetandCreatorSetBySelfadminSetCreator(address token, address creator)- owner only. Hard-sets the creator for a takeover, lost keys or a redirection. Also captures the unclaimed balance, which is intended. RevertsZeroAddress,NotRegistered. EmitsCreatorSetonlysetDisabled(address token, bool disabled)- owner only. Stops future accrual:activeCreatorreturns zero so the FeeRouter and the curve skim nothing further. Does not touch money already earned, because the vault gates claims oncreatorOf. RevertsNotRegistered. EmitsTokenDisabledsetRegistrar(address registrar, bool allowed)- owner only. RevertsZeroAddress. EmitsRegistrarSetactiveCreator(address token) view returns (address)- the swap hot-path read. The creator if registered and not disabled, else zero. One SLOADcreatorOf(address token) view returns (address)- the creator regardless of the disabled flag, or zero if never registered. The claim-side read- Inherited:
owner(),pendingOwner(),transferOwnership(address)(owner only),acceptOwnership()(pending owner only),renounceOwnership()(owner only, single step)
The two reads must not be conflated. activeCreator answers "should the router skim?"; creatorOf
answers "whose money is it?". Those stopped being the same question the moment a disabled token still
had a balance in the vault.
Events
event TokenRegistered(address indexed token, address indexed creator, address indexed registrar);
event CreatorSet(address indexed token, address indexed creator);
event CreatorSetBySelf(address indexed token, address indexed previousCreator, address indexed creator);
event TokenDisabled(address indexed token, bool disabled);
event RegistrarSet(address indexed registrar, bool allowed);CreatorSet fires on both the admin path and the self-redirect path, so anything already watching it
keeps working. CreatorSetBySelf fires only on the self-redirect path, so an indexer can tell a
takeover from a routine change.
Custom errors
ZeroAddress()-register,setCreator,adminSetCreatororsetRegistrarreceived zeroNotRegistrar()-registerfrom an address not inisRegistrarAlreadyRegistered()-registeron a token that already has a creatorNotRegistered()-setCreator,adminSetCreatororsetDisabledon an unregistered tokenNotCreator()-setCreatorfrom anyone but the current creator
Access control
| Function | Who |
|---|---|
register | a registrar (the moto.fun curve) |
setCreator | the token's current creator |
adminSetCreator, setDisabled, setRegistrar, transferOwnership, renounceOwnership | owner |
acceptOwnership | pending owner |
activeCreator, creatorOf, tokens, isRegistrar | anyone (views) |
Invariants
- A token's creator, once set, is never zero again. There is no unregister
activeCreator(t) != 0impliescreatorOf(t) == activeCreator(t). Disabling changes the first and never the second- Registration is gated on the caller, not on who deployed the token. The curve therefore checks the outcome of its own
registercall rather than trusting a baretry/catch; seeCreatorAlreadyClaimedandRegistryUnreadableon the curve
CreatorFeeVault (contracts/src/CreatorFeeVault.sol, vendored from evm-moto-contracts; identical to src/fees/CreatorFeeVault.sol)
Claim-based escrow for creator fees, per token, in the quote asset they were collected in. Creators
pull; nothing is ever pushed. Passive: plain ERC-20 transfers of vetted quote assets in, no hooks, so
the skim can never introduce a new revert path beyond the token transfer itself. Shared with the DEX.
Ownable2Step plus the classic ReentrancyGuard. Implements ICreatorFeeVault.
Constructor
constructor(address initialOwner, address registry_, address weth_)- revertsZeroAddresson a zero registry or WETH. Both become immutables
State variables and immutables (public getters)
registry: ICreatorFeeRegistry(immutable) - wherecreatorOfis readWETH: address(immutable) - the one asset it will unwrapaccrued(address token, address quoteAsset) -> uint256- claimable by the token's current creatorisDepositor(address) -> bool- who may callnotifyDeposit. The FeeRouter and the curve
External and public functions
setDepositor(address depositor, bool allowed)- owner only. RevertsZeroAddress. EmitsDepositorSet. Revoking the live depositor is a trading kill switch, not a fee switch: the FeeRouter callsnotifyDepositunconditionally on every swap that touches a registered token, and the curve calls it on every trade with a non-zero creator cut, so a vault that does not admit its depositor makes every registered token untradeable through that depositor. The rule issetCreatorFeeConfig(registry, vault, 0)first on the FeeRouter, thensetDepositor(feeRouter, false). Wrapping the call intry/catchwould be worse, because the quote asset is transferred in before the entitlement is recorded, so a swallowed revert strands it uncreditednotifyDeposit(address token, address quoteAsset, uint256 amount)- depositor only (NotDepositor). Recordsamountas accrued. Only records; the caller must already have transferred the asset in, which is what makes the solvency invariant hold by construction. EmitsAccruedclaim(address token, address[] quoteAssets, bool unwrapWeth) nonReentrant- the token's current creator only, read asregistry.creatorOf(token)(NotCreator). Claims across the listed assets to the caller. Zero balances are skipped rather than reverting. WETH is unwrapped to ETH whenunwrapWethis set, else transferred as WETH. Effects before interaction:accruedis zeroed before each transfer. RevertsEthTransferFailedif the ETH send failed. EmitsClaimedper non-zero assetclaimMany(address[] tokens, address[] quoteAssets, bool unwrapWeth) nonReentrant- the current creator of every listed token. The same body asclaim, once per token, with one quote-asset list for all. RevertsNothingToClaimon an empty token list. A third party's token anywhere in the list reverts the whole batch withNotCreatorrather than being skipped: the app builds this list from tokens it believes the caller owns, so a mismatch means a takeover or a self-redirect happened underneath it, and that is the event a creator most needs to see. Duplicates are harmless; the second pass reads a zeroed balancereceive() payable- accepts ETH only fromWETHduring an unwrapping claim. Anything else revertsEthTransferFailed- Inherited:
owner(),pendingOwner(),transferOwnership(address)(owner only),acceptOwnership()(pending owner only),renounceOwnership()(owner only, single step)
Events
event Accrued(address indexed token, address indexed quoteAsset, uint256 amount);
event Claimed(address indexed token, address indexed creator, address indexed quoteAsset, uint256 amount);
event DepositorSet(address indexed depositor, bool allowed);Custom errors
ZeroAddress()- constructor orsetDepositorreceived zeroNotDepositor()-notifyDepositfrom an address not inisDepositorNotCreator()-claimorclaimManyfrom anyone but the token's current creatorNothingToClaim()-claimManywith an empty token listEthTransferFailed()- the ETH send in an unwrapping claim failed, orreceivefrom anyone but WETH
Access control
| Function | Who |
|---|---|
notifyDeposit | a depositor (the FeeRouter, the curve) |
claim, claimMany | the token's current creator per creatorOf |
setDepositor, transferOwnership, renounceOwnership | owner |
acceptOwnership | pending owner |
receive | WETH only |
Invariants
- Solvency: the vault's balance of each quote asset always equals the sum of
accrued[*][quoteAsset]. It holds nothing else, and there is no rescue or sweep - A creator takeover or self-redirect captures the unclaimed balance, because accrual is per token rather than per recipient. Intended
- The only ETH the vault ever holds is in flight from
WETH.withdrawto the claimer inside oneclaim
Interfaces (contracts/src/interfaces/)
ICreatorFeeRegistry - activeCreator(address token) view returns (address),
creatorOf(address token) view returns (address), register(address token, address creator). The
curve's _routeFees reads activeCreator; its _registryCreator reads the tokens(address) public
getter by signature, because a public mapping getter cannot be referenced through abi.encodeCall.
ICreatorFeeVault - notifyDeposit(address token, address quoteAsset, uint256 amount),
isDepositor(address depositor) view returns (bool). isDepositor is declared here so the curve can
ask before it deposits, rather than letting one routine decommissioning call on the vault take every
trade down.
IExternal.sol holds the minimal surfaces of the deployed Motoswap contracts the curve touches.
Kept local so the repo builds standalone; the canonical definitions live in evm-moto-contracts and win
on any conflict. See the Motoswap contracts inventory for
the full contracts.
IWETH-deposit() payable,withdraw(uint256),transfer(address, uint256) returns (bool),approve(address, uint256) returns (bool),balanceOf(address) view returns (uint256)IMotoSwapFactory-getPair(address, address) view returns (address),createPair(address, address) returns (address),pairCodeHash() view returns (bytes32)(the one CREATE2 init-code hash for the whole deployment, because pairs are beacon proxies; read live per launch),swapFeeBps() view returns (uint256)(governance-retunable up toMAX_SWAP_FEE_BPS; every pair reads it live on every swap, so the graduation floor reads it live too)IMotoSwapPair-mint(address to) returns (uint256 liquidity),totalSupply() view returns (uint256),getReserves() view returns (uint112, uint112, uint32),token0() view returns (address),price0CumulativeLast() view returns (uint256),price1CumulativeLast() view returns (uint256). The graduation MOTO leg prices itself against the cumulatives instead of a signed voucherIFeeRouter-swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline) returns (uint256[] amounts),protocolFeeBps() view returns (uint256). The public swap entry. The core router is perimeter-gated onfactory.swapAllowedand the deployment asserts that only the router and the FeeRouter are admitted, so the curve goes through here like every other protocol swap: the protocol fee applies, the volume is visible to the indexer, and no allowlisting is needed. Pullspath[0]from the caller via allowanceIPveVault-recordPve(address token, address creator, uint256 amount)(launcher-gated on the vault; requires the coins to already sit in the vault, so the curve transfers first and records second) andnotePendingArrival(address token)(one-shot on the vault; a second call revertsAlreadyExpecting, which is why the curve calls it on the first fill only, and fail-closed, because a vault that cannot record an arrival is one the curve must not escrow into)
Part 2: canonical contracts moto.fun depends on
These live in evm-moto-contracts and are deployed by the DEX suite, not by the moto-fun repo. They
are here because the curve cannot function without them and an integrator reading moto.fun events will
meet their events too. The PveVault, CreatorFeeRegistry and CreatorFeeVault
entries on the Motoswap contracts inventory describe the
same deployments, and the two pages match.
PveVault (src/launcher/PveVault.sol)
Escrow for PvE coins on their way to Motocat stakers. The curve's PvE fills land here by plain
transfer during a coin's life on the curve, and at graduation the curve records one credit for the
whole sum. Payouts are pull-based and lazy: recordPve freezes one credit per token against the stake
at the preceding block, and each wallet divides against that frozen snapshot whenever it claims.
Nothing iterates a global token list, which keeps the number of PvE tokens unlimited.
UUPS proxy. Inherits Initializable, UUPSRenounceable, Ownable2StepUpgradeable,
ReentrancyGuardTransient. Storage is upgrade-safe: new fields are appended and each consumes one
slot of __gap, which stands at uint256[43] at this commit.
The vault reads stake history through IMotocatStakingHistory, declared in the same file:
stakedBalanceOfAt(address wallet, uint256 blockNumber) view returns (uint256),
totalStakedAt(uint256 blockNumber) view returns (uint256), seedingClosed() view returns (bool).
On Ethereum that is MotocatStakingV3. When other networks come later, the vault there will read a MirrorRegistry, which exposes exactly that
shape so the audited vault deploys unchanged; see the mirror section below.
Constructor and init
constructor()- disables initializers on the implementationinitialize(address initialOwner, address upgradeAuthority_)-initializer. Sets the owner and the upgrade authority (zero leaves upgrades with the owner) and initialises UUPS
Types
/// ONE credit per token, ever. Packed into one slot; both numbers are range-checked at the write.
struct Credit { uint128 amount; uint64 totalStaked; uint64 creditBlock; }State variables (public getters)
launcher: address- the primary launcher allowed to record receipts. Kept as its own slot rather than folded into the mapping because reinterpreting a live storage slot is the one thing a UUPS upgrade cannot do safely. A retired launch contract held it, and nothing new should claim the slottotalReceived(address token) -> uint256- cumulative PvE proceeds recorded per token. Indexer conveniencemotocatStaking: address- the staking contract (or mirror) whose history sizes a splitcredits(address token) -> (uint128 amount, uint64 totalStaked, uint64 creditBlock)- the credit, or zerosclaimed(address token, address wallet) -> bool- whether a wallet has taken its sharepending(address token) -> uint256- escrow that landed while nobody was staked, staking was unwired, or the seed was still in flight. A per-token amount awaitingrecreditPending, not a per-wallet claimable balanceextraLaunchers(address) -> bool- additional launchers allowed to record receipts, alongsidelauncher. The curve is admitted herestakingWiringFrozen: bool-trueonce the first credit has been frozen, after whichsetMotocatStakingis shut. Every outstanding credit is denominated in one staking contract's history, and re-pointing afterwards would silently re-price every unclaimed credit against a ledger that never contained those holdersexpectingArrival(address token) -> bool- the launcher has announced escrow in flight for this token and the vault has not credited it yet. Until the curve sets this, the vault cannot see its own escrow arrive: a token transfer notifies nobody, and the receipt only follows at graduation, days or weeks later. While set,forwardrefuses the token andrescuableExcessprices it at zero- Inherited:
upgradesRenounced() view returns (bool),proxiableUUID() view returns (bytes32),owner(),pendingOwner()
External and public functions
renounceUpgradeability()- the upgrade authority (the owner while the authority is zero). One-way. Freezes the implementation forever while ownership and every operational setter keep working. Never on launch day: it kills UUPS upgrades permanentlysetLauncher(address launcher_)- owner only. RevertsZeroAddress. EmitsLauncherSet. Refuses zero, so the slot cannot be un-set once writtensetExtraLauncher(address launcher_, bool allowed)- owner only. RevertsZeroAddress. EmitsExtraLauncherSet. Additive tolauncher. Revoking is immediate and strands nothing: an unauthorised launcher's coins still arrive by plain transfer, only the receipt fails, and the curve'srecordPveLatereplays it once re-authorisedsetMotocatStaking(address staking)- owner only. RevertsStakingWiringFrozenonce any credit exists,ZeroAddresson zero,NotAContract(staking)on an address with no code. The code check matters: a call to a codeless address succeeds with empty returndata, and the decode failure is not caught bytry/catch, so a vault pointed at an EOA would make everyrecordPverevert. EmitsMotocatStakingSetnotePendingArrival(address token)- launcher or extra launcher only (NotLauncher). Marks the token as expecting escrow. RevertsZeroAddresson a zero token,AlreadyExpecting(token)if already marked,AlreadyCreditedif a credit or pending hold already exists. One-shot per token, and the one-shot is load-bearing: the curve's own state machine uses the revert to detect a double-escrow, and without it a token could be re-marked afterrecordPvecleared the mark and re-lock a credited token's dust againstrescueExcessforever. Launcher-gated because an open marker would be a free permanent denial offorwardon any token an attacker named. EmitsPendingArrivalNotedclearPendingArrival(address token)- owner only. Retires a marker that guards nothing. RevertsNotExpecting(token)if no marker,AlreadyCreditedif a credit or pending hold exists, andTokenIsCredited(token)if the vault holds any balance of the token. That last check is the whole safety: a marked token with a balance is escrow promised to stakers, and this is never a way to release one. EmitsPendingArrivalClearedByOwner. Exists because a coin that stalls on the curve and never graduates would otherwise leave its marker set foreverrecordPve(address token, address creator, uint256 amount)- launcher or extra launcher only (NotLauncher). The receipt record, which also credits. RevertsAlreadyCreditedif a credit or pending hold exists,ValueTooLargeaboveuint128,AmountNotReceivedif the vault's balance of the token is belowamount. Adds tototalReceived, emitsPveReceived. ClearsexpectingArrivaland emitsPendingArrivalClearedwhen the receipt is non-zero, or when it is zero and the vault holds nothing for the token; a zero receipt against a non-zero balance keeps the marker, because that contradiction is the drain the marker exists to stop. A zeroamountreturns after the receipt. Otherwise sizes the split: ifmotocatStakingis zero, or the staking side's seed is not closed, ortotalStakedAt(block.number - 1)is zero, the amount is held inpendingandPveHeldfires; else the credit is frozen at(amount, staked, block.number),stakingWiringFrozenis set, andPveCreditedfires. The preceding-block rule is what makes staking into a split in the same block worthlessrecreditPending(address token)- owner only. Turns a held escrow into a claimable credit at today's stake. RevertsNoCreditif nothing is held,StakingNotSetif unwired,SeedingNotClosedwhile the staking side's seed is in flight,PoolStillEmptyif nobody is staked at the preceding block. Owner-only because the caller chooses the block, which is the whole basis of the anti-JIT rule; it used to be permissionless and that was wrongclaimable(address token, address wallet) view returns (uint256)- the wallet's share:credit.amount * stakedBalanceOfAt(wallet, creditBlock - 1) / credit.totalStaked. Zero if no credit, if already claimed, or if the wallet held no staked cats at the credit block. Note the token-first argument order. Truncates toward the pool, so a small permanent remainder always survivesclaim(address token) nonReentrant returns (uint256 amount)- anyone with a share. RevertsNothingToClaimon zero. Paysmin(share, balance): a rebasing or fee-on-holdings launched token can shrink the pot, and clamping means the tail is paid what exists rather than reverting forever. Marksclaimed, transfers, emitsPveClaimedclaimMany(address[] tokens) nonReentrant returns (uint256 total)- anyone. Claims across the caller's own list; entries with nothing to claim are skipped, but the call revertsNothingToClaimif the whole batch yields nothing. Bounded by the caller's array, never by global stateforward(address token, address to, uint256 amount) nonReentrant- owner only. Moves a token the vault owes nobody: a stray transfer, or a launch whose escrow never became an entitlement. RevertsZeroAddresson a zerotoandTokenIsCredited(token)for any token with a credit, a pending hold, or anexpectingArrivalmarker. PvE credits never expire; there is no sweep and no treasury reclaim of credited amounts. EmitsForwardedrescuableExcess(address token) view returns (uint256)- the balance the vault provably owes nobody for a token it is already paying out:balance - (credit.amount + pending), or zero if the token has no obligation at all (that case isforward's) or carries anexpectingArrivalmarker (an announced arrival is an obligation of unknown size, so the only sound bound is the whole balance)rescueExcess(address token, address to) nonReentrant returns (uint256 amount)- owner only. MovesrescuableExcess. RevertsZeroAddress,NoExcessToRescue(token). EmitsExcessRescued. Computed, not trusted, and it never reaches the rounding residue insidecredit.amount. No per-claim bookkeeping, deliberately: tracking a paid-out total would cost a cold store on every claim to serve an owner-only recovery- Inherited UUPS:
upgradeToAndCall(address newImplementation, bytes data) payable- the upgrade authority (the owner while the authority is zero), and revertsUpgradesAreRenouncedonce renounced - Inherited ownership:
transferOwnership(address)(owner only),acceptOwnership()(pending owner only),renounceOwnership()(owner only, single step)
Events
event LauncherSet(address launcher);
event ExtraLauncherSet(address indexed launcher, bool allowed);
event MotocatStakingSet(address staking);
event PveCredited(address indexed token, uint256 amount, uint256 totalStaked, uint256 creditBlock);
event PveHeld(address indexed token, uint256 amount);
event PveClaimed(address indexed token, address indexed wallet, uint256 amount);
event PveReceived(address indexed token, address indexed creator, uint256 amount);
event Forwarded(address indexed token, address indexed to, uint256 amount);
event ExcessRescued(address indexed token, address indexed to, uint256 amount);
event PendingArrivalNoted(address indexed token);
event PendingArrivalCleared(address indexed token);
event PendingArrivalClearedByOwner(address indexed token, address indexed by);Inherited: UpgradesRenounced() from the mix-in; Upgraded(address indexed implementation),
Initialized(uint64 version), OwnershipTransferStarted, OwnershipTransferred from OpenZeppelin.
PveReceived.creatoris whatever the launcher passed. The curve passescreatorOf[token], the launch signer, never the registry's fee recipientPveHeldis the signal to put on the distribution-day runbook: the escrow sits until the owner callsrecreditPendingPendingArrivalClearedandPendingArrivalClearedByOwnerare separate on purpose, so an indexer can tell a normal graduation from an owner override
Custom errors
ZeroAddress()- a setter,forward,rescueExcessornotePendingArrivalreceived zeroNotLauncher()-recordPveornotePendingArrivalfrom an address that is neitherlaunchernor an extra launcherStakingNotSet()-recreditPendingwith no staking contract wiredAlreadyCredited()-recordPve,notePendingArrivalorclearPendingArrivalon a token that already has a credit or a pending holdAmountNotReceived()- the vault holds less than the recordedamountNoCredit()-recreditPendingon a token with nothing heldAlreadyClaimed()- declared; no path raises it at this commit.claimablereturns zero for a claimed wallet and the claim then revertsNothingToClaimNothingToClaim()-claimorclaimManyyielded zeroPoolStillEmpty()-recreditPendingwhile nobody is staked at the preceding blockSeedingNotClosed()-recreditPendingwhile the staking side's seed is still in flightNotAContract(address target)-setMotocatStakingpointed at an address with no codeNoExcessToRescue(address token)-rescueExcessfound nothing above the obligationStakingWiringFrozen()-setMotocatStakingafter a credit existsTokenIsCredited(address token)-forwardon a token owed to stakers, orclearPendingArrivalwhile the vault holds a balance of the tokenValueTooLarge()- an amount aboveuint128or a staked count aboveuint64AlreadyExpecting(address token)- a secondnotePendingArrivalfor the same tokenNotExpecting(address token)-clearPendingArrivalon a token with no marker- Inherited:
UpgradesAreRenounced()on an upgrade afterrenounceUpgradeability
Access control
| Function | Who |
|---|---|
recordPve, notePendingArrival | launcher or an extra launcher (the curve) |
claim, claimMany, claimable, rescuableExcess | anyone |
setLauncher, setExtraLauncher, setMotocatStaking, clearPendingArrival, recreditPending, forward, rescueExcess, renounceUpgradeability, upgradeToAndCall, transferOwnership, renounceOwnership | owner |
acceptOwnership | pending owner |
Invariants
- One credit per token, ever. A PvE token is CREATE2-deployed inside the transaction that first escrows it, so it cannot be launched twice, and a wallet's share is fixed at credit time and cannot move afterwards
- A credit is never sized against the current block, and never against a half-finished seed. Held, not reverted, because a creator's paid launch must never fail over an operational condition they cannot see
- Once any credit exists,
motocatStakingis frozen. Upgrading the staking implementation behind its proxy is unaffected - The vault never moves a balance it owes: a credited token, a pending token and a marked token are all refused by
forward, andrescueExcessis bounded by the computed obligation claimabletruncates toward the pool and the residue is deliberately stranded. It is correct, not drift- The vault sizes the split against
totalStakedAt(block.number - 1)and pays againststakedBalanceOfAt(wallet, creditBlock - 1), so both sides of every share read the same block
Two things the source says about itself that are out of date: the extraLaunchers comment calls
the curve's recordPveLate permissionless. It is owner-or-keeper gated at the curve pin. And the
launcher slot is described as belonging to the old launch contract. That contract is retired, and
the deploy order below says what to do with the slot.
UUPSRenounceable (src/upgrade/UUPSRenounceable.sol)
The UUPS mix-in PveVault inherits. Adds an operations-preserving one-way "upgrade-to-immutable"
switch, independent of ownership, in ERC-7201 namespaced storage so it costs no sequential slots.
upgradesRenounced() view returns (bool)- anyone.trueonce the implementation can never be upgraded again_requireUpgradable()internal - called from_authorizeUpgrade; revertsUpgradesAreRenounced()once renounced_renounceUpgradeability()internal - one-way; emitsUpgradesRenounced(). The inheriting contract exposes the access-gated external entrypoint, which onPveVaultisrenounceUpgradeability()
The cat-stake mirror: how PvE will work off Ethereum
Motoswap and moto.fun open on Ethereum only. No L2 mirror is live, and outboxCount() is zero.
On Ethereum the vault reads MotocatStakingV3 directly. On an L2 the cats stay on Ethereum, so PvE has
nothing to read unless the stake state is mirrored. The mirror is one-way and count-only: every stake
or unstake on Ethereum fans a (wallet, stakedCount) payload out to every registered L1 outbox
adapter, each adapter carries it over its stack's canonical bridge, a receiver on the L2 authenticates
it, and a MirrorRegistry on the L2 stores it as checkpoints in the same append-only shape the L1
contract uses. The L2 PveVault is pointed at the registry and needs no changes.
Two invariants hold across every transport, and they are the reason there is one registry rather than one per stack:
- Pay once per answer. An adapter refuses to resend a count it has already sent for a wallet, returning rather than reverting because the escrow wraps the call in
try/catch. This closed an amplification where cheap permissionless rebroadcasts could drain a keeper float into duplicate messages - The ordering key, bit for bit:
(block.number << 64) | counter, with a per-wallet counter, so the registry can drop anything not strictly newer than what it has applied. Retryable tickets and bridge messages are not ordered, and a stake and unstake in one L1 block produce two messages with the same block number
A mirror that is live is not a mirror that is complete. Registering an outbox does not backfill
existing stakers; until every wallet has been broadcast once, totalStakedAt on the L2 is an
undercount, and a PvE credit landing then pays the never-swept holders nothing, permanently. The
rebroadcastMany sweep and the registry's closeSeeding latch exist for exactly that.
The broadcast surface of MotocatStakingV3 (src/nft/MotocatStakingV3.sol)
The staking contract itself is documented on the Motoswap contracts inventory. Only the part the mirror uses is here.
outboxes(uint256 i) -> address,isOutbox(address) -> bool- the registered adapters.isOutboxis also what an adapter's paid paths check before trusting the pointer back to the escrowoutboxCount() view returns (uint256)- anyone. Zero is a valid, expected state at Ethereum launchstakedBalanceOf(address wallet) view returns (uint256)- anyone. The one read an adapter takes from the escrowaddOutbox(address outbox)- owner only. RevertsZeroOutbox,OutboxAlreadyRegistered. EmitsOutboxAddedremoveOutbox(address outbox)- owner only. Swap-and-pop, so ordering is not stable. RevertsOutboxNotRegistered. EmitsOutboxRemovedrebroadcast(address wallet) nonReentrant- anyone. Re-sends the wallet's live count to every adapter. Safe to leave open because it re-reads state rather than replaying a message: the worst a caller achieves is paying gas to tell every mirror the truthrebroadcastMany(address[] wallets) nonReentrant- anyone. The same, batched. RevertsEmptyArray. No filtering and no deduplication: a wallet with zero staked cats still broadcasts0, because a mirror that missed an unstake is stale in exactly that direction
Every stake and unstake also broadcasts, through the same private fan-out, with zero value. A failing
adapter never blocks a stake: the call is wrapped in try/catch and surfaces as BroadcastFailed.
event OutboxAdded(address indexed outbox);
event OutboxRemoved(address indexed outbox);
event BroadcastFailed(address indexed outbox, address indexed wallet); // the stake succeeded; that chain's mirror is stale for wallet
event Broadcast(address indexed wallet, uint256 stakedCount);Errors on this surface: ZeroOutbox(), OutboxAlreadyRegistered(), OutboxNotRegistered(),
EmptyArray().
IL1Outbox and IStakedCount (src/nft/IL1Outbox.sol, src/crosschain/IStakedCount.sol)
IL1Outbox is the single seam every chain plugs into, vendored from the motocat-staking repo. One
function, deliberately empty of stack vocabulary:
send(bytes payload) payable- the payload isabi.encode(address wallet, uint256 stakedCount), opaque to the escrow. Implementations must tolerate zero value and must not assume the caller retries
IStakedCount is the single view an adapter needs from the escrow:
stakedBalanceOf(address wallet) view returns (uint256)isOutbox(address outbox) view returns (bool)- the other half of the wiring. An adapter that reads counts from a staking contract that never registered it is reading a stranger's ledger
ArbitrumOutbox (src/crosschain/ArbitrumOutbox.sol)
The first IL1Outbox: forwards a broadcast to an Arbitrum-stack L2 as a retryable ticket, paid from
the adapter's own float, so staking gas stays flat for users. Every Arbitrum-specific concept
(retryables, submission cost, address aliasing) is confined to this file. Ownable2Step.
Constructor
constructor(IArbitrumInbox inbox_, address mirror_, address owner_)- revertsZeroAddresson a zero inbox or mirror.refundTodefaults to the owner
Constants
MIN_GAS_LIMIT = 250_000- hard floor undergasLimit. Too low is the one misconfiguration that fails silently in both directions: the ticket is created,TicketCreatedfires, L1 reports success, and the auto-redeem runs out of gas on the far side, so the mirror goes staleMIN_MAX_FEE_PER_GAS = 0.01 gwei- hard floor undermaxFeePerGas. The same silent failure through a different door: a low fee makes the balance check pass more easily and the retryable never executes
State variables (public getters)
inbox: IArbitrumInbox(immutable) - the Arbitrum Inbox on L1mirror: address(immutable) - the L2MirrorRegistry. Immutable because a re-pointable target would let one owner transaction redirect every future broadcaststaking: address- the only address permitted to callsendgasLimit: uint256- L2 gas for the mirror write. Ships at500_000. Unused L2 gas is refunded, so headroom is close to freemaxFeePerGas: uint256- L2 gas price ceiling. Ships at0.1 gweimaxSubmissionCost: uint256- floor on the submission fee. Ships at0.001 ether. The live figure is read from the inbox and this only floors itrefundTo: address- where L2 refunds unspent fees on the float-funded pathlastMirroredPlusOne(address wallet) view returns (uint256)- the last count this adapter paid to mirror for the wallet, stored+1so zero means never. The pay-once guardsendCounter(address wallet) view returns (uint256)- how many messages this adapter has stamped for the wallet. Both numbers share one storage word: the encoded count in the low 128 bits, the counter above
External and public functions
setStaking(address staking_)- owner only. RevertsZeroAddress. Settable rather than immutable because the adapter is deployed before it is registered on the escrow, and an escrow redeploy must not strand a funded adapter. EmitsStakingSetsetParams(uint256 gasLimit_, uint256 maxFeePerGas_, uint256 maxSubmissionCost_)- owner only. RevertsGasLimitTooLow(given, floor)underMIN_GAS_LIMITandMaxFeePerGasTooLow(given, floor)underMIN_MAX_FEE_PER_GAS.maxSubmissionCostis unbounded because it is a floor the contract raises from live pricing. EmitsParamsSetsetRefundTo(address refundTo_)- owner only. RevertsZeroAddress. EmitsRefundToSetrequiredSubmissionCost() view returns (uint256)- anyone. The submission fee this ticket needs right now, read frominbox.calculateRetryableSubmissionFeefor the message size, plus a 50% margin for base-fee movement between the read and the submission, floored atmaxSubmissionCost. A failed read falls back to the floor, because this runs insidesend, which the escrow catchesticketCost() view returns (uint256)- anyone.requiredSubmissionCost() + gasLimit * maxFeePerGas. Read this to size a top-up or aresendsend(bytes payload) payable- staking only (NotStaking). EmitsFundedif value arrived. Decodes(wallet, stakedCount). If the adapter already mirrored this exact count, emitsAlreadyMirroredand returns. Else revertsUnderfunded(required, held)if the float cannot coverticketCost, stamps the ordering key, submits the retryable addressed tomirror.setStake(wallet, stakedCount, orderingKey)withrefundToas both refund and beneficiary, and emitsTicketCreatedresend(address wallet) payable- anyone, caller-funded. The escape hatch for a lost ticket: a retryable that expired unredeemed leaves the live count equal to the last count sent, so the pay-once guard correctly suppresses every free rebroadcast. Requires mutual wiring (StakingNotSet,NotRegisteredWithStaking(staking)), a non-zero wallet, andmsg.value >= ticketCost()elseResendUnderfunded(required, paid). Reads the count live from the escrow, never from the caller. The L2 refund and the ticket's beneficiary are the caller, notrefundTo. Does not touchlastMirroredPlusOne, so a stranger cannot consume the one float-funded send a wallet was owed. Overpayment on L1 stays as float and is logged asFunded. EmitsResentresendMany(address[] wallets) payable- anyone, caller-funded. The batched form, for the case where the L2 base fee rose abovemaxFeePerGasand every ticket in a window expired. RevertsEmptyArray. Cost is read once for the batch and the whole batch is refused if underfunded rather than paying for a prefix. EmitsResentper walletreceive() payable- anyone. Tops up the float. EmitsFundedsweep(address to, uint256 amount)- owner only. Recovers float. RevertsZeroAddress,SweepFailed. EmitsSwept- Inherited ownership surface
Events
event StakingSet(address indexed staking);
event ParamsSet(uint256 gasLimit, uint256 maxFeePerGas, uint256 maxSubmissionCost);
event RefundToSet(address indexed refundTo);
event TicketCreated(address indexed wallet, uint256 stakedCount, uint256 ticketId, uint256 orderingKey, uint256 gasLimit, uint256 maxFeePerGas, uint256 submissionCost);
event AlreadyMirrored(address indexed wallet, uint256 stakedCount);
event Resent(address indexed wallet, address indexed payer, uint256 stakedCount, uint256 paid);
event Funded(address indexed from, uint256 amount);
event Swept(address indexed to, uint256 amount);TicketCreated carries the parameters it was sent with because the one failure left on this path is
invisible from L1: if the far chain's gas price rises above maxFeePerGas, the ticket exists and never
executes. One subscription answers "was this ticket ever going to execute, and did it". Resent.paid
is what that ticket cost, not what the caller sent; the excess surfaces as Funded.
Custom errors
ZeroAddress()- constructor,setStaking,setRefundTo,sweep,resendorresendManyreceived zeroNotStaking()-sendfrom anyone butstakingUnderfunded(uint256 required, uint256 held)- the float cannot cover a float-funded ticketSweepFailed()- the sweep transfer revertedGasLimitTooLow(uint256 given, uint256 floor),MaxFeePerGasTooLow(uint256 given, uint256 floor)-setParamsboundsEmptyArray()-resendManywith no walletsStakingNotSet()- a paid path beforesetStakingNotRegisteredWithStaking(address staking)- a paid path while the escrow has not registered this adapterResendUnderfunded(uint256 required, uint256 paid)- the caller did not cover the ticket
Access control: send is staking-only. resend, resendMany, receive and the views are open.
setStaking, setParams, setRefundTo, sweep and the ownership transfer are owner-only.
Invariants
- The float is spent only on a count the mirror does not already have, and only from
send. The paid paths checkmsg.value, never the balance, so the float is unreachable from them - Every ticket carries a strictly increasing ordering key per wallet, within and across blocks
- The two funding paths encode exactly the same L2 call, through one private
_submit, so they cannot drift
OpOutbox (src/crosschain/OpOutbox.sol)
The second IL1Outbox: mirrors counts to an OP-stack L2 through the canonical L1CrossDomainMessenger.
Delivery is paid by this transaction's own L1 gas, so there is no submission cost, no maxFeePerGas
and no float to underfund. If minGasLimit is too low the message is delivered and reverts on L2,
which is a visible failure a replay can fix. Ownable2Step.
Constructor
constructor(address messenger_, address owner_)- revertsZeroAddress.minGasLimitstarts at300_000
Constants
MIN_GAS_FLOOR = 250_000(uint32) - floor underminGasLimit, sized off the measured write through the receiver. A floor below the write is a legal setting under which every first-ever message for a wallet reverts out of gas on arrival
State variables (public getters)
messenger: IL1CrossDomainMessenger(immutable) - a swappable messenger is a swappable trust rootreceiver: address- the L2OpMirrorReceiver, not the registry itselfstaking: address- the escrow permitted to broadcastminGasLimit: uint32- execution budget requested on L2lastMirroredPlusOne(address wallet) view returns (uint256),sendCounter(address wallet) view returns (uint256)- as on the Arbitrum adapter
External and public functions
setStaking(address staking_),setReceiver(address receiver_)- owner only. RevertsZeroAddress. EmitsStakingSet/ReceiverSetsetMinGasLimit(uint32 minGasLimit_)- owner only. RevertsMinGasLimitTooLow(given, floor)underMIN_GAS_FLOOR. EmitsMinGasLimitSetsend(bytes payload) payable- staking only (NotStaking);ReceiverNotSetbeforesetReceiver. Pay-once guard, then stamps the key and callsmessenger.sendMessage(receiver, setStake(wallet, stakedCount, orderingKey), minGasLimit)forwardingmsg.value. EmitsMessageSentresend(address wallet) payable- anyone, caller-funded by the transaction's own gas.ReceiverNotSet,StakingNotSet,ZeroAddress. Reads the count live from the escrow. Guard carried through unchanged; only the counter moves. EmitsResent- Inherited ownership surface
Events
event StakingSet(address indexed staking);
event ReceiverSet(address indexed receiver);
event MinGasLimitSet(uint32 minGasLimit);
event MessageSent(address indexed wallet, uint256 stakedCount, uint256 orderingKey, uint32 minGasLimit);
event AlreadyMirrored(address indexed wallet, uint256 stakedCount);
event Resent(address indexed wallet, address indexed payer, uint256 stakedCount);Custom errors: ZeroAddress(), NotStaking(), ReceiverNotSet(),
MinGasLimitTooLow(uint32 given, uint32 floor), StakingNotSet().
Access control: send is staking-only; resend is open; the setters and ownership are owner-only.
There is no receive, no sweep and no float on this adapter.
LzOutbox (src/crosschain/LzOutbox.sol)
The third IL1Outbox: mirrors counts over LayerZero V2 to a chain with no canonical bridge from
Ethereum (BSC). LayerZero charges a real per-message fee in native token, so the pay-once rule matters
more here, not less, and the fee is quoted from the endpoint rather than guessed. Ownable2Step.
Constructor
constructor(address endpoint_, address owner_)- revertsZeroAddress.refundTodefaults to the owner
State variables (public getters)
endpoint: ILayerZeroEndpointV2(immutable)dstEid: uint32,peer: bytes32- the destination endpoint id and the receiver there, as LayerZero addresses itoptions: bytes- execution options handed to LayerZero. Opaque here. Empty is not a benign default: probed against EndpointV2 on mainnet, an empty options blob makes the quote revert, so an unset lane cannot send at all, and because the escrow catches the revert, a future BSC mirror would never updatestaking: address,refundTo: addresslastMirroredPlusOne(address wallet) view returns (uint256),sendCounter(address wallet) view returns (uint256)
External and public functions
setStaking(address staking_),setRefundTo(address refundTo_)- owner only. RevertsZeroAddresssetPeer(uint32 dstEid_, bytes32 peer_)- owner only. Both together, because they are one fact. RevertsZeroAddressif either is zero. EmitsPeerSetsetOptions(bytes options_)- owner only. RevertsOptionsNotSeton empty. EmitsOptionsSetquoteFee(address wallet, uint256 stakedCount) view returns (uint256)- anyone. The native fee the next message for this wallet would cost, quoted from the endpointsend(bytes payload) payable- staking only (NotStaking);PeerNotSet,OptionsNotSet. EmitsFundedif value arrived. Pay-once guard, then quotes against the real key and revertsUnderfunded(required, held)if the float cannot cover it. Sends withrefundToas the refund address. EmitsMessageSentwith the LayerZeroguidresend(address wallet) payable- anyone, caller-funded.PeerNotSet,OptionsNotSet,StakingNotSet,ZeroAddress. Reads the count live. Quoted once, against the key actually sent, and charged to the caller (ResendUnderfunded(required, sent)); the caller is the refund address. Overpayment stays as float and is logged asFunded. EmitsResentreceive() payable- anyone. EmitsFundedsweep(address to)- owner only. Sweeps the whole balance. RevertsZeroAddress,SweepFailed. EmitsSwept- Inherited ownership surface
Events
event StakingSet(address indexed staking);
event PeerSet(uint32 dstEid, bytes32 peer);
event OptionsSet(bytes options);
event RefundToSet(address indexed refundTo);
event MessageSent(address indexed wallet, uint256 stakedCount, uint256 orderingKey, uint256 fee, bytes32 guid);
event AlreadyMirrored(address indexed wallet, uint256 stakedCount);
event Resent(address indexed wallet, address indexed payer, uint256 stakedCount, uint256 paid);
event Funded(address indexed from, uint256 amount);
event Swept(address indexed to, uint256 amount);Custom errors: ZeroAddress(), NotStaking(), PeerNotSet(), Underfunded(uint256 required, uint256 held),
SweepFailed(), ResendUnderfunded(uint256 required, uint256 sent), StakingNotSet(), OptionsNotSet().
The message body is abi.encode(wallet, stakedCount, orderingKey), decoded by LzMirrorReceiver.
OpMirrorReceiver (src/crosschain/OpMirrorReceiver.sol)
Authenticates an L1 cat-stake message on the OP stack and forwards it to an unchanged MirrorRegistry.
A receiver rather than a second registry, so the ordering-key invariant keeps one implementation.
Ownable2Step.
constructor(address messenger_, address registry_, address owner_)- revertsZeroAddressmessenger: IL2CrossDomainMessenger(immutable),registry: IMirrorRegistryWrite(immutable),l1Outbox: address- the L1 adapter whose messages are accepted. Owner-set, because the L1 side may be redeployed without redeploying thissetL1Outbox(address l1Outbox_)- owner only. RevertsZeroAddress. EmitsL1OutboxSetsetStake(address wallet, uint256 stakedCount, uint256 orderingKey)- the L2 messenger only (NotMessenger), and the L1 sender it reports must equall1Outbox(OutboxNotSet,NotL1Outbox(reported)). Both halves are checked:xDomainMessageSenderis stale-but-readable outside a cross-domain call, so checking only the second would let anyone call in while it happened to hold the right value. Forwards toregistry.setStake. EmitsStakeForwarded. Same signature as the registry's own, so the L1 adapter encodes one call for either stackaliasPreimage() view returns (address)- anyone.address(this) - 0x1111...1111, wrapping. The value the registry'sl1Outboxmust be set to for this receiver to be accepted: the registry authenticates by Arbitrum alias arithmetic and the OP stack has no aliasing, so setting the preimage makes the sum land back on this contract. It is not an address anyone controls, and correcting it later kills the lane silentlyregistryAcceptsThisReceiver() view returns (bool)- anyone. On-chain proof of the round trip. Deploy scripts and operators should call this rather than reasoning about the arithmetic
event L1OutboxSet(address indexed l1Outbox);
event StakeForwarded(address indexed wallet, uint256 stakedCount, uint256 orderingKey);Errors: ZeroAddress(), NotMessenger(), NotL1Outbox(address reported), OutboxNotSet().
LzMirrorReceiver (src/crosschain/LzMirrorReceiver.sol)
Authenticates a cat-stake message arriving over LayerZero and forwards it to an unchanged
MirrorRegistry. Implements ILayerZeroReceiver. Ownable2Step.
constructor(address endpoint_, address registry_, address owner_)- revertsZeroAddressendpoint: address(immutable),registry: IMirrorRegistryWrite(immutable),srcEid: uint32,peer: bytes32setPeer(uint32 srcEid_, bytes32 peer_)- owner only. Both together. RevertsZeroAddressif either is zero. EmitsPeerSetlzReceive(Origin origin, bytes32 guid, bytes message, address executor, bytes extraData) payable- the endpoint only (NotEndpoint), thenPeerNotSet,WrongSourceChain(got, expected)iforigin.srcEid != srcEid,WrongPeer(got, expected)iforigin.sender != peer. Three checks, not one: without the endpoint check anyone calls in with whatever origin they like; without the source-chain check the real peer address on a different chain is accepted; without the sender check any contract on the right chain can write the mirror.executorandextraDataare ignored deliberately, because they describe who paid to deliver, not who sent. Decodes(wallet, stakedCount, orderingKey), forwards toregistry.setStake, emitsStakeForwardedsweep(address to)- owner only. Recovers native token that arrived with a delivery, becauselzReceiveis payable and an executor option set with value would otherwise strand it. Swept rather than rejected, so a misconfiguration is a bookkeeping problem and not a stale mirror. RevertsZeroAddress,SweepFailed. EmitsSweptaliasPreimage() view returns (address),registryAcceptsThisReceiver() view returns (bool)- as onOpMirrorReceiver, for the same reason
event PeerSet(uint32 srcEid, bytes32 peer);
event StakeForwarded(address indexed wallet, uint256 stakedCount, uint256 orderingKey);
event Swept(address indexed to, uint256 amount);Errors: ZeroAddress(), SweepFailed(), NotEndpoint(), PeerNotSet(),
WrongSourceChain(uint32 got, uint32 expected), WrongPeer(bytes32 got, bytes32 expected).
MirrorRegistry (src/crosschain/MirrorRegistry.sol)
The L2 side of the mirror: how many cats a wallet has staked on Ethereum, readable on the L2 so PvE can
size a split there. This registry decides who can claim PvE, so anyone who can write it can mint
themselves a share. It is therefore writable only by the aliased L1 outbox, or by a receiver whose
alias preimage was installed as l1Outbox. Historical lookups, not just current state: writes are
checkpointed in the same append-only shape MotocatStakingV3 uses, so a past-block split cannot be
staked into. Ownable2Step, Solidity ^0.8.20.
Constructor
constructor(address owner_)
Types
struct Checkpoint { uint32 fromBlock; uint224 value; } // same packed shape as MotocatStaking.CheckpointState variables (public getters)
l1Outbox: address- the L1 adapter permitted to write, stored as its L1 address (or a receiver's alias preimage)seedingClosed: bool- whether the initial backfill is finished. One-way. The name and signature must stay exactlyseedingClosed()returningbool, because that is whatPveVaultprobes, and the vault's probe treats a revert as "closed". Against a registry without this function the mid-seed hold could never engage on an L2stakeOf(address wallet) -> uint256- the latest mirrored stake per wallettotalStaked: uint256- the sum of every wallet's mirrored stake, maintained as a running deltalastOrderingKey(address wallet) -> uint256-(L1 block << 64) | counterof the newest message applied for the wallet
External and public functions
closeSeeding()- owner only. One-way; a second call revertsSeedingAlreadyClosedso a runbook mistake is visible. ReleasesPveVault's mid-seed hold on this chain; anything the vault held while this was false is released afterwards byrecreditPending, one call per token. EmitsSeedingClosed. Call it only after the last wallet is in: closing late loses nothing, closing early cannot be undonesetL1Outbox(address l1Outbox_)- owner only. RevertsZeroAddress. EmitsL1OutboxSetexpectedAliasedSender() view returns (address)- anyone.l1Outbox + 0x1111...1111, unchecked because Arbitrum's aliasing wraps modulo 2^160setStake(address wallet, uint256 stakedCount, uint256 orderingKey)- the aliased L1 outbox only (NotAliasedL1Outbox, also whenl1Outboxis unset). A message withorderingKey <= lastOrderingKey[wallet]is ignored, not reverted:StaleMessageIgnoredfires and nothing changes, because reverting would leave a retryable permanently unredeemable for an outcome that is correct, and the newer message already carried the truth.<=rather than<, so equal stamps do not race. Otherwise records the key, writes the checkpoint, emitsStakeMirroredforceSync(address wallet, uint256 stakedCount)- owner only. Owner-attested write for when the L1 path itself is unavailable. Tryrebroadcastorresendon L1 first; they cannot assert something false. Advances the key to outrank the last source block the adapter was heard from, with the counter bits saturated, so an in-flight stale ticket cannot undo the correction. EmitsStakeForceSynced, deliberately distinct fromStakeMirroredforceSync(address wallet, uint256 stakedCount, uint256 outrankSourceBlock)- owner only. The same attestation, naming the source block it has to outrank, for a ticket minted in a later block that then stuck long enough to be out of date. RevertsForceSyncWouldNotStick(wallet, currentKey, proposedKey)if the named block would not outrank the applied key, because a write that silently does not stick is worse than a revertresetOrderingKey(address wallet, uint256 key)- owner only. Drops a wallet's key downwards only (OrderingKeyNotAhead(wallet, currentKey)otherwise), to unfreeze a mirror that a garbage-stamped key has locked out, which is the one stateforceSynccannot escape. Does not change the mirrored count. Its own gate and its own event because lowering a key re-opens the window a stale in-flight ticket needs. EmitsOrderingKeyReset. Do notforceSyncin the same block afterwards; it saturates the counter bits and would re-block every adapter message from that blockstakeOfAt(address wallet, uint256 blockNumber) view returns (uint256)- anyone. The wallet's mirrored count as of the end ofblockNumber. RevertsFutureLookupfor the current block or laterstakedBalanceOfAt(address wallet, uint256 blockNumber) view returns (uint256)- anyone. Alias ofstakeOfAtunder the namePveVaultcalls. This is the point of the contract: matching the vault's read shape means the audited vault deploys unchangedtotalStakedAt(uint256 blockNumber) view returns (uint256)- anyone. Total mirrored stake as of the end ofblockNumber, the denominator of an L2 PvE split. On Ethereum this denominator is the truth; here it is what the mirror has been told, and a holder never broadcast is not countedcheckpointCount(address wallet) view returns (uint256),totalCheckpointCount() view returns (uint256)- anyonelastSourceBlockOf(address wallet) view returns (uint256)- anyone.lastOrderingKey >> 64lastCounterOf(address wallet) view returns (uint64)- anyone. The low 64 bits- Inherited ownership surface
Events
event L1OutboxSet(address indexed l1Outbox);
event SeedingClosed();
event StaleMessageIgnored(address indexed wallet, uint256 orderingKey, uint256 applied);
event StakeMirrored(address indexed wallet, uint256 stakedCount);
event StakeForceSynced(address indexed wallet, uint256 stakedCount);
event OrderingKeyReset(address indexed wallet, uint256 previousKey, uint256 newKey);Custom errors
ZeroAddress()-setL1Outboxreceived zero, or a write named the zero walletSeedingAlreadyClosed()- a secondcloseSeedingNotAliasedL1Outbox()-setStakefrom anyone but the aliased outbox, or beforesetL1OutboxFutureLookup()- a historical read at the current block or laterCheckpointOverflow()- a block number aboveuint32or a value aboveuint224StaleMessage(address wallet, uint256 orderingKey, uint256 applied)- declared;setStakeemitsStaleMessageIgnoredand returns rather than raising itOrderingKeyNotAhead(address wallet, uint256 currentKey)-resetOrderingKeywith a key that is not below the current oneForceSyncWouldNotStick(address wallet, uint256 currentKey, uint256 proposedKey)- the three-argumentforceSyncnamed a block that would not outrank the applied key
Access control
| Function | Who |
|---|---|
setStake | the aliased L1 outbox (or a receiver via its alias preimage) |
closeSeeding, setL1Outbox, both forceSync forms, resetOrderingKey, transferOwnership, renounceOwnership | owner |
acceptOwnership | pending owner |
every *At, *Of, *Count read | anyone |
Invariants
- Ordering keys per wallet only ever move up, except through
resetOrderingKey, which is owner-gated, downward-only, and has its own event - Several writes in one block collapse to one checkpoint, so a lookup at that block sees the final value
totalStakedmoves by the difference on every write, because a mirror write replaces a wallet's count- Writes come from one of three transports through one implementation. Auth is native to each stack and lives in the adapter or receiver, never here
Cross-cutting mechanics
The bonding curve
Constant product against virtual reserves on both sides. The virtual token reserve is the zero-burn
condition W = R^2 / (S - 2R), rounded up to a whole token, where R is the coin's floor supply.
vEth, floorSupply and vTok below are the coin's own parameters, resolved once per call from
its paramsId (paramsOf(token) from outside). They equal VIRTUAL_ETH, FLOOR and
VIRTUAL_TOKENS only for a coin whose paramsId is 0.
(uint256 vEth, uint256 floorSupply, uint256 vTok) = _params(c);
// buy: fee off the input, then the swap
uint256 totalFeeBps = protocolFeeBps + creatorFeeBps;
uint256 net = gross - (gross * totalFeeBps) / BPS_DENOM;
uint256 ethVirt = vEth + c.realEth;
uint256 tokVirt = vTok + c.tokenReserve;
tokensOut = (tokVirt * net) / (ethVirt + net);
// floor-crossing partial fill: clamp to the floor, ceil the ETH needed, refund the rest
uint256 maxOut = c.tokenReserve - floorSupply;
if (tokensOut >= maxOut) {
tokensOut = maxOut;
uint256 netNeeded = (ethVirt * tokensOut + (tokVirt - tokensOut) - 1) / (tokVirt - tokensOut);
grossUsed = (netNeeded * BPS_DENOM + (BPS_DENOM - totalFeeBps) - 1) / (BPS_DENOM - totalFeeBps);
if (grossUsed > gross) grossUsed = gross;
net = grossUsed - (grossUsed * totalFeeBps) / BPS_DENOM;
}
// sell: the swap, then the fee off the output
uint256 grossOut = (ethVirt * tokensIn) / (tokVirt + tokensIn);
if (grossOut > c.realEth) revert CurveInsolvent();
uint256 fee = (grossOut * totalFeeBps) / BPS_DENOM;
ethOut = grossOut - fee;
// spot, 1e18 fixed point, the same number Trade.priceX18 carries
price = ((vEth + c.realEth) * 1e18) / (vTok + c.tokenReserve);
// progress, 0 to 1e18
progress = ((SUPPLY - c.tokenReserve) * 1e18) / (SUPPLY - floorSupply);Only the net, post-fee ETH enters realEth; the fee is wrapped and routed out in the same call. Both
ceilings in the partial fill round in the curve's favour, which is what makes the sell path
structurally solvent.
Slippage
- Buys take
minTokensOutonly; sells takeminEthOut. No curve trade has a deadline parameter, by design: there is no LP-side staleness. The only deadline on the user path isintent.deadline - The buy check is price-equivalent,
tokensOut * gross >= minTokensOut * grossUsed. On a full fillgrossUsed == grossand it reduces totokensOut >= minTokensOut. On a floor-crossing partial fill it compares the price actually paid against the price the caller asked for, so aminTokensOutset for the full amount still passes when the curve takes less ETH and returns fewer coins at the same rate tokensOut == 0always revertsSlippage()
The fee model
One blended rate on both sides: totalFeeBps = protocolFeeBps + creatorFeeBps, shipping at
70 + 50 = 120 bps. On a buy the fee comes off the input; on a sell it comes off the ETH output. The
creator fee ships at its cap, so it can only be lowered.
The split, in _routeFees:
creatorCut = (grossUsed * creatorFeeBps) / BPS_DENOM, clamped to the fee;protocolCut = fee - creatorCut- The registry's
activeCreator(token)is read through a guarded staticcall. Zero (disabled, or never registered) means the creator cut is zero and the whole fee is protocol - If there is a creator cut, the vault's
isDepositor(curve)is read the same way. A missing or false answer marks the routing degraded - If either read failed or the depositor grant is gone, and the creator cut was non-zero,
CreatorFeeRoutedToCollectorfires and the entire fee goes to the Collector. The trader-facing rate never changes. The registry and vault are governed by canonical governance and are immutable on the curve, so an upstream drift must not be able to stop a sell when the owner is forbidden to - The whole fee is wrapped to WETH. The protocol share transfers to
collector. The creator share transfers to the vault and is credited per coin vianotifyDeposit
The curve pays no referral fee. Referrals still earn on moto.fun: the referrer gets 10% of a referee's trading Points, the same as on Motoswap. The PvE vault receives no fee share: PvE is a coin donation funded by the buyer's own ETH.
Graduation adds one more fee leg: the MOTO buyback routes through the public FeeRouter, so the
protocol fee and the pair's live swapFeeBps both apply, and both are read live inside the floor
calculation.
Graduation
The trigger is a reserve check, not a market cap check. When a buy would take tokenReserve below
the coin's floorSupply, the fill clamps exactly to the floor, the excess ETH is refunded, status
becomes Frozen, frozenAt is stamped and FloorReached fires. Inside that transaction
FloorReached is emitted before the buy's own Trade, so an indexer folding status in log order
sees the freeze first. A keeper normally lands graduate within about a minute, which is a handful
of blocks, so Frozen is a state you will observe and must model.
graduate(token) then runs, permissionless and unpausable, in two phases:
Cheap-fail phase, no state written. Everything here is arithmetic and guarded reads, so a graduation that cannot be priced right now costs its keeper a few staticcalls rather than a full seeding that unwinds at the end.
- The bounty is taken from the pot and clamped to
pot / 50. The remainder splits:motoLegWeth = wethAmt / 2,wethLp = wethAmt - motoLegWeth - Both coin legs are sized at the final curve price,
p = (vEth + pot) / (floorSupply + vTok), using the coin's own parameters, with clamps guaranteeingtokensLpWeth + tokensLpMoto <= floorSupply. Atbounty = 0the unclamped sum exceeds the floor by a sub-token amount, and an unchecked subtraction once bricked graduation permanently - The MOTO floor is computed (below). An unreadable pool reverts
MotoPoolUnreadable; a missing reference reverts one of theMotoRef*errors - A zero
minMotoOut,tokensLpWeth,wethLportokensLpMotorevertsMotoPoolUnreadable. Both pools are a hard guarantee, so a MOTO leg with no coin side fails cheaply here rather than opening one pool
Commit phase. Nothing below returns a failure code; anything wrong reverts the whole thing.
status = Graduated,realEth = 0,tokenReserve = 0, and the pot minus the bounty is wrapped to WETH; the bounty stays as ETH for the keeper push- Swap before bond. The MOTO buy runs through the FeeRouter while the coin is still unbonded, so its own launch blocklist still refuses transfers to the future TOKEN/MOTO pair address.
motoOutis measured as the received balance delta, never the router's return value, and re-checked against the floor (MotoOutBelowFloor) setBonded()lifts the blocklist and the curve's allowance exemption permanently- TOKEN/WETH pool.
getPairorcreatePair, then the leg is scaled onto the pair's live reserve ratio if the pair is not empty, so the protocol never first-mints blind into someone else's ratio.mint(0xdead). A leg that shrank to zero revertsMotoPoolUnreadable - TOKEN/MOTO pool.
useMoto = min(motoOut, expectedOut), priced against the ratio TOKEN/WETH just opened at, then scaled onto the live pair ratio. Two pools or nothing: a dust-sized leg revertsMotoLegTooSmall.mint(0xdead) - Leftovers. Surplus MOTO above
useMotoand any WETH the ratio match shrank off go to the Collector. The coin-side remainder,floorSupply - tokensLpWeth - useTokens, burns to0xdead - The PvE credit runs, inside
try/catchwith an explicit code-length check first, so it can never revert the graduation - The bounty is pushed to
msg.sender, falling back tobountyOwedon failure Graduatedfires
LP disposition: both pools mint LP to 0xdead. Burned, not locked, not vested, not held.
Why atomic. Deferring the buyback meant graduation could complete with one pool seeded and the other owed, which forced a second transaction, a second bounty, an escrow, and a hold on the TOKEN/MOTO pair address. Every one of those existed to cover a window this function no longer opens.
Why holders are not stranded by a broken upstream. A graduation that reverts every block because
the FeeRouter is paused, the factory refuses pairs, or the MOTO pool is unreadable would leave the coin
Frozen for as long as somebody else's outage lasts. sell re-opens at SELL_REOPEN_DELAY and flips
the coin back to Trading, so the graduation math is untouched and holders can exit.
The MOTO price reference and floor
The MOTO leg is not gated by a spot-versus-TWAP deviation check. That design could not separate a sandwich from an honest rally, because both look like spot diverging from a lagging average, and a gate tight enough to catch the first refused the second.
Instead the curve keeps a two-slot ring of checkpoints of the MOTO/WETH pair's own cumulative price, and at graduation reconstructs what the reserves would be at the average price with today's depth:
twap = (cumulativeNow - cumulativeRef) / elapsed // UQ112x112, MOTO per WETH
rW' = ceil(sqrt(k * 2^112 / twap)) // rounded up, so rM' rounds down
rM' = k / rW'
net = wethIn * (BPS - protocolFeeBps) / BPS * (BPS - swapFeeBps)
expectedOut = net * rM' / (rW' * BPS + net)
minMotoOut = expectedOut * (BPS - maxTwapDeviationBps) / BPSA swap moves price but does not move k, so a front-runner who pushes the pool cannot lower this
floor; they can only make their own fill worse. What makes manipulation unprofitable is the fee stack:
every public swap must route the FeeRouter, so a manipulator pays the pair fee plus the router fee on
both legs. Both fees are read live from the factory and the FeeRouter and refused at or above 100%.
The reference window is bounded below by MIN_TWAP_WINDOW and above by MAX_TWAP_WINDOW. The older
slot is preferred because a longer window is more expensive to move. When the pair wrote its own
cumulative within TAIL_ANCHOR_MAX, the window ends at that write rather than at an extrapolated tail,
both on the way in (_rollCheckpoint) and on the way out (_motoFloor): the extrapolated tail is
spot times elapsed at the current spot, which is the one input an attacker controls for free inside a
block, and a V2 cumulative only ever records pre-swap prices, so a one-block push lives purely in that
tail. Refusing to read the tail gives it zero weight. Sustained multi-window manipulation remains the
known, expensive residual.
Checkpoints advance for free off ordinary traffic: every buy ends with try this.pokePriceCheckpoint{ gas: 120_000 }() {} catch {}.
It is an external self-call rather than an internal one so that a hostile pair's revert classes, including
the extcodesize check and the ABI decode, land in a frame the catch can see, and the gas cap bounds the
griefing. pokePriceCheckpoint is not nonReentrant for exactly this reason, and it writes only the
checkpoint slots.
PvE
The curve is a funnel into an external escrow plus one deferred credit. It never computes stake and never pays anyone.
- The vault is
pveVault, owner-settable. Zero disables fills. Per coin the vault is pinned at the first fill inpveVaultOfand never changes - Entry points: the
pveEthslice oflaunch, and the publicbuyPve. Nothing else reaches_escrowPve - Each fill adds to
pveAccrued, transfers the coins to the pinned vault immediately, and on the first fill only callsnotePendingArrivalfail-closed. The vault's marker is one-shot and stays set through every later fill untilrecordPveclears it, so per-fill calling would revert the second fill of every multi-fill coin - The credit runs exactly once, at graduation, inside
try/catchafter an explicit code-length check on the pinned vault. Success clearspveAccruedand emitsPveRecorded; failure keeps the accrual and emitsPveRecordFailed - The beneficiary is
creatorOf[token], the launch signer, read from the curve's own storage. Never the registry's fee recipient, and never dependent on the registry answering recordPveLateis the retry path and is owner orpveKeeperonly, because the vault sizes the credit at the caller's block- On the vault side the credit is one per token, sized at the preceding block, held rather than reverted when nobody is staked or the seed is in flight, and claimed pull-based by each staker against the frozen snapshot. On a future L2 deployment the vault would read a
MirrorRegistryinstead of the staking contract
The deploy order that keeps the first notePendingArrival from reverting NotLauncher is: vault
first, with the curve granted as an extra launcher, then the curve. A vault whose implementation
predates notePendingArrival fails the curve closed on the first fill; see the deploy section.
Launch and anti-snipe constraints
Present on chain:
- The pre-bond pool blocklist, derived per launch on every configured venue (Motoswap, and Uniswap V2 and Sushi when configured) against WETH, MOTO and every entry of
blockedQuoteAssets. Every venue gets the same quote set. The Motoswap init-code hash is read live per launch throughpairCodeHash(), because pairs are beacon proxies behind a UUPS factory and a cached hash could silently re-open pre-bond seeding after an upgrade. Coins launched before such an upgrade keep their baked lists; graduation resolves pairs live throughgetPair, so those coins still graduate correctly - The narrow curve custody exemption on the token, only while unbonded and only when
to == curve - Atomic dev buy handling: one buy at one price, split into PvE and dev slices after, with the PvE half transferred straight to the vault and never through the creator's balance
LaunchIntent.submitterbinding, and the rule that a fundedbuyWithIntentmust name its submitter. Unbound and funded, the signature would be a bearer token a mempool watcher could take the whole dev-buy allocation with- A creator-bound CREATE2 salt,
keccak256(abi.encode(creator, salt)), so a replayed salt from another wallet lands at a different address - Nonce and deadline on intents
- Name and symbol length rules: symbol 1 to 16 bytes, name 1 to 48 bytes. No character-set rule, no uniqueness
- The fee-recipient blocklist (
BadFeeRecipient) - Registration hijack checks: an address already registered to somebody else reverts
CreatorAlreadyClaimed; an unreadable registry revertsRegistryUnreadable; a refusal with the slot still empty revertsRegistryRefused. A refusal because the coin is already registered to this same creator is benign and the launch proceeds. No coin launches without its creator fee registered - Pause levers on launches, all buys, and per-coin buys
Deliberately absent:
- No ticker exclusivity or reservation window. Duplicate symbols are allowed and normal; the CREATE2 address is the identity, not the string. A reservation window only ever rationed a free resource, which made squatting the cheap attack and gave honest launches a way to fail. A short reserved list is refused outright (MOTO, WETH, ETH, USDC and USDT), which is the reserved-list check the app backend runs on
symbol; every other symbol is open to anyone - No block delay, no cooldown, no max buy, no per-wallet cap, no anti-bot window. The first buyer can take everything above the coin's graduation floor in one transaction. The partial-fill clamp bounds a single buy at the graduation floor, not below it
- No deadline on curve trades
- No LP lock, because LP is burned
- No withdraw or sweep on the curve
- No owner path over
graduateor over sells outside the boundedFrozenwindow
Deploy and upgrade shape
What is deployed, per chain
| Contract | Deployed by | Upgradeable | Owner |
|---|---|---|---|
LaunchpadCurve | moto-fun Deploy.s.sol, through ProxyDeploy: the proxy is deployed and initialize runs in one transaction | Yes. UUPS behind an ERC-1967 proxy. Upgrades belong to upgradeAuthority, a timelock deployed by DeployCurveTimelock.s.sol, and can be renounced one-way | Config.owner, Ownable2Step. The owner cannot upgrade |
GraduationSeeder, MotoFloorMath, LaunchpadTokenDeployer | moto-fun Deploy.s.sol, linked into the curve implementation | No. Libraries, replaced only by upgrading the curve to an implementation linked against new ones | none |
LaunchpadToken (implementation) | the curve's initialize, through LaunchpadTokenDeployer | No. Clones are EIP-1167 proxies over it | none |
LaunchpadLens | moto-fun Deploy.s.sol, same batch as the curve | No | none |
GasTank | moto-fun DeployGasTank.s.sol | No | its owner, Ownable2Step, renounce disabled |
CreatorFeeRegistry | the DEX suite (canonical) | No. Ownable2Step | governance |
CreatorFeeVault | the DEX suite (canonical) | No. Ownable2Step | governance |
PveVault | the DEX suite (canonical) | UUPS, with renounceUpgradeability | the suite's owner key |
MirrorRegistry, the outboxes, the receivers | the DEX suite (DeployCrosschain.s.sol) | No. Ownable2Step | the suite's owner key |
The curve is the one upgradeable contract in the moto-fun repo. Config is an initializer argument,
what used to be immutables are storage set once, and the address you read from /config is the
proxy. Its implementation address is in the ERC-1967 implementation slot and changes on an upgrade,
so never pin it. Confirm the deployed shape against /config and the chain rather than against this
page.
The dependency order
Standing moto.fun up on a chain has a fixed order, and every step has a readback. Steps 1 to 3 and 5 belong to the DEX suite; 4 and 6 to 8 to moto.fun.
- MOTO exists on the chain. Ethereum: the canonical ERC-20. Later networks will use a bridged child MOTO
- Quote registry. MOTO is a quote asset and
quoteRank(WETH) > quoteRank(MOTO), so the FeeRouter skims the WETH side of the graduation swap, which is what the curve's floor arithmetic assumes. The preflight hard-asserts this; the setting is the suite's - MOTO/WETH pool seeded to the armed depth floor. The preflight's
DEFAULT_MIN_MOTO_POOL_WETHis100 etheron the WETH side, overridable byMIN_MOTO_POOL_WETH, and the protocol-owned LP share must be at leastMIN_POL_SHARE_BPS = 9_000whenPOL_HOLDERis given. Read the pair after the seed, not the seed transaction; fee dust has drifted a seed under the floor before. Depth is a throughput floor, not a fill-quality knob: graduation is fill-or-revert-retry with no single-pool fallback, so back-to-back graduations beyond roughly one per reference window wait a bounded time rather than degrading - PvE vault. The suite deploys the canonical
PveVault; moto.fun binds to it.motocatStakingis the chain's staking contract or mirror, or zero until the mirror exists, in which case credits hold. The curve is admitted throughsetExtraLauncher(curve, true)and is the sole launcher. NeversetLauncher: that slot refuses zero and cannot be un-set, the old launch contract that held it is retired, and nothing new should claim it. Before the curve goes on a chain, probe the vault implementation fornotePendingArrival(address); a vault without it fails the curve closed on the first PvE fill. Read backowner,launcher,extraLaunchers(curve),motocatStaking - Chef and escrow against MOTO. Not moto.fun's contracts, but the Points and Rakeback regime assumes the chain's MasterChef and RewardVestingEscrow are wired to this MOTO
- Curve.
Deploy.s.sol, dry run first with everypreflight:line passing, then broadcast, then the three grants by readback, thenVerifyDeployment.s.sol - Env and address books. Backend per-chain service and web address books read the curve, lens, token implementation and PvE vault;
/configserves them - Armed-graduation proof with a non-creator wallet: launch with a PvE slice, stage to 90%, finish from a second wallet, and the keeper must graduate unprompted. Both pairs created, LP 100% burned, MOTO leg inside the band, bounty paid,
PveReceivedto the signer
Deploy.s.sol (moto-fun, contracts/script/Deploy.s.sol)
Reads everything from env so the same script serves every environment. It deploys the
curve behind its proxy through ProxyDeploy, so the proxy deploy and initialize are one
transaction.
Required env: OWNER, DEPLOYER_KEY, WETH, MOTO, MOTO_FACTORY, FEE_ROUTER, COLLECTOR,
UPGRADE_AUTHORITY (the timelock; there is no fallback to the owner, and initialize refuses zero),
and DEPLOY_MODE, which must be exactly dry or broadcast. Unset, or anything else, is refused.
Optional: POL_CHEF and POL_CHEF_PID, PVE_VAULT (zero disables PvE), PVE_KEEPER (zero leaves recordPveLate owner-only),
CREATOR_FEE_REGISTRY and CREATOR_FEE_VAULT (both or neither), UNI_FACTORY with
UNI_PAIR_INIT_CODE_HASH, SUSHI_FACTORY with SUSHI_PAIR_INIT_CODE_HASH, BLOCKED_QUOTE_ASSETS
(an address array; USDC and USDT on Ethereum), QUOTE_REGISTRY, MIN_MOTO_POOL_WETH, POL_HOLDER.
What it does, in order:
- Runs
LaunchpadPreflight.assertChainConfigbefore a wei of gas is spent: the FeeRouter's protocol fee is readable and below 100%; the factory's swap fee is readable and within its own cap; MOTO is a quote asset and WETH outranks it; the MOTO/WETH pair exists and holds at least the depth floor; the POL share meets its floor when a holder is named, and says loudly that it skipped the check when not - With both canonical creator-fee addresses set, points the curve at them. With neither, deploys the vendored pair, wires
setRegistrar(curve, true)while the deployer still owns the registry, and starts the two-step ownership transfer. Standalone testnets only - Deploys
LaunchpadCurvewith theConfigabove, thenLaunchpadLensagainst it in the same batch. A deployment that skips the lens ships a UI with no prices - Grants
pveKeeperwhenPVE_KEEPERis set and the deploy key is still the owner, and reads it back. WhenOWNERis already an account other than the deployer, prints the exactsetPveKeepercall the owner must make instead - Seeds the price reference with
pokePriceCheckpoint()inside the broadcast, and reverts the deploy if that fails, because the preflight already proved a funded pair exists and an unseeded curve would revert every graduationMotoRefMissinguntil somebody noticed - Prints the governance asks it cannot make itself
The three grants
The curve needs exactly three grants on contracts it does not own, and none of them are made by the script on the canonical path:
| Grant | On | Made by | If missed |
|---|---|---|---|
setRegistrar(curve, true) | CreatorFeeRegistry | governance | every launch reverts RegistryRefused. No coin launches unregistered |
setDepositor(curve, true) | CreatorFeeVault | governance | every trade routes its whole fee to the Collector with CreatorFeeRoutedToCollector. Trading continues; the creator share is recoverable by governance afterwards |
setExtraLauncher(curve, true) | PveVault | the vault owner | the first PvE fill on every coin reverts NotLauncher inside notePendingArrival, fail-closed |
The curve needs no factory.swapAllowed grant. The graduation MOTO leg routes through the public
FeeRouter, and the deployment's verification invariant is that only the router and the FeeRouter sit on
the swap perimeter. Do not ask to widen it.
VerifyDeployment.s.sol (moto-fun, contracts/script/VerifyDeployment.s.sol)
The second half of the deploy, run after the governance calls and before opening launches. Every
expectation is a required env var with no default (CURVE, EXPECT_OWNER, EXPECT_COLLECTOR,
EXPECT_WETH, EXPECT_MOTO, EXPECT_MOTO_FACTORY, EXPECT_FEE_ROUTER, EXPECT_PVE_VAULT,
EXPECT_REGISTRY, EXPECT_CREATOR_FEE_VAULT, the two owner expectations, EXPECT_PROTOCOL_FEE_BPS,
EXPECT_CREATOR_FEE_BPS, EXPECT_FEE_ROUTER_PROTOCOL_FEE_BPS, EXPECT_FACTORY_SWAP_FEE_BPS,
EXPECT_PVE_KEEPER, EXPECT_LAUNCHES_PAUSED, EXPECT_ALL_BUYS_PAUSED, EXPECT_GRADUATION_BOUNTY_WEI,
EXPECT_MAX_TWAP_DEVIATION_BPS, EXPECT_LAUNCH_INTENT_TYPEHASH, EXPECT_MIN_MOTO_POOL_WETH,
EXPECT_POL_HOLDER, and EXPECT_BLOCKED_QUOTE_ASSETS, which must exist even when empty). It re-runs
every preflight assertion, asserts the three grants, and reads the curve's own storage back against the
deploy book.
The failure it exists to catch is silent. A missed setDepositor leaves a launchpad that looks
perfectly deployed: the contract is there, zero-value launches succeed, the UI renders, and every
trade quietly routes its whole fee to the Collector.
DeployGasTank.s.sol (moto-fun, contracts/script/DeployGasTank.s.sol)
Deploys GasTank owned by the deploy key, writes one setPayee row per address in GAS_TANK_PAYEES,
optionally funds it, starts the two-step transfer to GAS_TANK_OWNER, then reads every
row and every setting back from storage before printing anything. Refuses before broadcasting a zero
payee, a payee equal to the owner, a duplicate payee, a target not above its floor, a zero per-call
cap, a per-day cap below the per-call cap, or a payee with code unless GAS_TANK_ALLOW_CONTRACT_PAYEE
is set.
Sizing env, each either one value for all payees or one per payee: GAS_TANK_FLOORS_WEI,
GAS_TANK_TARGETS_WEI, GAS_TANK_PER_CALL_CAPS_WEI, GAS_TANK_PER_DAY_CAPS_WEI. Plus
GAS_TANK_LOW_WATERMARK_WEI, GAS_TANK_SEND_GAS, GAS_TANK_INITIAL_FUNDING_WEI.
The script's defaults for the moto.fun keeper on Ethereum: floor 0.75 ether, target 2.5 ether, per
call 2 ether, per window 7.5 ether (half of the 15 ETH per 24 hours actually meant, because the
window is tumbling), watermark 2.5 ether, send gas 10_000. The real runway comes from the owner
after acceptOwnership, never from the deployer account. Then point the refiller cron at the address and
watch one refill land: an armed thing that has never fired is not proven.
Retiring a curve
A curve redeploy on a chain that already has one is a new singleton with a new coin set. Nothing
migrates: the old curve keeps serving its own coins until none of them is Trading or Frozen, and
only then is it marked retired on the backend and its grants revoked, setExtraLauncher(old, false) on
the vault and setRegistrar(old, false) on the registry, last. The app's rule is to compare the served
curve address with the one baked into its build before any wallet prompt, and to fail closed on a
mismatch rather than follow the new address blindly. Integrators should do the same.
Notes for integrators
- Every contract here is non-upgradeable except
LaunchpadCurveandPveVault, both UUPS proxies, and production points at the deployed canonical registry and vault rather than the vendored copies. The curve address in/configis the proxy. Confirm the deployed shape against/configand the chain rather than against this page FeeRouter,MotoSwapFactory,MotoSwapPair,CollectorandMotocatStakingV3are external to moto.fun. Only the surfaces the curve or the mirror touch are described here; see the Motoswap contracts inventory for the full contracts- Two error names do not match their conditions.
claimBountyrevertsPveNothingToRecord()when nothing is owed, andMotoPoolUnreadable()covers several unrelated read failures including the post-match WETH leg check. Decode by selector, and do not infer the cause from the name PveVault.AlreadyClaimed()andMirrorRegistry.StaleMessage(...)are declared and never raised at these commits. A claimed wallet revertsNothingToClaim, and a stale mirror message is ignored with an event- The
Tradeevent'sisBuyis not indexed,Graduateddoes not index the pair addresses, andFeeRecipientSetfires only on a redirect. Build the indexer around those three facts - A funded
buyWithIntentrequires a named submitter. A backend that publishes open intents and then lets the creator fund one from their own wallet getsValueNeedsSubmitter, not a launch. Route a creator's own dev buy throughlaunch, or bind the intent to them - Quote through
LaunchpadLens, never through a local copy of the math. Fees are owner-tunable state and the lens reads them at call time. A create form must usequoteLaunch, notquoteBuy - A keeper should drive graduation off
graduationReadinessand sleep untilreadyAtonTooFresh. Poking duringTooFreshmakes it worse.Upstreamis the only regime that means something is broken - Once an L2 deployment exists,
PveVault.totalStakedAtthere will be the mirror's count, not the truth. Before treating an L2 PvE credit as fair, check that the mirror was swept andseedingClosedon theMirrorRegistryistrue - The withheld parameters (the window bounds, the tail anchor cap, the band and its two bounds, and the blocked-quote cap, all named in the curve's constants list above) are public getters on the curve. Read them once at startup, and read
maxTwapDeviationBpsagain wheneverMaxTwapDeviationBpsSetfires