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.
ClickHouse, fact_token_transfers
ClickHouse, fact_token_transfers
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.Postgres, token_transfers
Postgres, token_transfers
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):FINAL):
Modes
Shipped defaults: ClickHousehybrid (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 thechain 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.
Fidelity gaps
The recipe lands exactly what thetokenTransfers 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.amountis 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 sametoken_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.
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, andorderparams are plainWHERE/ORDER BYclauses; no fixed page size. - High-volume tokens have enormous histories: keyset-paginate on
(block_number, log_index), notOFFSET. - For a token’s complete history, backfill from its deploy block (
historicalorhybridmode);realtimealone 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_addressis the token, not the wallet; derive direction by comparing the wallet tofrom_address/to_address, as above. - The endpoint’s
contract_addressesfilter becomesAND t.token_address = ANY($tokens); block/date windows andordermap the same way as by-token. - On ClickHouse the by-wallet read is served by the
bloom_filterskip indexes onfrom_address/to_address(see Example reads) rather than a second sort key.
Related
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.

