mirror of
https://github.com/labrinyang/lp-terminal.git
synced 2026-07-27 21:27:43 +00:00
4e317eff6d
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.
233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
// 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`)
|