Guides

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.

Any RPC provider that supports eth_getLogs / websocket subscriptions works. The relevant contracts and their high-value events:

The farm, staking and vesting contracts

ContractEventMeaning
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.
RewardVestingEscrowDeposited, ClaimedThe streamed half of every farm harvest.
Uniswap V2 pairMint, Burn, Swap, Sync, TransferThe LP tokens the launch farm accepted. See the feeTo log order below before you index Mint.
UniswapZapZappedOne-transaction zap into a Uniswap V2 LP.
MotoStakingStaked, Appended, UnstakeRequested, Unstaked, Claimed, ClaimedAsMoto, RewardNotifiedMOTO staking activity.
MotocatStakingStaked, Unstaked, Seeded, SeedingClosed, Broadcast, BroadcastFailedNFT escrow staking (no reward events; PvE pays from PveVault).

The DEX contracts

ContractEventMeaning
FeeRouterSwapCanonical 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.
FeeRouterCreatorFeeA creator-fee skim on the same quote leg.
MotoSwapPairSwapPer-hop swap. Emitted N times for an N-hop trade.
MotoSwapPairSyncReserves updated. Fires on every mint, burn, swap and sync. skim does not emit it.
MotoSwapPairMint / BurnLP added/removed.
MotoSwapFactoryPairCreated, FeeToSet, SwapFeeSetNew pair deployed; protocol LP-fee recipient moved; pair fee changed.
MasterChefDeposit, WithdrawQueued, WithdrawClaimed, EmergencyWithdraw, RewardPaid, RewardShortfall, RewardForfeitedStakes and withdrawals. No emission campaign is running and none is planned.
RakebackV2RootPosted, SlicePosted, Activated, Claimed, ClaimedAsMoto, Expired, SweptWeekly 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.
PveVaultPveReceived, PveCredited, PveHeld, PveClaimed, ForwardedPvE drops for staked Motocats.
CollectorDistributed, BucketPaid, BucketSkipped, BucketHeld, HeldReleased, HeldForceReleasedProtocol-fee routing.
CreatorFeeVaultAccrued, ClaimedCreator 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 router

So 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:

  1. _mintFee runs first. When the factory's feeTo is set and fees have accrued, it mints the protocol's LP share: Transfer(0x0 -> feeTo).
  2. 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).
  3. _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 indexEvent
613Sync
614Swap
621Transfer(0x0 -> 0xf385…5f85), the fee mint to feeTo
622Transfer(0x0 -> 0x2b9d…0ab0), the depositor's LP. This is the recipient.
623Sync
624Mint

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 Swap

Pair 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 activity record shape.

Reorgs

Chains reorg (rarely deeper than 1 block). Two mitigations:

  • Chain-direct watchers: wait k confirmations before treating an event as final. k = 2-3 is usually enough.
  • REST feed: the indexer's reorg-safety already rolls back and re-derives from block-numbered raw event tables. GET /chain/head returns 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 / 30d for charts; feed is paginated by cursor back to the indexer's earliest indexed block).

What NOT to do

  • Don't add up pair Swap events for volume. That double-counts multi-hop trades. Use FeeRouter.Swap (or the REST feed's activity rows, which are already de-duplicated per hop).
  • Don't filter pair Swap.to for "the trader". It's a router. Use FeeRouter.Swap.user.
  • Don't derive Rakeback from pair Swap. The fee is skimmed by FeeRouter and only surfaces as feeAmount on the FeeRouter event.
  • Don't pair a Mint with the first zero-address Transfer before it. That one can be the protocol fee mint to feeTo.

On this page