Motoswap Contracts - Full Inventory
Complete API reference for external integrators - every Solidity contract, function, event, and error.
Every signature on this page was checked against the current contracts source.
Deployment status is per contract. The deployment status table on the addresses page says which
addresses the site pins and which you read from GET /config. A key that
reads the zero address there is unset for the current phase. Read addresses
from /config. Do not integrate against a zero address.
Some deployed proxies run an implementation older than this source. Where a function is in the source but not on the deployed implementation, the entry says "not live yet, arrives with a contract upgrade". Check the verified implementation on a block explorer before you call one of those.
MotoSwapERC20 (packages/contracts/src/dex/core/MotoSwapERC20.sol)
LP token for Motoswap pairs with ERC-2612 permit. Fork-safe DOMAIN_SEPARATOR computed on-demand from chainid + address; no constructor since pairs are minimal proxies.
Constants / Immutables
name = "Motoswap V1"symbol = "MOTO-V1-LP"decimals = 18PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9
State Variables
totalSupply: uint256- total LP supplybalanceOf[address => uint256]- LP balance per addressallowance[address => mapping(address => uint256)]- ERC20 approval allowancesnonces[address => uint256]- EIP-712 permit nonces
External / Public Functions
DOMAIN_SEPARATOR() view returns (bytes32)- EIP-712 domain separator, computed from block.chainid and address(this)approve(address spender, uint256 value) returns (bool)- ERC20 approvetransfer(address to, uint256 value) returns (bool)- ERC20 transfertransferFrom(address from, address to, uint256 value) returns (bool)- ERC20 transferFrom; allows unlimited if prior approval is max uint256permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)- EIP-2612 permit; signs (owner, spender, value, nonces[owner]++, deadline); reverts if expired or signature invalid
Events
event Approval(indexed address owner, indexed address spender, uint256 value)event Transfer(indexed address from, indexed address to, uint256 value)
Notes
_NAME_HASH = keccak256("Motoswap V1")must remain in sync withnamefor permit signatures to validate. The EIP-712 domain isname = "Motoswap V1",version = "1", chain id, pair address
Reverts
"MotoSwap: EXPIRED"-permitpast itsdeadline"MotoSwap: INVALID_SIGNATURE"-permitsignature does not recover toowner- Balance and allowance shortfalls revert as an arithmetic panic (
0x11), not a string.transferandtransferFromnever returnfalse - Pairs never run the constructor, so all state is zero-initialized;
DOMAIN_SEPARATOR()reads live from block.chainid and address(this)
MotoSwapFactory (packages/contracts/src/dex/core/MotoSwapFactory.sol)
Deploys Motoswap pairs as ERC-1967 beacon proxies at deterministic CREATE2 addresses. All pairs delegate through a single upgradeable beacon. The factory is itself a UUPS proxy.
Constructor / Init
constructor()- disables initializersinitialize(address _feeToSetter)- deploy the pair logic, create the beacon, seed the fee;_feeToSetteris admin and upgrade authority, and the factory itself owns the beacon; reverts if zero. The contract default seeded here isswapFeeBps = 100.feeTois left unset. The deploy scripts then callsetSwapFee(30)andsetFeeTo(address(0)), which is a deploy-time setting, not a contract constant
Constants / Immutables
MAX_SWAP_FEE_BPS = 200- hard cap on pair swap fee (2.00%)
State Variables
beacon: address- UpgradeableBeacon pointing to pair logicpairCodeHash, INIT_CODE_PAIR_HASH: bytes32- CREATE2 init-code-hash for beacon proxies (same value, kept in sync)feeTo: address- protocol fee recipient (zero = no protocol fee; LPs get 100% of pair fee)feeToSetter: address- admin role (two-step)pendingFeeToSetter: address- proposed next feeToSetterswapFeeBps: uint256- the pair swap fee in bps, one global value every pair reads live on each swap. Contract default 100, deploy scripts set 30, the admin can move it anywhere from 0 toMAX_SWAP_FEE_BPS. Read the value live from the factory rather than assuming either numbergetPair[address => mapping(address => address)]- pair address lookup by (token0, token1)allPairs: address[]- all deployed pair addressesquoteRegistry: address- optional quote-asset registry; when set,createPairrequires one side to be a quote assetswapAllowed[address => bool]- the swap perimeter. Only listed addresses may callMotoSwapPair.swapor the core router's swap entrypoints. The deploy seeds exactly two:MotoSwapRouter02andFeeRouter. Outside integrators swap through theFeeRouter
External / Public Functions
swapConfig(address account) view returns (bool allowed, uint256 feeBps)-swapAllowed[account]andswapFeeBpsin one call; what every pair reads on each swapimplementation() view returns (address)- current pair logic implementationupgradePairImpl(address newPairImpl)- retarget beacon at new pair logic (one upgrade applies to EVERY live pair); onlyFeeToSetterrenouncePairUpgradeability()- freeze pair logic forever by renouncing beacon ownership; one-way; onlyFeeToSetterrenounceUpgradeability()- freeze factory implementation; one-way; onlyFeeToSettersetSwapAllowed(address account, bool allowed)- governance function; onlyFeeToSetter; emits SwapAllowedSet. It maintains the protocol's own perimeter (the core router and theFeeRouter) and doubles as the halt lever. It is not an integrator path: outside integrators route through theFeeRoutersetSwapAllowedBatch(address[] calldata accounts, bool allowed)- governance function; batch form of setSwapAllowed, used to seed the perimeter at deploysetSwapFee(uint256 _swapFeeBps)- set live pair fee (up to MAX_SWAP_FEE_BPS); onlyFeeToSetter; emits SwapFeeSetallPairsLength() view returns (uint256)- number of pairs deployedcreatePair(address tokenA, address tokenB) returns (address pair)- deploy a new pair; requires both tokens deployed, checks quote-registry gate if set, reverts if pair exists; emits PairCreatedsetQuoteRegistry(address _quoteRegistry)- set/unset quote-registry gate; onlyFeeToSettersetFeeTo(address _feeTo)- set protocol fee recipient, or switch the protocol's LP-fee share off with zero; onlyFeeToSetter; emits FeeToSetsetFeeToSetter(address _feeToSetter)- step 1 of two-step: propose new feeToSetter; onlyFeeToSetter; emits FeeToSetterTransferStartedacceptFeeToSetter()- step 2: claim the feeToSetter role if you are the pending setter; emits FeeToSetterTransferred
Events
event FeeToSet(indexed address previousFeeTo, indexed address newFeeTo)event SwapFeeSet(uint256 swapFeeBps)event QuoteRegistrySet(indexed address quoteRegistry)event SwapAllowedSet(indexed address account, bool allowed)event PairCreated(indexed address token0, indexed address token1, address pair, uint256)[from IMotoSwapFactory] - the unnamed fourth field isallPairs.lengthafter the pushevent FeeToSetterTransferStarted(indexed address previousFeeToSetter, indexed address newFeeToSetter)[from IMotoSwapFactory]event FeeToSetterTransferred(indexed address previousFeeToSetter, indexed address newFeeToSetter)[from IMotoSwapFactory]
Reverts (string reverts, not custom errors)
"MotoSwap: ZERO_ADDRESS"-initializereceived a zero_feeToSetter, ortoken0is zero after sort increatePair"MotoSwap: FORBIDDEN"- caller is notfeeToSetter(or notpendingFeeToSetteronacceptFeeToSetter)"MotoSwap: FEE_TOO_HIGH"-setSwapFeeaboveMAX_SWAP_FEE_BPS"MotoSwap: IDENTICAL_ADDRESSES"-createPairwithtokenA == tokenB"MotoSwap: TOKEN_NOT_DEPLOYED"- one side has no code"MotoSwap: NO_QUOTE"- createPair with a quote registry set and neither token a registered quote asset (every pool must have a quote side)"MotoSwap: PAIR_EXISTS"- pair already deployed for the sorted token order
MotoSwapFactory declares no custom errors.
Notes
- Pairs are Solady
LibCloneERC-1967 beacon proxies deployed with CREATE2,salt = keccak256(abi.encodePacked(token0, token1)). The init code hash depends on the deployment's beacon address, so it is different on every chain and is NOT the Uniswap V2 constant. ReadpairCodeHash()(aliasINIT_CODE_PAIR_HASH()) from the factory. Never hardcode it feeToSetteris never renounced. It keeps the fee rate,feeTo, the quote registry and the swap perimeter for the life of the deployment
MotoSwapPair (packages/contracts/src/dex/core/MotoSwapPair.sol)
Uniswap-V2-style AMM pair; each pair is an ERC-1967 beacon proxy. Uses transient storage reentrancy lock (EIP-1153), computes fees live from factory.swapFeeBps, streams prices to TWAP accumulators.
Constructor
constructor(address _factory)- bakes immutable factory address
Constants
MINIMUM_LIQUIDITY = 1000- Transient lock slot:
0x1dce58e7bd12b13f77dfd7a57473337c10d495bb3ac0b21edffa2e9514fa5402
State Variables
factory: address- immutable factorytoken0, token1: address- sorted token addressesreserve0, reserve1: uint112- cached reservesblockTimestampLast: uint32- last block.timestamp a Sync was emittedprice0CumulativeLast, price1CumulativeLast: uint256- TWAP numeratorskLast: uint256- reserve0 * reserve1 at last liquidity event (for fee accrual)
External / Public Functions
initialize(address _token0, address _token1)- one-shot initialization; onlyFactory; reverts if already initialized or called by non-factorygetReserves() view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast)- fetch cached reserves and timestampmint(address to) lock returns (uint256 liquidity)- deposit proportional amounts of token0/token1, mint LP toto, optionally collect protocol fee; low-level, requires tokens already transferredburn(address to) lock returns (uint256 amount0, uint256 amount1)- remove liquidity: burn LP from pair, transfer tokens toto; requires LP already transferredswap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) lock- atomic swap; readsfactory.swapConfig(msg.sender), requires the caller to be on the swap perimeter, enforces the constant-product check with the live fee;dataMUST be empty, there is no flash-swap callback; emits Swapskim(address to) lock- send the balance above reserves toto. Does not emit Syncsync() lock- resync reserves to balances
Events
event Mint(indexed address sender, uint256 amount0, uint256 amount1)event Burn(indexed address sender, uint256 amount0, uint256 amount1, indexed address to)event Swap(indexed address sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, indexed address to)event Sync(uint112 reserve0, uint112 reserve1)event Transfer(indexed address from, indexed address to, uint256 value)[from MotoSwapERC20]event Approval(indexed address owner, indexed address spender, uint256 value)[from MotoSwapERC20]
Reverts (string reverts, not custom errors)
"MotoSwap: LOCKED"- transient reentrancy lock held"MotoSwap: FORBIDDEN"- onlyFactory on initialize"MotoSwap: ALREADY_INITIALIZED""MotoSwap: TRANSFER_FAILED"- low-level token transfer returned false or reverted"MotoSwap: OVERFLOW"- balance > uint112.max"MotoSwap: INSUFFICIENT_LIQUIDITY_MINTED""MotoSwap: INSUFFICIENT_LIQUIDITY_BURNED""MotoSwap: SWAPPER_NOT_ALLOWED"- caller not infactory.swapAllowed(protocol-fee perimeter)"MotoSwap: INSUFFICIENT_OUTPUT_AMOUNT"- zero output"MotoSwap: INSUFFICIENT_LIQUIDITY"- not enough reserves"MotoSwap: INVALID_TO"-tois token0 or token1"MotoSwap: FLASH_SWAPS_CLOSED"-swapcalled with non-emptydata"MotoSwap: INSUFFICIENT_INPUT_AMOUNT"- zero input"MotoSwap: K"- constant product violated after fee
Notes
- Flash swaps are closed by an enforced
require(data.length == 0).motoSwapCallis never invoked.IMotoSwapCalleeremains in the source tree as an interface file only mintruns_mintFeebefore_mint(to, liquidity)and theMintevent has no recipient, the same order as Uniswap V2. Whilefactory.feeTo()is non-zero, aTransfer(0x0 -> feeTo)precedes the user'sTransfer(0x0 -> to). See the watch-events guide_mintFeeshare:liquidity = totalSupply * (rootK - rootKLast) * 3 / (rootK * 2 + rootKLast * 3), about 60% of fee growth tofeeTo- Swap fee is read LIVE from factory; a fee change mid-swap reprices it, bounded by MAX_SWAP_FEE_BPS
- Protocol-fee
_mintFeeno-ops entirely whenfeeTo == address(0); deployed config sets feeTo=0, so 100% of the pair fee (30 bps) accrues to LPs - Reentrancy lock uses EIP-1153 transient storage; requires evm_version=cancun
MotoSwapLibrary (packages/contracts/src/dex/periphery/MotoSwapLibrary.sol)
All functions are internal - the library is compiled into its callers, not
integrator-callable. Use MotoSwapRouter02's public getAmountsOut/getAmountsIn/quote instead.
Pure/view helpers for swaps, reserves, and CREATE2 pair addresses.
Internal functions
sortTokens(address tokenA, address tokenB) pure returns (address token0, address token1)- sort tokens and validatepairFor(address factory, address tokenA, address tokenB) view returns (address pair)- compute CREATE2 address; reads pairCodeHash() live from factorygetReserves(address factory, address tokenA, address tokenB) view returns (uint256 reserveA, uint256 reserveB)- fetch reserves, sorted to match token orderquote(uint256 amountA, uint256 reserveA, uint256 reserveB) pure returns (uint256 amountB)- proportional amount: amountB = (amountA * reserveB) / reserveAgetAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut, uint256 feeBps) pure returns (uint256 amountOut)- constant-product output after feegetAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut, uint256 feeBps) pure returns (uint256 amountIn)- constant-product input for target output after feegetAmountsOut(address factory, uint256 amountIn, address[] memory path) view returns (uint256[] memory amounts)- route amounts through a pathgetAmountsIn(address factory, uint256 amountOut, address[] memory path) view returns (uint256[] memory amounts)- route amounts in reverse through a path
Reverts (string reverts, not custom errors)
"MotoSwapLibrary: IDENTICAL_ADDRESSES"- tokenA == tokenB"MotoSwapLibrary: ZERO_ADDRESS"- token0 is zero after sort"MotoSwapLibrary: INSUFFICIENT_AMOUNT"-quotecalled withamountA == 0"MotoSwapLibrary: INSUFFICIENT_INPUT_AMOUNT"-getAmountOutcalled withamountIn == 0"MotoSwapLibrary: INSUFFICIENT_OUTPUT_AMOUNT"-getAmountIncalled withamountOut == 0"MotoSwapLibrary: INSUFFICIENT_LIQUIDITY"- reserves are zero"MotoSwapLibrary: INVALID_PATH"- path shorter than 2 tokens
MotoSwapRouter02 (packages/contracts/src/dex/periphery/MotoSwapRouter02.sol)
Stateless router for adding/removing liquidity and swapping. Modeled on Uniswap V2 Router02. Swap entrypoints are gated on factory.swapAllowed (protocol-fee perimeter).
Constructor / Init
constructor(address _factory, address _WETH)- immutable factory and WETH
Constants
factory, WETH: address- immutable
External / Public Functions
Liquidity (permissionless):
addLiquidity(address tokenA, address tokenB, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) ensure returns (uint256 amountA, uint256 amountB, uint256 liquidity)- add liquidity for token-token pairaddLiquidityETH(address token, uint256 amountTokenDesired, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) payable ensure returns (uint256 amountToken, uint256 amountETH, uint256 liquidity)- add liquidity for token-ETH pairremoveLiquidity(address tokenA, address tokenB, uint256 liquidity, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) ensure returns (uint256 amountA, uint256 amountB)- remove liquidity for token-token pairremoveLiquidityETH(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) ensure returns (uint256 amountToken, uint256 amountETH)- remove liquidity for token-ETH pairremoveLiquidityWithPermit(address tokenA, address tokenB, uint256 liquidity, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline, bool approveMax, uint8 v, bytes32 r, bytes32 s) returns (uint256 amountA, uint256 amountB)- remove liquidity with EIP-2612 permitremoveLiquidityETHWithPermit(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline, bool approveMax, uint8 v, bytes32 r, bytes32 s) returns (uint256 amountToken, uint256 amountETH)removeLiquidityETHSupportingFeeOnTransferTokens(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) ensure returns (uint256 amountETH)- for fee-on-transfer tokensremoveLiquidityETHWithPermitSupportingFeeOnTransferTokens(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline, bool approveMax, uint8 v, bytes32 r, bytes32 s) returns (uint256 amountETH)
Swaps (onlyPerimeter):
swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter returns (uint256[] memory amounts)- exact inputswapTokensForExactTokens(uint256 amountOut, uint256 amountInMax, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter returns (uint256[] memory amounts)- exact outputswapExactETHForTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) payable ensure onlyPerimeter returns (uint256[] memory amounts)- path[0] must be WETHswapTokensForExactETH(uint256 amountOut, uint256 amountInMax, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter returns (uint256[] memory amounts)- path[last] must be WETHswapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter returns (uint256[] memory amounts)- path[last] must be WETHswapETHForExactTokens(uint256 amountOut, address[] calldata path, address to, uint256 deadline) payable ensure onlyPerimeter returns (uint256[] memory amounts)- path[0] must be WETHswapExactTokensForTokensSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter- for fee-on-transfer; measures realized balance deltasswapExactETHForTokensSupportingFeeOnTransferTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) payable ensure onlyPerimeterswapExactTokensForETHSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter
Views (pure/view helpers):
quote(uint256 amountA, uint256 reserveA, uint256 reserveB) pure returns (uint256 amountB)- delegates to MotoSwapLibrary.quotegetAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) view returns (uint256 amountOut)- reads live factory.swapFeeBpsgetAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut) view returns (uint256 amountIn)- reads live factory.swapFeeBpsgetAmountsOut(uint256 amountIn, address[] memory path) view returns (uint256[] memory amounts)getAmountsIn(uint256 amountOut, address[] memory path) view returns (uint256[] memory amounts)
Events (None emitted by router; pairs emit Swap/Mint/Burn/Transfer)
Reverts (string reverts, not custom errors)
"MotoSwapRouter: EXPIRED"-deadline < block.timestamp"MotoSwapRouter: DESIRED_BELOW_MIN"- anamount*Minabove itsamount*Desiredon add-liquidity"MotoSwapRouter: INSUFFICIENT_OUTPUT_AMOUNT""MotoSwapRouter: EXCESSIVE_INPUT_AMOUNT""MotoSwapRouter: INSUFFICIENT_A_AMOUNT","MotoSwapRouter: INSUFFICIENT_B_AMOUNT""MotoSwapRouter: INVALID_PATH""MotoSwapRouter: SWAPPER_NOT_ALLOWED"- onlyPerimeter guard:factory.swapAllowed(msg.sender)is false"MotoSwapRouter: LP_TRANSFER_FAILED"- the pair'stransferFromreturned false.MotoSwapERC20reverts with a panic instead, so in practice a missing LP allowance shows as panic0x11
Notes
- Every swap entrypoint is gated on
factory.swapAllowed(msg.sender). Only theFeeRouter(and the router itself) is admitted, so an outside caller always reverts here. The exact-output entrypoints exist on this router but are reachable by nobody outside: theFeeRouteris exact-input only addLiquiditycreates a missing pair throughfactory.createPair, which requires both tokens to have code and one side to be a quote asset- Fee-on-transfer variants measure realized balance deltas instead of nominal amounts
- Router keeps low-level TransferHelper (not OZ SafeERC20) matching the reference
FeeRouter (packages/contracts/src/fees/FeeRouter.sol)
The swap entrypoint for users and outside integrators. Skims the protocol fee (protocolFeeBps) from the quote leg, forwards it to the Collector, then routes through the core router. Exact input only: there is no exact-output entrypoint and no quote view. Supports three path types: both endpoints quote, one endpoint quote, or a split at the first quote asset inside the path. A creator fee is skimmed on top when a path endpoint has an active creator. UUPS proxy, Ownable2Step.
Constructor / Init
constructor()- disables initializersinitialize(address router_, address registry_, address collector_, address permit2_, uint256 protocolFeeBps_, address owner_)- wire router, quote registry, collector, Permit2, starting fee; owner-only upgrade auth; reverts ZeroAddress on a zero router, registry, collector or owner, FeeTooHigh above the cap
Constants
LAUNCH_PROTOCOL_FEE_BPS = 70- the launch value of the protocol fee (0.70%). A constant for reference; the live value isprotocolFeeBpsMAX_PROTOCOL_FEE_BPS = 100- hard cap on protocol fee (1.00%)MAX_CREATOR_FEE_BPS = 50- hard cap on creator fee (0.50%)BPS_DENOM = 10_000MAX_SPLIT_LEGS = 4- max legs in one split callSwapLegstruct:(uint256 amountIn, address[] path)- one leg of a split fill
State Variables
router: IMotoSwapRouter02- core routerregistry: IQuoteAssetRegistry- quote-asset registrycollector: address- fee recipientWETH: address- read from the router at initializepermit2: IPermit2- Permit2 for the two signature entrypoints. Zero means they are not available (they revert Permit2NotSet) and the plain approve path appliesprotocolFeeBps: uint256- live fee (settable, capped at MAX_PROTOCOL_FEE_BPS)creatorRegistry: ICreatorFeeRegistry- optional creator-fee configcreatorVault: ICreatorFeeVault- creator-fee payout vaultcreatorFeeBps: uint256- creator-fee rate (settable, capped)
External / Public Functions
Config (onlyOwner):
setProtocolFeeBps(uint256 bps)- retune live protocol feesetCreatorFeeConfig(address registry_, address vault_, uint256 bps)- wire creator-fee components (zero bps or registry disables it); withbps > 0the vault must already list this router as a depositor, else VaultDoesNotAdmitRoutersetPermit2(address permit2_)- re-point or disable signature entrypointsrenounceUpgradeability()- freeze implementation; one-wayrescueERC20(address token, address to) onlyOwner nonReentrant- sweep stranded tokensrescueETH(address to) onlyOwner nonReentrant- sweep stranded ETH
Queries (view):
feeOnOutput(address[] calldata path) view returns (bool)- true if the fee is taken from the output leg (else input). Looks only atpath[0]and the last element; returnstruefor a path with no quote endpoint, where it means nothing. This is the only swap-related view: to quote, takeMotoSwapRouter02.getAmountsOutand apply the fee legs yourself (see the swap-via-fee-router guide)
Token→Token Swaps:
swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) nonReentrant returns (uint256[] memory amounts)- exact input; pulls viasafeTransferFromswapExactTokensForTokensPermit2(IPermit2.PermitTransferFrom calldata permit, bytes calldata signature, uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) nonReentrant returns (uint256[] memory amounts)- same, input via Permit2 signatureswapExactTokensForTokensSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) nonReentrant returns (uint256 delivered)- for fee-on-transfer tokens; measures realized balances
ETH→Token Swaps:
swapExactETHForTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) payable nonReentrant returns (uint256[] memory amounts)- path[0] must be WETHswapExactETHForTokensSupportingFeeOnTransferTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) payable nonReentrant returns (uint256 delivered)
Token→ETH Swaps:
swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) nonReentrant returns (uint256[] memory amounts)- path[last] must be WETH; pulls via safeTransferFromswapExactTokensForETHPermit2(IPermit2.PermitTransferFrom calldata permit, bytes calldata signature, uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) nonReentrant returns (uint256[] memory amounts)swapExactTokensForETHSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) nonReentrant returns (uint256 net)
Atomic Split Fills:
swapExactTokensForTokensSplit(SwapLeg[] calldata legs, uint256 minTotalOut, address to, uint256 deadline) nonReentrant returns (uint256[] memory legOuts, uint256 totalOut)- token-in; each leg shares path[0]/path[last]; enforces minTotalOut on sum; emits one Swap per legswapExactETHForTokensSplit(SwapLeg[] calldata legs, uint256 minTotalOut, address to, uint256 deadline) payable nonReentrant returns (uint256[] memory legOuts, uint256 totalOut)- eth-in; all legs path[0] == WETHswapExactTokensForETHSplit(SwapLeg[] calldata legs, uint256 minTotalOut, address to, uint256 deadline) nonReentrant returns (uint256[] memory legOuts, uint256 totalOut)- token-in, eth-out; all legs path[last] == WETH
Events
event Swap(indexed address user, indexed address tokenIn, indexed address tokenOut, uint256 amountIn, uint256 amountOut, address feeToken, uint256 feeAmount)-userismsg.senderof the FeeRouter call (the payer), neverto.amountInis the gross input,amountOutthe net delivered toto,feeAmountthe protocol fee only, infeeTokenevent Permit2Set(address permit2)event ProtocolFeeSet(uint256 protocolFeeBps)event CreatorFee(indexed address token, indexed address creator, address quoteAsset, uint256 amount)- creator fee skimevent CreatorFeeConfigSet(address registry, address vault, uint256 creatorFeeBps)event Rescued(indexed address token, indexed address to, uint256 amount)
Custom Errors
error InvalidPath()- swap path invalid (ETH routes must start/end with WETH)error InsufficientOutput()- net out below amountOutMinerror EthTransferFailed()- ETH transfer reverted or no receiveerror NoQuoteSide()- swap route has no quote asset endpoint or mid-quoteerror FeeTooHigh()- fee > MAX_PROTOCOL_FEE_BPS or MAX_CREATOR_FEE_BPSerror ZeroAddress()- initialize or config received zero addresserror Permit2NotSet()- signature swap called but permit2 not wirederror PermitTokenMismatch()- Permit2 token != path[0]error NoLegs()- split called with empty legs array or zero-amount legerror TooManyLegs()- legs > MAX_SPLIT_LEGSerror LegMismatch()- split legs don't share same endpointserror ValueMismatch()-swapExactETHForTokensSplitcalled withmsg.value != totalInerror VaultDoesNotAdmitRouter()-setCreatorFeeConfigpointed a live creator fee at a vault that does not list this router as a depositor
Notes
- The protocol fee is charged once, on the quote leg only (or at the first quote asset inside the path if neither endpoint is a quote asset). With two quote endpoints the higher
quoteRankside pays; a tie charges the output - The deadline is not checked here.
MotoSwapRouter02checks it and reverts"MotoSwapRouter: EXPIRED" amountOutMinis enforced on the net amount delivered, after the protocol fee and any creator fee- Returned
amounts:amounts[0]is the gross input and the last element the net delivered. On a mid-path fee the array does not chain across the fee hop.swapExactTokensForETHand its Permit2 twin fill only the first and last elements. The fee-on-transfer entrypoints return one measured number - Permit2 twins exist for
swapExactTokensForTokensandswapExactTokensForETHonly. The fee-on-transfer and token-in split entrypoints need a normal allowance to this contract receive()accepts ETH from the router and WETH only; anything else reverts EthTransferFailed- Both endpoints with an active creator pay the creator fee twice (each
creatorFeeBpsof the quote leg) - Creator fees are optional and charged on the SAME quote leg as the protocol fee
- Entrypoints emit one Swap event per logical trade; split fills emit per-leg
- The
SwapLegstruct contains only(amountIn, path[])and relies on validation to ensure legs sharepath[0]andpath[last]
RakebackV2 (packages/contracts/src/fees/RakebackV2.sol)
Merkle-root Rakeback distribution. No signer and no hot key on the claim path: the backend derives one tree per weekly epoch from public chain data, an automated poster puts the root on chain, and a claim is a pure merkle proof. One tree per epoch with token folded into the leaf, but the money is accounted per quote asset, so declared totals, funding, activation, the claim bitmap and expiry are all keyed per (epoch, token) slice. UUPS proxy.
Constructor / Init
constructor()- disables initializersinitialize(address owner_, address treasury_, address moto_, address poster_, address guardian_, uint64 activationDelay_, address upgradeAuthority_)- wire treasury destination, MOTO token, the poster and guardian roles, the activation delay and the upgrade authority (zero leaves upgrades with the owner); revertsBadDelay()if the delay is outside[MIN_ACTIVATION_DELAY, MAX_ACTIVATION_DELAY]. There is no router argument:claimAsMotoswaps throughfeeRouter
Constants
CLAIM_WINDOW = 30 days- claimable span, measured from a slice'sActivatedtimestamp (not from epoch close)MIN_ACTIVATION_DELAY,MAX_ACTIVATION_DELAY- bounds on the liveactivationDelay. Read the live values from the contractMAX_TOKENS_PER_EPOCH = 8- cap on tokens in one epoch'spostRoot
State Variables
treasury: address- sweep destination (settable)poster: address- the automated role allowed to callpostRoot(settable)guardian: address- hot ops role allowed tocancelRoota still-pending root (settable)moto: address- MOTO tokenfeeRouter: IFeeRouter- set post-init bysetFeeRouter; the swap venue forclaimAsMotoactivationDelay: uint64- live delay betweenpostRootand the earliestactivateepochRoots[uint256 => EpochRoot]- epoch ->(bytes32 root, uint64 postedAt)slices[uint256 => mapping(address => TokenSlice)]- epoch -> token ->(uint256 declaredTotal, uint256 claimedTotal, uint64 activatedAt, bool expired)outstanding[address => uint256]- token -> unclaimed liability across all live slices;sweepmust retain itactiveOutstanding[address => uint256]- the same over ACTIVE slices only; this is the activation funding gatetotalDeclared[address => uint256]- token -> everything ever declared; the liability-cap numeratorlifetimeInflow[address => uint256]- measured Collector inflow, credited bysyncInflow; the cap ceilingaccountedBalance[address => uint256]- last-synced balance, sosyncInflowcredits only genuine new inflow
External / Public Functions
Config (onlyOwner):
setFeeRouter(address feeRouter_)- wire fee-paying swap front door post-initsetPoster(address poster_)- rotate the poster rolesetGuardian(address guardian_)- rotate the guardian rolesetActivationDelay(uint64 delay_)- retune the delay within its constant boundssetTreasury(address treasury_)- set sweep destinationexpirePendingSlice(uint256 epoch, address token)- release a slice that was posted but never activatable, once its window has fully lapsed and the pot still cannot fund it. Owner-only in the code, whatever its comment says
Upgrade path:
upgradeAuthority() view returns (address)- the address that may upgrade; zero means the ownersetUpgradeAuthority(address newAuthority)- callable by the current authority (the owner while it is zero). Must be a contract, and once set can never go back to zerorenounceUpgradeability()- freeze implementation; one-way; gated on the upgrade authority
Views (view / pure):
isClaimed(uint256 epoch, address token, uint256 index) view returns (bool)- the leaf's bitmap bit; the authority on whether a claim already landedepochEnd(uint256 epoch) pure returns (uint256)- Sunday-anchored epoch boundary, pinned to the backend anchor
Publishing (poster / guardian):
postRoot(uint256 epoch, bytes32 root, address[] calldata tokens, uint256[] calldata declaredTotals) nonReentrant- callssyncInflowper token itself, then publishes an epoch's root with its per-token declared totals; poster-gated; the epoch must be closed, tokens sorted and at mostMAX_TOKENS_PER_EPOCH, each declared > 0, andtotalDeclared[token] + declaredmust stay within the token's measuredlifetimeInflow. That cap is the on-chain blast-radius bound on a compromised postercancelRoot(uint256 epoch)- void a still-PENDING root and rewind its books so a corrected root can be re-posted; guardian or owner; revertsRootActive()once any slice of the epoch has activated, so live claims cannot be rugged
Permissionless keeping:
syncInflow(address token)- credit genuine Collector inflow into the liability-cap ceilingactivate(uint256 epoch, address token)- open claims for a posted slice; requires the activation delay to have elapsed AND the pot to cover every already-active slice for the token plus this one. That establishesbalance >= activeOutstandingat activation, so an active claim can never fail on funds; an unfunded slice stays pending, meaning claims are delayed, never partialexpireRoot(uint256 epoch, address token)- after the claim window, release an activated slice's unclaimed remainder fromoutstandingso it becomes sweepable; reverts NotActivated for a slice that never activated
Claims:
claim(uint256 epoch, address token, uint256 index, address account, uint256 amount, bytes32[] calldata proof) nonReentrant returns (uint256)- verify the leaf against the epoch root and payamounttoaccount. All-or-nothing: one bitmap bit, no partial claim, no cumulative counter. Delivery is permissionless, so anyone can push a claim to its rightful ownerclaimMany(uint256[] calldata epochs, address[] calldata tokens, uint256[] calldata indexes, address[] calldata accounts, uint256[] calldata amounts, bytes32[][] calldata proofs) nonReentrant returns (uint256 total)- batch form, ATOMIC: any single entry reverting reverts the whole call, so send only entries known to be claimableclaimAsMoto(uint256 epoch, address token, uint256 index, uint256 amount, bytes32[] calldata proof, uint256 minMotoOut, address[] calldata path, uint256 deadline) nonReentrant returns (uint256 motoOut)- claim your OWN leaf (the proof must bindmsg.sender) and swap it to MOTO through the FeeRouter, so the swap pays the protocol fee;pathmust run token→MOTO. Whentokenis MOTO the swap is skipped
Sweep:
sweep(address token, uint256 amount) onlyOwner nonReentrant- reclaim only the SURPLUS above the token's live liability, to the treasury address only; bounded byExceedsSweepable()
Events
event RootPosted(indexed uint256 epoch, bytes32 root, uint64 postedAt)event SlicePosted(indexed uint256 epoch, indexed address token, uint256 declaredTotal)event Activated(indexed uint256 epoch, indexed address token, uint64 activatedAt)event Claimed(indexed uint256 epoch, indexed address token, uint256 index, address account, uint256 amount)event ClaimedAsMoto(indexed address user, indexed address token, indexed uint256 epoch, uint256 amountIn, uint256 motoOut)event Expired(indexed uint256 epoch, indexed address token, uint256 remainder)event PendingSliceExpired(indexed uint256 epoch, indexed address token, uint256 declared)event RootCancelled(indexed uint256 epoch)event InflowSynced(indexed address token, uint256 delta, uint256 lifetimeInflow)event Swept(indexed address token, uint256 amount)event PosterSet(address poster),event GuardianSet(address guardian),event ActivationDelaySet(uint64 activationDelay),event TreasurySet(address treasury),event FeeRouterSet(address feeRouter)
Custom Errors
error ZeroAddress()- init received zero addresserror NotPoster()/error NotGuardian()- role gate on postRoot / cancelRooterror BadDelay()- activation delay outside[MIN_ACTIVATION_DELAY, MAX_ACTIVATION_DELAY]error EpochNotClosed()- postRoot ran before the epoch endederror RootExists()/error NoRoot()- an epoch already has a root / has noneerror TokensNotSorted()/error TooManyTokens()/error ZeroDeclared()/error ZeroRoot()/error BadPerToken()- postRoot argument validationerror ExceedsInflowCap()- declared total would exceed the token's measured Collector inflowerror RootPending()- claim on a slice that has not activated yeterror NotActivated()-expireRooton a slice that never activatederror NotYetActivatable()/error NotFunded()/error AlreadyActivated()/error SliceNotFound()- activate gateserror RootActive()- cancelRoot after a slice activatederror InvalidProof()- proof does not verify against the epoch rooterror AlreadyClaimed()- the bitmap bit for that index is already seterror Overclaim()- the leaf would pushclaimedTotalpast the slice'sdeclaredTotal; the bound that stops a poisoned root draining the poterror RootExpired()- pastactivatedAt + CLAIM_WINDOWerror ClaimWindowOpen()/error SliceActivated()/error SliceStillActivatable()/error SliceIsFundable()/error AlreadyExpired()- expiry-path gateserror LengthMismatch()- claimMany arrays different lengthserror BadPath()- claimAsMoto path invaliderror ExceedsSweepable()- sweep tried to dip into live liability
Notes
- The 50/25/25 vest is applied by the backend at post time and baked into the leaf
amount. There is no read-time vest on chain: epoch x's published pool isslice0(R[x]) + slice1(R[x-1]) + slice2(R[x-2]), and the third slice is the remainder so the three sum to R exactly - Leaf =
keccak256(abi.encodePacked(uint256 index, address account, address token, uint256 amount)), in a sorted-pair (commutative) tree matching OpenZeppelinMerkleProof.verify, with an odd trailing node promoted unhashed. This is NOTStandardMerkleTree's double-hashed leaf. Leaves are ordered by token then account, andindexis the global 0-based position - Security of the automated poster is on chain, not in a key: a root can never declare more than the token's measured inflow, it sits behind an activation delay, and it is cancellable by the guardian while pending. Cancel is harmless by construction, since the worst it can do is delay a good root
- Creator fees never inflate Rakeback and Points (feeAmount in Swap event is protocol fee only, not creator fees)
Collector (packages/contracts/src/fees/Collector.sol)
Dynamic fee-distribution hub: receives 0.7% protocol fee from FeeRouter, splits across weighted buckets (staking notify, treasury, rakeback, buyback). Handles failed payouts via earmarks (heldFor). Supports per-token distribution rate-limiting. UUPS proxy.
Constructor / Init
initialize(address initialOwner, address stakingRecipient_, address treasury_, address rakeback_, address buybackBurn_, address upgradeAuthority_, address quoteRegistry_)- seed 2:2:2:1 split (staking notify, treasury, rakeback, buyback), the upgrade authority (zero leaves upgrades with the owner) and the quote registrydistributeis gated on
Constants
MAX_BUCKETS = 16- max bucket countMAX_MIN_INTERVAL- ceiling onminInterval. Read it from the contract
State Variables (Buckets)
Bucket[] buckets-`{ address recipient, uint96 weight, bool notify }`; notify=true uses IStakingRewards.notifyRewardtotalWeight: uint256- sum of all bucket weights
State Variables (Timing & Holds)
minInterval: uint256- min seconds between distributes per token (0 = unrestricted)lastDistributed[address => uint256]- last distribute timestamp for tokenheldFor[address => mapping(uint256 => uint256)]- token -> bucket index -> earmark balance (payouts that reverted)reserved[address => uint256]- token -> total held (excluded from distributable balance)heldIndexCount[uint256 => uint256]- bucket index -> count of distinct tokens with earmarks there (strand guard)quoteRegistry: IQuoteAssetRegistry- the quote-asset whitelistdistributeis gated on
External / Public Functions
Config (onlyOwner):
setBuckets(Bucket[] calldata newBuckets)- replace bucket list atomically; reverts if shrink would strand live earmarks (HeldWouldStrand)setBucketRecipient(uint256 index, address recipient, bool notify)- re-point one bucket's destination and state how it is paid (notify = truecallsnotifyRewardon it)addBucket(address recipient, uint96 weight, bool notify)- append one bucketsetMinInterval(uint256 minInterval_)- set per-token rate limit; IntervalTooLong aboveMAX_MIN_INTERVALforceReleaseHeld(address token, uint256 index) returns (uint256 amount)- abandon an earmark whose payout cannot succeed: clears the accounting without paying and returns the value to the ordinary split; emits HeldForceReleased
Upgrade path:
upgradeAuthority() view returns (address),setUpgradeAuthority(address newAuthority)- same split as RakebackV2renounceUpgradeability()- freeze implementation; one-way; gated on the upgrade authority
Distribution (permissionless, rate-limited per token):
distribute(address token) nonReentrant returns (uint256 paid)-tokenmust be a registered quote asset (NotQuoteAsset otherwise); split distributable balance across buckets pro-rata; skips buckets whose payout reverts (earmarks them); enforces minInterval per token; emits DistributedreleaseHeld(address token, uint256 index) nonReentrant returns (uint256 amount)- pay a bucket's earmark once available; retries the payout (reverts if it fails again)
Views:
bucketsLength() view returns (uint256)distributable(address token) view returns (uint256)- balance available to distribute (excluding reserved earmarks)previewSplit(uint256 amount) view returns (uint256[] memory shares)- show how amount would split
Events
event BucketConfigured(indexed uint256 index, address recipient, uint96 weight, bool notify)- bucket added/updatedevent BucketRecipientSet(indexed uint256 index, address recipient, bool notify)- recipient changed. Three fields: a decoder built on the older two-field shape will not matchevent MinIntervalSet(uint256 minInterval)event Distributed(indexed address token, uint256 amount)- total paid (excluding skipped buckets)event BucketPaid(indexed address token, indexed uint256 index, address recipient, uint256 amount)- one bucket paidevent BucketSkipped(indexed address token, indexed uint256 index, bytes reason)- payout reverted; earmark heldevent BucketHeld(indexed address token, indexed uint256 index, address recipient, uint256 amount, uint256 totalHeld)event HeldReleased(indexed address token, indexed uint256 index, address recipient, uint256 amount)- an earmark was paidevent HeldForceReleased(indexed address token, indexed uint256 index, uint256 amount)- an earmark was abandoned without a payout
Custom Errors
error ZeroAddress()- bucket recipient is zeroerror ZeroWeight()- bucket weight is zeroerror NoBuckets()- setBuckets called with empty arrayerror TooManyBuckets()- bucket count > MAX_BUCKETSerror BadIndex()- index >= buckets.lengtherror TooSoon()- distribute called within minIntervalerror OnlySelf()- payoutBucket called externallyerror NothingHeld()- releaseHeld called with zero earmarkerror HeldWouldStrand(uint256 index)- setBuckets would drop a bucket with live earmarkserror HeldWouldRedirect(uint256 index, address held, address replacement)- a bucket change would send a live earmark to a different recipienterror NotQuoteAsset(address token)-distributefor a token the quote registry does not knowerror IntervalTooLong()-setMinIntervalaboveMAX_MIN_INTERVAL
Notes
- Rounding dust goes to the last bucket (buyback and burn by default)
- Held earmarks stay out of the distributable balance, so a persistently-failing bucket never re-weights the healthy ones
- The staking bucket is seeded to the
MotoStakingproxy at deploy. Until the reward tokens are registered on the deployedMotoStakingimplementation, which needs a contract upgrade, the staking share is held at the Collector (BucketHeld) and released later
QuoteAssetRegistry (packages/contracts/src/fees/QuoteAssetRegistry.sol)
Admin-managed whitelist of quote assets (WETH/USDT/USDC/MOTO). Used by FeeRouter and Factory for fee-side selection and pair-creation gating. Plain Ownable2Step, non-upgradeable.
Constructor
constructor(address initialOwner, address[] memory initialQuotes)- seed initial quote assets
State Variables
isQuote[address => bool]- whether an asset is a quotequoteRank[address => uint256]- fee-side priority (higher = more senior); tiebreaks when both swap legs are quote_quoteList: address[]- enumerable mirror of isQuote (for indexers)
External / Public Functions
Config (onlyOwner):
setQuote(address token, bool status)- add/remove from quote set; updates _quoteList; no-ops if status unchangedsetQuoteBatch(address[] calldata tokens, bool status)- batch formsetQuoteRank(address token, uint256 rank)- set fee-side priority for a quote assetsetQuoteRankBatch(address[] calldata tokens, uint256[] calldata ranks)- batch form
Views:
quoteAssets() view returns (address[] memory)- enumerable quote set
Events
event QuoteSet(indexed address token, bool status)event QuoteRankSet(indexed address token, uint256 rank)
Custom Errors
error ZeroAddress()- setQuote received zero tokenerror LengthMismatch()- setQuoteRankBatch arrays different lengths
Notes
- No persistence after removal: setQuote(token, false) removes it from _quoteList and isQuote
- quoteRank defaults to 0 (unranked); FeeRouter defaults to output-side when tied
BuybackBurner (packages/contracts/src/fees/BuybackBurner.sol)
Keeper-driven MOTO buyback and burn. Receives quote assets from Collector, swaps each to MOTO through the FeeRouter along an owner-pinned route, and burns to 0x...dEaD. Every sale must clear the contract's own TWAP floor as well as the keeper's. UUPS proxy, Ownable2Step.
Constructor / Init
initialize(address initialOwner, address moto_, address router_, address keeper_, address upgradeAuthority_)- wire MOTO token, the core router (used to find the factory), keeper (may be zero: owner-only until one is appointed), and the upgrade authority
Constants
BURN_ADDRESS = 0x000000000000000000000000000000000000dEaDMIN_TWAP_WINDOW: uint32,MAX_TWAP_WINDOW: uint32- the shortest and longest reference window a sale is judged over. Not published here. Read them from the contractDEFAULT_TOLERANCE_BPS,MIN_TOLERANCE_BPS,MAX_TOLERANCE_BPS- the default TWAP tolerance and the bounds onsetTwapToleranceBps. Not published here. Read them from the contract
State Variables
moto: IERC20- MOTO tokenrouter: IMotoSwapRouter02- core router, read for its factorykeeper: address- an operational role address (settable; zero = owner-only)totalBurned: uint256- cumulative MOTO burnedfeeRouter: IFeeRouter- set post-init; the swap venuetwapToleranceBps: uint256- raw tolerance, 0 meaning unset. ReadtwapTolerance()instead
External / Public Functions
Config (onlyOwner):
setFeeRouter(address feeRouter_)- wire the swap venue post-initsetKeeper(address keeper_)- set or clear keeper (zero = owner-only)setPinnedPath(address tokenIn, address[] calldata path)- pin the one routetokenInmay be sold through, or clear it with an empty path. A pinned path is exactly[tokenIn, MOTO]; anything else reverts BadPinnedPathsetTwapToleranceBps(uint256 bps)- move the tolerance within its bounds; ToleranceOutOfRange outside them
Upgrade path:
upgradeAuthority() view returns (address),setUpgradeAuthority(address newAuthority)- same split as RakebackV2renounceUpgradeability()- freeze implementation; one-way; gated on the upgrade authority
Views:
pinnedPath(address tokenIn) view returns (address[] memory)pairFor(address tokenIn) view returns (address)- the pair a pinned token is priced against: the first hop of its pinned routeobservation(address tokenIn) view returns (uint256 priceCumulative, uint32 timestamp)- the cumulative-price reference held fortokenIn. Zeros mean never anchoredtwapTolerance() view returns (uint256)- the tolerance in force, resolving the unset sentineltwapFloor(address tokenIn, uint256 amountIn) view returns (uint256)- the MOTO floor this contract will not fill below for that sale: the window's average price, less the protocol fee, twice the creator fee, the pair fee and the tolerance. Reverts NoUsableTwap when there is no reference inside the window
Operations:
pokeCheckpoint(address tokenIn)- permissionless. Re-anchor the reference fortokenIn. Reverts CheckpointFresh insideMIN_TWAP_WINDOWof the last anchor, and CheckpointUsable while the existing reference is still inside the windowbuybackAndBurn(uint256 sellAmount, uint256 minMotoOut, address[] calldata path, uint256 deadline) onlyKeeper nonReentrant returns (uint256 motoOut)- sellsellAmountofpath[0]to MOTO and burn it plus any MOTO already held.sellAmountis clamped to the balance and0means the whole balance.pathmust equal the pinned route. The effective floor is the higher ofminMotoOutandtwapFloor, enforced on the measured MOTO balance delta. With nothing to sell and nothing to burn it emits NothingToDo and returns 0 instead of revertingburn() nonReentrant- burn all MOTO held (permissionless)
Events
event KeeperSet(address keeper)event FeeRouterSet(address feeRouter)event BoughtBack(indexed address tokenIn, uint256 amountIn, uint256 motoOut)event Burned(uint256 amount, uint256 totalBurned)event PinnedPathSet(indexed address tokenIn, address[] path)event NothingToDo(indexed address tokenIn)- a keeper run found nothing to sell and nothing to burnevent TwapToleranceSet(uint256 bps)event CheckpointPoked(indexed address tokenIn, indexed address pair, uint256 priceCumulative, uint32 timestamp)event TwapFloorBinding(indexed address tokenIn, uint256 keeperFloor, uint256 twapFloor)- the contract's floor was the binding one, not the keeper's
Custom Errors
error ZeroAddress()- initialize, a setter, or a sale with no feeRouter wirederror NotKeeper()- caller not keeper and not ownererror BadPath()- path shorter than 2,path[0] == MOTO, orpath[last] != MOTOerror NothingToBurn()-burnwith no MOTO helderror BelowMinMotoOut(uint256 motoOut, uint256 minMotoOut)- the measured MOTO received is below the effective floorerror BelowTwapFloor(uint256 motoOut, uint256 twapFloor)- declarederror TokenNotPinned(address tokenIn)- no pinned route forpath[0]error PathNotPinnedRoute()-pathdiffers from the pinned routeerror BadPinnedPath()-setPinnedPathwith anything but[tokenIn, MOTO]error ToleranceOutOfRange(uint256 bps)error NoUsableTwap(address tokenIn)- never anchored, window too short, window too old, or the pair's last write too olderror CheckpointFresh()-pokeCheckpointinsideMIN_TWAP_WINDOWof the last anchorerror CheckpointUsable(address tokenIn, uint32 age)-pokeCheckpointwhile the reference is still usableerror NoPairForToken(address tokenIn)- no pinned route, or no pair behind it
Notes
- Swaps go through FeeRouter, so the buyback pays the protocol fee like any other trade. The
Swapevent for a buyback names this contract asuser - Cumulative totalBurned never decreases (for indexer/stats)
- The
BuybackBurnerhas no entrypoint for an outside integrator beyondburn()andpokeCheckpoint()
BuybackDistributor (packages/contracts/src/fees/BuybackDistributor.sol)
Merkle-tree distributor over (index, account, amount) leaves with a fixed total allocation; claims stay open forever but surplus is sweepable post-deadline (after SWEEP_GRACE, unclaimed shares are recoverable). Plain Ownable2Step, non-upgradeable.
Constructor
constructor(address initialOwner, address token_, bytes32 merkleRoot_, uint256 claimDeadline_, uint256 totalAllocation_)- immutable root, deadline, and allocation; rejects zero root, past deadline, or zero allocation
Constants
SWEEP_GRACE = 180 days- grace window after claimDeadline before unclaimed shares become sweepable
State Variables (Immutable)
token: IERC20- payout assetmerkleRoot: bytes32- Merkle root over (index, account, amount) leavesclaimDeadline: uint256- claim window close timetotalAllocation: uint256- sum of all leaf amounts
State Variables
totalClaimed: uint256- cumulative claimed amountclaimedBitMap[uint256 => uint256]- bitmap for O(1) claim tracking
External / Public Functions
Claims (permissionless):
claim(uint256 index, address account, uint256 amount, bytes32[] calldata merkleProof)- claimamountforaccountat leaf index; verifies proof; reverts if already claimed; advancestotalClaimed
Sweeps (onlyOwner):
sweep(address to)- reclaim surplus above outstanding liability after claimDeadline; reverts if window still open (ClaimWindowOpen) or nothing to sweepsweepAll(address to)- reclaim entire balance after claimDeadline + SWEEP_GRACE (the genuinely-abandoned remainder); reverts if grace window still open (GracePeriodOpen)
Views:
isClaimed(uint256 index) view returns (bool)- check if leaf already claimedoutstanding() view returns (uint256)- unclaimed liability (totalAllocation - totalClaimed)sweepableSurplus() view returns (uint256)- balance above outstanding (ready for sweep now)
Events
event Claimed(indexed uint256 index, indexed address account, uint256 amount)event Swept(indexed address to, uint256 amount)- reclaimed surplusevent SweptAll(indexed address to, uint256 amount)- reclaimed remainder after grace
Custom Errors
error AlreadyClaimed()- index already claimederror InvalidProof()- Merkle proof doesn't match leaferror ClaimWindowOpen()- sweep before claimDeadlineerror InvalidDeadline()-deadline <= block.timestamperror ZeroRoot()- merkleRoot is bytes32(0)error ZeroToken()- token is zero addresserror ZeroAllocation()- totalAllocation is zeroerror NothingToSweep()- surplus or remainder is zeroerror GracePeriodOpen()- sweepAll before grace window closeserror Overclaim()- a claim would pushtotalClaimedpasttotalAllocation
Notes
- Two-stage sweep: surplus (after deadline) only covers dust over outstanding liability; unclaimed shares stay funded until grace expires
- The shape matches Uniswap's MerkleDistributor (bitmap-tracked)
CreatorFeeRegistry (packages/contracts/src/fees/CreatorFeeRegistry.sol)
Allowlist of creator-fee-eligible tokens and their creators. Registrar-settable (the moto.fun curve); creator-settable for their own payout address; admin-settable for takeovers. Plain Ownable2Step, non-upgradeable.
State Variables
tokens[address => TokenConfig]- token -> (creator address, disabled flag)isRegistrar[address => bool]- who may call register()
External / Public Functions
Registrar Path:
register(address token, address creator)- register token by an authorized registrar (the moto.fun curve); reverts if already registered or zero addresses; onlyRegistrar
Creator Path:
setCreator(address token, address creator)- called BY the token's current creator to move their own payout address. Gated oncreatorOf, so it also works on a disabled token; NotCreator otherwise; emits CreatorSetBySelf
Admin Path (onlyOwner):
adminSetCreator(address token, address creator)- override creator (community takeover); reverts if token not registeredsetDisabled(address token, bool disabled)- enable/disable a token's creator fee; reverts if not registeredsetRegistrar(address registrar, bool allowed)- add/remove registrar
Views:
activeCreator(address token) view returns (address)- creator if registered and not disabled, else zero. Answers "should the router skim?"creatorOf(address token) view returns (address)- the registered creator, ignoringdisabled. Answers "whose money is it?" and is what the vault gates claims on
Events
event TokenRegistered(indexed address token, indexed address creator, indexed address registrar)event CreatorSet(indexed address token, indexed address creator)- admin overrideevent CreatorSetBySelf(indexed address token, indexed address previousCreator, indexed address creator)- the creator moved their own payout addressevent TokenDisabled(indexed address token, bool disabled)- toggle disabled flagevent RegistrarSet(indexed address registrar, bool allowed)- registrar authorized/revoked
Custom Errors
error ZeroAddress()- register/adminSetCreator/setRegistrar received zeroerror NotRegistrar()- caller not an authorized registrarerror AlreadyRegistered()- token already has a creatorerror NotRegistered()- adminSetCreator or setDisabled on unregistered tokenerror NotCreator()-setCreatorby anyone but the token's current creator
CreatorFeeVault (packages/contracts/src/fees/CreatorFeeVault.sol)
Claim-based escrow for creator fees, per token, in quote assets. No hooks, no rescue. Integrates with CreatorFeeRegistry for creator validation. Plain Ownable2Step + ReentrancyGuard, non-upgradeable.
Constructor
constructor(address initialOwner, address registry_, address weth_)- immutable registry and WETH
State Variables
registry: ICreatorFeeRegistry- immutableWETH: address- immutableaccrued[address => mapping(address => uint256)]- token -> quote asset -> balance claimable by token's current creatorisDepositor[address => bool]- who may call notifyDeposit (FeeRouter)
External / Public Functions
Config (onlyOwner):
setDepositor(address depositor, bool allowed)- wire depositor (FeeRouter)
Core:
notifyDeposit(address token, address quoteAsset, uint256 amount) onlyDepositor- record fee skim (depositor must have already transferred tokens); emits Accruedclaim(address token, address[] calldata quoteAssets, bool unwrapWeth) nonReentrant- claimtoken's accrued balances across quote assets; caller must be the token's creator (registry.creatorOf, so a disabled token's balance stays claimable); unwraps WETH if requested; emits ClaimedclaimMany(address[] calldata tokens, address[] calldata quoteAssets, bool unwrapWeth) nonReentrant- the same for several tokens, one quote-asset list for all. A token the caller is not the creator of reverts the WHOLE batch; an emptytokensreverts NothingToClaim; duplicates are harmless
Events
event Accrued(indexed address token, indexed address quoteAsset, uint256 amount)event Claimed(indexed address token, indexed address creator, indexed address quoteAsset, uint256 amount)event DepositorSet(indexed address depositor, bool allowed)
Custom Errors
error ZeroAddress()- constructor received zero addresserror NotDepositor()- notifyDeposit called by non-depositorerror NotCreator()- claim called by non-creatorerror NothingToClaim()-claimManywith an empty token listerror EthTransferFailed()- ETH send failed or non-WETH receive
Notes
- Solvency: vault balance of each quote asset == sum of accrued[*][quoteAsset]
- A creator takeover captures unclaimed balance (by design; accrued is per token, not per old creator)
- WETH unwrapping on claim is optional (creator may take as WETH or unwrap to ETH)
MasterChef (packages/contracts/src/chef/MasterChef.sol)
Fixed-supply staking farm with time-based halving-curve emissions (the halving period is settable state, 42 days at initialize). Rewards: 50% liquid + 50% streamed through RewardVestingEscrow (180 days). Per-pool allocation, deposit fees, 24h unstake cooldown while the pool earns (none when no campaign is emitting or the pool's weight is 0); withdraw is queue then claim. UUPS proxy, Ownable2Step. Deployed with the DEX. While launchPhase was vamp, /config.addresses.masterChef served the launch farm, which is a VampChef. Not running: no emission campaign has been started on this contract, and none is planned.
Constructor / Init
constructor()- disables initializersinitialize(IERC20 _rewardToken, address _vestingEscrow, address _feeRecipient, address _owner, address upgradeAuthority_)- set reward token, vesting escrow (must match token), fee recipient, upgrade authority (zero leaves upgrades with the owner); defaults: halvingPeriod=42d, numPeriods=8
Constants
ACC_PRECISION = 1e12COOLDOWN = 24 hoursMAX_DEPOSIT_FEE_BPS = 1000(10%)MAX_HALVING_PERIOD = 365 daysMAX_START_DELAY = 90 daysHARVEST_GRACE = 30 days(grace period after emissionEnd before recovery opens)MAX_POOLS = 500
State Variables
rewardToken: IERC20- (settable pre-launch only)vestingEscrow: IRewardVestingEscrow- (settable pre-launch via setVestingEscrow)halvingPeriod, numPeriods: uint256- next campaign schedule (prospective-only; snapshot at startEmission)campaignHalvingPeriod, campaignNumPeriods: uint256- running campaign schedule snapshotinitialPeriodReward, totalEmission: uint256- running campaign reward configemissionStart, emissionEnd: uint64- running campaign windowfeeRecipient: address- deposit fee recipient (settable)trustedZapper: address- zap-and-stake exempt from depositFor third-party guard (set once)totalStakedRewardToken, pendingWithdrawalRewardToken: uint256- reward-token principal tracking (never paid as reward)totalAllocPoint, totalCredited, totalHarvested, forfeitedReward: uint256- allocation sum, reward credited into pools, reward paid, reward forfeited (by emergencyWithdraw or a clipped payout)poolExistence[address => bool]- which LP tokens already have a poolPoolInfo[] poolInfo-`{ lpToken, allocPoint, lastRewardTime, accRewardPerShare, lpSupply, depositFeeBps }`userInfo[pid => mapping(user => UserInfo)]-`{ amount, rewardDebt, pendingWithdrawal, cooldownEnd }`withdrawUnlockTime[pid => mapping(user => uint64)]- campaign-based unlock timestamp (sticky)
External / Public Functions
Campaign Config (onlyOwner):
startEmission(uint256 _initialPeriodReward, uint64 _startTime)- launch campaign with halving curve; pulls exact geometric total upfront; rejects ghost curves (<1 wei/sin final period), retroactive starts, or starts >90d future; runs massUpdatePools first; emits EmissionStartedsetEmissionSchedule(uint256 _halvingPeriod, uint256 _numPeriods)- prospective-only config for NEXT campaign; enforces_halvingPeriod >= 1 dayand<= MAX_HALVING_PERIOD, _numPeriods in [1,64]setRewardToken(IERC20 _rewardToken)- change reward token pre-launch (no campaign started, no pools); validates escrow bindingsetVestingEscrow(address _vestingEscrow)- swap escrow and reward token atomically pre-launchsetFeeRecipient(address _feeRecipient)- set deposit fee destinationsetTrustedZapper(address _trustedZapper)- exempt one zap-and-stake router from third-party guard on top-ups. ONE-SHOT: a second call reverts TrustedZapperAlreadySet, zero reverts ZeroAddress
Upgrade path:
upgradeAuthority() view returns (address),setUpgradeAuthority(address newAuthority)- same split as RakebackV2renounceUpgradeability()- freeze implementation; one-way; gated on the upgrade authority
Pool Management (onlyOwner):
add(uint256 _allocPoint, IERC20 _lpToken, uint16 _depositFeeBps)- add new pool (no duplicate LPs); runs massUpdatePools; reverts if depositFeeBps > MAX_DEPOSIT_FEE_BPS or already MAX_POOLS; emits PoolAddedset(uint256 _pid, uint256 _allocPoint, uint16 _depositFeeBps)- update pool config; runs massUpdatePools first; emits PoolSetsetMany(uint256[] calldata _pids, uint256[] calldata _allocPoints)- retune several pools' allocation with one settle; LengthMismatch on empty or unequal arrays; emits PoolSet per poolmassUpdatePools()- settle all pools (gas-intensive; O(poolCount))updatePool(uint256 _pid)- settle one pool
Staking:
deposit(uint256 _pid, uint256 _amount) nonReentrant- stake LP for callerdepositFor(uint256 _pid, uint256 _amount, address _user) nonReentrant- stake LP for_user. A third party may only open a NEW position: if_useralready has a stake in the pool it reverts Unauthorized unless the caller is_userortrustedZapper. A third-party call with_amount == 0returns silentlyharvest(uint256 _pid) nonReentrant- settle and pay the caller's pending reward without changing stake; behaviourally identical todeposit(_pid, 0), split out so a wallet shows "Harvest" rather than "Deposit" in the confirmationwithdraw(uint256 _pid, uint256 _amount) nonReentrant- queue a withdrawal: settles rewards and setscooldownEnd = min(now + COOLDOWN, emissionEnd), ornowwhencooldownWaivedFor(_pid). One queued withdrawal per (user, pool): while one is queued,withdraw,deposit,depositForandharvestrevert CooldownActiveclaimWithdrawal(uint256 _pid) nonReentrant- pay the queued withdrawal at or aftercooldownEndemergencyWithdraw(uint256 _pid) nonReentrant- return the whole ACTIVE stake instantly and forfeit pending rewards (RewardForfeited). An amount already queued bywithdrawis untouched and still exits throughclaimWithdrawal
Views:
stakedBalance(uint256 _pid, address _user) view returns (uint256)- actively-staked LP amountpendingReward(uint256 _pid, address _user) view returns (uint256)- settled but unclaimed rewardavailableReward() view returns (uint256)- reward tokens left to distribute (excludes staked + cooldown principal)recoverableReward() view returns (uint256)- uncredited emission (owed to no one)poolLength() view returns (uint256)emittedByTime(uint256 _t) view returns (uint256)- closed-form cumulative emission up to time _temissionBetween(uint256 _from, uint256 _to) view returns (uint256)- emission in time windowcurrentRewardPerSecond() view returns (uint256)- live per-second rate (0 outside window)cooldownWaived() view returns (bool)- true when no campaign emitting (no start, scheduled-not-begun, or ended)cooldownWaivedFor(uint256 _pid) view returns (bool)-cooldownWaived(), or the pool (or every pool) has zero allocation. Whatwithdrawactually usesHALVING_PERIOD(), NUM_PERIODS() view- next campaign schedule (back-compat)
Harvest & Recovery:
recoverUndistributedRewards(address _to, uint256 _amount) onlyOwner- recover emission that credited no pool (after emissionEnd + HARVEST_GRACE); bounded by recoverableReward(); emits UndistributedRewardsRecovered
Events
event PoolAdded(indexed uint256 pid, indexed address lpToken, uint256 allocPoint, uint16 depositFeeBps, uint256 totalAllocPoint)event PoolSet(indexed uint256 pid, uint256 allocPoint, uint16 depositFeeBps, uint256 totalAllocPoint)event PoolUpdated(indexed uint256 pid, uint256 accRewardPerShare, uint256 lastRewardTime)event Deposit(indexed address user, indexed uint256 pid, uint256 amount)event WithdrawQueued(indexed address user, indexed uint256 pid, uint256 amount, uint64 cooldownEnd)event WithdrawClaimed(indexed address user, indexed uint256 pid, uint256 amount)event EmergencyWithdraw(indexed address user, indexed uint256 pid, uint256 amount)event RewardPaid(indexed address user, indexed uint256 pid, uint256 amount)- the FULL reward, both the liquid half and the half sent to the escrowevent RewardShortfall(indexed address user, indexed uint256 pid, uint256 owed, uint256 paid)- the pot could not cover a payout;paidwent out, the rest was forfeitedevent RewardForfeited(indexed address account, indexed uint256 pid, uint256 amount)- pending reward abandoned by emergencyWithdrawevent EmissionStarted(uint256 totalReward, uint256 initialPeriodReward, uint64 startTime, uint64 endTime)event FeeRecipientUpdated(address feeRecipient)event EmissionScheduleSet(uint256 halvingPeriod, uint256 numPeriods)event RewardTokenSet(address rewardToken)event VestingEscrowSet(address vestingEscrow)event TrustedZapperSet(address trustedZapper)event UndistributedRewardsRecovered(address to, uint256 amount)
Custom Errors
error RewardsExhausted()-recoverUndistributedRewardsaboverecoverableReward(). A user payout never reverts with it: a short pot pays what it has and emits RewardShortfallerror CooldownActive()- deposit, depositFor, harvest or withdraw while a withdrawal is queued on that poolerror CooldownNotElapsed()- claimWithdrawal beforecooldownEnderror LengthMismatch()- setMany with empty or unequal arrayserror TrustedZapperAlreadySet()- second setTrustedZappererror NoPendingWithdrawal()- claimWithdrawal with no queued withdrawalerror DuplicatePool()- add tries to add LP token already in a poolerror InvalidPool()- _pid >= poolInfo.lengtherror DepositFeeTooHigh()- depositFeeBps > MAX_DEPOSIT_FEE_BPSerror InsufficientStaked()- withdraw amount > staked balanceerror ZeroAddress()- init or config received zeroerror Unauthorized()- depositFor third-party top-up without trustedZapper exemptionerror EmissionActive()- startEmission or setEmissionSchedule while a campaign is running; recovery inside the window plus graceerror InvalidEmission()- startEmission with ghost curve, past time, or too-far-futureerror TooManyPools()- pool count would exceed MAX_POOLSerror PoolsExist()- setRewardToken while pools existerror CampaignRan()- setRewardToken after campaign startederror RewardTokenMismatch()- escrow token != reward tokenerror HalvingPeriodTooLong()- _halvingPeriod > MAX_HALVING_PERIOD
Notes
- Emissions are TIME-BASED (not block-based); immune to block-time drift
- Halving curve: period k emits (initialPeriodReward >> k) spread linearly over halvingPeriod
- add/set ALWAYS call massUpdatePools first (no _withUpdate flag); settled balances are the only safe state for allocation changes
- Reward-token principal is tracked separately (totalStakedRewardToken) and never paid as reward
- The unstake cooldown is
min(now + 24h, emissionEnd)snapshotted at queue time: whichever comes FIRST. Waived when no campaign is emitting or the pool has zero allocation - Reward payout:
vested = amount / 2goes toRewardVestingEscrow.depositFor(user, vested),amount - vestedis transferred liquid, so an odd wei favors the liquid half
RewardVestingEscrow (packages/contracts/src/chef/RewardVestingEscrow.sol)
Per-user 180-day linear vesting for farm rewards. Folding grant (O(1) storage): claim pays released + banked, depositFor banks released-unclaimed and re-spreads unreleased+new over a fresh 180 days. UUPS proxy. Deployed on Ethereum at /config.addresses.rewardVestingEscrow.
Constructor / Init
initialize(IERC20 _token, address owner_, address upgradeAuthority_)- token is set once and has no setter. The deployed implementation predates the third argument, the upgrade-authority functions and two-step ownership: those are not live yet and arrive with a contract upgrade
Constants
VEST_DURATION = 180 days
State Variables
token: IERC20- the vested token (MOTO); no settergrants[user]: Grant- ONE schedule per user:`{ total, claimed, withdrawable, start }`isDepositor[address => bool]- who may call depositFor (MasterChef, VampChef)
External / Public Functions
Config (onlyOwner):
setDepositor(address depositor, bool allowed)- authorize or revoke a depositor; emits DepositorSet
Upgrade path:
upgradeAuthority() view returns (address),setUpgradeAuthority(address newAuthority)- same split as RakebackV2. Not live yet, arrives with a contract upgraderenounceUpgradeability()- freeze implementation; one-way; gated on the upgrade authority
Core (depositor-gated deposit, user-only claim):
depositFor(address user, uint256 amount) nonReentrant- depositor-gated (isDepositor[msg.sender], else NotDepositor): pull amount from caller, bank released-unclaimed from old schedule, re-spread unreleased+amount over fresh 180d; emits Depositedclaim() nonReentrant returns (uint256 amount)- pay caller's released + banked, without resetting clock; emits Claimed
Views:
claimable(address account) view returns (uint256)- what claim() would pay now (released + banked)vesting(address account) view returns (uint256)- unreleased tail of current schedulevestEndOf(address account) view returns (uint64)- when the account's CURRENT schedule finishes (start + 180 days); zero when there is no grant. Any later depositFor moves it, which is why the UI shows it before a user confirms a harvest
Events
event Deposited(indexed address user, uint256 amount, uint256 scheduleTotal, uint64 start)event Claimed(indexed address user, uint256 amount)event DepositorSet(indexed address depositor, bool allowed)
Custom Errors
error ZeroAddress()- init received zeroerror ZeroAmount()- depositFor called with zeroerror NotDepositor()- depositFor called by an address that is not on the allowlist
Notes
- No escape hatch: only the user can claim, so only the user can exit
- Repeated dust deposits geometrically delay tail release (accepted: cost to griefer is gas+dust, harm is negligible timing shift)
- MasterChef _safeRewardTransfer does the 50/50 split and depositFor call atomically
VampChef (packages/contracts/src/chef/VampChef.sol)
Deployed on Ethereum at /config.addresses.vampChef. The launch farming contract, contract A of the two-contract farm design. Distributes a fixed, pre-minted MOTO pool to stakers of Uniswap V2 LP tokens, and of MOTO on its own in pool 1, pro-rata by allocation points and time, over a single fixed 14-day window, then goes cold; that window has closed. Its counterpart contract is MasterChef (contract B), built for a halving farm on Motoswap LP; it is deployed, no emission campaign is running there, and none is planned. UUPS proxy.
Deliberately a trimmed MasterChef. Both share the same accRewardPerShare x time x allocPoint accrual core; the launch farm drops the rest rather than mode-switching it. Emission is FLAT (rewardPerSecond = budget / DURATION), not a 42-day halving, so the launch farming APR is easy to read. There is NO unstake cooldown queue: withdraw settles rewards and returns principal in the same call, because a 24h cooldown only protects a long distribution. There are NO deposit fees on any of its pools, the single-sided MOTO pool included. What is kept verbatim is what is a safety property rather than phase policy: reward-token-principal tracking, the RewardsExhausted backstop, emergencyWithdraw, the MAX_POOLS bound, and the one-way renounceUpgradeability.
Constructor / Init
constructor()- disables initializersinitialize(IERC20 _rewardToken, address _vestingEscrow, address _owner)- wire the pre-minted reward token and the escrow that streams the vested half. Reverts RewardTokenMismatch unless_rewardToken == vestingEscrow.token(), since a mismatch would send half of every harvest into an escrow denominated in a different token
Constants
ACC_PRECISION = 1e12DURATION = 14 days- the single fixed emission window, flat over its whole lengthMAX_START_DELAY = 90 days- bound on a scheduled start; there is no cancel path, so a wildly-future start would lock the pulled budgetHARVEST_GRACE = 30 days- window after emissionEnd during which recovery stays shut, so every staker has a published stretch of time to harvest firstMAX_POOLS = 500
State Variables
rewardToken: IERC20- the pre-minted reward token (MOTO)vestingEscrow: IRewardVestingEscrow- streams the vested 50% of every harvest over 180 daysrewardBudget: uint256- the pulled budget (300M at launch)rewardPerSecond: uint256-rewardBudget / DURATION. TherewardBudget % DURATIONwei remainder is un-emitted dustemissionStart, emissionEnd: uint64- the window.emissionStart == 0means no campaign has ever startedtotalStakedRewardToken: uint256- reward-token principal staked across all pools; never paid as rewardtotalCredited, totalHarvested, forfeitedReward: uint256- reward credited into pools, actually paid out, and abandoned by emergencyWithdraw.totalCredited - totalHarvested - forfeitedRewardis the stakers' outstanding claimpoolInfo[]: PoolInfo-`{ lpToken, allocPoint, lastRewardTime, accRewardPerShare, lpSupply }`userInfo[pid => mapping(user => UserInfo)]-`{ amount, rewardDebt }`totalAllocPoint: uint256poolExistence[address => bool]- which LP tokens already have a pooltrustedZapper: address- the one contract allowed to deposit on a user's behalf (UniswapZap). Set once
External / Public Functions
Campaign and pool config (onlyOwner):
startEmission(uint256 _budget, uint64 _startTime)- start the single campaign. Pulls_budgetup front viatransferFrom(approve first), runs massUpdatePools, setsemissionEnd = start + 14 days. One-shot: reverts EmissionActive onceemissionStartis set. Rejects a ghost curve (_budget / DURATION == 0, which also covers zero), a retroactive start, and a start more than MAX_START_DELAY out._startTime == 0means nowsetRewardToken(IERC20 _rewardToken)- swap the reward token before the farm ever runs; locked out once a campaign has started (CampaignRan) or any pool exists (PoolsExist). Must keep matchingvestingEscrow.token(), and there is no setter to re-point the escrowadd(uint256 _allocPoint, IERC20 _lpToken)- add a pool. No duplicate LP tokens, no deposit-fee argument. Runs massUpdatePools first, since changing totalAllocPoint without settling re-weights every existing pool's elapsed intervalset(uint256 _pid, uint256 _allocPoint)- retune allocation; also settles firstsetMany(uint256[] calldata _pids, uint256[] calldata _allocPoints)- retune several pools with one settle; LengthMismatch on empty or unequal arrayssetTrustedZapper(address _trustedZapper)- name the trusted zapper. ONE-SHOT: no re-point, no clear; a second call reverts TrustedZapperAlreadySetrenounceUpgradeability()- freeze the implementation; one-way; onlyOwner
Staking:
deposit(uint256 _pid, uint256 _amount) nonReentrant- stake LP, harvesting pending first. Credits the realized balance delta, so a fee-on-transfer LP token is accounted at what actually arriveddepositFor(uint256 _pid, uint256 _amount, address _user) nonReentrant- deposit and credit_user. A third party may only open a NEW position: if_useralready has a stake in the pool it reverts Unauthorized unless the caller is_userortrustedZapper. A third-party call with_amount == 0returns silentlyharvest(uint256 _pid) nonReentrant- settle and pay pending without changing stake. Behaviourally identical todeposit(_pid, 0), split out so a wallet shows "Harvest" instead of "Deposit" in the confirmation. Self-onlywithdraw(uint256 _pid, uint256 _amount) nonReentrant- harvest and return principal in the same call. No cooldown. Reverts InsufficientStaked on zero or more than stakedemergencyWithdraw(uint256 _pid) nonReentrant- take principal immediately, forfeiting all pending reward. The forfeited amount is recorded so it stops counting as an outstanding claim forever
Settlement:
massUpdatePools()- settle every pool; O(pools)updatePool(uint256 _pid)- settle one pool
Views:
poolLength() view returns (uint256)stakedBalance(uint256 _pid, address _user) view returns (uint256)pendingReward(uint256 _pid, address _user) view returns (uint256)emittedByTime(uint256 _t) view returns (uint256)- cumulative emission by time_tacross all pools, before the per-pool split. Linear, clamped to the window: 0 before the start,rewardPerSecond * DURATIONafter the endemissionBetween(uint256 _from, uint256 _to) view returns (uint256)- emission in a window; 0 outside it, so accrual auto-stopscurrentRewardPerSecond() view returns (uint256)- live rate, 0 outside the windowavailableReward() view returns (uint256)- reward-token balance less staked reward-token principalrecoverableReward() view returns (uint256)-availableReward()less the stakers' outstanding claim. As a plain view it settles only to each pool's last updatePool, so it can read slightly high between settlements
Recovery:
recoverUndistributedRewards(address _to, uint256 _amount) onlyOwner- recover emission that streamed while a pool sat at zero stake (ortotalAllocPoint == 0) and so was credited to nobody. Runs massUpdatePools first, then bounds the amount byrecoverableReward(), so it can never reach staked principal or an accrued reward. Gated toblock.timestamp >= emissionEnd + HARVEST_GRACE, unless no campaign ever ran - VampChef is one-shot, so without that second clause any token mis-sent to a never-started deployment would be permanently unrecoverable
Events
event PoolAdded(indexed uint256 pid, indexed address lpToken, uint256 allocPoint, uint256 totalAllocPoint)event PoolSet(indexed uint256 pid, uint256 allocPoint, uint256 totalAllocPoint)event PoolUpdated(indexed uint256 pid, uint256 accRewardPerShare, uint256 lastRewardTime)event Deposit(indexed address user, indexed uint256 pid, uint256 amount)event Withdraw(indexed address user, indexed uint256 pid, uint256 amount)event EmergencyWithdraw(indexed address user, indexed uint256 pid, uint256 amount)event RewardPaid(indexed address user, indexed uint256 pid, uint256 amount)- the FULL reward, both halvesevent RewardShortfall(indexed address user, indexed uint256 pid, uint256 owed, uint256 paid)- the pot could not cover a payout;paidwent out, the rest was forfeitedevent RewardForfeited(indexed address user, indexed uint256 pid, uint256 amount)event TrustedZapperSet(address trustedZapper)event EmissionStarted(uint256 budget, uint256 rewardPerSecond, uint64 startTime, uint64 endTime)event RewardTokenSet(address rewardToken)event UndistributedRewardsRecovered(address to, uint256 amount)
Custom Errors
error RewardsExhausted()- recovery exceeds recoverableReward. A user payout never reverts with it: a short pot pays what it has and emits RewardShortfallerror LengthMismatch()- setMany with empty or unequal arrayserror TrustedZapperAlreadySet()- second setTrustedZappererror Unauthorized()- depositFor top-up of someone else's existing positionerror DuplicatePool()- add for an LP token that already has a poolerror InvalidPool()-_pid >= poolInfo.lengtherror InsufficientStaked()- withdraw of zero, or more than stakederror ZeroAddress()- init, add, depositFor, setTrustedZapper or recovery received zeroerror EmissionActive()- startEmission called twice, or recovery inside the window plus graceerror InvalidEmission()- ghost curve, retroactive start, or a start beyond MAX_START_DELAYerror TooManyPools()- pool count would exceed MAX_POOLSerror PoolsExist()- setRewardToken while pools existerror CampaignRan()- setRewardToken after the campaign startederror RewardTokenMismatch()- reward token differs fromvestingEscrow.token()
Notes
- Every harvest pays 50% liquid and folds 50% into the same 180-day RewardVestingEscrow MasterChef uses; odd wei favours the liquid half
- Pre-minted: the whole budget is pulled at startEmission, so a RewardShortfall is a backstop, never a normal path
- One campaign, ever. The launch farming window does not restart
- If a single-sided MOTO pool is added, its staked principal is reserved out of availableReward and can never be paid as reward
addat 499 existing pools measures around 483k gas, roughly 1.6% of a 30M block, because massUpdatePools skips pools with nothing staked
MotoStaking (packages/contracts/src/stake/MotoStaking.sol)
MOTO staking with lock options (3/6/12/24 months) at boosted weights (1x/2x/4x/8x). Multi-token rewards (quote assets) streamed per reward token. Principal vests linearly; requestUnstake queues into 30-day linear release. UUPS proxy. Deployed on Ethereum at /config.addresses.motoStaking. The deployed implementation predates parts of this source: addRewardToken, the upgrade-authority functions and the two-argument initializeV2 are not live yet and arrive with a contract upgrade. Its feeRouter() is a 0x…dEaD placeholder, so claimAsMoto is not live yet either.
Constructor / Init
constructor()- disables initializersinitialize(address initialOwner, address moto_, address router_, address feeRouter_, address treasury_)- set MOTO, routers, treasury for unclaimed rewards when no stake; defaults: durations=[90d,180d,365d,730d], boosts=[0,10000,30000,70000]
Constants
UNSTAKE_DRIP = 30 days- linear unstake release windowMAX_REWARD_TOKENS = 32MAX_BOOST_BPS = 100_000- ceiling onsetBoost(11x; the shipped ladder tops out at 70_000)MAX_OPEN_TICKETS = 64- cap on a wallet's concurrent unstake release ticketsMAX_PURGE_RESIDUAL = 1e6- hard ceiling onpurgeRewardToken'smaxResidualNUM_OPTIONS = 4- lock tiersACC = 1e30- reward accumulator precision (private)BPS = 10_000(private)
State Variables
moto: IERC20- staked principal and reward tokenlegacyRouter: IMotoSwapRouter02- the router address passed toinitialize, recorded for continuity and never read.claimAsMotoswaps throughfeeRouterfeeRouter: IFeeRouter- the swap venueclaimAsMotoroutes through, installed byinitializeV2treasury: address- reward destination when no effective stakedurations[4]: uint256- lock lengths per tierboostBps[4]: uint256- extra weight per tier (prospective-only; snapshots at open). Weight isamount * (BPS + boostBps) / BPS, so the multiplier is1 + boostBps / 10000rewardNotifier: address- allowed to call notifyReward (Collector)vestAnchor: uint64- optional vest-clock anchor for imported stakers (non-retroactive)positions[user]: Position-`{ amount, original, durationOption, start, boostBps, duration, availableFloor }`;durationandboostBpsare snapshotted at open so a later config change never reprices a live position, andavailableFlooris the withdrawable figure crystallised at append time soavailable()can never falltickets[user][]: Ticket[]- unstaking release streams`{ amount, start, claimed }`totalEffectiveStake: uint256- sum of all positions' effective stake (with boosts)rewardTokens[]: address[]- enumerable reward tokensisRewardToken[token => bool]- whether token is activeaccRewardPerEffShare[token => uint256]- per-effective-share accumulator (scaled by ACC)userRewardDebt[user => mapping(token => uint256)]- snapshot of accRewardPerEffShare at user's last settleclaimableStored[user => mapping(token => uint256)]- settled reward ready to claimwasRewardToken[token => bool]- delisted but not yet purged (stays in settle loop)everRewardToken[token => bool]- ever been a reward token; permanent flag (prevents re-listing)pendingClaims[token => uint256]- the sum of settled, unclaimedclaimableStoredfor the tokenrewardOwed[token => uint256]- reward notified in minus reward paid out; what a purge must leave alone
External / Public Functions
Config (onlyOwner):
initializeV2(address feeRouter_, address upgradeAuthority_) onlyOwner reinitializer(2)- not live yet (the deployed implementation has a one-argument form). The v1 to v2 upgrade step, meant to run insideupgradeToAndCall: installs the FeeRouter thatclaimAsMotoswaps through and the upgrade authority; emits FeeRouterSet.legacyRouteris deliberately left untouchedaddRewardToken(address token)- approvetokenas a reward the notifier may pay in. Refuses an address that was ever delisted, refuses pastMAX_REWARD_TOKENS, RewardTokenAlreadyApproved on a repeat. Not live yet, arrives with a contract upgradeclaimReward(address account, address token) nonReentrant returns (uint256 amount)- pushaccount's settled reward toaccount. Exists so one abandoned wallet cannot block a purge; it can only ever pay the stakersetRewardNotifier(address notifier)- set fee Collector (re-pointable)setVestAnchor(uint64 anchor)- set vest-clock anchor (0 disables; bounds checking: 90d future max, < durations[0] in past)setBoost(uint8 durationOption, uint256 bps)- retune boost (prospective-only; capped at MAX_BOOST_BPS)removeRewardToken(address token)- delist from accepts (stays in settle loop until purgeRewardToken)purgeRewardToken(address token, uint256 maxResidual)- drop a fully-drained delisted token from the settle loop, writing off up tomaxResidualof unclaimable residue totreasury. The index truncates in bothnotifyRewardand_settle, so a few wei always remain and an exact-zero gate could never free the slot. MOTO is rejected outright (CannotPurgeMoto) because its balance is staked principal
Upgrade path:
upgradeAuthority() view returns (address),setUpgradeAuthority(address newAuthority)- same split as RakebackV2. Not live yet, arrives with a contract upgraderenounceUpgradeability()- freeze implementation; one-way; gated on the upgrade authority
Staking:
stake(uint256 amount, uint8 durationOption) nonReentrant- open new position (one active per user); reverts if position existsappendStake(uint256 amount) nonReentrant- add to existing position (no duration arg - the tier is fixed at open; lock re-weighted: average remaining + full new lock)requestUnstake(uint256 amount) nonReentrant- queue withdrawal (settles rewards, stops earning)claimUnstaked() nonReentrant returns (uint256 amount)- collect whatever has dripped from all release ticketsclaimUnstakedFrom(uint256 maxTickets) nonReentrant returns (uint256 amount)- bounded variant for wallets with many ticketsclaim() nonReentrant- claim all reward tokensclaim(address token) nonReentrant returns (uint256 amount)- claim one token's rewardsclaimTokenSelf(address account, address token)- settle a single token for an accountclaimAsMoto(address token, uint256 minMotoOut, address[] calldata path, uint256 deadline) nonReentrant returns (uint256 motoOut)- not live yet, arrives with a contract upgrade (the deployedfeeRouter()is a placeholder). Claim and swap to MOTO through the FeeRouter (path must run token→MOTO);minMotoOutis checked against the MOTO that actually reached the caller (BelowMinMotoOut)bulkImport(address[] calldata accounts, uint256[] calldata amounts, uint8[] calldata durationOptions) onlyOwner nonReentrant- import staker positions; pullssum(amounts)MOTO from the owner; each target must have no active position. Three arrays: there is nostartsargument
Reward Notifications (onlyNotifier):
notifyReward(address token, uint256 amount) nonReentrant- add token rewards (spread per effective stake);tokenmust be on the owner-approved list, else RewardTokenNotApproved; with no effective stake the amount is forwarded totreasury; emits RewardNotified. The deployed implementation lists an unknown token on first notify instead, until the contract upgrade
Views:
pendingReward(address account, address token) view returns (uint256)- user's accrued reward (settled + accruing)vested(address account) view returns (uint256)/available(address account)- vested principal and what requestUnstake can move noweffectiveStakeOf(address account) view returns (uint256)- boosted stake weightticketCount(address account) view returns (uint256)/ticketsLength(address account) view returns (uint256)/claimableUnstaked(address account) view returns (uint256 amount)- unstake-ticket surfacerewardTokensLength() view returns (uint256)
Events
event Staked(indexed address account, uint256 amount, uint8 durationOption)event Imported(indexed address account, uint256 amount, uint8 durationOption)event RewardNotified(indexed address token, uint256 amount, uint256 accRewardPerEffShare)event RewardForwardedToTreasury(indexed address token, uint256 amount)- notifyReward arrived with no effective stake, so it went to treasuryevent Claimed(indexed address account, indexed address token, uint256 amount)event ClaimSkipped(indexed address account, indexed address token)- one token's payout was skipped inside a multi-token claimevent ClaimedAsMoto(indexed address account, indexed address token, uint256 rewardIn, uint256 motoOut)event UnstakeRequested(indexed address account, uint256 amount, uint64 start)event Unstaked(indexed address account, uint256 amount)event Appended(indexed address account, uint256 added, uint256 total, uint8 durationOption, uint64 start)event BoostSet(uint8 durationOption, uint256 bps)event RewardNotifierSet(address rewardNotifier)event FeeRouterSet(address feeRouter)event VestAnchorSet(uint64 anchor)event RewardTokenAdded(indexed address token)event RewardTokenRemoved(indexed address token)event RewardTokenPurged(indexed address token)
Custom Errors
error ZeroAmount()error MaxRewardTokensReached()error InvalidDuration()error PositionExists()- stake when position already openerror NoPosition()- appendStake without active positionerror ExceedsAvailable()- withdraw > unlocked principalerror TooManyOpenTickets()- requestUnstake would exceed MAX_OPEN_TICKETSerror LengthMismatch()error ZeroAddress()error NotNotifier()error BadPath()- claimAsMoto path invaliderror BelowMinMotoOut(uint256 received, uint256 floor)- claimAsMoto delivered less MOTO than the caller's floorerror OnlySelf()error InvalidAnchor()error NotRewardToken()- removeRewardToken on non-reward tokenerror TokenPermanentlyDelisted()- try to re-listerror RewardTokenNotApproved(address token)- notifyReward for a token the owner has not approvederror RewardTokenAlreadyApproved(address token)- addRewardToken for a listed tokenerror ResidualCeilingTooHigh(uint256 requested, uint256 cap)- purgeRewardToken withmaxResidualaboveMAX_PURGE_RESIDUALerror TokenNotDelisted()- purgeRewardToken on non-delistederror TokenBalanceNotZero()- purgeRewardToken while the free balance exceeds maxResidualerror BoostTooHigh()- setBoost above MAX_BOOST_BPSerror CannotPurgeMoto()- purgeRewardToken on MOTO, whose balance is staked principal
Notes
- One active position per user; appendStake re-weights remaining lock with full new lock (average by principal)
- Principal vests linearly from (anchor if set and valid, else start) over lock duration
- Unstaking streams principal over 30 days (separate Ticket per request); requestUnstake settles rewards but queued principal stops earning
- Reward accounting is index-based (never balanceOf); per-reward-token accumulators prevent principal ever being paid as reward
- claimAsMoto goes through FeeRouter (pays protocol fee like any other trade)
- MOTO is BOTH principal AND valid reward token; internal accounting keeps them separate
- removedRewardToken stays in settle loop until fully drained and purged (prevents re-list accumulator-reuse bug)
PveVault (packages/contracts/src/launcher/PveVault.sol)
Escrow for PvE-bought tokens (bought with the creator's own ETH at launch, reserved for Motocat stakers). Read its address from /config.addresses.pveVault. PvE is claimed on Motoswap's staking page (/stake/motocats). The claim surface is claimable(address token, address wallet) (note the token-first argument order), claim(address token) and claimMany(address[] tokens). Eligibility is snapshotted via stakedBalanceOfAt(wallet, creditBlock - 1) on MotocatStaking, so it is the wallet's cat balance when the credit landed that counts. pending(address token) is a per-TOKEN amount awaiting credit, not a per-wallet claimable balance. UUPS proxy.
Constructor / Init
initialize(address initialOwner, address upgradeAuthority_)- set owner and the upgrade authority (zero leaves upgrades with the owner)
State Variables
launcher: address- the primary launch contract allowed to record PvE receiptsextraLaunchers[address => bool]- additional authorized launchers (moto.fun's LaunchpadCurve), additive tolaunchermotocatStaking: address- the staking contract whose historical stake sizes a splittotalReceived[token => uint256]- cumulative PvE tokens received per token (indexer stats)credits[token => Credit]-`{ uint128 amount, uint64 totalStaked, uint64 creditBlock }`; ONE credit per token, ever, packed into a single slotclaimed[token => mapping(wallet => bool)]- whether a wallet has taken its sharepending[token => uint256]- escrow that landed while nobody was staked, awaitingrecreditPending. A per-TOKEN amount, not a per-wallet claimable balancestakingWiringFrozen: bool- true once the first credit is frozen, after whichsetMotocatStakingis shutexpectingArrival[token => bool]- tokens a launcher has announced escrow for that are not credited yet. The moto.fun curve escrows at fill time and the receipt only follows at graduation; the marker keepsforwardandrescueExcessoff that balance in betweenescrowFloor[token => uint256]- the balance the vault held when a marked token's escrow was credited; the rescue ceiling reads this rather than the receipt
External / Public Functions
Config (onlyOwner):
setLauncher(address launcher_)- wire the primary launchersetExtraLauncher(address launcher_, bool allowed)- authorize or revoke an additional launchersetMotocatStaking(address staking)- wire the staking contract a split reads history from; rejects a zero address and an address with no code, and reverts StakingWiringFrozen once any credit existsrecreditPending(address token)- turn a held escrow into a claimable credit at today's stake. Owner-only because the caller picks the block, which is the whole basis of the preceding-block anti-JIT ruleforward(address token, address to, uint256 amount) nonReentrant- move a token the vault owes nobody. Refuses outright (TokenIsCredited) for any token with a credit or a pending holdclearPendingArrival(address token)- clear an arrival marker for a token this vault holds nothing for (a coin that never graduates); emits PendingArrivalClearedByOwnerrescueExcess(address token, address to) nonReentrant returns (uint256 amount)- recover a stray transfer of a token the vault is ALREADY paying out. Moves only the balance abovecredit.amount + pending; NoExcessToRescue when there is none
Upgrade path:
upgradeAuthority() view returns (address),setUpgradeAuthority(address newAuthority)- same split as RakebackV2renounceUpgradeability()- freeze implementation; one-way; gated on the upgrade authority
Core (launcher-only):
notePendingArrival(address token)- mark a token whose escrow is about to arrive by plain transfer. One-shot per token: AlreadyExpecting if marked, AlreadyCredited if credited or pendingrecordPve(address token, address creator, uint256 amount)- launcher-only receipt record, which also credits the escrow with no owner action. Checksamountagainst the vault's actual balance rather than trusting it, then sizes the split againsttotalStakedAt(block.number - 1). Emits PveReceived, then PveCredited or PveHeld
Views and claims:
rescuableExcess(address token) view returns (uint256)- whatrescueExcesscould move right now; zero for a token with no creditclaimable(address token, address wallet) view returns (uint256)- the wallet's share,credit.amount * stakedBalanceOfAt(wallet, creditBlock - 1) / credit.totalStaked. Zero once claimed or if the wallet was not staked at the credit block. Note the token-first argument orderclaim(address token) nonReentrant returns (uint256 amount)- claim one token's share; reverts NothingToClaim on zeroclaimMany(address[] tokens) nonReentrant returns (uint256 total)- claim across several tokens. Bounded by the caller's array, never by global state, which is what keeps the PvE token count unlimited. Entries with nothing to claim are skipped, but the call still reverts if the whole batch yields nothing
Events
event LauncherSet(address launcher)event ExtraLauncherSet(indexed address launcher, bool allowed)event MotocatStakingSet(address staking)event PveReceived(indexed address token, indexed address creator, uint256 amount)event PveCredited(indexed address token, uint256 amount, uint256 totalStaked, uint256 creditBlock)event PveHeld(indexed address token, uint256 amount)event PveClaimed(indexed address token, indexed address wallet, uint256 amount)event Forwarded(indexed address token, indexed address to, uint256 amount)event ExcessRescued(indexed address token, indexed address to, uint256 amount)event PendingArrivalNoted(indexed address token)event PendingArrivalCleared(indexed address token)- cleared by the receiptevent PendingArrivalClearedByOwner(indexed address token, indexed address by)
Custom Errors
error ZeroAddress()- a setter or forward received zeroerror NotLauncher()- recordPve called by an address that is neitherlaunchernor an extra launchererror StakingNotSet()- recreditPending with no staking contract wirederror AlreadyCredited()- recordPve for a token that already has a credit or a pending holderror AmountNotReceived()- the vault holds less than the recordedamounterror NoCredit()- recreditPending on a token with nothing helderror AlreadyClaimed()- the wallet already took its shareerror NothingToClaim()- claim or claimMany yielded zeroerror PoolStillEmpty()- recreditPending while nobody is stakederror SeedingNotClosed()- the staking contract's seed is still in flight, so its supply is not the allocation table'serror NotAContract(address target)- setMotocatStaking pointed at an address with no codeerror StakingWiringFrozen()- setMotocatStaking after a credit existserror TokenIsCredited(address token)- forward on a token owed to stakerserror ValueTooLarge()- amount above uint128 or staked count above uint64error TableMovedThisBlock()- a stake or unstake landed in the same block as the credit, so the snapshot is not final. Retry in the next blockerror NoExcessToRescue(address token)error AlreadyExpecting(address token)/error NotExpecting(address token)- arrival-marker gates
Notes
- Payouts are pull-based and lazy, and PvE credits never expire. There is no sweep and no treasury reclaim of a credited amount
- A credit landing while nobody is staked, while staking is unwired, or mid-seed is HELD rather than reverted: a creator's paid launch must never fail over an operational condition they cannot see
claimabletruncates toward the pool (the standard MasterChef rule), so a small permanent remainder always survives. That residue is correct, not drift, and it is deliberately strandeddepositToStakingand itsDepositedToStakingevent were REMOVED with the staking contract's streamed-rewards track.MotocatStakingV3has nodepositRewardto push into
MotocatStakingV3 (packages/contracts/src/nft/MotocatStakingV3.sol)
Deployed on Ethereum at /config.addresses.motocatStaking, with the seed closed. Escrow staking for the Motocat collection: a stake ledger, historical checkpoints, a one-time seeding phase for distribution day, and an L1 to L2 broadcast hook for future networks (no outbox is registered on Ethereum today). It distributes nothing. 1 staked cat = 1 share, no rarity weighting. UUPS proxy behind an owner-gated upgrade path with a one-way renounceUpgradeability() switch back to immutable.
The streamed-rewards surface is gone
Earlier revisions of this page documented a reward-streaming contract - an owner-curated allowlist, depositReward, per-token accRewardPerShare, rewardDebt, pending, strandedRewards, a JIT warmup, claim, and an owner pause. None of that exists in the deployed V3. PvE airdrops are the only reward surface and PveVault pays them against a frozen per-token snapshot, reading this contract's stakedBalanceOfAt and totalStakedAt. There is no depositReward, no claim, no pause, and no rewardWarmupBlocks here. If you are porting an integration off the old docs, that is the change to plan around.
The deletion is structural, not tidy-up. A MasterChef-shaped accumulator has to re-baseline every staker's rewardDebt for every token ever issued on every balance change, so stake and unstake grew with the token count. Measured on V2, a seeded holder's partial exit cost 2,259,022 gas at 32 reward tokens and 8,781,454 at 128, against EIP-7825's per-transaction ceiling of 16,777,216. With an unlimited number of PvE tokens that pattern is not mitigable. The invariant V3 holds instead: no operation's gas depends on how many PvE tokens have ever been airdropped. stake and unstake scale only with the cats in the call plus two checkpoint slots, and every remaining loop is bounded by an array the caller passes, so hitting a gas ceiling can only ever mean "send a smaller batch".
Anti-JIT moved with it. PveVault sizes a credit against totalStakedAt(creditBlock - 1), so staking in the credit's own block is worthless, which is why the warmup window has no successor.
Constructor / Init
constructor()- disables initializers on the implementationinitialize(IERC721 collection_, address initialOwner)- one-shot. Rejects a zero collection AND one with no code (collectionhas no setter, so a proxy initialised against an EOA is bricked for good), and a zero owner. Must run in the same transaction as the proxy deployment
State Variables
collection: IERC721- the escrowed collection, written once at initialize. Deliberately storage rather thanimmutable, so an upgrade cannot silently re-point the escrowtotalStaked: uint256- cats currently staked across all walletsstakerOf[tokenId => address]- who staked each cat; zero when not staked hereseedingClosed: bool- one-way latch. False during setup, true forever aftercloseSeedingseeder: address- the address permitted to callseedStakesandcloseSeeding(the MotocatSeeder). Deliberately not the owner, though the owner can call both paths directlyseedSource: address- the walletseedStakespulls cats from when the contract does not already hold themoutboxes: address[]- registered L1 to L2 outbox adapters, one per destination chain. May be emptyisOutbox[address => bool]- guards against registering the same adapter twice_stakeCheckpoints[wallet], _totalStakeCheckpoints- private append-only`Checkpoint { uint32 fromBlock, uint224 value }`arrays, the OZ ERC20Votes shape: one entry per block that changed the value, packed into a single slot. Several stakes in one block collapse to one entry_staked[wallet => uint256[]],_stakedIndex[tokenId => uint256]- private enumeration and its swap-and-pop index
The reentrancy guard and the upgrade-renounced flag live in ERC-7201 namespaced slots rather than sequential storage, deliberately: the contract is compiled against two different OpenZeppelin versions across two repos, and OZ's own ReentrancyGuard moved its storage between them, which behind a proxy would shift every field above by one slot.
External / Public Functions
Staking (permissionless, never pausable):
stake(uint256[] calldata tokenIds) nonReentrant- pull each cat from the caller into escrow and record it staked to them. UsestransferFrom, notsafeTransferFrom, so there is no hook back into the caller. Blocked untilseedingClosed. Batch size is uncapped by design: the only bound is the per-transaction gas ceiling, so an oversized call fails and the caller sends a smaller batch. Emits Staked, then broadcastsunstake(uint256[] calldata tokenIds) nonReentrant- return cats to the caller. Instant, no lockup, no fee, no slashing. Only the wallet that staked a cat may unstake it. Never blocked, not even during seeding. A PvE entitlement already credited is unaffected: it was frozen against a past block. Emits Unstaked, then broadcasts
Seeding (owner or seeder, setup phase only):
seedStakes(address[] wallets, uint256[] counts, uint256[] tokenIds) nonReentrant onlySeeder- initialise staked positions from the allocation table.counts[i]consumes the nextcounts[i]ids from the flattokenIds. Writes checkpoints per wallet, without which every seeded holder reads zero fromstakedBalanceOfAtand receives nothing from every PvE credit. Pulls each cat fromseedSourceunless the contract already holds it. Not idempotent: a repeated id reverts AlreadySeeded. Emits both Staked and Seeded per wallet, so the points indexer can tell a seeded position from a wallet's own action. Does NOT broadcastcloseSeeding() onlySeeder- end the setup phase permanently: the seed path dies, publicstakeopens. One-way, no reopensetSeeder(address seeder_) onlyOwner- settable only while seeding is opensetSeedSource(address seedSource_) onlyOwner- settable only while seeding is open
Upgrades and admin (onlyOwner):
renounceUpgradeability()- permanently freeze the implementation. One-way. Ownership, the seeder wiring and the outbox registry keep workingupgradesRenounced() view returns (bool)- whether the implementation can still be replacedrenounceOwnership() view- disabled, always reverts RenounceDisabled. Ownership can still be transferred and accepted
Rescue (onlyOwner, never touches an escrowed cat):
rescueERC20(address token, address to, uint256 amount) nonReentrant- unconditional, and only because the rewards track is gone: this contract has no path that accepts, accounts for or pays out an ERC20, so every ERC20 balance here is a strayrescueERC721(address nft, address to, uint256 tokenId) nonReentrant- a cat from the escrowed collection is recoverable only if it has no recorded staker (a stray that arrived via rawtransferFrom); otherwise reverts CannotRescueStakedCat. Any other collection's NFT is fully recoverablerescueETH(address to, uint256 amount) nonReentrant- the contract has no payable entrypoint, so any ETH here was force-sent and is owed to nobody
Cross-chain broadcast:
addOutbox(address outbox) onlyOwner- register an L1 to L2 adapterremoveOutbox(address outbox) onlyOwner- deregister. Swap-and-pop, so index ordering is not stable across a removaloutboxCount() view returns (uint256)- zero is a valid, expected staterebroadcast(address wallet) nonReentrant- permissionless. Re-sends the wallet's CURRENT staked count to every adapter, re-reading live state rather than replaying a stored message, so the worst an attacker achieves is paying gas to tell every mirror the truth. This is the recovery path for a BroadcastFailedrebroadcastMany(address[] wallets) nonReentrant- permissionless batch form. Exists for the seed:seedStakesbroadcasts nothing, so after distribution day essentially the entire holder base is unknown to every mirror, and a sweep over the allocation table is a precondition of the first L2 PvE launch, not a cleanup. No filtering and no deduplication - a zero-stake wallet still broadcasts0, which is a legitimate correction
An adapter that reverts never blocks a stake: the call is wrapped in try/catch, surfaced as BroadcastFailed, and recovered via rebroadcast. The payload is the wallet's COUNT, not token ids, so cost does not scale with how many cats a whale holds. An empty registry costs one SLOAD.
Views:
stakedBalanceOf(address wallet) view returns (uint256)- live count of cats the wallet has stakedstakedTokensOf(address wallet) view returns (uint256[] memory)- the tokenIds it currently has staked. Cost grows with the walletstakedTokensOfSlice(address wallet, uint256 offset, uint256 limit) view returns (uint256[] memory ids)- a bounded page: up tolimitids starting atoffset, clamped (not reverting) past the end. Not live yet, arrives with a contract upgradestakedBalanceOfAt(address wallet, uint256 blockNumber) view returns (uint256)- how many cats the wallet had staked as of the END ofblockNumber. Reverts FutureLookup for the current or a future blocktotalStakedAt(uint256 blockNumber) view returns (uint256)- total staked as of the end ofblockNumber; the denominator for a PvE split. Same FutureLookup ruleisStaked(uint256 tokenId) view returns (bool)onERC721Received(...) pure- always reverts DirectTransferNotAllowed. Staking must go throughstake, so every escrowed cat maps to a staker who can retrieve it
Events
event Staked(indexed address wallet, uint256[] tokenIds)event Unstaked(indexed address wallet, uint256[] tokenIds)event Seeded(indexed address wallet, uint256[] tokenIds)- one wallet's slice of a seed batch. Emitted alongside Staked so existing consumers keep working while a consumer that cares can tell a seeded position from a wallet's own actionevent SeedingClosed()event SeederSet(indexed address seeder)event SeedSourceSet(indexed address seedSource)event ERC20Rescued(indexed address token, indexed address to, uint256 amount)event ERC721Rescued(indexed address token, indexed address to, uint256 tokenId)event ETHRescued(indexed address to, uint256 amount)event OutboxAdded(indexed address outbox)event OutboxRemoved(indexed address outbox)event Broadcast(indexed address wallet, uint256 stakedCount)event BroadcastFailed(indexed address outbox, indexed address wallet)- the adapter reverted. The stake or unstake it accompanied SUCCEEDED anyway; that chain's mirror is stale for the wallet until someone calls rebroadcastevent UpgradesRenounced()
Custom Errors
error ZeroCollection()- initialize received a zero collection, or one with no codeerror ZeroOutbox()- addOutbox received zeroerror OutboxAlreadyRegistered()error OutboxNotRegistered()error EmptyArray()- stake, unstake, seedStakes or rebroadcastMany called with an empty arrayerror NotStaker(uint256 tokenId)- unstake for a cat not staked by the callererror UnstakeExceedsStaked()- unstake more than stakederror DirectTransferNotAllowed()- safeTransferFrom into this contract rejectederror FutureLookup()- a historical stake lookup for the current or a future block, where the value is not final yeterror CheckpointOverflow()- block number or stake count outside the packed checkpoint range. Unreachable in practiceerror ZeroToken()- rescue called with a zero token or NFT addresserror RenounceDisabled()- renounceOwnership is blockederror CannotRescueStakedCat(uint256 tokenId)- rescueERC721 on a cat with a recorded stakererror ETHTransferFailed()error ZeroRecipient()- initialize or a rescue received a zero recipienterror SeedingNotClosed()- public stake attempted before the latch flipped. Unstake is never shuterror SeedingAlreadyClosed()- a seed-phase call after closeSeedingerror NotSeeder()- caller is neitherseedernor ownererror SeedLengthMismatch()-countsandwalletsdisagree, a count is zero, orcountsdoes not sum totokenIds.lengtherror AlreadySeeded(uint256 tokenId)- the id already has a staker: a repeated id inside one batch, or a re-run batcherror ZeroSeedSource()- a cat must be pulled but no seedSource is wirederror InvalidSeedWallet(address wallet)- a seed row naming address(0) or this contract, both of which permanently strand the catserror ReentrantCall()error UpgradesAreRenounced()- an upgrade attempted after renounceUpgradeability
Notes
- No lockup, no minimum staking time, no unstake fee, no slashing, in any code path
- No pause: stake and unstake are always free actions on V3
- UUPS-upgradeable, owner-gated, with a one-way
renounceUpgradeability()that converts the contract to immutable without disturbing any position seedStakesis sized by GAS, not by cat count: per-wallet cost is fixed at two checkpoint pushes and does not amortise, so 200 cats across 200 wallets does not fit in one transaction where 200 across 10 wallets fits comfortablyunstakeis never gated by the seeding latch. If a seeded holder exits before reconciliation,MotocatSeeder.reconcilereverts on the total mismatch, which is the correct outcome and not a lock-out: the owner can callcloseSeedingdirectly once the discrepancy is understood
MotoToken (packages/contracts/src/token/MotoToken.sol)
Fixed-supply (10B) ERC-20 + EIP-2612 permit. No mint, no burn function, no owner, no pause, no upgrade path. Entire supply minted to recipient at construction. Deployed on Ethereum at /config.addresses.motoToken.
The token carries a launch gate that governed transfers and approvals before trading opened. It is over: tradingOpen() returns true on the deployed token and can never return false again, so for an integrator MOTO is a plain ERC-20. The gate surface is listed because it is in the ABI.
Constructor
constructor(address recipient)- mint INITIAL_SUPPLY torecipient, who is also recorded asDEPLOYER; BadDeploy on zero
Constants / Immutables
INITIAL_SUPPLY = 10_000_000_000 * 1e18DEPLOYER: address- immutableWINDOW: uint256- launch-gate parameter. Read it from the contract
State Variables
PAIR: address- the Uniswap V2 MOTO/WETH pair (/config.addresses.motoUniswapPair)WETH: address,UNWRAPPER: address,GATE_SIGNER: address- bound when the gate was armedHARD_OPEN: uint256,openAt: uint256- gate timestampsgateArmed: bool,armPowerRenounced: boolauthorizationRevoked[address => bool]
External / Public Functions
ERC-20 and permit: name() = "Motoswap", symbol() = "MOTO", decimals() = 18, totalSupply, balanceOf, allowance, approve, transfer, transferFrom, permit, nonces, DOMAIN_SEPARATOR, eip712Domain. Standard OpenZeppelin behaviour while tradingOpen() is true.
Views:
tradingOpen() view returns (bool)- true once the gate is open. One-wayowner() pure returns (address)- always the zero addressquoteBuy(uint256 ethIn) view returns (uint256)/quoteSell(uint256 tokenIn) view returns (uint256)- Uniswap V2 output math (0.30%) againstPAIR's live reservesreserves() view returns (uint256 rToken, uint256 rWeth)-PAIRreserves, token side firstisAuthorized(address account) view returns (bool),gateDigest(address account, uint256 authDeadline) view returns (bytes32)- gate reads
Direct pair access (works after the gate opened; signatures are ignored once tradingOpen()):
buy(bytes calldata sig, uint256 authDeadline, uint256 amountOutMin, uint256 deadline) payable returns (uint256 amountOut)- wrapmsg.valueand swap it for MOTO onPAIR, delivered to the callersell(bytes calldata sig, uint256 authDeadline, uint256 amountIn, uint256 amountOutMin, uint256 deadline) returns (uint256 amountOut)- swap the caller's MOTO for ETH onPAIR, unwrapped throughUNWRAPPER
Gate (historical):
authorize(address account, uint256 authDeadline, bytes calldata sig),authorizeAndTransfer(address to, uint256 amount, uint256 toDeadline, bytes calldata toSig, uint256 fromDeadline, bytes calldata fromSig) returns (bool)- no-ops on the gate once trading is open;authorizeAndTransferthen behaves astransfer
Deployer-only:
armGate(address gateSigner, address factory, address weth, bytes32 pairInitCodeHash),renounceArmPower(),openNow(),setAuthorizationRevoked(address account, bool revoked_),addLiquidity(bytes, uint256, uint256 tokenAmount, uint256 expectedTokensInPair, uint256 minLiquidity, address lpRecipient, uint256 deadline) payable returns (uint256 liquidity)- launch-day operations.openNowandsetAuthorizationRevokedrevert AlreadyOpen now that trading is open
Events
event Transfer(indexed address from, indexed address to, uint256 value),event Approval(indexed address owner, indexed address spender, uint256 value)event Authorized(indexed address account, indexed address by, uint256 authDeadline)event AuthorizationRevoked(indexed address account, bool revoked)event TradingScheduled(uint256 openAt, indexed address by)event GateArmed(indexed address pair, indexed address gateSigner, address weth, uint256 hardOpen, address unwrapper)event ArmPowerGivenUp(indexed address by)
Custom Errors
error GateClosed(),error ApprovalsClosed()- a transfer or approval before trading opened. Unreachable nowerror BadSignature(),error AuthorizationExpired(),error AuthorizationRevoked_()- gate authorisation failureserror Expired()-buy/sell/addLiquiditypastdeadlineerror Slippage()- output below the caller's minimum, or zeroerror Reentrancy()error WethTransferFailed(),error DirectEthRejected()- the token rejects plain ETH transferserror NotDeployer(),error BadDeploy(),error GateAlreadyArmed(),error GateNotArmed(),error ArmPowerGone(),error AlreadyOpen(),error DonationExceedsSeed(),error TransientStorageUnavailable()- deployer-path gates
Notes
- EIP-2612 permit domain:
name = "Motoswap",version = "1" - No owner, no upgrade path, no pause, no burn function. Burns elsewhere in the system are transfers to
0x…dEaD
MotoTokenUnwrapper (packages/contracts/src/token/MotoTokenUnwrapper.sol)
Deployed by MotoToken when its gate was armed. Turns the WETH leg of MotoToken.sell into native ETH for the seller. Holds nothing between calls. Address: MotoToken.UNWRAPPER().
State Variables (Immutable)
TOKEN: address- the MotoToken that deployed it, and the only caller allowedWETH: address
External / Public Functions
unwrapTo(address to, uint256 amount)- withdrawamountWETH and send it totoas ETH; NotToken unless the caller isTOKEN
Custom Errors
error NotToken(),error DirectEthRejected()(ETH from anything but WETH),error EthTransferFailed()
TreasuryVesting (packages/contracts/src/token/TreasuryVesting.sol)
Step-vester for treasury allocation: 10 equal tranches released every 180 days over 5 years to fixed beneficiary. Lazy funding (balance + released = total). UUPS proxy. Deployed on Ethereum at /config.addresses.treasuryVesting.
Constructor / Init
initialize(IERC20 _token, address _beneficiary, uint64 _start, address _owner)- token, beneficiary and schedule anchor are set once and have no setter; owner authorizes upgrades only
Constants
TRANCHES = 10- equal 10% tranchesTRANCHE_INTERVAL = 180 days
State Variables
token: IERC20- MOTO; no setterbeneficiary: address- the treasury; no setterstart: uint64- schedule anchor; no setterreleased: uint256- total paid to beneficiary
External / Public Functions
Core (permissionless release):
release() nonReentrant returns (uint256 amount)- pay all unlocked-but-unpaid tranches to beneficiary; reverts if nothing to release; emits Released
Config (onlyOwner):
renounceUpgradeability()- freeze implementation; one-way
Views:
unlockedTranches() view returns (uint256)- tranches unlocked so far (0..TRANCHES)vested() view returns (uint256)- amount vested so far (of everything ever funded)releasable() view returns (uint256)- amount available to release nownextUnlock() view returns (uint256)- unix time next tranche unlocks (0 when fully unlocked)
Events
event Released(uint256 amount, uint256 totalReleased)
Custom Errors
error ZeroAddress()- init received zero addresserror NothingToRelease()- release called when releasable() == 0
Notes
- Lazy funding: total = balance + released (top-ups join the schedule; already-elapsed tranches of new amount become releasable immediately)
- No rescue, no pause: only the beneficiary can receive funds
MotoSwapZap (packages/contracts/src/zap/MotoSwapZap.sol)
One-sided liquidity provisioning: swap optimal half, add liquidity with remainder + swap output. Rejects fee-on-transfer tokens. Zap-and-stake also supported. Non-upgradeable.
Constructor
constructor(address _router, address _feeRouter, address _masterChef)- immutable router, feeRouter, factory, masterChef, WETH
State Variables (none; stateless)
External / Public Functions
Views:
getSwapAmount(uint256 amountIn, uint256 reserveIn, address tokenIn, address otherToken) view returns (uint256)- optimal half-swap, accounting for live pair fee + protocol feecreatorSkimBps(address tokenIn, address otherToken) view returns (uint256 bps)- total creator skim on the leg:feeRouter.creatorFeeBps()counted once per endpoint the registry recognises, so 0x, 1x or 2x. Zero when no registry is wired or the rate is zero; quote assets are never registered
Zap Token→LP:
zapInToken(address tokenIn, uint256 amountIn, address otherToken, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 deadline) nonReentrant ensure returns (uint256 lpAmount)- deposit tokenIn, mint LP to caller; pulls via safeTransferFromzapInETH(address otherToken, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 deadline) payable nonReentrant ensure returns (uint256 lpAmount)- deposit ETH (wrapped to WETH), mint LP to caller
Zap Token→LP→Stake:
zapInTokenAndStake(address tokenIn, uint256 amountIn, address otherToken, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 pid, uint256 deadline) nonReentrant ensure returns (uint256 lpAmount)- zap + stake into MasterChef pool pid for caller (trusted zapper exemption on MasterChef)zapInETHAndStake(address otherToken, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 pid, uint256 deadline) payable nonReentrant ensure returns (uint256 lpAmount)
Events (none)
Custom Errors
error PairNotFound()- no pair for (tokenIn, otherToken)error IdenticalTokens()- tokenIn == otherTokenerror Expired()- deadline < block.timestamperror EthTransferFailed()- ETH refund failederror InsufficientLpStaked()- LP minted < amountLpMin (zap-and-stake only)error InsufficientLp()- LP minted < amountLpMin (zap only)error FeeOnTransferNotSupported()- token taxes its own transferserror PoolPairMismatch()- pid's pool LP token != expected pair
Notes
- Swap leg goes through FeeRouter (pays protocol fee)
- Fee-on-transfer tokens are explicitly rejected (the zap measures real balance changes and reverts
FeeOnTransferNotSupportedif they differ) - Optimal formula reads live factory.swapFeeBps() + feeRouter.protocolFeeBps()
- Residual input/other dust is refunded to caller after add-liquidity (first leg) or after stake (second leg)
- No MEV protection on add-liquidity leg (amountTokenMin=0, amountOtherMin=0 opt-out individual floors)
amountLpMinfloors final LP out (protects against sandwich on add-liquidity)
UniswapZap (packages/contracts/src/zap/UniswapZap.sol)
One-sided liquidity into Uniswap V2 pairs for launch farming: swap the optimal portion on Uniswap V2, add liquidity, return the LP or stake it into the launch farm for the caller. No owner, no roles, no state. Rejects fee-on-transfer tokens. Non-upgradeable. Deployed on Ethereum at /config.addresses.uniswapZap.
Constructor
constructor(address router_, address chef_)- immutable Uniswap V2 router (factory and WETH are read from it) and theVampChef
State Variables (Immutable)
router: IUniswapV2Router02,factory: IUniswapV2Factory,WETH: address,chef: IVampChefDeposit
External / Public Functions
getSwapAmount(uint256 amountIn, uint256 reserveIn) pure returns (uint256)- optimal swap portion at Uniswap V2's fixed 0.30%zapInToken(address tokenIn, uint256 amountIn, address otherToken, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 deadline) nonReentrant ensure returns (uint256 lpAmount)- LP to the callerzapInETH(address otherToken, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 deadline) payable nonReentrant ensure returns (uint256 lpAmount)- LP to the callerzapInTokenAndStake(address tokenIn, uint256 amountIn, address otherToken, uint256 pid, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 deadline) nonReentrant ensure returns (uint256 lpAmount)- LP staked intochefpoolpidfor the callerzapInETHAndStake(address otherToken, uint256 pid, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 deadline) payable nonReentrant ensure returns (uint256 lpAmount)
Events
event Zapped(indexed address sender, indexed address pair, address tokenIn, uint256 amountIn, uint256 swapAmount, uint256 lpAmount)
Custom Errors
error PairNotFound(),error EmptyPair()(pair with zero reserves),error IdenticalTokens(),error ZeroAmount(),error Expired(),error EthTransferFailed(),error InsufficientLp(),error FeeOnTransferNotSupported(),error ChefNotSet(),error WrongPool()(pid's LP token is not this pair)
Notes
pidcomes right afterotherTokenhere. OnMotoSwapZapit is second to last. Both positions areuint256, so a swapped order still encodes- The beneficiary is always
msg.sender; there is no recipient argument on any path - Unused input and output dust is refunded to the caller; router approvals are zeroed on the way out
UUPSRenounceable (packages/contracts/src/upgrade/UUPSRenounceable.sol)
Abstract base of every UUPS contract above. Not deployed on its own.
upgradesRenounced() view returns (bool)- true once the implementation can never be upgraded againupgradeSplitArmed() view returns (bool)- whether a non-zero upgrade authority has ever been installed, after which zero is refusedevent UpgradesRenounced(),event UpgradeAuthoritySet(indexed address previousAuthority, indexed address newAuthority)error UpgradesAreRenounced(),error NotUpgradeAuthority(address caller),error UpgradeSplitArmed(),error UpgradeAuthorityNotAContract(address authority)
Seven contracts expose the split externally (upgradeAuthority() and setUpgradeAuthority(address)): RakebackV2, Collector, BuybackBurner, MasterChef, RewardVestingEscrow, MotoStaking and PveVault. FeeRouter, VampChef and TreasuryVesting keep upgrades on the owner; the factory keeps them on feeToSetter.
Snapshotter (packages/contracts/src/periphery/Snapshotter.sol)
One-shot snapshot marker (block + timestamp) for off-chain processes. Owner-only, non-upgradeable Ownable2Step.
State Variables
taken: bool- whether snapshot has been takensnapshotBlock: uint256- snapshot block numbersnapshotTimestamp: uint256- snapshot timestamp
External / Public Functions
snapshot() onlyOwner- record snapshot marker; one-shot; reverts if already taken; emits SnapshotTaken
Events
event SnapshotTaken(indexed uint256 blockNumber, uint256 timestamp)
Custom Errors
error AlreadyTaken()
Notes
- Purely a marker contract; off-chain indexer/governance reads snapshotBlock and snapshotTimestamp to know which block/time to use for a census
Not covered on this page
- The moto.fun contracts: see moto.fun contracts.
- The retired launch contracts under
src/launcherother thanPveVault. They are not deployed on Ethereum and nothing integrates against them. - The cross-chain set under
src/crosschainandMotocatSeeder: bridge adapters, outboxes and distribution tooling with no integrator entrypoint on Ethereum. - Interfaces under
src/interfaces.