Reference

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 = 18
  • PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9

State Variables

  • totalSupply: uint256 - total LP supply
  • balanceOf[address => uint256] - LP balance per address
  • allowance[address => mapping(address => uint256)] - ERC20 approval allowances
  • nonces[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 approve
  • transfer(address to, uint256 value) returns (bool) - ERC20 transfer
  • transferFrom(address from, address to, uint256 value) returns (bool) - ERC20 transferFrom; allows unlimited if prior approval is max uint256
  • permit(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 with name for permit signatures to validate. The EIP-712 domain is name = "Motoswap V1", version = "1", chain id, pair address

Reverts

  • "MotoSwap: EXPIRED" - permit past its deadline
  • "MotoSwap: INVALID_SIGNATURE" - permit signature does not recover to owner
  • Balance and allowance shortfalls revert as an arithmetic panic (0x11), not a string. transfer and transferFrom never return false
  • 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 initializers
  • initialize(address _feeToSetter) - deploy the pair logic, create the beacon, seed the fee; _feeToSetter is admin and upgrade authority, and the factory itself owns the beacon; reverts if zero. The contract default seeded here is swapFeeBps = 100. feeTo is left unset. The deploy scripts then call setSwapFee(30) and setFeeTo(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 logic
  • pairCodeHash, 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 feeToSetter
  • swapFeeBps: 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 to MAX_SWAP_FEE_BPS. Read the value live from the factory rather than assuming either number
  • getPair[address => mapping(address => address)] - pair address lookup by (token0, token1)
  • allPairs: address[] - all deployed pair addresses
  • quoteRegistry: address - optional quote-asset registry; when set, createPair requires one side to be a quote asset
  • swapAllowed[address => bool] - the swap perimeter. Only listed addresses may call MotoSwapPair.swap or the core router's swap entrypoints. The deploy seeds exactly two: MotoSwapRouter02 and FeeRouter. Outside integrators swap through the FeeRouter

External / Public Functions

  • swapConfig(address account) view returns (bool allowed, uint256 feeBps) - swapAllowed[account] and swapFeeBps in one call; what every pair reads on each swap
  • implementation() view returns (address) - current pair logic implementation
  • upgradePairImpl(address newPairImpl) - retarget beacon at new pair logic (one upgrade applies to EVERY live pair); onlyFeeToSetter
  • renouncePairUpgradeability() - freeze pair logic forever by renouncing beacon ownership; one-way; onlyFeeToSetter
  • renounceUpgradeability() - freeze factory implementation; one-way; onlyFeeToSetter
  • setSwapAllowed(address account, bool allowed) - governance function; onlyFeeToSetter; emits SwapAllowedSet. It maintains the protocol's own perimeter (the core router and the FeeRouter) and doubles as the halt lever. It is not an integrator path: outside integrators route through the FeeRouter
  • setSwapAllowedBatch(address[] calldata accounts, bool allowed) - governance function; batch form of setSwapAllowed, used to seed the perimeter at deploy
  • setSwapFee(uint256 _swapFeeBps) - set live pair fee (up to MAX_SWAP_FEE_BPS); onlyFeeToSetter; emits SwapFeeSet
  • allPairsLength() view returns (uint256) - number of pairs deployed
  • createPair(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 PairCreated
  • setQuoteRegistry(address _quoteRegistry) - set/unset quote-registry gate; onlyFeeToSetter
  • setFeeTo(address _feeTo) - set protocol fee recipient, or switch the protocol's LP-fee share off with zero; onlyFeeToSetter; emits FeeToSet
  • setFeeToSetter(address _feeToSetter) - step 1 of two-step: propose new feeToSetter; onlyFeeToSetter; emits FeeToSetterTransferStarted
  • acceptFeeToSetter() - 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 is allPairs.length after the push
  • event 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" - initialize received a zero _feeToSetter, or token0 is zero after sort in createPair
  • "MotoSwap: FORBIDDEN" - caller is not feeToSetter (or not pendingFeeToSetter on acceptFeeToSetter)
  • "MotoSwap: FEE_TOO_HIGH" - setSwapFee above MAX_SWAP_FEE_BPS
  • "MotoSwap: IDENTICAL_ADDRESSES" - createPair with tokenA == 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 LibClone ERC-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. Read pairCodeHash() (alias INIT_CODE_PAIR_HASH()) from the factory. Never hardcode it
  • feeToSetter is 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 factory
  • token0, token1: address - sorted token addresses
  • reserve0, reserve1: uint112 - cached reserves
  • blockTimestampLast: uint32 - last block.timestamp a Sync was emitted
  • price0CumulativeLast, price1CumulativeLast: uint256 - TWAP numerators
  • kLast: 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-factory
  • getReserves() view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) - fetch cached reserves and timestamp
  • mint(address to) lock returns (uint256 liquidity) - deposit proportional amounts of token0/token1, mint LP to to, optionally collect protocol fee; low-level, requires tokens already transferred
  • burn(address to) lock returns (uint256 amount0, uint256 amount1) - remove liquidity: burn LP from pair, transfer tokens to to; requires LP already transferred
  • swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) lock - atomic swap; reads factory.swapConfig(msg.sender), requires the caller to be on the swap perimeter, enforces the constant-product check with the live fee; data MUST be empty, there is no flash-swap callback; emits Swap
  • skim(address to) lock - send the balance above reserves to to. Does not emit Sync
  • sync() 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 in factory.swapAllowed (protocol-fee perimeter)
  • "MotoSwap: INSUFFICIENT_OUTPUT_AMOUNT" - zero output
  • "MotoSwap: INSUFFICIENT_LIQUIDITY" - not enough reserves
  • "MotoSwap: INVALID_TO" - to is token0 or token1
  • "MotoSwap: FLASH_SWAPS_CLOSED" - swap called with non-empty data
  • "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). motoSwapCall is never invoked. IMotoSwapCallee remains in the source tree as an interface file only
  • mint runs _mintFee before _mint(to, liquidity) and the Mint event has no recipient, the same order as Uniswap V2. While factory.feeTo() is non-zero, a Transfer(0x0 -> feeTo) precedes the user's Transfer(0x0 -> to). See the watch-events guide
  • _mintFee share: liquidity = totalSupply * (rootK - rootKLast) * 3 / (rootK * 2 + rootKLast * 3), about 60% of fee growth to feeTo
  • Swap fee is read LIVE from factory; a fee change mid-swap reprices it, bounded by MAX_SWAP_FEE_BPS
  • Protocol-fee _mintFee no-ops entirely when feeTo == 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 validate
  • pairFor(address factory, address tokenA, address tokenB) view returns (address pair) - compute CREATE2 address; reads pairCodeHash() live from factory
  • getReserves(address factory, address tokenA, address tokenB) view returns (uint256 reserveA, uint256 reserveB) - fetch reserves, sorted to match token order
  • quote(uint256 amountA, uint256 reserveA, uint256 reserveB) pure returns (uint256 amountB) - proportional amount: amountB = (amountA * reserveB) / reserveA
  • getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut, uint256 feeBps) pure returns (uint256 amountOut) - constant-product output after fee
  • getAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut, uint256 feeBps) pure returns (uint256 amountIn) - constant-product input for target output after fee
  • getAmountsOut(address factory, uint256 amountIn, address[] memory path) view returns (uint256[] memory amounts) - route amounts through a path
  • getAmountsIn(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" - quote called with amountA == 0
  • "MotoSwapLibrary: INSUFFICIENT_INPUT_AMOUNT" - getAmountOut called with amountIn == 0
  • "MotoSwapLibrary: INSUFFICIENT_OUTPUT_AMOUNT" - getAmountIn called with amountOut == 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 pair
  • addLiquidityETH(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 pair
  • removeLiquidity(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 pair
  • removeLiquidityETH(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) ensure returns (uint256 amountToken, uint256 amountETH) - remove liquidity for token-ETH pair
  • removeLiquidityWithPermit(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 permit
  • removeLiquidityETHWithPermit(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 tokens
  • removeLiquidityETHWithPermitSupportingFeeOnTransferTokens(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 input
  • swapTokensForExactTokens(uint256 amountOut, uint256 amountInMax, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter returns (uint256[] memory amounts) - exact output
  • swapExactETHForTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) payable ensure onlyPerimeter returns (uint256[] memory amounts) - path[0] must be WETH
  • swapTokensForExactETH(uint256 amountOut, uint256 amountInMax, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter returns (uint256[] memory amounts) - path[last] must be WETH
  • swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter returns (uint256[] memory amounts) - path[last] must be WETH
  • swapETHForExactTokens(uint256 amountOut, address[] calldata path, address to, uint256 deadline) payable ensure onlyPerimeter returns (uint256[] memory amounts) - path[0] must be WETH
  • swapExactTokensForTokensSupportingFeeOnTransferTokens(uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) ensure onlyPerimeter - for fee-on-transfer; measures realized balance deltas
  • swapExactETHForTokensSupportingFeeOnTransferTokens(uint256 amountOutMin, address[] calldata path, address to, uint256 deadline) payable ensure onlyPerimeter
  • swapExactTokensForETHSupportingFeeOnTransferTokens(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.quote
  • getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) view returns (uint256 amountOut) - reads live factory.swapFeeBps
  • getAmountIn(uint256 amountOut, uint256 reserveIn, uint256 reserveOut) view returns (uint256 amountIn) - reads live factory.swapFeeBps
  • getAmountsOut(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" - an amount*Min above its amount*Desired on 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's transferFrom returned false. MotoSwapERC20 reverts with a panic instead, so in practice a missing LP allowance shows as panic 0x11

Notes

  • Every swap entrypoint is gated on factory.swapAllowed(msg.sender). Only the FeeRouter (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: the FeeRouter is exact-input only
  • addLiquidity creates a missing pair through factory.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 initializers
  • initialize(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 is protocolFeeBps
  • MAX_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_000
  • MAX_SPLIT_LEGS = 4 - max legs in one split call
  • SwapLeg struct: (uint256 amountIn, address[] path) - one leg of a split fill

State Variables

  • router: IMotoSwapRouter02 - core router
  • registry: IQuoteAssetRegistry - quote-asset registry
  • collector: address - fee recipient
  • WETH: address - read from the router at initialize
  • permit2: IPermit2 - Permit2 for the two signature entrypoints. Zero means they are not available (they revert Permit2NotSet) and the plain approve path applies
  • protocolFeeBps: uint256 - live fee (settable, capped at MAX_PROTOCOL_FEE_BPS)
  • creatorRegistry: ICreatorFeeRegistry - optional creator-fee config
  • creatorVault: ICreatorFeeVault - creator-fee payout vault
  • creatorFeeBps: uint256 - creator-fee rate (settable, capped)

External / Public Functions

Config (onlyOwner):

  • setProtocolFeeBps(uint256 bps) - retune live protocol fee
  • setCreatorFeeConfig(address registry_, address vault_, uint256 bps) - wire creator-fee components (zero bps or registry disables it); with bps > 0 the vault must already list this router as a depositor, else VaultDoesNotAdmitRouter
  • setPermit2(address permit2_) - re-point or disable signature entrypoints
  • renounceUpgradeability() - freeze implementation; one-way
  • rescueERC20(address token, address to) onlyOwner nonReentrant - sweep stranded tokens
  • rescueETH(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 at path[0] and the last element; returns true for a path with no quote endpoint, where it means nothing. This is the only swap-related view: to quote, take MotoSwapRouter02.getAmountsOut and 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 via safeTransferFrom
  • swapExactTokensForTokensPermit2(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 signature
  • swapExactTokensForTokensSupportingFeeOnTransferTokens(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 WETH
  • swapExactETHForTokensSupportingFeeOnTransferTokens(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 safeTransferFrom
  • swapExactTokensForETHPermit2(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 leg
  • swapExactETHForTokensSplit(SwapLeg[] calldata legs, uint256 minTotalOut, address to, uint256 deadline) payable nonReentrant returns (uint256[] memory legOuts, uint256 totalOut) - eth-in; all legs path[0] == WETH
  • swapExactTokensForETHSplit(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) - user is msg.sender of the FeeRouter call (the payer), never to. amountIn is the gross input, amountOut the net delivered to to, feeAmount the protocol fee only, in feeToken
  • event Permit2Set(address permit2)
  • event ProtocolFeeSet(uint256 protocolFeeBps)
  • event CreatorFee(indexed address token, indexed address creator, address quoteAsset, uint256 amount) - creator fee skim
  • event 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 amountOutMin
  • error EthTransferFailed() - ETH transfer reverted or no receive
  • error NoQuoteSide() - swap route has no quote asset endpoint or mid-quote
  • error FeeTooHigh() - fee > MAX_PROTOCOL_FEE_BPS or MAX_CREATOR_FEE_BPS
  • error ZeroAddress() - initialize or config received zero address
  • error Permit2NotSet() - signature swap called but permit2 not wired
  • error PermitTokenMismatch() - Permit2 token != path[0]
  • error NoLegs() - split called with empty legs array or zero-amount leg
  • error TooManyLegs() - legs > MAX_SPLIT_LEGS
  • error LegMismatch() - split legs don't share same endpoints
  • error ValueMismatch() - swapExactETHForTokensSplit called with msg.value != totalIn
  • error VaultDoesNotAdmitRouter() - setCreatorFeeConfig pointed 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 quoteRank side pays; a tie charges the output
  • The deadline is not checked here. MotoSwapRouter02 checks it and reverts "MotoSwapRouter: EXPIRED"
  • amountOutMin is 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. swapExactTokensForETH and its Permit2 twin fill only the first and last elements. The fee-on-transfer entrypoints return one measured number
  • Permit2 twins exist for swapExactTokensForTokens and swapExactTokensForETH only. 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 creatorFeeBps of 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 SwapLeg struct contains only (amountIn, path[]) and relies on validation to ensure legs share path[0] and path[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 initializers
  • initialize(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); reverts BadDelay() if the delay is outside [MIN_ACTIVATION_DELAY, MAX_ACTIVATION_DELAY]. There is no router argument: claimAsMoto swaps through feeRouter

Constants

  • CLAIM_WINDOW = 30 days - claimable span, measured from a slice's Activated timestamp (not from epoch close)
  • MIN_ACTIVATION_DELAY, MAX_ACTIVATION_DELAY - bounds on the live activationDelay. Read the live values from the contract
  • MAX_TOKENS_PER_EPOCH = 8 - cap on tokens in one epoch's postRoot

State Variables

  • treasury: address - sweep destination (settable)
  • poster: address - the automated role allowed to call postRoot (settable)
  • guardian: address - hot ops role allowed to cancelRoot a still-pending root (settable)
  • moto: address - MOTO token
  • feeRouter: IFeeRouter - set post-init by setFeeRouter; the swap venue for claimAsMoto
  • activationDelay: uint64 - live delay between postRoot and the earliest activate
  • epochRoots[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; sweep must retain it
  • activeOutstanding[address => uint256] - the same over ACTIVE slices only; this is the activation funding gate
  • totalDeclared[address => uint256] - token -> everything ever declared; the liability-cap numerator
  • lifetimeInflow[address => uint256] - measured Collector inflow, credited by syncInflow; the cap ceiling
  • accountedBalance[address => uint256] - last-synced balance, so syncInflow credits only genuine new inflow

External / Public Functions

Config (onlyOwner):

  • setFeeRouter(address feeRouter_) - wire fee-paying swap front door post-init
  • setPoster(address poster_) - rotate the poster role
  • setGuardian(address guardian_) - rotate the guardian role
  • setActivationDelay(uint64 delay_) - retune the delay within its constant bounds
  • setTreasury(address treasury_) - set sweep destination
  • expirePendingSlice(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 owner
  • setUpgradeAuthority(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 zero
  • renounceUpgradeability() - 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 landed
  • epochEnd(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 - calls syncInflow per 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 most MAX_TOKENS_PER_EPOCH, each declared > 0, and totalDeclared[token] + declared must stay within the token's measured lifetimeInflow. That cap is the on-chain blast-radius bound on a compromised poster
  • cancelRoot(uint256 epoch) - void a still-PENDING root and rewind its books so a corrected root can be re-posted; guardian or owner; reverts RootActive() 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 ceiling
  • activate(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 establishes balance >= activeOutstanding at activation, so an active claim can never fail on funds; an unfunded slice stays pending, meaning claims are delayed, never partial
  • expireRoot(uint256 epoch, address token) - after the claim window, release an activated slice's unclaimed remainder from outstanding so 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 pay amount to account. All-or-nothing: one bitmap bit, no partial claim, no cumulative counter. Delivery is permissionless, so anyone can push a claim to its rightful owner
  • claimMany(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 claimable
  • claimAsMoto(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 bind msg.sender) and swap it to MOTO through the FeeRouter, so the swap pays the protocol fee; path must run token→MOTO. When token is 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 by ExceedsSweepable()

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 address
  • error NotPoster() / error NotGuardian() - role gate on postRoot / cancelRoot
  • error BadDelay() - activation delay outside [MIN_ACTIVATION_DELAY, MAX_ACTIVATION_DELAY]
  • error EpochNotClosed() - postRoot ran before the epoch ended
  • error RootExists() / error NoRoot() - an epoch already has a root / has none
  • error TokensNotSorted() / error TooManyTokens() / error ZeroDeclared() / error ZeroRoot() / error BadPerToken() - postRoot argument validation
  • error ExceedsInflowCap() - declared total would exceed the token's measured Collector inflow
  • error RootPending() - claim on a slice that has not activated yet
  • error NotActivated() - expireRoot on a slice that never activated
  • error NotYetActivatable() / error NotFunded() / error AlreadyActivated() / error SliceNotFound() - activate gates
  • error RootActive() - cancelRoot after a slice activated
  • error InvalidProof() - proof does not verify against the epoch root
  • error AlreadyClaimed() - the bitmap bit for that index is already set
  • error Overclaim() - the leaf would push claimedTotal past the slice's declaredTotal; the bound that stops a poisoned root draining the pot
  • error RootExpired() - past activatedAt + CLAIM_WINDOW
  • error ClaimWindowOpen() / error SliceActivated() / error SliceStillActivatable() / error SliceIsFundable() / error AlreadyExpired() - expiry-path gates
  • error LengthMismatch() - claimMany arrays different lengths
  • error BadPath() - claimAsMoto path invalid
  • error 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 is slice0(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 OpenZeppelin MerkleProof.verify, with an odd trailing node promoted unhashed. This is NOT StandardMerkleTree's double-hashed leaf. Leaves are ordered by token then account, and index is 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 registry distribute is gated on

Constants

  • MAX_BUCKETS = 16 - max bucket count
  • MAX_MIN_INTERVAL - ceiling on minInterval. Read it from the contract

State Variables (Buckets)

  • Bucket[] buckets - `{ address recipient, uint96 weight, bool notify }`; notify=true uses IStakingRewards.notifyReward
  • totalWeight: 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 token
  • heldFor[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 whitelist distribute is 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 = true calls notifyReward on it)
  • addBucket(address recipient, uint96 weight, bool notify) - append one bucket
  • setMinInterval(uint256 minInterval_) - set per-token rate limit; IntervalTooLong above MAX_MIN_INTERVAL
  • forceReleaseHeld(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 RakebackV2
  • renounceUpgradeability() - freeze implementation; one-way; gated on the upgrade authority

Distribution (permissionless, rate-limited per token):

  • distribute(address token) nonReentrant returns (uint256 paid) - token must 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 Distributed
  • releaseHeld(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/updated
  • event BucketRecipientSet(indexed uint256 index, address recipient, bool notify) - recipient changed. Three fields: a decoder built on the older two-field shape will not match
  • event 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 paid
  • event BucketSkipped(indexed address token, indexed uint256 index, bytes reason) - payout reverted; earmark held
  • event 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 paid
  • event HeldForceReleased(indexed address token, indexed uint256 index, uint256 amount) - an earmark was abandoned without a payout

Custom Errors

  • error ZeroAddress() - bucket recipient is zero
  • error ZeroWeight() - bucket weight is zero
  • error NoBuckets() - setBuckets called with empty array
  • error TooManyBuckets() - bucket count > MAX_BUCKETS
  • error BadIndex() - index >= buckets.length
  • error TooSoon() - distribute called within minInterval
  • error OnlySelf() - payoutBucket called externally
  • error NothingHeld() - releaseHeld called with zero earmark
  • error HeldWouldStrand(uint256 index) - setBuckets would drop a bucket with live earmarks
  • error HeldWouldRedirect(uint256 index, address held, address replacement) - a bucket change would send a live earmark to a different recipient
  • error NotQuoteAsset(address token) - distribute for a token the quote registry does not know
  • error IntervalTooLong() - setMinInterval above MAX_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 MotoStaking proxy at deploy. Until the reward tokens are registered on the deployed MotoStaking implementation, 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 quote
  • quoteRank[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 unchanged
  • setQuoteBatch(address[] calldata tokens, bool status) - batch form
  • setQuoteRank(address token, uint256 rank) - set fee-side priority for a quote asset
  • setQuoteRankBatch(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 token
  • error 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 = 0x000000000000000000000000000000000000dEaD
  • MIN_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 contract
  • DEFAULT_TOLERANCE_BPS, MIN_TOLERANCE_BPS, MAX_TOLERANCE_BPS - the default TWAP tolerance and the bounds on setTwapToleranceBps. Not published here. Read them from the contract

State Variables

  • moto: IERC20 - MOTO token
  • router: IMotoSwapRouter02 - core router, read for its factory
  • keeper: address - an operational role address (settable; zero = owner-only)
  • totalBurned: uint256 - cumulative MOTO burned
  • feeRouter: IFeeRouter - set post-init; the swap venue
  • twapToleranceBps: uint256 - raw tolerance, 0 meaning unset. Read twapTolerance() instead

External / Public Functions

Config (onlyOwner):

  • setFeeRouter(address feeRouter_) - wire the swap venue post-init
  • setKeeper(address keeper_) - set or clear keeper (zero = owner-only)
  • setPinnedPath(address tokenIn, address[] calldata path) - pin the one route tokenIn may be sold through, or clear it with an empty path. A pinned path is exactly [tokenIn, MOTO]; anything else reverts BadPinnedPath
  • setTwapToleranceBps(uint256 bps) - move the tolerance within its bounds; ToleranceOutOfRange outside them

Upgrade path:

  • upgradeAuthority() view returns (address), setUpgradeAuthority(address newAuthority) - same split as RakebackV2
  • renounceUpgradeability() - 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 route
  • observation(address tokenIn) view returns (uint256 priceCumulative, uint32 timestamp) - the cumulative-price reference held for tokenIn. Zeros mean never anchored
  • twapTolerance() view returns (uint256) - the tolerance in force, resolving the unset sentinel
  • twapFloor(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 for tokenIn. Reverts CheckpointFresh inside MIN_TWAP_WINDOW of the last anchor, and CheckpointUsable while the existing reference is still inside the window
  • buybackAndBurn(uint256 sellAmount, uint256 minMotoOut, address[] calldata path, uint256 deadline) onlyKeeper nonReentrant returns (uint256 motoOut) - sell sellAmount of path[0] to MOTO and burn it plus any MOTO already held. sellAmount is clamped to the balance and 0 means the whole balance. path must equal the pinned route. The effective floor is the higher of minMotoOut and twapFloor, enforced on the measured MOTO balance delta. With nothing to sell and nothing to burn it emits NothingToDo and returns 0 instead of reverting
  • burn() 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 burn
  • event 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 wired
  • error NotKeeper() - caller not keeper and not owner
  • error BadPath() - path shorter than 2, path[0] == MOTO, or path[last] != MOTO
  • error NothingToBurn() - burn with no MOTO held
  • error BelowMinMotoOut(uint256 motoOut, uint256 minMotoOut) - the measured MOTO received is below the effective floor
  • error BelowTwapFloor(uint256 motoOut, uint256 twapFloor) - declared
  • error TokenNotPinned(address tokenIn) - no pinned route for path[0]
  • error PathNotPinnedRoute() - path differs from the pinned route
  • error BadPinnedPath() - setPinnedPath with 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 old
  • error CheckpointFresh() - pokeCheckpoint inside MIN_TWAP_WINDOW of the last anchor
  • error CheckpointUsable(address tokenIn, uint32 age) - pokeCheckpoint while the reference is still usable
  • error 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 Swap event for a buyback names this contract as user
  • Cumulative totalBurned never decreases (for indexer/stats)
  • The BuybackBurner has no entrypoint for an outside integrator beyond burn() and pokeCheckpoint()

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 asset
  • merkleRoot: bytes32 - Merkle root over (index, account, amount) leaves
  • claimDeadline: uint256 - claim window close time
  • totalAllocation: uint256 - sum of all leaf amounts

State Variables

  • totalClaimed: uint256 - cumulative claimed amount
  • claimedBitMap[uint256 => uint256] - bitmap for O(1) claim tracking

External / Public Functions

Claims (permissionless):

  • claim(uint256 index, address account, uint256 amount, bytes32[] calldata merkleProof) - claim amount for account at leaf index; verifies proof; reverts if already claimed; advances totalClaimed

Sweeps (onlyOwner):

  • sweep(address to) - reclaim surplus above outstanding liability after claimDeadline; reverts if window still open (ClaimWindowOpen) or nothing to sweep
  • sweepAll(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 claimed
  • outstanding() 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 surplus
  • event SweptAll(indexed address to, uint256 amount) - reclaimed remainder after grace

Custom Errors

  • error AlreadyClaimed() - index already claimed
  • error InvalidProof() - Merkle proof doesn't match leaf
  • error ClaimWindowOpen() - sweep before claimDeadline
  • error InvalidDeadline() - deadline <= block.timestamp
  • error ZeroRoot() - merkleRoot is bytes32(0)
  • error ZeroToken() - token is zero address
  • error ZeroAllocation() - totalAllocation is zero
  • error NothingToSweep() - surplus or remainder is zero
  • error GracePeriodOpen() - sweepAll before grace window closes
  • error Overclaim() - a claim would push totalClaimed past totalAllocation

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 on creatorOf, 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 registered
  • setDisabled(address token, bool disabled) - enable/disable a token's creator fee; reverts if not registered
  • setRegistrar(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, ignoring disabled. 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 override
  • event CreatorSetBySelf(indexed address token, indexed address previousCreator, indexed address creator) - the creator moved their own payout address
  • event TokenDisabled(indexed address token, bool disabled) - toggle disabled flag
  • event RegistrarSet(indexed address registrar, bool allowed) - registrar authorized/revoked

Custom Errors

  • error ZeroAddress() - register/adminSetCreator/setRegistrar received zero
  • error NotRegistrar() - caller not an authorized registrar
  • error AlreadyRegistered() - token already has a creator
  • error NotRegistered() - adminSetCreator or setDisabled on unregistered token
  • error NotCreator() - setCreator by 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 - immutable
  • WETH: address - immutable
  • accrued[address => mapping(address => uint256)] - token -> quote asset -> balance claimable by token's current creator
  • isDepositor[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 Accrued
  • claim(address token, address[] calldata quoteAssets, bool unwrapWeth) nonReentrant - claim token'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 Claimed
  • claimMany(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 empty tokens reverts 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 address
  • error NotDepositor() - notifyDeposit called by non-depositor
  • error NotCreator() - claim called by non-creator
  • error NothingToClaim() - claimMany with an empty token list
  • error 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 initializers
  • initialize(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 = 1e12
  • COOLDOWN = 24 hours
  • MAX_DEPOSIT_FEE_BPS = 1000 (10%)
  • MAX_HALVING_PERIOD = 365 days
  • MAX_START_DELAY = 90 days
  • HARVEST_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 snapshot
  • initialPeriodReward, totalEmission: uint256 - running campaign reward config
  • emissionStart, emissionEnd: uint64 - running campaign window
  • feeRecipient: 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 pool
  • PoolInfo[] 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/s in final period), retroactive starts, or starts >90d future; runs massUpdatePools first; emits EmissionStarted
  • setEmissionSchedule(uint256 _halvingPeriod, uint256 _numPeriods) - prospective-only config for NEXT campaign; enforces _halvingPeriod >= 1 day and <= MAX_HALVING_PERIOD, _numPeriods in [1,64]
  • setRewardToken(IERC20 _rewardToken) - change reward token pre-launch (no campaign started, no pools); validates escrow binding
  • setVestingEscrow(address _vestingEscrow) - swap escrow and reward token atomically pre-launch
  • setFeeRecipient(address _feeRecipient) - set deposit fee destination
  • setTrustedZapper(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 RakebackV2
  • renounceUpgradeability() - 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 PoolAdded
  • set(uint256 _pid, uint256 _allocPoint, uint16 _depositFeeBps) - update pool config; runs massUpdatePools first; emits PoolSet
  • setMany(uint256[] calldata _pids, uint256[] calldata _allocPoints) - retune several pools' allocation with one settle; LengthMismatch on empty or unequal arrays; emits PoolSet per pool
  • massUpdatePools() - settle all pools (gas-intensive; O(poolCount))
  • updatePool(uint256 _pid) - settle one pool

Staking:

  • deposit(uint256 _pid, uint256 _amount) nonReentrant - stake LP for caller
  • depositFor(uint256 _pid, uint256 _amount, address _user) nonReentrant - stake LP for _user. A third party may only open a NEW position: if _user already has a stake in the pool it reverts Unauthorized unless the caller is _user or trustedZapper. A third-party call with _amount == 0 returns silently
  • harvest(uint256 _pid) nonReentrant - settle and pay the caller's pending reward without changing stake; behaviourally identical to deposit(_pid, 0), split out so a wallet shows "Harvest" rather than "Deposit" in the confirmation
  • withdraw(uint256 _pid, uint256 _amount) nonReentrant - queue a withdrawal: settles rewards and sets cooldownEnd = min(now + COOLDOWN, emissionEnd), or now when cooldownWaivedFor(_pid). One queued withdrawal per (user, pool): while one is queued, withdraw, deposit, depositFor and harvest revert CooldownActive
  • claimWithdrawal(uint256 _pid) nonReentrant - pay the queued withdrawal at or after cooldownEnd
  • emergencyWithdraw(uint256 _pid) nonReentrant - return the whole ACTIVE stake instantly and forfeit pending rewards (RewardForfeited). An amount already queued by withdraw is untouched and still exits through claimWithdrawal

Views:

  • stakedBalance(uint256 _pid, address _user) view returns (uint256) - actively-staked LP amount
  • pendingReward(uint256 _pid, address _user) view returns (uint256) - settled but unclaimed reward
  • availableReward() 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 _t
  • emissionBetween(uint256 _from, uint256 _to) view returns (uint256) - emission in time window
  • currentRewardPerSecond() 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. What withdraw actually uses
  • HALVING_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 escrow
  • event RewardShortfall(indexed address user, indexed uint256 pid, uint256 owed, uint256 paid) - the pot could not cover a payout; paid went out, the rest was forfeited
  • event RewardForfeited(indexed address account, indexed uint256 pid, uint256 amount) - pending reward abandoned by emergencyWithdraw
  • event 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() - recoverUndistributedRewards above recoverableReward(). A user payout never reverts with it: a short pot pays what it has and emits RewardShortfall
  • error CooldownActive() - deposit, depositFor, harvest or withdraw while a withdrawal is queued on that pool
  • error CooldownNotElapsed() - claimWithdrawal before cooldownEnd
  • error LengthMismatch() - setMany with empty or unequal arrays
  • error TrustedZapperAlreadySet() - second setTrustedZapper
  • error NoPendingWithdrawal() - claimWithdrawal with no queued withdrawal
  • error DuplicatePool() - add tries to add LP token already in a pool
  • error InvalidPool() - _pid >= poolInfo.length
  • error DepositFeeTooHigh() - depositFeeBps > MAX_DEPOSIT_FEE_BPS
  • error InsufficientStaked() - withdraw amount > staked balance
  • error ZeroAddress() - init or config received zero
  • error Unauthorized() - depositFor third-party top-up without trustedZapper exemption
  • error EmissionActive() - startEmission or setEmissionSchedule while a campaign is running; recovery inside the window plus grace
  • error InvalidEmission() - startEmission with ghost curve, past time, or too-far-future
  • error TooManyPools() - pool count would exceed MAX_POOLS
  • error PoolsExist() - setRewardToken while pools exist
  • error CampaignRan() - setRewardToken after campaign started
  • error RewardTokenMismatch() - escrow token != reward token
  • error 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 / 2 goes to RewardVestingEscrow.depositFor(user, vested), amount - vested is 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 setter
  • grants[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 upgrade
  • renounceUpgradeability() - 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 Deposited
  • claim() 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 schedule
  • vestEndOf(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 zero
  • error ZeroAmount() - depositFor called with zero
  • error 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 initializers
  • initialize(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 = 1e12
  • DURATION = 14 days - the single fixed emission window, flat over its whole length
  • MAX_START_DELAY = 90 days - bound on a scheduled start; there is no cancel path, so a wildly-future start would lock the pulled budget
  • HARVEST_GRACE = 30 days - window after emissionEnd during which recovery stays shut, so every staker has a published stretch of time to harvest first
  • MAX_POOLS = 500

State Variables

  • rewardToken: IERC20 - the pre-minted reward token (MOTO)
  • vestingEscrow: IRewardVestingEscrow - streams the vested 50% of every harvest over 180 days
  • rewardBudget: uint256 - the pulled budget (300M at launch)
  • rewardPerSecond: uint256 - rewardBudget / DURATION. The rewardBudget % DURATION wei remainder is un-emitted dust
  • emissionStart, emissionEnd: uint64 - the window. emissionStart == 0 means no campaign has ever started
  • totalStakedRewardToken: uint256 - reward-token principal staked across all pools; never paid as reward
  • totalCredited, totalHarvested, forfeitedReward: uint256 - reward credited into pools, actually paid out, and abandoned by emergencyWithdraw. totalCredited - totalHarvested - forfeitedReward is the stakers' outstanding claim
  • poolInfo[]: PoolInfo - `{ lpToken, allocPoint, lastRewardTime, accRewardPerShare, lpSupply }`
  • userInfo[pid => mapping(user => UserInfo)] - `{ amount, rewardDebt }`
  • totalAllocPoint: uint256
  • poolExistence[address => bool] - which LP tokens already have a pool
  • trustedZapper: 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 _budget up front via transferFrom (approve first), runs massUpdatePools, sets emissionEnd = start + 14 days. One-shot: reverts EmissionActive once emissionStart is 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 == 0 means now
  • setRewardToken(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 matching vestingEscrow.token(), and there is no setter to re-point the escrow
  • add(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 interval
  • set(uint256 _pid, uint256 _allocPoint) - retune allocation; also settles first
  • setMany(uint256[] calldata _pids, uint256[] calldata _allocPoints) - retune several pools with one settle; LengthMismatch on empty or unequal arrays
  • setTrustedZapper(address _trustedZapper) - name the trusted zapper. ONE-SHOT: no re-point, no clear; a second call reverts TrustedZapperAlreadySet
  • renounceUpgradeability() - 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 arrived
  • depositFor(uint256 _pid, uint256 _amount, address _user) nonReentrant - deposit and credit _user. A third party may only open a NEW position: if _user already has a stake in the pool it reverts Unauthorized unless the caller is _user or trustedZapper. A third-party call with _amount == 0 returns silently
  • harvest(uint256 _pid) nonReentrant - settle and pay pending without changing stake. Behaviourally identical to deposit(_pid, 0), split out so a wallet shows "Harvest" instead of "Deposit" in the confirmation. Self-only
  • withdraw(uint256 _pid, uint256 _amount) nonReentrant - harvest and return principal in the same call. No cooldown. Reverts InsufficientStaked on zero or more than staked
  • emergencyWithdraw(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 _t across all pools, before the per-pool split. Linear, clamped to the window: 0 before the start, rewardPerSecond * DURATION after the end
  • emissionBetween(uint256 _from, uint256 _to) view returns (uint256) - emission in a window; 0 outside it, so accrual auto-stops
  • currentRewardPerSecond() view returns (uint256) - live rate, 0 outside the window
  • availableReward() view returns (uint256) - reward-token balance less staked reward-token principal
  • recoverableReward() 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 (or totalAllocPoint == 0) and so was credited to nobody. Runs massUpdatePools first, then bounds the amount by recoverableReward(), so it can never reach staked principal or an accrued reward. Gated to block.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 halves
  • event RewardShortfall(indexed address user, indexed uint256 pid, uint256 owed, uint256 paid) - the pot could not cover a payout; paid went out, the rest was forfeited
  • event 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 RewardShortfall
  • error LengthMismatch() - setMany with empty or unequal arrays
  • error TrustedZapperAlreadySet() - second setTrustedZapper
  • error Unauthorized() - depositFor top-up of someone else's existing position
  • error DuplicatePool() - add for an LP token that already has a pool
  • error InvalidPool() - _pid >= poolInfo.length
  • error InsufficientStaked() - withdraw of zero, or more than staked
  • error ZeroAddress() - init, add, depositFor, setTrustedZapper or recovery received zero
  • error EmissionActive() - startEmission called twice, or recovery inside the window plus grace
  • error InvalidEmission() - ghost curve, retroactive start, or a start beyond MAX_START_DELAY
  • error TooManyPools() - pool count would exceed MAX_POOLS
  • error PoolsExist() - setRewardToken while pools exist
  • error CampaignRan() - setRewardToken after the campaign started
  • error RewardTokenMismatch() - reward token differs from vestingEscrow.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
  • add at 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 initializers
  • initialize(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 window
  • MAX_REWARD_TOKENS = 32
  • MAX_BOOST_BPS = 100_000 - ceiling on setBoost (11x; the shipped ladder tops out at 70_000)
  • MAX_OPEN_TICKETS = 64 - cap on a wallet's concurrent unstake release tickets
  • MAX_PURGE_RESIDUAL = 1e6 - hard ceiling on purgeRewardToken's maxResidual
  • NUM_OPTIONS = 4 - lock tiers
  • ACC = 1e30 - reward accumulator precision (private)
  • BPS = 10_000 (private)

State Variables

  • moto: IERC20 - staked principal and reward token
  • legacyRouter: IMotoSwapRouter02 - the router address passed to initialize, recorded for continuity and never read. claimAsMoto swaps through feeRouter
  • feeRouter: IFeeRouter - the swap venue claimAsMoto routes through, installed by initializeV2
  • treasury: address - reward destination when no effective stake
  • durations[4]: uint256 - lock lengths per tier
  • boostBps[4]: uint256 - extra weight per tier (prospective-only; snapshots at open). Weight is amount * (BPS + boostBps) / BPS, so the multiplier is 1 + boostBps / 10000
  • rewardNotifier: 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 }`; duration and boostBps are snapshotted at open so a later config change never reprices a live position, and availableFloor is the withdrawable figure crystallised at append time so available() can never fall
  • tickets[user][]: Ticket[] - unstaking release streams `{ amount, start, claimed }`
  • totalEffectiveStake: uint256 - sum of all positions' effective stake (with boosts)
  • rewardTokens[]: address[] - enumerable reward tokens
  • isRewardToken[token => bool] - whether token is active
  • accRewardPerEffShare[token => uint256] - per-effective-share accumulator (scaled by ACC)
  • userRewardDebt[user => mapping(token => uint256)] - snapshot of accRewardPerEffShare at user's last settle
  • claimableStored[user => mapping(token => uint256)] - settled reward ready to claim
  • wasRewardToken[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, unclaimed claimableStored for the token
  • rewardOwed[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 inside upgradeToAndCall: installs the FeeRouter that claimAsMoto swaps through and the upgrade authority; emits FeeRouterSet. legacyRouter is deliberately left untouched
  • addRewardToken(address token) - approve token as a reward the notifier may pay in. Refuses an address that was ever delisted, refuses past MAX_REWARD_TOKENS, RewardTokenAlreadyApproved on a repeat. Not live yet, arrives with a contract upgrade
  • claimReward(address account, address token) nonReentrant returns (uint256 amount) - push account's settled reward to account. Exists so one abandoned wallet cannot block a purge; it can only ever pay the staker
  • setRewardNotifier(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 to maxResidual of unclaimable residue to treasury. The index truncates in both notifyReward and _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 upgrade
  • renounceUpgradeability() - 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 exists
  • appendStake(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 tickets
  • claimUnstakedFrom(uint256 maxTickets) nonReentrant returns (uint256 amount) - bounded variant for wallets with many tickets
  • claim() nonReentrant - claim all reward tokens
  • claim(address token) nonReentrant returns (uint256 amount) - claim one token's rewards
  • claimTokenSelf(address account, address token) - settle a single token for an account
  • claimAsMoto(address token, uint256 minMotoOut, address[] calldata path, uint256 deadline) nonReentrant returns (uint256 motoOut) - not live yet, arrives with a contract upgrade (the deployed feeRouter() is a placeholder). Claim and swap to MOTO through the FeeRouter (path must run token→MOTO); minMotoOut is 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; pulls sum(amounts) MOTO from the owner; each target must have no active position. Three arrays: there is no starts argument

Reward Notifications (onlyNotifier):

  • notifyReward(address token, uint256 amount) nonReentrant - add token rewards (spread per effective stake); token must be on the owner-approved list, else RewardTokenNotApproved; with no effective stake the amount is forwarded to treasury; 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 now
  • effectiveStakeOf(address account) view returns (uint256) - boosted stake weight
  • ticketCount(address account) view returns (uint256) / ticketsLength(address account) view returns (uint256) / claimableUnstaked(address account) view returns (uint256 amount) - unstake-ticket surface
  • rewardTokensLength() 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 treasury
  • event 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 claim
  • event 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 open
  • error NoPosition() - appendStake without active position
  • error ExceedsAvailable() - withdraw > unlocked principal
  • error TooManyOpenTickets() - requestUnstake would exceed MAX_OPEN_TICKETS
  • error LengthMismatch()
  • error ZeroAddress()
  • error NotNotifier()
  • error BadPath() - claimAsMoto path invalid
  • error BelowMinMotoOut(uint256 received, uint256 floor) - claimAsMoto delivered less MOTO than the caller's floor
  • error OnlySelf()
  • error InvalidAnchor()
  • error NotRewardToken() - removeRewardToken on non-reward token
  • error TokenPermanentlyDelisted() - try to re-list
  • error RewardTokenNotApproved(address token) - notifyReward for a token the owner has not approved
  • error RewardTokenAlreadyApproved(address token) - addRewardToken for a listed token
  • error ResidualCeilingTooHigh(uint256 requested, uint256 cap) - purgeRewardToken with maxResidual above MAX_PURGE_RESIDUAL
  • error TokenNotDelisted() - purgeRewardToken on non-delisted
  • error TokenBalanceNotZero() - purgeRewardToken while the free balance exceeds maxResidual
  • error BoostTooHigh() - setBoost above MAX_BOOST_BPS
  • error 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 receipts
  • extraLaunchers[address => bool] - additional authorized launchers (moto.fun's LaunchpadCurve), additive to launcher
  • motocatStaking: address - the staking contract whose historical stake sizes a split
  • totalReceived[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 slot
  • claimed[token => mapping(wallet => bool)] - whether a wallet has taken its share
  • pending[token => uint256] - escrow that landed while nobody was staked, awaiting recreditPending. A per-TOKEN amount, not a per-wallet claimable balance
  • stakingWiringFrozen: bool - true once the first credit is frozen, after which setMotocatStaking is shut
  • expectingArrival[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 keeps forward and rescueExcess off that balance in between
  • escrowFloor[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 launcher
  • setExtraLauncher(address launcher_, bool allowed) - authorize or revoke an additional launcher
  • setMotocatStaking(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 exists
  • recreditPending(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 rule
  • forward(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 hold
  • clearPendingArrival(address token) - clear an arrival marker for a token this vault holds nothing for (a coin that never graduates); emits PendingArrivalClearedByOwner
  • rescueExcess(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 above credit.amount + pending; NoExcessToRescue when there is none

Upgrade path:

  • upgradeAuthority() view returns (address), setUpgradeAuthority(address newAuthority) - same split as RakebackV2
  • renounceUpgradeability() - 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 pending
  • recordPve(address token, address creator, uint256 amount) - launcher-only receipt record, which also credits the escrow with no owner action. Checks amount against the vault's actual balance rather than trusting it, then sizes the split against totalStakedAt(block.number - 1). Emits PveReceived, then PveCredited or PveHeld

Views and claims:

  • rescuableExcess(address token) view returns (uint256) - what rescueExcess could move right now; zero for a token with no credit
  • claimable(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 order
  • claim(address token) nonReentrant returns (uint256 amount) - claim one token's share; reverts NothingToClaim on zero
  • claimMany(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 receipt
  • event PendingArrivalClearedByOwner(indexed address token, indexed address by)

Custom Errors

  • error ZeroAddress() - a setter or forward received zero
  • error NotLauncher() - recordPve called by an address that is neither launcher nor an extra launcher
  • error StakingNotSet() - recreditPending with no staking contract wired
  • error AlreadyCredited() - recordPve for a token that already has a credit or a pending hold
  • error AmountNotReceived() - the vault holds less than the recorded amount
  • error NoCredit() - recreditPending on a token with nothing held
  • error AlreadyClaimed() - the wallet already took its share
  • error NothingToClaim() - claim or claimMany yielded zero
  • error PoolStillEmpty() - recreditPending while nobody is staked
  • error SeedingNotClosed() - the staking contract's seed is still in flight, so its supply is not the allocation table's
  • error NotAContract(address target) - setMotocatStaking pointed at an address with no code
  • error StakingWiringFrozen() - setMotocatStaking after a credit exists
  • error TokenIsCredited(address token) - forward on a token owed to stakers
  • error ValueTooLarge() - amount above uint128 or staked count above uint64
  • error TableMovedThisBlock() - a stake or unstake landed in the same block as the credit, so the snapshot is not final. Retry in the next block
  • error 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
  • claimable truncates toward the pool (the standard MasterChef rule), so a small permanent remainder always survives. That residue is correct, not drift, and it is deliberately stranded
  • depositToStaking and its DepositedToStaking event were REMOVED with the staking contract's streamed-rewards track. MotocatStakingV3 has no depositReward to 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 implementation
  • initialize(IERC721 collection_, address initialOwner) - one-shot. Rejects a zero collection AND one with no code (collection has 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 than immutable, so an upgrade cannot silently re-point the escrow
  • totalStaked: uint256 - cats currently staked across all wallets
  • stakerOf[tokenId => address] - who staked each cat; zero when not staked here
  • seedingClosed: bool - one-way latch. False during setup, true forever after closeSeeding
  • seeder: address - the address permitted to call seedStakes and closeSeeding (the MotocatSeeder). Deliberately not the owner, though the owner can call both paths directly
  • seedSource: address - the wallet seedStakes pulls cats from when the contract does not already hold them
  • outboxes: address[] - registered L1 to L2 outbox adapters, one per destination chain. May be empty
  • isOutbox[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. Uses transferFrom, not safeTransferFrom, so there is no hook back into the caller. Blocked until seedingClosed. 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 broadcasts
  • unstake(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 next counts[i] ids from the flat tokenIds. Writes checkpoints per wallet, without which every seeded holder reads zero from stakedBalanceOfAt and receives nothing from every PvE credit. Pulls each cat from seedSource unless 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 broadcast
  • closeSeeding() onlySeeder - end the setup phase permanently: the seed path dies, public stake opens. One-way, no reopen
  • setSeeder(address seeder_) onlyOwner - settable only while seeding is open
  • setSeedSource(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 working
  • upgradesRenounced() view returns (bool) - whether the implementation can still be replaced
  • renounceOwnership() 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 stray
  • rescueERC721(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 raw transferFrom); otherwise reverts CannotRescueStakedCat. Any other collection's NFT is fully recoverable
  • rescueETH(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 adapter
  • removeOutbox(address outbox) onlyOwner - deregister. Swap-and-pop, so index ordering is not stable across a removal
  • outboxCount() view returns (uint256) - zero is a valid, expected state
  • rebroadcast(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 BroadcastFailed
  • rebroadcastMany(address[] wallets) nonReentrant - permissionless batch form. Exists for the seed: seedStakes broadcasts 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 broadcasts 0, 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 staked
  • stakedTokensOf(address wallet) view returns (uint256[] memory) - the tokenIds it currently has staked. Cost grows with the wallet
  • stakedTokensOfSlice(address wallet, uint256 offset, uint256 limit) view returns (uint256[] memory ids) - a bounded page: up to limit ids starting at offset, clamped (not reverting) past the end. Not live yet, arrives with a contract upgrade
  • stakedBalanceOfAt(address wallet, uint256 blockNumber) view returns (uint256) - how many cats the wallet had staked as of the END of blockNumber. Reverts FutureLookup for the current or a future block
  • totalStakedAt(uint256 blockNumber) view returns (uint256) - total staked as of the end of blockNumber; the denominator for a PvE split. Same FutureLookup rule
  • isStaked(uint256 tokenId) view returns (bool)
  • onERC721Received(...) pure - always reverts DirectTransferNotAllowed. Staking must go through stake, 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 action
  • event 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 rebroadcast
  • event UpgradesRenounced()

Custom Errors

  • error ZeroCollection() - initialize received a zero collection, or one with no code
  • error ZeroOutbox() - addOutbox received zero
  • error OutboxAlreadyRegistered()
  • error OutboxNotRegistered()
  • error EmptyArray() - stake, unstake, seedStakes or rebroadcastMany called with an empty array
  • error NotStaker(uint256 tokenId) - unstake for a cat not staked by the caller
  • error UnstakeExceedsStaked() - unstake more than staked
  • error DirectTransferNotAllowed() - safeTransferFrom into this contract rejected
  • error FutureLookup() - a historical stake lookup for the current or a future block, where the value is not final yet
  • error CheckpointOverflow() - block number or stake count outside the packed checkpoint range. Unreachable in practice
  • error ZeroToken() - rescue called with a zero token or NFT address
  • error RenounceDisabled() - renounceOwnership is blocked
  • error CannotRescueStakedCat(uint256 tokenId) - rescueERC721 on a cat with a recorded staker
  • error ETHTransferFailed()
  • error ZeroRecipient() - initialize or a rescue received a zero recipient
  • error SeedingNotClosed() - public stake attempted before the latch flipped. Unstake is never shut
  • error SeedingAlreadyClosed() - a seed-phase call after closeSeeding
  • error NotSeeder() - caller is neither seeder nor owner
  • error SeedLengthMismatch() - counts and wallets disagree, a count is zero, or counts does not sum to tokenIds.length
  • error AlreadySeeded(uint256 tokenId) - the id already has a staker: a repeated id inside one batch, or a re-run batch
  • error ZeroSeedSource() - a cat must be pulled but no seedSource is wired
  • error InvalidSeedWallet(address wallet) - a seed row naming address(0) or this contract, both of which permanently strand the cats
  • error 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
  • seedStakes is 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 comfortably
  • unstake is never gated by the seeding latch. If a seeded holder exits before reconciliation, MotocatSeeder.reconcile reverts on the total mismatch, which is the correct outcome and not a lock-out: the owner can call closeSeeding directly 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 to recipient, who is also recorded as DEPLOYER; BadDeploy on zero

Constants / Immutables

  • INITIAL_SUPPLY = 10_000_000_000 * 1e18
  • DEPLOYER: address - immutable
  • WINDOW: 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 armed
  • HARD_OPEN: uint256, openAt: uint256 - gate timestamps
  • gateArmed: bool, armPowerRenounced: bool
  • authorizationRevoked[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-way
  • owner() pure returns (address) - always the zero address
  • quoteBuy(uint256 ethIn) view returns (uint256) / quoteSell(uint256 tokenIn) view returns (uint256) - Uniswap V2 output math (0.30%) against PAIR's live reserves
  • reserves() view returns (uint256 rToken, uint256 rWeth) - PAIR reserves, token side first
  • isAuthorized(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) - wrap msg.value and swap it for MOTO on PAIR, delivered to the caller
  • sell(bytes calldata sig, uint256 authDeadline, uint256 amountIn, uint256 amountOutMin, uint256 deadline) returns (uint256 amountOut) - swap the caller's MOTO for ETH on PAIR, unwrapped through UNWRAPPER

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; authorizeAndTransfer then behaves as transfer

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. openNow and setAuthorizationRevoked revert 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 now
  • error BadSignature(), error AuthorizationExpired(), error AuthorizationRevoked_() - gate authorisation failures
  • error Expired() - buy/sell/addLiquidity past deadline
  • error Slippage() - output below the caller's minimum, or zero
  • error Reentrancy()
  • error WethTransferFailed(), error DirectEthRejected() - the token rejects plain ETH transfers
  • error 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 allowed
  • WETH: address

External / Public Functions

  • unwrapTo(address to, uint256 amount) - withdraw amount WETH and send it to to as ETH; NotToken unless the caller is TOKEN

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% tranches
  • TRANCHE_INTERVAL = 180 days

State Variables

  • token: IERC20 - MOTO; no setter
  • beneficiary: address - the treasury; no setter
  • start: uint64 - schedule anchor; no setter
  • released: 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 now
  • nextUnlock() 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 address
  • error 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 fee
  • creatorSkimBps(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 safeTransferFrom
  • zapInETH(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 == otherToken
  • error Expired() - deadline < block.timestamp
  • error EthTransferFailed() - ETH refund failed
  • error InsufficientLpStaked() - LP minted < amountLpMin (zap-and-stake only)
  • error InsufficientLp() - LP minted < amountLpMin (zap only)
  • error FeeOnTransferNotSupported() - token taxes its own transfers
  • error 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 FeeOnTransferNotSupported if 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)
  • amountLpMin floors 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 the VampChef

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 caller
  • zapInETH(address otherToken, uint256 amountSwapOutMin, uint256 amountTokenMin, uint256 amountOtherMin, uint256 amountLpMin, uint256 deadline) payable nonReentrant ensure returns (uint256 lpAmount) - LP to the caller
  • zapInTokenAndStake(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 into chef pool pid for the caller
  • zapInETHAndStake(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

  • pid comes right after otherToken here. On MotoSwapZap it is second to last. Both positions are uint256, 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 again
  • upgradeSplitArmed() view returns (bool) - whether a non-zero upgrade authority has ever been installed, after which zero is refused
  • event 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 taken
  • snapshotBlock: uint256 - snapshot block number
  • snapshotTimestamp: 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/launcher other than PveVault. They are not deployed on Ethereum and nothing integrates against them.
  • The cross-chain set under src/crosschain and MotocatSeeder: bridge adapters, outboxes and distribution tooling with no integrator entrypoint on Ethereum.
  • Interfaces under src/interfaces.

On this page

MotoSwapERC20 (packages/contracts/src/dex/core/MotoSwapERC20.sol)MotoSwapFactory (packages/contracts/src/dex/core/MotoSwapFactory.sol)MotoSwapPair (packages/contracts/src/dex/core/MotoSwapPair.sol)MotoSwapLibrary (packages/contracts/src/dex/periphery/MotoSwapLibrary.sol)MotoSwapRouter02 (packages/contracts/src/dex/periphery/MotoSwapRouter02.sol)FeeRouter (packages/contracts/src/fees/FeeRouter.sol)RakebackV2 (packages/contracts/src/fees/RakebackV2.sol)Collector (packages/contracts/src/fees/Collector.sol)QuoteAssetRegistry (packages/contracts/src/fees/QuoteAssetRegistry.sol)BuybackBurner (packages/contracts/src/fees/BuybackBurner.sol)BuybackDistributor (packages/contracts/src/fees/BuybackDistributor.sol)CreatorFeeRegistry (packages/contracts/src/fees/CreatorFeeRegistry.sol)CreatorFeeVault (packages/contracts/src/fees/CreatorFeeVault.sol)MasterChef (packages/contracts/src/chef/MasterChef.sol)RewardVestingEscrow (packages/contracts/src/chef/RewardVestingEscrow.sol)VampChef (packages/contracts/src/chef/VampChef.sol)MotoStaking (packages/contracts/src/stake/MotoStaking.sol)PveVault (packages/contracts/src/launcher/PveVault.sol)MotocatStakingV3 (packages/contracts/src/nft/MotocatStakingV3.sol)MotoToken (packages/contracts/src/token/MotoToken.sol)MotoTokenUnwrapper (packages/contracts/src/token/MotoTokenUnwrapper.sol)TreasuryVesting (packages/contracts/src/token/TreasuryVesting.sol)MotoSwapZap (packages/contracts/src/zap/MotoSwapZap.sol)UniswapZap (packages/contracts/src/zap/UniswapZap.sol)UUPSRenounceable (packages/contracts/src/upgrade/UUPSRenounceable.sol)Snapshotter (packages/contracts/src/periphery/Snapshotter.sol)Not covered on this page