Indexer
The naka frontend ships with a lightweight, stateless indexer implemented
as a set of Next.js API routes. There is no separate service, no database, and
no background process. Every request walks the chain via eth_getLogs and
returns the result, with HTTP cache headers in front to absorb load.
This is intentional. Sepolia traffic is light; the protocol is itself stateless above the chain; and the existing Next.js deployment already covers the data-plane footprint.
Endpoints
All endpoints accept ?chainId=<id> (defaults to Sepolia) and return JSON.
GET /api/indexer/stats
Rolling 24-hour aggregates from Buy and Sell events on the NakaHook
contract.
{
"volEthWei": "12345000000000000",
"swaps": 17,
"buys": 11,
"sells": 6,
"fromBlock": 8123400,
"toBlock": 8130600
}
- Window:
head - 7200blocks (≈24h on Sepolia at 12s blocks). - Cache:
s-maxage=60, stale-while-revalidate=120.
GET /api/indexer/trades?limit=N
The most recent min(limit, 100) trades within a 7-day lookback, sorted
newest-first.
{
"trades": [
{
"kind": "buy",
"user": "0x…",
"ethValue": "200000000000000000",
"tokenValue": "10500000000000000000000",
"tokensLocked": "31500000000000000000",
"blockNumber": 8130591,
"blockTimestamp": 1757123412,
"txHash": "0x…",
"logIndex": 3
}
]
}
- Window: 7 days. Limit defaults to 25, capped at 100.
- Cache:
s-maxage=30, stale-while-revalidate=90. - Block timestamps are resolved with deduplication so the lookback only fetches each block once even when multiple trades share a block.
GET /api/indexer/flows?hours=N
Hourly buckets of buy and sell ETH volume + counts over the last N hours
(default 24, capped 72). Used by the homepage flow chart.
{
"buckets": [
{
"tStart": 1778097600,
"tEnd": 1778101200,
"buyEthWei": "1200000000000000000",
"sellEthWei": "800000000000000000",
"buys": 3,
"sells": 2
}
],
"totals": {
"inEthWei": "12340000000000000000",
"outEthWei": "9870000000000000000",
"netEthWei": "2470000000000000000",
"buys": 18,
"sells": 12
},
"windowHours": 24
}
- Bucket boundaries are aligned to wall-clock UTC hours so bars are visually stable as the window slides forward.
- Block timestamps are deduplicated; events sharing a block reuse one
getBlocklookup. - Cache:
s-maxage=60, stale-while-revalidate=120.
GET /api/indexer/holders?lookbackDays=N
Replays NakaToken Transfer events over a configurable window (default 30
days, capped 180), accumulates per-address balance deltas, and returns the
count of addresses with positive net balance excluding 0xdEaD.
{
"holders": 142,
"approximate": true,
"scannedBlocks": 216000
}
approximate: truewhenever the window does not start at genesis. Holders whose last transfer predates the window are missed; the number trends up as the window grows.- Cache:
s-maxage=300, stale-while-revalidate=600.
Frontend Integration
Three React Query hooks in lib/hooks/useIndexer.ts:
use24hStats()→ 60-second polluseRecentTrades(limit)→ 30-second polluseHolders()→ 5-minute polluseHourlyFlows(hours)→ 60-second poll
All three include automatic chain-id binding via wagmi, so switching networks
re-fetches against the correct contract set. When the active chain has no
deployed contracts (a custom or unsupported chain), the API returns 502 and
the UI gracefully shows — plus an indexer offline hint.
RPC Configuration
Server-side, the public client in lib/server/publicClient.ts reads:
SEPOLIA_RPC/MAINNET_RPC(server-only. Preferred for paid provider URLs that should never reach the browser bundle).NEXT_PUBLIC_SEPOLIA_RPC/NEXT_PUBLIC_MAINNET_RPC(fallback).- PublicNode public endpoints (last-resort fallback).
Public RPCs typically cap eth_getLogs at ~5–10k blocks per call, so the
client chunks queries via getLogsChunked() with a default chunk size of
5000 blocks. For higher throughput, wire a dedicated provider (Alchemy,
Infura, QuickNode) into SEPOLIA_RPC.
When to Replace This
The current implementation handles the early-mainnet case fine. Once daily
trades exceed a few hundred or holder count grows past a few thousand, the
on-demand getLogs model becomes the wrong shape:
- Latency: the holders endpoint scans 30 days of transfers per cold miss.
- Cost: each
getLogschunk is a paid RPC call. - Correctness: balance reconstruction misses pre-window holders.
The migration path is a stateful indexer:
- Ponder (TypeScript, viem-native). Drop-in replacement, GraphQL out of the box. Schema would mirror these endpoints.
- The Graph subgraph. If multi-chain / hosted infra is preferred.
- Custom Postgres + cron. If you want full control.
In all three cases, the API surface above stays the same; only
lib/server/indexer.ts swaps from eth_getLogs calls to database reads.
The frontend hooks and components require no changes.
Local Testing
# from repo root
npm run dev
# stats endpoint
curl 'http://localhost:3000/api/indexer/stats?chainId=11155111' | jq
# trades feed
curl 'http://localhost:3000/api/indexer/trades?limit=10' | jq
# holders count
curl 'http://localhost:3000/api/indexer/holders?lookbackDays=14' | jq
A failing endpoint returns a 502 with { "error": "..." }. Common causes:
- Contract not deployed on the requested chain (e.g. an unsupported network).
- RPC rate limit hit (set
MAINNET_RPC/SEPOLIA_RPCto a paid provider). - Network timeout. The client uses a 20s
timeoutper request.