Skip to main content

Question it answers

“Give me every NFT transfer: by contract 0x…, by wallet 0x…, or for a single (contract, token_id).”
A single recipe serves all three access paths. It mirrors the on-chain subset of Moralis GET /nft/{address}/transfers (by contract), GET /{address}/nft/transfers (by wallet), and GET /nft/{address}/{token_id}/transfers (single token). It carries no off-chain enrichment: the source array has no collection metadata or address labels (see Fidelity gaps).

What you get

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

Source

The transform reads one per-block array and lands one row per transfer: nftTokenTransfers Fields map straight from the source struct: tokenAddress, tokenId (as a string), fromAddress, toAddress, amount, tokenType (→ contract_type), and initiatedBy. There’s no price or metadata join; this is a flat event stream.

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 cleanly. The fact table’s sort key is contract-first, so by-contract is a prefix scan on token_address and by-token-id a prefix scan on (token_address, token_id). By-wallet reads are served by bloom_filter data-skipping indexes on from_address and to_address.

Full schema

Below is the complete read table this recipe produces. Keep the columns you need and drop the rest (see Schema & flexibility). token_id and amount are stored at full uint256 width: token_id as text (hash-derived ENS / ERC-1155 ids routinely exceed any SQL numeric precision), amount as a wide decimal.
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 VARCHAR(80) for token_id and equivalent ascending composite keys. position is the block-level cursor used during backfill.

Example reads

All transfers of an NFT contract, newest first (ClickHouse):
Provenance of a single token (contract + token_id):
All NFT transfers involving a wallet (either side), bloom-pruned:

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: its log table corrects reorgs per-block via the collapsing companion table. The Postgres / MySQL shapes here are intended for historical backfill.

Multichain

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

Fidelity gaps

The recipe lands only what the nftTokenTransfers array carries. Fields a Moralis NFT transfers endpoint surfaces that have no onchain source in this array are omitted:
  • Collection metadata (token_name, token_symbol): these come from the NFT contract / a metadata indexer, not the transfer event. Out of scope for a flat transfer log; see NFT Collection Metadata for the contract-metadata pattern.
  • Address labels / entity tags (from_address_label, to_address_entity, …): off-chain labels from a separate labelling service, not on-chain data.
  • value: the native value attached to the transfer transaction isn’t carried on the transfer event; omitted. amount (the ERC-1155 quantity) is present.
  • Pre/post NFT balances: the recipe omits the balance surface to stay a flat event stream.

Migrating from the REST API

This recipe replaces the wallet NFT transfers REST endpoint. Data Feeds are not 1:1 replicas of the old responses: you land the underlying data in your own database and reconstruct the response shape with a query. Below: what replaces the endpoint, the field mapping (exact = straight from the chain, calculated = derived from real trades, very close, add yourself = off-chain signal not in the feed), and the endpoint-specific gotchas.

GET /:address/nft/transfers

A wallet’s NFT transfer history. Replaced by this recipe’s nft_transfers table: a wallet’s transfers are the rows where it appears on either side, and the old endpoint’s contract_addresses, from_block/to_block, and order parameters become plain WHERE / ORDER BY clauses.
Reproducing the full REST response needs three recipes, not one. The transfer event carries no collection metadata or sale context. Run this recipe plus NFT Collection Metadata (token_name / token_symbol) and NFT Trades (last_sale) against the same database and join them.
The reconstruction query (Postgres; adapt to your destination):
Gotchas
  • Direction is yours to derive: the feed has no send/receive flag; compare the wallet to from_address/to_address as above. The recipe indexes both sides, so the OR match stays fast.
  • The address labels and spam/verification flags are off-chain signals with no onchain source: drop them, bring your own lists, or layer in a label source.
  • token_id is a uint256 stored as text; compare as strings, never as numbers.
  • Backfill depth is your choice: transfers are an event list, not current state, so index only as much history as your app serves.

NFT Trades

Marketplace sales with price, the trade-priced complement to raw transfers.

NFT Marketplace

The use case these NFT recipes power.