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

217 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pool catalog — the authoritative list, built ONLY from the official
// factories themselves (events / enumeration). Third-party APIs never admit a
// pool here, they only enrich pools that already exist (spoofing is therefore
// structural­ly impossible: a fork pool is simply never in the table).
//
// univ3: PoolCreated logs. Backfill via Blockscout's etherscan-style getLogs
// (no block-range cap, 1000/page, fromBlock-cursor pagination —
// measured ~22 pages for the full 21,979-pool history), then a plain
// RPC getLogs tail in ≤9k-block windows (Alchemy caps at 10k).
// univ2: the factory keeps an allPairs array — enumeration IS the catalog.
// Backfill and tail are the same code path: read allPairsLength,
// fetch any indices we haven't seen.
import { parseAbiItem, toEventSelector } from 'viem'
import { uniV2FactoryAbi, uniV2PairAbi } from '../src/abi'
import { BLOCKSCOUT, log, sleep, UNI } from './config'
import { mc, ok, pc } from './rpc'
import { insertPool, kvGet, kvSet, tx } from './store'
const POOL_CREATED = parseAbiItem(
'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)',
)
const POOL_CREATED_TOPIC = toEventSelector(POOL_CREATED)
const hexInt = (x: string | number) => (typeof x === 'number' ? x : parseInt(x, 16))
const addrOfTopic = (t: string) => ('0x' + t.slice(-40)).toLowerCase()
async function bsJson(url: string): Promise<Record<string, unknown>> {
for (let i = 0; i < 4; i++) {
try {
const r = await fetch(url, {
headers: { accept: 'application/json', 'user-agent': 'up33-lp-indexer/0.1' },
})
const text = await r.text()
if (text.trim()) return JSON.parse(text)
} catch {
/* retry below */
}
await sleep(1_200 * (i + 1))
}
throw new Error('blockscout: no response after retries')
}
type BsLog = { topics: string[]; data: string; blockNumber: string }
/**
* One-time full-history PoolCreated scan (resumable via the v3_cursor kv).
* Primary source is Blockscout (no range cap, ~22 pages for full history);
* if it flakes persistently the scan falls back to windowed RPC getLogs from
* the same cursor — slower (~1.2k windows) but unconditionally available.
*/
export async function backfillV3(): Promise<number> {
if (kvGet('v3_backfilled')) return 0
let cursor = Number(kvGet('v3_cursor') ?? 0)
let added = 0
let flakes = 0
// INDEXER_BACKFILL=rpc skips Blockscout entirely — useful when it throttles
// (observed 2026-07-16: page pace degraded 1min → 6min mid-backfill). With a
// private RPC the windowed scan is deterministic (~770 windows for full
// history) and resumes from the same cursor.
if (process.env.INDEXER_BACKFILL === 'rpc') {
log(`[catalog] v3 backfill via RPC windows from blk ${cursor} (INDEXER_BACKFILL=rpc)`)
const head = Number(await pc.getBlockNumber())
added = (await scanV3Windows(cursor, head)).length
kvSet('v3_cursor', String(head))
kvSet('v3_backfilled', '1')
return added
}
for (;;) {
const j = await bsJson(
`${BLOCKSCOUT}/api?module=logs&action=getLogs&fromBlock=${cursor}&toBlock=latest&address=${UNI.V3_FACTORY}&topic0=${POOL_CREATED_TOPIC}`,
).catch(() => ({ status: '0', message: 'no response' }) as Record<string, unknown>)
if (j.status !== '1') {
if (/no records/i.test(String(j.message))) break
if (++flakes >= 6) {
// Blockscout is down/unhappy — finish the remaining range over RPC
log(`[catalog] blockscout flaking ("${j.message}") — RPC-window fallback from blk ${cursor}`)
const head = Number(await pc.getBlockNumber())
added += (await scanV3Windows(cursor, head)).length
kvSet('v3_cursor', String(head))
break
}
await sleep(2_000 * flakes)
continue
}
flakes = 0
const logs = j.result as BsLog[]
tx(() => {
for (const l of logs) {
// topics: [sig, token0, token1, fee]; data: [tickSpacing:int24, pool:address]
const tickSpacing = Number(BigInt.asIntN(24, BigInt('0x' + l.data.slice(2, 66))))
if (
insertPool({
address: addrOfTopic(l.data.slice(66, 130)),
proto: 'univ3',
token0: addrOfTopic(l.topics[1]),
token1: addrOfTopic(l.topics[2]),
feePpm: hexInt(l.topics[3]),
tickSpacing,
createdBlock: hexInt(l.blockNumber),
})
)
added++
}
})
const last = hexInt(logs[logs.length - 1].blockNumber)
kvSet('v3_cursor', String(last))
log(`[catalog] v3 backfill +${added} pools (cursor blk ${last})`)
if (logs.length < 1000) break
cursor = last // overlap the last block; PK dedupes
await sleep(300)
}
kvSet('v3_backfilled', '1')
return added
}
/** windowed RPC getLogs scan (≤9k blocks per request — under Alchemy's 10k cap) */
async function scanV3Windows(from: number, to: number): Promise<string[]> {
const fresh: string[] = []
let logged = 0
for (let lo = from; lo <= to; lo += 9_001) {
const hi = Math.min(lo + 9_000, to)
const logs = await pc.getLogs({
address: UNI.V3_FACTORY,
event: POOL_CREATED,
fromBlock: BigInt(lo),
toBlock: BigInt(hi),
})
for (const l of logs) {
const a = l.args
if (!a.pool || !a.token0 || !a.token1 || a.fee === undefined || a.tickSpacing === undefined) continue
if (
insertPool({
address: a.pool.toLowerCase(),
proto: 'univ3',
token0: a.token0,
token1: a.token1,
feePpm: a.fee,
tickSpacing: a.tickSpacing,
createdBlock: Number(l.blockNumber),
})
)
fresh.push(a.pool.toLowerCase())
}
if (to - from > 100_000 && ++logged % 100 === 0)
log(`[catalog] rpc scan blk ${hi}/${to} (+${fresh.length})`)
}
return fresh
}
/** RPC tail from the stored cursor to head; returns newly added pool addresses */
export async function tailV3(): Promise<string[]> {
const head = Number(await pc.getBlockNumber())
const from = Math.max(0, Number(kvGet('v3_cursor') ?? head - 2_000) - 120) // ~12s overlap
const fresh = await scanV3Windows(from, head)
kvSet('v3_cursor', String(head))
return fresh
}
/**
* univ2 catalog sync (backfill == tail): fetch allPairs indices we haven't
* seen yet. The cursor only advances past indices that fully resolved, so a
* partial multicall failure is retried on the next tick.
*/
export async function syncV2(): Promise<string[]> {
const count = Number(
await pc.readContract({ abi: uniV2FactoryAbi, address: UNI.V2_FACTORY, functionName: 'allPairsLength' }),
)
let known = Number(kvGet('v2_count') ?? 0)
if (count <= known) return []
const fresh: string[] = []
while (known < count) {
const n = Math.min(2_000, count - known) // 2k pairs per round = 5 + 10 aggregates
const idx = Array.from({ length: n }, (_, i) => known + i)
const pairRes = await mc(
idx.map((i) => ({ abi: uniV2FactoryAbi, address: UNI.V2_FACTORY, functionName: 'allPairs', args: [BigInt(i)] })),
)
const pairs: string[] = []
for (const r of pairRes) {
const a = ok<string>(r)
if (!a) break // stop at first failure — cursor advances only past successes
pairs.push(a)
}
if (!pairs.length) break
const tokRes = await mc(
pairs.flatMap((p) => [
{ abi: uniV2PairAbi, address: p as `0x${string}`, functionName: 'token0' },
{ abi: uniV2PairAbi, address: p as `0x${string}`, functionName: 'token1' },
]),
)
let done = 0
tx(() => {
for (let i = 0; i < pairs.length; i++) {
const t0 = ok<string>(tokRes[i * 2])
const t1 = ok<string>(tokRes[i * 2 + 1])
if (!t0 || !t1) break
if (
insertPool({
address: pairs[i].toLowerCase(),
proto: 'univ2',
token0: t0,
token1: t1,
feePpm: 3_000, // vanilla v2: fixed 0.30%
pairIndex: known + i,
})
)
fresh.push(pairs[i].toLowerCase())
done++
}
})
if (!done) break
known += done
kvSet('v2_count', String(known))
if (known < count) log(`[catalog] v2 sync ${known}/${count}`)
}
return fresh
}