Files
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

260 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SQLite store (node:sqlite — built into node ≥22.13, zero dependencies).
// bigints are stored as TEXT and travel as strings through the API; REAL
// columns are display/ranking data only, never used to build transactions.
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { DB_PATH, TUNE, now } from './config'
mkdirSync(dirname(DB_PATH), { recursive: true })
export const db = new DatabaseSync(DB_PATH)
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS pools (
address TEXT PRIMARY KEY, -- lowercase
proto TEXT NOT NULL, -- 'univ2' | 'univ3'
token0 TEXT NOT NULL, -- lowercase
token1 TEXT NOT NULL,
fee_ppm INTEGER NOT NULL, -- univ2 fixed 3000 (0.30%)
tick_spacing INTEGER, -- univ3 only
created_block INTEGER, -- univ3 only (from PoolCreated)
pair_index INTEGER, -- univ2 only (allPairs index)
added_ts INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pools_t0 ON pools(token0);
CREATE INDEX IF NOT EXISTS idx_pools_t1 ON pools(token1);
CREATE TABLE IF NOT EXISTS tokens (
address TEXT PRIMARY KEY,
symbol TEXT NOT NULL DEFAULT '?',
decimals INTEGER NOT NULL DEFAULT 18,
meta_ok INTEGER NOT NULL DEFAULT 0, -- 0 = symbol/decimals defaulted (call reverted)
price_usd REAL,
price_depth_usd REAL NOT NULL DEFAULT 0, -- USD depth backing the price (bigger wins)
price_src TEXT, -- 'gt' | 'pool' | 'anchor'
price_updated INTEGER
);
CREATE TABLE IF NOT EXISTS pool_state (
address TEXT PRIMARY KEY,
sqrt_price TEXT, -- univ3
tick INTEGER, -- univ3
liquidity TEXT, -- univ3 in-range L
reserve0 TEXT NOT NULL DEFAULT '0', -- univ2: reserves; univ3: erc20 balances (TVL basis)
reserve1 TEXT NOT NULL DEFAULT '0',
total_supply TEXT, -- univ2 LP supply
tvl_usd REAL,
tvl_approx INTEGER NOT NULL DEFAULT 0, -- 1 = bounded figure: one side unpriced (2× priced side) or junk-side clamp (see tvlOf)
updated INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_state_tvl ON pool_state(tvl_usd);
CREATE TABLE IF NOT EXISTS pool_stats (
address TEXT PRIMARY KEY,
vol24h_usd REAL,
txns24h INTEGER,
liq_usd REAL, -- GT's own reserve figure (cross-check; tvl_usd is chain-derived)
source TEXT NOT NULL,
updated INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT NOT NULL);
`)
// ---- kv ----
const kvGetQ = db.prepare('SELECT v FROM kv WHERE k = ?')
const kvSetQ = db.prepare('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v')
export const kvGet = (k: string): string | undefined => (kvGetQ.get(k) as { v: string } | undefined)?.v
export const kvSet = (k: string, v: string) => void kvSetQ.run(k, v)
// ---- pools ----
const insPoolQ = db.prepare(`
INSERT OR IGNORE INTO pools (address, proto, token0, token1, fee_ppm, tick_spacing, created_block, pair_index, added_ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
/** returns true when the pool is new */
export function insertPool(p: {
address: string
proto: 'univ2' | 'univ3'
token0: string
token1: string
feePpm: number
tickSpacing?: number
createdBlock?: number
pairIndex?: number
}): boolean {
const r = insPoolQ.run(
p.address.toLowerCase(),
p.proto,
p.token0.toLowerCase(),
p.token1.toLowerCase(),
p.feePpm,
p.tickSpacing ?? null,
p.createdBlock ?? null,
p.pairIndex ?? null,
now(),
)
return Number(r.changes) > 0
}
export type PoolRow = {
address: string
proto: 'univ2' | 'univ3'
token0: string
token1: string
fee_ppm: number
tick_spacing: number | null
}
const poolsByAddrQ = db.prepare('SELECT address, proto, token0, token1, fee_ppm, tick_spacing FROM pools WHERE address = ?')
export const poolRow = (addr: string) => poolsByAddrQ.get(addr.toLowerCase()) as PoolRow | undefined
export const allPoolAddrs = (): string[] =>
(db.prepare('SELECT address FROM pools').all() as { address: string }[]).map((r) => r.address)
export const poolCounts = () =>
db.prepare(`SELECT proto, COUNT(*) AS n FROM pools GROUP BY proto`).all() as { proto: string; n: number }[]
// ---- tokens ----
const insTokenQ = db.prepare(`
INSERT INTO tokens (address, symbol, decimals, meta_ok) VALUES (?, ?, ?, ?)
ON CONFLICT(address) DO UPDATE SET symbol = excluded.symbol, decimals = excluded.decimals, meta_ok = excluded.meta_ok`)
export const upsertTokenMeta = (addr: string, symbol: string, decimals: number, metaOk: boolean) =>
void insTokenQ.run(addr.toLowerCase(), symbol, decimals, metaOk ? 1 : 0)
const priceQ = db.prepare(`
INSERT INTO tokens (address, price_usd, price_depth_usd, price_src, price_updated) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(address) DO UPDATE SET price_usd = excluded.price_usd, price_depth_usd = excluded.price_depth_usd,
price_src = excluded.price_src, price_updated = excluded.price_updated`)
export const setTokenPrice = (addr: string, usd: number, depthUsd: number, src: string) =>
void priceQ.run(addr.toLowerCase(), usd, depthUsd, src, now())
// Drop every pool-derived price (reprice rebuilds them from the credible seeds
// on the same pass) plus anything outside the plausibility band, whatever its
// source — a GT quote can be garbage too, and a stored fantasy price is how one
// broken pool poisoned 138 tokens. Runs inside reprice's transaction, so the
// API never observes the gap.
const clearDerivedQ = db.prepare(
`UPDATE tokens SET price_usd = NULL, price_depth_usd = 0, price_src = NULL, price_updated = NULL
WHERE price_src = 'pool' OR (price_usd IS NOT NULL AND (price_usd < ? OR price_usd > ?))`,
)
export const clearDerivedPrices = () => void clearDerivedQ.run(TUNE.minTokenUsd, TUNE.maxTokenUsd)
export type TokenRow = {
address: string
symbol: string
decimals: number
meta_ok: number
price_usd: number | null
price_depth_usd: number
price_src: string | null
price_updated: number | null
}
export const allTokens = () => db.prepare('SELECT * FROM tokens').all() as TokenRow[]
export const missingMetaTokens = (): string[] =>
(
db
.prepare(
`SELECT DISTINCT u.addr FROM (SELECT token0 AS addr FROM pools UNION SELECT token1 FROM pools) u
LEFT JOIN tokens t ON t.address = u.addr WHERE t.address IS NULL`,
)
.all() as { addr: string }[]
).map((r) => r.addr)
// ---- pool_state ----
const upStateQ = db.prepare(`
INSERT INTO pool_state (address, sqrt_price, tick, liquidity, reserve0, reserve1, total_supply, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(address) DO UPDATE SET sqrt_price = excluded.sqrt_price, tick = excluded.tick,
liquidity = excluded.liquidity, reserve0 = excluded.reserve0, reserve1 = excluded.reserve1,
total_supply = excluded.total_supply, updated = excluded.updated`)
export const upsertState = (
addr: string,
s: { sqrtPrice?: bigint; tick?: number; liquidity?: bigint; reserve0: bigint; reserve1: bigint; totalSupply?: bigint },
) =>
void upStateQ.run(
addr.toLowerCase(),
s.sqrtPrice !== undefined ? String(s.sqrtPrice) : null,
s.tick ?? null,
s.liquidity !== undefined ? String(s.liquidity) : null,
String(s.reserve0),
String(s.reserve1),
s.totalSupply !== undefined ? String(s.totalSupply) : null,
now(),
)
const tvlQ = db.prepare('UPDATE pool_state SET tvl_usd = ?, tvl_approx = ? WHERE address = ?')
export const setTvl = (addr: string, tvl: number | null, approx: boolean) =>
void tvlQ.run(tvl, approx ? 1 : 0, addr.toLowerCase())
// ---- pool_stats ----
const upStatsQ = db.prepare(`
INSERT INTO pool_stats (address, vol24h_usd, txns24h, liq_usd, source, updated) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(address) DO UPDATE SET vol24h_usd = excluded.vol24h_usd, txns24h = excluded.txns24h,
liq_usd = excluded.liq_usd, source = excluded.source, updated = excluded.updated`)
export const upsertStats = (addr: string, vol24h: number | null, txns24h: number | null, liqUsd: number | null, source: string) =>
void upStatsQ.run(addr.toLowerCase(), vol24h, txns24h, liqUsd, source, now())
/**
* Frontpage set: the top-N pools by TVL — exactly what /api/pools?sort=tvl
* returns to the POOLS tab, so sweeping these keeps every on-screen row fresh.
* The maxPoolTvlUsd ceiling matches the API's ranking guard: a corrupt figure
* must not be able to spend the fast tier's whole RPC budget on itself.
*/
export const frontpageAddrs = (n: number): string[] =>
(
db
.prepare('SELECT address FROM pool_state WHERE tvl_usd IS NOT NULL AND tvl_usd < ? ORDER BY tvl_usd DESC LIMIT ?')
.all(TUNE.maxPoolTvlUsd, n) as { address: string }[]
).map((r) => r.address)
// ---- demand gate ----
// The frontpage sweep only spends RPC while the POOLS tab is actually being
// polled: the API stamps `lastPoolsHit` on every /api/pools request, the sweep
// loop checks its age. Idle site → no fast sweeps → no cost. Starts "closed"
// (lastPoolsHit = 0) so nothing sweeps until the first request lands.
let lastPoolsHit = 0
export const notePoolsHit = (): void => {
lastPoolsHit = Date.now()
}
export const poolsHitAgeMs = (): number => Date.now() - lastPoolsHit
/** hot set: real TVL, or GT-visible activity, or freshly created */
export const hotAddrs = (): string[] =>
(
db
.prepare(
`SELECT address FROM pool_state WHERE tvl_usd BETWEEN ? AND ?
UNION SELECT address FROM pool_stats WHERE vol24h_usd > 0
UNION SELECT address FROM pools WHERE added_ts > ?`,
)
.all(TUNE.hotTvlUsd, TUNE.maxPoolTvlUsd, now() - 3_600) as { address: string }[]
).map((r) => r.address)
/**
* active set for the hourly sweep: anything that ever showed ≥$100 TVL plus
* everything younger than 48h. The launchpads mint ~20k dust pools/day — the
* 6-hourly census (allPoolAddrs) keeps their state honest, the hourly sweep
* stays bounded by real liquidity instead of catalog size.
*/
export const activeAddrs = (): string[] =>
(
db
.prepare(
`SELECT address FROM pool_state WHERE tvl_usd >= ?
UNION SELECT address FROM pools WHERE added_ts > ?`,
)
.all(100, now() - 172_800) as { address: string }[]
).map((r) => r.address)
export const tx = (fn: () => void) => {
db.exec('BEGIN')
try {
fn()
db.exec('COMMIT')
} catch (e) {
db.exec('ROLLBACK')
throw e
}
}