Skip to main content

Question it answers

“Give me every ERC-20 transfer: all transfers of token 0x…, or every transfer wallet 0x… sent or received.”
One flat event log of token transfers, served two ways from a single sync: by-token (every movement of a given token) and by-wallet (every transfer a wallet was on either side of). Each row is one transfer carrying exactly one token and one amount; there’s no USD enrichment, because a transfer event carries no price.

What you get

One row per transfer, from Moralis-indexed, normalized per-block onchain data:

Source

The transform reads a single per-block array, tokenTransfers, and lands one row per transfer. Fields map straight from the source struct (tokenAddress → token_address, fromAddress → from_address, toAddress → to_address, amount → amount, type → transfer_type, initiatedBy → initiated_by). There’s no price join: transfers are unpriced, so there is no amount_usd column. The per-transfer vendor_event_id is widened beyond (tx_hash, log_index) so the id stays unique on Solana, where logIndex is not row-unique within an instruction (see Multichain).

Destination

ClickHouse uses the collapsing log-table pattern (see the recipes overview) so chain reorganizations self-correct: the +1/−1 reorg pair for a row shares an identical key and collapses on merge. The fact table’s sort key is token-first, so all transfers of a token are a contiguous range read; by-wallet reads are accelerated by data-skipping bloom filters rather than a second sort key.

Full schema

Below is the complete read table this recipe produces. It’s a starting point: keep the columns you need and drop the rest (see Schema & flexibility). amount is stored raw (uint256 token units) because the transfer event carries no decimals; scale by 10^token_decimals at read time.
The sign column drives reorg collapsing: read with FINAL or sum(sign), never a bare WHERE sign = 1. A single-node setup can use CollapsingMergeTree(sign) without the replication path.
MySQL is the same shape with the same indexes. amount uses NUMERIC(76, 0) so large raw uint256 values don’t overflow; position is the block-level cursor used during backfill.

Example reads

All transfers of a token, newest first (ClickHouse):
All transfers involving a wallet on either side (bloom-pruned):
Net amount received by a wallet for one token (sign-aware, cheaper than FINAL):

Modes

Shipped defaults: ClickHouse hybrid (backfill → realtime), Postgres / MySQL historical (one-shot backfill). For live/reorg-safe ingestion, use ClickHouse; see the overview.
The realtime reorg path needs a single-column UNIQUE on the position column, but position is block-level (many transfers share one block), so array-expanded transfer rows can only carry a composite unique. Run realtime/hybrid on ClickHouse, where the collapsing log table corrects reorgs per-block. The Postgres / MySQL configs are intended for historical backfill.

Multichain

The recipe is chain-parametrized via the chain setting: point it at any supported EVM chain or Solana. On Solana, multiple events in one instruction can share a logIndex, so the vendor_event_id is widened with (from_address, to_address, token_address, amount) to keep rows distinct; the transfer log it produces is identical in shape.

How much data does this consume?

A continuous real-time feed centered on tokenTransfers measured: Measured 2026-07-31, 30 sampled partitions per chain, metered delivery. Billing is on uncompressed bytes. The Ethereum and BNB Smart Chain runs included the small tokenPriceUpdates slice, so they run slightly high for a transfers-only feed; the Solana run included the nftTokenTransfers slice, which is negligible there. Size your allowance against the busy figure, not the typical one. Chain activity is bursty, and the same feed can land on either side of a fixed allowance depending on the month. Three caveats:
  • Real-time only. A historical backfill is billed separately and is usually the larger number; its size depends entirely on how far back you go.
  • Measured chains only. Volume concentrates hard per chain (BNB Smart Chain measured over 10x Ethereum on the same selection), so these figures do not extrapolate to unmeasured chains.
  • Filtering does not reduce it. Restricting to specific tokens or wallets happens after the data is delivered. To reduce cost, narrow the block range, not the entity set.
If you want your exact scope measured before you commit to a plan, reach out and we will probe it against the live feed rather than estimate it.

Fidelity gaps

The recipe lands exactly what the tokenTransfers array carries. Fields a transfers endpoint might surface that have no onchain source in this array are omitted:
  • USD value: a transfer carries no price. Pricing requires joining the same-block price data; that’s out of scope for a plain transfer log (see Token Prices for the price-join pattern).
  • Token metadata (symbol, name, decimals, logo, verified/spam flags): these come from a separate token-metadata sync, not the transfer event. amount is therefore stored raw, not decimal-adjusted.
  • Pre/post balances: the balance surface lives in the balances recipes (Token Balances by Token / by Wallet); this transfer log stays a flat event stream.

Migrating from the REST API

This recipe replaces both ERC-20 transfer REST endpoints from one sync: the same token_transfers table serves the by-token and by-wallet reads.
Not 1:1. The REST responses inlined token_name / token_symbol / token_decimals and a pre-scaled value_decimal; this recipe’s amount is raw uint256. Pair it with Token Metadata and join on token_address to scale amounts and label tokens. token_logo, possible_spam, verified_contract, security_score, and the address *_label / *_entity fields are off-chain signals; add them from your own token/label lists or drop them.
Field mapping (both endpoints share it; calculated = derived at read time, add yourself = off-chain): Neither endpoint returned USD values (a transfer carries no price), so every mapped value is exact from the chain, and the feed adds two fields the API omitted: transfer_type and initiated_by.

GET /erc20/{address}/transfers: transfers by token

Every transfer of one token becomes a plain by-token read of token_transfers, the table’s primary sort, so it’s a contiguous range scan:
  • The endpoint’s from_date / to_date, from_block / to_block, and order params are plain WHERE / ORDER BY clauses; no fixed page size.
  • High-volume tokens have enormous histories: keyset-paginate on (block_number, log_index), not OFFSET.
  • For a token’s complete history, backfill from its deploy block (historical or hybrid mode); realtime alone only captures new transfers.

GET /{address}/erc20/transfers: transfers by wallet

A wallet’s transfers are the rows where it appears on either side; the recipe indexes both from_address and to_address, so it’s one query:
  • In each row the token_address is the token, not the wallet; derive direction by comparing the wallet to from_address / to_address, as above.
  • The endpoint’s contract_addresses filter becomes AND t.token_address = ANY($tokens); block/date windows and order map the same way as by-token.
  • On ClickHouse the by-wallet read is served by the bloom_filter skip indexes on from_address / to_address (see Example reads) rather than a second sort key.

Token Holders

The balance roll-up these transfers feed: all non-zero holders of a token.

Accounting & Tax

Per-asset transfer ledgers for reconciliation, valued via Token Prices.