Back to OScope
oscope/src/store.mjs
Code
415 lines (383 loc) · 18.1 KB
// 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; }
}
}