Stake Motocat NFTs
Escrow-stake Motocats, read the historical checkpoints, and claim PvE drops from the vault.
MotocatStakingV3 sits behind a UUPS proxy. The address to call is addresses.motocatStaking from
GET /config, and the collection is addresses.motocatNft. A non-zero value there means the
contract is deployed. Motoswap runs on Ethereum only for now. The per-contract status for Ethereum is in one place:
deployment status. The contract has no pause function and no setter that gates
unstake.
It is a pure escrow with a memory. Deposit Motocats, 1 cat = 1 share, instant unstake, no lockup, no fee, and no rewards paid by this contract at all. What it keeps is history: per-wallet and global staked balances checkpointed per block, which two consumers read:
PveVaultsizes every PvE drop against the snapshot at the block before the credit landed, and pays claims there, forever.- The Points indexer reads a wallet's staked cat count (staked only; a held cat counts for nothing) at the block before each swap for the Points boost. The boost formula is not published.
If you integrated the old contract
Earlier deployments carried a streamed reward track (depositReward,
pendingReward, claim/claimAll, warmup, a 32-token list, pause). All of
it is gone. Stake and unstake gas no longer varies with anything global, there
is no pause, and the only claim surface is the vault below.
Stake
import { parseAbi } from "viem";
// 1. Approve the collection once (setApprovalForAll).
await wallet.writeContract({
address: motocatNft, // both addresses come from GET /config
abi: parseAbi(["function setApprovalForAll(address operator, bool approved)"]),
functionName: "setApprovalForAll",
args: [motocatStaking, true],
});
// 2. Stake any number of cats in one transaction. The batch is uncapped by
// the contract; per-transaction gas caps it around a couple hundred cats.
await wallet.writeContract({
address: motocatStaking,
abi: parseAbi(["function stake(uint256[] tokenIds)"]),
functionName: "stake",
args: [[1n, 42n, 137n]],
});Two gates to know about:
DirectTransferNotAllowed- a rawsafeTransferFrominto the contract reverts. Staking only goes throughstake(), so every escrowed cat has a recorded staker who can withdraw it.SeedingNotClosed- on a freshly deployed contract, public staking opens only after the team finishes seeding the cats staked on distribution day (a one-way switch,seedingClosed()). Unstake is never gated, in any state.
Unstake
await wallet.writeContract({
address: motocatStaking,
abi: parseAbi(["function unstake(uint256[] tokenIds)"]),
functionName: "unstake",
args: [[1n, 42n]],
});Instant, always. NotStaker(tokenId) if you did not stake that specific cat;
UnstakeExceedsStaked if you list more than you have; EmptyArray for [].
Unstaking after a PvE credit does not change that credit. Your share was
fixed at its snapshot block.
Read state, current and historical
const count = await publicClient.readContract({
address: motocatStaking,
abi: parseAbi(["function stakedBalanceOf(address wallet) view returns (uint256)"]),
functionName: "stakedBalanceOf",
args: [user],
});
const ids = await publicClient.readContract({
address: motocatStaking,
abi: parseAbi(["function stakedTokensOf(address wallet) view returns (uint256[])"]),
functionName: "stakedTokensOf",
args: [user],
});
// The checkpoint reads - the whole point of the contract. "As of the END of
// block N." Asking about the current or a future block reverts FutureLookup.
const wasStaked = await publicClient.readContract({
address: motocatStaking,
abi: parseAbi(["function stakedBalanceOfAt(address wallet, uint256 blockNumber) view returns (uint256)"]),
functionName: "stakedBalanceOfAt",
args: [user, creditBlock - 1n],
});
// totalStakedAt(blockNumber) is the matching global read, and the
// denominator every PvE split uses.isStaked(uint256 tokenId) view returns (bool) answers for one cat without listing a wallet.
stakedTokensOf returns the wallet's whole list in one call, so its cost grows with the number of
cats the wallet has staked. For a large wallet, prefer stakedBalanceOf when you only need the
count. A paged read, stakedTokensOfSlice(address, uint256, uint256), exists in the contract's
source and is not on the deployed contract yet: a call to it reverts. It arrives with a contract
upgrade, so do not build on it until then.
Claim PvE drops (from the vault, not here)
Read addresses.pveVault from /config and branch on a zero address before calling anything
below.
Launch PvE proceeds land in PveVault and are claimed there. In the app, holders of staked
Motocats claim on Motoswap's staking page (/stake/motocats) from launch day. One credit per
token, ever; your share is fixed at the credit block and never expires. You do not need
to still be staked to claim it.
// Argument order: TOKEN first, then wallet.
const owed = await publicClient.readContract({
address: pveVault,
abi: parseAbi(["function claimable(address token, address wallet) view returns (uint256)"]),
functionName: "claimable",
args: [token, user],
});
// Single token, or any set in one transaction. Entries with nothing to claim
// are skipped; the call reverts only if the WHOLE batch yields nothing.
await wallet.writeContract({
address: pveVault,
abi: parseAbi(["function claimMany(address[] tokens) returns (uint256)"]),
functionName: "claimMany",
args: [[tokenA, tokenB]],
});The eligibility rule, precisely: the vault reads
stakedBalanceOfAt(wallet, creditBlock - 1). Staking in the same block as
the credit earns nothing from it (reading the block before stops anyone staking just in time for a
credit); staking after it earns nothing from that token and everything from the
next one.
pending() is not a wallet balance
PveVault.pending(token) is escrow waiting to be credited (a launch that
landed while nobody was staked), not anyone's claimable amount. Per-wallet
truth is claimable(token, wallet).
Events
event Staked(address indexed wallet, uint256[] tokenIds);
event Unstaked(address indexed wallet, uint256[] tokenIds);
// Distribution-day seed rows. Emitted ALONGSIDE Staked for the same cats, so
// indexers that only know Staked keep working; Seeded marks rows where the
// wallet did not act (do not score them as user actions).
event Seeded(address indexed wallet, uint256[] tokenIds);
event SeedingClosed();
// Cross-chain mirror, for future networks. When an outbox is registered, every stake and unstake
// pushes the wallet's new staked count (a number, never an NFT) to it. A failed push never reverts the stake: it emits
// BroadcastFailed, and anyone can repair it later with rebroadcast(wallet).
event Broadcast(address indexed wallet, uint256 stakedCount);
event BroadcastFailed(address indexed outbox, address indexed wallet);Index Staked and Unstaked for balances. Broadcast is not a second source of truth for them,
and it is not guaranteed to appear at all: with no outbox registered, neither Broadcast nor
BroadcastFailed fires. outboxCount() tells you how many are registered.
The vault side emits PveReceived, PveCredited(token, amount, totalStaked, creditBlock), PveHeld, PveClaimed(token, wallet, amount).
Read via REST
GET /motocats/{address} -> the wallet's cat inventory
GET /pve/wallet/{address} -> every token credited to this wallet's history,
with its credit block and claim status; pair it
with claimable() for live amountsCommon errors
| Error | Cause |
|---|---|
DirectTransferNotAllowed() | Raw NFT transfer to the contract. Use stake(). |
SeedingNotClosed() | Public staking before the seed latch closed. |
NotStaker(tokenId) | unstake for a cat you did not stake. |
UnstakeExceedsStaked() | unstake more than you have staked. |
EmptyArray() | stake/unstake with []. |
FutureLookup() | Checkpoint read at the current or a future block. |
NothingToClaim() (vault) | claim/claimMany with no claimable balance. |