Watch on-chain events
Stream events via your own eth_getLogs provider, or poll the REST activity feed.
Two integrator options. Pick based on your existing infra.
Deployment status
Status is per contract, and the deployment status table on the addresses page is the reference.
Every address comes from GET /config. A zero address means the contract is
unset for the current phase. Do not subscribe to a zero address.
Option A: your own event-log provider (recommended)
Any RPC provider that supports eth_getLogs / websocket subscriptions works.
The relevant contracts and their high-value events:
The farm, staking and vesting contracts
| Contract | Event | Meaning |
|---|---|---|
VampChef (/config.addresses.vampChef) | Deposit, Withdraw, EmergencyWithdraw, RewardPaid, RewardForfeited, RewardShortfall (not live yet, arrives with a contract upgrade) | Launch farm activity. Withdraw is instant here: there is no WithdrawQueued on this chef. RewardShortfall is in the contract source but not in the deployed implementation. |
RewardVestingEscrow | Deposited, Claimed | The streamed half of every farm harvest. |
| Uniswap V2 pair | Mint, Burn, Swap, Sync, Transfer | The LP tokens the launch farm accepted. See the feeTo log order below before you index Mint. |
UniswapZap | Zapped | One-transaction zap into a Uniswap V2 LP. |
MotoStaking | Staked, Appended, UnstakeRequested, Unstaked, Claimed, ClaimedAsMoto, RewardNotified | MOTO staking activity. |
MotocatStaking | Staked, Unstaked, Seeded, SeedingClosed, Broadcast, BroadcastFailed | NFT escrow staking (no reward events; PvE pays from PveVault). |
The DEX contracts
| Contract | Event | Meaning |
|---|---|---|
FeeRouter | Swap | Canonical trade event. One per fee-paying swap (multi-hop = 1 event). user is msg.sender of the FeeRouter call, and feeAmount is the exact protocol fee. |
FeeRouter | CreatorFee | A creator-fee skim on the same quote leg. |
MotoSwapPair | Swap | Per-hop swap. Emitted N times for an N-hop trade. |
MotoSwapPair | Sync | Reserves updated. Fires on every mint, burn, swap and sync. skim does not emit it. |
MotoSwapPair | Mint / Burn | LP added/removed. |
MotoSwapFactory | PairCreated, FeeToSet, SwapFeeSet | New pair deployed; protocol LP-fee recipient moved; pair fee changed. |
MasterChef | Deposit, WithdrawQueued, WithdrawClaimed, EmergencyWithdraw, RewardPaid, RewardShortfall, RewardForfeited | Stakes and withdrawals. No emission campaign is running and none is planned. |
RakebackV2 | RootPosted, SlicePosted, Activated, Claimed, ClaimedAsMoto, Expired, Swept | Weekly epoch roots and the payouts claimed against them. Claimed indexes epoch and token only (the claimant account is not indexed, so match it in your handler); ClaimedAsMoto does index the user. |
PveVault | PveReceived, PveCredited, PveHeld, PveClaimed, Forwarded | PvE drops for staked Motocats. |
Collector | Distributed, BucketPaid, BucketSkipped, BucketHeld, HeldReleased, HeldForceReleased | Protocol-fee routing. |
CreatorFeeVault | Accrued, Claimed | Creator fee accrual + payout. |
Full event signatures (with indexed args):
reference/addresses-config-events.
The feeTo log order on Uniswap V2 pairs
Every indexer that follows the launch farming pairs hits this. A Mint event
carries no recipient:
event Mint(address indexed sender, uint256 amount0, uint256 amount1); // sender is the routerSo you find the LP recipient from the LP-token Transfer events from the zero
address that the same pair emits before the Mint, in the same transaction.
mint() emits up to three of them, always in this order:
_mintFeeruns first. When the factory'sfeeTois set and fees have accrued, it mints the protocol's LP share:Transfer(0x0 -> feeTo).- On a pair's first ever mint only,
_mint(address(0), MINIMUM_LIQUIDITY)mints the minimum liquidity to the zero address:Transfer(0x0 -> 0x0, 1000). _mint(to, liquidity)mints the depositor's LP:Transfer(0x0 -> to).
Then the pair emits Sync and Mint.
The rule: the recipient of a Mint is the to of the last Transfer from
the zero address that the same pair emitted before that Mint, in the same
transaction. Step 3 always comes last, so this holds in every case. An
indexer that takes the first such Transfer credits the deposit to feeTo
whenever step 1 fired.
Do not filter out transfers to feeTo. The last transfer is never the fee
mint, and feeTo can add liquidity itself. In that one case a feeTo filter
drops the real recipient.
The Uniswap V2 factory 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f returns
feeTo() = 0xf38521f130fcCF29dB1961597bc5d2B60F995f85, so step 1 fires on the
launch farming pairs. Read it yourself rather than trusting this page. Example:
transaction
0x751c1e7c6f0257510e478335f0338d013694b7e2e636239bf31b784dfb9b6a71, pair
0xca7c2771D248dCBe09EABE0CE57A62e18dA178c0 (launch farm pool 9). The pair's
logs, in order:
| Log index | Event |
|---|---|
| 613 | Sync |
| 614 | Swap |
| 621 | Transfer(0x0 -> 0xf385…5f85), the fee mint to feeTo |
| 622 | Transfer(0x0 -> 0x2b9d…0ab0), the depositor's LP. This is the recipient. |
| 623 | Sync |
| 624 | Mint |
You supply your own Ethereum mainnet RPC endpoint. The sample reads it from the
RPC_URL environment variable and stops with a clear error when it is unset.
import { createPublicClient, http, zeroAddress, type Address, type Hex } from "viem";
import { mainnet } from "viem/chains";
// Your own Ethereum mainnet RPC endpoint.
const RPC_URL = process.env.RPC_URL;
if (!RPC_URL) throw new Error("Set RPC_URL to your own Ethereum mainnet RPC endpoint.");
const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) });
const TRANSFER = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
const MINT = "0x4c209b5fc8ad50758f13e2e1088ba56a560dff690a1c6fef26394f4c03821c4f";
const topicToAddress = (t: Hex) => (`0x${t.slice(26)}`) as Address;
/** LP recipient of every Mint in a transaction: [{ pair, mintLogIndex, recipient, liquidity }]. */
export async function mintRecipients(hash: Hex) {
const { logs } = await client.getTransactionReceipt({ hash });
const out: { pair: Address; mintLogIndex: number; recipient: Address; liquidity: bigint }[] = [];
logs.forEach((mint, i) => {
if (mint.topics[0] !== MINT) return;
// Walk BACKWARDS from the Mint. The first zero-address Transfer you meet on this pair is the last one emitted.
for (let k = i - 1; k >= 0; k--) {
const l = logs[k];
if (l.address.toLowerCase() !== mint.address.toLowerCase()) continue;
if (l.topics[0] === MINT) break; // an earlier Mint on the same pair owns everything before it
if (l.topics[0] !== TRANSFER || topicToAddress(l.topics[1]!) !== zeroAddress) continue;
out.push({ pair: mint.address, mintLogIndex: mint.logIndex, recipient: topicToAddress(l.topics[2]!), liquidity: BigInt(l.data) });
break;
}
});
return out;
}
console.log(await mintRecipients("0x751c1e7c6f0257510e478335f0338d013694b7e2e636239bf31b784dfb9b6a71"));
// [{ pair: 0xca7c…78c0, mintLogIndex: 624, recipient: 0x2b9d…0ab0, liquidity: 16388034085079n }]Burn does not have this problem: it carries to. It is still preceded by
the same fee mint to feeTo, so do not read every zero-address Transfer as
a user deposit.
MotoSwapPair has the same order. mint() calls _mintFee and then
_mint(to, liquidity), and its Mint event has no recipient either. The fee
mint only happens while factory.feeTo() is non-zero. The deploy script sets
it to the zero address, which makes the whole pair fee accrue to LPs, but it
is a live setting the factory admin can change, and the factory emits
FeeToSet(previousFeeTo, newFeeTo) when it does. Apply the same last-transfer
rule from day one and a feeTo change cannot break your indexer.
Example: watch FeeRouter.Swap
import { createPublicClient, http, parseAbiItem } from "viem";
// `chain` is built from `/config.chainId` - see the quickstart. Never hardcode one.
const client = createPublicClient({ chain, transport: http(RPC) });
const feeRouter = config.addresses.feeRouter; // from GET /config; a zero address there is unset for the phase
const unwatch = client.watchEvent({
address: feeRouter,
event: parseAbiItem(
"event Swap(address indexed user, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut, address feeToken, uint256 feeAmount)",
),
onLogs: (logs) => {
for (const log of logs) {
const { user, tokenIn, tokenOut, amountIn, amountOut, feeToken, feeAmount } = log.args;
console.log(`${user} swapped ${amountIn} ${tokenIn} → ${amountOut} ${tokenOut} (fee ${feeAmount} ${feeToken})`);
}
},
});Historical backfill via eth_getLogs (chunked by block range, indexed
filters recommended for user / tokenIn / tokenOut).
user is msg.sender of the FeeRouter call: the payer, never the to
the caller passed. A contract that swaps for its users shows up as user
itself. If you need the wallet behind it, attribute to the transaction
sender.
Split fills
swap*Split calls emit one FeeRouter.Swap per leg. To attribute the
combined trade back to a single tx, group by transactionHash + user.
The amountIn and amountOut per leg are the leg's amounts, not the total.
Legs of one order can carry different feeToken values.
Getting topic0 values
Compute keccak256("EventName(type1,type2,...)"). viem's toEventSelector
gives it:
import { toEventSelector } from "viem";
toEventSelector("Swap(address,address,address,uint256,uint256,address,uint256)");
// => 0x886b45a07c2ce3f9de6744b942d8023d325ac0b64972104e38bb5f30d1452fc3 - FeeRouter.Swap
toEventSelector("Swap(address,uint256,uint256,uint256,uint256,address)");
// => 0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822 - pair SwapPair Swap and FeeRouter Swap have DIFFERENT topic0: different argument
lists. They will never collide. MotoSwapPair events share their signatures,
and so their topic0, with Uniswap V2 pair events, so filter by pair address
(from PairCreated), never by topic0 alone.
Option B: REST feed (GET /activity)
Instead of watching the chain yourself, poll the activity feed:
GET /activity?cursor=<opaque>Returns { items, nextCursor, hasMore }: cursor-paginated events (swaps, LP
adds/removes, stakes, harvests) with a normalised shape. Downsides:
- Not real-time (~1 block behind).
- The cursor is tied to the filters you passed, so you cannot reuse it for a different query.
The guide's fetch calls are simple GETs, which work from any origin. A request that triggers a CORS preflight (a script-set If-None-Match, a JSON POST) only passes for the Motoswap app origin, so make those server-side.
Upsides:
- Zero infra on your side.
- Normalised into a single
activityrecord shape.
Reorgs
Chains reorg (rarely deeper than 1 block). Two mitigations:
- Chain-direct watchers: wait
kconfirmations before treating an event as final.k = 2-3is usually enough. - REST feed: the indexer's reorg-safety already rolls back and re-derives
from block-numbered raw event tables.
GET /chain/headreturns the current tip (with the head hash folded into the ETag), so a reorg shows up as a new ETag and your next read reflects the canonical chain.
Retention
- Chain: as long as your RPC provider keeps history (archive nodes keep all of it).
- REST: the API caches ETag-versioned reads; historical windows are
bounded (
24h/7d/30dfor charts; feed is paginated by cursor back to the indexer's earliest indexed block).
What NOT to do
- Don't add up pair
Swapevents for volume. That double-counts multi-hop trades. UseFeeRouter.Swap(or the REST feed'sactivityrows, which are already de-duplicated per hop). - Don't filter pair
Swap.tofor "the trader". It's a router. UseFeeRouter.Swap.user. - Don't derive Rakeback from pair
Swap. The fee is skimmed byFeeRouterand only surfaces asfeeAmounton the FeeRouter event. - Don't pair a
Mintwith the first zero-addressTransferbefore it. That one can be the protocol fee mint tofeeTo.