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.
This commit is contained in:
labrinyang
2026-07-17 19:10:30 +08:00
commit 4e317eff6d
77 changed files with 22678 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
// 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`)