On-Chain Analytics
Sibling skills: protocol integration patterns → defi-integration; contract review → smart-contract-auditor; on-chain trade execution → wallet-integration; prediction markets → polymarket-trading.
0. The 2026 On-Chain Data Stack — pick the right layer
There is no single "best" tool; match the layer to the job. Five access patterns dominate in 2026:
| Layer | Tools | Best for | Watch out for |
|---|---|---|---|
| Ad-hoc SQL | Dune (DuneSQL/Trino), Allium | Exploratory analysis, dashboards, holder/flow studies | Curated tables lag the chain tip (minutes–hours); decoded coverage varies by chain |
| Custom indexers | The Graph (subgraphs), Envio HyperIndex, Goldsky, Substreams, Ponder | App back-ends needing low-latency, app-specific schema | You own reorg handling, schema migrations, and infra cost |
| Enhanced RPC / data APIs | Alchemy, Infura, QuickNode, Moralis | Wallet balances, NFT ownership, transfer history without indexing | Compute-unit metering; per-provider quotas drift — verify before relying on a number |
| Curated metrics | Token Terminal, Artemis, DefiLlama, Nansen | Standardized fees/revenue/TVL, labeled wallets, fast comparisons | Methodology is the vendor's, not yours — read their docs before quoting |
| Raw warehouse / streams | Allium, Goldsky Mirror, Dune's *_decoded tables, RPC eth_getLogs | Bespoke pipelines, ML features, full-fidelity traces | Volume and cost scale fast; build dedup + finality logic |
Indexer performance note (Sentio benchmarks, 2025): RPC-native frameworks (Envio HyperSync, Substreams) backfill EVM history dramatically faster than RPC-only subgraph indexing — orders of magnitude on factory-style workloads. Validate the latest numbers for your chain before committing; benchmarks shift release-to-release.
Cross-source reconciliation rule: any headline number (TVL, 24h volume, holder count, revenue) should agree within a few percent across at least two independent sources (e.g. Dune vs DefiLlama, your subgraph vs Etherscan). A >5–10% gap means a methodology difference (price source, fee split, double-counted wrapped assets, unindexed events) — find it before publishing.
Data-quality checklist (apply to every query before you trust the output)
- Finality / reorgs: Ethereum L1 finalizes in ~2 epochs (~13 min). Treat the last ~2 finalized epochs of L1, and the last several minutes of fast L2s, as mutable. For point-in-time balances, cut off at a finalized block, not
latest. - Decimals: scale by the token's real
decimals(USDC=6, WBTC=8, most ERC-20=18). Never hardcode/1e18. - Mints/burns: transfers from/to
0x0000000000000000000000000000000000000000(and known burn addresses like0x...dEaD) are supply changes, not holder movements — keep them in supply math, drop them from "holder" counts. - Logs vs traces: ERC-20/721/1155 movements live in event logs (
Transfer). Native-ETH internal sends and contract-to-contract value live only in traces (ethereum.traces), not logs. Pick the right source for the asset. - Price joins: join prices on
(blockchain, contract_address, minute)— never onsymbolalone (symbols collide across tokens/chains). Missing minutes need forward-fill or aprices.dayfallback. - Rebasing/fee-on-transfer tokens (stETH, some reflection tokens): a balance computed from
Transferevents will not match the real balance. Read on-chainbalanceOffor these, or note the caveat.
1. Dune Analytics SQL Queries
Engine: Dune runs DuneSQL (Trino/Presto dialect) since the 2024 migration off Postgres. Use double quotes for identifiers (
"from","to"),from_hex/varbinaryfor addresses,bytearray_substringfor byte slicing, and DuneSQL date functions. Prefer curated Spellbook tables (dex.trades,tokens.transfers,nft.trades,prices.usd/prices.minute,labels.*,tokens.erc20) over raw decoded protocol event tables — Spellbook normalizes decimals, symbols, USD value, and multichain schemas, and is far less brittle than per-protocol*_evt_*tables whose names change between protocol versions.
Token Holder Analysis — balance ledger
The correct pattern for an ERC-20 balance is a normalized transfer ledger: every transfer contributes +value to the recipient and -value to the sender; sum per address and keep positives. Always scale by real decimals and exclude the zero address (mint/burn sink) from the holder set. Prefer the curated tokens.transfers Spellbook table — it already normalizes amounts and works multichain.
-- Top 100 holders of USDC on Ethereum (USDC has 6 decimals)
WITH ledger AS (
-- inflows: recipient gains
SELECT "to" AS holder, amount_raw AS delta
FROM tokens.transfers
WHERE blockchain = 'ethereum'
AND contract_address = 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 -- USDC
UNION ALL
-- outflows: sender loses
SELECT "from" AS holder, -amount_raw AS delta
FROM tokens.transfers
WHERE blockchain = 'ethereum'
AND contract_address = 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
)
SELECT
holder,
SUM(delta) / 1e6 AS balance -- USDC decimals = 6
FROM ledger
WHERE holder <> 0x0000000000000000000000000000000000000000 -- drop mint/burn sink
GROUP BY holder
HAVING SUM(delta) > 0
ORDER BY balance DESC
LIMIT 100;
If you must use raw decoded events instead of
tokens.transfers, substituteerc20_ethereum.evt_Transferwith columns"to","from",value, and jointokens.erc20to getdecimalsrather than hardcoding1e6/1e18.
Token Holder Distribution — concentration / Nakamoto-style
Build the per-holder balance ledger once, then rank and bucket. The original version was wrong on two counts: it referenced the output alias holder inside its own aggregate (illegal — aliases are not visible in the expression that defines them), and it summed only "to" rows so outgoing transfers were never subtracted. This version fixes both.
WITH ledger AS (
SELECT "to" AS holder, amount_raw AS delta
FROM tokens.transfers
WHERE blockchain = 'ethereum'
AND contract_address = 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
UNION ALL
SELECT "from" AS holder, -amount_raw AS delta
FROM tokens.transfers
WHERE blockchain = 'ethereum'
AND contract_address = 0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
),
balances AS (
SELECT holder, SUM(delta) / 1e6 AS balance
FROM ledger
WHERE holder <> 0x0000000000000000000000000000000000000000
GROUP BY holder
HAVING SUM(delta) > 0
),
ranked AS (
SELECT
holder,
balance,
ROW_NUMBER() OVER (ORDER BY balance DESC) AS rnk,
SUM(balance) OVER () AS circulating
FROM balances
)
SELECT
CASE
WHEN rnk <= 10 THEN 'Top 10'
WHEN rnk <= 50 THEN 'Top 11-50'
WHEN rnk <= 100 THEN 'Top 51-100'
ELSE 'Rest'
END AS tier,
COUNT(*) AS holders,
SUM(balance) AS total_balance,
SUM(balance) / MAX(circulating) * 100 AS pct_of_supply
FROM ranked
GROUP BY 1
ORDER BY MIN(rnk);
circulatinghere is the on-ledger circulating supply (sum of positive balances), not max/total supply — state that explicitly when you publish. CEX hot wallets, bridges, and staking contracts inflate "top holder" concentration; label and optionally exclude them vialabels.allbefore drawing conclusions about decentralization.
DEX Volume — use the curated dex.trades table
Do not query per-pool/per-version event tables like uniswap_v3_ethereum.Pair_evt_Swap for volume — Uniswap V3 swaps are pool-based (not "Pair") and table names differ by protocol version, so they break constantly. Spellbook's dex.trades already aggregates every DEX/version across chains, normalizes decimals, and precomputes amount_usd. It also avoids the original query's price-join bug (joining on symbol='ETH' ignored chain and token address and could fan out rows).
-- Daily Uniswap volume on Ethereum (all versions), pool-level via dex.trades
SELECT
DATE_TRUNC('day', block_time) AS day,
COUNT(*) AS num_trades,
SUM(amount_usd) AS volume_usd
FROM dex.trades
WHERE blockchain = 'ethereum'
AND project = 'uniswap' -- omit for total cross-DEX volume
AND block_time >= NOW() - INTERVAL '30' DAY
AND amount_usd IS NOT NULL -- rows with no reliable price are dropped from $ volume
GROUP BY 1
ORDER BY 1;
dex.tradesis pool-level: a swap routed through 1inch/CoWSwap appears once per pool hop, so per-pool DEX volume is correct but user-intent volume is overstated. Usedex_aggregator.tradesfor aggregator trade intents (one row per intent), and never sum the two tables together (that double counts). If you need a token whose USD price is missing fromdex.trades, joinprices.minuteon the full key (ON p.blockchain = t.blockchain AND p.contract_address = t.token_bought_address AND p.timestamp = DATE_TRUNC('minute', t.block_time)), never onsymbolalone.
Protocol TVL (simplified, flow-based)
-- Cumulative net flow for a lending protocol. Aggregate to daily FIRST, then
-- run the running total over the daily grain (a window over raw rows gives a
-- per-row total, not per-day). See §6 for the balance-based caveat.
WITH daily AS (
SELECT
DATE_TRUNC('day', evt_block_time) AS day,
SUM(CASE WHEN event_type = 'deposit' THEN amount_usd ELSE -amount_usd END) AS net_usd
FROM protocol_events
WHERE evt_block_time >= NOW() - INTERVAL '90' DAY
GROUP BY 1
)
SELECT day, SUM(net_usd) OVER (ORDER BY day) AS cumulative_tvl
FROM daily
ORDER BY day;
Whale Tracking
-- Large ERC-20 transfers (>$1M) in the last 24 hours, any token
SELECT
tr.evt_block_time,
tr."from",
tr."to",
tr.value / POWER(10, t.decimals) AS amount,
tr.value / POWER(10, t.decimals) * p.price AS value_usd,
t.symbol
FROM erc20_ethereum.evt_Transfer tr
JOIN tokens.erc20 t
ON t.contract_address = tr.contract_address
AND t.blockchain = 'ethereum'
-- price keyed on full (blockchain, contract, minute); INNER JOIN so untradeable
-- tokens with no price are excluded instead of producing NULL > 1e6 = false silently
JOIN prices.minute p
ON p.blockchain = 'ethereum'
AND p.contract_address = tr.contract_address
AND p.timestamp = DATE_TRUNC('minute', tr.evt_block_time)
WHERE tr.evt_block_time >= NOW() - INTERVAL '24' HOUR
AND tr.value / POWER(10, t.decimals) * p.price > 1000000
ORDER BY value_usd DESC
LIMIT 50;
Note
prices.minuteis the current Spellbook minute-resolution price table (the olderprices.usdalias may still resolve). Many "whale" transfers are exchange/bridge plumbing — joinlabels.allon"from"/"to"to filter out CEX, bridge, and known protocol addresses before calling a wallet a whale.
2. Etherscan API (V2 — unified multichain)
V1 is fully deprecated (since 15 Aug 2025). Use the V2 base
https://api.etherscan.io/v2/apiwith achainidquery param — one chain per call. A single API key now works across 60+ chains (Ethereum1, Base8453, Arbitrum42161, Optimism10, Polygon137, BSC56, …) on the same host — you no longer hitapi.basescan.orgetc. Verify the current chain list and limits at https://docs.etherscan.io/etherscan-v2 and https://etherscan.io/apis.
Setup
const ETHERSCAN_API = 'https://api.etherscan.io/v2/api'; // V2 unified endpoint
const API_KEY = process.env.ETHERSCAN_API_KEY;
// Etherscan returns 200 even on logical errors; status/result conventions differ
// by endpoint (some return status '1'/'0', proxy/stats endpoints return jsonrpc/result).
async function etherscanQuery(params: Record<string, string>, chainId = 1) {
const url = `${ETHERSCAN_API}?${new URLSearchParams({
chainid: String(chainId),
...params,
apikey: API_KEY!,
})}`;
// Retry on rate-limit / transient errors with backoff (see rate-limit table below)
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(url);
const data = await res.json();
// "NOTOK" + a rate-limit message → back off and retry
if (data.message === 'NOTOK' && /rate limit/i.test(String(data.result))) {
await new Promise(r => setTimeout(r, 250 * 2 ** attempt));
continue;
}
// status '0' with "No transactions found" is an empty result, not an error
if (data.status === '0' && data.message === 'No transactions found') return [];
if (data.status !== '1' && data.message !== 'OK' && data.jsonrpc === undefined) {
throw new Error(typeof data.result === 'string' ? data.result : data.message);
}
return data.result;
}
throw new Error('Etherscan: exhausted retries (rate limited)');
}
// Example: same key, different chain
const baseBalance = await etherscanQuery(
{ module: 'account', action: 'balance', address: '0xYourWalletAddress', tag: 'latest' },
8453, // Base
);
Account Balance
// Single address ETH balance (returns wei as a string)
const balance = await etherscanQuery({
module: 'account', action: 'balance',
address: '0xYourWalletAddress', tag: 'latest',
});
console.log(`Balance: ${Number(balance) / 1e18} ETH`);
// Multi-address balance (up to 20 addresses)
const balances = await etherscanQuery({
module: 'account', action: 'balancemulti',
address: '0xAddr1,0xAddr2,0xAddr3', tag: 'latest',
});
Transaction List (with pagination)
// Normal transactions — page through with page/offset; window with start/endblock.
// Hard limit: at most 10,000 records are returnable for a given query window, so
// for full history walk forward by block range, not by ever-larger page numbers.
const txs = await etherscanQuery({
module: 'account', action: 'txlist',
address: '0xYourWalletAddress',
startblock: '0', endblock: '99999999',
page: '1', offset: '100', sort: 'asc', // asc + advancing startblock = stable paging
});
// To page a large history: keep the last block seen and re-query from there
async function allTxs(address: string, chainId = 1) {
const out: any[] = [];
let startblock = 0;
for (;;) {
const batch = await etherscanQuery({
module: 'account', action: 'txlist', address,
startblock: String(startblock), endblock: '99999999',
page: '1', offset: '1000', sort: 'asc',
}, chainId);
if (!Array.isArray(batch) || batch.length === 0) break;
out.push(...batch);
const last = Number(batch[batch.length - 1].blockNumber);
if (batch.length < 1000) break;
startblock = last + 1; // advance past the last block to avoid dupes
}
return out;
}
// ERC20 token transfers (tokentx), ERC-721 (tokennfttx), ERC-1155 (token1155tx)
const tokenTxs = await etherscanQuery({
module: 'account', action: 'tokentx',
address: '0xYourWalletAddress', startblock: '0', endblock: '99999999',
page: '1', offset: '100', sort: 'desc',
});
// Internal transactions (value moved by contract execution; NOT in event logs)
const internalTxs = await etherscanQuery({
module: 'account', action: 'txlistinternal',
address: '0xYourWalletAddress', startblock: '0', endblock: '99999999',
});
Etherscan is a convenience/lookup API, not an analytics warehouse: it caps results (~10k/window), has no aggregation, and historical-state endpoints (token balance at block, historical ETH balance) are paid-tier only. For aggregates use Dune; for full transfer history use Alchemy
getAssetTransfersor your own indexer.
Contract ABI
const abi = await etherscanQuery({
module: 'contract', action: 'getabi',
address: '0xContractAddress',
});
const parsedAbi = JSON.parse(abi);
Gas Tracker
const gasPrice = await etherscanQuery({
module: 'gastracker', action: 'gasoracle',
});
console.log(`Safe: ${gasPrice.SafeGasPrice} Gwei`);
console.log(`Propose: ${gasPrice.ProposeGasPrice} Gwei`);
console.log(`Fast: ${gasPrice.FastGasPrice} Gwei`);
Rate Limits (as of Jun 2026 — verify at https://etherscan.io/apis)
A single key spans all V2 chains, but the per-second/daily limits are shared across every chain.
| Plan | Rate | Daily cap |
|---|---|---|
| Free | 3 calls/sec | 100,000/day |
| Lite | 5 calls/sec | 100,000/day |
| Standard | 10 calls/sec | 200,000/day |
| Advanced | 20 calls/sec | 500,000/day |
| Professional | 30 calls/sec | 1,000,000/day |
| Pro Plus | 30 calls/sec | 1,500,000/day |
There is no "unlimited" consumer tier — paid plans raise the per-second and daily caps. Always implement the backoff shown in the Setup block; bursting past the per-second limit returns NOTOK with a rate-limit message, not an HTTP 429.
3. The Graph — Subgraph Queries (decentralized network)
The hosted service was sunset on 12 Jun 2024 — all queries now run on The Graph Network. You query
https://gateway.thegraph.com/api/<API_KEY>/subgraphs/id/<SUBGRAPH_ID>using an API key created in Subgraph Studio (free tier ~100k queries/month; paid in GRT or card). Oldapi.thegraph.com/subgraphs/name/...hosted URLs no longer work. Keep the gateway key server-side. Decentralized subgraphs can be served by multiple Indexers, so allow for slight indexing-lag and occasional Indexer differences; check the subgraph's_meta { block { number } hasIndexingErrors }to know how fresh and healthy the data is.
# Always check freshness/health alongside your data
{
_meta { block { number timestamp } hasIndexingErrors }
}
Querying a subgraph (gateway + error handling)
// Subgraph IDs are looked up in The Graph Explorer; keep the key in env, server-side.
const SUBGRAPH_ID = process.env.UNISWAP_V3_SUBGRAPH_ID!; // e.g. from explorer
const GATEWAY = `https://gateway.thegraph.com/api/${process.env.GRAPH_API_KEY}/subgraphs/id/${SUBGRAPH_ID}`;
async function querySubgraph<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const res = await fetch(GATEWAY, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Graph gateway HTTP ${res.status}`);
const json = await res.json();
// GraphQL returns 200 with an `errors` array on query errors — surface them
if (json.errors?.length) throw new Error(json.errors.map((e: any) => e.message).join('; '));
return json.data as T;
}
The Graph also exposes decentralized indexer Substreams and Substreams-powered subgraphs for high-throughput backfills. For app back-ends that need sub-second latency or non-EVM/exotic schemas, compare against Envio HyperIndex, Goldsky, and Ponder (see §0) — they often index history far faster than a classic subgraph.
Top Pools by TVL
{
pools(first: 10, orderBy: totalValueLockedUSD, orderDirection: desc) {
id
token0 { symbol decimals }
token1 { symbol decimals }
feeTier
totalValueLockedUSD
volumeUSD
txCount
}
}
Token Price and Volume
query TokenData($address: String!) {
token(id: $address) {
symbol
name
decimals
totalSupply
volumeUSD
totalValueLockedUSD
tokenDayData(first: 30, orderBy: date, orderDirection: desc) {
date
priceUSD
volumeUSD
totalValueLockedUSD
}
}
}
Recent Swaps
{
swaps(first: 20, orderBy: timestamp, orderDirection: desc,
where: { pool: "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8" }) {
timestamp
sender
recipient
amount0
amount1
amountUSD
tick
}
}
Aave V3 Subgraph
# Markets overview
{
markets(first: 10, orderBy: totalValueLockedUSD, orderDirection: desc) {
id
name
inputToken { symbol }
totalValueLockedUSD
totalBorrowBalanceUSD
rates {
side
rate
type
}
}
}
4. Alchemy / Infura Enhanced APIs
JSON-RPC POSTs must send
Content-Type: application/json— without it some gateways reject the body or treat it as form data. Always check the JSON-RPC envelope forerrorbefore readingresult.
const alchemyUrl = `https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`;
async function alchemyRpc<T>(method: string, params: unknown[]): Promise<T> {
const res = await fetch(alchemyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // required
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const json = await res.json();
if (json.error) throw new Error(`${method}: ${json.error.message}`);
return json.result as T;
}
// All ERC-20 balances for an address (paginate via pageKey when present)
const tokenBalances = await alchemyRpc('alchemy_getTokenBalances', ['0xYourWalletAddress', 'erc20']);
// Token metadata (decimals, symbol, name, logo)
const metadata = await alchemyRpc('alchemy_getTokenMetadata', ['0xTokenAddress']);
// Full transfer history — the right tool for "everything a wallet ever sent/received".
// Walk `pageKey` until it is absent; `external` = native ETH, `internal` needs a trace-enabled tier.
async function getAllTransfers(address: string) {
const out: any[] = [];
let pageKey: string | undefined;
do {
const page: any = await alchemyRpc('alchemy_getAssetTransfers', [{
fromBlock: '0x0', toBlock: 'latest',
fromAddress: address,
category: ['erc20', 'erc721', 'erc1155', 'external'],
withMetadata: true, excludeZeroValue: true,
maxCount: '0x3e8', // 1000 per page
...(pageKey ? { pageKey } : {}),
}]);
out.push(...page.transfers);
pageKey = page.pageKey;
} while (pageKey);
return out;
}
// NFTs owned by an address (Alchemy NFT API v3 — REST, not JSON-RPC)
const nfts = await fetch(
`https://eth-mainnet.g.alchemy.com/nft/v3/${process.env.ALCHEMY_KEY}/getNFTsForOwner?owner=0xYourWalletAddress&withMetadata=true`
).then(r => r.json());
Infura/QuickNode/Moralis expose similar enhanced methods, and Alchemy mirrors this API across L2s (Base, Arbitrum, Optimism, Polygon) by swapping the subdomain. Free compute-unit quotas and CU-per-method pricing change frequently and differ per provider — treat them as verify-before-relying, not constants (see §10).
5. Wallet Profiling
Privacy, compliance & accuracy guardrails — read before profiling. On-chain data is pseudonymous, not anonymous, but linking an address to a real person/entity is a serious claim with legal and safety consequences.
- No unsupported identity claims. Never assert "address X is person Y" without a cited, verifiable source (a public ENS/Twitter self-link, an exchange's own published label, a court/OFAC record). Heuristic clustering (common-input, timing, funding-source) yields hypotheses, not facts.
- Attach confidence + source to every label. Emit
{ label, confidence: 0..1, source }and surface it in the UI. Distinguish protocol/contract labels (high confidence, fromlabels.*/Etherscan verified tags) from behavioral inferences (low).- One address ≠ one human. Smart-contract wallets, multisigs, shared custody, and MEV bots break the "one wallet = one user" assumption. Mixers/privacy tools and CEX omnibus wallets defeat naive clustering.
- Sanctions/compliance: screening against OFAC SDN / sanctioned-address lists is a regulated activity — use a licensed provider (Chainalysis, TRM, Elliptic) and qualified counsel; do not roll your own AML determinations.
- Aggregate, don't dox. For research/marketing, prefer cohort-level aggregates (e.g. "23% of LPs also hold token Z") over per-individual dossiers. Don't republish a private individual's full financial history because it happens to be on-chain.
Activity Pattern Analysis
-- Dune: wallet activity fingerprint (timezone of EXTRACT is UTC — state it)
WITH activity AS (
SELECT
"from" AS wallet,
DATE_TRUNC('hour', block_time) AS hour,
COUNT(*) AS tx_count,
-- gas_used (receipt) × gas_price (effective price), wei → ETH
SUM(CAST(gas_used AS DOUBLE) * gas_price) / 1e18 AS gas_spent_eth
FROM ethereum.transactions
WHERE "from" = 0xWalletAddress -- replace with the address under study
AND block_time >= NOW() - INTERVAL '90' DAY
GROUP BY 1, 2
)
SELECT
EXTRACT(DOW FROM hour) AS day_of_week, -- UTC
EXTRACT(HOUR FROM hour) AS hour_of_day, -- UTC; a tight active-hours band hints at timezone/automation
SUM(tx_count) AS total_txs,
AVG(tx_count) AS avg_txs_per_active_hour,
SUM(gas_spent_eth) AS total_gas_eth
FROM activity
GROUP BY 1, 2
ORDER BY total_txs DESC;
Timing fingerprints are weak heuristics, not identity. A consistent UTC active-hours window suggests a likely timezone or, if 24/7 and regular, automation/bot behavior — never an identity. Label such conclusions with low confidence (see §5 guardrails).
Protocol Interaction Map
-- Which contracts/protocols does a wallet interact with?
SELECT
t."to" AS contract,
-- label if known, else a short hex prefix (to_hex returns varchar, so slice with substr)
COALESCE(l.name, '0x' || substr(to_hex(t."to"), 1, 8) || '…') AS protocol,
l.category AS label_category, -- e.g. dex, lending, cex
COUNT(*) AS interactions,
MIN(t.block_time) AS first_seen,
MAX(t.block_time) AS last_seen,
SUM(t.value / 1e18) AS total_eth_sent
FROM ethereum.transactions t
LEFT JOIN labels.all l
ON l.address = t."to" AND l.blockchain = 'ethereum'
WHERE t."from" = 0xWalletAddress
AND t.block_time >= NOW() - INTERVAL '365' DAY
AND t."to" IS NOT NULL
GROUP BY 1, 2, 3
ORDER BY interactions DESC
LIMIT 20;
labels.allis community-curated and incomplete — an unlabeled"to"is "unknown," not "suspicious." Surfacel.category/source so a reader can judge label trustworthiness, per the §5 guardrails.
6. DeFi Metrics
Two ways to compute TVL — know which you're doing. (a) Flow-based (cumulative deposits − withdrawals, below) is cheap but drifts: it ignores price changes on already-deposited assets, rebases, liquidations, and any non-event balance change, so it diverges from reality over time. (b) Balance-based / point-in-time (read each vault's
balanceOfper asset at a block × price) is correct but heavier. For published TVL, reconcile against DefiLlama (§10), which does balance-based accounting across protocols.
TVL Calculation (flow-based approximation)
-- Cumulative TVL from deposit/withdraw events (approximation — see caveat above)
SELECT
day,
SUM(net_usd) OVER (ORDER BY day) AS tvl_approx
FROM (
SELECT
DATE_TRUNC('day', f.evt_block_time) AS day,
SUM(f.signed_amount * p.price) AS net_usd
FROM (
SELECT evt_block_time, asset, amount AS signed_amount FROM protocol.deposits
UNION ALL
SELECT evt_block_time, asset, -amount AS signed_amount FROM protocol.withdrawals
) f
JOIN prices.minute p
ON p.blockchain = 'ethereum' -- always key price on chain too
AND p.contract_address = f.asset
AND p.timestamp = DATE_TRUNC('minute', f.evt_block_time)
GROUP BY 1
) daily
ORDER BY day;
Protocol Revenue
-- Fee estimate for a DEX: dex.trades has no fee columns, so estimate from volume x fee rate
SELECT
DATE_TRUNC('day', block_time) AS day,
SUM(amount_usd) AS volume_usd,
SUM(amount_usd) * 0.003 AS est_fees_usd -- replace 0.003 with the real pool fee tier(s)
FROM dex.trades
WHERE project = 'uniswap'
AND blockchain = 'ethereum'
AND block_time >= NOW() - INTERVAL '30' DAY
GROUP BY 1
ORDER BY 1;
dex.tradescarries no per-trade fee split. For a real protocol vs LP revenue split, decode the protocol's own fee events (fee-switch config) or per-pool fee tiers, or use Token Terminal/Artemis for standardized cross-protocol fee and revenue series.
Key DeFi Metrics Reference
| Metric | Definition | Source / caveat |
|---|---|---|
| TVL | Σ deposited-asset balances × price | Balance-based; flow-based drifts (above) |
| Volume (24h) | Σ trade notional in 24h | Use dex.trades; dedupe aggregator hops |
| Fees (24h) | Total fees paid by users | Volume × tier fee; some V3 pools vary by tier |
| Revenue | Protocol's share of fees (to treasury) | Depends on fee-switch config; ≠ total fees |
| P/F ratio | Market cap (or FDV) ÷ annualized fees | State which cap you used (circulating vs FDV) |
| P/S ratio | Market cap (or FDV) ÷ annualized revenue | Revenue = protocol's cut, not total fees |
| FDV | Total/max supply × price | Overstates value when emissions are far in the future |
| Market cap | Circulating supply × price | Circulating ≠ total; check vesting/locks |
"Fees" and "revenue" are routinely conflated and inflate valuation multiples. Be explicit: fees = paid by users; revenue = the protocol's retained cut. With a fee switch off, protocol revenue can be ~0 even with large fees. State FDV vs circulating cap whenever you publish a P/F or P/S number.
7. NFT Analytics
Collection Stats (Dune)
-- Daily volume + robust "floor" for an NFT collection (BAYC)
SELECT
DATE_TRUNC('day', block_time) AS day,
COUNT(*) AS sales,
SUM(amount_usd) AS volume_usd,
APPROX_PERCENTILE(amount_usd, 0.05) AS floor_proxy_usd, -- 5th pct, not min
APPROX_PERCENTILE(amount_usd, 0.50) AS median_price_usd,
AVG(amount_usd) AS avg_price_usd,
MAX(amount_usd) AS max_price_usd
FROM nft.trades
WHERE nft_contract_address = 0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d -- BAYC
AND block_time >= NOW() - INTERVAL '30' DAY
AND amount_usd > 0
GROUP BY 1
ORDER BY 1;
MIN(amount_usd)is not a floor price — a single wash trade, a sweep at a discount, or a 1-wei sale tanks it. The realtime floor is the lowest live ask in the order book (Blur/OpenSea/Magic Eden APIs), not a trade aggregate. As a backward-looking proxy use a low percentile (5th) of executed sales, and filter wash trades (same/looping buyer-seller, zero-royalty self-trades). Royalty/marketplace-fee handling differs acrossnft.tradesrows — checkplatform_fee_amount_usd/royalty_fee_amount_usdwhen computing net proceeds.
Holder Analysis — current owner = latest transfer per token
The original "to" NOT IN (SELECT "from" ... AND token_id = nft.transfers.token_id ...) was a broken correlated subquery: the inner reference to nft.transfers.token_id is ambiguous and it doesn't model "the most recent transfer of each token." The reliable pattern is to rank every transfer per (contract, token_id) by recency and keep the latest — its "to" is the current owner. Order by block number and a tiebreaker (evt_index/log index) because multiple transfers of one token can land in the same block.
-- Current holders and holdings for an NFT collection (BAYC)
WITH latest AS (
SELECT
token_id,
"to" AS owner,
ROW_NUMBER() OVER (
PARTITION BY contract_address, token_id
ORDER BY evt_block_number DESC, evt_index DESC -- newest transfer wins
) AS rn
FROM nft.transfers
WHERE contract_address = 0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d -- BAYC
)
SELECT
owner AS holder,
COUNT(*) AS nfts_held
FROM latest
WHERE rn = 1
AND owner <> 0x0000000000000000000000000000000000000000 -- exclude burned tokens
GROUP BY owner
ORDER BY nfts_held DESC
LIMIT 50;
Use
nft.transfers(curated, multichain) and the matching trade tablenft.trades. Column names may beblock_number/tx_indexrather thanevt_block_number/evt_indexdepending on the table — check the schema panel; the ranking logic is identical either way. For ERC-1155 (semi-fungible) you must also sumamountper(token_id, owner)because one token_id can have many holders.
8. Mempool Monitoring
Reality check first. The "public mempool" is increasingly not where value-bearing transactions live. A large and growing share of Ethereum flow is private — sent via Flashbots Protect / MEV-Share, direct builder relays, or order-flow auctions — and never appears as a pending tx. So mempool monitoring sees a biased subset, gives no execution guarantee (txs can be dropped/replaced/reordered by builders), and on most fast L2s there is no meaningful pending mempool at all (centralized sequencer). Use it for signal, not as a source of truth, and never as a front-running edge you rely on.
Watching pending transactions — with batching, filtering, and backpressure
The naive pattern (fire one getTransaction per hash inside the callback) floods your RPC: a busy mempool emits thousands of hashes/sec, so you instantly exceed per-second compute-unit limits and build an unbounded promise backlog. Batch, bound concurrency, and drop on overload.
import { createPublicClient, webSocket, getAddress } from 'viem';
import { mainnet } from 'viem/chains';
const UNISWAP_ROUTER = getAddress('0xUniswapRouterAddress'); // checksum-normalize before comparing
const client = createPublicClient({
chain: mainnet,
transport: webSocket(`wss://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_KEY}`),
});
const MAX_INFLIGHT = 20; // cap concurrent getTransaction calls
let inflight = 0;
let dropped = 0;
const unwatch = client.watchPendingTransactions({
onTransactions: (hashes) => {
for (const hash of hashes) {
if (inflight >= MAX_INFLIGHT) { dropped++; continue; } // backpressure: shed load
inflight++;
client.getTransaction({ hash })
.then((tx) => {
// tx is null if it was already mined/dropped between notify and fetch — skip
if (tx?.to && getAddress(tx.to) === UNISWAP_ROUTER) {
console.log('Router tx pending:', {
from: tx.from, value: tx.value,
selector: tx.input.slice(0, 10), // 4-byte function selector
});
}
})
.catch(() => { /* dropped/replaced tx, or RPC hiccup — ignore */ })
.finally(() => { inflight--; });
}
},
onError: (e) => console.error('pending sub error (will need re-subscribe):', e.message),
});
Even better: many providers expose a filtered subscription (e.g. Alchemy
alchemy_pendingTransactionswithtoAddress/fromAddressfilters) so the server only streams txs you care about — far cheaper than fetching every hash. WebSocket subscriptions also silently die; add reconnect-with-backoff and re-subscribe ononError/close. Most providers meter pending-tx streams heavily, so confirm your plan supports the volume before relying on it.
Flashbots MEV-Share event stream (SSE)
// Hints about pending transactions/bundles (intentionally partial — privacy-preserving).
// You receive HINTS, not full calldata, so you cannot reconstruct or front-run the original.
const es = new EventSource('https://mev-share.flashbots.net'); // verify current URL in Flashbots docs
es.onmessage = (event) => {
try {
const hint = JSON.parse(event.data); // { hash, logs?, txs?, functionSelector?, ... } — fields are optional
console.log('MEV-Share hint:', hint.hash);
} catch { /* keepalive/non-JSON line */ }
};
es.onerror = () => { /* EventSource auto-reconnects; log + monitor */ };
MEV-Share deliberately exposes only hints, so do not treat it as a full mempool feed. For backtesting MEV, analyze landed transactions historically (Dune
dex.trades/ sandwich-detection spells, or block-builder datasets) rather than racing the live stream.
9. Building Dashboards
Architecture
Data sources → ETL/Indexer → Database → API → Frontend
│ │
├── Dune API (SQL queries, scheduled) ├── Next.js + Chart.js/Recharts
├── Etherscan V2 API (lookups) ├── TanStack Query for caching
├── The Graph (GraphQL queries/polling) └── Tailwind for styling
└── RPC nodes / indexer (custom indexing)
Don't hammer Dune's execute endpoint from the browser on every page load — executions cost credits and take seconds-to-minutes. Pattern: schedule the query on Dune (or run it server-side on a cron), then have the frontend read cached latest results through your own API route. Keep the Dune key server-side only.
Dune API Integration (robust: terminal states, HTTP errors, pagination, rate limits)
The common failing pattern only loops while PENDING/EXECUTING and then blindly reads result.result.rows — so a FAILED/CANCELLED/EXPIRED execution either throws an opaque undefined error or silently returns nothing, and large result sets are truncated at the first page. Handle all terminal states, check HTTP status, respect 429s, and page via next_uri.
const DUNE_API_KEY = process.env.DUNE_API_KEY!;
const DUNE = 'https://api.dune.com/api/v1';
const H = { 'X-Dune-API-Key': DUNE_API_KEY };
const TERMINAL_OK = 'QUERY_STATE_COMPLETED';
const TERMINAL_FAIL = new Set([
'QUERY_STATE_FAILED', 'QUERY_STATE_CANCELLED', 'QUERY_STATE_EXPIRED',
]);
async function duneFetch(url: string, init?: RequestInit, attempt = 0): Promise<any> {
const res = await fetch(url, init);
if (res.status === 429) { // rate limited → backoff + retry
if (attempt >= 5) throw new Error('Dune: rate limited, retries exhausted');
const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt;
await new Promise(r => setTimeout(r, retryAfter * 1000));
return duneFetch(url, init, attempt + 1);
}
if (!res.ok) throw new Error(`Dune HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
// Trigger a fresh execution and wait for a terminal state
async function executeDuneQuery(queryId: number, params?: Record<string, unknown>) {
const { execution_id } = await duneFetch(`${DUNE}/query/${queryId}/execute`, {
method: 'POST',
headers: { ...H, 'Content-Type': 'application/json' },
body: JSON.stringify(params ? { query_parameters: params } : {}),
});
// Poll status (cheap) — not the full results endpoint — until terminal
for (;;) {
await new Promise(r => setTimeout(r, 2000));
const { state } = await duneFetch(`${DUNE}/execution/${execution_id}/status`, { headers: H });
if (state === TERMINAL_OK) break;
if (TERMINAL_FAIL.has(state)) throw new Error(`Dune execution ${execution_id} ended in ${state}`);
// else PENDING / EXECUTING → keep polling
}
return fetchAllRows(`${DUNE}/execution/${execution_id}/results?limit=1000`);
}
// Page through every result via next_uri (or next_offset)
async function fetchAllRows(firstUrl: string) {
const rows: any[] = [];
let url: string | undefined = firstUrl;
while (url) {
const page = await duneFetch(url, { headers: H });
rows.push(...(page.result?.rows ?? []));
// Dune returns an absolute next_uri when more pages exist
url = page.next_uri;
}
return rows;
}
// Cached latest results (no credits, no re-execution) — preferred for dashboards
async function getLatestResults(queryId: number) {
return fetchAllRows(`${DUNE}/query/${queryId}/results?limit=1000`);
}
Parameter typing: Dune
query_parametersare typed (text,number,dateasYYYY-MM-DD HH:mm:ss,enum). Pass them as the correct JS type or the execution fails validation. There's also a one-shotPOST /query/{id}/execute+GET /execution/{id}/resultsflow shown here, plus a higher-level "run query" convenience endpoint — check current limits and credit costs at https://docs.dune.com.
Dashboard Data Patterns
// React component with TanStack Query
import { useQuery } from '@tanstack/react-query';
function TVLChart({ queryId }: { queryId: number }) {
const { data, isLoading } = useQuery({
queryKey: ['tvl', queryId],
queryFn: () => getLatestResults(queryId),
staleTime: 5 * 60 * 1000, // 5 min cache
refetchInterval: 10 * 60 * 1000, // refresh every 10 min
});
if (isLoading) return <Skeleton />;
return (
<ResponsiveContainer width="100%" height={400}>
<AreaChart data={data}>
<XAxis dataKey="day" />
<YAxis tickFormatter={(v) => `$${(v / 1e6).toFixed(0)}M`} />
<Tooltip formatter={(v: number) => `$${v.toLocaleString()}`} />
<Area type="monotone" dataKey="tvl" stroke="#8884d8" fill="#8884d8" fillOpacity={0.3} />
</AreaChart>
</ResponsiveContainer>
);
}
10. Useful API Endpoints
Free quotas and unit pricing change constantly — do not treat the right-hand column as a contract. Verify on each vendor's pricing page before architecting around a limit. (As of Jun 2026.)
| Service | Endpoint (V2 where noted) | Free-tier note → verify at |
|---|---|---|
| Etherscan V2 | api.etherscan.io/v2/api (+chainid) | 3 calls/s, 100k/day → etherscan.io/apis |
| Dune | api.dune.com | Credit-metered free plan → dune.com/pricing |
| The Graph | gateway.thegraph.com | ~100k queries/mo free → thegraph.com/studio |
| Alchemy | *.g.alchemy.com | CU-metered monthly free pool → alchemy.com/pricing |
| Infura | mainnet.infura.io | Daily request cap → infura.io/pricing |
| DefiLlama | api.llama.fi | Open, no key; courtesy rate limits → defillama.com/docs/api |
| CoinGecko | api.coingecko.com | Free Demo plan; per-minute cap varies → coingecko.com/en/api/pricing |
| Moralis | deep-index.moralis.io | CU-metered daily free pool → moralis.io/pricing |
| Allium / Token Terminal / Artemis | (enterprise/SQL/metrics) | Paid; for warehouse-grade & standardized metrics |
Compute-unit (CU) models make a flat "X CU/month" number nearly meaningless on its own — different methods cost wildly different CU, and the per-method costs get repriced. Budget against your method mix, measure actual usage, and re-check the vendor page rather than hardcoding a figure.
DefiLlama (no API key, great for cross-checking TVL/prices)
// Current TVL for a protocol (number, USD)
const tvl = await fetch('https://api.llama.fi/tvl/aave').then(r => r.json());
// All protocols with TVL + chain breakdown
const protocols = await fetch('https://api.llama.fi/protocols').then(r => r.json());
// Historical chain TVL (array of { date, tvl })
const chainTvl = await fetch('https://api.llama.fi/v2/historicalChainTvl/Ethereum').then(r => r.json());
// Token prices by (chain:address) — handy to sanity-check your own price joins
const prices = await fetch(
'https://coins.llama.fi/prices/current/ethereum:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'
).then(r => r.json());
DefiLlama does balance-based TVL across protocols, so it's the fastest independent check on a TVL number you computed yourself. The yields API (
yields.llama.fi) and stablecoins API are similarly key-free. Be a good citizen: cache responses and don't hammer it.
11. Verification & reproducibility checklist
Before you publish any on-chain figure:
- State the source and as-of block/time ("USDC holders as of block N / 2026-06-01 UTC"). On-chain numbers are only meaningful with a timestamp.
- Reconcile across ≥2 independent sources (Dune vs DefiLlama vs your subgraph). Investigate any >5–10% gap before shipping.
- Confirm decimals and price keys (
blockchain+contract_address+minute), not symbols. - Cut off at a finalized block for balances; flag the last ~2 finalized epochs (L1) / recent minutes (L2) as mutable.
- Label, don't dox for wallet work; every wallet label carries
confidence+source; no identity claims without a verifiable citation (see §5). - Note the methodology for fees vs revenue, FDV vs circulating cap, floor proxy vs live floor — conflating these is the most common way an on-chain analysis misleads.