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>
This commit is contained in:
labrinyang
2026-07-22 23:59:56 +08:00
co-authored by Claude Opus 4.8
parent 3a392cb141
commit d73eaf873a
17 changed files with 1002 additions and 112 deletions
+233 -52
View File
@@ -5,18 +5,43 @@
// are the TVL basis, matching how GT/dexscreener report "reserve".
// univ2: getReserves + totalSupply.
//
// Pricing is a waterfall: GeckoTerminal token prices are ground truth while
// fresh (stats.ts seeds them, depth = pool reserve/2); everything else comes
// from anchor propagation — a token gets priced through the deepest pool that
// pairs it against an already-priced token, requiring ≥ TUNE.minDepthUsd of
// priced-side depth so dust pools can't set prices. TVL then = sum of priced
// sides (single-priced-side pools: 2× that side, flagged approximate).
// Pricing: GeckoTerminal token prices (stats.ts, depth = pool reserve/2) plus
// the USDG anchor are the only CREDIBLE seeds; every other price is propagated
// from them through pools, using the pool's SPOT — v2 reserve ratio, v3 slot0
// sqrt price. v3 BALANCES are liquidity-shape artifacts, never a price
// (measured: a near-edge pool priced a token 11,000× off spot, turning a ~$1.4k
// pool into $10.85M of "TVL").
//
// Four rails keep a manipulated pool from minting a price (2026-07-22 autopsy:
// ONE v3 pool parked at MAX_TICK with zero in-range liquidity, holding $1,464
// of real USDG, priced its counter-token at $3.5e50 — which then poisoned 138
// tokens and put 120 fake rows on the POOLS frontpage, burying every real one):
//
// 1. spot must be usable — a v3 pool with no in-range liquidity, or parked at
// a boundary tick, has a slot0 anyone can move for free: not a price.
// 2. propagation is a BFS by HOP from the seeds. A token settles at the
// shortest hop that can price it and never re-prices from a longer path, so
// a chain of junk pools can't out-shout the WETH pool next door.
// 3. depth (the weight behind a quote) is CREDIBLE dollars: past hop 1 it is
// capped by the depth backing the known side. The old `balance × its price`
// let a fabricated price fabricate its own authority — the bigger the lie,
// the more it outranked every honest quote, permanently.
// 4. within a hop, a token's quotes are combined by DEPTH-WEIGHTED MEDIAN, so
// one manipulated pool has to outweigh every honest pool to move a price.
//
// Nothing pool-derived is inherited across passes (loadSeeds reads GT/anchor
// only) — a poisoned price cannot survive a reprice. TVL = sum of priced sides,
// bounded by tvlOf(): one-sided → 2× that side; a pool-priced side that dwarfs
// the credible side → clamped to 2× credible; and any pool-priced side is
// capped at maxSideOverDepth × the credible depth that established its price.
// Every bounded figure flags tvl_approx.
import { erc20Abi, formatUnits } from 'viem'
import { uniV2PairAbi, uniV3PoolAbi } from '../src/abi'
import { ADDR, TUNE, log, now } from './config'
import { mc, ok, type Call } from './rpc'
import {
allTokens,
clearDerivedPrices,
db,
missingMetaTokens,
setTokenPrice,
@@ -71,6 +96,8 @@ export async function sweepState(addrs: string[]): Promise<number> {
for (let i = 0; i < addrs.length; i += 5_000) {
done += await sweepSlice(addrs.slice(i, i + 5_000))
}
if (done < addrs.length)
log(`[sweep] ${done}/${addrs.length} pools updated; kept last-good state for ${addrs.length - done}`)
return done
}
@@ -95,6 +122,7 @@ async function sweepSlice(addrs: string[]): Promise<number> {
}
const res = await mc(calls)
let i = 0
let done = 0
tx(() => {
for (const p of rows) {
if (p.proto === 'univ3') {
@@ -102,32 +130,58 @@ async function sweepSlice(addrs: string[]): Promise<number> {
const liq = ok<bigint>(res[i++])
const b0 = ok<bigint>(res[i++])
const b1 = ok<bigint>(res[i++])
if (!s0) continue
if (!s0 || liq === undefined || b0 === undefined || b1 === undefined) continue
upsertState(p.address, {
sqrtPrice: s0[0],
tick: s0[1],
liquidity: liq ?? 0n,
reserve0: b0 ?? 0n,
reserve1: b1 ?? 0n,
liquidity: liq,
reserve0: b0,
reserve1: b1,
})
done++
} else {
const rs = ok<readonly [bigint, bigint, number]>(res[i++])
const ts = ok<bigint>(res[i++])
if (!rs) continue
upsertState(p.address, { reserve0: rs[0], reserve1: rs[1], totalSupply: ts ?? 0n })
if (!rs || ts === undefined) continue
upsertState(p.address, { reserve0: rs[0], reserve1: rs[1], totalSupply: ts })
done++
}
}
})
return rows.length
return done
}
type PriceEntry = { usd: number; depth: number; src: string; updated: number }
/** `hops` = distance from a credible seed: 0 = GT/anchor, 1..n = propagated */
type PriceEntry = { usd: number; depth: number; src: string; hops: number }
/** a price outside this band is a broken pool, not a market */
export const plausibleUsd = (x: number | null | undefined): x is number =>
x != null && Number.isFinite(x) && x >= TUNE.minTokenUsd && x <= TUNE.maxTokenUsd
/**
* Seeds for a pricing pass: GT + anchor ONLY. Pool-derived prices are rebuilt
* from scratch every time — inheriting one (with the depth it claimed) is what
* made poisoning permanent, since nothing honest could ever outbid it again.
*/
const loadSeeds = (): Map<string, PriceEntry> => {
const m = new Map<string, PriceEntry>()
for (const t of allTokens())
if (t.price_src && t.price_src !== 'pool' && plausibleUsd(t.price_usd))
m.set(t.address, { usd: t.price_usd!, depth: t.price_depth_usd, src: t.price_src, hops: 0 })
return m
}
/** every stored price, for TVL arithmetic only (computeTvlFor — no propagation) */
const loadPrices = (): Map<string, PriceEntry> => {
const m = new Map<string, PriceEntry>()
for (const t of allTokens())
if (t.price_usd != null && t.price_usd > 0)
m.set(t.address, { usd: t.price_usd, depth: t.price_depth_usd, src: t.price_src ?? '?', updated: t.price_updated ?? 0 })
if (plausibleUsd(t.price_usd))
m.set(t.address, {
usd: t.price_usd!,
depth: t.price_depth_usd,
src: t.price_src ?? '?',
hops: t.price_src === 'pool' ? 1 : 0,
})
return m
}
@@ -138,66 +192,193 @@ type StateRow = {
token1: string
reserve0: string
reserve1: string
sqrt_price: string | null
liquidity: string | null
tick: number | null
}
const statesQ = () =>
db
.prepare(
`SELECT p.address, p.proto, p.token0, p.token1, s.reserve0, s.reserve1
`SELECT p.address, p.proto, p.token0, p.token1, s.reserve0, s.reserve1, s.sqrt_price, s.liquidity, s.tick
FROM pools p JOIN pool_state s ON s.address = p.address`,
)
.all() as StateRow[]
const Q96 = 2 ** 96
/** univ3 spot from slot0: HUMAN token1 per token0 (null when absent/degenerate) */
export function v3Price1Per0(sqrtPrice: string | null, dec0: number, dec1: number): number | null {
if (!sqrtPrice) return null
const r = Number(sqrtPrice) / Q96
const p = r * r * 10 ** (dec0 - dec1)
return Number.isFinite(p) && p > 0 ? p : null
}
/**
* Full pricing pass: propagate USD prices from GT/anchor seeds through pools,
* then recompute every pool's TVL. Pure JS over in-memory rows (~35k pools),
* runs after full sweeps and after each GT cycle.
* Is a univ3 pool's slot0 a price at all? Two ways it isn't:
* - no in-range liquidity: slot0 is wherever the last poke left it and the
* next dust swap moves it anywhere, for free.
* - parked at a boundary tick (|tick| → MAX_TICK 887272): a pool initialized
* at the extreme, which is how the $3.5e50 quote got minted.
* Such pools still get TVL from the OTHER side's price — they just don't vote.
*/
export function v3SpotOk(liquidity: string | null, tick: number | null): boolean {
if (tick === null || Math.abs(tick) > TUNE.maxAbsTick) return false
try {
return liquidity !== null && BigInt(liquidity) > 0n
} catch {
return false
}
}
/** HUMAN token1-per-token0 spot, or null when this pool may not set a price */
const spotOf = (s: StateRow, b0: number, b1: number, decs: Map<string, number>): number | null => {
if (s.proto !== 'univ3') return b0 > 0 && b1 > 0 ? b1 / b0 : null
if (!v3SpotOk(s.liquidity, s.tick)) return null
return v3Price1Per0(s.sqrt_price, decs.get(s.token0) ?? 18, decs.get(s.token1) ?? 18)
}
/**
* Depth-weighted median of a token's candidate quotes. A single manipulated
* pool now has to bring more than half the credible depth backing a token to
* move its price — with "deepest wins" it only had to claim a bigger number.
*/
export function weightedMedian(quotes: readonly { usd: number; depth: number }[]): number {
if (quotes.length === 1) return quotes[0].usd
const sorted = [...quotes].sort((a, b) => a.usd - b.usd)
const half = sorted.reduce((a, q) => a + q.depth, 0) / 2
let acc = 0
for (const q of sorted) {
acc += q.depth
if (acc >= half) return q.usd
}
return sorted[sorted.length - 1].usd
}
/**
* TVL from two priced sides. `credible` = the side's price came from GT or the
* USDG anchor rather than pool propagation. Three bounds, all flagging approx:
* - `cap0/cap1`: a pool-priced side may not claim more than
* maxSideOverDepth × the credible depth that established its price. This is
* the absolute ceiling — without it a $1.4k pool's fabricated quote turned
* into a $2.95e49 "TVL" that no ratio test could catch.
* - a side claiming more than maxUncredibleRatio × its credible counterparty
* is capped at 2× the credible side.
* - junk-vs-junk pools are bounded by the smaller side.
*/
export function tvlOf(
u0: number | null,
cred0: boolean,
u1: number | null,
cred1: boolean,
cap0 = Infinity,
cap1 = Infinity,
): { tvl: number | null; approx: boolean } {
let capped = false
if (u0 != null && u0 > cap0) {
u0 = cap0
capped = true
}
if (u1 != null && u1 > cap1) {
u1 = cap1
capped = true
}
const bounded = (r: { tvl: number | null; approx: boolean }) => (capped ? { ...r, approx: true } : r)
if (u0 == null && u1 == null) return { tvl: null, approx: false }
if (u0 == null || u1 == null) return { tvl: (u0 ?? u1)! * 2, approx: true }
if (cred0 !== cred1) {
const cu = cred0 ? u0 : u1
const ju = cred0 ? u1 : u0
if (ju > cu * TUNE.maxUncredibleRatio) return { tvl: cu * 2, approx: true }
} else if (!cred0 && !cred1) {
const lo = Math.min(u0, u1)
if (Math.max(u0, u1) > lo * TUNE.maxUncredibleRatio) return { tvl: lo * 2, approx: true }
}
return bounded({ tvl: u0 + u1, approx: false })
}
const credible = (e?: PriceEntry) => !!e && e.src !== 'pool'
/** ceiling on what a pool-priced side may claim; credible seeds are unbounded */
const capOf = (e?: PriceEntry) => (e && e.src === 'pool' ? e.depth * TUNE.maxSideOverDepth : Infinity)
/** at most this many quotes retained per token per hop (memory bound) */
const MAX_QUOTES = 32
/**
* Full pricing pass: BFS USD prices out from the GT/anchor seeds, then
* recompute every pool's TVL. Pure JS over in-memory rows (~230k pools), runs
* after full sweeps and after each GT cycle. Pool-derived prices are discarded
* and rebuilt from scratch on every call — see the rails in the file header.
*/
export function reprice(): { priced: number; tvlPools: number } {
const decs = new Map(allTokens().map((t) => [t.address, t.decimals]))
const prices = loadPrices()
const prices = loadSeeds()
// bootstrap anchor before the first GT cycle: USDG ≈ $1 (GT overwrites it)
if (!prices.has(ADDR.USDG.toLowerCase()))
prices.set(ADDR.USDG.toLowerCase(), { usd: 1, depth: 1, src: 'anchor', updated: now() })
prices.set(ADDR.USDG.toLowerCase(), { usd: 1, depth: 1, src: 'anchor', hops: 0 })
const states = statesQ()
// Balances and spot are decoded ONCE — every pool is walked maxPriceHops
// times plus once more for TVL, and formatUnits+BigInt per visit was the
// pass's whole GC bill (the container's heap cap is 448MB).
const human = (raw: string, addr: string) => Number(formatUnits(BigInt(raw), decs.get(addr) ?? 18))
const gtFresh = (e: PriceEntry) => e.src === 'gt' && now() - e.updated < TUNE.gtFreshSecs
const states = statesQ().map((s) => {
const b0 = human(s.reserve0, s.token0)
const b1 = human(s.reserve1, s.token1)
return { s, b0, b1, spot: spotOf(s, b0, b1, decs) }
})
const dirty = new Map<string, PriceEntry>()
for (let round = 0; round < 3; round++) {
let changed = 0
for (const s of states) {
const b0 = human(s.reserve0, s.token0)
const b1 = human(s.reserve1, s.token1)
for (const [known, other, kb, ob] of [
[s.token0, s.token1, b0, b1],
[s.token1, s.token0, b1, b0],
// hop 1 = priced directly against a seed, hop 2 = against a hop-1 token, ...
// A token settles at the FIRST hop that can price it and is never revisited,
// so a long chain of junk pools can never displace a nearer honest quote.
const derived = new Map<string, PriceEntry>()
for (let hop = 1; hop <= TUNE.maxPriceHops; hop++) {
const quotes = new Map<string, { usd: number; depth: number }[]>()
for (const { s, b0, b1, spot: p1per0 } of states) {
if (p1per0 === null) continue
for (const [known, other, kb, ob, knownIs0] of [
[s.token0, s.token1, b0, b1, true],
[s.token1, s.token0, b1, b0, false],
] as const) {
const kp = prices.get(known)
if (!kp || ob <= 0) continue
const depth = kb * kp.usd
if (depth < TUNE.minDepthUsd) continue
const existing = prices.get(other)
if (existing && (gtFresh(existing) || existing.depth >= depth)) continue
const e: PriceEntry = { usd: depth / ob, depth, src: 'pool', updated: now() }
prices.set(other, e)
dirty.set(other, e)
changed++
if (!kp || kp.hops !== hop - 1 || ob <= 0 || prices.has(other)) continue
// credible dollars only: past the seeds a quote is worth no more than
// the depth backing the side it came from, so an inflated price can no
// longer inflate its own authority
const depth = kp.hops === 0 ? kb * kp.usd : Math.min(kb * kp.usd, kp.depth)
if (!(depth >= TUNE.minDepthUsd)) continue
const usd = knownIs0 ? kp.usd / p1per0 : kp.usd * p1per0
if (!plausibleUsd(usd)) continue
const list = quotes.get(other)
if (!list) quotes.set(other, [{ usd, depth }])
else if (list.push({ usd, depth }) > MAX_QUOTES * 2) {
list.sort((a, b) => b.depth - a.depth)
list.length = MAX_QUOTES
}
}
}
if (!changed) break
if (!quotes.size) break
for (const [addr, qs] of quotes) {
const e: PriceEntry = {
usd: weightedMedian(qs),
depth: qs.reduce((m, q) => Math.max(m, q.depth), 0),
src: 'pool',
hops: hop,
}
prices.set(addr, e)
derived.set(addr, e)
}
}
let tvlPools = 0
tx(() => {
for (const [addr, e] of dirty) setTokenPrice(addr, e.usd, e.depth, e.src)
for (const s of states) {
clearDerivedPrices() // no pool-derived price outlives the pass that made it
for (const [addr, e] of derived) setTokenPrice(addr, e.usd, e.depth, e.src)
for (const { s, b0, b1 } of states) {
const p0 = prices.get(s.token0)
const p1 = prices.get(s.token1)
const u0 = p0 ? human(s.reserve0, s.token0) * p0.usd : null
const u1 = p1 ? human(s.reserve1, s.token1) * p1.usd : null
const tvl = u0 != null && u1 != null ? u0 + u1 : u0 != null ? u0 * 2 : u1 != null ? u1 * 2 : null
setTvl(s.address, tvl, tvl != null && (u0 == null || u1 == null))
const u0 = p0 ? b0 * p0.usd : null
const u1 = p1 ? b1 * p1.usd : null
const { tvl, approx } = tvlOf(u0, credible(p0), u1, credible(p1), capOf(p0), capOf(p1))
setTvl(s.address, tvl, approx)
if (tvl != null) tvlPools++
}
})
@@ -210,7 +391,7 @@ export function computeTvlFor(addrs: string[]): void {
const decs = new Map(allTokens().map((t) => [t.address, t.decimals]))
const prices = loadPrices()
const q = db.prepare(
`SELECT p.address, p.proto, p.token0, p.token1, s.reserve0, s.reserve1
`SELECT p.address, p.proto, p.token0, p.token1, s.reserve0, s.reserve1, s.sqrt_price, s.liquidity, s.tick
FROM pools p JOIN pool_state s ON s.address = p.address WHERE p.address = ?`,
)
tx(() => {
@@ -222,8 +403,8 @@ export function computeTvlFor(addrs: string[]): void {
const p1 = prices.get(s.token1)
const u0 = p0 ? human(s.reserve0, s.token0) * p0.usd : null
const u1 = p1 ? human(s.reserve1, s.token1) * p1.usd : null
const tvl = u0 != null && u1 != null ? u0 + u1 : u0 != null ? u0 * 2 : u1 != null ? u1 * 2 : null
setTvl(s.address, tvl, tvl != null && (u0 == null || u1 == null))
const { tvl, approx } = tvlOf(u0, credible(p0), u1, credible(p1), capOf(p0), capOf(p1))
setTvl(s.address, tvl, approx)
}
})
}