mirror of
https://github.com/labrinyang/lp-terminal.git
synced 2026-07-27 13:17:43 +00:00
d73eaf873a
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>
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
// 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
|
||
}
|
||
}
|