Files
lp-terminal_github/indexer/state.ts
T
labrinyang 4e317eff6d LP TERMINAL — terminal-style LP frontend for Robinhood Chain
Static SPA over UP33 (ve(3,3)) + the official Uniswap v2/v3 deployments:
browse and add liquidity across all three, manage CL/v2 positions, one-token
ZAP adds, aggregator vs native swaps, and LP-based limit orders. Ships with a
zero-dependency pool indexer for uniswap discovery.

Browser-wallet signing only; no key material anywhere.
2026-07-17 19:10:30 +08:00

233 lines
8.3 KiB
TypeScript
Raw 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.
// On-chain state sweeps + USD pricing.
//
// State per pool (multicall):
// univ3: slot0 + liquidity + erc20 balanceOf(token0/1) — balances (not L)
// 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).
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,
db,
missingMetaTokens,
setTokenPrice,
setTvl,
tx,
upsertState,
upsertTokenMeta,
type PoolRow,
} from './store'
const printable = (s: unknown): string | null => {
if (typeof s !== 'string') return null
const t = s.replace(/[^\x20-\x7e]/g, '').trim()
return t ? t.slice(0, 24) : null
}
/** fetch symbol/decimals for catalog tokens we haven't met yet (10k/slice) */
export async function ensureTokenMeta(): Promise<number> {
const all = missingMetaTokens()
for (let i = 0; i < all.length; i += 10_000) {
const missing = all.slice(i, i + 10_000)
const res = await mc(
missing.flatMap((t) => [
{ abi: erc20Abi, address: t as `0x${string}`, functionName: 'symbol' },
{ abi: erc20Abi, address: t as `0x${string}`, functionName: 'decimals' },
]),
)
tx(() => {
missing.forEach((t, j) => {
const sym = printable(ok<string>(res[j * 2]))
const dec = ok<number>(res[j * 2 + 1])
upsertTokenMeta(t, sym ?? t.slice(0, 6) + '…', dec ?? 18, sym !== null && dec !== undefined)
})
})
}
return all.length
}
const poolRowsQ = (addrs: string[]): PoolRow[] => {
const out: PoolRow[] = []
const q = db.prepare('SELECT address, proto, token0, token1, fee_ppm, tick_spacing FROM pools WHERE address = ?')
for (const a of addrs) {
const r = q.get(a.toLowerCase()) as PoolRow | undefined
if (r) out.push(r)
}
return out
}
/** refresh raw on-chain state for the given pools (memory-bounded slices) */
export async function sweepState(addrs: string[]): Promise<number> {
let done = 0
for (let i = 0; i < addrs.length; i += 5_000) {
done += await sweepSlice(addrs.slice(i, i + 5_000))
}
return done
}
async function sweepSlice(addrs: string[]): Promise<number> {
if (!addrs.length) return 0
const rows = poolRowsQ(addrs)
const calls: Call[] = []
for (const p of rows) {
const a = p.address as `0x${string}`
if (p.proto === 'univ3')
calls.push(
{ abi: uniV3PoolAbi, address: a, functionName: 'slot0' },
{ abi: uniV3PoolAbi, address: a, functionName: 'liquidity' },
{ abi: erc20Abi, address: p.token0 as `0x${string}`, functionName: 'balanceOf', args: [a] },
{ abi: erc20Abi, address: p.token1 as `0x${string}`, functionName: 'balanceOf', args: [a] },
)
else
calls.push(
{ abi: uniV2PairAbi, address: a, functionName: 'getReserves' },
{ abi: uniV2PairAbi, address: a, functionName: 'totalSupply' },
)
}
const res = await mc(calls)
let i = 0
tx(() => {
for (const p of rows) {
if (p.proto === 'univ3') {
const s0 = ok<readonly [bigint, number, ...unknown[]]>(res[i++])
const liq = ok<bigint>(res[i++])
const b0 = ok<bigint>(res[i++])
const b1 = ok<bigint>(res[i++])
if (!s0) continue
upsertState(p.address, {
sqrtPrice: s0[0],
tick: s0[1],
liquidity: liq ?? 0n,
reserve0: b0 ?? 0n,
reserve1: b1 ?? 0n,
})
} 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 })
}
}
})
return rows.length
}
type PriceEntry = { usd: number; depth: number; src: string; updated: number }
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 })
return m
}
type StateRow = {
address: string
proto: string
token0: string
token1: string
reserve0: string
reserve1: string
}
const statesQ = () =>
db
.prepare(
`SELECT p.address, p.proto, p.token0, p.token1, s.reserve0, s.reserve1
FROM pools p JOIN pool_state s ON s.address = p.address`,
)
.all() as StateRow[]
/**
* 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.
*/
export function reprice(): { priced: number; tvlPools: number } {
const decs = new Map(allTokens().map((t) => [t.address, t.decimals]))
const prices = loadPrices()
// 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() })
const states = statesQ()
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 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],
] 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 (!changed) break
}
let tvlPools = 0
tx(() => {
for (const [addr, e] of dirty) setTokenPrice(addr, e.usd, e.depth, e.src)
for (const s 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))
if (tvl != null) tvlPools++
}
})
return { priced: prices.size, tvlPools }
}
/** cheap TVL refresh for a few pools using already-stored prices (no propagation) */
export function computeTvlFor(addrs: string[]): void {
if (!addrs.length) return
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
FROM pools p JOIN pool_state s ON s.address = p.address WHERE p.address = ?`,
)
tx(() => {
for (const a of addrs) {
const s = q.get(a.toLowerCase()) as StateRow | undefined
if (!s) continue
const human = (raw: string, addr: string) => Number(formatUnits(BigInt(raw), decs.get(addr) ?? 18))
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))
}
})
}
export const sweepLog = (label: string, n: number, ms: number) =>
log(`[sweep] ${label} ${n} pools in ${(ms / 1000).toFixed(1)}s`)