Files
lp-terminal_github/indexer/logtail.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

50 lines
2.7 KiB
TypeScript

// Event-driven state refresh. A pool's reserves change only when it emits an
// event, so we don't reread on a timer — we ask the chain which pools changed.
// One eth_getLogs (v2 Sync, v3 Swap/Mint/Burn, any address) since the last block
// we processed yields exactly the pools that traded; we reread only those. Cost
// tracks trading activity, not catalog size — this is what lets the 6-hourly
// census over 100k+ dust pools, and the timed hot/active sweeps, go away.
//
// Two deliberate choices:
// - reread state, don't apply the logs' own deltas: whatever the chain returns
// now is the truth, so a reorg needs no unwinding and a missed log self-heals
// on the pool's next event (the daily census is the longstop).
// - gated on the same POOLS-tab demand signal as the frontpage sweep — the only
// consumer of fresh state is a watching user, and a getLogs every 12s costs
// 75 CU. Idle → no polling. On resume we jump the cursor to head instead of
// replaying the idle backlog: freshness wants the current state, not history.
import { numberToHex, toEventSelector } from 'viem'
import { TUNE, log } from './config'
import { pc } from './rpc'
import { computeTvlFor, sweepState } from './state'
import { kvGet, kvSet, poolsHitAgeMs } from './store'
export const TOPICS = [
toEventSelector('Sync(uint112,uint112)'), // univ2 reserves (fires on every mint/burn/swap)
toEventSelector('Swap(address,address,int256,int256,uint160,uint128,int24)'), // univ3 price + liquidity
toEventSelector('Mint(address,address,int24,int24,uint128,uint256,uint256)'), // univ3 liquidity add
toEventSelector('Burn(address,int24,int24,uint128,uint256,uint256)'), // univ3 liquidity remove
]
/** reread every catalog pool that emitted a tracked event since the last run */
export async function logtail(): Promise<void> {
if (poolsHitAgeMs() > TUNE.frontpageGateMs) return // nobody watching — don't spend getLogs
const head = Number(await pc.getBlockNumber())
const cursor = Number(kvGet('logtail_block') ?? 0)
const from = cursor && head - cursor <= TUNE.logtailMaxBlocks ? cursor + 1 : head // skip idle backlog
if (from > head) return
const logs = (await pc.request({
method: 'eth_getLogs',
params: [{ fromBlock: numberToHex(from), toBlock: numberToHex(head), topics: [TOPICS] }],
})) as { address: string }[]
const dirty = [...new Set(logs.map((l) => l.address.toLowerCase()))]
if (dirty.length) {
const swept = await sweepState(dirty) // sweepState ignores addresses not in the catalog
computeTvlFor(dirty)
log(`[logtail] blocks ${from}-${head}: ${dirty.length} pools emitted, ${swept} in catalog`)
}
kvSet('logtail_block', String(head))
}