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);| Field | Type | Description |
|---|---|---|
token0 | address indexed | Sorted lower token |
token1 | address indexed | Sorted higher token |
pair | address | The pool. An 82-byte ERC-1967 beacon proxy |
| (4th) | uint256 | Running 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);| Event | topic0 |
|---|---|
Swap | 0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822 |
Mint | 0x4c209b5fc8ad50758f13e2e1088ba56a560dff690a1c6fef26394f4c03821c4f |
Burn | 0xdccd412f0b1252819cb1fd330b93224ca42612892bb3f4f789976e6d81936496 |
Sync | 0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1 |
The semantics an indexer needs on top:
| Event | The trap | The rule |
|---|---|---|
Swap | sender 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 exits | On Motoswap, join with FeeRouter.Swap in the same transaction for the trader. On Uniswap V2 pairs, use the transaction sender |
Sync | None. Emitted on every reserve change, before the Swap, Mint or Burn it belongs to | The canonical reserve feed. Price from here |
Mint | No recipient field. sender is the router | Pair 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 |
Burn | to is the router on ETH exits, because the router unwraps and forwards | Use 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
);| Field | Type | Description |
|---|---|---|
user | address indexed | The 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 |
tokenIn | address indexed | Path start. WETH on ETH-in swaps |
tokenOut | address indexed | Path end. WETH on ETH-out swaps |
amountIn | uint256 | Gross input. On the fee-on-transfer entrypoints, the amount that reached the FeeRouter |
amountOut | uint256 | Net delivery after all fees |
feeToken | address | The quote asset the protocol fee was taken in |
feeAmount | uint256 | The 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.
| Contract | Status on Ethereum | Events an indexer consumes |
|---|---|---|
VampChef (launch farming, ended, /config.addresses.vampChef) | Deployed, farming ended | PoolAdded, 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 |
MotoStaking | Deployed | Staked, 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 |
MotocatStaking | Deployed | Staked, Unstaked, Seeded (distribution-day rows; wallets did not act), SeedingClosed |
MasterChef (deployed; no sustain farm is running) | Deployed | PoolAdded, PoolSet, Deposit, WithdrawQueued, WithdrawClaimed, EmergencyWithdraw, RewardPaid, RewardShortfall, RewardForfeited, EmissionStarted |
Collector | Deployed | Distributed, BucketPaid, BucketHeld, HeldReleased: the split of the protocol fee across its buckets, per distribution |
RakebackV2 | Deployed | RootPosted, 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) |
PveVault | Deployed | PveReceived, 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.
| Event | Fields | What an indexer does with it |
|---|---|---|
TokenCreated | token indexed, creator indexed, name, symbol, metadataHash, quoteToken indexed | Discovery. Insert the coin. creator is the launch signer, the attribution identity, and not necessarily the fee recipient |
Trade | trader indexed, token indexed, isBuy, ethAmount, tokenAmount, priceX18, protocolFee, creatorFee | The trade-level truth. One per curve trade, no pair leg |
FloorReached | token indexed, realEth | The coin froze. Trading is paused, tokenReserve is exactly the floor |
Graduated | token indexed, keeper indexed, plus unindexed pairWeth, pairMoto, four amounts and dustBurned | Switch the coin to pool indexing. Decode the payload to get the pairs |
PveFill | token indexed, contributor indexed, tokens | An escrow contribution for Motocat stakers |
PveRecorded / PveRecordFailed | token indexed, tokens | The graduation-time credit landed, or is still pending a retry |
FeeRecipientSet | token indexed, recipient indexed | Fires only when the recipient differs from the creator |
CreatorFeeRoutedToCollector | token indexed, amount | The 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
- Full event signatures with every field and topic.
- Watch events guide for a running viem subscription setup.
- Aggregators if your scanner also prices routes.