// Read-only HTTP API. Response shapes mirror the frontend's PoolsData / // PoolStat structures so the POOLS tab maps rows 1:1 (bigints travel as // strings). Served same-origin in production (nginx /api → this) and through // the vite dev/preview proxy locally. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { PORT, TUNE, log } from './config' import { db, kvGet, notePoolsHit, poolCounts } from './store' const JSONH = { 'content-type': 'application/json; charset=utf-8' } type Params = URLSearchParams const PROTOS = new Set(['univ2', 'univ3']) const HEX40 = /^0x[0-9a-f]{40}$/ function poolsWhere(params: Params): { where: string; args: (string | number)[] } { const clauses: string[] = [] const args: (string | number)[] = [] const proto = (params.get('proto') ?? '') .split(',') .map((s) => s.trim()) .filter((s) => PROTOS.has(s)) if (proto.length) { clauses.push(`p.proto IN (${proto.map(() => '?').join(',')})`) args.push(...proto) } const minTvl = Number(params.get('min_tvl')) if (Number.isFinite(minTvl) && minTvl > 0) { clauses.push('s.tvl_usd >= ?') args.push(minTvl) } const q = (params.get('q') ?? '').trim().toLowerCase() if (q) { if (HEX40.test(q)) { clauses.push('(p.address = ? OR p.token0 = ? OR p.token1 = ?)') args.push(q, q, q) } else if (q.includes('/')) { // pair search: "weth/usdg" — both sides must match (either orientation) const [a, b] = q.split('/', 2).map((s) => s.trim()) const side = `SELECT address FROM tokens WHERE symbol LIKE ?` clauses.push( `((p.token0 IN (${side}) AND p.token1 IN (${side})) OR (p.token0 IN (${side}) AND p.token1 IN (${side})))`, ) args.push(a + '%', b + '%', b + '%', a + '%') } else { const side = `SELECT address FROM tokens WHERE symbol LIKE ?` clauses.push(`(p.token0 IN (${side}) OR p.token1 IN (${side}))`) args.push(q + '%', q + '%') } } return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', args } } // TVL ranking sinks corrupt figures to the bottom instead of filtering them: // this chain's whole TVL is ~8 orders under maxPoolTvlUsd, so anything above it // is a pricing artefact, not a whale — but it must stay reachable by address or // symbol search so it can still be diagnosed. (2026-07-22: without this guard // 120 of the 120 rows the POOLS tab fetches were fabricated.) const ORDER: Record = { tvl: `ORDER BY (s.tvl_usd IS NULL OR s.tvl_usd >= ${TUNE.maxPoolTvlUsd}), s.tvl_usd DESC`, vol: 'ORDER BY (st.vol24h_usd IS NULL), st.vol24h_usd DESC', created: 'ORDER BY (p.created_block IS NULL), p.created_block DESC, p.pair_index DESC', } type PoolOut = Record function getPools(params: Params) { const { where, args } = poolsWhere(params) const order = ORDER[params.get('sort') ?? 'tvl'] ?? ORDER.tvl const limit = Math.min(Math.max(Number(params.get('limit')) || 100, 1), 500) const offset = Math.min(Math.max(Number(params.get('offset')) || 0, 0), 20_000) const base = `FROM pools p LEFT JOIN pool_state s ON s.address = p.address LEFT JOIN pool_stats st ON st.address = p.address ${where}` const count = (db.prepare(`SELECT COUNT(*) AS n ${base}`).get(...args) as { n: number }).n const rows = db .prepare( `SELECT p.address, p.proto, p.token0, p.token1, p.fee_ppm, p.tick_spacing, p.created_block, s.sqrt_price, s.tick, s.liquidity, s.reserve0, s.reserve1, s.total_supply, s.tvl_usd, s.tvl_approx, s.updated AS state_updated, st.vol24h_usd, st.txns24h, st.liq_usd, st.source AS stats_source ${base} ${order} LIMIT ? OFFSET ?`, ) .all(...args, limit, offset) as Record[] const tokenAddrs = new Set() const pools: PoolOut[] = rows.map((r) => { tokenAddrs.add(r.token0 as string) tokenAddrs.add(r.token1 as string) return { proto: r.proto, address: r.address, token0: r.token0, token1: r.token1, feePpm: r.fee_ppm, tickSpacing: r.tick_spacing, createdBlock: r.created_block, sqrtPriceX96: r.sqrt_price, tick: r.tick, liquidity: r.liquidity, reserve0: r.reserve0 ?? '0', reserve1: r.reserve1 ?? '0', totalSupply: r.total_supply, tvlUsd: r.tvl_usd, tvlApprox: r.tvl_approx === 1, vol24hUsd: r.vol24h_usd, txns24h: r.txns24h, gtLiqUsd: r.liq_usd, statsSource: r.stats_source, stateUpdated: r.state_updated, } }) const tokens: Record = {} if (tokenAddrs.size) { const list = [...tokenAddrs] const trs = db .prepare(`SELECT address, symbol, decimals, price_usd FROM tokens WHERE address IN (${list.map(() => '?').join(',')})`) .all(...list) as { address: string; symbol: string; decimals: number; price_usd: number | null }[] for (const t of trs) tokens[t.address] = { address: t.address, symbol: t.symbol, decimals: t.decimals, priceUsd: t.price_usd } } const totals = Object.fromEntries(poolCounts().map((c) => [c.proto, c.n])) return { ready: kvGet('ready') === '1', asof: Number(kvGet('snapshot_asof')) || null, totals, count, pools, tokens } } function getTokens(params: Params) { const q = (params.get('q') ?? '').trim().toLowerCase() if (!q) return { tokens: [] } const rows = HEX40.test(q) ? db.prepare('SELECT address, symbol, decimals, price_usd FROM tokens WHERE address = ?').all(q) : db .prepare( `SELECT t.address, t.symbol, t.decimals, t.price_usd, (SELECT COUNT(*) FROM pools p WHERE p.token0 = t.address OR p.token1 = t.address) AS pools FROM tokens t WHERE t.symbol LIKE ? ORDER BY pools DESC LIMIT 20`, ) .all(q + '%') return { tokens: rows } } function getHealth() { const totals = Object.fromEntries(poolCounts().map((c) => [c.proto, c.n])) const tokens = (db.prepare('SELECT COUNT(*) AS n FROM tokens').get() as { n: number }).n const priced = (db.prepare('SELECT COUNT(*) AS n FROM tokens WHERE price_usd > 0').get() as { n: number }).n const tvl = (db.prepare('SELECT COUNT(*) AS n FROM pool_state WHERE tvl_usd IS NOT NULL').get() as { n: number }).n // pricing-health canary: a healthy chain has zero of these const corrupt = ( db.prepare('SELECT COUNT(*) AS n FROM pool_state WHERE tvl_usd >= ?').get(TUNE.maxPoolTvlUsd) as { n: number } ).n const stateFreshness = db.prepare('SELECT MIN(updated) AS oldest, MAX(updated) AS newest FROM pool_state').get() const statsFreshness = db.prepare('SELECT MAX(updated) AS newest FROM pool_stats').get() return { ready: kvGet('ready') === '1', asof: Number(kvGet('snapshot_asof')) || null, lastBootError: kvGet('boot_error') || null, pools: totals, tokens, pricedTokens: priced, tvlPools: tvl, corruptTvlPools: corrupt, stateFreshness, statsFreshness, v3Cursor: Number(kvGet('v3_cursor') ?? 0), v2Count: Number(kvGet('v2_count') ?? 0), rssMb: Math.round(process.memoryUsage.rss() / 1e6), } } export function startApi(): void { const srv = createServer((req: IncomingMessage, res: ServerResponse) => { const started = Date.now() try { const url = new URL(req.url ?? '/', 'http://indexer') if (req.method !== 'GET') { res.writeHead(405, JSONH) res.end('{"error":"GET only"}') return } let body: unknown let cache = 'public, max-age=10' if (url.pathname === '/api/pools') { notePoolsHit() // open the frontpage-sweep demand gate body = getPools(url.searchParams) } else if (url.pathname === '/api/tokens') body = getTokens(url.searchParams) else if (url.pathname === '/api/health') { body = getHealth() cache = 'no-store' } else { res.writeHead(404, JSONH) res.end('{"error":"not found"}') return } res.writeHead(200, { ...JSONH, 'cache-control': cache }) res.end(JSON.stringify(body)) if (Date.now() - started > 500) log(`[api] slow ${url.pathname} ${Date.now() - started}ms`) } catch (e) { res.writeHead(500, JSONH) res.end(JSON.stringify({ error: String(e) })) } }) srv.listen(PORT, () => log(`[api] listening on :${PORT}`)) }