OScope: every text file of commit 9d9eae4d711c8575b6fbb4d882cc53b3af7dc798, part 2 of 3. Code: https://git.oscope.trade/oscope · git clone https://git.oscope.trade/oscope.git · manifest: https://git.oscope.trade/oscope/manifest.json Each file starts with "===== FILE: =====" and a line with its sha256, size and line count. Nothing is left out or changed. ===== FILE: tools/tape.mjs ===== sha256: 82d88cf130461c3d826097756be61ec24bf3914d55047d7326cc632d6f37ef7d · bytes: 2992 · lines: 50 // Live trades in your terminal. // // npm run tape pump.fun and PumpSwap trades of 0.01 SOL or more (smaller ones are mostly volume bots) // npm run tape -- 1 only trades of 1 SOL or more // npm run tape -- 0 every single trade // // It reads OScope's firehose when OScope is running on this computer, otherwise the stream itself. import WebSocket from 'ws'; import { config } from '../src/config.mjs'; const minSol = Math.max(0, Number(process.argv[2] ?? process.env.MIN_SOL ?? 0.01) || 0); const local = `ws://127.0.0.1:${config.port}/firehose?pools=pump,pump-amm`; const c = { g: s => `\x1b[32m${s}\x1b[0m`, r: s => `\x1b[31m${s}\x1b[0m`, d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`, y: s => `\x1b[33m${s}\x1b[0m` }; let usd = 0; const price = () => fetch('https://api.coinbase.com/v2/prices/SOL-USD/spot', { signal: AbortSignal.timeout(4000) }).then(r => r.json()).then(j => { usd = +j.data.amount || usd; }).catch(() => {}); setInterval(price, 30000).unref(); const money = v => (v >= 1e6 ? (v / 1e6).toFixed(2) + 'M' : v >= 1e3 ? (v / 1e3).toFixed(1) + 'K' : v.toFixed(0)); let shown = 0, seen = 0; function open(url, fallback) { const ws = new WebSocket(url); let ok = false; ws.on('open', () => { ok = true; console.log(c.d(`reading ${url.split('?')[0]}${minSol ? `, trades of ${minSol} SOL or more` : ''} (Ctrl+C to stop)\n`)); }); ws.on('message', buf => { let f; try { f = JSON.parse(buf.toString()); } catch { return; } if ((f.pool !== 'pump' && f.pool !== 'pump-amm') || (f.action !== 'buy' && f.action !== 'sell' && f.action !== 'create' && f.action !== 'migrate')) return; seen++; const t = new Date(f.timestamp || Date.now()).toTimeString().slice(0, 8); const sym = String(f.symbol || '').slice(0, 12).padEnd(12); if (f.action === 'create') return console.log(`${c.d(t)} ${c.y('NEW ')} ${c.b(sym)} ${String(f.name || '').slice(0, 40)} ${c.d(f.mint)}`); if (f.action === 'migrate') return console.log(`${c.d(t)} ${c.y('MIGR')} ${c.b(sym)} moved to PumpSwap ${c.d(f.mint)}`); const sol = +f.quoteAmount || 0; if (sol < minSol) return; shown++; const side = f.action === 'buy' ? c.g('BUY ') : c.r('SELL'); const mc = usd && f.marketCapQuote ? '$' + money(f.marketCapQuote * usd) : (f.marketCapQuote || 0).toFixed(0) + ' SOL'; console.log(`${c.d(t)} ${side} ${sol.toFixed(3).padStart(9)} SOL ${c.b(sym)} MC ${mc.padEnd(8)} ${c.d(f.pool === 'pump' ? 'curve' : 'swap ')} ${c.d(String(f.txSigner || '').slice(0, 6) + '…')}`); }); ws.on('close', () => { if (!ok && fallback) return open(fallback, null); console.log(c.d('\nstream closed, reopening in 3 s')); setTimeout(() => open(url, null), 3000); }); ws.on('error', () => {}); } process.on('SIGINT', () => { console.log(c.d(`\n${seen.toLocaleString('en-US')} trades seen, ${shown.toLocaleString('en-US')} shown`)); process.exit(0); }); price().then(() => open(local, config.feedUrl)); ===== FILE: public/js/app.js ===== sha256: 0325dd15f13a4a21f6abc22f3ef1274a005f4a9653346edc6eeace010d8349e1 · bytes: 4207 · lines: 84 // OScope: page entry. Header, search, routes. import { $, h, usd, sol as fsol, short } from './util.js'; import { icon } from './icons.js'; import { live, net } from './net.js'; import { paper } from './paper.js'; import './trackers.js'; import { route, mount, go } from './router.js'; import { avatar } from './parts.js'; import { openSettings } from './settings.js'; import { pulseView } from './pulse.js'; import { liveView } from './live.js'; import { coinView } from './coin.js'; import { portfolioView } from './portfolio.js'; import { trackersView, walletView } from './trackerview.js'; route(/^\/$/, pulseView, 'pulse'); route(/^\/live$/, liveView, 'live'); route(/^\/coin\/([1-9A-HJ-NP-Za-km-z]{32,44})$/, coinView, ''); route(/^\/portfolio$/, portfolioView, 'portfolio'); route(/^\/trackers$/, trackersView, 'trackers'); route(/^\/wallet\/([1-9A-HJ-NP-Za-km-z]{32,44})$/, walletView, ''); // ---------- header ---------- $('.search-ic').innerHTML = icon('search', 15); $('.wallet-ic').innerHTML = icon('wallet', 15); $('#settings-btn').innerHTML = icon('gear', 17); $('#settings-btn').addEventListener('click', openSettings); function status() { const f = $('#feed'); const ok = live.open && live.feed && (live.lag == null || live.lag < 15000); f.className = 'feed ' + (ok ? 'ok' : live.open ? 'bad' : ''); $('#feed-txt').textContent = !live.open ? 'Connecting' : !live.feed ? 'Stream down' : live.lag > 15000 ? 'Stream quiet' : 'Live'; $('#solpx span').textContent = live.sol ? '$' + live.sol.toFixed(2) : '–'; } live.on('status', status); status(); function wallet() { $('#wallet-sol').textContent = fsol(paper.s.sol) + ' SOL'; } paper.on('change', wallet); wallet(); // ---------- search ---------- const q = $('#q'), res = $('#search-res'); let timer = 0, items = [], sel = -1, seq = 0; async function search() { const text = q.value.trim(); if (!text) { res.classList.remove('open'); return; } const my = ++seq; let list = []; try { list = await net.get('/api/search?q=' + encodeURIComponent(text)); } catch {} if (my !== seq) return; items = list; sel = list.length ? 0 : -1; const isAddr = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(text); res.replaceChildren(...(list.length ? list.map((r, i) => h('a.sr' + (i === sel ? '.sel' : ''), { href: `/coin/${r.m}`, 'data-link': '', onmousedown: e => e.preventDefault(), onclick: () => close() }, avatar(r, 36), h('div', { style: { minWidth: 0 } }, h('b', null, r.n || r.s), ' ', h('span.mut', null, r.s), h('div.mut', null, short(r.m, 5))), h('div', { style: { textAlign: 'right' } }, h('b', null, usd(r.mc * live.sol)), h('div.mut', null, r.mig ? 'Migrated' : r.pr.toFixed(0) + '% curve')))) : isAddr ? [h('a.sr', { href: `/coin/${text}`, 'data-link': '', onclick: () => close() }, h('div'), h('div', null, h('b', null, 'Open this coin'), h('div.mut', null, short(text, 6))), h('span')), h('a.sr', { href: `/wallet/${text}`, 'data-link': '', onclick: () => close() }, h('div'), h('div', null, h('b', null, 'Open as a wallet'), h('div.mut', null, short(text, 6))), h('span'))] : [h('div.sr-empty', null, 'No coin by that name in the last hours')])); res.classList.add('open'); } function close() { res.classList.remove('open'); q.value = ''; q.blur(); } q.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(search, 120); }); q.addEventListener('focus', () => { if (q.value.trim()) search(); }); q.addEventListener('blur', () => setTimeout(() => res.classList.remove('open'), 120)); q.addEventListener('keydown', e => { const links = [...res.querySelectorAll('a.sr')]; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); sel = Math.max(0, Math.min(links.length - 1, sel + (e.key === 'ArrowDown' ? 1 : -1))); links.forEach((a, i) => a.classList.toggle('sel', i === sel)); } else if (e.key === 'Enter') { const a = links[Math.max(0, sel)]; if (a) { go(a.getAttribute('href')); close(); } } else if (e.key === 'Escape') close(); }); document.addEventListener('keydown', e => { if (e.key === '/' && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName)) { e.preventDefault(); q.focus(); } }); mount(); ===== FILE: public/js/coin.js ===== sha256: 058df857d94ff1bd740c9394dd559872b55772f08f59708ecfe79d37a1a7bb89 · bytes: 26216 · lines: 477 // Coin page: chart, live trades, holders, top traders, the dev's other coins, and the paper trade panel. import { createChart, CandlestickSeries, HistogramSeries, createSeriesMarkers, CrosshairMode } from '/vendor/lightweight-charts.mjs'; import { h, $, age, usd, sol as fsol, compact, count, pct, tinyUsd, short, copy, store, signed } from './util.js'; import { icon, iconEl } from './icons.js'; import { live, net } from './net.js'; import { paper } from './paper.js'; import { trackers } from './trackers.js'; import { avatar, setImg, socials, level, emptyState } from './parts.js'; import { settings } from './settings.js'; const TFS = [['1s', 1], ['5s', 5], ['15s', 15], ['30s', 30], ['1m', 60], ['5m', 300], ['15m', 900], ['1h', 3600], ['4h', 14400]]; const SOLSCAN = 'https://solscan.io'; export function coinView(root, mint) { let d = null; // coin detail let alive = true; const ui = {}; const pref = store.get('chart', { tf: 15, unit: 'mc', cur: 'usd' }); let candles = [], trades = [], tab = 'trades', side = 'b', amount = '', sellFrac = 0.5, holdersData = null, devList = null; const page = h('div.coin.page-in'); root.append(page); // ---------- band ---------- ui.av = avatar({ m: mint, s: '' }, 48); ui.nm = h('span.nm', null, '…'); ui.sy = h('span.sy'); ui.age = h('span.agev'); ui.soc = h('div.soc'); ui.mcv = h('span.v', null, '–'); ui.stats = h('div.band-stats'); const band = h('div.band', null, ui.av, h('div.band-id', null, h('div.l1', null, ui.nm, ui.sy), h('div.l2', null, ui.age, h('span.addr', { title: 'Copy address', onclick: () => copy(mint, 'Address copied') }, short(mint, 5), iconEl('copy', 12)), ui.soc)), h('div.hero', null, h('span.k', null, 'Market cap'), ui.mcv), ui.stats, h('div.band-r', null, h('a.btn.sm', { href: `https://pump.fun/coin/${mint}`, target: '_blank', rel: 'noopener noreferrer', html: 'pump.fun ' + icon('ext', 12) }), h('a.btn.sm', { href: `${SOLSCAN}/token/${mint}`, target: '_blank', rel: 'noopener noreferrer', html: 'Solscan ' + icon('ext', 12) }))); page.append(band); // ---------- chart ---------- const tfSeg = h('div.seg'); for (const [label, s] of TFS) tfSeg.append(h('button' + (pref.tf === s ? '.on' : ''), { onclick: e => { pref.tf = s; savePref(); mark(tfSeg, e.currentTarget); loadCandles(); } }, label)); const unitSeg = h('div.seg', null, h('button' + (pref.unit === 'mc' ? '.on' : ''), { onclick: e => { pref.unit = 'mc'; savePref(); mark(unitSeg, e.currentTarget); drawAll(); } }, 'MC'), h('button' + (pref.unit === 'price' ? '.on' : ''), { onclick: e => { pref.unit = 'price'; savePref(); mark(unitSeg, e.currentTarget); drawAll(); } }, 'Price')); const curSeg = h('div.seg', null, h('button' + (pref.cur === 'usd' ? '.on' : ''), { onclick: e => { pref.cur = 'usd'; savePref(); mark(curSeg, e.currentTarget); drawAll(); } }, 'USD'), h('button' + (pref.cur === 'sol' ? '.on' : ''), { onclick: e => { pref.cur = 'sol'; savePref(); mark(curSeg, e.currentTarget); drawAll(); } }, 'SOL')); ui.ohlc = h('div.ohlc'); ui.chartBox = h('div.chart'); ui.chartNote = h('div.chart-note'); ui.chartBox.append(ui.chartNote); const chartWrap = h('div.chart-wrap', null, h('div.chart-bar', null, tfSeg, unitSeg, curSeg, ui.ohlc), ui.chartBox); // ---------- tabs ---------- ui.tabsH = h('div.tabs-h'); ui.tabBody = h('div.tab-body'); const TABS = [['trades', 'Trades'], ['holders', 'Holders'], ['traders', 'Top traders'], ['dev', 'Dev tokens'], ['mine', 'Your trades']]; ui.tabBtn = {}; for (const [k, label] of TABS) { const cnt = h('span.count'); const b = h('button' + (k === tab ? '.on' : ''), { onclick: () => { tab = k; for (const x of Object.values(ui.tabBtn)) x.classList.remove('on'); b.classList.add('on'); drawTab(); } }, label, cnt); b._cnt = cnt; ui.tabBtn[k] = b; ui.tabsH.append(b); } page.append(h('div.main', null, chartWrap, h('div.tabs', null, ui.tabsH, ui.tabBody))); // ---------- side ---------- ui.side = h('div.side'); page.append(ui.side); buildSide(); // ---------- chart setup ---------- const chart = createChart(ui.chartBox, { autoSize: true, layout: { background: { color: '#06070a' }, textColor: '#737b8c', fontFamily: 'Geist, system-ui, sans-serif', fontSize: 11, attributionLogo: true }, grid: { vertLines: { color: 'rgba(255,255,255,.025)' }, horzLines: { color: 'rgba(255,255,255,.035)' } }, rightPriceScale: { borderColor: '#171a22', scaleMargins: { top: 0.12, bottom: 0.2 } }, timeScale: { borderColor: '#171a22', timeVisible: true, secondsVisible: true, rightOffset: 6, barSpacing: 8 }, crosshair: { mode: CrosshairMode.Normal, vertLine: { color: '#2e3442', labelBackgroundColor: '#1b1f2a' }, horzLine: { color: '#2e3442', labelBackgroundColor: '#1b1f2a' } }, localization: { priceFormatter: v => fmtAxis(v) }, }); const series = chart.addSeries(CandlestickSeries, { upColor: '#2fe39b', downColor: '#ff5d7b', borderVisible: false, wickUpColor: '#2fe39b', wickDownColor: '#ff5d7b', priceFormat: { type: 'custom', formatter: v => fmtAxis(v), minMove: 1e-12 }, priceLineColor: '#4d8dff', priceLineStyle: 2, }); const volSeries = chart.addSeries(HistogramSeries, { priceScaleId: 'vol', priceFormat: { type: 'volume' }, lastValueVisible: false, priceLineVisible: false }); chart.priceScale('vol').applyOptions({ scaleMargins: { top: 0.84, bottom: 0 } }); const markers = createSeriesMarkers(series, []); chart.subscribeCrosshairMove(p => { const c = p && p.time ? p.seriesData.get(series) : null; showOhlc(c || null); }); function fmtAxis(v) { if (v < 0) return ''; // the axis margin below a coin near zero is not a price if (pref.unit === 'price') return pref.cur === 'usd' ? tinyUsd(v) : (v < 1e-3 ? v.toExponential(2) : v.toPrecision(4)); return pref.cur === 'usd' ? '$' + compact(v, 2) : compact(v, 2); } function conv(mcSol) { const supply = d?.supply || 1e9; let v = mcSol; if (pref.unit === 'price') v /= supply; if (pref.cur === 'usd') v *= live.sol || 0; return v; } function bar(c) { return { time: c[0], open: conv(c[1]), high: conv(c[2]), low: conv(c[3]), close: conv(c[4]) }; } function vbar(c) { return { time: c[0], value: c[5] * (pref.cur === 'usd' ? live.sol || 0 : 1), color: c[4] >= c[1] ? 'rgba(47,227,155,.28)' : 'rgba(255,93,123,.28)' }; } function showOhlc(c) { const last = c || (candles.length ? bar(candles[candles.length - 1]) : null); if (!last) { ui.ohlc.textContent = ''; return; } const ch = last.open ? (last.close / last.open - 1) * 100 : 0; ui.ohlc.innerHTML = ''; ui.ohlc.append( h('span', null, 'O ', h('b', null, fmtAxis(last.open))), h('span', null, 'H ', h('b', null, fmtAxis(last.high))), h('span', null, 'L ', h('b', null, fmtAxis(last.low))), h('span', null, 'C ', h('b', null, fmtAxis(last.close))), h('span.' + (ch >= 0 ? 'up' : 'down'), null, signed(ch, x => x.toFixed(2) + '%'))); } function drawAll() { series.applyOptions({ priceFormat: { type: 'custom', formatter: v => fmtAxis(v), minMove: pref.unit === 'price' ? 1e-14 : 1e-6 } }); series.setData(candles.map(bar)); volSeries.setData(candles.map(vbar)); drawMarkers(); showOhlc(null); ui.chartNote.replaceChildren(candles.length ? '' : (d ? 'No trades in this window yet' : '')); } async function loadCandles() { try { candles = await net.get(`/api/candles/${mint}?tf=${pref.tf}`); } catch { candles = []; } if (!alive) return; drawAll(); fitView(); } function fitView() { const ts = chart.timeScale(); // a young chart starts at the left and grows right; a long one shows its last 90 bars if (candles.length < 60) ts.setVisibleLogicalRange({ from: -1, to: 64 }); else ts.setVisibleLogicalRange({ from: candles.length - 90, to: candles.length + 4 }); } function liveCandle(tr) { const tf = pref.tf, ts = tr[0], mc = tr[5], v = tr[2]; if (!(mc > 0)) return; const t = Math.floor(ts / 1000 / tf) * tf; const last = candles[candles.length - 1]; if (last && last[0] === t) { last[2] = Math.max(last[2], mc); last[3] = Math.min(last[3], mc); last[4] = mc; last[5] += v; } else if (!last || t > last[0]) { const o = last ? last[4] : mc; candles.push([t, o, Math.max(o, mc), Math.min(o, mc), mc, v]); } else return; const c = candles[candles.length - 1]; series.update(bar(c)); volSeries.update(vbar(c)); if (candles.length === 1) ui.chartNote.replaceChildren(''); } function drawMarkers() { const out = []; const tf = pref.tf; const track = trackers.byW(); const first = candles.length ? candles[0][0] : Infinity; for (const tr of trades) { const w = tr[6]; const isDev = d && w === d.dev; const tk = track.get(w); if (!isDev && !tk) continue; const t = Math.floor(tr[0] / 1000 / tf) * tf; if (t < first) continue; out.push({ time: t, position: tr[1] ? 'belowBar' : 'aboveBar', shape: 'circle', color: isDev ? '#ffd479' : '#8fe3b8', text: (isDev ? 'Dev ' : (tk.emoji || tk.name.slice(0, 8)) + ' ') + (tr[1] ? 'B' : 'S'), size: 0.8 }); } for (const x of paper.s.hist) { if (x.m !== mint) continue; const t = Math.floor(x.ts / 1000 / tf) * tf; if (t < first) continue; out.push({ time: t, position: x.buy ? 'belowBar' : 'aboveBar', shape: x.buy ? 'arrowUp' : 'arrowDown', color: '#4d8dff', text: x.buy ? 'You B' : 'You S', size: 1 }); } out.sort((a, b) => a.time - b.time); markers.setMarkers(out.slice(-300)); } // a price with many zeros: $0.0₄123 drawn with a real small figure (the text face has no subscript digits) function priceHtml(v) { const t = tinyUsd(v), m = t.match(/^\$0\.0([₀-₉]+)(\d+)$/); if (!m) return t; const n = [...m[1]].map(ch => '₀₁₂₃₄₅₆₇₈₉'.indexOf(ch)).join(''); return h('span', null, '$0.0', h('sub.z', null, n), m[2]); } function savePref() { store.set('chart', pref); } function mark(seg, btn) { for (const b of seg.children) b.classList.toggle('on', b === btn); } // ---------- band + side numbers ---------- function drawBand() { if (!d) return; const sol = live.sol || 0; ui.nm.textContent = d.n || d.s || short(mint); ui.sy.textContent = d.s; document.title = `${d.s || 'Coin'} ${usd(d.mc * sol)} · OScope`; if (d.img) setImg(ui.av, d.img); const ph = ui.av.querySelector('.ph'); if (ph && d.s) ph.textContent = d.s.replace(/^\$/, '').slice(0, 2).toUpperCase(); const ts = d.t || 0; ui.age.dataset.t = ts; ui.age.textContent = ts ? age(ts) : ''; if (!ui.soc._k || ui.soc._k !== d.tw + d.web + d.tg) { const s = socials(d, 15); s._k = d.tw + d.web + d.tg; ui.soc.replaceWith(s); ui.soc = s; } ui.mcv.textContent = usd(d.mc * sol); const kv = (k, v, cls) => h('div.kv', null, h('span.k', null, k), h('span.v' + (cls ? '.' + cls : ''), null, v)); ui.stats.replaceChildren( kv('Price', priceHtml(d.price * sol)), kv('Liquidity', usd(d.liq * sol)), kv('Volume', usd(d.vol * sol)), kv('Holders', d.seen ? count(d.h) : '–'), kv('Buys / sells', `${count(d.buys)} / ${count(d.sells)}`), kv('ATH', usd(d.ath * sol))); } function buildSide() { ui.side.replaceChildren(); // trade panel const bsB = h('button.b', { onclick: () => { side = 'b'; buildSide(); } }, 'Buy'); const bsS = h('button.s', { onclick: () => { side = 's'; buildSide(); } }, 'Sell'); (side === 'b' ? bsB : bsS).classList.add('on'); const s = settings.get(); ui.quote = h('div.quote'); ui.bal = h('span.mut'); let body; if (side === 'b') { if (amount === '') amount = String(s.buyPresets[1] ?? 0.5); const input = h('input', { value: amount, inputMode: 'decimal', oninput: e => { amount = e.target.value; markPresets(); drawQuote(); } }); const presets = h('div.presets'); const markPresets = () => { for (const b of presets.children) b.classList.toggle('on', +b.dataset.v === +amount); }; for (const v of s.buyPresets) presets.append(h('button', { 'data-v': v, onclick: () => { amount = String(v); input.value = amount; markPresets(); drawQuote(); } }, String(v))); markPresets(); ui.goBtn = h('button.go.b', { onclick: () => { learn(); paper.buy(mint, parseFloat(amount), { n: d?.n, s: d?.s, img: d?.img }); } }); body = [presets, h('label.amt', null, input, h('span', null, 'SOL')), ui.quote, ui.goBtn]; } else { const presets = h('div.presets'); for (const v of s.sellPresets) presets.append(h('button' + (Math.abs(v / 100 - sellFrac) < 1e-6 ? '.on' : ''), { onclick: e => { sellFrac = v / 100; for (const b of presets.children) b.classList.toggle('on', b === e.currentTarget); drawQuote(); } }, v + '%')); ui.goBtn = h('button.go.s', { onclick: () => { learn(); paper.sell(mint, sellFrac); } }); body = [presets, ui.quote, ui.goBtn]; } ui.side.append(h('div.card', null, h('div.bs', null, bsB, bsS), ...body, h('div.quote', null, h('span', null, 'Paper wallet'), ui.bal))); // position ui.pos = h('div.pos'); ui.side.append(h('div.card', null, h('div.card-h', null, 'Your position'), ui.pos)); // token info ui.info = h('div.grid4'); ui.prog = h('div.prog'); ui.side.append(h('div.card', null, h('div.card-h', null, 'Token info'), ui.info, ui.prog)); // dev + about ui.dev = h('div.card'); ui.about = h('div.card'); ui.side.append(ui.dev, ui.about); drawSide(); } function learn() { if (d) paper.learn(mint, { rq: d.rq, rt: d.rt, fee: d.fee, price: d.price, mc: d.mc, pool: d.pool, n: d.n, s: d.s, img: d.img }); } function drawQuote() { const sol = live.sol || 0; ui.bal.textContent = fsol(paper.s.sol) + ' SOL'; if (!d) { ui.quote.textContent = ''; ui.goBtn.textContent = side === 'b' ? 'Buy' : 'Sell'; return; } const q = { rq: d.rq, rt: d.rt, fee: d.fee }; if (side === 'b') { const a = parseFloat(amount); ui.goBtn.textContent = a > 0 ? `Buy ${a} SOL` : 'Buy'; if (a > 0 && q.rq > 0) { const tok = paper.quoteBuy(q, a); const avg = a / tok, spot = q.rq / q.rt; ui.quote.replaceChildren(h('span', null, 'You get ', h('b', null, compact(tok, 2) + ' ' + (d.s || ''))), h('span', null, 'Impact ', h('b', null, pct((avg / spot - 1) * 100, 2)))); } else ui.quote.replaceChildren(); } else { const p = paper.position(mint); const tok = p ? p.tok * sellFrac : 0; ui.goBtn.textContent = `Sell ${Math.round(sellFrac * 100)}%`; if (tok > 0 && q.rq > 0) { const got = paper.quoteSell(q, tok); ui.quote.replaceChildren(h('span', null, 'You get ', h('b', null, fsol(got) + ' SOL')), h('span', null, h('b', null, usd(got * sol)))); } else ui.quote.replaceChildren(h('span', null, 'No tokens to sell')); } } function drawSide() { drawQuote(); const sol = live.sol || 0; const p = paper.position(mint); const cell = (k, v, cls) => h('div', null, h('span.k', null, k), h('span.v' + (cls ? '.' + cls : ''), null, v)); if (p && (p.tok > 0 || p.spent > 0)) { const value = d ? p.tok * d.price : 0; const pnl = value + p.got - p.spent; const pp = p.spent ? pnl / p.spent * 100 : 0; ui.pos.replaceChildren( cell('Holding', `${usd(value * sol)}`), cell('Tokens', compact(p.tok, 2)), cell('Bought', fsol(p.spent) + ' SOL'), cell('Sold', fsol(p.got) + ' SOL'), cell('PnL', signed(pnl, x => fsol(x)) + ' SOL', pnl >= 0 ? 'up' : 'down'), cell('PnL %', signed(pp, x => x.toFixed(1) + '%'), pnl >= 0 ? 'up' : 'down')); } else ui.pos.replaceChildren(h('div', { style: { gridColumn: '1 / -1' } }, h('span.k', null, 'No position'), h('span.mut', null, 'Paper buys show here with live PnL.'))); if (!d) return; const c = (k, ic, v, cls) => h('div.cell' + (cls ? '.' + cls : ''), null, h('span.v', null, v), h('span.k', { html: icon(ic, 11) + k })); const known = d.top10 != null; ui.info.replaceChildren( c('Top 10', 'crown', known ? pct(d.top10) : '–', level(d.top10, 20, 40)), c('Dev', 'chef', !known ? '–' : d.ds ? 'Sold' : pct(d.dh), d.ds ? 'lv-m' : level(d.dh, 5, 15)), c('Snipers', 'target', known ? pct(d.sn) : '–', level(d.sn, 3, 15)), c('Bundle', 'boxes', known ? pct(d.bu) : '–', level(d.bu, 5, 20)), c('Holders', 'users', d.seen ? count(d.h) : '–'), c('Fees paid', 'bolt', fsol(d.fees) + ' SOL')); if (d.pool === 'pump' && !d.mig) { ui.prog.replaceChildren( h('div.prog-h', null, h('span', null, 'Bonding curve'), h('b', null, d.pr.toFixed(1) + '%')), h('div.prog-bar', null, h('i', { style: { width: Math.min(100, d.pr) + '%' } }))); } else ui.prog.replaceChildren(h('div.prog-h', null, h('span', null, d.mig ? 'Migrated ' + age(d.mig) + ' ago' : 'Trading on PumpSwap'), h('span.tag.mig', null, 'PumpSwap'))); // dev if (d.dev) { const t = trackers.name(d.dev); ui.dev.replaceChildren( h('div.card-h', null, 'Dev', h('a.btn.sm.ghost', { href: `/wallet/${d.dev}`, 'data-link': '', html: 'Wallet ' + icon('ext', 12) })), h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' } }, h('span.addr', { onclick: () => copy(d.dev, 'Dev address copied') }, short(d.dev, 6), iconEl('copy', 12)), t ? h('span.tag.tracked', null, t.name) : h('button.btn.sm', { onclick: () => { trackers.add(d.dev, 'Dev of ' + (d.s || short(mint))); drawSide(); } }, iconEl('plus', 12), 'Track')), h('div.quote', null, h('span', null, 'Coins seen from this dev'), h('b', null, `${d.dc} launched · ${d.dm} migrated`))); } else ui.dev.replaceChildren(h('div.card-h', null, 'Dev'), h('span.mut', null, 'Launched before OScope started watching.')); ui.about.replaceChildren(h('div.card-h', null, 'About'), d.desc ? h('div.desc', null, d.desc) : h('span.mut', null, 'No description.')); } // ---------- tabs ---------- function tradeRow(tr, fresh) { const sol = live.sol || 0; const [ts, buy, s, tok, , mc, w, sig] = tr; const isDev = d && w === d.dev; const tk = trackers.name(w); const tg = isDev ? h('span.tag.dev', null, 'Dev') : tk ? h('span.tag.tracked', null, tk.name) : d && holdersTag(w); const row = h('tr' + (fresh ? '.fresh' : ''), null, h('td.mut', { 'data-t': ts }, age(ts)), h('td', null, h('b.' + (buy ? 'up' : 'down'), null, buy ? 'Buy' : 'Sell')), h('td', null, usd(mc * sol)), h('td.r', null, compact(tok, 2)), h('td.r.' + (buy ? 'up' : 'down'), null, fsol(s)), h('td.r', null, usd(s * sol)), h('td', null, h('a.w', { href: `/wallet/${w}`, 'data-link': '' }, short(w, 4)), tg ? ' ' : '', tg || ''), h('td.r', null, sig ? h('a.ext', { href: `${SOLSCAN}/tx/${sig}`, target: '_blank', rel: 'noopener noreferrer', html: icon('ext', 13) }) : '')); return row; } function holdersTag(w) { if (!holdersData) return null; const x = holdersData.holders.find(y => y.w === w) || holdersData.traders.find(y => y.w === w); return x && x.tag ? h('span.tag.' + x.tag, null, x.tag) : null; } function tradesTable() { ui.tbody = h('tbody'); for (const tr of trades.slice(0, 200)) ui.tbody.append(tradeRow(tr)); return h('table.t', null, h('thead', null, h('tr', null, h('th', null, 'Age'), h('th', null, 'Type'), h('th', null, 'MC'), h('th.r', null, 'Amount'), h('th.r', null, 'SOL'), h('th.r', null, 'USD'), h('th', null, 'Trader'), h('th'))), ui.tbody); } function holdersTable(list, traders) { const sol = live.sol || 0; const tb = h('tbody'); list.forEach((x, i) => { const tk = trackers.name(x.w); const pnl = traders ? x.pnl : x.ss - x.bs + x.bal * (d?.price || 0); tb.append(h('tr', null, h('td.mut', null, i + 1), h('td', null, h('a.w', { href: `/wallet/${x.w}`, 'data-link': '' }, short(x.w, 5)), ' ', x.tag ? h('span.tag.' + x.tag, null, x.tag) : '', tk ? h('span.tag.tracked', null, tk.name) : ''), h('td.r', null, pct(x.pct, 2)), h('td.r', null, compact(x.bal, 2)), h('td.r.up', null, fsol(x.bs)), h('td.r.down', null, fsol(x.ss)), h('td.r.' + (pnl >= 0 ? 'up' : 'down'), null, signed(pnl * sol, v => usd(v))))); }); if (!list.length) return emptyState('No holders seen yet', 'Wallets that trade this coin while OScope runs are listed here.'); return h('table.t', null, h('thead', null, h('tr', null, h('th', null, '#'), h('th', null, 'Wallet'), h('th.r', null, 'Holds'), h('th.r', null, 'Amount'), h('th.r', null, 'Bought'), h('th.r', null, 'Sold'), h('th.r', null, 'PnL'))), tb); } async function drawTab() { const body = ui.tabBody; ui.tbody = null; if (tab === 'trades') body.replaceChildren(trades.length ? tradesTable() : emptyState('No trades yet', 'Trades stream in live.')); else if (tab === 'holders' || tab === 'traders') { if (!holdersData) body.replaceChildren(emptyState('Loading holders')); await loadHolders(); if (tab === 'holders' || tab === 'traders') body.replaceChildren(holdersTable(tab === 'holders' ? holdersData?.holders || [] : holdersData?.traders || [], tab === 'traders')); } else if (tab === 'dev') { if (!d?.dev) { body.replaceChildren(emptyState('Unknown dev', 'This coin launched before OScope started watching.')); return; } try { devList = await net.get(`/api/dev/${d.dev}`); } catch { devList = []; } if (tab !== 'dev') return; const sol = live.sol || 0; body.replaceChildren(devList.length ? h('div.dev-cards', null, devList.map(x => h('a.dc', { href: `/coin/${x.m}`, 'data-link': '' }, avatar({ m: x.m, s: x.s, img: x.img }, 36), h('div', null, h('b', null, x.n || x.s), ' ', h('span.mut', null, x.s), x.m === mint ? h('span.tag.you', { style: { marginLeft: '6px' } }, 'This coin') : '', h('div.mut', { style: { fontSize: '12px' } }, age(x.t) + ' ago')), x.mig ? h('span.tag.mig', null, 'Migrated') : h('span'), h('div.r', null, h('span', null, 'MC ', h('b', null, x.live ? usd(x.mc * sol) : '–')), h('span.mut', null, 'ATH ' + usd(x.ath * sol)))))) : emptyState('No other coins seen', 'Coins this wallet launches while OScope runs are listed here.')); } else if (tab === 'mine') { const mine = paper.s.hist.filter(x => x.m === mint); const sol = live.sol || 0; body.replaceChildren(mine.length ? h('table.t', null, h('thead', null, h('tr', null, h('th', null, 'Age'), h('th', null, 'Type'), h('th', null, 'MC'), h('th.r', null, 'Amount'), h('th.r', null, 'SOL'))), h('tbody', null, mine.map(x => h('tr.me', null, h('td.mut', null, age(x.ts)), h('td', null, h('b.' + (x.buy ? 'up' : 'down'), null, x.buy ? 'Buy' : 'Sell')), h('td', null, usd(x.mc * sol)), h('td.r', null, compact(x.tok, 2)), h('td.r', null, fsol(x.sol)))))) : emptyState('No paper trades on this coin', 'Use the panel on the right.')); } countTabs(); } function countTabs() { ui.tabBtn.trades._cnt.textContent = trades.length ? count(trades.length) : ''; ui.tabBtn.holders._cnt.textContent = d?.h ? count(d.h) : ''; ui.tabBtn.dev._cnt.textContent = d?.dc ? d.dc : ''; const mine = paper.s.hist.filter(x => x.m === mint).length; ui.tabBtn.mine._cnt.textContent = mine || ''; } let holdersAt = 0; async function loadHolders(force) { if (!force && Date.now() - holdersAt < 4000 && holdersData) return; holdersAt = Date.now(); try { holdersData = await net.get(`/api/holders/${mint}`); } catch {} } // ---------- data ---------- async function load() { try { d = await net.get(`/api/coin/${mint}`); } catch (e) { d = null; ui.nm.textContent = short(mint, 6); ui.chartNote.replaceChildren(emptyState('Not seen yet', 'OScope shows this coin the moment it trades.')); } if (!alive) return; if (d) { learn(); drawBand(); drawSide(); } try { trades = await net.get(`/api/trades/${mint}?limit=400`); } catch { trades = []; } await loadCandles(); if (!alive) return; drawTab(); loadHolders(true).then(() => { if (alive && tab === 'trades') drawTab(); }); } const offC = live.on('c', m => { if (m.d.m !== mint) return; const was = d; d = m.d; learn(); drawBand(); drawSide(); countTabs(); if (!was) loadCandles(); }); const offT = live.on('trs', m => { if (m.m !== mint) return; for (const tr of m.list) { trades.unshift(tr); liveCandle(tr); if (ui.tbody && tab === 'trades') { ui.tbody.prepend(tradeRow(tr, true)); if (ui.tbody.children.length > 300) ui.tbody.lastChild.remove(); } } if (trades.length > 2000) trades.length = 2000; if (tab === 'trades' && !ui.tbody && trades.length) drawTab(); if (m.list.some(tr => (d && tr[6] === d.dev) || trackers.name(tr[6]))) drawMarkers(); countTabs(); }); const offPaper = paper.on('trade', x => { if (x.m === mint) { drawSide(); drawMarkers(); if (tab === 'mine') drawTab(); countTabs(); } }); const offPx = paper.on('change', () => drawQuote()); const ageTick = setInterval(() => { const t = Date.now(); if (ui.age.dataset.t > 0) ui.age.textContent = age(+ui.age.dataset.t, t); if (ui.tbody) for (const td of ui.tbody.querySelectorAll('td[data-t]')) td.textContent = age(+td.dataset.t, t); if (tab === 'holders' || tab === 'traders') { loadHolders().then(() => { if (alive && (tab === 'holders' || tab === 'traders') && !ui.tabBody.matches(':hover')) ui.tabBody.replaceChildren(holdersTable(tab === 'holders' ? holdersData?.holders || [] : holdersData?.traders || [], tab === 'traders')); }); } }, 1000); const offSol = live.on('status', () => { if (d) { drawBand(); } }); net.coin(mint); load(); return () => { alive = false; net.coin(null); offC(); offT(); offPaper(); offPx(); offSol(); clearInterval(ageTick); chart.remove(); document.title = 'OScope'; }; } ===== FILE: public/js/icons.js ===== sha256: 0d3ea36d9bf32452b607c41b491839f542da9bd84cfe8394dd5d1a7a66efcb78 · bytes: 4407 · lines: 46 // Line icons, 24x24, drawn with currentColor. const P = { x: '', xlogo: '', globe: '', tg: '', search: '', copy: '', bolt: '', user: '', users: '', crown: '', chef: '', target: '', boxes: '', gear: '', eyeoff: '', filter: '', pause: '', wallet: '', plus: '', trash: '', ext: '', pill: '', swap: '', chart: '', radar: '', star: '', bell: '', bellOff: '', reset: '', info: '', edit: '', down: '', github: '', }; export function icon(name, size = 14, cls = '') { return ``; } export function iconEl(name, size, cls) { const t = document.createElement('template'); t.innerHTML = icon(name, size, cls); return t.content.firstChild; } ===== FILE: public/js/live.js ===== sha256: 03d32d84c68fb88cbba80898cd747b124a412a7bcb32fabdcb82fb0eaab75cb3 · bytes: 6600 · lines: 115 // Firehose: every pump.fun and PumpSwap trade as it lands. import { h, age, usd, sol as fsol, short, store, count } from './util.js'; import { icon } from './icons.js'; import { live, net } from './net.js'; import { avatar } from './parts.js'; import { trackers } from './trackers.js'; const KEEP = 160; export function liveView(root) { const pref = { pool: 'all', side: 'all', min: '0.01', ...store.get('live', {}) }; // under 0.01 SOL is mostly volume bots const win = []; // [ts, buy, sol] of the last 60 s, for the numbers on top let paused = false, held = [], rate = 0; const page = h('div.page.page-in'); const wrap = h('div.wrap.live-wrap'); page.append(wrap); root.append(page); const tile = (k) => { const v = h('span.v', null, '–'), s = h('span.s'); return { el: h('div.tile', null, h('span.k', null, k), v, s), v, s }; }; const tRate = tile('Trades per second'), tBuy = tile('Buys, last minute'), tVol = tile('Volume, last minute'), tBig = tile('Biggest trade, last minute'); const seg = (key, opts) => { const box = h('div.seg'); for (const [v, label] of opts) box.append(h('button' + (pref[key] === v ? '.on' : ''), { onclick: e => { pref[key] = v; save(); for (const b of box.children) b.classList.toggle('on', b === e.currentTarget); } }, label)); return box; }; const minIn = h('input', { value: pref.min, placeholder: '0', inputMode: 'decimal', oninput: e => { pref.min = e.target.value.trim(); save(); } }); const pauseBtn = h('button.btn.sm', { onclick: () => setPause(!paused) }); const state = h('span.live-state'); const tbody = h('tbody'); const table = h('table.t.tape', null, h('thead', null, h('tr', null, h('th', null, 'Time'), h('th', null, 'Coin'), h('th', null, 'Type'), h('th.r', null, 'SOL'), h('th.r', null, 'USD'), h('th.r', null, 'MC'), h('th', null, 'Trader'), h('th', null, 'Where'), h('th'))), tbody); const body = h('div.panel.tape-panel', null, h('div.panel-h', null, h('div.tape-tools', null, seg('pool', [['all', 'All'], ['pump', 'Curve'], ['pump-amm', 'PumpSwap']]), seg('side', [['all', 'Buys + sells'], ['buy', 'Buys'], ['sell', 'Sells']]), h('label.min-sol', { title: 'Hide trades smaller than this' }, 'Min', minIn, h('span', null, 'SOL'))), h('div.tape-tools', null, state, pauseBtn)), h('div.tape-scroll', null, table)); wrap.append( h('div.ph-h', null, h('div', null, h('h1', null, 'Firehose'), h('p', null, 'Every pump.fun and PumpSwap trade, the moment it lands.'))), h('div.tiles', null, tRate.el, tBuy.el, tVol.el, tBig.el), body); table.addEventListener('mouseenter', () => setPause(true, true)); table.addEventListener('mouseleave', () => { if (paused === 'hover') setPause(false); }); function setPause(on, hover) { paused = on ? (hover ? 'hover' : true) : false; pauseBtn.innerHTML = paused ? icon('bolt', 13) + 'Resume' : icon('pause', 13) + 'Pause'; state.textContent = paused ? (held.length ? `Paused · ${count(held.length)} new` : 'Paused') : ''; state.classList.toggle('on', !!paused); if (!paused && held.length) { add(held.slice(-KEEP)); held = []; } } setPause(false); function save() { store.set('live', pref); } function pass(x) { if (pref.pool !== 'all' && x[8] !== pref.pool) return false; if (pref.side === 'buy' && !x[4]) return false; if (pref.side === 'sell' && x[4]) return false; const m = parseFloat(pref.min); if (m > 0 && x[5] < m) return false; return true; } function row(x) { const [ts, mint, sym, img, buy, s, mc, w, pool, sig, name] = x; const solUsd = live.sol || 0; const tk = trackers.name(w); return h('tr.fresh' + (s >= 5 ? '.big' : ''), null, h('td.mut', { 'data-t': ts }, age(ts)), h('td', null, h('a.coin-cell', { href: `/coin/${mint}`, 'data-link': '' }, avatar({ m: mint, s: sym, img }, 26), h('b', null, sym || short(mint)), h('span.mut.tape-name', null, name || ''))), h('td', null, h('b.' + (buy ? 'up' : 'down'), null, buy ? 'Buy' : 'Sell')), h('td.r.' + (buy ? 'up' : 'down'), null, fsol(s)), h('td.r', null, usd(s * solUsd)), h('td.r', null, usd(mc * solUsd)), h('td', null, h('a.w', { href: `/wallet/${w}`, 'data-link': '' }, short(w, 4)), tk ? h('span.tag.tracked', { style: { marginLeft: '6px' } }, tk.name) : ''), h('td', null, h('span.tag' + (pool === 'pump' ? '' : '.mig'), { style: pool === 'pump' ? { color: 'var(--acc)', background: 'var(--acc-bg)' } : null }, pool === 'pump' ? 'Curve' : 'PumpSwap')), h('td.r', null, sig ? h('a.ext', { href: `https://solscan.io/tx/${sig}`, target: '_blank', rel: 'noopener noreferrer', html: icon('ext', 13) }) : '')); } function add(list) { const frag = document.createDocumentFragment(); for (let i = list.length - 1; i >= 0; i--) frag.append(row(list[i])); tbody.prepend(frag); while (tbody.children.length > KEEP) tbody.lastChild.remove(); } const off = live.on('tape', m => { const t = Date.now(); for (const x of m.list) win.push([t, x[4], x[5]]); const shown = m.list.filter(pass).slice(-KEEP); if (!shown.length) return; if (paused) { held.push(...shown); if (held.length > 2000) held = held.slice(-1000); state.textContent = `Paused · ${count(held.length)} new`; return; } add(shown); }); const tick = setInterval(() => { const t = Date.now(); while (win.length && t - win[0][0] > 60000) win.shift(); const last10 = win.filter(x => t - x[0] <= 10000).length; rate = last10 / 10; const buys = win.filter(x => x[1]).length, vol = win.reduce((a, x) => a + x[2], 0), big = win.reduce((a, x) => Math.max(a, x[2]), 0); const solUsd = live.sol || 0; tRate.v.textContent = win.length ? rate.toFixed(rate < 10 ? 1 : 0) : '–'; tRate.s.textContent = win.length ? `${count(win.length)} in the last minute` : 'Waiting for the stream'; tBuy.v.textContent = win.length ? Math.round(buys / win.length * 100) + '%' : '–'; tBuy.s.textContent = win.length ? `${count(buys)} buys · ${count(win.length - buys)} sells` : ''; tVol.v.textContent = win.length ? fsol(vol, vol >= 100 ? 0 : 1) + ' SOL' : '–'; tVol.s.textContent = win.length && solUsd ? usd(vol * solUsd) : ''; tBig.v.textContent = win.length ? fsol(big) + ' SOL' : '–'; tBig.s.textContent = win.length && solUsd ? usd(big * solUsd) : ''; if (!paused) for (const td of tbody.querySelectorAll('td[data-t]')) td.textContent = age(+td.dataset.t, t); }, 1000); net.tape(true); return () => { net.tape(false); off(); clearInterval(tick); }; } ===== FILE: public/js/net.js ===== sha256: ba54a679b26150d7477006464a6338f60937951f82ff8abd573494323fcc3f8b · bytes: 2041 · lines: 53 // The one websocket to the OScope server, and plain reads. import { emitter } from './util.js'; export const live = Object.assign(emitter(), { sol: 0, feed: false, lag: null, open: false, coins: 0 }); let ws = null, wait = 500; const wants = { pulse: false, tape: false, coin: null, watch: [], track: [] }; function sendNow(o) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); } function connect() { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); ws.onopen = () => { live.open = true; wait = 500; if (wants.pulse) sendNow({ t: 'pulse', on: true }); if (wants.tape) sendNow({ t: 'tape', on: true }); if (wants.coin) sendNow({ t: 'coin', mint: wants.coin }); if (wants.watch.length) sendNow({ t: 'watch', mints: wants.watch }); if (wants.track.length) sendNow({ t: 'track', wallets: wants.track }); live.emit('status'); }; ws.onmessage = e => { let m; try { m = JSON.parse(e.data); } catch { return; } if (m.t === 'hello' || m.t === 'st') { if (m.sol) live.sol = m.sol; live.feed = m.feed; live.lag = m.lag ?? null; if (m.coins) live.coins = m.coins; if (m.stretchAt) live.stretchAt = m.stretchAt; live.emit('status'); return; } live.emit(m.t, m); }; ws.onclose = () => { live.open = false; live.emit('status'); setTimeout(connect, wait); wait = Math.min(wait * 2, 8000); }; } connect(); export const net = { pulse(on) { wants.pulse = on; sendNow({ t: 'pulse', on }); }, tape(on) { wants.tape = on; sendNow({ t: 'tape', on }); }, coin(mint) { wants.coin = mint; sendNow({ t: 'coin', mint }); }, watch(mints) { wants.watch = mints; sendNow({ t: 'watch', mints }); }, track(wallets) { wants.track = wallets; sendNow({ t: 'track', wallets }); }, async get(path) { const r = await fetch(path, { headers: { accept: 'application/json' } }); if (!r.ok) { const e = new Error('http ' + r.status); e.status = r.status; throw e; } return r.json(); }, }; ===== FILE: public/js/paper.js ===== sha256: 7730adebb063641d34fbbb1bb2e74ecf137a35a25fcd24bcba4a42463f5728dc · bytes: 4217 · lines: 95 // Paper trading: a wallet that lives in this browser. Fills use the pool's reserves at the moment of the click // (constant product, the pool's fee included), so size moves the price the way it would on chain. import { store, emitter, toast, sol as fsol } from './util.js'; import { live, net } from './net.js'; const KEY = 'paper'; const fresh = start => ({ start, sol: start, pos: {}, hist: [], realized: 0, created: Date.now() }); export const paper = Object.assign(emitter(), { s: store.get(KEY, null) || fresh(10), px: {}, // mint -> { price, mc, pool, rq, rt, fee, n, s, img } save() { store.set(KEY, this.s); this.emit('change'); this.syncWatch(); }, reset(start = this.s.start) { this.s = fresh(+start > 0 ? +start : 10); this.save(); }, syncWatch() { net.watch(Object.keys(this.s.pos)); }, // a coin's pool as the page last saw it learn(m, q) { if (q && q.rq > 0 && q.rt > 0) this.px[m] = { ...this.px[m], ...q }; }, quoteBuy(q, solIn) { const solNet = solIn / (1 + q.fee); const k = q.rq * q.rt; return q.rt - k / (q.rq + solNet); }, quoteSell(q, tok) { const k = q.rq * q.rt; const gross = q.rq - k / (q.rt + tok); return gross * (1 - q.fee); }, buy(m, solIn, info = {}) { const q = this.px[m]; solIn = +solIn; if (!q) return toast('No price for this coin yet', 'warn'); if (!(solIn > 0)) return toast('Enter an amount', 'warn'); if (solIn > this.s.sol + 1e-9) return toast(`Not enough SOL: ${fsol(this.s.sol)} in the paper wallet`, 'warn'); const tok = this.quoteBuy(q, solIn); if (!(tok > 0)) return toast('This pool cannot fill that', 'warn'); const p = this.s.pos[m] ||= { tok: 0, cost: 0, spent: 0, got: 0, realized: 0, n: info.n || q.n || '', s: info.s || q.s || '', img: info.img || q.img || '', first: Date.now() }; p.tok += tok; p.cost += solIn; p.spent += solIn; p.last = Date.now(); if (info.n) p.n = info.n; if (info.s) p.s = info.s; if (info.img) p.img = info.img; this.s.sol -= solIn; this.trade(m, 1, solIn, tok, q, p); toast(`Bought ${p.s || 'coin'} for ${fsol(solIn)} SOL`, 'buy', 2200); return tok; }, sell(m, frac) { const p = this.s.pos[m]; const q = this.px[m]; if (!p || p.tok <= 0) return toast('Nothing to sell', 'warn'); if (!q) return toast('No price for this coin yet', 'warn'); frac = Math.min(1, Math.max(0, +frac)); const tok = frac >= 0.999 ? p.tok : p.tok * frac; const got = this.quoteSell(q, tok); const costPart = p.cost * (tok / p.tok); p.realized += got - costPart; this.s.realized += got - costPart; p.cost -= costPart; p.tok -= tok; p.got += got; p.last = Date.now(); this.s.sol += got; this.trade(m, 0, got, tok, q, p); if (p.tok <= 1e-6) { p.tok = 0; p.cost = 0; p.closed = Date.now(); } toast(`Sold ${frac >= 0.999 ? 'all' : Math.round(frac * 100) + '%'} ${p.s || ''} for ${fsol(got)} SOL`, 'sell', 2200); return got; }, trade(m, buy, solAmt, tok, q, p) { const k = q.rq * q.rt; // pool after the fill, so the next paper fill and the page's price follow it if (buy) { q.rq += solAmt / (1 + q.fee); q.rt = k / q.rq; } else { q.rt += tok; q.rq = k / q.rt; } const mc = q.mc || 0; this.s.hist.unshift({ ts: Date.now(), m, n: p.n, s: p.s, img: p.img, buy, sol: solAmt, tok, mc }); if (this.s.hist.length > 1000) this.s.hist.length = 1000; this.save(); this.emit('trade', { m, buy, sol: solAmt, tok, mc, ts: Date.now() }); }, position(m) { return this.s.pos[m]; }, value(m) { const p = this.s.pos[m], q = this.px[m]; return p && q ? p.tok * (q.price || q.rq / q.rt) : 0; }, totals() { let value = 0, cost = 0; for (const [m, p] of Object.entries(this.s.pos)) { if (p.tok > 0) { value += this.value(m); cost += p.cost; } } return { sol: this.s.sol, value, cost, unreal: value - cost, realized: this.s.realized, total: this.s.sol + value, start: this.s.start }; }, }); live.on('px', m => { for (const [mint, a] of Object.entries(m.p)) { const [price, mc, pool, rq, rt, fee, n, s, img] = a; paper.px[mint] = { ...paper.px[mint], price, mc, pool, rq, rt, fee, n, s, img }; } paper.emit('prices'); }); paper.syncWatch(); ===== FILE: public/js/parts.js ===== sha256: b5b8065fcb3dd9362fff5b4cce1a09fa0fd3485f049f4a627ff0a25c4f3c7490 · bytes: 2689 · lines: 65 // Pieces several views draw the same way. import { h, hue, safeHref } from './util.js'; import { icon, iconEl } from './icons.js'; export function avatar(c, size) { const sym = (c.s || c.n || '?').replace(/^\$/, ''); const el = h('div.av', size ? { style: { width: size + 'px', height: size + 'px' } } : null); const ph = h('div.ph', { style: { background: `linear-gradient(135deg, hsl(${hue(c.m || sym)} 45% 28%), hsl(${(hue(c.m || sym) + 40) % 360} 50% 16%))` } }, sym.slice(0, 2).toUpperCase()); el.append(ph); if (c.img) setImg(el, c.img); return el; } export function setImg(av, src) { if (av.dataset.src === src) return; av.dataset.src = src; av.querySelector('img')?.remove(); const img = new Image(); img.decoding = 'async'; img.loading = 'lazy'; img.alt = ''; img.referrerPolicy = 'no-referrer'; img.onload = () => { img.classList.add('ok'); av.querySelector('.ph')?.remove(); }; // a picture that fails is asked for again at a later update, twice at most img.onerror = () => { img.remove(); av._tries = (av._tries || 0) + 1; if (av._tries <= 2) setTimeout(() => { if (av.dataset.src === src) delete av.dataset.src; }, 8000); }; img.src = src; av.append(img); } export function socials(c, size = 14) { const box = h('div.soc'); const add = (href, ic, title) => { const u = href && safeHref(href); const a = h('a', { href: u || '#', target: '_blank', rel: 'noopener noreferrer', title, html: icon(ic, size), onclick: e => e.stopPropagation() }); if (!u) a.classList.add('off'); box.append(a); }; add(c.tw, 'xlogo', 'X'); add(c.web, 'globe', 'Website'); add(c.tg, 'tg', 'Telegram'); add(`https://x.com/search?q=${encodeURIComponent(c.m)}&f=live`, 'search', 'Search the address on X'); return box; } // green when low, amber in between, red when high (for holder concentration numbers) export function level(v, good, bad) { if (!Number.isFinite(v) || v === 0) return ''; return v <= good ? 'lv-g' : v >= bad ? 'lv-b' : 'lv-m'; } export function chip(ic, text, cls = '', title = '') { const el = h('span.chip' + (cls ? '.' + cls : ''), { title }); el.append(iconEl(ic, 12), document.createTextNode(text)); return el; } export function traceSvg() { const t = document.createElement('template'); t.innerHTML = ''; return t.content.firstChild; } export function emptyState(title, text) { return h('div.empty', null, traceSvg(), h('b', null, title), text ? h('span', null, text) : null); } ===== FILE: public/js/portfolio.js ===== sha256: 75e9557b8c4eaa79f725b5b9e65ad7c15020e3703fba07d9706721941f239061 · bytes: 4738 · lines: 77 // Portfolio: the paper wallet, open positions with live PnL, and every paper trade. import { h, age, usd, sol as fsol, compact, signed } from './util.js'; import { icon } from './icons.js'; import { live } from './net.js'; import { paper } from './paper.js'; import { avatar, emptyState } from './parts.js'; import { openSettings } from './settings.js'; export function portfolioView(root) { const wrap = h('div.wrap'); root.append(h('div.page.page-in', null, wrap)); const tiles = h('div.tiles'); const posBody = h('div'); const histBody = h('div'); wrap.append( h('div.ph-h', null, h('div', null, h('h1', null, 'Portfolio'), h('p', null, 'Paper wallet. Fills use the live pool at the moment you click.')), h('button.btn', { onclick: () => openSettings(), html: icon('reset', 14) + 'Reset or change balance' })), tiles, h('div.panel', null, h('div.panel-h', null, h('h3', null, 'Open positions')), posBody), h('div.panel', null, h('div.panel-h', null, h('h3', null, 'Trade history')), histBody)); const coinCell = (m, x) => h('a.coin-cell', { href: `/coin/${m}`, 'data-link': '' }, avatar({ m, s: x.s, img: x.img }, 30), h('div', null, h('b', null, x.s || '–'), h('div.mut', { style: { fontSize: '11.5px' } }, x.n || ''))); function draw() { const sol = live.sol || 0; const t = paper.totals(); const growth = t.total - t.start; const tile = (k, v, s, cls) => h('div.tile', null, h('span.k', null, k), h('span.v' + (cls ? '.' + cls : ''), null, v), h('span.s', null, s)); tiles.replaceChildren( tile('Total value', fsol(t.total) + ' SOL', usd(t.total * sol)), tile('Cash', fsol(t.sol) + ' SOL', usd(t.sol * sol)), tile('Open PnL', signed(t.unreal, x => fsol(x)) + ' SOL', `${t.cost ? signed(t.unreal / t.cost * 100, x => x.toFixed(1) + '%') : '–'} on ${fsol(t.cost)} SOL`, t.unreal >= 0 ? 'up' : 'down'), tile('Since start', signed(growth, x => fsol(x)) + ' SOL', `${signed(growth / t.start * 100, x => x.toFixed(1) + '%')} from ${fsol(t.start)} SOL`, growth >= 0 ? 'up' : 'down')); const open = Object.entries(paper.s.pos).filter(([, p]) => p.tok > 0).sort((a, b) => (b[1].last || 0) - (a[1].last || 0)); if (!open.length) posBody.replaceChildren(emptyState('No open positions', 'Quick buy on Pulse or buy on a coin page.')); else { posBody.replaceChildren(h('table.t', null, h('thead', null, h('tr', null, h('th', null, 'Coin'), h('th.r', null, 'Value'), h('th.r', null, 'Cost'), h('th.r', null, 'PnL'), h('th.r', null, 'MC now'), h('th.r', null, 'Entry MC'), h('th.r', null, ''))), h('tbody', null, open.map(([m, p]) => { const q = paper.px[m]; const value = paper.value(m); const pnl = value - p.cost; const pp = p.cost ? pnl / p.cost * 100 : 0; const entry = p.tok && q && q.price && q.mc ? (p.cost / p.tok) / q.price * q.mc : 0; return h('tr', null, h('td', null, coinCell(m, p)), h('td.r', null, h('b', null, fsol(value) + ' SOL'), h('div.mut', null, usd(value * sol))), h('td.r', null, fsol(p.cost) + ' SOL'), h('td.r.' + (pnl >= 0 ? 'up' : 'down'), null, h('b', null, signed(pnl, x => fsol(x)) + ' SOL'), h('div', null, signed(pp, x => x.toFixed(1) + '%'))), h('td.r', null, q?.mc ? usd(q.mc * sol) : '–'), h('td.r.mut', null, entry ? usd(entry * sol) : '–'), h('td.r', null, h('div', { style: { display: 'inline-flex', gap: '6px' } }, h('button.btn.sm', { onclick: () => paper.sell(m, 0.5) }, 'Sell 50%'), h('button.btn.sm', { onclick: () => paper.sell(m, 1) }, 'Sell all')))); })))); } const hist = paper.s.hist.slice(0, 200); if (!hist.length) histBody.replaceChildren(emptyState('No trades yet')); else histBody.replaceChildren(h('table.t', null, h('thead', null, h('tr', null, h('th', null, 'Age'), h('th', null, 'Coin'), h('th', null, 'Type'), h('th.r', null, 'SOL'), h('th.r', null, 'Amount'), h('th.r', null, 'MC'))), h('tbody', null, hist.map(x => h('tr', null, h('td.mut', null, age(x.ts)), h('td', null, coinCell(x.m, x)), h('td', null, h('b.' + (x.buy ? 'up' : 'down'), null, x.buy ? 'Buy' : 'Sell')), h('td.r', null, fsol(x.sol)), h('td.r', null, compact(x.tok, 2)), h('td.r', null, usd(x.mc * sol))))))); } draw(); let busy = false; wrap.addEventListener('pointerdown', () => { busy = true; }); wrap.addEventListener('pointerup', () => setTimeout(() => { busy = false; }, 300)); const offs = [paper.on('change', draw), paper.on('prices', () => { if (!busy) draw(); })]; return () => offs.forEach(f => f()); } ===== FILE: public/js/pulse.js ===== sha256: 8cf5b633340b934564fdbc1d222510c0127989c8aba22773f8279a57daca66d1 · bytes: 12927 · lines: 254 // Pulse: New Pairs, Final Stretch and Migrated, live. import { h, $, age, usd, compact, count, copy, store, toast } from './util.js'; import { icon, iconEl } from './icons.js'; import { live, net } from './net.js'; import { paper } from './paper.js'; import { avatar, setImg, socials, level, chip, emptyState } from './parts.js'; import { go } from './router.js'; const COLS = [ { key: 'new', title: 'New Pairs', empty: ['Watching for launches', 'New coins appear here the second they are created.'] }, { key: 'stretch', title: 'Final Stretch', empty: ['Nothing close to migrating', 'Coins that sell most of their curve show up here.'] }, { key: 'migrated', title: 'Migrated', empty: ['No migrations yet', 'Coins land here the moment their curve completes.'] }, ]; const SHOW = 60; const DEF_FILTER = { mcMin: '', mcMax: '', hMin: '', top10Max: '', devMax: '', snMax: '', buMax: '', ageMax: '', socials: false, tw: false, web: false, tg: false, kw: '' }; export function pulseView(root) { const rows = new Map(); const hidden = new Set(store.get('hidden', [])); const st = {}; const grid = h('div.pulse.page-in'); let first = true, raf = 0; for (const c of COLS) { const s = st[c.key] = { order: [], els: new Map(), hold: false, filter: { ...DEF_FILTER, ...store.get('filter.' + c.key, {}) }, qb: store.get('qb.' + c.key, '0.1'), }; const qbInput = h('input', { value: s.qb, inputMode: 'decimal', title: 'Quick buy amount (SOL)', oninput: e => { s.qb = e.target.value; store.set('qb.' + c.key, s.qb); } }); const fbtn = h('button.icon-btn', { title: 'Filters', html: icon('filter', 15), onclick: e => openFilter(c, s, e.currentTarget) }); s.fbtn = fbtn; s.count = h('span.count', null, '0'); s.list = h('div.list'); s.emptyEl = h('div.col-empty', null, emptyState(...c.empty)); s.col = h('section.col', null, h('div.col-h', null, h('h2', null, c.title), s.count, h('span.paused', { html: icon('pause', 11) + 'Paused' }), h('div.col-tools', null, h('label.qb-set', { title: 'Quick buy amount (SOL)' }, iconEl('bolt', 13), qbInput, h('span.qb-unit', null, 'SOL')), fbtn)), s.list); s.list.addEventListener('mouseenter', () => { s.hold = true; s.col.classList.add('hold'); }); s.list.addEventListener('mouseleave', () => { s.hold = false; s.col.classList.remove('hold'); schedule(); }); markFilter(s); grid.append(s.col); } root.append(grid); function passes(r, f) { const sol = live.sol || 0; const mcUsd = r.mc * sol; if (f.mcMin !== '' && mcUsd < +f.mcMin * 1000) return false; if (f.mcMax !== '' && mcUsd > +f.mcMax * 1000) return false; if (f.hMin !== '' && r.h < +f.hMin) return false; if (f.top10Max !== '' && r.top10 > +f.top10Max) return false; if (f.devMax !== '' && r.dh > +f.devMax) return false; if (f.snMax !== '' && r.sn > +f.snMax) return false; if (f.buMax !== '' && r.bu > +f.buMax) return false; if (f.ageMax !== '' && r.t && Date.now() - r.t > +f.ageMax * 60e3) return false; if (f.socials && !r.tw && !r.web && !r.tg) return false; if (f.tw && !r.tw) return false; if (f.web && !r.web) return false; if (f.tg && !r.tg) return false; if (f.kw) { const words = f.kw.toLowerCase().split(/[\s,]+/).filter(Boolean); const text = (r.n + ' ' + r.s).toLowerCase(); if (words.length && !words.some(w => text.includes(w))) return false; } return true; } function render() { raf = 0; for (const c of COLS) { const s = st[c.key]; const want = []; for (const m of s.order) { const r = rows.get(m); if (!r || hidden.has(m) || !passes(r, s.filter)) continue; want.push(r); if (want.length >= SHOW) break; } s.count.textContent = want.length; for (const r of want) { let el = s.els.get(r.m); if (!el) { el = rowEl(r, c.key); s.els.set(r.m, el); if (!first) el.classList.add('new'); } else if (el._v !== r.v) updateRow(el, r); } if (s.hold) continue; if (!want.length) { if (s.list.firstChild !== s.emptyEl) { s.list.replaceChildren(s.emptyEl); s.els.clear(); } continue; } if (s.emptyEl.parentNode) s.emptyEl.remove(); const keep = new Set(want.map(r => r.m)); for (const [m, el] of s.els) if (!keep.has(m)) { el.remove(); s.els.delete(m); } let i = 0; for (const r of want) { const el = s.els.get(r.m); const at = s.list.children[i]; if (at !== el) s.list.insertBefore(el, at || null); i++; } } if (rows.size) first = false; } function schedule() { if (!raf) raf = requestAnimationFrame(render); } function rowEl(r, col) { const ring = h('div.ring'); const av = avatar(r, 64); const badge = h('span.badge'); ring.append(av, badge); const nm = h('span.nm'), sy = h('span.sy'); const cp = h('button.cp', { title: 'Copy address', html: icon('copy', 13), onclick: e => { e.preventDefault(); e.stopPropagation(); copy(r.m, 'Address copied'); } }); const agev = h('span.agev'); const soc = h('div.soc'); const hc = h('span.hc'); const l3 = h('div.l3'); const mc = h('span.v'), vol = h('span.v.sm'), txn = h('span'), bar = h('span.bar', null, h('i')); const qb = h('button.qb', { title: 'Paper buy', onclick: e => { e.preventDefault(); e.stopPropagation(); quickBuy(el._r, col); } }); const hide = h('button.hide-btn', { title: 'Hide this coin', html: icon('eyeoff', 13), onclick: e => { e.preventDefault(); e.stopPropagation(); hideCoin(el._r.m); } }); const el = h('a.row', { href: '/coin/' + r.m, 'data-link': '' }, ring, h('div.mid', null, h('div.l1', null, nm, sy, cp), h('div.l2', null, agev, soc, hc), l3), h('div.right', null, h('div.stat', null, h('span.k', null, 'MC'), mc), h('div.stat', null, h('span.k', null, 'V'), vol), h('div.txbar', null, h('span.k.mut', { style: { fontSize: '11px', fontWeight: 600, color: 'var(--tx4)' } }, 'TX'), txn, bar), qb), hide); Object.assign(el, { _ring: ring, _av: av, _badge: badge, _nm: nm, _sy: sy, _age: agev, _soc: soc, _hc: hc, _l3: l3, _mc: mc, _vol: vol, _txn: txn, _bar: bar, _qb: qb, _col: col }); updateRow(el, r); return el; } function updateRow(el, r) { const prev = el._r; el._r = r; el._v = r.v; if (!prev || prev.n !== r.n) { el._nm.textContent = r.n || r.s || '–'; el.title = r.n || ''; } if (!prev || prev.s !== r.s) el._sy.textContent = r.s; if (r.img) setImg(el._av, r.img); const mig = !!r.mig || r.pool === 'pump-amm'; el._ring.classList.toggle('mig', mig); el._ring.style.setProperty('--p', mig ? 100 : r.pr); if (!prev || (prev.pool !== r.pool)) { el._badge.innerHTML = icon(mig ? 'swap' : 'pill', 10); el._badge.title = mig ? 'PumpSwap' : 'pump.fun curve'; } el._badge.title = mig ? 'PumpSwap' : `Bonding curve ${r.pr.toFixed(1)}%`; const ts = el._col === 'migrated' ? r.mig : r.t; el._age.dataset.t = ts || ''; el._age.textContent = ts ? age(ts) : ''; if (!prev || prev.tw !== r.tw || prev.web !== r.web || prev.tg !== r.tg) el._soc.replaceWith(el._soc = socials(r, 14)); const seen = !!r.t && r.top10 != null; el._hc.replaceChildren(iconEl('users', 13), h('span', null, r.t ? String(r.h) : '–')); el._hc.title = r.t ? 'Holders' : 'Holders: this coin launched before OScope started watching'; if (!seen) el._l3.replaceChildren( chip('crown', '–', '', 'Top 10 holders: not known yet'), chip('chef', '–', '', 'Dev holds: not known yet'), chip('target', '–', '', 'Snipers: not known yet'), chip('boxes', '–', '', 'Bundle: not known yet')); else el._l3.replaceChildren( chip('crown', r.top10.toFixed(0) + '%', level(r.top10, 20, 40), 'Top 10 holders'), chip('chef', (r.ds ? 'Sold' : r.dh.toFixed(r.dh < 10 ? 1 : 0) + '%'), r.ds ? 'lv-m' : level(r.dh, 5, 15), r.ds ? 'Dev sold everything' : 'Dev holds'), chip('target', r.sn.toFixed(0) + '%', level(r.sn, 3, 15), 'Snipers hold'), chip('boxes', r.bu.toFixed(0) + '%', level(r.bu, 5, 20), 'Bundle holds')); const sol = live.sol || 0; const mcText = usd(r.mc * sol); if (el._mc.textContent !== mcText) { if (prev && prev.mc !== r.mc) { el._mc.classList.remove('fl-up', 'fl-down'); el._mc.classList.add(r.mc > prev.mc ? 'fl-up' : 'fl-down'); clearTimeout(el._fl); el._fl = setTimeout(() => el._mc.classList.remove('fl-up', 'fl-down'), 650); } el._mc.textContent = mcText; } el._vol.textContent = usd(r.vol * sol); const n = r.buys + r.sells; el._txn.textContent = count(n); el._bar.firstChild.style.width = (n ? (r.buys / n) * 100 : 50) + '%'; el._bar.title = `${r.buys} buys · ${r.sells} sells`; const amt = st[el._col].qb; el._qb.replaceChildren(iconEl('bolt', 12), h('span', null, amt || '0')); } function quickBuy(r, col) { const amt = parseFloat(st[col].qb); paper.learn(r.m, { rq: r.rq, rt: r.rt, fee: r.fee, price: r.p, mc: r.mc, pool: r.pool, n: r.n, s: r.s, img: r.img }); paper.buy(r.m, amt, { n: r.n, s: r.s, img: r.img }); } function hideCoin(m) { hidden.add(m); store.set('hidden', [...hidden].slice(-3000)); toast('Coin hidden. Settings → Hidden coins brings it back.', 'info', 2200); schedule(); } function markFilter(s) { const on = Object.entries(s.filter).some(([k, v]) => v !== DEF_FILTER[k]); s.fbtn.classList.toggle('on', on); } let pop = null; function closePop() { if (pop) { pop.remove(); pop = null; document.removeEventListener('mousedown', outside, true); } } function outside(e) { if (pop && !pop.contains(e.target)) closePop(); } function openFilter(c, s, btn) { if (pop) { const same = pop._col === c.key; closePop(); if (same) return; } const f = s.filter; const num = (k, ph) => h('input.inp', { value: f[k], placeholder: ph, inputMode: 'decimal', oninput: e => set(k, e.target.value.trim()) }); const ck = (k, label, ic) => { const b = h('button.ck' + (f[k] ? '.on' : ''), { onclick: () => { set(k, !f[k]); b.classList.toggle('on', f[k]); } }, ic ? iconEl(ic, 12) : null, label); return b; }; function set(k, v) { f[k] = v; store.set('filter.' + c.key, f); markFilter(s); schedule(); } pop = h('div.pop', null, h('h4', null, c.title + ' filters', h('button.btn.ghost.sm', { onclick: () => { Object.assign(f, DEF_FILTER); store.set('filter.' + c.key, f); markFilter(s); schedule(); closePop(); } }, 'Reset')), h('div.field', null, h('span.lbl', null, 'Market cap, $K'), h('div.two', null, num('mcMin', 'Min'), num('mcMax', 'Max'))), h('div.two', null, h('div.field', null, h('span.lbl', null, 'Holders, min'), num('hMin', '0')), h('div.field', null, h('span.lbl', null, 'Age, max minutes'), num('ageMax', 'Any'))), h('div.two', null, h('div.field', null, h('span.lbl', null, 'Top 10, max %'), num('top10Max', 'Any')), h('div.field', null, h('span.lbl', null, 'Dev holds, max %'), num('devMax', 'Any'))), h('div.two', null, h('div.field', null, h('span.lbl', null, 'Snipers, max %'), num('snMax', 'Any')), h('div.field', null, h('span.lbl', null, 'Bundle, max %'), num('buMax', 'Any'))), h('div.field', null, h('span.lbl', null, 'Has'), h('div.checks', null, ck('socials', 'Any social'), ck('tw', 'X', 'xlogo'), ck('web', 'Website', 'globe'), ck('tg', 'Telegram', 'tg'))), h('div.field', null, h('span.lbl', null, 'Name contains (any of)'), h('input.inp', { value: f.kw, placeholder: 'cat, dog, ai', oninput: e => set('kw', e.target.value) }))); pop._col = c.key; const r = btn.getBoundingClientRect(); pop.style.top = r.bottom + 8 + 'px'; pop.style.left = Math.min(window.innerWidth - 316, Math.max(8, r.right - 300)) + 'px'; document.body.append(pop); setTimeout(() => document.addEventListener('mousedown', outside, true)); } const offP = live.on('p', m => { for (const r of m.rows) rows.set(r.m, r); if (m.cols) { for (const c of COLS) st[c.key].order = m.cols[c.key] || []; const keep = new Set([...(m.cols.new || []), ...(m.cols.stretch || []), ...(m.cols.migrated || [])]); for (const k of rows.keys()) if (!keep.has(k)) rows.delete(k); } schedule(); }); const tick = setInterval(() => { const t = Date.now(); for (const c of COLS) for (const el of st[c.key].els.values()) { const ts = +el._age.dataset.t; if (ts) { const a = age(ts, t); if (el._age.textContent !== a) el._age.textContent = a; el._age.classList.toggle('old', t - ts > 3600e3); } } }, 1000); net.pulse(true); return () => { net.pulse(false); offP(); clearInterval(tick); closePop(); if (raf) cancelAnimationFrame(raf); }; } ===== FILE: public/js/router.js ===== sha256: 945e2734056f5fc3d6371a31d0832f0b45639a94cb098af82c7677c4035f0d74 · bytes: 1279 · lines: 37 // Path router: /, /coin/, /portfolio, /trackers, /wallet/
. const routes = []; let cleanup = null; let view = null; export function route(re, make, nav) { routes.push({ re, make, nav }); } export function go(path, replace = false) { if (path === location.pathname + location.search) return; history[replace ? 'replaceState' : 'pushState'](null, '', path); mount(); } export function mount() { view ||= document.getElementById('view'); if (cleanup) { try { cleanup(); } catch {} cleanup = null; } view.replaceChildren(); const p = location.pathname; for (const r of routes) { const m = p.match(r.re); if (!m) continue; for (const a of document.querySelectorAll('[data-nav]')) a.classList.toggle('on', a.dataset.nav === r.nav); cleanup = r.make(view, ...m.slice(1)) || null; return; } history.replaceState(null, '', '/'); mount(); } // links marked data-link stay inside the page; ctrl / middle click still open a new tab document.addEventListener('click', e => { const a = e.target.closest('a[data-link]'); if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); go(a.getAttribute('href')); }); window.addEventListener('popstate', mount); ===== FILE: public/js/settings.js ===== sha256: 0418ac90f844bc04688dcc7bcc0777890d08df1af158bd38624c18d27fcf70a4 · bytes: 5284 · lines: 94 // Settings: kept in this browser. import { h, store, toast } from './util.js'; import { icon } from './icons.js'; import { paper } from './paper.js'; import { trackers } from './trackers.js'; const DEF = { buyPresets: [0.1, 0.5, 1, 2], sellPresets: [25, 50, 75, 100], trackerToasts: true }; export const settings = { get() { return { ...DEF, ...store.get('settings', {}) }; }, set(patch) { store.set('settings', { ...this.get(), ...patch }); }, }; export function openModal(title, body) { const root = document.getElementById('modal-root'); const close = () => { back.remove(); document.removeEventListener('keydown', esc); }; const esc = e => { if (e.key === 'Escape') close(); }; const back = h('div.modal-back', { onmousedown: e => { if (e.target === back) close(); } }, h('div.modal', null, h('div.modal-h', null, h('h3', null, title), h('button.icon-btn', { html: icon('x', 15), onclick: () => close() })), h('div.modal-b', null, body))); root.append(back); document.addEventListener('keydown', esc); return close; } const list = (arr, n) => arr.map(v => String(v)).join(', '); const parse = (s, max) => s.split(/[\s,]+/).map(Number).filter(v => v > 0 && v <= max).slice(0, 4); export function openSettings() { const s = settings.get(); const buy = h('input.inp', { value: list(s.buyPresets), placeholder: '0.1, 0.5, 1, 2' }); const sell = h('input.inp', { value: list(s.sellPresets), placeholder: '25, 50, 75, 100' }); const start = h('input.inp', { value: paper.s.start, inputMode: 'decimal' }); const tg = h('button.tg' + (s.trackerToasts ? '.on' : ''), { onclick: () => tg.classList.toggle('on') }); const hiddenCount = (store.get('hidden', []) || []).length; let close; const body = [ h('div.field', null, h('span.lbl', null, 'Buy amounts on the coin page (SOL)'), buy), h('div.field', null, h('span.lbl', null, 'Sell amounts on the coin page (%)'), sell), h('div.switch', null, h('div', null, h('div', null, 'Tracked wallet pop-ups'), h('div.lbl', null, 'Show a note when a tracked wallet trades')), tg), h('div.field', null, h('span.lbl', null, 'Paper wallet start balance (SOL)'), h('div', { style: { display: 'flex', gap: '8px' } }, start, h('button.btn.danger', { onclick: () => { paper.reset(parseFloat(start.value) || 10); toast('Paper wallet reset', 'info'); } }, 'Reset wallet'))), h('div.field', null, h('span.lbl', null, 'Your data'), h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } }, h('button.btn', { onclick: () => exportData() }, 'Export'), h('button.btn', { onclick: () => importData() }, 'Import'), h('button.btn', { disabled: !hiddenCount, onclick: e => { store.set('hidden', []); e.currentTarget.disabled = true; e.currentTarget.textContent = 'Hidden coins shown'; } }, hiddenCount ? `Show ${hiddenCount} hidden coins` : 'No hidden coins'))), h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '8px' } }, h('button.btn.ghost', { onclick: () => close() }, 'Cancel'), h('button.btn.acc', { onclick: () => { const b = parse(buy.value, 1e4), se = parse(sell.value, 100); settings.set({ buyPresets: b.length ? b : DEF.buyPresets, sellPresets: se.length ? se : DEF.sellPresets, trackerToasts: tg.classList.contains('on') }); toast('Settings saved', 'info', 1500); close(); } }, 'Save')), ]; close = openModal('Settings', body); } function exportData() { const data = { app: 'oscope', v: 1, at: Date.now(), settings: settings.get(), paper: paper.s, trackers: trackers.list, hidden: store.get('hidden', []) }; const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 1)], { type: 'application/json' })); a.download = 'oscope-data.json'; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } function importData() { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'application/json,.json'; inp.onchange = async () => { try { const d = JSON.parse(await inp.files[0].text()); if (d.app !== 'oscope') throw new Error(); if (d.settings && typeof d.settings === 'object') { const b = parse(String(d.settings.buyPresets), 1e4), se = parse(String(d.settings.sellPresets), 100); settings.set({ buyPresets: b.length ? b : DEF.buyPresets, sellPresets: se.length ? se : DEF.sellPresets, trackerToasts: d.settings.trackerToasts !== false }); } if (d.paper && typeof d.paper === 'object' && +d.paper.sol >= 0 && d.paper.pos && Array.isArray(d.paper.hist)) { paper.s = d.paper; paper.save(); } const addr = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; if (Array.isArray(d.trackers)) { trackers.list = d.trackers.filter(t => t && addr.test(t.w)).map(t => ({ w: t.w, name: String(t.name || '').slice(0, 32) || t.w.slice(0, 4), emoji: String(t.emoji || '').slice(0, 4), alerts: t.alerts !== false, added: +t.added || Date.now() })); trackers.save(); } if (Array.isArray(d.hidden)) store.set('hidden', d.hidden.filter(m => addr.test(m)).slice(-3000)); toast('Imported', 'info'); } catch { toast('That file is not an OScope export', 'warn'); } }; inp.click(); } ===== FILE: public/js/trackers.js ===== sha256: 0e33fd3395f73201bd280d362298a0bb0f0a592d4d92ecc40321b6f896837a29 · bytes: 2885 · lines: 61 // Tracked wallets: names you give to addresses. Their trades arrive live on any page. import { store, emitter, toast, h, sol, usd, short } from './util.js'; import { live, net } from './net.js'; export const trackers = Object.assign(emitter(), { list: store.get('trackers', []), // [{ w, name, emoji, alerts, added }] feed: [], // newest first, this session byW() { return new Map(this.list.map(t => [t.w, t])); }, save() { store.set('trackers', this.list); net.track(this.list.map(t => t.w)); this.emit('change'); }, add(w, name, emoji = '') { w = w.trim(); if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(w)) { toast('That is not a Solana address', 'warn'); return false; } if (this.list.some(t => t.w === w)) { toast('Already tracked', 'info'); return false; } this.list.unshift({ w, name: (name || '').trim().slice(0, 32) || short(w), emoji: emoji.slice(0, 4), alerts: true, added: Date.now() }); this.save(); return true; }, remove(w) { this.list = this.list.filter(t => t.w !== w); this.save(); }, update(w, patch) { const t = this.list.find(x => x.w === w); if (t) Object.assign(t, patch); this.save(); }, name(w) { return this.list.find(t => t.w === w); }, importJson(text) { let arr; try { arr = JSON.parse(text); } catch { toast('Not valid JSON', 'warn'); return 0; } if (!Array.isArray(arr)) arr = Object.entries(arr).map(([w, v]) => ({ w, name: typeof v === 'string' ? v : v?.name })); let n = 0; for (const x of arr) { const w = typeof x === 'string' ? x : x?.w || x?.wallet || x?.address || x?.trackedWalletAddress; const name = typeof x === 'object' ? x?.name || x?.label : ''; if (w && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(w) && !this.list.some(t => t.w === w)) { this.list.push({ w, name: (name || short(w)).slice(0, 32), emoji: x?.emoji || '', alerts: true, added: Date.now() }); n++; } } this.save(); return n; }, }); live.on('wt', m => { const by = trackers.byW(); const alertsOn = store.get('settings', {}).trackerToasts !== false; for (const it of m.list) { const t = by.get(it.w); if (!t) continue; it.who = t; trackers.feed.unshift(it); if (alertsOn && t.alerts && document.visibilityState === 'visible') { const node = h('a.tt', { href: `/coin/${it.m}`, 'data-link': '' }, h('span.tt-who', null, (t.emoji ? t.emoji + ' ' : '') + t.name), h('span.' + (it.buy ? 'up' : 'down'), null, it.buy ? ' bought ' : ' sold '), h('b', null, sol(it.sol) + ' SOL'), h('span.mut', null, ' of '), h('b', null, it.s || short(it.m)), h('span.mut', null, ' at ' + usd(it.mc * live.sol))); toast('', it.buy ? 'buy' : 'sell', 4500, node); } } if (trackers.feed.length > 500) trackers.feed.length = 500; trackers.emit('feed', m.list); }); net.track(trackers.list.map(t => t.w)); ===== FILE: public/js/trackerview.js ===== sha256: dd58ac7119c47f0813b1b6eaf2cc347324ca46e46afcfaf98a789bc70f4ada42 · bytes: 7504 · lines: 111 // Trackers: the wallets you follow and their trades, live. Wallet page: what one wallet holds among the coins OScope has seen. import { h, age, usd, sol as fsol, compact, short, copy, signed, toast } from './util.js'; import { icon, iconEl } from './icons.js'; import { live, net } from './net.js'; import { trackers } from './trackers.js'; import { avatar, emptyState } from './parts.js'; export function trackersView(root) { const wrap = h('div.wrap'); root.append(h('div.page.page-in', null, wrap)); const listBox = h('div.trk-list'); const feedBody = h('div'); const addr = h('input.inp', { placeholder: 'Wallet address', spellcheck: false }); const name = h('input.inp', { placeholder: 'Name' }); const add = () => { if (trackers.add(addr.value, name.value)) { addr.value = ''; name.value = ''; } }; addr.addEventListener('keydown', e => { if (e.key === 'Enter') add(); }); name.addEventListener('keydown', e => { if (e.key === 'Enter') add(); }); const fileIn = h('input', { type: 'file', accept: '.json,application/json', style: { display: 'none' }, onchange: async e => { const n = trackers.importJson(await e.target.files[0].text()); toast(`${n} wallets added`, 'info'); e.target.value = ''; } }); wrap.append( h('div.ph-h', null, h('div', null, h('h1', null, 'Trackers'), h('p', null, 'Name any wallet and see its trades as they land, on every page.'))), h('div.trk', null, h('div.panel', null, h('div.panel-h', null, h('h3', null, 'Wallets'), h('div', { style: { display: 'flex', gap: '4px' } }, h('button.btn.sm.ghost', { onclick: () => fileIn.click() }, 'Import'), h('button.btn.sm.ghost', { onclick: () => exportList() }, 'Export'), fileIn)), h('div.add-form', null, addr, h('div.two', null, name, h('button.btn.acc', { onclick: add, html: icon('plus', 14) + 'Track' }))), listBox), h('div.panel', null, h('div.panel-h', null, h('h3', null, 'Live trades'), h('span.mut', { style: { fontSize: '12px' } }, 'This session')), feedBody))); function drawList() { if (!trackers.list.length) { listBox.replaceChildren(emptyState('No wallets yet', 'Paste an address above, or import a list.')); return; } listBox.replaceChildren(...trackers.list.map(t => h('div.tw', null, h('div', { style: { minWidth: 0 } }, h('a.nmw', { href: `/wallet/${t.w}`, 'data-link': '' }, t.emoji || '', t.name), h('span.addr', { onclick: () => copy(t.w, 'Address copied') }, short(t.w, 6), iconEl('copy', 11))), h('div.acts', null, h('button.icon-btn', { title: t.alerts ? 'Pop-ups on' : 'Pop-ups off', html: icon(t.alerts ? 'bell' : 'bellOff', 15), onclick: () => trackers.update(t.w, { alerts: !t.alerts }) }), h('button.icon-btn', { title: 'Rename', html: icon('edit', 15), onclick: () => { const n = prompt('Name for this wallet', t.name); if (n != null) trackers.update(t.w, { name: n.trim().slice(0, 32) || t.name }); } }), h('button.icon-btn', { title: 'Stop tracking', html: icon('trash', 15), onclick: () => trackers.remove(t.w) }))))); } function exportList() { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([JSON.stringify(trackers.list.map(({ w, name, emoji }) => ({ w, name, emoji })), null, 1)], { type: 'application/json' })); a.download = 'oscope-wallets.json'; a.click(); } function feedRow(it, fresh) { const sol = live.sol || 0; return h('tr.feed-row' + (fresh ? '.fresh' : ''), null, h('td.mut', { 'data-t': it.ts }, age(it.ts)), h('td', null, h('b', null, (it.who?.emoji ? it.who.emoji + ' ' : '') + (it.who?.name || short(it.w)))), h('td', null, h('b.' + (it.buy ? 'up' : 'down'), null, it.buy ? 'Buy' : 'Sell')), h('td', null, h('a.coin-cell', { href: `/coin/${it.m}`, 'data-link': '' }, avatar({ m: it.m, s: it.s, img: it.img }, 30), h('b', null, it.s || short(it.m)))), h('td.r', null, fsol(it.sol) + ' SOL'), h('td.r.mut', null, usd(it.sol * sol)), h('td.r', null, usd(it.mc * sol))); } let tbody = null; function drawFeed() { if (!trackers.feed.length) { tbody = null; feedBody.replaceChildren(emptyState('Waiting for trades', trackers.list.length ? 'Trades of your wallets appear here the moment they land.' : 'Add a wallet to start.')); return; } tbody = h('tbody', null, trackers.feed.slice(0, 200).map(it => feedRow(it))); feedBody.replaceChildren(h('table.t', null, h('thead', null, h('tr', null, h('th', null, 'Age'), h('th', null, 'Wallet'), h('th', null, 'Type'), h('th', null, 'Coin'), h('th.r', null, 'Amount'), h('th.r', null, 'USD'), h('th.r', null, 'MC'))), tbody)); } drawList(); drawFeed(); const offs = [ trackers.on('change', () => { drawList(); drawFeed(); }), trackers.on('feed', list => { if (!tbody) return drawFeed(); for (const it of list) { if (it.who) tbody.prepend(feedRow(it, true)); } while (tbody.children.length > 200) tbody.lastChild.remove(); }), ]; const tick = setInterval(() => { if (tbody) for (const td of tbody.querySelectorAll('td[data-t]')) td.textContent = age(+td.dataset.t); }, 1000); return () => { offs.forEach(f => f()); clearInterval(tick); }; } export function walletView(root, w) { const wrap = h('div.wrap'); root.append(h('div.page.page-in', null, wrap)); const t = trackers.name(w); const body = h('div'); const head = h('div.ph-h', null, h('div', null, h('h1', null, t ? t.name : 'Wallet'), h('p', null, h('span.addr', { onclick: () => copy(w, 'Address copied') }, w, iconEl('copy', 12)))), h('div', { style: { display: 'flex', gap: '8px' } }, t ? null : h('button.btn.acc', { onclick: e => { if (trackers.add(w, '')) e.currentTarget.remove(); }, html: icon('plus', 14) + 'Track' }), h('a.btn', { href: `https://solscan.io/account/${w}`, target: '_blank', rel: 'noopener noreferrer', html: 'Solscan ' + icon('ext', 12) }))); wrap.append(head, h('div.panel', null, h('div.panel-h', null, h('h3', null, 'Coins traded'), h('span.mut', { style: { fontSize: '12px' } }, 'Among coins OScope has seen')), body)); let alive = true; async function load() { let list = []; try { list = await net.get(`/api/wallet/${w}`); } catch {} if (!alive) return; const sol = live.sol || 0; if (!list.length) { body.replaceChildren(emptyState('No trades seen', 'Trades of this wallet show here once it trades a coin OScope is watching.')); return; } body.replaceChildren(h('table.t', null, h('thead', null, h('tr', null, h('th', null, 'Coin'), h('th.r', null, 'Holds'), h('th.r', null, 'Bought'), h('th.r', null, 'Sold'), h('th.r', null, 'PnL'), h('th.r', null, 'MC'), h('th.r', null, 'Last trade'))), h('tbody', null, list.map(x => h('tr', null, h('td', null, h('a.coin-cell', { href: `/coin/${x.m}`, 'data-link': '' }, avatar({ m: x.m, s: x.s, img: x.img }, 30), h('div', null, h('b', null, x.s || short(x.m)), h('div.mut', { style: { fontSize: '11.5px' } }, x.n)))), h('td.r', null, x.bal > 0 ? usd(x.value * sol) : h('span.mut', null, 'Sold')), h('td.r.up', null, fsol(x.bs) + ' SOL'), h('td.r.down', null, fsol(x.ss) + ' SOL'), h('td.r.' + (x.pnl >= 0 ? 'up' : 'down'), null, h('b', null, signed(x.pnl, v => fsol(v)) + ' SOL')), h('td.r', null, usd(x.mc * sol)), h('td.r.mut', null, age(x.last))))))); } load(); const iv = setInterval(load, 5000); return () => { alive = false; clearInterval(iv); }; }