Files
lp-terminal_github/indexer/api.ts
T
labrinyang d73eaf873a fix(indexer): stop a manipulated pool from minting prices
A price used to propagate from any pool with enough depth on the priced side,
which let a thin pool with one inflated side mint a price for its other token
and carry it across the graph. Depth is now measured on the side that is
already credibly priced, and a pool-priced side may claim at most 100x that
depth.

Guard rails around it, all in one place: prices travel at most 3 hops from a
GT/anchor seed, |tick| beyond 700k is a broken init rather than a market,
stored prices must land in a plausibility band, and a TVL above $1B is treated
as corrupt instead of as a whale.

Adds a log tail so pool stats follow chain events instead of polling, a
demand-gated frontpage, and a last-good fallback so a failed refresh serves
the previous stat rather than a hole.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 23:59:56 +08:00

207 lines
8.2 KiB
TypeScript

// 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<string, string> = {
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<string, unknown>
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<string, unknown>[]
const tokenAddrs = new Set<string>()
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<string, unknown> = {}
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}`))
}