// Everything the terminal knows about coins, built from the live trade stream. // // One frame = one transaction on the pump.fun bonding curve ("pump") or on PumpSwap ("pump-amm"). // Frames carry the pool reserves after the transaction, so price, market cap and curve progress are exact; // they also carry each trader's token balance after the transaction, so holder balances are exact for every // wallet that traded while OScope was running. // // All amounts are in SOL. The page turns them into dollars with the live SOL price. import fs from 'node:fs'; import path from 'node:path'; import { config } from './config.mjs'; const WSOL = 'So11111111111111111111111111111111111111112'; const CURVE_TOKENS = 793_100_000; // tokens a fresh pump.fun curve can sell before it migrates const isSol = q => q === WSOL || q === 'sol' || q == null; const now = () => Date.now(); // Memory: full detail only for coins somebody may look at (in a Pulse column, open on a page, held in the paper // wallet, or launched in the last ten minutes). Every other coin keeps a light copy: live numbers, its last trades, the // wallets that still hold it and two hours of minute candles. Without this, coins traded around the clock by bots // pile up until the process uses gigabytes. const LIGHT = { trades: 30, holders: 100, s1: 60, m1: 120 }; const YOUNG_MS = 10 * 60e3, VIEWED_MS = 30 * 60e3; const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); function newCoin(f) { return { mint: f.mint, name: f.name || '', symbol: f.symbol || '', uri: f.uri || '', image: '', description: '', twitter: '', website: '', telegram: '', metaState: 0, created: 0, createdBlock: null, dev: '', origin: f.pool === 'pump' || f.poolCreatedBy === 'pump' ? 'pump' : 'other', pool: f.pool, poolId: f.poolId || '', supply: +f.supply || 1e9, decimals: f.decimals ?? 6, tokenProgram: f.tokenProgram || '', mayhem: !!f.mayhemMode, price: 0, mc: 0, ath: 0, liq: 0, rq: 0, rt: 0, inPool: 0, fee: +f.poolFeeRate || 0.0125, progress: 0, seenCurve: f.pool === 'pump', complete: false, migratedAt: 0, stretchAt: 0, buys: 0, sells: 0, volB: 0, volS: 0, fees: 0, firstSeen: now(), last: 0, holders: new Map(), // wallet -> { bal, bs, ss, bt, st, n, first, last } hGone: 0, // wallets still holding that were dropped from a light copy (counted in holders) viewed: 0, // when a page last asked for this coin snipers: new Set(), bundle: new Set(), trades: [], // [ts, isBuy, sol, tokens, priceSol, mcSol, wallet, signature] m1: [], // minute candles of market cap in SOL: [t, o, h, l, c, volSol] s1: [], // one-second candles, the same, for the last hour of trading seconds ver: 1, }; } export class Store { constructor() { this.coins = new Map(); this.devs = new Map(); // dev wallet -> [{ m, n, s, t, mig, ath }] this.onTrade = null; // (coin, trade) => void this.onCoin = null; // (coin) => void, a coin was created or migrated this.wantMeta = null; // (coin) => void. Pictures and socials are fetched only for coins somebody looks at. this.stats = { frames: 0, trades: 0, creates: 0, migrations: 0, lastFrame: 0, started: now() }; this.columns = { new: [], stretch: [], migrated: [] }; this.pinned = new Set(); // mints a page is looking at: never dropped } ingest(f) { this.stats.frames++; this.stats.lastFrame = now(); if (!f || !f.mint || (f.pool !== 'pump' && f.pool !== 'pump-amm')) return; const a = f.action; if (a === 'buy' || a === 'sell') this.trade(f); else if (a === 'create') this.create(f); else if (a === 'migrate' || a === 'complete' || (a === 'createPool' && f.poolCreatedBy === 'pump')) this.migrate(f); } coinOf(f) { let c = this.coins.get(f.mint); if (!c) { c = newCoin(f); this.coins.set(f.mint, c); } else { if (!c.name && f.name) c.name = f.name; if (!c.symbol && f.symbol) c.symbol = f.symbol; if (!c.uri && f.uri) c.uri = f.uri; if (f.poolCreatedBy === 'pump') c.origin = 'pump'; } return c; } applyPool(c, f, ts) { if (f.pool === 'pump') { c.seenCurve = true; c.rq = +f.vQuoteInBondingCurve || c.rq; c.rt = +f.vTokensInBondingCurve || c.rt; if (f.tokensInPool != null) { c.progress = clamp((CURVE_TOKENS - f.tokensInPool) / CURVE_TOKENS * 100, 0, 100); if (f.tokensInPool <= 0.5) c.complete = true; } } else { // PumpSwap: price = (SOL in pool + virtual SOL) / tokens in pool if (c.pool === 'pump' && c.seenCurve && !c.migratedAt && c.origin === 'pump' && f.poolCreatedBy === 'pump') this.markMigrated(c, ts); c.rq = (+f.quoteInPool || 0) + (+f.virtualQuoteInPool || 0); c.rt = +f.tokensInPool || c.rt; c.progress = 100; } c.pool = f.pool; if (f.poolId) c.poolId = f.poolId; if (f.supply) c.supply = +f.supply; if (f.poolFeeRate != null) c.fee = +f.poolFeeRate; if (f.price > 0) c.price = +f.price; if (f.marketCapQuote > 0) c.mc = +f.marketCapQuote; c.liq = (+f.quoteInPool || 0) + (+f.tokensInPool || 0) * c.price; if (f.tokensInPool != null) c.inPool = +f.tokensInPool; if (c.mc > c.ath) c.ath = c.mc; if (c.pool === 'pump' && c.progress >= config.stretchAt && !c.stretchAt) c.stretchAt = ts; } create(f) { if (!isSol(f.quoteMint)) return; const ts = +f.timestamp || now(); const c = this.coinOf(f); c.created = ts; c.createdBlock = f.block ?? null; c.dev = f.txSigner || f.creatorFeeAddress || ''; c.origin = 'pump'; c.mayhem = !!f.mayhemMode; this.applyPool(c, f, ts); this.stats.creates++; this.devAdd(c); if (this.wantMeta) this.wantMeta(c); // a new coin goes straight to New Pairs: get its picture now // a launch with a dev buy carries the buy in the same frame this.fills(c, f, ts); c.ver++; if (this.onCoin) this.onCoin(c, 'create'); } migrate(f) { const c = this.coins.get(f.mint); if (!c) return; const ts = +f.timestamp || now(); if (f.action === 'complete') { c.complete = true; c.ver++; return; } if (f.pool === 'pump-amm') this.applyPool(c, f, ts); if (!c.migratedAt) this.markMigrated(c, ts); } markMigrated(c, ts) { c.migratedAt = ts; c.complete = true; this.stats.migrations++; const d = c.dev && this.devs.get(c.dev); if (d) { const e = d.find(x => x.m === c.mint); if (e) e.mig = ts; } c.ver++; if (this.onCoin) this.onCoin(c, 'migrate'); } trade(f) { if (!isSol(f.quoteMint)) return; // anyone can open a side pool for a pump coin; only the pool made by the migration counts if (f.pool === 'pump-amm' && f.poolCreatedBy && f.poolCreatedBy !== 'pump') return; const ts = +f.timestamp || now(); const c = this.coinOf(f); this.applyPool(c, f, ts); this.fills(c, f, ts); c.ver++; } fills(c, f, ts) { const parts = Array.isArray(f.breakdown) && f.breakdown.length ? f.breakdown : [{ action: f.action, trader: f.txSigner, tokenAmount: f.tokenAmount, quoteAmount: f.quoteAmount }]; let any = false; for (const p of parts) { if (p.action !== 'buy' && p.action !== 'sell') continue; const buy = p.action === 'buy'; const sol = +p.quoteAmount || 0, tok = +p.tokenAmount || 0, w = p.trader || f.txSigner; if (!w || (!sol && !tok)) continue; let h = c.holders.get(w); if (!h) { h = { bal: 0, bs: 0, ss: 0, bt: 0, st: 0, n: 0, first: ts, last: ts }; c.holders.set(w, h); } if (buy) { h.bs += sol; h.bt += tok; c.buys++; c.volB += sol; } else { h.ss += sol; h.st += tok; c.sells++; c.volS += sol; } h.n++; h.last = ts; const post = f.postBalances?.[w]?.[c.mint]; h.bal = post != null && Number.isFinite(+post) ? +post : Math.max(0, h.bal + (buy ? tok : -tok)); if (buy && c.createdBlock != null && w !== c.dev && f.block != null) { const d = f.block - c.createdBlock; if (d === 0) c.bundle.add(w); else if (d > 0 && d <= 2) c.snipers.add(w); } c.fees += sol * c.fee; const tr = [ts, buy ? 1 : 0, sol, tok, c.price, c.mc, w, f.signature || '']; c.trades.push(tr); if (c.trades.length > config.tradeCap + 200) c.trades.splice(0, c.trades.length - config.tradeCap); this.candle(c, ts, c.mc, sol); this.stats.trades++; any = true; if (this.onTrade) this.onTrade(c, tr); } if (any) { c.fees += +f.priorityFee || 0; c.last = ts; } } candle(c, ts, v, sol) { if (!(v > 0)) return; this.bucket(c.s1, Math.floor(ts / 1000), v, sol, 3600); this.bucket(c.m1, Math.floor(ts / 60000) * 60, v, sol, 4320); } bucket(m, t, v, sol, cap) { const last = m[m.length - 1]; if (last && last[0] === t) { if (v > last[2]) last[2] = v; if (v < last[3]) last[3] = v; last[4] = v; last[5] += sol; } else if (!last || t > last[0]) { const o = last ? last[4] : v; m.push([t, o, Math.max(o, v), Math.min(o, v), v, sol]); if (m.length > cap + 100) m.splice(0, m.length - cap); } } devAdd(c) { if (!c.dev) return; let d = this.devs.get(c.dev); if (!d) { d = []; this.devs.set(c.dev, d); } if (!d.some(x => x.m === c.mint)) { d.push({ m: c.mint, n: c.name, s: c.symbol, t: c.created, mig: 0, ath: c.ath }); if (d.length > 100) d.splice(0, d.length - 100); } } // ---------- what the page reads ---------- holderStats(c) { let top = [], n = 0, devBal = 0, snBal = 0, buBal = 0, sum = 0; for (const [w, h] of c.holders) { if (h.bal <= 0.5) continue; n++; sum += h.bal; if (w === c.dev) devBal = h.bal; if (c.snipers.has(w)) snBal += h.bal; if (c.bundle.has(w)) buBal += h.bal; if (top.length < 10) { top.push(h.bal); if (top.length === 10) top.sort((a, b) => a - b); } else if (h.bal > top[0]) { top[0] = h.bal; top.sort((a, b) => a - b); } } const s = c.supply || 1e9, pct = v => +Math.min(100, v / s * 100).toFixed(2); // Tokens sent by plain transfer never show in the trade stream, so a sender still looks like a holder. // When the wallets add up to more than the tokens outside the pool, the split is not known. if (sum > (s - c.inPool) * 1.03 + 1) return { holders: n + c.hGone, top10: null, dev: null, snipers: null, bundle: null, devSold: false }; return { holders: n + c.hGone, top10: pct(top.reduce((a, b) => a + b, 0)), dev: pct(devBal), snipers: pct(snBal), bundle: pct(buBal), devSold: !!c.dev && c.holders.has(c.dev) && devBal <= 0.5 }; } row(c) { if (c._rowVer === c.ver && c._row) return c._row; const hs = this.holderStats(c); const d = c.dev ? this.devs.get(c.dev) : null; c._row = { m: c.mint, n: c.name, s: c.symbol, img: c.image ? `/pic/${c.mint}` : '', tw: c.twitter, web: c.website, tg: c.telegram, t: c.created, mig: c.migratedAt, last: c.last, mc: c.mc, ath: c.ath, liq: c.liq, vol: c.volB + c.volS, buys: c.buys, sells: c.sells, fees: c.fees, p: c.price, rq: c.rq, rt: c.rt, fee: c.fee, pr: +c.progress.toFixed(1), pool: c.pool, may: c.mayhem ? 1 : 0, done: c.complete ? 1 : 0, h: hs.holders, top10: hs.top10, dh: hs.dev, sn: hs.snipers, bu: hs.bundle, ds: hs.devSold ? 1 : 0, dev: c.dev, dc: d ? d.length : 0, dm: d ? d.filter(x => x.mig).length : 0, v: c.ver, }; c._rowVer = c.ver; return c._row; } detail(c) { return { ...this.row(c), price: c.price, supply: c.supply, rq: c.rq, rt: c.rt, fee: c.fee, poolId: c.poolId, desc: c.description, uri: c.uri, firstSeen: c.firstSeen, decimals: c.decimals, tp: c.tokenProgram, seen: !!c.created }; } holdersOf(c, limit = 100) { const list = []; for (const [w, h] of c.holders) list.push([w, h]); const tag = w => (w === c.dev ? 'dev' : c.bundle.has(w) ? 'bundle' : c.snipers.has(w) ? 'sniper' : ''); const holders = list.filter(([, h]) => h.bal > 0.5).sort((a, b) => b[1].bal - a[1].bal).slice(0, limit) .map(([w, h]) => ({ w, bal: h.bal, pct: h.bal / c.supply * 100, bs: h.bs, ss: h.ss, n: h.n, first: h.first, tag: tag(w) })); const traders = list.map(([w, h]) => [w, h, h.ss - h.bs + h.bal * c.price]).sort((a, b) => b[2] - a[2]).slice(0, limit) .map(([w, h, pnl]) => ({ w, bal: h.bal, pct: h.bal / c.supply * 100, bs: h.bs, ss: h.ss, bt: h.bt, st: h.st, n: h.n, pnl, tag: tag(w) })); return { holders, traders, total: list.filter(([, h]) => h.bal > 0.5).length }; } candles(c, tf) { // tf in seconds: under a minute from the one-second candles (the last hour), a minute and up from minute candles const src = tf < 60 ? c.s1 : c.m1; const step = tf; const out = []; for (const [t0, o, h, l, cl, v] of src) { const t = Math.floor(t0 / step) * step; const last = out[out.length - 1]; if (last && last[0] === t) { if (h > last[2]) last[2] = h; if (l < last[3]) last[3] = l; last[4] = cl; last[5] += v; } else out.push([t, o, h, l, cl, v]); } return out; } // ---------- Pulse columns ---------- placeColumns() { const t = now(), maxAge = config.newMaxAgeMin * 60e3, cap = config.columnCap; const nw = [], st = [], mg = []; for (const c of this.coins.values()) { if (c.origin !== 'pump') continue; if (c.migratedAt) { if (t - c.migratedAt < 24 * 3600e3) mg.push(c); continue; } if (c.pool !== 'pump') continue; if (c.progress >= config.stretchAt || c.complete) { if (t - c.last < 15 * 60e3) st.push(c); } else if (c.progress < config.stretchAt - 5 && c.stretchAt) c.stretchAt = 0; if (c.created && t - c.created < maxAge && c.progress < config.stretchAt && !c.complete) nw.push(c); } nw.sort((a, b) => b.created - a.created); st.sort((a, b) => b.progress - a.progress); mg.sort((a, b) => b.migratedAt - a.migratedAt); this.columns = { new: nw.slice(0, cap), stretch: st.slice(0, cap), migrated: mg.slice(0, cap) }; return this.columns; } // ---------- memory ---------- // a light copy of a coin nobody is looking at (see LIGHT) lighten(c) { if (c.trades.length > LIGHT.trades) c.trades.splice(0, c.trades.length - LIGHT.trades); if (c.s1.length > LIGHT.s1) c.s1.splice(0, c.s1.length - LIGHT.s1); if (c.m1.length > LIGHT.m1) c.m1.splice(0, c.m1.length - LIGHT.m1); if (c.holders.size > LIGHT.holders) { for (const [w, h] of c.holders) if (h.bal <= 0.5 && w !== c.dev) c.holders.delete(w); if (c.holders.size > LIGHT.holders) { const small = [...c.holders].filter(([w]) => w !== c.dev).sort((a, b) => a[1].bal - b[1].bal); for (const [w] of small.slice(0, c.holders.size - LIGHT.holders)) { c.holders.delete(w); c.hGone++; } } } c.light = true; } // what the memory holds, for /api/status memory() { let trades = 0, holders = 0, candles = 0, light = 0; for (const c of this.coins.values()) { trades += c.trades.length; holders += c.holders.size; candles += c.s1.length + c.m1.length; if (c.light) light++; } return { coins: this.coins.size, light, trades, holders, candles }; } prune() { const t = now(), idle = config.idleDropMin * 60e3; const keep = new Set(); for (const col of Object.values(this.columns)) for (const c of col) keep.add(c.mint); for (const m of this.pinned) keep.add(m); for (const c of this.coins.values()) { if (keep.has(c.mint) || t - c.viewed < VIEWED_MS || (c.created && t - c.created < YOUNG_MS)) c.light = false; else this.lighten(c); } let dropped = 0; for (const [m, c] of this.coins) { if (keep.has(m)) continue; const lastAct = Math.max(c.last, c.created, c.migratedAt, c.firstSeen); if (t - lastAct > idle) { this.coins.delete(m); dropped++; } } if (this.coins.size > config.maxCoins) { const list = [...this.coins.values()].filter(c => !keep.has(c.mint) && c.light).sort((a, b) => a.last - b.last); for (const c of list.slice(0, this.coins.size - config.maxCoins)) { this.coins.delete(c.mint); dropped++; } } // a busy coin's holder map: drop wallets that sold out long ago for (const c of this.coins.values()) { if (c.holders.size > 4000) { for (const [w, h] of c.holders) if (h.bal <= 0.5 && t - h.last > 10 * 60e3 && w !== c.dev) c.holders.delete(w); } } // dev index: keep 20,000 devs if (this.devs.size > 20000) { const list = [...this.devs.entries()].sort((a, b) => a[1].at(-1).t - b[1].at(-1).t); for (const [d] of list.slice(0, this.devs.size - 20000)) this.devs.delete(d); } return dropped; } // ---------- saved between runs ---------- save() { const dir = config.dataDir; fs.mkdirSync(dir, { recursive: true }); const t = now(), keep = []; for (const c of this.coins.values()) { if (c.origin !== 'pump') continue; // what the Pulse columns need after a restart, and what pages have open const fresh = (c.created && t - c.created < config.newMaxAgeMin * 60e3) || (c.migratedAt && t - c.migratedAt < 24 * 3600e3) || this.pinned.has(c.mint) || (c.progress >= config.stretchAt && t - c.last < 15 * 60e3); if (!fresh) continue; const holders = [...c.holders].sort((a, b) => b[1].bal - a[1].bal).slice(0, 200); keep.push({ ...c, holders, snipers: [...c.snipers], bundle: [...c.bundle], trades: c.trades.slice(-200), s1: c.s1.slice(-600), m1: c.m1.slice(-1440), _row: undefined, _rowVer: undefined }); } const write = (name, data) => { const p = path.join(dir, name); fs.writeFileSync(p + '.tmp', JSON.stringify(data)); fs.renameSync(p + '.tmp', p); }; write('coins.json', { saved: t, coins: keep }); write('devs.json', [...this.devs]); return keep.length; } load() { const dir = config.dataDir; try { const d = JSON.parse(fs.readFileSync(path.join(dir, 'devs.json'), 'utf8')); this.devs = new Map(d); } catch {} try { const { coins } = JSON.parse(fs.readFileSync(path.join(dir, 'coins.json'), 'utf8')); for (const o of coins) { const c = { ...newCoin(o), ...o, holders: new Map(o.holders), snipers: new Set(o.snipers), bundle: new Set(o.bundle), ver: 1 }; if (!c.s1.length) for (const tr of c.trades) this.bucket(c.s1, Math.floor(tr[0] / 1000), tr[5], tr[2], 3600); this.coins.set(c.mint, c); } return coins.length; } catch { return 0; } } }