Reference

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 /config on the Motoswap API and /config on 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 initializer

initialize 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 equal LaunchpadToken.LAUNCH_SUPPLY
  • FLOOR = 200_000_000 ether (2e26) - parameter set 0: the graduation floor of a coin whose paramsId is 0, which is 80% sold. A coin's own floor is the second member of paramsOf(token)
  • VIRTUAL_TOKENS = 66_666_667 ether - parameter set 0: virtual token reserve, the zero-burn condition FLOOR^2 / (SUPPLY - 2 * FLOOR) rounded up to a whole token. The sub-token remainder becomes graduation dust
  • VIRTUAL_ETH = uint256(4 ether) / 3 - parameter set 0: 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 of paramsOf(token). Set 0 is what a fresh deployment launches coins under, since a launch is stamped with nextParamsId and that is zero until the first setLaunchParams. 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 stamped 0
  • BPS_DENOM = 10_000
  • MAX_PROTOCOL_FEE_BPS = 100 - cap on protocolFeeBps (1.00%)
  • MAX_CREATOR_FEE_BPS = 50 - cap on creatorFeeBps (0.50%)
  • MAX_BOUNTY = 0.08 ether - cap on graduationBounty. A constant, not a gas-scaled formula: a bounty that read tx.gasprice or block.basefee would be a lever an attacker sets, carved from the coin's own raise
  • SELL_REOPEN_DELAY = 15 minutes - how long a coin sits Frozen before sell reopens. 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 of Frozen in the block before its graduation lands; not unbounded, so a competitor cannot hold every frozen coin hostage by keeping graduation broken
  • MIN_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 contract
  • MAX_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 of MIN_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 contract
  • TAIL_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 here
  • MAX_TWAP_DEVIATION_CAP_BPS / MIN_TWAP_DEVIATION_BPS - the ceiling and floor on maxTwapDeviationBps. There is no "off" setting: with an atomic buyback, zero would not defer the MOTO leg, it would stop every graduation. Deliberately not published here
  • MAX_BLOCKED_QUOTES - cap on blockedQuoteAssets. 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 contract
  • LAUNCH_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. paramsId is stamped at launch from nextParamsId and never written again
  • launchNonce(address creator) -> uint256 - the per-creator EIP-712 nonce. Consumed by buyWithIntent only; launch never reads it
  • frozenAt(address token) -> uint256 - when the coin froze. Starts the sell-reopen clock
  • pveAccrued(address token) -> uint256 - running PvE escrow. Every fill adds here; cleared only by a successful credit, so a failed one stays retryable
  • pveVaultOf(address token) -> address - the vault a coin's PvE fills were paid into, pinned at the first fill and never changed
  • creatorOf(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 registry
  • bountyOwed(address keeper) -> uint256 - graduation bounty that could not be pushed to its keeper
  • motoPriceCumulativeLast: uint256 / motoPriceTimestampLast: uint32 - the newer checkpoint slot
  • motoPriceCumulativePrev: uint256 / motoPriceTimestampPrev: uint32 - the older checkpoint slot, kept so the measured window never collapses on a refresh
  • collector: address - protocol fee destination
  • maxTwapDeviationBps: 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 contract
  • blockedQuoteAssets(uint256 i) -> address - the extra quote assets whose pairs are blocked pre-bond. Owner-maintained; a coin keeps whatever list existed at its own launch
  • pveVault: address - the PvE escrow. Zero disables fills. Only steers coins that have not escrowed yet
  • pveKeeper: address - the second address, besides the owner, allowed to call recordPveLate. Zero means owner-only
  • protocolFeeBps: uint256 - ships at 70
  • creatorFeeBps: uint256 - ships at 50, which is also its cap
  • graduationBounty: uint256 - ships at 0.015 ether
  • launchesPaused: bool, allBuysPaused: bool
  • upgradeAuthority: address - the only account that may upgrade the implementation. A timelock, separate from owner, on a storage slot of its own
  • nextParamsId: uint16 - the id the next launch is stamped with, which is also the newest entry in the parameter table. Zero until the first setLaunchParams, and zero means parameter set 0, the three constants above
  • raiseBand() -> (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 every setLaunchParams. 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 inside initialize
  • registry: CreatorFeeRegistry, vault: CreatorFeeVault
  • WETH: IWETH, MOTO: address
  • motoFactory: IMotoSwapFactory, feeRouter: IFeeRouter
  • uniFactory: address / uniPairInitCodeHash: bytes32
  • sushiFactory: 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 of msg.value. pveEth is the slice of that value donated to the PvE escrow: 0 is a plain dev buy, msg.value is an all-PvE launch, anything between is both at once from one curve fill at one price. Reverts PveExceedsValue if pveEth > msg.value, and PveUnavailable if pveEth > 0 while pveVault is zero. The internal buy runs with minTokensOut = 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 against grossUsed, not msg.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 balance
  • buyWithIntent(LaunchIntent intent, bytes signature, uint256 minTokensOut) payable nonReentrant returns (address token) - anyone, subject to the intent. The offline-deploy path. Checks in order: IntentExpired past intent.deadline; NotTheSubmitter if intent.submitter is set and is not msg.sender; ValueNeedsSubmitter if intent.submitter is zero and msg.value > 0; BadNonce if intent.nonce != launchNonce[intent.creator]; BadIntentSignature if ECDSA recovery of launchIntentDigest(intent) is not intent.creator. Then bumps the nonce, materializes the coin bound to the signer, and if value was sent runs an ordinary buy credited to msg.sender. Zero value materializes without buying; that is the relayer path and it stays open to anyone
  • buy(address token, uint256 minTokensOut) payable nonReentrant returns (uint256 tokensOut) - anyone. Ordinary buy; tokens land with msg.sender
  • sell(address token, uint256 tokensIn, uint256 minEthOut) nonReentrant returns (uint256 ethOut) - anyone. Pulls tokensIn with safeTransferFrom (the token's narrow curve exemption means no approval is needed while unbonded), pays the net ETH. Accepts a Frozen coin once SELL_REOPEN_DELAY has passed since frozenAt, and such a sell first flips the coin back to Trading, because it is no longer at the floor and no longer a graduation candidate
  • buyPve(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 a Trade under their address, and keeps nothing. Only while the coin is Trading: a Frozen coin is before graduation but reverts NotTrading, 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. Requires Status.Frozen, else NotFrozen. 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 below
  • pokePriceCheckpoint() - anyone. Deliberately not nonReentrant, because every buy re-enters it through a gas-capped external self-call. Seeds or advances the MOTO/WETH price checkpoint. Reverts NoMotoPool if the pair cannot be read, and CheckpointFresh if the last roll is younger than MIN_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 forever
  • recordPveLate(address token) nonReentrant - owner or pveKeeper only, else NotAuthorized. Retries a PvE credit that failed at graduation. Requires Status.Graduated (NotGraduated) and a non-zero pveAccrued (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 earned
  • claimBounty(address to) nonReentrant returns (uint256 amount) - anyone with a balance in bountyOwed. to is explicit because a credit exists precisely when the keeper could not receive ETH. Reverts ZeroAddress on a zero to and PveNothingToRecord on 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 set 0 constants. An address with no curve answers the set 0 triple rather than reverting, which is not what a fresh launch would get once a parameter set exists: a preview of an unlaunched coin must resolve nextParamsId, as LaunchpadLens.quoteLaunch does
  • launchParams(uint16 id) view returns (uint128 virtualEth, uint128 floorSupply, uint256 virtualTokens) - one entry of the parameter table. Id 0 is not an entry and reads as zeros, not as the set 0 constants. Use paramsOf
  • twapRef() view returns (bool ok, uint256 refCum, uint32 elapsed) and twapRefAt(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. ok describes the checkpoint ring alone. It is not a prediction that graduate will succeed; LaunchpadLens.graduationReadiness answers that
  • upgradesRenounced() view returns (bool) - inherited from UUPSRenounceable
  • launchIntentDigest(LaunchIntent intent) view returns (bytes32) - the exact EIP-712 digest a creator signs. Hashes name and symbol as keccak256(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) - reverts FeeAboveCap above either cap. Emits FeesSet
  • setGraduationBounty(uint256 bounty) - reverts BountyAboveCap above MAX_BOUNTY. Emits GraduationBountySet
  • setCollector(address collector_) - zero reverts ZeroAddress. Emits CollectorSet
  • setPveVault(address escrow) - zero disables PvE fills for coins that have not escrowed yet. address(this) reverts ZeroAddress (every fill would be a no-op transfer that still credits pveAccrued, stranding the coins with no sweep). A non-zero codeless address reverts NotAContract. Emits PveVaultSet. Never moves a coin that already has pveVaultOf set
  • setMaxTwapDeviationBps(uint256 bps) - bounded in both directions by MIN_TWAP_DEVIATION_BPS and MAX_TWAP_DEVIATION_CAP_BPS, else DeviationOutOfRange. The only owner lever over the buyback. Emits MaxTwapDeviationBpsSet
  • setRaiseBand(uint128 minRaise, uint128 maxRaise) - a zero floor or a floor above the ceiling reverts RaiseBandOutOfRange. Gates what the next setLaunchParams may append and never touches a coin. Emits RaiseBandSet
  • setLaunchParams(uint128 virtualEth, uint128 floorSupply) returns (uint16 id) - appends a parameter set. Takes no id, so there is no path that edits an existing entry. Reverts RaiseBandUnset before a band exists, FloorSupplyOutOfRange for a floorSupply of zero or too close to half of SUPPLY, and RaiseOutOfBand when the implied raise falls outside raiseBand. virtualTokens is derived from floorSupply by 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. Emits LaunchParamsSet
  • setPveKeeper(address keeper) - any address including zero. No code check, because it authorises a caller rather than being escrowed into. Emits PveKeeperSet
  • setBlockedQuoteAssets(address[] quotes) - replaces the list. Above MAX_BLOCKED_QUOTES reverts TooManyQuotes; a zero entry reverts ZeroAddress. Affects only coins launched after the call. Emits BlockedQuoteAssetsSet
  • setLaunchesPaused(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 the Frozen window, and that window is bounded by SELL_REOPEN_DELAY
  • renounceOwnership() - overridden. Clears launchesPaused and allBuysPaused first, emitting the matching events, so a renounce cannot latch a global pause forever. Per-coin buysPaused is 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 reverts UpgradesAreRenounced once renounced
  • setUpgradeAuthority(address next) - zero reverts ZeroAddress. Gated on the current authority, not on owner, so a change of authority inherits the timelock's delay. There is no owner override: lose the authority and upgrades are gone. Emits UpgradeAuthoritySet
  • renounceUpgradeability() - one-way. Freezes the implementation for good while ownership and every operational setter keep working. Emits UpgradesRenounced

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:

  • TokenCreated fires at materialization, on every launch, whichever path
  • FeeRecipientSet fires only when the recipient differs from the creator. Its absence means the creator is the recipient. Additive, so every existing TokenCreated decoder keeps working
  • Trade fires on every buy (isBuy = true) and every sell. ethAmount is grossUsed on a buy and grossOut on a sell. isBuy is not indexed, so buys cannot be topic-filtered from sells. trader is always msg.sender, including on buyPve and on the launch fill
  • FloorReached fires inside the buy that clamps at FLOOR and freezes the coin
  • Graduated indexes token and keeper but not the pair addresses; pair discovery means decoding the data. motoLp and tokensLpMoto are the amounts actually seeded after price matching, which may be less than the leg's allotment
  • PriceCheckpointed fires on every successful roll, whether from a poke, a buy's self-call or a deploy script. timestamp is the anchor timestamp, which is the pair's own last write when that write was recent
  • BountyOwed fires when the bounty push failed; BountyClaimed when it was later pulled
  • CreatorFeeRoutedToCollector fires 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 state
  • CreatorFeeUnregistered is declared and nothing on this implementation emits it. The branch it belonged to, a registry refusal with the coin's slot still empty, now reverts RegistryRefused. The declaration stays so that logs written before that change still decode against the deployed ABI
  • LaunchParamsSet fires when the owner appends a parameter set. id is what every launch after that block is stamped with. impliedRaise is emitted although it is derivable, because it is the number a reviewer of a timelocked call can check by eye, and virtualTokens because it is derived by the contract rather than supplied
  • RaiseBandSet fires with both halves of the band, since one without the other tells an indexer nothing
  • UpgradeAuthoritySet fires once in initialize with a zero previous, and on every setUpgradeAuthority
  • PveFill fires on every escrow fill. contributor is whoever paid the ETH: the creator on a launch slice, anyone on buyPve
  • PveRecorded / PveRecordFailed fire from the graduation-time credit and from recordPveLate. A failure keeps the accrual

Custom errors

  • ZeroAddress() - constructor zero dependency, moto == weth, setCollector(0), setPveVault(address(this)), a zero entry in setBlockedQuoteAssets, 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 claimed
  • LaunchesArePaused() - any launch while launchesPaused
  • BuysArePaused() - a buy while allBuysPaused or the coin's buysPaused
  • NotTrading() - a buy on a non-Trading curve, or a sell on a curve that is neither Trading nor a Frozen coin past SELL_REOPEN_DELAY
  • NotFrozen() - graduate on a non-Frozen curve
  • BadSymbol() - symbol empty or over 16 bytes. BadName() - name empty or over 48 bytes
  • BadIntentSignature() - ECDSA recovery is not intent.creator
  • NotTheSubmitter() - intent.submitter is set and is not msg.sender
  • ValueNeedsSubmitter() - buyWithIntent called with value on an intent whose submitter is 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 open
  • BadNonce() - intent.nonce != launchNonce[creator]
  • IntentExpired() - block.timestamp > intent.deadline
  • ZeroAmount() - a buy with no value, or a sell of zero
  • Slippage() - tokensOut == 0, the price-equivalent buy guard, or ethOut < minEthOut
  • FeeAboveCap(), BountyAboveCap(), DeviationOutOfRange() - the admin setter bounds
  • NoMotoPool() - pokePriceCheckpoint could not read the MOTO/WETH cumulative
  • CheckpointFresh() - pokePriceCheckpoint inside MIN_TWAP_WINDOW of the last roll. Costs a buy about 2k gas inside its self-call and nothing else
  • NotAContract() - a codeless dependency in the constructor, or a codeless setPveVault
  • NotAuthorized() - recordPveLate from neither owner nor pveKeeper
  • VenueHalfConfigured() - a venue factory set without its init-code hash, or the reverse
  • TooManyQuotes() - blockedQuoteAssets above MAX_BLOCKED_QUOTES, in the constructor or the setter
  • EthTransferFailed() - a buy refund, a sell payout or a bounty claim transfer failed
  • PveUnavailable() - a PvE fill while pveVault is zero, from either entry point
  • PveExceedsValue() - launch with pveEth > msg.value
  • PveNothingToRecord() - recordPveLate with zero accrual, and also claimBounty with zero owed. The name does not match the second condition; decode by selector
  • NotGraduated() - recordPveLate before graduation
  • CurveInsolvent() - a sell whose curve-implied gross output exceeds the real ETH on hand. A named invariant break rather than a Panic(0x01), so monitoring can tell this invariant broke
  • CreatorAlreadyClaimed() - 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 closed
  • RegistryUnreadable() - the registry could neither register nor be read back, so the launch's creator-fee claim cannot be verified either way
  • RegistryRefused() - 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 on CreatorFeeRegistry. Fixed by registry.setRegistrar(curve, true) from the registry owner, then re-sending the launch
  • RaiseBandOutOfRange() - setRaiseBand with a zero floor or a floor above the ceiling
  • RaiseBandUnset() - setLaunchParams before any setRaiseBand
  • FloorSupplyOutOfRange() - setLaunchParams with a floorSupply of zero, at or above half of SUPPLY, or so close to it that the derived virtual token reserve no longer fits. Raised inside MotoFloorMath
  • RaiseOutOfBand() - setLaunchParams whose implied raise falls outside raiseBand. Raised inside MotoFloorMath
  • NotUpgradeAuthority() - an upgrade, setUpgradeAuthority or renounceUpgradeability from any account other than upgradeAuthority
  • UpgradesAreRenounced() - an upgrade after renounceUpgradeability. Inherited from UUPSRenounceable
  • MotoRefMissing() - no checkpoint has ever been taken. Anyone may fix it with pokePriceCheckpoint
  • MotoRefTooFresh(uint64 readyAt) - a checkpoint exists but no slot has served MIN_TWAP_WINDOW yet. Carries the maturity timestamp of the soonest slot: sleep until readyAt rather than retrying blind
  • MotoRefStale() - both slots are past MAX_TWAP_WINDOW. A permissionless poke re-anchors, and one window later this clears
  • MotoPoolUnreadable() - 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 than uint112, 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 selector
  • MotoOutBelowFloor(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 retries
  • MotoLegTooSmall() - the TOKEN/MOTO leg would be dust (useMoto * useTokens <= 1e6, or either side zero). Raised inside GraduationSeeder.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

FunctionWhoGuard
launch, buy, sell, buyPveanyonenonReentrant, pause flags on launches and buys
buyWithIntentanyone for a zero-value open intent; intent.submitter when set; a funded call must name a submitternonReentrant, nonce, deadline, signature
graduateanyonenonReentrant. Not pausable
pokePriceCheckpointanyonerate limit only. Not nonReentrant
claimBountyanyone with a bountyOwed balancenonReentrant
recordPveLateowner or pveKeepernonReentrant
set* except setUpgradeAuthority, renounceOwnership, transferOwnershipowneronlyOwner
upgradeToAndCall, setUpgradeAuthority, renounceUpgradeabilitythe upgrade authority (a timelock), never the ownerNotUpgradeAuthority
acceptOwnershippending ownerOwnable2Step

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. So realEth is 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 makes CurveInsolvent unreachable in ordinary operation
  • A Frozen curve has tokenReserve == FLOOR exactly. A sell after SELL_REOPEN_DELAY takes it back to Trading, and a re-buy re-freezes it at exactly FLOOR through the same clamp, so graduate never 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
  • pveAccrued is cleared only by a successful recordPve, 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 a swapAllowed grant; 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, because try/catch does not cover the ABI decode. The PvE credit is a state-changing call, so it is wrapped in try/catch instead, 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_) - reverts ZeroAddress on a zero curve. Sets the curve immutable, which every clone reads through delegatecall semantics

Constants and immutables

  • LAUNCH_SUPPLY = 1_000_000_000 ether
  • curve: address - the curve singleton, shared by every clone

State variables (public getters)

  • name: string, symbol: string, totalSupply: uint256
  • initialized: bool - one-shot init flag. true on the implementation from birth
  • bonded: bool - set true exactly once, by the curve, at graduation
  • balanceOf(address) -> uint256, allowance(address owner, address spender) -> uint256
  • blockedWhileUnbonded(address) -> bool - the pre-bond transfer blocklist: the precomputed pair addresses

External and public functions

  • decimals() pure returns (uint8) - returns 18
  • initialize(string name_, string symbol_, address[] blocked) - curve only (OnlyCurve), one-shot (AlreadyInitialized). Sets name and symbol, writes every entry of blocked into the blocklist, mints LAUNCH_SUPPLY to the curve, emits Transfer(address(0), curve, LAUNCH_SUPPLY)
  • setBonded() - curve only (OnlyCurve). Sets bonded = true. Permanently lifts both unbonded behaviours. No way back, and no event of its own; the curve's Graduated is the signal
  • approve(address spender, uint256 value) returns (bool) - standard. Emits Approval
  • transfer(address to, uint256 value) returns (bool) - standard, through the blocklist check
  • transferFrom(address from, address to, uint256 value) returns (bool) - standard, with one narrow exemption: the allowance check is skipped only when msg.sender == curve && !bonded && to == curve. That is the gasless sell pull, and nothing else. An allowance of type(uint256).max is not decremented. Reverts InsufficientAllowance otherwise

Events

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

Custom errors

  • OnlyCurve() - initialize or setBonded from anyone but the curve
  • AlreadyInitialized() - a second initialize, or any initialize on the implementation
  • BlockedUntilBonded() - a transfer to a blocklisted address while unbonded
  • InsufficientBalance(), InsufficientAllowance()
  • ZeroAddress() - a zero curve in the constructor, or a transfer to address(0)

Access control: initialize and setBonded are curve-only. Everything else is the ERC-20 surface, callable by anyone.

Invariants

  • totalSupply is LAUNCH_SUPPLY from initialize onward and never changes. There is no mint and no burn function; the graduation "burn" is a transfer to 0xdead
  • The blocklist is written once, at initialize, and never edited. A coin keeps the list derived at its own launch even if the curve's blockedQuoteAssets changes 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.

RegimeCurve error it predictsWhat to do
Readynonesend graduate
TooFreshMotoRefTooFreshwait until readyAt. A poke would make it worse
StaleMotoRefStalepoke, then wait one window. readyAt assumes the poke is sent now
MissingMotoRefMissingpoke, then wait one window
OutOfBandMotoOutBelowFloorwait, 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
UpstreamMotoPoolUnreadablethe only regime that means something is broken
NotFrozenNotFrozennot 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) - what buy with msg.value == ethIn would deliver, the ETH it would actually charge, and the refund if the buy crosses the floor. Returns zeros if the coin is not Trading or ethIn == 0
  • quoteLaunch(uint256 ethIn, uint256 pveEth) returns (uint256 devTokens, uint256 pveTokens, uint256 grossUsed, uint256 refund) - what a split launch actually delivers. Exact and token-free, because a launch always fills a fresh curve. A create form must use this, not quoteBuy: quoteBuy answers the whole-curve-fill question and overstates the creator's take by the PvE ratio. Returns zeros if ethIn == 0 or pveEth > ethIn. Same clamp and rounding as the split in launch: the donation is measured against what is actually spent and floors toward the creator
  • quoteSell(address token, uint256 tokensIn) returns (uint256 ethOut) - net of fees. Returns 0 if not Trading or tokensIn == 0
  • currentPrice(address token) returns (uint256) - spot in ETH per whole coin, 1e18 fixed point. The same number Trade.priceX18 carries
    • It does not check the coin's status. Graduation zeroes realEth and tokenReserve, so for a Graduated coin this returns virtualEth * 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 equals virtualEth / 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 set 0 ratio, a non-zero price for something that is not a coin. quoteBuy and quoteSell return zeros outside Trading, and bondingProgress pins at 1e18, but currentPrice keeps answering. Read curves(token).status first, and take a graduated coin's live price from its pools
  • bondingProgress(address token) returns (uint256) - 0 to 1e18. None is 0; anything past Trading is 1e18
  • predictToken(address creator, uint256 salt) returns (address) - the CREATE2 address launch will 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, and Lens.t.sol asserts it against a real launch
  • graduationReadiness(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 send graduate, and when to try again if not. Never reverts: every cross-domain read is guarded and every failure classified. readyAt is the earliest timestamp the coin could become Ready: now for Ready, the soonest slot's maturity for TooFresh, one window from now for Stale and Missing, and 0 for OutOfBand, Upstream and NotFrozen, where no timestamp can be promised. minMotoOut is the floor the swap must clear; quotedOut is what the swap would return at the pool's current reserves, and quotedOut < minMotoOut is exactly OutOfBand. deviationBps is how far quotedOut sits from the honest expectation the floor was built from, unsigned. All three are zero unless the regime is Ready or OutOfBand. bountyNow is what graduate would pay after the pot / 50 clamp, and sellsOpenAt is frozenAt + SELL_REOPEN_DELAY; both are filled for every Frozen coin 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 tail
  • motoPoolDeviationBps() 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 and usable still reports whether a reference exists inside the window bounds, but a true here no longer implies the buyback will clear its floor, and the deviation itself no longer gates anything. Do not build on it. Use graduationReadiness

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 curve pointer and can move no value
  • Every quote reads protocolFeeBps and creatorFeeBps from 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
  • graduationReadiness classifies rather than inherits a failure: short returndata from the factory, the pair, the FeeRouter or the curve reads as Upstream, 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) - initialSendGas must sit inside [MIN_SEND_GAS, MAX_SEND_GAS], else BadSendGasLimit. Emits LowWatermarkSet(0, lowWatermark) and SendGasLimitSet(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 for perDayCapWei. Tumbling, not rolling, and not a UTC calendar day. A payee's window opens on its first refill and shuts exactly WINDOW seconds later. That bounds spend at perDayCapWei per window but at twice perDayCapWei across 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: set perDayCapWei to half the 24-hour number you mean, which is what the deploy script does
  • MIN_SEND_GAS = 2_300 (uint32) - floor on sendGasLimit, the classic transfer stipend
  • MAX_SEND_GAS = 100_000 (uint32) - ceiling on sendGasLimit, 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 it
  • lowWatermarkWei: uint256 - tank balance below which TankLow is emitted after every outflow. An alarm backstop, not a trigger. Zero disables the event
  • sendGasLimit: 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 emits Funded. Nobody needs a key to add runway
  • refill(address payee) nonReentrant whenNotPaused returns (uint256 amount) - anyone. Checks in order: UnknownPayee if not registered, PayeeDisabled if disabled, AboveFloor if payee.balance >= floorWei. Opens a fresh window if windowStart == 0 or the current one has shut. DailyCapReached if nothing remains in the window (saturating, so an owner lowering the cap below what is already spent closes the window rather than panicking). Then amount = targetWei - balance, capped by perCallCapWei, by the window remainder, and by the tank's own balance; TankEmpty if that leaves zero. Writes windowStart and spentInWindow before the send, sends with gas: sendGasLimit, reverts RefillFailed if the send failed. Emits Refilled, then TankLow if 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 none
  • quoteRefill(address payee) view returns (uint256 amount) - anyone. The read-only twin of refill, for the cron and for /health. Returns zero instead of reverting in every case refill would revert, including while paused, so a caller never has to decode errors or reimplement the arithmetic
  • setPayee(address payee, uint128 floorWei, uint128 targetWei, uint128 perCallCapWei, uint128 perDayCapWei, bool enabled) - owner only. Registers or reconfigures a payee. Reverts ZeroAddress on a zero payee and InvalidConfig if floorWei == 0, targetWei <= floorWei, perCallCapWei == 0 or perDayCapWei < perCallCapWei. Sets registered = true. Never resets windowStart or spentInWindow: if it did, an owner (or whoever took the owner key) could hand out unlimited daily allowances by rewriting the row between refills. Emits PayeeSet
  • setPayeeEnabled(address payee, bool enabled) - owner only. Turns a registered payee off or on without losing its sizing. UnknownPayee if not registered. Emits PayeeSet with 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 tank
  • setLowWatermark(uint256 newWatermarkWei) - owner only. Emits LowWatermarkSet(previous, new). Zero disables TankLow
  • setSendGasLimit(uint32 newLimit) - owner only. Must sit inside [MIN_SEND_GAS, MAX_SEND_GAS], else BadSendGasLimit. Emits SendGasLimitSet(previous, new)
  • pause() / unpause() - owner only. pause stops every refill in one transaction. withdraw stays open while paused on purpose, because pausing is what you do on the way to emptying the tank. Inherited paused() view returns (bool)
  • withdraw(address to, uint256 amount) - owner only, paused or not. ZeroAddress on a zero to, ZeroAmount on 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. Reverts WithdrawFailed if the send failed. Emits Withdrawn, then TankLow if applicable. Automation is never a one-way door
  • renounceOwnership() pure - disabled. Always reverts RenounceDisabled. Ownable2Step makes 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 and refill still pays out. Hand it to a new owner with transferOwnership plus acceptOwnership
  • 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() - setPayee or withdraw with a zero address
  • ZeroAmount() - withdraw(_, 0)
  • InvalidConfig() - a setPayee row that breaks 0 < floorWei < targetWei, perCallCapWei > 0 or perDayCapWei >= perCallCapWei
  • UnknownPayee(address payee) - refill or setPayeeEnabled on an address never registered
  • PayeeDisabled(address payee) - refill on a registered but disabled payee
  • AboveFloor(address payee, uint256 balance, uint256 floorWei) - refill when the payee does not need one yet. The normal steady state
  • DailyCapReached(address payee, uint256 perDayCapWei) - the payee has taken its whole per-window allowance
  • TankEmpty() - the computed refill amount is zero because the tank holds nothing
  • RefillFailed(address payee, uint256 amount) - the refill send reverted. Most likely a payee with code that cannot receive inside sendGasLimit
  • WithdrawFailed(address to, uint256 amount) - the withdraw send reverted
  • InsufficientTankBalance(uint256 requested, uint256 available) - withdraw above the balance
  • BadSendGasLimit(uint32 sendGasLimit) - constructor or setSendGasLimit outside the bounds
  • RenounceDisabled() - any renounceOwnership
  • Inherited from Pausable: EnforcedPause() on refill while paused, ExpectedPause() on unpause while not paused

Access control

FunctionWho
receive, refill, quoteRefillanyone
setPayee, setPayeeEnabled, setLowWatermark, setSendGasLimit, pause, unpause, withdraw, transferOwnershipowner
acceptOwnershippending owner
renounceOwnershipnobody; 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 than perCallCapWei, and never sends more than remains in the window. spentInWindow is not bounded by perDayCapWei from above: setPayee rewrites 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, and refill reverts DailyCapReached until the window rolls
  • Window accounting is written before the send, so a reentrant caller sees the spend already booked; nonReentrant and 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 perDayCapWei to 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 registered

State variables (public getters)

  • tokens(address token) -> (address creator, bool disabled)
  • isRegistrar(address) -> bool - who may call register

External and public functions

  • register(address token, address creator) - registrar only (NotRegistrar). Sets the initial creator. Reverts ZeroAddress on a zero token or creator and AlreadyRegistered if the slot is taken, whoever holds it. Emits TokenRegistered
  • setCreator(address token, address creator) - the current creator only (NotCreator), gated on creatorOf, not activeCreator. Redirects the token's fee stream without going through an admin. Reverts ZeroAddress on a zero creator and NotRegistered on 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 both CreatorSet and CreatorSetBySelf
  • adminSetCreator(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. Reverts ZeroAddress, NotRegistered. Emits CreatorSet only
  • setDisabled(address token, bool disabled) - owner only. Stops future accrual: activeCreator returns zero so the FeeRouter and the curve skim nothing further. Does not touch money already earned, because the vault gates claims on creatorOf. Reverts NotRegistered. Emits TokenDisabled
  • setRegistrar(address registrar, bool allowed) - owner only. Reverts ZeroAddress. Emits RegistrarSet
  • activeCreator(address token) view returns (address) - the swap hot-path read. The creator if registered and not disabled, else zero. One SLOAD
  • creatorOf(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, adminSetCreator or setRegistrar received zero
  • NotRegistrar() - register from an address not in isRegistrar
  • AlreadyRegistered() - register on a token that already has a creator
  • NotRegistered() - setCreator, adminSetCreator or setDisabled on an unregistered token
  • NotCreator() - setCreator from anyone but the current creator

Access control

FunctionWho
registera registrar (the moto.fun curve)
setCreatorthe token's current creator
adminSetCreator, setDisabled, setRegistrar, transferOwnership, renounceOwnershipowner
acceptOwnershippending owner
activeCreator, creatorOf, tokens, isRegistraranyone (views)

Invariants

  • A token's creator, once set, is never zero again. There is no unregister
  • activeCreator(t) != 0 implies creatorOf(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 register call rather than trusting a bare try/catch; see CreatorAlreadyClaimed and RegistryUnreadable on 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_) - reverts ZeroAddress on a zero registry or WETH. Both become immutables

State variables and immutables (public getters)

  • registry: ICreatorFeeRegistry (immutable) - where creatorOf is read
  • WETH: address (immutable) - the one asset it will unwrap
  • accrued(address token, address quoteAsset) -> uint256 - claimable by the token's current creator
  • isDepositor(address) -> bool - who may call notifyDeposit. The FeeRouter and the curve

External and public functions

  • setDepositor(address depositor, bool allowed) - owner only. Reverts ZeroAddress. Emits DepositorSet. Revoking the live depositor is a trading kill switch, not a fee switch: the FeeRouter calls notifyDeposit unconditionally 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 is setCreatorFeeConfig(registry, vault, 0) first on the FeeRouter, then setDepositor(feeRouter, false). Wrapping the call in try/catch would be worse, because the quote asset is transferred in before the entitlement is recorded, so a swallowed revert strands it uncredited
  • notifyDeposit(address token, address quoteAsset, uint256 amount) - depositor only (NotDepositor). Records amount as accrued. Only records; the caller must already have transferred the asset in, which is what makes the solvency invariant hold by construction. Emits Accrued
  • claim(address token, address[] quoteAssets, bool unwrapWeth) nonReentrant - the token's current creator only, read as registry.creatorOf(token) (NotCreator). Claims across the listed assets to the caller. Zero balances are skipped rather than reverting. WETH is unwrapped to ETH when unwrapWeth is set, else transferred as WETH. Effects before interaction: accrued is zeroed before each transfer. Reverts EthTransferFailed if the ETH send failed. Emits Claimed per non-zero asset
  • claimMany(address[] tokens, address[] quoteAssets, bool unwrapWeth) nonReentrant - the current creator of every listed token. The same body as claim, once per token, with one quote-asset list for all. Reverts NothingToClaim on an empty token list. A third party's token anywhere in the list reverts the whole batch with NotCreator rather 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 balance
  • receive() payable - accepts ETH only from WETH during an unwrapping claim. Anything else reverts EthTransferFailed
  • 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 or setDepositor received zero
  • NotDepositor() - notifyDeposit from an address not in isDepositor
  • NotCreator() - claim or claimMany from anyone but the token's current creator
  • NothingToClaim() - claimMany with an empty token list
  • EthTransferFailed() - the ETH send in an unwrapping claim failed, or receive from anyone but WETH

Access control

FunctionWho
notifyDeposita depositor (the FeeRouter, the curve)
claim, claimManythe token's current creator per creatorOf
setDepositor, transferOwnership, renounceOwnershipowner
acceptOwnershippending owner
receiveWETH 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.withdraw to the claimer inside one claim

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 to MAX_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 voucher
  • IFeeRouter - 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 on factory.swapAllowed and 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. Pulls path[0] from the caller via allowance
  • IPveVault - 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) and notePendingArrival(address token) (one-shot on the vault; a second call reverts AlreadyExpecting, 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 implementation
  • initialize(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 slot
  • totalReceived(address token) -> uint256 - cumulative PvE proceeds recorded per token. Indexer convenience
  • motocatStaking: address - the staking contract (or mirror) whose history sizes a split
  • credits(address token) -> (uint128 amount, uint64 totalStaked, uint64 creditBlock) - the credit, or zeros
  • claimed(address token, address wallet) -> bool - whether a wallet has taken its share
  • pending(address token) -> uint256 - escrow that landed while nobody was staked, staking was unwired, or the seed was still in flight. A per-token amount awaiting recreditPending, not a per-wallet claimable balance
  • extraLaunchers(address) -> bool - additional launchers allowed to record receipts, alongside launcher. The curve is admitted here
  • stakingWiringFrozen: bool - true once the first credit has been frozen, after which setMotocatStaking is 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 holders
  • expectingArrival(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, forward refuses the token and rescuableExcess prices 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 permanently
  • setLauncher(address launcher_) - owner only. Reverts ZeroAddress. Emits LauncherSet. Refuses zero, so the slot cannot be un-set once written
  • setExtraLauncher(address launcher_, bool allowed) - owner only. Reverts ZeroAddress. Emits ExtraLauncherSet. Additive to launcher. Revoking is immediate and strands nothing: an unauthorised launcher's coins still arrive by plain transfer, only the receipt fails, and the curve's recordPveLate replays it once re-authorised
  • setMotocatStaking(address staking) - owner only. Reverts StakingWiringFrozen once any credit exists, ZeroAddress on 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 by try/catch, so a vault pointed at an EOA would make every recordPve revert. Emits MotocatStakingSet
  • notePendingArrival(address token) - launcher or extra launcher only (NotLauncher). Marks the token as expecting escrow. Reverts ZeroAddress on a zero token, AlreadyExpecting(token) if already marked, AlreadyCredited if 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 after recordPve cleared the mark and re-lock a credited token's dust against rescueExcess forever. Launcher-gated because an open marker would be a free permanent denial of forward on any token an attacker named. Emits PendingArrivalNoted
  • clearPendingArrival(address token) - owner only. Retires a marker that guards nothing. Reverts NotExpecting(token) if no marker, AlreadyCredited if a credit or pending hold exists, and TokenIsCredited(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. Emits PendingArrivalClearedByOwner. Exists because a coin that stalls on the curve and never graduates would otherwise leave its marker set forever
  • recordPve(address token, address creator, uint256 amount) - launcher or extra launcher only (NotLauncher). The receipt record, which also credits. Reverts AlreadyCredited if a credit or pending hold exists, ValueTooLarge above uint128, AmountNotReceived if the vault's balance of the token is below amount. Adds to totalReceived, emits PveReceived. Clears expectingArrival and emits PendingArrivalCleared when 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 zero amount returns after the receipt. Otherwise sizes the split: if motocatStaking is zero, or the staking side's seed is not closed, or totalStakedAt(block.number - 1) is zero, the amount is held in pending and PveHeld fires; else the credit is frozen at (amount, staked, block.number), stakingWiringFrozen is set, and PveCredited fires. The preceding-block rule is what makes staking into a split in the same block worthless
  • recreditPending(address token) - owner only. Turns a held escrow into a claimable credit at today's stake. Reverts NoCredit if nothing is held, StakingNotSet if unwired, SeedingNotClosed while the staking side's seed is in flight, PoolStillEmpty if 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 wrong
  • claimable(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 survives
  • claim(address token) nonReentrant returns (uint256 amount) - anyone with a share. Reverts NothingToClaim on zero. Pays min(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. Marks claimed, transfers, emits PveClaimed
  • claimMany(address[] tokens) nonReentrant returns (uint256 total) - anyone. Claims across the caller's own list; entries with nothing to claim are skipped, but the call reverts NothingToClaim if the whole batch yields nothing. Bounded by the caller's array, never by global state
  • forward(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. Reverts ZeroAddress on a zero to and TokenIsCredited(token) for any token with a credit, a pending hold, or an expectingArrival marker. PvE credits never expire; there is no sweep and no treasury reclaim of credited amounts. Emits Forwarded
  • rescuableExcess(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 is forward's) or carries an expectingArrival marker (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. Moves rescuableExcess. Reverts ZeroAddress, NoExcessToRescue(token). Emits ExcessRescued. Computed, not trusted, and it never reaches the rounding residue inside credit.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 reverts UpgradesAreRenounced once 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.creator is whatever the launcher passed. The curve passes creatorOf[token], the launch signer, never the registry's fee recipient
  • PveHeld is the signal to put on the distribution-day runbook: the escrow sits until the owner calls recreditPending
  • PendingArrivalCleared and PendingArrivalClearedByOwner are separate on purpose, so an indexer can tell a normal graduation from an owner override

Custom errors

  • ZeroAddress() - a setter, forward, rescueExcess or notePendingArrival received zero
  • NotLauncher() - recordPve or notePendingArrival from an address that is neither launcher nor an extra launcher
  • StakingNotSet() - recreditPending with no staking contract wired
  • AlreadyCredited() - recordPve, notePendingArrival or clearPendingArrival on a token that already has a credit or a pending hold
  • AmountNotReceived() - the vault holds less than the recorded amount
  • NoCredit() - recreditPending on a token with nothing held
  • AlreadyClaimed() - declared; no path raises it at this commit. claimable returns zero for a claimed wallet and the claim then reverts NothingToClaim
  • NothingToClaim() - claim or claimMany yielded zero
  • PoolStillEmpty() - recreditPending while nobody is staked at the preceding block
  • SeedingNotClosed() - recreditPending while the staking side's seed is still in flight
  • NotAContract(address target) - setMotocatStaking pointed at an address with no code
  • NoExcessToRescue(address token) - rescueExcess found nothing above the obligation
  • StakingWiringFrozen() - setMotocatStaking after a credit exists
  • TokenIsCredited(address token) - forward on a token owed to stakers, or clearPendingArrival while the vault holds a balance of the token
  • ValueTooLarge() - an amount above uint128 or a staked count above uint64
  • AlreadyExpecting(address token) - a second notePendingArrival for the same token
  • NotExpecting(address token) - clearPendingArrival on a token with no marker
  • Inherited: UpgradesAreRenounced() on an upgrade after renounceUpgradeability

Access control

FunctionWho
recordPve, notePendingArrivallauncher or an extra launcher (the curve)
claim, claimMany, claimable, rescuableExcessanyone
setLauncher, setExtraLauncher, setMotocatStaking, clearPendingArrival, recreditPending, forward, rescueExcess, renounceUpgradeability, upgradeToAndCall, transferOwnership, renounceOwnershipowner
acceptOwnershippending 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, motocatStaking is 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, and rescueExcess is bounded by the computed obligation
  • claimable truncates 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 against stakedBalanceOfAt(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. true once the implementation can never be upgraded again
  • _requireUpgradable() internal - called from _authorizeUpgrade; reverts UpgradesAreRenounced() once renounced
  • _renounceUpgradeability() internal - one-way; emits UpgradesRenounced(). The inheriting contract exposes the access-gated external entrypoint, which on PveVault is renounceUpgradeability()

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:

  1. 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
  2. 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. isOutbox is also what an adapter's paid paths check before trusting the pointer back to the escrow
  • outboxCount() view returns (uint256) - anyone. Zero is a valid, expected state at Ethereum launch
  • stakedBalanceOf(address wallet) view returns (uint256) - anyone. The one read an adapter takes from the escrow
  • addOutbox(address outbox) - owner only. Reverts ZeroOutbox, OutboxAlreadyRegistered. Emits OutboxAdded
  • removeOutbox(address outbox) - owner only. Swap-and-pop, so ordering is not stable. Reverts OutboxNotRegistered. Emits OutboxRemoved
  • rebroadcast(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 truth
  • rebroadcastMany(address[] wallets) nonReentrant - anyone. The same, batched. Reverts EmptyArray. No filtering and no deduplication: a wallet with zero staked cats still broadcasts 0, 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 is abi.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_) - reverts ZeroAddress on a zero inbox or mirror. refundTo defaults to the owner

Constants

  • MIN_GAS_LIMIT = 250_000 - hard floor under gasLimit. Too low is the one misconfiguration that fails silently in both directions: the ticket is created, TicketCreated fires, L1 reports success, and the auto-redeem runs out of gas on the far side, so the mirror goes stale
  • MIN_MAX_FEE_PER_GAS = 0.01 gwei - hard floor under maxFeePerGas. 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 L1
  • mirror: address (immutable) - the L2 MirrorRegistry. Immutable because a re-pointable target would let one owner transaction redirect every future broadcast
  • staking: address - the only address permitted to call send
  • gasLimit: uint256 - L2 gas for the mirror write. Ships at 500_000. Unused L2 gas is refunded, so headroom is close to free
  • maxFeePerGas: uint256 - L2 gas price ceiling. Ships at 0.1 gwei
  • maxSubmissionCost: uint256 - floor on the submission fee. Ships at 0.001 ether. The live figure is read from the inbox and this only floors it
  • refundTo: address - where L2 refunds unspent fees on the float-funded path
  • lastMirroredPlusOne(address wallet) view returns (uint256) - the last count this adapter paid to mirror for the wallet, stored +1 so zero means never. The pay-once guard
  • sendCounter(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. Reverts ZeroAddress. 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. Emits StakingSet
  • setParams(uint256 gasLimit_, uint256 maxFeePerGas_, uint256 maxSubmissionCost_) - owner only. Reverts GasLimitTooLow(given, floor) under MIN_GAS_LIMIT and MaxFeePerGasTooLow(given, floor) under MIN_MAX_FEE_PER_GAS. maxSubmissionCost is unbounded because it is a floor the contract raises from live pricing. Emits ParamsSet
  • setRefundTo(address refundTo_) - owner only. Reverts ZeroAddress. Emits RefundToSet
  • requiredSubmissionCost() view returns (uint256) - anyone. The submission fee this ticket needs right now, read from inbox.calculateRetryableSubmissionFee for the message size, plus a 50% margin for base-fee movement between the read and the submission, floored at maxSubmissionCost. A failed read falls back to the floor, because this runs inside send, which the escrow catches
  • ticketCost() view returns (uint256) - anyone. requiredSubmissionCost() + gasLimit * maxFeePerGas. Read this to size a top-up or a resend
  • send(bytes payload) payable - staking only (NotStaking). Emits Funded if value arrived. Decodes (wallet, stakedCount). If the adapter already mirrored this exact count, emits AlreadyMirrored and returns. Else reverts Underfunded(required, held) if the float cannot cover ticketCost, stamps the ordering key, submits the retryable addressed to mirror.setStake(wallet, stakedCount, orderingKey) with refundTo as both refund and beneficiary, and emits TicketCreated
  • resend(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, and msg.value >= ticketCost() else ResendUnderfunded(required, paid). Reads the count live from the escrow, never from the caller. The L2 refund and the ticket's beneficiary are the caller, not refundTo. Does not touch lastMirroredPlusOne, so a stranger cannot consume the one float-funded send a wallet was owed. Overpayment on L1 stays as float and is logged as Funded. Emits Resent
  • resendMany(address[] wallets) payable - anyone, caller-funded. The batched form, for the case where the L2 base fee rose above maxFeePerGas and every ticket in a window expired. Reverts EmptyArray. Cost is read once for the batch and the whole batch is refused if underfunded rather than paying for a prefix. Emits Resent per wallet
  • receive() payable - anyone. Tops up the float. Emits Funded
  • sweep(address to, uint256 amount) - owner only. Recovers float. Reverts ZeroAddress, SweepFailed. Emits Swept
  • 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, resend or resendMany received zero
  • NotStaking() - send from anyone but staking
  • Underfunded(uint256 required, uint256 held) - the float cannot cover a float-funded ticket
  • SweepFailed() - the sweep transfer reverted
  • GasLimitTooLow(uint256 given, uint256 floor), MaxFeePerGasTooLow(uint256 given, uint256 floor) - setParams bounds
  • EmptyArray() - resendMany with no wallets
  • StakingNotSet() - a paid path before setStaking
  • NotRegisteredWithStaking(address staking) - a paid path while the escrow has not registered this adapter
  • ResendUnderfunded(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 check msg.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_) - reverts ZeroAddress. minGasLimit starts at 300_000

Constants

  • MIN_GAS_FLOOR = 250_000 (uint32) - floor under minGasLimit, 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 root
  • receiver: address - the L2 OpMirrorReceiver, not the registry itself
  • staking: address - the escrow permitted to broadcast
  • minGasLimit: uint32 - execution budget requested on L2
  • lastMirroredPlusOne(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. Reverts ZeroAddress. Emits StakingSet / ReceiverSet
  • setMinGasLimit(uint32 minGasLimit_) - owner only. Reverts MinGasLimitTooLow(given, floor) under MIN_GAS_FLOOR. Emits MinGasLimitSet
  • send(bytes payload) payable - staking only (NotStaking); ReceiverNotSet before setReceiver. Pay-once guard, then stamps the key and calls messenger.sendMessage(receiver, setStake(wallet, stakedCount, orderingKey), minGasLimit) forwarding msg.value. Emits MessageSent
  • resend(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. Emits Resent
  • 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_) - reverts ZeroAddress. refundTo defaults 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 it
  • options: 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 update
  • staking: address, refundTo: address
  • lastMirroredPlusOne(address wallet) view returns (uint256), sendCounter(address wallet) view returns (uint256)

External and public functions

  • setStaking(address staking_), setRefundTo(address refundTo_) - owner only. Reverts ZeroAddress
  • setPeer(uint32 dstEid_, bytes32 peer_) - owner only. Both together, because they are one fact. Reverts ZeroAddress if either is zero. Emits PeerSet
  • setOptions(bytes options_) - owner only. Reverts OptionsNotSet on empty. Emits OptionsSet
  • quoteFee(address wallet, uint256 stakedCount) view returns (uint256) - anyone. The native fee the next message for this wallet would cost, quoted from the endpoint
  • send(bytes payload) payable - staking only (NotStaking); PeerNotSet, OptionsNotSet. Emits Funded if value arrived. Pay-once guard, then quotes against the real key and reverts Underfunded(required, held) if the float cannot cover it. Sends with refundTo as the refund address. Emits MessageSent with the LayerZero guid
  • resend(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 as Funded. Emits Resent
  • receive() payable - anyone. Emits Funded
  • sweep(address to) - owner only. Sweeps the whole balance. Reverts ZeroAddress, SweepFailed. Emits Swept
  • 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_) - reverts ZeroAddress
  • messenger: 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 this
  • setL1Outbox(address l1Outbox_) - owner only. Reverts ZeroAddress. Emits L1OutboxSet
  • setStake(address wallet, uint256 stakedCount, uint256 orderingKey) - the L2 messenger only (NotMessenger), and the L1 sender it reports must equal l1Outbox (OutboxNotSet, NotL1Outbox(reported)). Both halves are checked: xDomainMessageSender is 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 to registry.setStake. Emits StakeForwarded. Same signature as the registry's own, so the L1 adapter encodes one call for either stack
  • aliasPreimage() view returns (address) - anyone. address(this) - 0x1111...1111, wrapping. The value the registry's l1Outbox must 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 silently
  • registryAcceptsThisReceiver() 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_) - reverts ZeroAddress
  • endpoint: address (immutable), registry: IMirrorRegistryWrite (immutable), srcEid: uint32, peer: bytes32
  • setPeer(uint32 srcEid_, bytes32 peer_) - owner only. Both together. Reverts ZeroAddress if either is zero. Emits PeerSet
  • lzReceive(Origin origin, bytes32 guid, bytes message, address executor, bytes extraData) payable - the endpoint only (NotEndpoint), then PeerNotSet, WrongSourceChain(got, expected) if origin.srcEid != srcEid, WrongPeer(got, expected) if origin.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. executor and extraData are ignored deliberately, because they describe who paid to deliver, not who sent. Decodes (wallet, stakedCount, orderingKey), forwards to registry.setStake, emits StakeForwarded
  • sweep(address to) - owner only. Recovers native token that arrived with a delivery, because lzReceive is 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. Reverts ZeroAddress, SweepFailed. Emits Swept
  • aliasPreimage() view returns (address), registryAcceptsThisReceiver() view returns (bool) - as on OpMirrorReceiver, 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.Checkpoint

State 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 exactly seedingClosed() returning bool, because that is what PveVault probes, 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 L2
  • stakeOf(address wallet) -> uint256 - the latest mirrored stake per wallet
  • totalStaked: uint256 - the sum of every wallet's mirrored stake, maintained as a running delta
  • lastOrderingKey(address wallet) -> uint256 - (L1 block << 64) | counter of the newest message applied for the wallet

External and public functions

  • closeSeeding() - owner only. One-way; a second call reverts SeedingAlreadyClosed so a runbook mistake is visible. Releases PveVault's mid-seed hold on this chain; anything the vault held while this was false is released afterwards by recreditPending, one call per token. Emits SeedingClosed. Call it only after the last wallet is in: closing late loses nothing, closing early cannot be undone
  • setL1Outbox(address l1Outbox_) - owner only. Reverts ZeroAddress. Emits L1OutboxSet
  • expectedAliasedSender() view returns (address) - anyone. l1Outbox + 0x1111...1111, unchecked because Arbitrum's aliasing wraps modulo 2^160
  • setStake(address wallet, uint256 stakedCount, uint256 orderingKey) - the aliased L1 outbox only (NotAliasedL1Outbox, also when l1Outbox is unset). A message with orderingKey <= lastOrderingKey[wallet] is ignored, not reverted: StaleMessageIgnored fires 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, emits StakeMirrored
  • forceSync(address wallet, uint256 stakedCount) - owner only. Owner-attested write for when the L1 path itself is unavailable. Try rebroadcast or resend on 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. Emits StakeForceSynced, deliberately distinct from StakeMirrored
  • forceSync(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. Reverts ForceSyncWouldNotStick(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 revert
  • resetOrderingKey(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 state forceSync cannot 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. Emits OrderingKeyReset. Do not forceSync in the same block afterwards; it saturates the counter bits and would re-block every adapter message from that block
  • stakeOfAt(address wallet, uint256 blockNumber) view returns (uint256) - anyone. The wallet's mirrored count as of the end of blockNumber. Reverts FutureLookup for the current block or later
  • stakedBalanceOfAt(address wallet, uint256 blockNumber) view returns (uint256) - anyone. Alias of stakeOfAt under the name PveVault calls. This is the point of the contract: matching the vault's read shape means the audited vault deploys unchanged
  • totalStakedAt(uint256 blockNumber) view returns (uint256) - anyone. Total mirrored stake as of the end of blockNumber, 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 counted
  • checkpointCount(address wallet) view returns (uint256), totalCheckpointCount() view returns (uint256) - anyone
  • lastSourceBlockOf(address wallet) view returns (uint256) - anyone. lastOrderingKey >> 64
  • lastCounterOf(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() - setL1Outbox received zero, or a write named the zero wallet
  • SeedingAlreadyClosed() - a second closeSeeding
  • NotAliasedL1Outbox() - setStake from anyone but the aliased outbox, or before setL1Outbox
  • FutureLookup() - a historical read at the current block or later
  • CheckpointOverflow() - a block number above uint32 or a value above uint224
  • StaleMessage(address wallet, uint256 orderingKey, uint256 applied) - declared; setStake emits StaleMessageIgnored and returns rather than raising it
  • OrderingKeyNotAhead(address wallet, uint256 currentKey) - resetOrderingKey with a key that is not below the current one
  • ForceSyncWouldNotStick(address wallet, uint256 currentKey, uint256 proposedKey) - the three-argument forceSync named a block that would not outrank the applied key

Access control

FunctionWho
setStakethe aliased L1 outbox (or a receiver via its alias preimage)
closeSeeding, setL1Outbox, both forceSync forms, resetOrderingKey, transferOwnership, renounceOwnershipowner
acceptOwnershippending owner
every *At, *Of, *Count readanyone

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
  • totalStaked moves 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 minTokensOut only; sells take minEthOut. No curve trade has a deadline parameter, by design: there is no LP-side staleness. The only deadline on the user path is intent.deadline
  • The buy check is price-equivalent, tokensOut * gross >= minTokensOut * grossUsed. On a full fill grossUsed == gross and it reduces to tokensOut >= minTokensOut. On a floor-crossing partial fill it compares the price actually paid against the price the caller asked for, so a minTokensOut set for the full amount still passes when the curve takes less ETH and returns fewer coins at the same rate
  • tokensOut == 0 always reverts Slippage()

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, CreatorFeeRoutedToCollector fires 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 via notifyDeposit

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.

  1. The bounty is taken from the pot and clamped to pot / 50. The remainder splits: motoLegWeth = wethAmt / 2, wethLp = wethAmt - motoLegWeth
  2. Both coin legs are sized at the final curve price, p = (vEth + pot) / (floorSupply + vTok), using the coin's own parameters, with clamps guaranteeing tokensLpWeth + tokensLpMoto <= floorSupply. At bounty = 0 the unclamped sum exceeds the floor by a sub-token amount, and an unchecked subtraction once bricked graduation permanently
  3. The MOTO floor is computed (below). An unreadable pool reverts MotoPoolUnreadable; a missing reference reverts one of the MotoRef* errors
  4. A zero minMotoOut, tokensLpWeth, wethLp or tokensLpMoto reverts MotoPoolUnreadable. 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.

  1. 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
  2. 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. motoOut is measured as the received balance delta, never the router's return value, and re-checked against the floor (MotoOutBelowFloor)
  3. setBonded() lifts the blocklist and the curve's allowance exemption permanently
  4. TOKEN/WETH pool. getPair or createPair, 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 reverts MotoPoolUnreadable
  5. 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 reverts MotoLegTooSmall. mint(0xdead)
  6. Leftovers. Surplus MOTO above useMoto and any WETH the ratio match shrank off go to the Collector. The coin-side remainder, floorSupply - tokensLpWeth - useTokens, burns to 0xdead
  7. The PvE credit runs, inside try/catch with an explicit code-length check first, so it can never revert the graduation
  8. The bounty is pushed to msg.sender, falling back to bountyOwed on failure
  9. Graduated fires

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) / BPS

A 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 in pveVaultOf and never changes
  • Entry points: the pveEth slice of launch, and the public buyPve. Nothing else reaches _escrowPve
  • Each fill adds to pveAccrued, transfers the coins to the pinned vault immediately, and on the first fill only calls notePendingArrival fail-closed. The vault's marker is one-shot and stays set through every later fill until recordPve clears it, so per-fill calling would revert the second fill of every multi-fill coin
  • The credit runs exactly once, at graduation, inside try/catch after an explicit code-length check on the pinned vault. Success clears pveAccrued and emits PveRecorded; failure keeps the accrual and emits PveRecordFailed
  • 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
  • recordPveLate is the retry path and is owner or pveKeeper only, 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 MirrorRegistry instead 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 through pairCodeHash(), 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 through getPair, 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.submitter binding, and the rule that a funded buyWithIntent must 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 reverts RegistryUnreadable; a refusal with the slot still empty reverts RegistryRefused. 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 graduate or over sells outside the bounded Frozen window

Deploy and upgrade shape

What is deployed, per chain

ContractDeployed byUpgradeableOwner
LaunchpadCurvemoto-fun Deploy.s.sol, through ProxyDeploy: the proxy is deployed and initialize runs in one transactionYes. UUPS behind an ERC-1967 proxy. Upgrades belong to upgradeAuthority, a timelock deployed by DeployCurveTimelock.s.sol, and can be renounced one-wayConfig.owner, Ownable2Step. The owner cannot upgrade
GraduationSeeder, MotoFloorMath, LaunchpadTokenDeployermoto-fun Deploy.s.sol, linked into the curve implementationNo. Libraries, replaced only by upgrading the curve to an implementation linked against new onesnone
LaunchpadToken (implementation)the curve's initialize, through LaunchpadTokenDeployerNo. Clones are EIP-1167 proxies over itnone
LaunchpadLensmoto-fun Deploy.s.sol, same batch as the curveNonone
GasTankmoto-fun DeployGasTank.s.solNoits owner, Ownable2Step, renounce disabled
CreatorFeeRegistrythe DEX suite (canonical)No. Ownable2Stepgovernance
CreatorFeeVaultthe DEX suite (canonical)No. Ownable2Stepgovernance
PveVaultthe DEX suite (canonical)UUPS, with renounceUpgradeabilitythe suite's owner key
MirrorRegistry, the outboxes, the receiversthe DEX suite (DeployCrosschain.s.sol)No. Ownable2Stepthe 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.

  1. MOTO exists on the chain. Ethereum: the canonical ERC-20. Later networks will use a bridged child MOTO
  2. 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
  3. MOTO/WETH pool seeded to the armed depth floor. The preflight's DEFAULT_MIN_MOTO_POOL_WETH is 100 ether on the WETH side, overridable by MIN_MOTO_POOL_WETH, and the protocol-owned LP share must be at least MIN_POL_SHARE_BPS = 9_000 when POL_HOLDER is 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
  4. PvE vault. The suite deploys the canonical PveVault; moto.fun binds to it. motocatStaking is the chain's staking contract or mirror, or zero until the mirror exists, in which case credits hold. The curve is admitted through setExtraLauncher(curve, true) and is the sole launcher. Never setLauncher: 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 for notePendingArrival(address); a vault without it fails the curve closed on the first PvE fill. Read back owner, launcher, extraLaunchers(curve), motocatStaking
  5. 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
  6. Curve. Deploy.s.sol, dry run first with every preflight: line passing, then broadcast, then the three grants by readback, then VerifyDeployment.s.sol
  7. Env and address books. Backend per-chain service and web address books read the curve, lens, token implementation and PvE vault; /config serves them
  8. 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, PveReceived to 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:

  1. Runs LaunchpadPreflight.assertChainConfig before 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
  2. 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
  3. Deploys LaunchpadCurve with the Config above, then LaunchpadLens against it in the same batch. A deployment that skips the lens ships a UI with no prices
  4. Grants pveKeeper when PVE_KEEPER is set and the deploy key is still the owner, and reads it back. When OWNER is already an account other than the deployer, prints the exact setPveKeeper call the owner must make instead
  5. 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 graduation MotoRefMissing until somebody noticed
  6. 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:

GrantOnMade byIf missed
setRegistrar(curve, true)CreatorFeeRegistrygovernanceevery launch reverts RegistryRefused. No coin launches unregistered
setDepositor(curve, true)CreatorFeeVaultgovernanceevery trade routes its whole fee to the Collector with CreatorFeeRoutedToCollector. Trading continues; the creator share is recoverable by governance afterwards
setExtraLauncher(curve, true)PveVaultthe vault ownerthe 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 LaunchpadCurve and PveVault, both UUPS proxies, and production points at the deployed canonical registry and vault rather than the vendored copies. The curve address in /config is the proxy. Confirm the deployed shape against /config and the chain rather than against this page
  • FeeRouter, MotoSwapFactory, MotoSwapPair, Collector and MotocatStakingV3 are 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. claimBounty reverts PveNothingToRecord() when nothing is owed, and MotoPoolUnreadable() 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() and MirrorRegistry.StaleMessage(...) are declared and never raised at these commits. A claimed wallet reverts NothingToClaim, and a stale mirror message is ignored with an event
  • The Trade event's isBuy is not indexed, Graduated does not index the pair addresses, and FeeRecipientSet fires only on a redirect. Build the indexer around those three facts
  • A funded buyWithIntent requires a named submitter. A backend that publishes open intents and then lets the creator fund one from their own wallet gets ValueNeedsSubmitter, not a launch. Route a creator's own dev buy through launch, 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 use quoteLaunch, not quoteBuy
  • A keeper should drive graduation off graduationReadiness and sleep until readyAt on TooFresh. Poking during TooFresh makes it worse. Upstream is the only regime that means something is broken
  • Once an L2 deployment exists, PveVault.totalStakedAt there will be the mirror's count, not the truth. Before treating an L2 PvE credit as fair, check that the mirror was swept and seedingClosed on the MirrorRegistry is true
  • 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 maxTwapDeviationBps again whenever MaxTwapDeviationBpsSet fires

On this page

Part 1: the moto.fun repoLaunchpadCurve (contracts/src/LaunchpadCurve.sol)LaunchpadToken (contracts/src/LaunchpadToken.sol)LaunchpadLens (contracts/src/LaunchpadLens.sol)GasTank (contracts/src/GasTank.sol)CreatorFeeRegistry (contracts/src/CreatorFeeRegistry.sol, a copy of the canonical Motoswap contract)CreatorFeeVault (contracts/src/CreatorFeeVault.sol, vendored from evm-moto-contracts; identical to src/fees/CreatorFeeVault.sol)Interfaces (contracts/src/interfaces/)Part 2: canonical contracts moto.fun depends onPveVault (src/launcher/PveVault.sol)UUPSRenounceable (src/upgrade/UUPSRenounceable.sol)The cat-stake mirror: how PvE will work off EthereumThe broadcast surface of MotocatStakingV3 (src/nft/MotocatStakingV3.sol)IL1Outbox and IStakedCount (src/nft/IL1Outbox.sol, src/crosschain/IStakedCount.sol)ArbitrumOutbox (src/crosschain/ArbitrumOutbox.sol)OpOutbox (src/crosschain/OpOutbox.sol)LzOutbox (src/crosschain/LzOutbox.sol)OpMirrorReceiver (src/crosschain/OpMirrorReceiver.sol)LzMirrorReceiver (src/crosschain/LzMirrorReceiver.sol)MirrorRegistry (src/crosschain/MirrorRegistry.sol)Cross-cutting mechanicsThe bonding curveSlippageThe fee modelGraduationThe MOTO price reference and floorPvELaunch and anti-snipe constraintsDeploy and upgrade shapeWhat is deployed, per chainThe dependency orderDeploy.s.sol (moto-fun, contracts/script/Deploy.s.sol)The three grantsVerifyDeployment.s.sol (moto-fun, contracts/script/VerifyDeployment.s.sol)DeployGasTank.s.sol (moto-fun, contracts/script/DeployGasTank.s.sol)Retiring a curveNotes for integrators