| |
| |
| |
| |
| |
| |
| |
| |
| |
| import fs from 'node:fs'; |
| import path from 'node:path'; |
| import { config } from './config.mjs'; |
| |
| const WSOL = 'So11111111111111111111111111111111111111112'; |
| const CURVE_TOKENS = 793_100_000; |
| const isSol = q => q === WSOL || q === 'sol' || q == null; |
| |
| const now = () => Date.now(); |
| |
| |
| |
| |
| |
| 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(), |
| hGone: 0, |
| viewed: 0, |
| snipers: new Set(), bundle: new Set(), |
| trades: [], |
| m1: [], |
| s1: [], |
| ver: 1, |
| }; |
| } |
| |
| export class Store { |
| constructor() { |
| this.coins = new Map(); |
| this.devs = new Map(); |
| this.onTrade = null; |
| this.onCoin = null; |
| this.wantMeta = null; |
| this.stats = { frames: 0, trades: 0, creates: 0, migrations: 0, lastFrame: 0, started: now() }; |
| this.columns = { new: [], stretch: [], migrated: [] }; |
| this.pinned = new Set(); |
| } |
| |
| 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 { |
| |
| 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); |
| |
| 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; |
| |
| 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); |
| } |
| } |
| |
| |
| |
| 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); |
| |
| |
| 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) { |
| |
| 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; |
| } |
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| 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++; } |
| } |
| |
| 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); |
| } |
| } |
| |
| 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; |
| } |
| |
| |
| |
| 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; |
| |
| 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; } |
| } |
| } |