Integrations

Scanners and indexers

The event catalog, attribution rules, and enumeration patterns for indexing Motoswap from raw logs.

Everything the official indexer knows, it learned from logs; there is no private state. This page is the map: which events exist, which fields mean what, how to decode them, and the attribution traps our own indexer had to solve that yours will hit too.

What you can index

Status is per contract. GET /config is the source, and the per-contract table with addresses is on addresses.

In short: the Motoswap factory, pairs, FeeRouter, Collector, RakebackV2 and the moto.fun curve are deployed, next to the MOTO token, MOTO staking, Motocat staking and the launch farm (/config keys factory, feeRouter, collector, rakeback, launchpadCurve, motoToken, motoStaking, motocatStaking, vampChef). Read every address from /config, and treat a zero key there as unset for the current phase. The launch farm's pools are Uniswap V2 pairs (plus one single-sided MOTO pool) from launch farming (ended), not Motoswap pairs; GET /farms lists them as lpToken, and /config.addresses.motoUniswapPair is the MOTO/WETH one.

The pair event set below is the same on both. A Motoswap pair emits the Uniswap V2 events with identical signatures and topics, so a decoder you build against Uniswap V2 pairs works unchanged on Motoswap pairs. The trade-level FeeRouter.Swap event exists only on Motoswap.

Enumerate from genesis

PairCreated on the factory is the discovery event, exactly as in V2:

event PairCreated(address indexed token0, address indexed token1, address pair, uint256);
FieldTypeDescription
token0address indexedSorted lower token
token1address indexedSorted higher token
pairaddressThe pool. An 82-byte ERC-1967 beacon proxy
(4th)uint256Running pair count

Watch the factory address from /config, backfill with getLogs, and treat the pair set as append-only.

Pools are proxies

eth_getCode on a Motoswap pair returns 82 bytes, not V2 pair bytecode, and bytecode verification against a V2 template will fail. Identity comes from the factory event or from pair.factory(), not the code. Offline address derivation needs factory.pairCodeHash(), never a hardcoded hash. The recipe is on aggregators.

The core event set

Pair: Swap, Sync, Mint, Burn

Signatures and indexed flags, from the pair source. They match Uniswap V2 exactly, topics included:

event Swap(
    address indexed sender,
    uint256 amount0In,
    uint256 amount1In,
    uint256 amount0Out,
    uint256 amount1Out,
    address indexed to
);
event Mint(address indexed sender, uint256 amount0, uint256 amount1);
event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to);
event Sync(uint112 reserve0, uint112 reserve1);
Eventtopic0
Swap0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822
Mint0x4c209b5fc8ad50758f13e2e1088ba56a560dff690a1c6fef26394f4c03821c4f
Burn0xdccd412f0b1252819cb1fd330b93224ca42612892bb3f4f789976e6d81936496
Sync0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1

The semantics an indexer needs on top:

EventThe trapThe rule
Swapsender is the router. to is often a contract too: the next pair on a multi-hop, the FeeRouter when the fee comes off the output, the router on ETH exitsOn Motoswap, join with FeeRouter.Swap in the same transaction for the trader. On Uniswap V2 pairs, use the transaction sender
SyncNone. Emitted on every reserve change, before the Swap, Mint or Burn it belongs toThe canonical reserve feed. Price from here
MintNo recipient field. sender is the routerPair it with the last LP Transfer from the zero address that the same pair emitted before it in the same transaction. See the next section
Burnto is the router on ETH exits, because the router unwraps and forwardsUse the transaction sender, or the LP Transfer(user, pair, ...) that precedes it, for the withdrawing account

The feeTo transfer comes before the depositor's

The recipient of a Mint is the to of the last LP Transfer from the zero address that the same pair emitted before it in the same transaction, never the first. In mint() the pair runs _mintFee before it mints the depositor's LP tokens, so when the factory's feeTo is set, a Transfer(0x0, feeTo, ...) comes first. The Uniswap V2 factory has feeTo set, and the Motoswap pair runs the same order.

The full rule, the first-mint case, a worked mainnet transaction and a runnable sample are in the feeTo log order on the events guide.

Decode pair logs

This runs as written once you set RPC_URL to your own Ethereum mainnet RPC endpoint. It reads the Motoswap MOTO/WETH pair, found with factory.getPair. Point pair at any other Motoswap pair (from factory.getPair or GET /dex/pools) and nothing else changes.

import { createPublicClient, http, parseAbi, parseEventLogs, zeroAddress } from "viem";

const API = "https://api.motoswap.org";
const cfg = await fetch(`${API}/config`).then((r) => r.json());

// 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({ transport: http(RPC_URL) });

// The Motoswap MOTO/WETH pair, read from the factory in /config.
const factoryAbi = parseAbi(["function getPair(address tokenA, address tokenB) view returns (address)"]);
const pair = await client.readContract({
  address: cfg.addresses.factory,
  abi: factoryAbi,
  functionName: "getPair",
  args: [cfg.addresses.motoToken, cfg.addresses.weth],
});
if (pair === zeroAddress) throw new Error("No Motoswap MOTO/WETH pair at this factory.");

const pairAbi = parseAbi([
  "event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to)",
  "event Mint(address indexed sender, uint256 amount0, uint256 amount1)",
  "event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to)",
  "event Sync(uint112 reserve0, uint112 reserve1)",
  "event Transfer(address indexed from, address indexed to, uint256 value)",
]);

const head = await client.getBlockNumber();
const raw = await client.getLogs({ address: pair, fromBlock: head - 300n, toBlock: head });
const logs = parseEventLogs({ abi: pairAbi, logs: raw });

for (const log of logs) {
  if (log.eventName === "Swap") {
    const { amount0In, amount1In, amount0Out, amount1Out, to } = log.args;
    console.log("Swap", { amount0In, amount1In, amount0Out, amount1Out, to });
  }
  if (log.eventName === "Sync") {
    console.log("Sync", log.args.reserve0, log.args.reserve1);
  }
  if (log.eventName === "Mint") {
    // The LP recipient is the last zero-address Transfer the pair emitted
    // before this Mint in the same transaction.
    const recipient = logs
      .filter(
        (l) =>
          l.eventName === "Transfer" &&
          l.transactionHash === log.transactionHash &&
          l.logIndex < log.logIndex &&
          l.args.from === zeroAddress,
      )
      .at(-1)?.args.to;
    console.log("Mint", log.args.amount0, log.args.amount1, "LP to", recipient);
  }
  if (log.eventName === "Burn") {
    console.log("Burn", log.args.amount0, log.args.amount1, "to", log.args.to);
  }
}

Every amount comes back as a bigint. Keep it that way: a reserve does not fit in a JS Number.

Reserves and price

Sync carries both reserves as uint112. Spot price is their ratio, scaled for decimals, in integer math:

const pairReadAbi = parseAbi([
  "function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)",
  "function token0() view returns (address)",
  "function token1() view returns (address)",
]);
const erc20Abi = parseAbi(["function decimals() view returns (uint8)"]);

const [[reserve0, reserve1], token0, token1] = await Promise.all([
  client.readContract({ address: pair, abi: pairReadAbi, functionName: "getReserves" }),
  client.readContract({ address: pair, abi: pairReadAbi, functionName: "token0" }),
  client.readContract({ address: pair, abi: pairReadAbi, functionName: "token1" }),
]);
const [decimals0, decimals1] = await Promise.all(
  [token0, token1].map((address) =>
    client.readContract({ address, abi: erc20Abi, functionName: "decimals" }),
  ),
);

// Price of one whole token0 in token1, as an 18-decimal fixed-point bigint.
const price0In1 =
  (reserve1 * 10n ** BigInt(decimals0) * 10n ** 18n) / (reserve0 * 10n ** BigInt(decimals1));

Spot price ignores the fee and the trade size. For an executable price use the amount-out formula with the live pair fee, and on Motoswap the FeeRouter skims on top of it: the quote recipe. The pair also keeps the V2 cumulative price accumulators (price0CumulativeLast, price1CumulativeLast) for TWAPs.

FeeRouter.Swap: the trade-level event

Motoswap only: a trade that never touches the FeeRouter does not emit it. A multi-hop trade emits N pair Swaps but one of these, and it is the only place the net delivery and the exact protocol fee appear:

event Swap(
    address indexed user,
    address indexed tokenIn,
    address indexed tokenOut,
    uint256 amountIn,
    uint256 amountOut,
    address feeToken,
    uint256 feeAmount
);
FieldTypeDescription
useraddress indexedThe caller of the FeeRouter (msg.sender). The payer, not the to recipient. They differ when a contract swaps on someone's behalf: an aggregator's settlement contract is the user on every swap it routes
tokenInaddress indexedPath start. WETH on ETH-in swaps
tokenOutaddress indexedPath end. WETH on ETH-out swaps
amountInuint256Gross input. On the fee-on-transfer entrypoints, the amount that reached the FeeRouter
amountOutuint256Net delivery after all fees
feeTokenaddressThe quote asset the protocol fee was taken in
feeAmountuint256The protocol fee transferred to the Collector. Creator fees are not included

A split fill emits one Swap per leg, not one for the whole order, because legs can pay their fee in different quote assets. Sum the legs of a transaction if you want order-level volume.

A creator fee, when one applies, has its own event on the FeeRouter: CreatorFee(address indexed token, address indexed creator, address quoteAsset, uint256 amount).

Volume, fee revenue, and per-trade accounting all reconcile from these events. Do not sum pair legs to get trade volume; on multi-hop and interior-quote routes the legs do not sum to the delivery.

Every Motoswap pair Swap has a FeeRouter.Swap

Only the core router and the FeeRouter can reach pair.swap, and only the FeeRouter can call the core router's swap functions. So every pair Swap on Motoswap sits in a transaction with a FeeRouter.Swap. When the protocol fee is set to 0 the event is still emitted, with feeAmount 0. Trade-level metrics come from the FeeRouter event; pool-level flow comes from pair events. Keep the two pipelines separate and they never double-count.

Module catalog

Beyond the DEX core, each module has a small, stable event set. The names below are the events an indexer consumes, checked against the contract source; admin and config events are left out. Full signatures with every field: addresses, config and events.

ContractStatus on EthereumEvents an indexer consumes
VampChef (launch farming, ended, /config.addresses.vampChef)Deployed, farming endedPoolAdded, PoolSet, Deposit, Withdraw, EmergencyWithdraw, RewardPaid, RewardForfeited, EmissionStarted, RewardShortfall (not live yet, arrives with a contract upgrade). Withdrawals are one step here: there is no WithdrawQueued or WithdrawClaimed
MotoStakingDeployedStaked, Imported, Appended, UnstakeRequested, Unstaked, plus RewardNotified per revshare inflow and Claimed / ClaimedAsMoto per payout. ClaimedAsMoto needs a FeeRouter wired into the deployed implementation, which arrives with a contract upgrade
MotocatStakingDeployedStaked, Unstaked, Seeded (distribution-day rows; wallets did not act), SeedingClosed
MasterChef (deployed; no sustain farm is running)DeployedPoolAdded, PoolSet, Deposit, WithdrawQueued, WithdrawClaimed, EmergencyWithdraw, RewardPaid, RewardShortfall, RewardForfeited, EmissionStarted
CollectorDeployedDistributed, BucketPaid, BucketHeld, HeldReleased: the split of the protocol fee across its buckets, per distribution
RakebackV2DeployedRootPosted, SlicePosted, Activated, Expired, RootCancelled: the weekly epoch roots. Claimed and ClaimedAsMoto: the Rakeback ledger paid against them. Claimed(epoch, token, index, account, amount) indexes epoch and token only. The claimant account is NOT indexed, so you cannot topic-filter by claimant; filter by epoch or token and match account in your handler (ClaimedAsMoto does index the user)
PveVaultDeployedPveReceived, PveCredited, PveHeld, PveClaimed, Forwarded: the PvE drop ledger for Motocat stakers

"Deployed" means the /config key is set, there is code at the address, and every event named in that row is in the deployed implementation, except where a row marks one "not live yet". Read the address from /config in every case, and never subscribe to a zero address.

A Rakeback Claimed row is not the whole story

A Claimed row alone does not tell you what a wallet was owed, because a leaf is all-or-nothing against a published root. Consume the root lifecycle (RootPosted / SlicePosted / Activated) to know what was payable per (epoch, token), and Expired to know what lapsed unclaimed. Unclaimed Rakeback expires after 30 days and is swept to the treasury.

moto.fun curve events

moto.fun coins emit nothing on the factory or the pairs until they graduate. Their whole pre-graduation life is logs from one address, the LaunchpadCurve singleton, which is also the discovery source: there is no factory to enumerate here.

EventFieldsWhat an indexer does with it
TokenCreatedtoken indexed, creator indexed, name, symbol, metadataHash, quoteToken indexedDiscovery. Insert the coin. creator is the launch signer, the attribution identity, and not necessarily the fee recipient
Tradetrader indexed, token indexed, isBuy, ethAmount, tokenAmount, priceX18, protocolFee, creatorFeeThe trade-level truth. One per curve trade, no pair leg
FloorReachedtoken indexed, realEthThe coin froze. Trading is paused, tokenReserve is exactly the floor
Graduatedtoken indexed, keeper indexed, plus unindexed pairWeth, pairMoto, four amounts and dustBurnedSwitch the coin to pool indexing. Decode the payload to get the pairs
PveFilltoken indexed, contributor indexed, tokensAn escrow contribution for Motocat stakers
PveRecorded / PveRecordFailedtoken indexed, tokensThe graduation-time credit landed, or is still pending a retry
FeeRecipientSettoken indexed, recipient indexedFires only when the recipient differs from the creator
CreatorFeeRoutedToCollectortoken indexed, amountThe creator leg failed over to the protocol on that trade

The full catalog, including the admin and bounty events, is in the moto.fun event reference.

Curve parameters are per coin. The virtual reserves and the floor are fixed for a coin at launch, so read them once with paramsOf(token) on the curve and keep them. Do not price every coin from one global set. LaunchParamsSet marks the block from which new coins use a new parameter set. Coins that already exist keep theirs.

Key the TokenCreated decoder on topic0. It is 0x6223a619ba904a9914dac4461d25f8f27e372c0a030791c3a5c012768765ef45, the signature that carries quoteToken. See which asset a coin is quoted in, and tracking one coin from launch to pools for the step-by-step event walk.

Three traps specific to this event set

Trade.isBuy is not indexed. You cannot topic-filter buys from sells. Decode and branch.

Graduated does not index the pair addresses. A topic filter finds the graduation and misses the pools. The pairs are in the data.

ethAmount is fee-inclusive on buys and pre-fee on sells. Pick one notion of volume and apply it consistently, or your buy and sell totals are measured differently.

Lifecycle, with the back edge

None → Trading → Frozen → Graduated is the happy path, and Frozen is not terminal. If nobody graduates a frozen coin, selling reopens 15 minutes after the freeze and a sell returns it to Trading, with no event of its own beyond the Trade. Model that edge, or a stuck-frozen row will outlive the coin's actual state.

Two ordering facts. In the transaction that freezes a coin, FloorReached is emitted before that buy's own Trade, so a pipeline that folds status in log order sees the freeze first. And Frozen is not instantaneous: a keeper normally graduates the coin within about a minute, which is several blocks in which the coin is frozen and visible as such.

Both official indexers fold status from the latest surviving lifecycle event rather than storing a status column, so a rollback is a range delete with nothing to rebuild. That is worth copying.

After graduation

The coin becomes an ordinary Motoswap token: pair Swap, Sync, Mint, Burn and one FeeRouter.Swap per trade, exactly as above. Keep both pipelines pointed at the same coin id so the history joins rather than restarting.

Graduated carries the two pair addresses in its data, and those are what you read the post-curve chart from. A graduated coin's long-range price history comes from GET /dex/pools/{pair}/chart?range=30d (hourly buckets) and ?range=1y (daily), on the Motoswap API. The moto.fun backend's own candles cover the curve phase; the pool chart covers everything after it. See pool chart ranges for the bucket widths and the minute units, which are minutes rather than seconds.

Reorg discipline

Index by (blockNumber, txHash, logIndex), keep raw events append-only, and derive aggregates from the raw table so a rollback is a range delete plus re-derive. That is how the official indexer survives reorgs, and the shape we would recommend to anyone: an accumulator you cannot rebuild from raw rows is a liability on any chain with reorgs.

If you would rather not run your own pipeline, the REST API serves the indexed result of everything above with ETag caching. Start there and move to raw logs when you need something it does not serve. Response shapes are in the API reference.

Where to go next

On this page