Question it answers
“Give me every DEX trade made by wallet 0x…, newest first, direct pool fills and aggregator-routed trades alike, each with its USD notional, side, and protocol.”A single prefix scan returns what the public Moralis Swaps endpoints serve, pre-shaped and keyed by trader so a wallet’s trade history is a contiguous range read.
What you get
The recipe lands one row per trade, a trade belongs to exactly one trader (fromAddress), so there’s no per-side fan-out. Both direct pool fills and aggregator-routed trades land in the same table:
| Column | Description |
|---|---|
trader_address | The wallet that made the trade |
event_ts, block_number | When the trade happened |
tx_hash | Transaction that produced the trade |
pool_address | The pair (pool fill) or aggregator (aggregator route) |
token0_address, token1_address | The two legs of the trade |
amount0, amount1 | Raw uint256 amounts moved on each leg |
side | buy / sell, decided by the base token’s pool-balance delta |
source_kind | pool_fill (direct pool) or aggregator (router) |
price_usd | Bought-leg (token1) USD price at block time |
notional_usd | Trade value: token1Amount / 10^token1Decimals × price_usd |
protocol | The DEX protocol of the fill |
fee_amount, fee_token, fee_usd | Pool-fill fees (bps fees converted to absolute), NULL for aggregator rows |
Source
The transform reads two per-block arrays and unions them into the trade feed, so every fill is captured exactly once:tokenSwaps (direct pool fills, source_kind = 'pool_fill') · aggregateTokenSwaps (aggregator routes, source_kind = 'aggregator')
The two branches are disjoint by construction. USD enrichment is computed inline from the same block’s tokenPriceUpdates, a per-block price map is built once and the bought leg is priced O(log N) per trade, with no separate price join at read time.
Destination
| Destination | Table | Read pattern |
|---|---|---|
| ClickHouse (first-class) | fact_swaps_by_wallet | Prefix scan on (chain_id, trader_address, event_ts); read with FINAL or sum(sign) |
| Postgres | swaps | Index on (trader_address, block_number DESC) |
| MySQL | swaps | Index on (trader_address, block_number) |
+1/−1 pair shares an identical key and collapses cleanly. The fact table’s sort key is trader-first, so a wallet’s trade history is a contiguous range read.
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). Rawuint256 amounts are stored as text in ClickHouse (they exceed numeric precision) and as NUMERIC(76, 0) in Postgres; USD columns are wide decimals so a low-decimals token × price never overflows.
ClickHouse, fact_swaps_by_wallet
ClickHouse, fact_swaps_by_wallet
sign column drives reorg collapsing, read with FINAL or sum(sign). A single-node setup can use CollapsingMergeTree(sign) without the replication path.Postgres, swaps
Postgres, swaps
DECIMAL(38,18) for the USD columns and DECIMAL(76,0) for raw amounts. position is the block-level cursor used during backfill.Example reads
All trades made by a wallet, 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 block-level position, but
position is block-level (many trades share one block), so array-expanded trade 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, point it at any supported EVM chain or Solana. On Solana, the samelogIndex can be assigned to multiple events in one instruction, so the pool-fill event identity is widened with (token0_address, token1_address, token0_amount) to keep rows distinct; the trade feed it produces is identical in shape.
Fidelity gaps
notional_usdis single-leg. The trade value is computed from the bought leg (token1) only,token1Amount / 10^token1Decimals × price_usd. If the bought token had no in-block price update,price_usd,notional_usd, andfee_usdareNULL.amount0/amount1are rawuint256. They are not decimal-scaled, divide by10^token_decimals(sourced from a Token Metadata sync) to read human amounts.- Fees are pool-fill only.
fee_*columns are populated for direct pool fills (bps fees converted to an absolute amount); aggregator rows leave themNULL.
Migrating from the REST API
This recipe replaces the legacy wallet-swaps endpoint. It is not a 1:1 replica of the response, you land the trades in your own database and reconstruct the response shape with a query. Fidelity labels: exact = straight from the chain, calculated = derived from real DEX trades (very close, compare with a tolerance), add yourself = off-chain signal not in the feed.GET /wallets/:address/swaps
A wallet’s DEX trade history with bought/sold legs and USD values. Replaced directly by this recipe, trades are keyed bytrader_address, so the legacy response is a single by-wallet read, with Token Metadata joined in for the leg symbols and pairLabel.
/wallets/:address/swaps | Data Feeds | Fidelity |
|---|---|---|
transactionHash, transactionIndex, blockNumber, blockTimestamp | swaps.* | exact |
transactionType (buy/sell) | swaps.side | exact |
pairAddress, exchangeName | swaps.pool_address / protocol | exact |
bought / sold legs (address, amount) | swaps.token0/1_address, amount0/1 | exact |
bought/sold.symbol, name, pairLabel | token_metadata join / derive | exact |
usdPrice, usdAmount, totalValueUsd, baseQuotePrice | swaps.price_usd / notional_usd | calculated |
walletAddressLabel, entity, entityLogo, exchangeLogo, token logos | add yourself | add yourself |
subCategory (accumulation/distribution) | classify yourself | add yourself |
- USD values are calculated from real trades, compare with a tolerance, and they’re
NULLwhen the bought leg had no in-block price update (see Fidelity gaps). - Leg symbols and
pairLabelneed thetoken_metadatajoin, andamount0/amount1land as rawuint256, scale by10^decimalsfrom the same table. - The off-chain extras (
walletAddressLabel,entity, logos) come from your own directory or get dropped;subCategoryis a classification rule you define over the wallet’s trades. - The legacy
transactionTypesfilter becomesWHERE side = 'buy'/'sell'; date and block windows are plain SQL. - Both direct pool fills and aggregator-routed trades land in the one table,
source_kinddistinguishes them, matching the old endpoint’s mixed feed.
Related
Swaps by Token
The same trade feed keyed by token instead of trader.
Token Analytics
The use case this trade feed powers.

