OScope: every text file of commit 9d9eae4d711c8575b6fbb4d882cc53b3af7dc798. Code: https://git.oscope.trade/oscope · git clone https://git.oscope.trade/oscope.git · manifest: https://git.oscope.trade/oscope/manifest.json 35 text files below (217118 bytes), then the 6 binary files with their SHA-256. If your reader cuts this page off, read it in 3 parts: https://git.oscope.trade/oscope/ai/1.txt https://git.oscope.trade/oscope/ai/2.txt https://git.oscope.trade/oscope/ai/3.txt Each file starts with "===== FILE: =====" and a line with its sha256, size and line count. Nothing is left out or changed. ===== FILE: README.md ===== sha256: 2a63c60ece6f44fe9e6299f5e8dfc4974b3e5a51d356a6798696b3c37137e28d · bytes: 11986 · lines: 249
OScope # OScope **The open source memecoin terminal for Solana.** Live Pulse, a trade firehose, charts, holders, wallet trackers and paper trading.
It runs on your own computer. No account, no wallet, no keys, no fees. [Website](https://oscope.trade)  ·  [X](https://x.com/OScopetrade)  ·  [Start with your AI](#start-it-with-your-ai)  ·  [Safety](SAFETY.md)  ·  [Verify with AI](https://git.oscope.trade/oscope/ai)  ·  [For AI agents](AGENTS.md) ![License](https://img.shields.io/badge/license-MIT-4d8dff?style=flat-square) ![Node](https://img.shields.io/badge/node-18.17%2B-4d8dff?style=flat-square) ![Dependencies](https://img.shields.io/badge/dependencies-2-4d8dff?style=flat-square) ![Private keys](https://img.shields.io/badge/private%20keys-never-2fe39b?style=flat-square) ![Fees](https://img.shields.io/badge/fees-0%25-2fe39b?style=flat-square) OScope trailer Trailer with sound: latest release
--- ## Start it with your AI You do not need to code. Paste the prompt below into your AI. It checks that the code is safe, installs everything and starts OScope. A couple of minutes later trades are flowing on your screen. - **Best:** an AI that can run commands on your computer, like Claude Code, Codex, Cursor, Windsurf or Gemini CLI. It does every step itself. - **Also works:** a chat app like Claude, ChatGPT or Gemini. It gives you one command at a time and you paste back what you see. ```text I want to run OScope, an open source memecoin terminal for Solana, on my computer. The code is here: https://git.oscope.trade/oscope STEP 1. SAFETY CHECK. Do this before you install or run anything. Get the code (git clone https://git.oscope.trade/oscope.git) or read it in the browser. Do not run it yet. If you cannot run commands, read https://git.oscope.trade/oscope/ai.txt: every text file of the project in one page. Read every file in src/, tools/ and public/js/, and also package.json, package-lock.json and .npmrc. Then tell me in plain words: 1. Does it ever ask for, read, store or send a private key, a seed phrase or a wallet file? Can it sign or send a transaction? 2. Every internet address it connects to, and what for. 3. Does it read or write any file outside its own folder? 4. Does anything run at install time (install scripts in package.json, in its dependencies, in .npmrc)? How many dependencies does it have and what are they? 5. Does it only listen on my own computer (127.0.0.1) unless I change that? 6. Is anything hidden: scrambled code, encoded text, code downloaded at run time, eval? Compare what you found with SAFETY.md in the same repository. That file is the project's own list of what the code does and does not do. If the code does anything SAFETY.md does not say, or anything looks wrong or unclear, STOP and tell me. Do not go on until I say so. STEP 2. INSTALL AND START. Only if step 1 was clean. 1. Run `node -v`. OScope needs Node.js 18.17 or newer (20 or newer is best). If Node.js is missing or too old, help me install the current LTS version from https://nodejs.org the normal way for my system. 2. In the oscope folder run `npm ci`. It installs exactly what package-lock.json pins: ws, lightweight-charts and fancy-canvas (a helper lightweight-charts needs). Nothing else. 3. Run `npm run doctor`. It checks everything OScope needs. Fix whatever it reports. 4. Run `npm start` and leave it running. It prints the address to open, normally http://localhost:3000, and every 15 seconds a line with how many trades came in. 5. Tell me the address. Tell me that I should see new coins appear on the Pulse page and trades flowing on the Firehose page within a few seconds. If you cannot run commands on my computer, give me one step at a time, tell me exactly what to type and wait for me to paste the result. If a step fails, read AGENTS.md in the repository (it has a troubleshooting list), fix it, and tell me what you changed. ``` ### We want you to feel safe Never run code from the internet because a README says it is fine. That is how wallets get drained. So the prompt makes your AI **read the code first and tell you what it does, before anything is installed**, and stop if anything looks off. What it will find: | Check | Result | |---|---| | **No keys, ever** | OScope never asks for a private key, a seed phrase or a wallet file, and cannot sign or send a transaction. Trading is paper trading: practice money that lives in your browser. | | **Your computer only** | The server listens on `127.0.0.1`. Nobody on the internet, and no other website open in your browser, can reach it. | | **Public data only** | It reads a public trade stream, public SOL price pages and the pictures of the coins you look at. It sends nothing about you anywhere. No tracking. | | **Nothing runs at install** | Two libraries and one helper, exact versions, install scripts switched off. No build step: the code you read is the code that runs. | The full list, with the file behind every point so your AI can check it line by line: **[SAFETY.md](SAFETY.md)**. --- ## Inside Pulse: New Pairs, Final Stretch and Migrated, live Coin page: chart, live trades, holders and the paper trade panel | Feature | What it does | |---|---| | **Pulse** | **New Pairs**, **Final Stretch** and **Migrated**, live. Market cap, volume, trades, holders, top 10, dev, sniper and bundle holdings, socials and curve progress on every row. Filters per column. A column holds still while your mouse is on it. | | **Firehose** | Every pump.fun and PumpSwap trade the moment it lands, with trades per second, buy share and volume. Filter by size, side and venue. | | **Coin page** | Candles from 1 second to 4 hours, market cap or price, USD or SOL. Live trades, holders, top traders with PnL, the dev's other coins. Marks for the dev, your tracked wallets and your own trades. | | **Paper trading** | Quick buy from Pulse, buy and sell on the coin page, live PnL in Portfolio. Fills come from the pool's reserves and fee at the moment you click, so size moves the price like it does on chain. | | **Trackers** | Name any wallet and see its trades land, on every page. | --- ## Data ### Instant: the PumpApi firehose OScope starts with **no database, no Helius, no API key and no sign-up**. It reads the free public stream of [PumpApi](https://pumpapi.io): one message per pump.fun and PumpSwap transaction, with the pool's reserves and every trader's balance after it. Every number in OScope is built from that: - price and market cap from the pool reserves - holders from each trader's balance after their trade - dev = the wallet that launched the coin, bundle = buys in the launch block, snipers = buys in the next two blocks - charts from the trades You see everything **from the moment you start**. A coin launched after that is complete: every trade, every holder. A coin launched before shows its live price at once and builds its chart and holders from then on; where a number is not known yet OScope shows `–` instead of guessing. What it has seen is saved in `data/`, so a restart keeps it. ### The full picture A coin's past (its chart since launch, every holder of a week-old coin) needs history, and a live stream has none. Three ways to get it: | | What you do | What you get | Cost | |---|---|---|---| | **1. Start now with PumpApi** | Nothing. This is how OScope starts. Leave it running. | Everything from the moment you started. | Free | | **2. Helius** | An API key from [helius.dev](https://www.helius.dev); OScope asks it for a coin's past trades and full holder list when you open the coin. | The whole history of any coin, on demand. | Free plan for light use | | **3. Your own database + backfill** | Record the stream into a database, load past days once, start OScope from it. | Everything, for every coin, and it is yours. | Disk space and setup | 2 and 3 are **not built in yet**. History only has to be handed to the same function the live stream uses, and [AGENTS.md](AGENTS.md) describes exactly how. Ask your AI: *"Add a Helius backfill to OScope. Read AGENTS.md, section Adding history."* --- ## The firehose for your own tools PumpApi allows only a couple of connections per internet address. OScope opens one and **passes it on**, so your own bots and scripts read the same stream without opening another: ``` ws://localhost:3000/firehose every message ws://localhost:3000/firehose?pools=pump,pump-amm only pump.fun and PumpSwap ``` ```js import WebSocket from 'ws'; const ws = new WebSocket('ws://localhost:3000/firehose?pools=pump,pump-amm'); ws.on('message', m => { const t = JSON.parse(m); if (t.action === 'buy') console.log(t.symbol, t.quoteAmount, 'SOL'); }); ``` | Command | What it does | |---|---| | `npm run tape` | Trades scroll by in your terminal. `npm run tape -- 1` shows only trades of 1 SOL or more. | | `npm run firehose` | The firehose alone, without the page, on `ws://127.0.0.1:3100`. | | `npm run doctor` | Checks Node, the libraries, the port, the stream and the data folder, and says how to fix what is wrong. | --- ## Install by hand [Node.js](https://nodejs.org) 18.17 or newer (20+ recommended). ```sh git clone https://git.oscope.trade/oscope.git cd oscope npm ci npm start ``` Open **http://localhost:3000**. Something off? `npm run doctor`.
Settings
OScope needs no settings. To change one, copy `.env.example` to `.env`. | Setting | Default | What it does | |---|---|---| | `PORT` | `3000` | Port of the page. If it is taken and you set nothing, OScope takes the next free one and prints it. | | `HOST` | `127.0.0.1` | Only this computer. `0.0.0.0` opens it to your network (no password: trusted networks only). | | `FEED_URL` | `wss://stream.pumpapi.io/` | The live stream. Point it at a firehose you run to share one connection. | | `FIREHOSE` | `on` | `off` stops passing the stream on at `/firehose`. | | `IPFS_GATEWAYS` | three public gateways | Where coin pictures kept on IPFS are fetched from. | | `DATA_DIR` | `data` | Where OScope keeps what it saw between runs. | | `STRETCH_AT` | `60` | Curve progress (%) at which a coin moves to Final Stretch. | | `NEW_MAX_AGE_MIN` | `60` | Minutes a coin stays in New Pairs. | | `LOG_EVERY` | `15` | Seconds between the trade-count lines in the terminal. `0` turns them off. | Everything you set in the page (paper wallet, tracked wallets, filters, hidden coins) stays in your browser. Settings → Export saves it to a file; Import brings it back.
How it is built
``` src/ server.mjs web server, the page's websocket, the firehose feed.mjs stream connection and SOL price store.mjs coins, trades, candles, holders, the Pulse columns meta.mjs coin pictures and socials, every address checked first config.mjs settings tools/ doctor.mjs npm run doctor tape.mjs npm run tape firehose.mjs npm run firehose public/ index.html, css/, js/ the page: plain JavaScript modules, no build step ``` It uses about 400–500 MB of memory: full detail for the coins you can see or open, a light copy of the rest.
--- ## Credits Charts by [TradingView Lightweight Charts](https://github.com/tradingview/lightweight-charts) (Apache 2.0). Fonts: [Geist](https://vercel.com/font) (SIL Open Font License). Live data: [PumpApi](https://pumpapi.io). ## Not financial advice OScope shows public market data. Memecoins are extremely risky and most go to zero. ## License [MIT](LICENSE) ===== FILE: SAFETY.md ===== sha256: c6743a59b2a7f593a8db1db1934fcc7a29d8fe5e211ff3ec79802185a706f3c7 · bytes: 5735 · lines: 101 # Safety This file is the project's own list of everything OScope does and does not do on your computer. It is written to be **checked against the code**, by you or by your AI. Each point names the file it lives in. If the code ever does something this file does not say, that is a bug: please open an issue. ## What OScope never does - It never asks for, reads, stores or sends a **private key, seed phrase or wallet file**. There is no place to enter one. - It never **signs or sends a transaction**. It has no code that can. - It has **no accounts, no logins, no tracking, no analytics** and no "phone home". - It never **downloads or runs other code**. No auto-update, no `eval`, no scrambled or encoded code. - It never reads or writes files **outside its own folder**. (It reads `.env` if you made one, and writes `data/`.) Trading in OScope is **paper trading**: a practice wallet that exists only in your browser (`public/js/paper.js`). No real SOL and no real coins are involved. ## What it connects to All outgoing connections are made by the server, in two files. The server only *reads* from them; it sends no information about you. (Like any web request, the other side sees your IP address.) | Where | What for | Code | |---|---|---| | `wss://stream.pumpapi.io/` | The live trade stream. OScope listens; it sends nothing. You can change the address with `FEED_URL`. | `src/feed.mjs` `connectFeed` | | `api.coinbase.com`, `api.kraken.com`, `api.binance.com` | The SOL price in dollars, every 20 seconds. The first that answers is used. | `src/feed.mjs` `solPrice` | | The metadata and picture links of the coins you look at | Each coin's picture, description and social links. These addresses are chosen by whoever launched the coin. | `src/meta.mjs` | | `ipfs.filebase.io`, `4everland.io`, `pump.mypinata.cloud` | Public IPFS gateways. Most coins keep their picture on IPFS; a link of that kind is asked for on these. You can change the list with `IPFS_GATEWAYS`. | `src/meta.mjs` `candidates`, `src/config.mjs` | Because coin creators choose those addresses, every one is checked before it is fetched (`src/meta.mjs` `safeUrl` and `get`): - only `http` and `https`; - never an address on your own computer or your local network (the name is looked up first; private, loopback and link-local addresses are refused); - every redirect is checked the same way, three at most; - size limits (256 KB for metadata, 6 MB for a picture) and an 8 second time limit. `npm run doctor` and `npm run tape` also ask `api.coinbase.com` for the SOL price (`tools/`). **Your browser** talks only to your own OScope server. The page's content policy (`csp` in `src/server.mjs`) allows scripts, styles, fonts, pictures and connections from that server only: coin pictures reach the page through the server, never straight from the internet. Links to pump.fun, Solscan and X open only when you click them, in a new tab. ## What it listens on - `127.0.0.1:3000` by default: **only your own computer**. If port 3000 is taken, the next free port. - It answers only requests addressed to `localhost` / `127.0.0.1` (`hostOk` in `src/server.mjs`), so a website that points its own name at your computer gets nothing. - Two websockets: `/ws` for the page and `/firehose` for your own tools. A web page from any other site cannot open either (the Origin check in the `upgrade` handler in `src/server.mjs`). - It accepts only `GET` and `HEAD`. Nothing can be uploaded or changed through it. - `HOST=0.0.0.0` opens it to other devices on your network. Only do that on a network you trust: it has no password. - `npm run firehose` listens on `127.0.0.1:3100` and refuses every web page (`tools/firehose.mjs`). ## What it stores - `data/coins.json` and `data/devs.json`: coins, trades and holders it saw. Public chain data, nothing about you. - In your browser's local storage: your paper wallet, tracked wallet names, filters, hidden coins and settings. It stays in your browser. Settings → Export gives you a copy. ## What gets installed - Two libraries and one helper, exact versions, with checksums in `package-lock.json`: [`ws`](https://github.com/websockets/ws) (websockets), [`lightweight-charts`](https://github.com/tradingview/lightweight-charts) (TradingView's open source chart library) and `fancy-canvas` (a helper `lightweight-charts` needs). - `.npmrc` sets `ignore-scripts=true`: no package can run code while it installs. - `package.json` has no install scripts. - No build step. The files in `src/` and `public/` are what runs. The only minified file is the chart library's own release inside `node_modules/lightweight-charts/dist/`, as published on npm. ## Content from strangers Coin names, descriptions, links and pictures are written by whoever launched the coin. OScope treats them as untrusted: - names and descriptions are put on the page as plain text, never as HTML (`h()` in `public/js/util.js`); - links must be `http` or `https`, and open in a new tab with no access to the OScope page (`safeHref` in `public/js/util.js`, `cleanLink` in `src/meta.mjs`); - pictures are served with a policy that stops them from running scripts (the `/pic/` handler in `src/server.mjs`). ## Check it yourself ```sh # every outgoing connection in the code grep -rn "fetch(\|new WebSocket(" src tools public/js # anything that touches files grep -rn "node:fs\|node:child_process" src tools # nothing runs at install cat .npmrc grep -n "\"scripts\"" -A 6 package.json # keys, signing: there should be no match at all grep -rniE "private.?key|secret.?key|seed phrase|mnemonic|signTransaction|sendTransaction|keypair" src tools public/js ``` ## Found a problem? Tell us on X: https://x.com/OScopetrade ===== FILE: AGENTS.md ===== sha256: 7650af048b8e6721d76fd4c49f9e1668c385037f97bd024dacf6b53187ec130f · bytes: 9182 · lines: 146 # For AI assistants You are helping someone run OScope, an open source memecoin terminal for Solana, on their own computer. They may not know how to code. Use plain words with them, do one step at a time, and say what you did. ## Safety check first Before installing or running anything, read the code and report to the user. [SAFETY.md](SAFETY.md) lists what the code does and does not do, with the file for each point. Check the code against it: - no private key, seed phrase or wallet file is ever asked for, read, stored or sent; nothing signs or sends a transaction; - outgoing connections: only `src/feed.mjs` (the trade stream, SOL price) and `src/meta.mjs` (coin metadata and pictures, each address checked first); `tools/` asks for the SOL price and talks to the local server; - files: `src/config.mjs` reads `.env`; `src/store.mjs` reads and writes `data/`; `src/server.mjs` serves `public/` and one file of `node_modules/lightweight-charts/dist/`; - install: `.npmrc` has `ignore-scripts=true`, `package.json` has no install scripts, `package-lock.json` pins `ws`, `lightweight-charts` and `fancy-canvas`; - the server listens on `127.0.0.1` unless `HOST` says otherwise. If the code does anything SAFETY.md does not say, stop and tell the user. ## Install and run ```sh node -v # 18.17 or newer; 20 or newer is best git clone https://git.oscope.trade/oscope.git cd oscope npm ci # exactly what package-lock.json pins npm run doctor # checks Node, the libraries, the port, the stream, the data folder npm start # leave it running ``` `npm start` prints: ``` OScope is running Open http://127.0.0.1:3000 Firehose ws://127.0.0.1:3000/firehose Data wss://stream.pumpapi.io/ ``` then `feed: connected …`, and every 15 seconds a line like `live: 2,910 trades in the last 15 s (194/s) · 3 launches and 0 migrations seen · 2,513 coins in memory`. It works when: - that `live:` line shows trades (well above zero), and - `http://localhost:3000/api/status` answers with `"feed":{"connected":true…}` and a growing `stats.trades`. Tell the user to open the printed address. New Pairs fills within seconds. Final Stretch fills as curve coins trade. Migrated fills as coins migrate, which can take some minutes on a fresh start. The Firehose page shows every trade. No settings are needed. On Windows, set a setting by putting it in a `.env` file (copy `.env.example`) instead of typing `NAME=value` before the command. ## Troubleshooting | What you see | Why | What to do | |---|---|---| | `node: command not found`, or a version under 18.17 | Node.js missing or old | Install the current LTS from https://nodejs.org (or with the system's package manager, nvm, fnm, winget). | | `npm ci` fails | No `package-lock.json` in the folder, or no internet | Run it inside the `oscope` folder. Check the connection. | | `port 3000 is taken, trying 3001` | Another program uses the port | Nothing: OScope takes the next free port and prints the address. To choose one, set `PORT`. | | `feed: closed (…) after 0 s … only a few connections per IP address` | The stream allows only a couple of connections per internet address | Close other programs that read the same stream (another OScope, a bot). Or run one firehose (`npm run firehose`) and point everything at it with `FEED_URL=ws://127.0.0.1:3100`. | | `feed:` errors like `ENOTFOUND`, `ETIMEDOUT`, `certificate` | No internet, a firewall, a VPN or a proxy blocks websockets | It needs outgoing `wss://` on port 443. Try without the VPN. | | Dollar values show `–` | No SOL price source answered yet | It retries every 20 seconds by itself. Check that `api.coinbase.com` is reachable. | | The page says `Stream down` or `Stream quiet` | The server lost the stream | Read the terminal: the `feed:` lines say why. It reconnects by itself. | | A coin page says `Not seen yet` | The coin has not traded since OScope started | It appears at its next trade. See "Adding history". | | Holders, top 10, dev show `–` | The coin launched before OScope started, or its tokens were moved by plain transfers the stream does not carry | Expected with the live stream alone. See "Adding history". | | `403 OScope answers only on localhost` | The page was opened under another name while `HOST` is `127.0.0.1` | Open `http://localhost:`. To reach it from another device set `HOST=0.0.0.0`. | `npm run doctor` checks most of this and prints a fix for each problem. ## How the code is laid out - `src/server.mjs`: the web server. Serves `public/`, answers `/api/*`, runs the page's websocket `/ws` and the firehose `/firehose`. Only `GET` and `HEAD`. - `src/feed.mjs`: `connectFeed(url, onFrame)` keeps one websocket to the stream open and calls `onFrame(frame, rawText)` for every message. `solPrice()` keeps the SOL price. - `src/store.mjs`: `Store`. `ingest(frame)` is the only way data gets in. It keeps coins, trades, candles (one second and one minute), holders and the three Pulse columns, and saves a snapshot to `data/` every minute and on exit. - `src/meta.mjs`: fetches coin metadata and pictures, with the address checks. - `public/js/`: the page. Plain ES modules, no framework, no build step. One file per view (`pulse.js`, `live.js`, `coin.js`, `portfolio.js`, `trackerview.js`), plus `paper.js` (the paper wallet), `net.js` (the websocket). ## The frame format One frame is one transaction. `Store.ingest` uses these fields (amounts are whole SOL and whole tokens, not lamports): ```js { pool: 'pump' | 'pump-amm', // bonding curve | PumpSwap. Other pools are ignored. action: 'create' | 'buy' | 'sell' | 'migrate', mint, name, symbol, uri, // uri = the coin's metadata link quoteMint, // must be SOL (So11111111111111111111111111111111111111112) txSigner, // the wallet that sent the transaction (for create: the dev) breakdown: [{ action, trader, tokenAmount, quoteAmount }], // optional; without it the four fields above/below are used tokenAmount, quoteAmount, price, // SOL per token after the transaction marketCapQuote, // market cap in SOL after the transaction tokensInPool, quoteInPool, // real reserves after the transaction vTokensInBondingCurve, vQuoteInBondingCurve, // pump: virtual reserves virtualQuoteInPool, poolCreatedBy, // pump-amm: extra virtual SOL; 'pump' = the pool made by the migration supply, poolFeeRate, decimals, mayhemMode, postBalances: { [wallet]: { [mint]: tokensHeldAfter } }, block, timestamp, signature, priorityFee // slot, milliseconds } ``` ## Adding history The live stream starts at the moment OScope starts. A coin that launched earlier has no chart, trades or holders from before that. To add the past, **build frames in the format above and pass them to `store.ingest()` oldest first, before the live stream is connected** (in `src/server.mjs`, between `store.load()` and `connectFeed(...)`). Nothing else in OScope has to change: the columns, charts, holders and the page all read the store. Two ways people do it: **Helius (or any Solana RPC that keeps history).** Good for filling one coin when the user opens it. - Past trades: list the signatures for the coin's bonding curve or pool account (`getSignaturesForAddress`), load the transactions (`getTransaction` with `maxSupportedTransactionVersion`, or Helius' parsed transactions), and turn each pump.fun / PumpSwap trade into a frame. Reserves and the trader's balance after the trade are in the transaction's post balances. - Current holders: Helius' token-accounts API or `getTokenLargestAccounts` gives balances per wallet. Write them into the coin's `holders` map (`wallet -> { bal, … }`) instead of making frames. - Keep the API key in `.env` (for example `HELIUS_API_KEY`), read it in `src/config.mjs`, never send it to the page and never commit it. Stay inside the plan's rate limits: fetch on demand, cache the result in `data/`. **A local database and a backfill.** Good for running OScope all the time or for many users. - Record: read `ws://localhost:3000/firehose` (or the stream itself) and store every message, as rows in a database or as one file per hour. - Backfill: PumpApi publishes past hours of the same stream as downloadable archives (see their documentation). An hour is about half a gigabyte compressed, so load only the hours you need. - Start: at boot, read the stored frames for the window you want, oldest first, into `store.ingest()`, then connect the live stream. Raise `TRADE_CAP`, `MAX_COINS` and `IDLE_DROP_MIN` if you load a long window: everything is held in memory. ## Rules for changing this code - Never add code that takes a private key or signs a transaction unless the user asks for exactly that and understands it. - Keep the server on `127.0.0.1` by default. - Coin names, descriptions and links are untrusted: put them on the page as text (`h()` in `public/js/util.js`), never as HTML. - Every new outgoing connection goes into SAFETY.md. - No build step, no new dependency without a reason. ===== FILE: package.json ===== sha256: 5f445c2f61752dfb3894cab385039c3baaee5828b0067436d666e8da3e993b9c · bytes: 630 · lines: 25 { "name": "oscope", "version": "0.1.0", "description": "Open source memecoin terminal for Solana: live pulse, charts, holders, wallet tracking and paper trading.", "license": "MIT", "author": "OScope contributors", "type": "module", "engines": { "node": ">=18.17" }, "scripts": { "start": "node src/server.mjs", "tape": "node tools/tape.mjs", "firehose": "node tools/firehose.mjs", "doctor": "node tools/doctor.mjs" }, "dependencies": { "lightweight-charts": "5.2.1", "ws": "8.22.0" }, "repository": { "type": "git", "url": "https://git.oscope.trade/oscope.git" } } ===== FILE: package-lock.json ===== sha256: b57642727d96f6a889bee8feeec9c9ce6aaed68979a2c99b2980489e54a737b0 · bytes: 1623 · lines: 56 { "name": "oscope", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oscope", "version": "0.1.0", "license": "MIT", "dependencies": { "lightweight-charts": "5.2.1", "ws": "8.22.0" }, "engines": { "node": ">=18.17" } }, "node_modules/fancy-canvas": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", "license": "MIT" }, "node_modules/lightweight-charts": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.1.tgz", "integrity": "sha512-IVwoK1RLFiLPubaKIjNbtjWLnpPMqiABSrTay6whmNa8L1+19292VtHJ+BWyPUuLCwF0tcQlhEWd1CLB2a1nsQ==", "license": "Apache-2.0", "dependencies": { "fancy-canvas": "2.1.0" } }, "node_modules/ws": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { "optional": true }, "utf-8-validate": { "optional": true } } } } } ===== FILE: .npmrc ===== sha256: f7d367cb01a037434a5a1d32328abe5cfdff2eb7efad417bf879ea2c49ff1314 · bytes: 136 · lines: 4 # Never run install scripts of packages (OScope needs none). npm start / npm run still work. ignore-scripts=true audit=false fund=false ===== FILE: src/config.mjs ===== sha256: 5eb44a44a5ab64846170f9881b5df46eff8dfdcc6f9b6566f57bcbb64866589c · bytes: 2571 · lines: 45 // Settings come from the environment, or from a .env file next to package.json (see .env.example). // Every setting has a default: OScope runs with no .env at all. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // .env: KEY=value lines; a value already set in the environment wins try { for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split(/\r?\n/)) { const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/i); if (m && process.env[m[1]] == null) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2'); } } catch {} const env = process.env; const num = (v, d) => (v != null && v !== '' && Number.isFinite(+v) ? +v : d); export const config = { port: num(env.PORT, 3000), portFixed: env.PORT != null && env.PORT !== '', // a port you chose is never swapped for a free one host: env.HOST || '127.0.0.1', // Live pump.fun + PumpSwap trade stream (JSON, one message per transaction). Free, no key, no sign-up. feedUrl: env.FEED_URL || 'wss://stream.pumpapi.io/', // OScope shares its one stream connection at ws://localhost:/firehose for your own tools. "off" turns it off. firehose: (env.FIREHOSE || 'on').toLowerCase() !== 'off', dataDir: path.resolve(ROOT, env.DATA_DIR || 'data'), // Public IPFS gateways asked for coin pictures and metadata, in this order (comma separated, each ending in /ipfs/). ipfsGateways: (env.IPFS_GATEWAYS || 'https://ipfs.filebase.io/ipfs/,https://4everland.io/ipfs/,https://pump.mypinata.cloud/ipfs/').split(',').map(s => s.trim()).filter(s => /^https:\/\/[^\s]+\/ipfs\/$/.test(s)), // A curve coin moves from New Pairs to Final Stretch at this bonding-curve progress (percent). stretchAt: num(env.STRETCH_AT, 60), // New Pairs keeps coins younger than this (minutes). newMaxAgeMin: num(env.NEW_MAX_AGE_MIN, 60), // Coins kept per Pulse column (the page filters further). columnCap: num(env.COLUMN_CAP, 80), // Trades kept in memory per coin that is on screen, open or young (the coin page's trade list reads these). tradeCap: num(env.TRADE_CAP, 1000), // Quiet coins are dropped from memory after this many minutes without a trade. idleDropMin: num(env.IDLE_DROP_MIN, 120), // Coins kept in memory at most; the ones traded least recently go first. maxCoins: num(env.MAX_COINS, 6000), // A line in the terminal every N seconds saying how many trades came in (0 = off). logEvery: num(env.LOG_EVERY, 15), }; ===== FILE: src/feed.mjs ===== sha256: a8ba8ede2edd8f7e23929c246668e295bc6c8f47286b3a696122783e75a42ac4 · bytes: 3137 · lines: 65 // The live trade stream (one websocket, reopened when it drops) and the SOL price. import WebSocket from 'ws'; export function connectFeed(url, onFrame, log = console.log) { let ws = null, wait = 1000, alive = 0, timer = null, closed = false; const state = { url, connected: false, since: 0, reconnects: 0 }; const open = () => { if (closed) return; ws = new WebSocket(url, { perMessageDeflate: true, handshakeTimeout: 15000 }); ws.on('open', () => { state.connected = true; state.since = Date.now(); if (!state.reconnects) log(`feed: connected to ${url}`); }); ws.on('message', buf => { alive = Date.now(); const raw = buf.toString(); let f; try { f = JSON.parse(raw); } catch { return; } // onFrame gets each transaction and its exact text (the firehose passes the text on untouched) if (Array.isArray(f)) for (const x of f) onFrame(x, null); else onFrame(f, raw); }); ws.on('close', (code, reason) => { const lived = state.connected ? Date.now() - state.since : 0; // a connection that lasted a while starts the wait over; one that drops at once backs off if (lived > 15000) wait = 1000; const why = `${code}${reason && reason.length ? ' ' + reason.toString().slice(0, 120) : ''}`; if (lived > 15000 || state.reconnects < 3 || state.reconnects % 20 === 0) { log(`feed: closed (${why}) after ${Math.round(lived / 1000)} s, retrying in ${Math.round(wait / 1000)} s${lived < 2000 && state.reconnects > 1 ? '. The stream may allow only a few connections per IP address: close other apps reading it.' : ''}`); } state.connected = false; state.reconnects++; setTimeout(open, wait); wait = Math.min(wait * 2, 30000); }); ws.on('error', err => { log(`feed: ${err.message}`); try { ws.terminate(); } catch {} }); }; open(); // a stream that goes quiet for 30 s is reopened timer = setInterval(() => { if (state.connected && Date.now() - alive > 30000) { log('feed: quiet for 30 s, reopening'); try { ws.terminate(); } catch {} } }, 10000); state.close = () => { closed = true; clearInterval(timer); try { ws.close(); } catch {} }; return state; } // SOL in dollars, from public exchange prices. export function solPrice(log = console.log) { const st = { usd: 0, at: 0 }; const sources = [ async () => +(await (await fetch('https://api.coinbase.com/v2/prices/SOL-USD/spot', { signal: AbortSignal.timeout(6000) })).json()).data.amount, async () => +(await (await fetch('https://api.kraken.com/0/public/Ticker?pair=SOLUSD', { signal: AbortSignal.timeout(6000) })).json()).result.SOLUSD.c[0], async () => +(await (await fetch('https://api.binance.com/api/v3/ticker/price?symbol=SOLUSDT', { signal: AbortSignal.timeout(6000) })).json()).price, ]; const tick = async () => { for (const s of sources) { try { const v = await s(); if (v > 1) { st.usd = v; st.at = Date.now(); return; } } catch {} } if (!st.usd) log('sol price: no source answered, retrying'); }; tick(); setInterval(tick, 20000).unref(); return st; } ===== FILE: src/meta.mjs ===== sha256: 05e36cc7c917937ee3af1f2772da62be16567495f35bc04b0c3a464d05e0716f · bytes: 7725 · lines: 183 // Coin metadata (picture, description, socials) from each coin's metadata link, and a small picture cache. // // Metadata links are chosen by whoever launched the coin, so every fetch is checked first: only http(s), and // never an address on this machine or a private network (a link like http://127.0.0.1/... must not reach // services running next to OScope). import dns from 'node:dns/promises'; import net from 'node:net'; import { config } from './config.mjs'; const MAX_JSON = 256 * 1024; const MAX_IMG = 6 * 1024 * 1024; const IMG_CACHE_BYTES = 64 * 1024 * 1024; function privateIp(ip) { if (net.isIPv4(ip)) { const [a, b] = ip.split('.').map(Number); return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224; } const v = ip.toLowerCase(); if (v.startsWith('::ffff:')) return privateIp(v.slice(7)); return v === '::1' || v === '::' || v.startsWith('fc') || v.startsWith('fd') || v.startsWith('fe80'); } async function safeUrl(raw) { let u; try { u = new URL(raw); } catch { return null; } if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; if (u.username || u.password) return null; const host = u.hostname.replace(/^\[|\]$/g, ''); if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) return null; try { const addrs = net.isIP(host) ? [{ address: host }] : await dns.lookup(host, { all: true }); if (!addrs.length || addrs.some(a => privateIp(a.address))) return null; } catch { return null; } return u; } async function get(raw, maxBytes, accept, hops = 0) { const u = await safeUrl(raw); if (!u) throw new Error('blocked url'); const res = await fetch(u, { redirect: 'manual', signal: AbortSignal.timeout(8000), headers: { accept, 'user-agent': 'OScope' } }); if (res.status >= 300 && res.status < 400 && res.headers.get('location') && hops < 3) { return get(new URL(res.headers.get('location'), u).href, maxBytes, accept, hops + 1); } if (!res.ok) throw new Error('http ' + res.status); const len = +res.headers.get('content-length') || 0; if (len > maxBytes) throw new Error('too big'); const chunks = []; let size = 0; for await (const ch of res.body) { size += ch.length; if (size > maxBytes) throw new Error('too big'); chunks.push(ch); } return { type: (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(), body: Buffer.concat(chunks) }; } // Most coins keep their metadata and picture on IPFS and name one gateway in the link. Gateways come and go // (several big ones no longer answer programs at all), so a link with /ipfs/ is asked for on the gateways // in IPFS_GATEWAYS. The one that answered last is asked first. const DEAD_FOR_PROGRAMS = /^https?:\/\/(ipfs\.io|dweb\.link|w3s\.link|nftstorage\.link|gateway\.pinata\.cloud|cloudflare-ipfs\.com)\//i; const gateways = [...config.ipfsGateways]; function candidates(raw) { const m = String(raw).match(/^(?:https?:\/\/[^/]+\/ipfs\/|ipfs:\/\/(?:ipfs\/)?)([A-Za-z0-9]{20,}(?:[/?].*)?)$/); if (!m) return [raw]; const own = DEAD_FOR_PROGRAMS.test(raw) || raw.startsWith('ipfs:') ? [] : [raw]; return [...own, ...gateways.filter(g => !raw.startsWith(g)).map(g => g + m[1])]; } async function getAny(raw, maxBytes, accept) { let last; for (const url of candidates(raw)) { try { const r = await get(url, maxBytes, accept); const i = gateways.findIndex(g => url.startsWith(g)); if (i > 0) gateways.unshift(...gateways.splice(i, 1)); return r; } catch (e) { last = e; } } throw last || new Error('no address'); } const cleanLink = v => { if (typeof v !== 'string') return ''; v = v.trim(); if (!v) return ''; if (!/^https?:\/\//i.test(v)) { if (/^@?[A-Za-z0-9_]{1,15}$/.test(v)) return 'https://x.com/' + v.replace(/^@/, ''); if (/^[\w.-]+\.[a-z]{2,}(\/\S*)?$/i.test(v)) v = 'https://' + v; else return ''; } try { const u = new URL(v); return u.protocol === 'https:' || u.protocol === 'http:' ? u.href.slice(0, 300) : ''; } catch { return ''; } }; export class Meta { constructor(store) { this.store = store; this.queue = []; this.active = 0; this.limit = 12; this.imgs = new Map(); // mint -> { type, body } this.imgBytes = 0; this.imgWait = new Map(); this.imgBad = new Map(); // mint -> when its picture last failed: not asked for again for 5 minutes } want(c) { if (c.metaState) return; c.metaState = 1; this.queue.push(c); this.pump(); } // a page is looking at this coin: fetch it before the rest urgent(c) { if (c.metaState === 1) { const i = this.queue.indexOf(c); if (i > 0) { this.queue.splice(i, 1); this.queue.unshift(c); } } else if (!c.metaState) { c.metaState = 1; this.queue.unshift(c); this.pump(); } } pump() { while (this.active < this.limit && this.queue.length) { // newest first: a coin launched a second ago matters more than one from the backlog const c = this.queue.length > 400 ? this.queue.pop() : this.queue.shift(); this.active++; this.load(c).finally(() => { this.active--; this.pump(); }); } if (this.queue.length > 4000) this.queue.splice(0, this.queue.length - 4000); } async load(c) { if (!c.uri) { c.metaState = 3; return; } try { const r = await getAny(c.uri, MAX_JSON, 'application/json,*/*'); if (r.type.startsWith('image/')) { c.image = c.uri; } else { const j = JSON.parse(r.body.toString('utf8')); const img = typeof j.image === 'string' ? j.image : ''; c.image = img; c.description = typeof j.description === 'string' ? j.description.slice(0, 600) : ''; const ext = j.extensions || {}; c.twitter = cleanLink(j.twitter || ext.twitter || ''); c.website = cleanLink(j.website || ext.website || ''); c.telegram = cleanLink(j.telegram || ext.telegram || ''); if (!c.name && typeof j.name === 'string') c.name = j.name.slice(0, 60); if (!c.symbol && typeof j.symbol === 'string') c.symbol = j.symbol.slice(0, 20); } c.metaState = 2; } catch { // a link is often not reachable yet in the first seconds after a launch: try again a few times c.metaTries = (c.metaTries || 0) + 1; c.metaState = 3; const wait = [3000, 15000, 60000][c.metaTries - 1]; if (wait) setTimeout(() => { if (c.metaState === 3) { c.metaState = 0; this.want(c); } }, wait).unref(); } c.ver++; } async image(mint) { const hit = this.imgs.get(mint); if (hit) { this.imgs.delete(mint); this.imgs.set(mint, hit); return hit; } if (this.imgWait.has(mint)) return this.imgWait.get(mint); const c = this.store.coins.get(mint); if (!c || !c.image) return null; const bad = this.imgBad.get(mint); if (bad && Date.now() - bad < 300000) return null; const p = getAny(c.image, MAX_IMG, 'image/*').then(r => { if (!r.type.startsWith('image/')) { this.imgBad.set(mint, Date.now()); return null; } this.imgBad.delete(mint); const v = { type: r.type, body: r.body }; this.imgs.set(mint, v); this.imgBytes += r.body.length; for (const [k, old] of this.imgs) { if (this.imgBytes <= IMG_CACHE_BYTES) break; this.imgs.delete(k); this.imgBytes -= old.body.length; } return v; }).catch(() => { this.imgBad.set(mint, Date.now()); if (this.imgBad.size > 5000) this.imgBad.clear(); return null; }).finally(() => this.imgWait.delete(mint)); this.imgWait.set(mint, p); return p; } } ===== FILE: src/server.mjs ===== sha256: bbd0dd94f4ec70cdfd8d53515108ced30247cc5e5fa0e36559b20e2e3623189d · bytes: 18779 · lines: 382 // OScope server: reads the live trade stream, keeps the coins in memory, serves the terminal page and talks to it // over one websocket. It also passes the raw stream on at /firehose, so your own tools can share its one connection. import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import zlib from 'node:zlib'; import { WebSocketServer } from 'ws'; import { config, ROOT } from './config.mjs'; import { Store } from './store.mjs'; import { Meta } from './meta.mjs'; import { connectFeed, solPrice } from './feed.mjs'; const PUBLIC = path.join(ROOT, 'public'); const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a); const store = new Store(); const meta = new Meta(store); store.wantMeta = c => meta.want(c); const loaded = store.load(); if (loaded) log(`loaded ${loaded} coins from ${config.dataDir}`); // pictures that were being fetched or had failed when OScope stopped are fetched again when their coin is looked at for (const c of store.coins.values()) if (c.metaState === 1 || c.metaState === 3) { c.metaState = 0; c.metaTries = 0; } const sol = solPrice(log); // ---------- firehose: the raw stream, passed on to local tools ---------- // ws://localhost:/firehose every message // ws://localhost:/firehose?pools=pump,pump-amm only those pools const fire = new Set(); function relay(f, raw) { let text = raw; for (const cl of fire) { if (cl.pools && !cl.pools.has(f.pool)) continue; if (cl.ws.readyState !== 1) continue; if (cl.ws.bufferedAmount > 8e6) { cl.ws.terminate(); continue; } // a reader that cannot keep up is cut, never the stream cl.ws.send(text ??= JSON.stringify(f)); } } const feed = connectFeed(config.feedUrl, (f, raw) => { store.ingest(f); if (fire.size) relay(f, raw); }, log); // ---------- websocket clients ---------- const clients = new Set(); const coinSubs = new Map(); // mint -> Set(client) const walletSubs = new Map(); // wallet -> Set(client) const MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; function send(cl, obj) { if (cl.ws.readyState !== 1) return; if (cl.ws.bufferedAmount > 16e6) { cl.ws.terminate(); return; } cl.ws.send(JSON.stringify(obj)); } function unsubCoin(cl) { if (!cl.coin) return; const s = coinSubs.get(cl.coin); if (s) { s.delete(cl); if (!s.size) { coinSubs.delete(cl.coin); store.pinned.delete(cl.coin); } } cl.coin = null; } function setWallets(cl, list) { for (const w of cl.wallets) { const s = walletSubs.get(w); if (s) { s.delete(cl); if (!s.size) walletSubs.delete(w); } } cl.wallets = new Set(list.filter(w => typeof w === 'string' && MINT_RE.test(w)).slice(0, 1000)); for (const w of cl.wallets) { if (!walletSubs.has(w)) walletSubs.set(w, new Set()); walletSubs.get(w).add(cl); } } let tape = []; let tapeOn = 0; store.onTrade = (c, tr) => { if (tapeOn) { if (!c.metaState) meta.want(c); tape.push([tr[0], c.mint, c.symbol, c.image ? `/pic/${c.mint}` : '', tr[1], tr[2], tr[5], tr[6], c.pool, tr[7], c.name]); if (tape.length > 3000) tape = tape.slice(-1500); } const subs = coinSubs.get(c.mint); if (subs) for (const cl of subs) cl.trades.push(tr); const ws = walletSubs.get(tr[6]); if (ws) { const item = { w: tr[6], m: c.mint, n: c.name, s: c.symbol, img: c.image ? `/pic/${c.mint}` : '', buy: tr[1], sol: tr[2], tok: tr[3], mc: tr[5], ts: tr[0], sig: tr[7] }; for (const cl of ws) cl.wt.push(item); } }; const wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024, perMessageDeflate: { threshold: 1024 } }); wss.on('connection', ws => { const cl = { ws, pulse: false, tape: false, sent: new Map(), cols: '', coin: null, coinVer: 0, trades: [], watch: new Set(), wallets: new Set(), wt: [] }; clients.add(cl); send(cl, { t: 'hello', sol: sol.usd, feed: feed.connected, stretchAt: config.stretchAt }); ws.on('message', buf => { let msg; try { msg = JSON.parse(buf.toString()); } catch { return; } if (!msg || typeof msg !== 'object') return; if (msg.t === 'pulse') { cl.pulse = !!msg.on; cl.sent.clear(); cl.cols = ''; } else if (msg.t === 'tape') { if (!!msg.on !== cl.tape) { cl.tape = !!msg.on; tapeOn += cl.tape ? 1 : -1; } } else if (msg.t === 'coin') { unsubCoin(cl); if (typeof msg.mint === 'string' && MINT_RE.test(msg.mint)) { cl.coin = msg.mint; cl.coinVer = 0; cl.trades = []; if (!coinSubs.has(msg.mint)) coinSubs.set(msg.mint, new Set()); coinSubs.get(msg.mint).add(cl); store.pinned.add(msg.mint); const c = store.coins.get(msg.mint); if (c) { c.viewed = Date.now(); meta.urgent(c); } } } else if (msg.t === 'watch' && Array.isArray(msg.mints)) { cl.watch = new Set(msg.mints.filter(m => typeof m === 'string' && MINT_RE.test(m)).slice(0, 300)); } else if (msg.t === 'track' && Array.isArray(msg.wallets)) setWallets(cl, msg.wallets); }); ws.on('close', () => { unsubCoin(cl); setWallets(cl, []); if (cl.tape) tapeOn--; clients.delete(cl); }); }); // Pulse: every 400 ms each page gets the column order (when it changed) and the rows that changed since its last send. setInterval(() => { const cols = store.placeColumns(); const ids = { new: cols.new.map(c => c.mint), stretch: cols.stretch.map(c => c.mint), migrated: cols.migrated.map(c => c.mint) }; const idsKey = JSON.stringify(ids); const all = [...cols.new, ...cols.stretch, ...cols.migrated]; for (const c of all) if (!c.metaState || (c.metaState === 1 && !c.urged)) { c.urged = true; meta.urgent(c); } for (const cl of clients) { if (!cl.pulse || cl.ws.bufferedAmount > 4e6) continue; const rows = []; const present = new Set(); for (const c of all) { present.add(c.mint); if (cl.sent.get(c.mint) !== c.ver) { rows.push(store.row(c)); cl.sent.set(c.mint, c.ver); } } for (const m of cl.sent.keys()) if (!present.has(m)) cl.sent.delete(m); if (!rows.length && cl.cols === idsKey) continue; const out = { t: 'p', rows }; if (cl.cols !== idsKey) { out.cols = ids; cl.cols = idsKey; } send(cl, out); } }, 400); // Coin page: live trades every 250 ms, the coin's numbers when they change. Tracked wallets' trades. setInterval(() => { for (const cl of clients) { if (cl.coin) { const c = store.coins.get(cl.coin); if (c && cl.coinVer !== c.ver) { cl.coinVer = c.ver; send(cl, { t: 'c', d: store.detail(c) }); } if (cl.trades.length) { send(cl, { t: 'trs', m: cl.coin, list: cl.trades.slice(-200) }); cl.trades = []; } } if (cl.wt.length) { send(cl, { t: 'wt', list: cl.wt.slice(-100) }); cl.wt = []; } } if (tape.length) { const list = tape.slice(-400); tape = []; for (const cl of clients) if (cl.tape && cl.ws.bufferedAmount < 4e6) send(cl, { t: 'tape', list, n: store.stats.trades }); } }, 250); // Prices of the coins a page holds (paper positions), once a second. setInterval(() => { for (const cl of clients) { if (!cl.watch.size) continue; const p = {}; for (const m of cl.watch) { const c = store.coins.get(m); if (c && c.price) p[m] = [c.price, c.mc, c.pool, c.rq, c.rt, c.fee, c.name, c.symbol, c.image ? `/pic/${c.mint}` : '']; } send(cl, { t: 'px', p }); } }, 1000); setInterval(() => { const st = { t: 'st', sol: sol.usd, feed: feed.connected, lag: store.stats.lastFrame ? Date.now() - store.stats.lastFrame : null, coins: store.coins.size, trades: store.stats.trades, fire: fire.size }; for (const cl of clients) send(cl, st); }, 3000); setInterval(() => { // coins open on a page or held in a paper wallet keep their full detail const pinned = new Set(coinSubs.keys()); for (const cl of clients) for (const m of cl.watch) pinned.add(m); store.pinned = pinned; for (const m of coinSubs.keys()) { const c = store.coins.get(m); if (c) c.viewed = Date.now(); } const n = store.prune(); if (n > 2000) log(`dropped ${n} quiet coins, ${store.coins.size} kept`); }, 30000); setInterval(() => { try { store.save(); } catch (e) { log('save failed:', e.message); } }, 60000).unref(); // ---------- http ---------- const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.json': 'application/json', '.txt': 'text/plain; charset=utf-8', '.webmanifest': 'application/manifest+json' }; // The page may load and connect to this server only: no outside scripts, pictures, fonts or connections. const csp = host => `default-src 'self'; img-src 'self' data: blob:; connect-src 'self' ws://${host} wss://${host}; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'`; const VENDOR = { '/vendor/lightweight-charts.mjs': path.join(ROOT, 'node_modules/lightweight-charts/dist/lightweight-charts.standalone.production.mjs') }; const fileCache = new Map(); function staticFile(file) { const st = fs.statSync(file); const hit = fileCache.get(file); if (hit && hit.mtime === st.mtimeMs) return hit; const body = fs.readFileSync(file); const type = TYPES[path.extname(file)] || 'application/octet-stream'; const text = /^(text|application\/(json|manifest))|svg/.test(type); const v = { mtime: st.mtimeMs, body, type, etag: `"${st.size.toString(36)}-${Math.floor(st.mtimeMs).toString(36)}"`, br: text ? zlib.brotliCompressSync(body) : null, gz: text ? zlib.gzipSync(body) : null }; fileCache.set(file, v); return v; } function sendStatic(req, res, file, cache) { let f; try { f = staticFile(file); } catch { return notFound(res); } const h = { 'content-type': f.type, etag: f.etag, 'cache-control': cache, 'x-content-type-options': 'nosniff', vary: 'accept-encoding' }; if (f.type.startsWith('text/html')) { const host = String(req.headers.host || '').replace(/[^a-zA-Z0-9.:\-\[\]]/g, '') || 'localhost'; h['content-security-policy'] = csp(host); h['referrer-policy'] = 'no-referrer'; } if (req.headers['if-none-match'] === f.etag) { res.writeHead(304, h); return res.end(); } const ae = req.headers['accept-encoding'] || ''; let body = f.body; if (f.br && /\bbr\b/.test(ae)) { body = f.br; h['content-encoding'] = 'br'; } else if (f.gz && /\bgzip\b/.test(ae)) { body = f.gz; h['content-encoding'] = 'gzip'; } res.writeHead(200, h); res.end(req.method === 'HEAD' ? undefined : body); } function json(res, obj, status = 200) { const body = JSON.stringify(obj); res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(body); } function notFound(res) { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('not found'); } const SPA = /^\/(|live|coin\/[1-9A-HJ-NP-Za-km-z]{32,44}|portfolio|trackers|wallet\/[1-9A-HJ-NP-Za-km-z]{32,44}|settings)$/; async function api(req, res, url) { const parts = url.pathname.split('/').filter(Boolean); // api, what, id const what = parts[1], id = parts[2]; if (what === 'status') { return json(res, { feed: { connected: feed.connected, reconnects: feed.reconnects, url: config.feedUrl }, firehose: config.firehose ? { path: '/firehose', readers: fire.size } : null, sol: sol.usd, coins: store.coins.size, clients: clients.size, stats: store.stats, uptime: Math.round(process.uptime()), memoryMB: Math.round(process.memoryUsage().rss / 1048576), held: { ...store.memory(), picturesMB: Math.round(meta.imgBytes / 1048576) } }); } if (what === 'search') { const q = (url.searchParams.get('q') || '').trim().slice(0, 60); if (!q) return json(res, []); if (MINT_RE.test(q) && store.coins.has(q)) return json(res, [store.row(store.coins.get(q))]); const lq = q.toLowerCase(), out = []; for (const c of store.coins.values()) { if (c.origin !== 'pump') continue; const s = c.symbol.toLowerCase(), n = c.name.toLowerCase(); const score = s === lq ? 3 : s.startsWith(lq) ? 2 : n.includes(lq) ? 1 : c.mint.startsWith(q) ? 1 : 0; if (score) out.push([score, c]); } out.sort((a, b) => b[0] - a[0] || b[1].mc - a[1].mc); const top = out.slice(0, 20).map(([, c]) => c); for (const c of top) if (!c.metaState) meta.urgent(c); return json(res, top.map(c => store.row(c))); } if (what === 'dev' && id && MINT_RE.test(id)) { const list = (store.devs.get(id) || []).map(e => { const c = store.coins.get(e.m); return { m: e.m, n: c?.name || e.n, s: c?.symbol || e.s, t: e.t, mig: c?.migratedAt || e.mig, mc: c?.mc || 0, ath: Math.max(c?.ath || 0, e.ath || 0), img: c?.image ? `/pic/${e.m}` : '', live: !!c }; }).sort((a, b) => b.t - a.t); return json(res, list); } if (what === 'wallet' && id && MINT_RE.test(id)) { const out = []; for (const c of store.coins.values()) { const h = c.holders.get(id); if (!h) continue; out.push({ m: c.mint, n: c.name, s: c.symbol, img: c.image ? `/pic/${c.mint}` : '', bal: h.bal, bs: h.bs, ss: h.ss, n_: h.n, last: h.last, mc: c.mc, pnl: h.ss - h.bs + h.bal * c.price, value: h.bal * c.price }); } out.sort((a, b) => b.last - a.last); return json(res, out.slice(0, 300)); } if (!id || !MINT_RE.test(id)) return notFound(res); const c = store.coins.get(id); if (!c) return json(res, { error: 'not seen', mint: id }, 404); c.viewed = Date.now(); if (what === 'coin') { meta.urgent(c); return json(res, store.detail(c)); } if (what === 'candles') { const tf = Math.max(1, Math.min(86400, parseInt(url.searchParams.get('tf') || '60', 10) || 60)); return json(res, store.candles(c, tf)); } if (what === 'trades') { const limit = Math.max(1, Math.min(2000, parseInt(url.searchParams.get('limit') || '200', 10) || 200)); return json(res, c.trades.slice(-limit).reverse()); } if (what === 'holders') return json(res, store.holdersOf(c, 100)); return notFound(res); } // When OScope listens only on this computer, it answers only requests addressed to this computer. A website // that points its own name at 127.0.0.1 (DNS rebinding) gets nothing. const LOCAL_ONLY = ['127.0.0.1', 'localhost', '::1'].includes(config.host); const LOCAL_NAMES = new Set(['localhost', '127.0.0.1', '[::1]']); function hostOk(req) { if (!LOCAL_ONLY) return true; const name = String(req.headers.host || '').replace(/:\d+$/, '').toLowerCase(); return LOCAL_NAMES.has(name) || name.endsWith('.localhost'); } const server = http.createServer(async (req, res) => { try { if (!hostOk(req)) { res.writeHead(403, { 'content-type': 'text/plain' }); return res.end('OScope answers only on localhost'); } const url = new URL(req.url, 'http://x'); const p = decodeURIComponent(url.pathname); if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); } if (p.startsWith('/api/')) return await api(req, res, url); if (p.startsWith('/pic/')) { const mint = p.slice(5); if (!MINT_RE.test(mint)) return notFound(res); const img = await meta.image(mint); if (!img) return notFound(res); res.writeHead(200, { 'content-type': img.type, 'cache-control': 'public, max-age=86400', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox" }); return res.end(img.body); } if (VENDOR[p]) return sendStatic(req, res, VENDOR[p], 'public, max-age=86400'); if (SPA.test(p)) return sendStatic(req, res, path.join(PUBLIC, 'index.html'), 'no-cache'); const file = path.join(PUBLIC, path.normalize(p)); if (!file.startsWith(PUBLIC + path.sep)) return notFound(res); return sendStatic(req, res, file, 'no-cache'); } catch (e) { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'text/plain' }); res.end('error'); } log('http error:', e.message); } }); const fireWss = new WebSocketServer({ noServer: true, maxPayload: 4096, perMessageDeflate: false }); fireWss.on('connection', (ws, req) => { const pools = new URL(req.url, 'http://x').searchParams.get('pools'); const cl = { ws, pools: pools ? new Set(pools.split(',').map(p => p.trim()).filter(Boolean)) : null }; fire.add(cl); ws.on('close', () => fire.delete(cl)); ws.on('message', () => {}); // readers only listen }); server.on('upgrade', (req, sock, head) => { const p = req.url.split('?')[0]; if (!hostOk(req)) return sock.destroy(); if (p !== '/ws' && !(p === '/firehose' && config.firehose)) return sock.destroy(); // A web page may open these sockets only if this server served it: other websites you visit cannot read them. // Tools (no Origin header) may. const origin = req.headers.origin; if (origin) { try { if (new URL(origin).host !== req.headers.host) return sock.destroy(); } catch { return sock.destroy(); } } const target = p === '/ws' ? wss : fireWss; target.handleUpgrade(req, sock, head, ws => target.emit('connection', ws, req)); }); // ---------- start ---------- let port = config.port; function listen() { server.listen(port, config.host); } server.on('error', err => { if (err.code === 'EADDRINUSE' && !config.portFixed && port < config.port + 20) { log(`port ${port} is taken, trying ${port + 1}`); port++; return setTimeout(listen, 50); } if (err.code === 'EADDRINUSE') { console.error(`\nPort ${port} is already in use. Pick another one: PORT=${port + 1} npm start\n`); process.exit(1); } console.error(err); process.exit(1); }); server.on('listening', () => { const host = config.host === '0.0.0.0' || config.host === '::' ? 'localhost' : config.host; const url = `http://${host}:${port}`; console.log(`\n OScope is running\n\n Open ${url}\n Firehose ${config.firehose ? url.replace('http', 'ws') + '/firehose' : 'off'}\n Data ${config.feedUrl}\n`); }); listen(); // a line every LOG_EVERY seconds, so you can see the trades coming in let lastTrades = 0; if (config.logEvery > 0) { setInterval(() => { const n = store.stats.trades - lastTrades; lastTrades = store.stats.trades; if (!feed.connected) return log('waiting for the stream…'); log(`live: ${n.toLocaleString('en-US')} trades in the last ${config.logEvery} s (${(n / config.logEvery).toFixed(0)}/s) · ${store.stats.creates} launches and ${store.stats.migrations} migrations seen · ${store.coins.size.toLocaleString('en-US')} coins in memory · ${Math.round(process.memoryUsage().rss / 1048576)} MB`); }, config.logEvery * 1000).unref(); } const stop = () => { try { store.save(); log('saved'); } catch {} process.exit(0); }; process.on('SIGINT', stop); process.on('SIGTERM', stop); ===== FILE: src/store.mjs ===== sha256: b93d06441ddafb200303675c5d97d9854f0e52b24d06374e7c3b65617090c65f · bytes: 18523 · lines: 415 // Everything the terminal knows about coins, built from the live trade stream. // // One frame = one transaction on the pump.fun bonding curve ("pump") or on PumpSwap ("pump-amm"). // Frames carry the pool reserves after the transaction, so price, market cap and curve progress are exact; // they also carry each trader's token balance after the transaction, so holder balances are exact for every // wallet that traded while OScope was running. // // All amounts are in SOL. The page turns them into dollars with the live SOL price. import fs from 'node:fs'; import path from 'node:path'; import { config } from './config.mjs'; const WSOL = 'So11111111111111111111111111111111111111112'; const CURVE_TOKENS = 793_100_000; // tokens a fresh pump.fun curve can sell before it migrates const isSol = q => q === WSOL || q === 'sol' || q == null; const now = () => Date.now(); // Memory: full detail only for coins somebody may look at (in a Pulse column, open on a page, held in the paper // wallet, or launched in the last ten minutes). Every other coin keeps a light copy: live numbers, its last trades, the // wallets that still hold it and two hours of minute candles. Without this, coins traded around the clock by bots // pile up until the process uses gigabytes. const LIGHT = { trades: 30, holders: 100, s1: 60, m1: 120 }; const YOUNG_MS = 10 * 60e3, VIEWED_MS = 30 * 60e3; const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); function newCoin(f) { return { mint: f.mint, name: f.name || '', symbol: f.symbol || '', uri: f.uri || '', image: '', description: '', twitter: '', website: '', telegram: '', metaState: 0, created: 0, createdBlock: null, dev: '', origin: f.pool === 'pump' || f.poolCreatedBy === 'pump' ? 'pump' : 'other', pool: f.pool, poolId: f.poolId || '', supply: +f.supply || 1e9, decimals: f.decimals ?? 6, tokenProgram: f.tokenProgram || '', mayhem: !!f.mayhemMode, price: 0, mc: 0, ath: 0, liq: 0, rq: 0, rt: 0, inPool: 0, fee: +f.poolFeeRate || 0.0125, progress: 0, seenCurve: f.pool === 'pump', complete: false, migratedAt: 0, stretchAt: 0, buys: 0, sells: 0, volB: 0, volS: 0, fees: 0, firstSeen: now(), last: 0, holders: new Map(), // wallet -> { bal, bs, ss, bt, st, n, first, last } hGone: 0, // wallets still holding that were dropped from a light copy (counted in holders) viewed: 0, // when a page last asked for this coin snipers: new Set(), bundle: new Set(), trades: [], // [ts, isBuy, sol, tokens, priceSol, mcSol, wallet, signature] m1: [], // minute candles of market cap in SOL: [t, o, h, l, c, volSol] s1: [], // one-second candles, the same, for the last hour of trading seconds ver: 1, }; } export class Store { constructor() { this.coins = new Map(); this.devs = new Map(); // dev wallet -> [{ m, n, s, t, mig, ath }] this.onTrade = null; // (coin, trade) => void this.onCoin = null; // (coin) => void, a coin was created or migrated this.wantMeta = null; // (coin) => void. Pictures and socials are fetched only for coins somebody looks at. this.stats = { frames: 0, trades: 0, creates: 0, migrations: 0, lastFrame: 0, started: now() }; this.columns = { new: [], stretch: [], migrated: [] }; this.pinned = new Set(); // mints a page is looking at: never dropped } ingest(f) { this.stats.frames++; this.stats.lastFrame = now(); if (!f || !f.mint || (f.pool !== 'pump' && f.pool !== 'pump-amm')) return; const a = f.action; if (a === 'buy' || a === 'sell') this.trade(f); else if (a === 'create') this.create(f); else if (a === 'migrate' || a === 'complete' || (a === 'createPool' && f.poolCreatedBy === 'pump')) this.migrate(f); } coinOf(f) { let c = this.coins.get(f.mint); if (!c) { c = newCoin(f); this.coins.set(f.mint, c); } else { if (!c.name && f.name) c.name = f.name; if (!c.symbol && f.symbol) c.symbol = f.symbol; if (!c.uri && f.uri) c.uri = f.uri; if (f.poolCreatedBy === 'pump') c.origin = 'pump'; } return c; } applyPool(c, f, ts) { if (f.pool === 'pump') { c.seenCurve = true; c.rq = +f.vQuoteInBondingCurve || c.rq; c.rt = +f.vTokensInBondingCurve || c.rt; if (f.tokensInPool != null) { c.progress = clamp((CURVE_TOKENS - f.tokensInPool) / CURVE_TOKENS * 100, 0, 100); if (f.tokensInPool <= 0.5) c.complete = true; } } else { // PumpSwap: price = (SOL in pool + virtual SOL) / tokens in pool if (c.pool === 'pump' && c.seenCurve && !c.migratedAt && c.origin === 'pump' && f.poolCreatedBy === 'pump') this.markMigrated(c, ts); c.rq = (+f.quoteInPool || 0) + (+f.virtualQuoteInPool || 0); c.rt = +f.tokensInPool || c.rt; c.progress = 100; } c.pool = f.pool; if (f.poolId) c.poolId = f.poolId; if (f.supply) c.supply = +f.supply; if (f.poolFeeRate != null) c.fee = +f.poolFeeRate; if (f.price > 0) c.price = +f.price; if (f.marketCapQuote > 0) c.mc = +f.marketCapQuote; c.liq = (+f.quoteInPool || 0) + (+f.tokensInPool || 0) * c.price; if (f.tokensInPool != null) c.inPool = +f.tokensInPool; if (c.mc > c.ath) c.ath = c.mc; if (c.pool === 'pump' && c.progress >= config.stretchAt && !c.stretchAt) c.stretchAt = ts; } create(f) { if (!isSol(f.quoteMint)) return; const ts = +f.timestamp || now(); const c = this.coinOf(f); c.created = ts; c.createdBlock = f.block ?? null; c.dev = f.txSigner || f.creatorFeeAddress || ''; c.origin = 'pump'; c.mayhem = !!f.mayhemMode; this.applyPool(c, f, ts); this.stats.creates++; this.devAdd(c); if (this.wantMeta) this.wantMeta(c); // a new coin goes straight to New Pairs: get its picture now // a launch with a dev buy carries the buy in the same frame this.fills(c, f, ts); c.ver++; if (this.onCoin) this.onCoin(c, 'create'); } migrate(f) { const c = this.coins.get(f.mint); if (!c) return; const ts = +f.timestamp || now(); if (f.action === 'complete') { c.complete = true; c.ver++; return; } if (f.pool === 'pump-amm') this.applyPool(c, f, ts); if (!c.migratedAt) this.markMigrated(c, ts); } markMigrated(c, ts) { c.migratedAt = ts; c.complete = true; this.stats.migrations++; const d = c.dev && this.devs.get(c.dev); if (d) { const e = d.find(x => x.m === c.mint); if (e) e.mig = ts; } c.ver++; if (this.onCoin) this.onCoin(c, 'migrate'); } trade(f) { if (!isSol(f.quoteMint)) return; // anyone can open a side pool for a pump coin; only the pool made by the migration counts if (f.pool === 'pump-amm' && f.poolCreatedBy && f.poolCreatedBy !== 'pump') return; const ts = +f.timestamp || now(); const c = this.coinOf(f); this.applyPool(c, f, ts); this.fills(c, f, ts); c.ver++; } fills(c, f, ts) { const parts = Array.isArray(f.breakdown) && f.breakdown.length ? f.breakdown : [{ action: f.action, trader: f.txSigner, tokenAmount: f.tokenAmount, quoteAmount: f.quoteAmount }]; let any = false; for (const p of parts) { if (p.action !== 'buy' && p.action !== 'sell') continue; const buy = p.action === 'buy'; const sol = +p.quoteAmount || 0, tok = +p.tokenAmount || 0, w = p.trader || f.txSigner; if (!w || (!sol && !tok)) continue; let h = c.holders.get(w); if (!h) { h = { bal: 0, bs: 0, ss: 0, bt: 0, st: 0, n: 0, first: ts, last: ts }; c.holders.set(w, h); } if (buy) { h.bs += sol; h.bt += tok; c.buys++; c.volB += sol; } else { h.ss += sol; h.st += tok; c.sells++; c.volS += sol; } h.n++; h.last = ts; const post = f.postBalances?.[w]?.[c.mint]; h.bal = post != null && Number.isFinite(+post) ? +post : Math.max(0, h.bal + (buy ? tok : -tok)); if (buy && c.createdBlock != null && w !== c.dev && f.block != null) { const d = f.block - c.createdBlock; if (d === 0) c.bundle.add(w); else if (d > 0 && d <= 2) c.snipers.add(w); } c.fees += sol * c.fee; const tr = [ts, buy ? 1 : 0, sol, tok, c.price, c.mc, w, f.signature || '']; c.trades.push(tr); if (c.trades.length > config.tradeCap + 200) c.trades.splice(0, c.trades.length - config.tradeCap); this.candle(c, ts, c.mc, sol); this.stats.trades++; any = true; if (this.onTrade) this.onTrade(c, tr); } if (any) { c.fees += +f.priorityFee || 0; c.last = ts; } } candle(c, ts, v, sol) { if (!(v > 0)) return; this.bucket(c.s1, Math.floor(ts / 1000), v, sol, 3600); this.bucket(c.m1, Math.floor(ts / 60000) * 60, v, sol, 4320); } bucket(m, t, v, sol, cap) { const last = m[m.length - 1]; if (last && last[0] === t) { if (v > last[2]) last[2] = v; if (v < last[3]) last[3] = v; last[4] = v; last[5] += sol; } else if (!last || t > last[0]) { const o = last ? last[4] : v; m.push([t, o, Math.max(o, v), Math.min(o, v), v, sol]); if (m.length > cap + 100) m.splice(0, m.length - cap); } } devAdd(c) { if (!c.dev) return; let d = this.devs.get(c.dev); if (!d) { d = []; this.devs.set(c.dev, d); } if (!d.some(x => x.m === c.mint)) { d.push({ m: c.mint, n: c.name, s: c.symbol, t: c.created, mig: 0, ath: c.ath }); if (d.length > 100) d.splice(0, d.length - 100); } } // ---------- what the page reads ---------- holderStats(c) { let top = [], n = 0, devBal = 0, snBal = 0, buBal = 0, sum = 0; for (const [w, h] of c.holders) { if (h.bal <= 0.5) continue; n++; sum += h.bal; if (w === c.dev) devBal = h.bal; if (c.snipers.has(w)) snBal += h.bal; if (c.bundle.has(w)) buBal += h.bal; if (top.length < 10) { top.push(h.bal); if (top.length === 10) top.sort((a, b) => a - b); } else if (h.bal > top[0]) { top[0] = h.bal; top.sort((a, b) => a - b); } } const s = c.supply || 1e9, pct = v => +Math.min(100, v / s * 100).toFixed(2); // Tokens sent by plain transfer never show in the trade stream, so a sender still looks like a holder. // When the wallets add up to more than the tokens outside the pool, the split is not known. if (sum > (s - c.inPool) * 1.03 + 1) return { holders: n + c.hGone, top10: null, dev: null, snipers: null, bundle: null, devSold: false }; return { holders: n + c.hGone, top10: pct(top.reduce((a, b) => a + b, 0)), dev: pct(devBal), snipers: pct(snBal), bundle: pct(buBal), devSold: !!c.dev && c.holders.has(c.dev) && devBal <= 0.5 }; } row(c) { if (c._rowVer === c.ver && c._row) return c._row; const hs = this.holderStats(c); const d = c.dev ? this.devs.get(c.dev) : null; c._row = { m: c.mint, n: c.name, s: c.symbol, img: c.image ? `/pic/${c.mint}` : '', tw: c.twitter, web: c.website, tg: c.telegram, t: c.created, mig: c.migratedAt, last: c.last, mc: c.mc, ath: c.ath, liq: c.liq, vol: c.volB + c.volS, buys: c.buys, sells: c.sells, fees: c.fees, p: c.price, rq: c.rq, rt: c.rt, fee: c.fee, pr: +c.progress.toFixed(1), pool: c.pool, may: c.mayhem ? 1 : 0, done: c.complete ? 1 : 0, h: hs.holders, top10: hs.top10, dh: hs.dev, sn: hs.snipers, bu: hs.bundle, ds: hs.devSold ? 1 : 0, dev: c.dev, dc: d ? d.length : 0, dm: d ? d.filter(x => x.mig).length : 0, v: c.ver, }; c._rowVer = c.ver; return c._row; } detail(c) { return { ...this.row(c), price: c.price, supply: c.supply, rq: c.rq, rt: c.rt, fee: c.fee, poolId: c.poolId, desc: c.description, uri: c.uri, firstSeen: c.firstSeen, decimals: c.decimals, tp: c.tokenProgram, seen: !!c.created }; } holdersOf(c, limit = 100) { const list = []; for (const [w, h] of c.holders) list.push([w, h]); const tag = w => (w === c.dev ? 'dev' : c.bundle.has(w) ? 'bundle' : c.snipers.has(w) ? 'sniper' : ''); const holders = list.filter(([, h]) => h.bal > 0.5).sort((a, b) => b[1].bal - a[1].bal).slice(0, limit) .map(([w, h]) => ({ w, bal: h.bal, pct: h.bal / c.supply * 100, bs: h.bs, ss: h.ss, n: h.n, first: h.first, tag: tag(w) })); const traders = list.map(([w, h]) => [w, h, h.ss - h.bs + h.bal * c.price]).sort((a, b) => b[2] - a[2]).slice(0, limit) .map(([w, h, pnl]) => ({ w, bal: h.bal, pct: h.bal / c.supply * 100, bs: h.bs, ss: h.ss, bt: h.bt, st: h.st, n: h.n, pnl, tag: tag(w) })); return { holders, traders, total: list.filter(([, h]) => h.bal > 0.5).length }; } candles(c, tf) { // tf in seconds: under a minute from the one-second candles (the last hour), a minute and up from minute candles const src = tf < 60 ? c.s1 : c.m1; const step = tf; const out = []; for (const [t0, o, h, l, cl, v] of src) { const t = Math.floor(t0 / step) * step; const last = out[out.length - 1]; if (last && last[0] === t) { if (h > last[2]) last[2] = h; if (l < last[3]) last[3] = l; last[4] = cl; last[5] += v; } else out.push([t, o, h, l, cl, v]); } return out; } // ---------- Pulse columns ---------- placeColumns() { const t = now(), maxAge = config.newMaxAgeMin * 60e3, cap = config.columnCap; const nw = [], st = [], mg = []; for (const c of this.coins.values()) { if (c.origin !== 'pump') continue; if (c.migratedAt) { if (t - c.migratedAt < 24 * 3600e3) mg.push(c); continue; } if (c.pool !== 'pump') continue; if (c.progress >= config.stretchAt || c.complete) { if (t - c.last < 15 * 60e3) st.push(c); } else if (c.progress < config.stretchAt - 5 && c.stretchAt) c.stretchAt = 0; if (c.created && t - c.created < maxAge && c.progress < config.stretchAt && !c.complete) nw.push(c); } nw.sort((a, b) => b.created - a.created); st.sort((a, b) => b.progress - a.progress); mg.sort((a, b) => b.migratedAt - a.migratedAt); this.columns = { new: nw.slice(0, cap), stretch: st.slice(0, cap), migrated: mg.slice(0, cap) }; return this.columns; } // ---------- memory ---------- // a light copy of a coin nobody is looking at (see LIGHT) lighten(c) { if (c.trades.length > LIGHT.trades) c.trades.splice(0, c.trades.length - LIGHT.trades); if (c.s1.length > LIGHT.s1) c.s1.splice(0, c.s1.length - LIGHT.s1); if (c.m1.length > LIGHT.m1) c.m1.splice(0, c.m1.length - LIGHT.m1); if (c.holders.size > LIGHT.holders) { for (const [w, h] of c.holders) if (h.bal <= 0.5 && w !== c.dev) c.holders.delete(w); if (c.holders.size > LIGHT.holders) { const small = [...c.holders].filter(([w]) => w !== c.dev).sort((a, b) => a[1].bal - b[1].bal); for (const [w] of small.slice(0, c.holders.size - LIGHT.holders)) { c.holders.delete(w); c.hGone++; } } } c.light = true; } // what the memory holds, for /api/status memory() { let trades = 0, holders = 0, candles = 0, light = 0; for (const c of this.coins.values()) { trades += c.trades.length; holders += c.holders.size; candles += c.s1.length + c.m1.length; if (c.light) light++; } return { coins: this.coins.size, light, trades, holders, candles }; } prune() { const t = now(), idle = config.idleDropMin * 60e3; const keep = new Set(); for (const col of Object.values(this.columns)) for (const c of col) keep.add(c.mint); for (const m of this.pinned) keep.add(m); for (const c of this.coins.values()) { if (keep.has(c.mint) || t - c.viewed < VIEWED_MS || (c.created && t - c.created < YOUNG_MS)) c.light = false; else this.lighten(c); } let dropped = 0; for (const [m, c] of this.coins) { if (keep.has(m)) continue; const lastAct = Math.max(c.last, c.created, c.migratedAt, c.firstSeen); if (t - lastAct > idle) { this.coins.delete(m); dropped++; } } if (this.coins.size > config.maxCoins) { const list = [...this.coins.values()].filter(c => !keep.has(c.mint) && c.light).sort((a, b) => a.last - b.last); for (const c of list.slice(0, this.coins.size - config.maxCoins)) { this.coins.delete(c.mint); dropped++; } } // a busy coin's holder map: drop wallets that sold out long ago for (const c of this.coins.values()) { if (c.holders.size > 4000) { for (const [w, h] of c.holders) if (h.bal <= 0.5 && t - h.last > 10 * 60e3 && w !== c.dev) c.holders.delete(w); } } // dev index: keep 20,000 devs if (this.devs.size > 20000) { const list = [...this.devs.entries()].sort((a, b) => a[1].at(-1).t - b[1].at(-1).t); for (const [d] of list.slice(0, this.devs.size - 20000)) this.devs.delete(d); } return dropped; } // ---------- saved between runs ---------- save() { const dir = config.dataDir; fs.mkdirSync(dir, { recursive: true }); const t = now(), keep = []; for (const c of this.coins.values()) { if (c.origin !== 'pump') continue; // what the Pulse columns need after a restart, and what pages have open const fresh = (c.created && t - c.created < config.newMaxAgeMin * 60e3) || (c.migratedAt && t - c.migratedAt < 24 * 3600e3) || this.pinned.has(c.mint) || (c.progress >= config.stretchAt && t - c.last < 15 * 60e3); if (!fresh) continue; const holders = [...c.holders].sort((a, b) => b[1].bal - a[1].bal).slice(0, 200); keep.push({ ...c, holders, snipers: [...c.snipers], bundle: [...c.bundle], trades: c.trades.slice(-200), s1: c.s1.slice(-600), m1: c.m1.slice(-1440), _row: undefined, _rowVer: undefined }); } const write = (name, data) => { const p = path.join(dir, name); fs.writeFileSync(p + '.tmp', JSON.stringify(data)); fs.renameSync(p + '.tmp', p); }; write('coins.json', { saved: t, coins: keep }); write('devs.json', [...this.devs]); return keep.length; } load() { const dir = config.dataDir; try { const d = JSON.parse(fs.readFileSync(path.join(dir, 'devs.json'), 'utf8')); this.devs = new Map(d); } catch {} try { const { coins } = JSON.parse(fs.readFileSync(path.join(dir, 'coins.json'), 'utf8')); for (const o of coins) { const c = { ...newCoin(o), ...o, holders: new Map(o.holders), snipers: new Set(o.snipers), bundle: new Set(o.bundle), ver: 1 }; if (!c.s1.length) for (const tr of c.trades) this.bucket(c.s1, Math.floor(tr[0] / 1000), tr[5], tr[2], 3600); this.coins.set(c.mint, c); } return coins.length; } catch { return 0; } } } ===== FILE: tools/doctor.mjs ===== sha256: ea013ea0c59384c664836e8f218f335098596455315798f0d772371f360d3691 · bytes: 3865 · lines: 65 // npm run doctor: checks everything OScope needs and says how to fix what is missing. import fs from 'node:fs'; import path from 'node:path'; import net from 'node:net'; import { config, ROOT } from '../src/config.mjs'; const ok = s => console.log(` \x1b[32m✓\x1b[0m ${s}`); const bad = (s, fix) => { console.log(` \x1b[31m✗\x1b[0m ${s}${fix ? `\n fix: ${fix}` : ''}`); problems++; }; const note = s => console.log(` \x1b[2m· ${s}\x1b[0m`); let problems = 0; console.log('\nOScope doctor\n'); // 1. Node.js const [maj, min] = process.versions.node.split('.').map(Number); if (maj > 18 || (maj === 18 && min >= 17)) ok(`Node.js ${process.versions.node}`); else bad(`Node.js ${process.versions.node} is too old`, 'install Node.js 20 or newer (LTS) from https://nodejs.org'); // 2. dependencies for (const p of ['ws', 'lightweight-charts']) { if (fs.existsSync(path.join(ROOT, 'node_modules', p, 'package.json'))) ok(`${p} installed`); else bad(`${p} is not installed`, 'run: npm ci'); } // 3. the port: free, or OScope already running there const status = await fetch(`http://127.0.0.1:${config.port}/api/status`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()).catch(() => null); let localFire = false; if (status && status.feed) { ok(`OScope is already running on http://localhost:${config.port} (stream ${status.feed.connected ? 'live' : 'not connected'}, ${status.stats?.trades ?? 0} trades so far)`); localFire = !!status.firehose && status.feed.connected; } else { const free = await new Promise(res => { const s = net.createServer().once('error', () => res(false)).once('listening', () => s.close(() => res(true))).listen(config.port, '127.0.0.1'); }); if (free) ok(`port ${config.port} is free`); else note(`port ${config.port} is used by another program: OScope will take the next free port and print its address`); } // 4. the stream { const { default: WebSocket } = await import('ws'); const url = localFire ? `ws://127.0.0.1:${config.port}/firehose` : config.feedUrl; const r = await new Promise(res => { let n = 0, opened = false; const ws = new WebSocket(url, { handshakeTimeout: 8000 }); const done = why => { try { ws.terminate(); } catch {} res({ n, opened, why }); }; ws.on('open', () => { opened = true; setTimeout(() => done('time'), 5000); }); ws.on('message', () => n++); ws.on('close', code => done('closed ' + code)); ws.on('error', e => done(e.message)); }); if (r.n > 0) ok(`stream works: ${r.n} messages in 5 s from ${url}`); else if (r.opened) bad(`the stream opened but sent nothing (${r.why})`, 'the stream allows only a few connections per internet address: close other programs reading it, or point FEED_URL at a firehose you already run'); else bad(`cannot reach the stream at ${url} (${r.why})`, 'check your internet connection, VPN or firewall (it needs outgoing websockets on port 443)'); } // 5. SOL price const px = await fetch('https://api.coinbase.com/v2/prices/SOL-USD/spot', { signal: AbortSignal.timeout(6000) }).then(r => r.json()).then(j => +j.data.amount).catch(() => 0); if (px > 0) ok(`SOL price: $${px.toFixed(2)}`); else note('SOL price source did not answer: OScope tries two more, dollar values wait until one answers'); // 6. data folder try { fs.mkdirSync(config.dataDir, { recursive: true }); fs.accessSync(config.dataDir, fs.constants.W_OK); ok(`data folder ${path.relative(ROOT, config.dataDir) || config.dataDir} is writable`); } catch { bad(`cannot write ${config.dataDir}`, 'set DATA_DIR in .env to a folder you can write to'); } console.log(problems ? `\n${problems} problem(s). Fix them and run npm run doctor again.\n` : status ? `\nAll good. OScope is running: open http://localhost:${config.port}\n` : `\nAll good. Run: npm start (then open http://localhost:${config.port})\n`); process.exit(problems ? 1 : 0); ===== FILE: tools/firehose.mjs ===== sha256: 4cd35a01cebfe50f8f99557a3181361b563ddd0d60758ee24bdf50a16ffbabd4 · bytes: 1999 · lines: 39 // The firehose on its own: one connection to the stream, passed on to as many local readers as you like. // OScope itself already does this at ws://localhost:3000/firehose; use this when you want the stream without the page. // // npm run firehose -> ws://127.0.0.1:3100 // readers may add ?pools=pump,pump-amm to get only those pools // // Settings: FEED_URL (the stream), FIREHOSE_PORT (3100), FIREHOSE_HOST (127.0.0.1). // Point OScope at it with FEED_URL=ws://127.0.0.1:3100 to run several things on one stream connection. import { WebSocketServer } from 'ws'; import { config } from '../src/config.mjs'; import { connectFeed } from '../src/feed.mjs'; const port = Number(process.env.FIREHOSE_PORT) || 3100; const host = process.env.FIREHOSE_HOST || '127.0.0.1'; const readers = new Set(); let n = 0; const wss = new WebSocketServer({ port, host, maxPayload: 4096, perMessageDeflate: false }); wss.on('connection', (ws, req) => { if (req.headers.origin) return ws.close(1008, 'tools only'); // web pages you visit cannot read it const pools = new URL(req.url, 'http://x').searchParams.get('pools'); const r = { ws, pools: pools ? new Set(pools.split(',')) : null }; readers.add(r); ws.on('close', () => readers.delete(r)); }); wss.on('listening', () => console.log(`\n Firehose on ws://${host}:${port}\n Stream ${config.feedUrl}\n`)); wss.on('error', e => { console.error(e.code === 'EADDRINUSE' ? `Port ${port} is in use: FIREHOSE_PORT=${port + 1} npm run firehose` : e.message); process.exit(1); }); connectFeed(config.feedUrl, (f, raw) => { n++; let text = raw; for (const r of readers) { if (r.pools && !r.pools.has(f.pool)) continue; if (r.ws.bufferedAmount > 8e6) { r.ws.terminate(); continue; } r.ws.send(text ??= JSON.stringify(f)); } }); setInterval(() => { console.log(`${new Date().toTimeString().slice(0, 8)} ${n.toLocaleString('en-US')} messages in the last 30 s, ${readers.size} reader(s)`); n = 0; }, 30000).unref(); ===== 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); }; } ===== FILE: public/js/util.js ===== sha256: ea7a16c0ef7b926a5ba8afb100d93afeb1d8dabfd60551bb792e37c506a7b912 · bytes: 4796 · lines: 112 // Small helpers shared by every view. export const $ = (sel, root = document) => root.querySelector(sel); export const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; // h('div.row.active', { onclick, title }, child, 'text', ...) export function h(tag, attrs, ...kids) { const [name, ...cls] = tag.split('.'); const el = document.createElement(name || 'div'); if (cls.length) el.className = cls.join(' '); if (attrs) { for (const [k, v] of Object.entries(attrs)) { if (v == null || v === false) continue; if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v); else if (k === 'html') el.innerHTML = v; // only ever used with our own markup (icons) else if (k in el && k !== 'list' && k !== 'type') el[k] = v; else el.setAttribute(k, v === true ? '' : v); } } for (const k of kids.flat()) if (k != null && k !== false) el.append(k instanceof Node ? k : document.createTextNode(String(k))); return el; } export const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); export function compact(v, digits = 1) { if (!Number.isFinite(v)) return '–'; const a = Math.abs(v); if (a >= 1e12) return (v / 1e12).toFixed(digits) + 'T'; if (a >= 1e9) return (v / 1e9).toFixed(digits) + 'B'; if (a >= 1e6) return (v / 1e6).toFixed(digits) + 'M'; if (a >= 1e3) return (v / 1e3).toFixed(digits) + 'K'; if (a >= 100) return v.toFixed(0); if (a >= 1) return v.toFixed(a >= 10 ? 1 : 2); if (a === 0) return '0'; return v.toFixed(a >= 0.01 ? 2 : a >= 0.001 ? 3 : 4); } export const count = n => (Number.isFinite(n) ? (Math.abs(n) < 1000 ? String(Math.round(n)) : compact(n, Math.abs(n) < 1e4 ? 1 : 0).replace('.0K', 'K')) : '–'); export const usd = v => (v < 0 ? '-$' : '$') + compact(Math.abs(v)); export function sol(v, d) { if (!Number.isFinite(v)) return '–'; const a = Math.abs(v); const digits = d ?? (a >= 1000 ? 0 : a >= 100 ? 1 : a >= 1 ? 2 : a >= 0.01 ? 3 : 4); return a >= 10000 ? compact(v) : v.toFixed(digits); } export const pct = (v, d = 1) => (Number.isFinite(v) ? (+v).toFixed(v !== 0 && Math.abs(v) < 1 && d < 2 ? 2 : d) + '%' : '–'); export const signed = (v, f) => (v > 0 ? '+' : v < 0 ? '−' : '') + f(Math.abs(v)); // price of one token in dollars: many zeros are written as 0.0₅123 export function tinyUsd(v) { if (!Number.isFinite(v) || v <= 0) return '–'; if (v >= 1) return '$' + v.toFixed(v >= 100 ? 2 : 4); const s = v.toFixed(20).slice(2); const zeros = s.match(/^0*/)[0].length; if (zeros < 4) return '$' + v.toFixed(zeros + 4); const sub = String(zeros).split('').map(d => '₀₁₂₃₄₅₆₇₈₉'[d]).join(''); return '$0.0' + sub + s.slice(zeros, zeros + 4).replace(/0+$/, ''); } export function age(ts, nowMs = Date.now()) { if (!ts) return '–'; const s = Math.max(0, Math.floor((nowMs - ts) / 1000)); if (s < 60) return s + 's'; const m = Math.floor(s / 60); if (m < 60) return m + 'm'; const hh = Math.floor(m / 60); if (hh < 24) return hh + 'h'; const d = Math.floor(hh / 24); return d < 30 ? d + 'd' : Math.floor(d / 30) + 'mo'; } export const short = (a, n = 4) => (a ? a.slice(0, n) + '…' + a.slice(-n) : ''); export function copy(text, label = 'Copied') { navigator.clipboard?.writeText(text).then(() => toast(label, 'info', 1400)).catch(() => {}); } let toastRoot; export function toast(text, kind = 'info', ms = 3000, node) { toastRoot ||= document.getElementById('toasts'); const t = h('div.toast.' + kind, null, node || h('span', null, text)); toastRoot.prepend(t); while (toastRoot.children.length > 5) toastRoot.lastChild.remove(); requestAnimationFrame(() => t.classList.add('in')); setTimeout(() => { t.classList.remove('in'); setTimeout(() => t.remove(), 250); }, ms); return t; } export function safeHref(u) { try { const x = new URL(u); return x.protocol === 'https:' || x.protocol === 'http:' ? x.href : null; } catch { return null; } } export const store = { get(k, d) { try { const v = localStorage.getItem('oscope.' + k); return v == null ? d : JSON.parse(v); } catch { return d; } }, set(k, v) { try { localStorage.setItem('oscope.' + k, JSON.stringify(v)); } catch {} }, }; // a deterministic colour for a coin with no picture export function hue(str) { let x = 0; for (let i = 0; i < str.length; i++) x = (x * 31 + str.charCodeAt(i)) >>> 0; return x % 360; } export function emitter() { const m = new Map(); return { on(e, f) { if (!m.has(e)) m.set(e, new Set()); m.get(e).add(f); return () => m.get(e).delete(f); }, emit(e, ...a) { for (const f of m.get(e) || []) f(...a); }, }; } ===== FILE: public/index.html ===== sha256: af00a42a5ee4c6f9d8a6cc947771dd8cf337582fc21836a384691bb68c9b5d3b · bytes: 1950 · lines: 47 OScope
OScope
Connecting
SOL–
–Paper
===== FILE: public/css/app.css ===== sha256: ad5af6aaf02e57eb042b6016f5d2f23eb6e95f321109eaa53ecb26c7347eb37b · bytes: 29223 · lines: 378 @font-face { font-family: 'Geist'; src: url('/fonts/Geist-Variable.woff2') format('woff2'); font-weight: 100 900; font-display: swap; } @font-face { font-family: 'Geist Mono'; src: url('/fonts/GeistMono-Variable.woff2') format('woff2'); font-weight: 100 900; font-display: swap; } :root { --bg: #06070a; --bg1: #0a0c10; --bg2: #0f1117; --bg3: #141720; --bg4: #1b1f2a; --line: #171a22; --line2: #222733; --line3: #2e3442; --tx: #eceff5; --tx2: #a9afbd; --tx3: #737b8c; --tx4: #4c5363; --acc: #4d8dff; --acc2: #2f6bff; --acc-bg: rgba(77, 141, 255, .11); --acc-bg2: rgba(77, 141, 255, .2); --up: #2fe39b; --up-bg: rgba(47, 227, 155, .1); --down: #ff5d7b; --down-bg: rgba(255, 93, 123, .1); --warn: #ffbf57; --warn-bg: rgba(255, 191, 87, .1); --gold: #f2c96b; --r: 6px; --r2: 4px; --f: 'Geist', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; --fm: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; --top: 48px; color-scheme: dark; } * { box-sizing: border-box; } html, body { margin: 0; height: 100%; background: var(--bg); color: var(--tx); font: 13px/1.35 var(--f); -webkit-font-smoothing: antialiased; } body { overflow: hidden; font-feature-settings: 'tnum' 1, 'cv11' 1; } a { color: inherit; text-decoration: none; } button, input, select, textarea { font: inherit; color: inherit; } button { cursor: pointer; background: none; border: 0; padding: 0; } .ic { display: block; flex: none; } .mono { font-family: var(--fm); font-size: .92em; letter-spacing: -.01em; } .up { color: var(--up); } .down { color: var(--down); } .mut { color: var(--tx3); } .warn { color: var(--warn); } ::selection { background: var(--acc-bg2); } ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-thumb { background: var(--line2); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } ::-webkit-scrollbar-thumb:hover { background-color: var(--line3); } ::-webkit-scrollbar-track { background: transparent; } /* ---------- header ---------- */ .top { position: fixed; inset: 0 0 auto 0; height: var(--top); display: flex; align-items: center; gap: 18px; padding: 0 14px 0 16px; background: rgba(8, 9, 13, .92); border-bottom: 1px solid var(--line); z-index: 20; backdrop-filter: blur(10px); } .brand { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 15px; letter-spacing: -.02em; } .brand img { display: block; } .nav { display: flex; gap: 2px; margin-left: 6px; } .nav a { height: 30px; display: flex; align-items: center; padding: 0 11px; border-radius: var(--r); color: var(--tx3); font-weight: 500; transition: color .15s, background .15s; } .nav a:hover { color: var(--tx); } .nav a.on { color: var(--tx); background: var(--bg3); } .search { position: relative; display: flex; align-items: center; height: 32px; width: 340px; margin-left: 8px; padding: 0 8px 0 10px; gap: 8px; background: var(--bg2); border: 1px solid var(--line2); border-radius: var(--r); transition: border-color .15s, background .15s; } .search:focus-within { border-color: var(--line3); background: var(--bg3); } .search-ic { color: var(--tx4); } .search input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; font-size: 13px; } .search input::placeholder { color: var(--tx4); } kbd { font: 500 11px var(--fm); color: var(--tx4); border: 1px solid var(--line2); border-radius: 4px; padding: 1px 5px; } .search-res { position: absolute; top: 38px; left: 0; width: 460px; max-height: 70vh; overflow: auto; background: var(--bg2); border: 1px solid var(--line2); border-radius: 8px; box-shadow: 0 20px 50px rgba(0, 0, 0, .55); display: none; padding: 4px; } .search-res.open { display: block; } .sr { display: grid; grid-template-columns: 36px 1fr auto; gap: 10px; align-items: center; padding: 7px 8px; border-radius: var(--r); } .sr:hover, .sr.sel { background: var(--bg3); } .sr .av { width: 36px; height: 36px; } .sr b { font-weight: 600; } .sr .mut { font-size: 12px; } .sr-empty { padding: 14px; color: var(--tx3); text-align: center; } .top-right { margin-left: auto; display: flex; align-items: center; gap: 8px; } .feed { display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 10px; color: var(--tx3); font-size: 12px; font-weight: 500; } .feed i { width: 7px; height: 7px; border-radius: 50%; background: var(--tx4); box-shadow: 0 0 0 3px rgba(255, 255, 255, .04); } .feed.ok i { background: var(--up); box-shadow: 0 0 0 3px var(--up-bg), 0 0 10px rgba(47, 227, 155, .5); animation: beat 2.4s ease-in-out infinite; } .feed.bad i { background: var(--down); box-shadow: 0 0 0 3px var(--down-bg); } @keyframes beat { 50% { box-shadow: 0 0 0 5px rgba(47, 227, 155, .05), 0 0 14px rgba(47, 227, 155, .35); } } .solpx em { font-style: normal; font-size: 11px; font-weight: 650; color: var(--tx4); } .solpx { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px; border-radius: var(--r); background: var(--bg2); border: 1px solid var(--line); font-weight: 600; font-size: 12.5px; } .wallet { display: flex; align-items: center; gap: 7px; height: 30px; padding: 0 6px 0 10px; border-radius: var(--r); background: var(--bg2); border: 1px solid var(--line); font-size: 12.5px; transition: border-color .15s; } .wallet:hover { border-color: var(--line3); } .wallet-ic { color: var(--tx3); } .wallet b { font-weight: 600; } .wallet em { font-style: normal; font-size: 10.5px; font-weight: 600; letter-spacing: .02em; color: var(--warn); background: var(--warn-bg); border-radius: 4px; padding: 2px 6px; } .icon-btn { width: 30px; height: 30px; display: grid; place-items: center; border-radius: var(--r); color: var(--tx3); transition: color .15s, background .15s; } .icon-btn:hover { color: var(--tx); background: var(--bg3); } .icon-btn.on { color: var(--acc); background: var(--acc-bg); } #view { position: fixed; inset: var(--top) 0 0 0; overflow: hidden; } .page { height: 100%; overflow: auto; } .page-in { animation: pageIn .22s ease-out; } @keyframes pageIn { from { opacity: 0; transform: translateY(4px); } } /* ---------- common bits ---------- */ .av { position: relative; width: 64px; height: 64px; border-radius: 8px; overflow: hidden; flex: none; background: var(--bg3); } .av img { width: 100%; height: 100%; object-fit: cover; display: block; opacity: 0; transition: opacity .25s; } .av img.ok { opacity: 1; } .av .ph { position: absolute; inset: 0; display: grid; place-items: center; font-weight: 700; font-size: 20px; color: rgba(255, 255, 255, .85); letter-spacing: -.03em; } .chip { display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 6px; border-radius: 4px; font-size: 11.5px; font-weight: 550; color: var(--tx2); background: var(--bg3); white-space: nowrap; } .chip.lv-g { color: var(--up); background: var(--up-bg); } .chip.lv-b { color: var(--down); background: var(--down-bg); } .chip.lv-m { color: var(--warn); background: var(--warn-bg); } .chip .ic { opacity: .9; } .tag { display: inline-flex; align-items: center; height: 17px; padding: 0 5px; border-radius: 3px; font-size: 10px; font-weight: 650; letter-spacing: .03em; text-transform: uppercase; vertical-align: 1px; } .tag.dev { color: #ffd479; background: rgba(255, 212, 121, .12); } .tag.sniper { color: #ff8db5; background: rgba(255, 141, 181, .12); } .tag.bundle { color: #c7a4ff; background: rgba(199, 164, 255, .12); } .tag.you { color: var(--acc); background: var(--acc-bg); } .tag.tracked { color: #8fe3b8; background: rgba(143, 227, 184, .12); } .tag.mig { color: var(--gold); background: rgba(242, 201, 107, .12); } .btn { height: 32px; padding: 0 14px; border-radius: var(--r); background: var(--bg3); border: 1px solid var(--line2); font-weight: 550; display: inline-flex; align-items: center; justify-content: center; gap: 7px; transition: background .15s, border-color .15s, color .15s; white-space: nowrap; } .btn:hover { background: var(--bg4); border-color: var(--line3); } .btn.acc { background: var(--acc); color: #fff; border-color: transparent; } .btn.acc:hover { background: #6aa2ff; } .btn.ghost { background: none; border-color: transparent; color: var(--tx3); } .btn.ghost:hover { color: var(--tx); background: var(--bg3); } .btn.sm { height: 26px; padding: 0 9px; font-size: 12px; } .btn.danger:hover { color: var(--down); border-color: rgba(255, 93, 123, .35); } .inp { height: 32px; padding: 0 10px; border-radius: var(--r); background: var(--bg2); border: 1px solid var(--line2); outline: 0; min-width: 0; transition: border-color .15s; } .inp:focus { border-color: var(--acc2); } .inp::placeholder { color: var(--tx4); } .lbl { font-size: 12px; color: var(--tx3); font-weight: 500; } .empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 48px 20px; color: var(--tx3); text-align: center; } .empty b { color: var(--tx2); font-weight: 600; font-size: 14px; } .trace { width: 120px; height: 32px; color: var(--acc); opacity: .7; } .trace path { stroke-dasharray: 160; animation: trace 2.2s linear infinite; } @keyframes trace { from { stroke-dashoffset: 320; } to { stroke-dashoffset: 0; } } /* ---------- toasts ---------- */ #toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 50; pointer-events: none; } .toast { pointer-events: auto; min-width: 240px; max-width: 420px; padding: 10px 14px; border-radius: 8px; background: rgba(20, 23, 32, .97); border: 1px solid var(--line2); box-shadow: 0 16px 40px rgba(0, 0, 0, .5); font-size: 13px; opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; border-left: 3px solid var(--line3); } .toast.in { opacity: 1; transform: none; } .toast.buy { border-left-color: var(--up); } .toast.sell { border-left-color: var(--down); } .toast.warn { border-left-color: var(--warn); } .toast.info { border-left-color: var(--acc); } .tt { display: block; } .tt-who { font-weight: 650; } /* ---------- modal ---------- */ .modal-back { position: fixed; inset: 0; background: rgba(3, 4, 7, .7); backdrop-filter: blur(3px); z-index: 40; display: grid; place-items: center; animation: fade .15s; } @keyframes fade { from { opacity: 0; } } .modal { width: min(520px, calc(100vw - 32px)); max-height: calc(100vh - 64px); overflow: auto; background: var(--bg1); border: 1px solid var(--line2); border-radius: 12px; box-shadow: 0 30px 80px rgba(0, 0, 0, .6); animation: pop .18s ease-out; } @keyframes pop { from { transform: scale(.98) translateY(6px); opacity: 0; } } .modal-h { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px; border-bottom: 1px solid var(--line); } .modal-h h3 { margin: 0; font-size: 15px; font-weight: 650; } .modal-b { padding: 16px 18px 18px; display: grid; gap: 16px; } .field { display: grid; gap: 6px; } .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .switch { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .tg { width: 34px; height: 20px; border-radius: 20px; background: var(--bg4); position: relative; transition: background .15s; flex: none; } .tg::after { content: ''; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--tx3); transition: transform .15s, background .15s; } .tg.on { background: var(--acc2); } .tg.on::after { transform: translateX(14px); background: #fff; } /* ---------- pulse ---------- */ .pulse { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); height: 100%; } .col { display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--line); background: var(--bg); } .col:last-child { border-right: 0; } .col-h { flex: none; height: 46px; display: flex; align-items: center; gap: 10px; padding: 0 10px 0 16px; border-bottom: 1px solid var(--line); background: var(--bg1); } .col-h h2 { margin: 0; font-size: 14px; font-weight: 650; letter-spacing: -.01em; } .count:empty { display: none; } .count { font: 600 11px var(--fm); color: var(--tx3); background: var(--bg3); border-radius: 4px; padding: 2px 6px; } .paused { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; color: var(--warn); opacity: 0; transition: opacity .15s; } .col.hold .paused { opacity: 1; } .col-tools { margin-left: auto; display: flex; align-items: center; gap: 4px; } .qb-set { display: flex; align-items: center; gap: 5px; height: 28px; padding: 0 8px; border-radius: var(--r); background: var(--bg2); border: 1px solid var(--line2); color: var(--acc); } .qb-set input { width: 42px; border: 0; outline: 0; background: none; font-weight: 600; font-size: 12.5px; color: var(--tx); } .qb-unit { font-size: 11px; font-weight: 600; color: var(--tx4); } .list { flex: 1; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; } .row { position: relative; display: grid; grid-template-columns: 68px minmax(0, 1fr) auto; gap: 12px; padding: 12px 14px 12px 14px; border-bottom: 1px solid var(--line); cursor: pointer; transition: background .12s; content-visibility: auto; contain-intrinsic-size: auto 93px; height: 93px; } .row:hover { background: var(--bg2); } .row.new { animation: rowIn 1.6s ease-out; } @keyframes rowIn { 0% { background: rgba(77, 141, 255, .1); } 100% { background: transparent; } } .ring { position: relative; width: 68px; height: 68px; padding: 2px; border-radius: 10px; background: conic-gradient(var(--ringc, var(--acc)) calc(var(--p, 0) * 1%), var(--line2) 0); } .ring.mig { --ringc: var(--gold); background: var(--gold); } .ring .av { border: 2px solid var(--bg); border-radius: 8px; width: 64px; height: 64px; } .row:hover .ring .av { border-color: var(--bg2); } .badge { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; background: var(--bg1); border: 1.5px solid var(--line3); color: var(--acc); } .ring.mig .badge { color: var(--gold); } .mid { min-width: 0; display: flex; flex-direction: column; justify-content: space-between; padding: 1px 0; } .l1 { display: flex; align-items: baseline; gap: 6px; min-width: 0; } .nm { font-weight: 650; font-size: 14px; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 62%; } .sy { color: var(--tx3); font-weight: 500; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } .cp { color: var(--tx4); align-self: center; opacity: 0; transition: opacity .12s, color .12s; } .row:hover .cp { opacity: 1; } .cp:hover { color: var(--tx); } .l2 { display: flex; align-items: center; gap: 8px; color: var(--tx3); font-size: 12px; height: 18px; } .agev { color: var(--up); font-weight: 600; min-width: 26px; } .agev.old { color: var(--tx2); } .soc { display: flex; align-items: center; gap: 6px; } .soc a { color: var(--tx3); transition: color .12s; display: grid; place-items: center; } .soc a:hover { color: var(--tx); } .soc a.off { color: var(--tx4); opacity: .4; pointer-events: none; } .hc { display: flex; align-items: center; gap: 3px; color: var(--tx2); font-weight: 550; } .l3 { display: flex; gap: 4px; overflow: hidden; height: 20px; } .right { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; min-width: 92px; padding: 1px 0; } .stat { display: flex; align-items: baseline; gap: 5px; white-space: nowrap; } .stat .k { font-size: 11px; color: var(--tx4); font-weight: 600; } .stat .v { font-weight: 650; font-size: 15px; letter-spacing: -.01em; transition: color .5s; } .stat .v.sm { font-size: 12.5px; font-weight: 600; color: var(--tx2); } .v.fl-up { color: var(--up) !important; transition: none; } .v.fl-down { color: var(--down) !important; transition: none; } .txbar { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--tx2); font-weight: 600; } .bar { width: 38px; height: 3px; border-radius: 3px; background: var(--down); overflow: hidden; opacity: .9; } .bar i { display: block; height: 100%; background: var(--up); } .qb { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 9px 0 7px; border-radius: 12px; background: var(--acc-bg); color: var(--acc); font-weight: 650; font-size: 12px; transition: background .12s, color .12s, transform .08s; } .qb:hover { background: var(--acc); color: #fff; } .qb:active { transform: scale(.96); } .hide-btn { position: absolute; top: 8px; right: 8px; width: 22px; height: 22px; border-radius: 5px; display: grid; place-items: center; color: var(--tx4); background: var(--bg3); opacity: 0; transition: opacity .12s, color .12s; } .row:hover .hide-btn { opacity: 1; } .hide-btn:hover { color: var(--tx); } .row .right { transition: transform .12s; } .row:hover .right { transform: translateX(-26px); } .col-empty { height: 100%; } .pop { position: fixed; z-index: 30; width: 300px; background: var(--bg2); border: 1px solid var(--line2); border-radius: 10px; box-shadow: 0 24px 60px rgba(0, 0, 0, .6); padding: 14px; display: grid; gap: 12px; animation: pop .14s ease-out; } .pop h4 { margin: 0; font-size: 13px; font-weight: 650; display: flex; justify-content: space-between; align-items: center; } .pop .two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .pop .inp { height: 30px; width: 100%; } .checks { display: flex; flex-wrap: wrap; gap: 6px; } .ck { height: 26px; padding: 0 10px; border-radius: 13px; border: 1px solid var(--line2); color: var(--tx3); font-size: 12px; font-weight: 550; display: inline-flex; align-items: center; gap: 5px; } .ck.on { color: var(--acc); border-color: rgba(77, 141, 255, .4); background: var(--acc-bg); } .filter-on { color: var(--acc); } /* ---------- coin page ---------- */ .coin { display: grid; grid-template-columns: minmax(0, 1fr) 340px; grid-template-rows: auto minmax(0, 1fr); height: 100%; } .band { grid-column: 1 / -1; display: flex; align-items: center; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--line); background: var(--bg1); min-height: 74px; } .band .av { width: 48px; height: 48px; border-radius: 8px; } .band-id { min-width: 0; display: grid; gap: 3px; } .band-id .l1 { gap: 8px; align-items: center; } .band-id .nm { font-size: 16px; max-width: 300px; } .band-id .l2 { gap: 10px; } .addr { display: inline-flex; align-items: center; gap: 5px; color: var(--tx3); font-family: var(--fm); font-size: 11.5px; cursor: pointer; } .addr:hover { color: var(--tx); } .hero { margin-left: 18px; padding-left: 18px; border-left: 1px solid var(--line2); display: grid; gap: 1px; } .hero .k { font-size: 11.5px; color: var(--tx3); font-weight: 500; } .hero .v { font-size: 22px; font-weight: 700; letter-spacing: -.025em; } .kv { display: grid; gap: 3px; padding: 0 14px; border-left: 1px solid var(--line); } .kv .k { font-size: 11.5px; color: var(--tx3); font-weight: 500; white-space: nowrap; } .kv .v { font-size: 13.5px; font-weight: 600; white-space: nowrap; } sub.z { font-size: .68em; vertical-align: -.25em; margin: 0 .5px; color: var(--tx2); } .band-stats { display: flex; align-items: center; margin-left: 4px; overflow: hidden; } .band-r { margin-left: auto; display: flex; gap: 8px; } .main { display: grid; grid-template-rows: minmax(260px, 1fr) minmax(220px, 38%); min-width: 0; min-height: 0; border-right: 1px solid var(--line); } .chart-wrap { display: flex; flex-direction: column; min-height: 0; border-bottom: 1px solid var(--line); } .chart-bar { flex: none; height: 38px; display: flex; align-items: center; gap: 4px; padding: 0 10px; border-bottom: 1px solid var(--line); } .seg { display: flex; background: var(--bg2); border: 1px solid var(--line); border-radius: var(--r); padding: 2px; gap: 1px; } .seg button { height: 24px; padding: 0 8px; border-radius: 4px; color: var(--tx3); font-weight: 550; font-size: 12px; transition: color .12s, background .12s; } .seg button:hover { color: var(--tx); } .seg button.on { color: var(--tx); background: var(--bg4); } .ohlc { margin-left: 10px; font-size: 11.5px; color: var(--tx3); display: flex; gap: 10px; white-space: nowrap; overflow: hidden; } .ohlc b { color: var(--tx2); font-weight: 600; } .chart { flex: 1; min-height: 0; position: relative; } .chart-note { position: absolute; inset: 0; display: grid; place-items: center; color: var(--tx3); pointer-events: none; z-index: 2; } .tabs { display: flex; flex-direction: column; min-height: 0; } .tabs-h { flex: none; height: 40px; display: flex; align-items: stretch; gap: 2px; padding: 0 10px; border-bottom: 1px solid var(--line); } .tabs-h button { padding: 0 10px; color: var(--tx3); font-weight: 550; position: relative; display: flex; align-items: center; gap: 6px; } .tabs-h button:hover { color: var(--tx); } .tabs-h button.on { color: var(--tx); } .tabs-h button.on::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--acc); } .tabs-h .count { font-size: 10.5px; } .tab-body { flex: 1; overflow: auto; min-height: 0; } table.t { width: 100%; border-collapse: collapse; font-size: 12.5px; } .t th { position: sticky; top: 0; background: var(--bg); z-index: 1; text-align: left; font-weight: 550; color: var(--tx4); font-size: 11.5px; padding: 8px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; } .t td { padding: 0 12px; height: 34px; border-bottom: 1px solid var(--line); white-space: nowrap; } .t tr:hover td { background: var(--bg1); } .t .r { text-align: right; } .t tr.fresh td { animation: rowIn 1.2s ease-out; } .t tr.me td { background: rgba(77, 141, 255, .04); } .t a.w { color: var(--tx2); font-family: var(--fm); font-size: 11.5px; } .t a.w:hover { color: var(--acc); } .t .ext { color: var(--tx4); display: inline-grid; } .t .ext:hover { color: var(--tx); } .side { overflow-y: auto; background: var(--bg); min-height: 0; } .card { border-bottom: 1px solid var(--line); padding: 14px 16px; display: grid; gap: 12px; } .card-h { display: flex; align-items: center; justify-content: space-between; font-size: 12.5px; font-weight: 600; color: var(--tx2); } .bs { display: grid; grid-template-columns: 1fr 1fr; background: var(--bg2); border: 1px solid var(--line); border-radius: 8px; padding: 3px; } .bs button { height: 32px; border-radius: 6px; font-weight: 650; color: var(--tx3); transition: color .12s, background .12s; } .bs button.on.b { background: var(--up-bg); color: var(--up); } .bs button.on.s { background: var(--down-bg); color: var(--down); } .presets { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } .presets button { height: 32px; border-radius: 6px; background: var(--bg2); border: 1px solid var(--line2); font-weight: 600; font-size: 12.5px; color: var(--tx2); transition: border-color .12s, color .12s, background .12s; } .presets button:hover { border-color: var(--line3); color: var(--tx); } .presets button.on { border-color: var(--acc2); color: var(--acc); background: var(--acc-bg); } .amt { display: flex; align-items: center; height: 40px; border-radius: 8px; background: var(--bg2); border: 1px solid var(--line2); padding: 0 12px; gap: 8px; transition: border-color .15s; } .amt:focus-within { border-color: var(--acc2); } .amt input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; font-size: 15px; font-weight: 600; } .amt span { color: var(--tx3); font-weight: 600; font-size: 12.5px; } .go { height: 42px; border-radius: 8px; font-weight: 700; font-size: 14px; letter-spacing: -.01em; transition: filter .12s, transform .08s; } .go.b { background: var(--up); color: #032015; } .go.s { background: var(--down); color: #2a0610; } .go:hover { filter: brightness(1.08); } .go:active { transform: scale(.99); } .quote { display: flex; justify-content: space-between; font-size: 12px; color: var(--tx3); } .quote b { color: var(--tx2); font-weight: 600; } .pos { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } .pos > div { background: var(--bg1); padding: 10px 12px; display: grid; gap: 3px; } .pos .k { font-size: 11.5px; color: var(--tx3); } .pos .v { font-weight: 650; font-size: 14px; } .grid4 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } .cell { background: var(--bg1); border: 1px solid var(--line); border-radius: 8px; padding: 9px 8px 8px; display: grid; gap: 4px; justify-items: center; text-align: center; } .cell .v { font-weight: 650; font-size: 13.5px; } .cell .k { font-size: 11px; color: var(--tx3); display: flex; align-items: center; gap: 4px; } .cell.lv-g .v { color: var(--up); } .cell.lv-b .v { color: var(--down); } .cell.lv-m .v { color: var(--warn); } .prog { display: grid; gap: 7px; } .prog-h { display: flex; justify-content: space-between; font-size: 12px; color: var(--tx3); } .prog-h b { color: var(--tx); font-weight: 650; } .prog-bar { height: 6px; border-radius: 6px; background: var(--bg3); overflow: hidden; } .prog-bar i { display: block; height: 100%; border-radius: 6px; background: linear-gradient(90deg, var(--acc2), var(--acc)); box-shadow: 0 0 12px rgba(77, 141, 255, .5); transition: width .6s ease; } .desc { color: var(--tx2); font-size: 12.5px; line-height: 1.5; word-break: break-word; } .links { display: flex; gap: 6px; flex-wrap: wrap; } .dev-cards { display: grid; gap: 1px; } .dc { display: grid; grid-template-columns: 36px 1fr auto auto; gap: 12px; align-items: center; padding: 8px 14px; border-bottom: 1px solid var(--line); } .dc:hover { background: var(--bg1); } .dc .av { width: 36px; height: 36px; border-radius: 6px; } .dc .ph { font-size: 13px; } .dc .r { text-align: right; display: grid; gap: 2px; font-size: 12px; } /* ---------- portfolio / trackers ---------- */ .wrap { max-width: 1240px; margin: 0 auto; padding: 24px 24px 60px; display: grid; gap: 20px; } .ph-h { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; } .ph-h h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.025em; } .ph-h p { margin: 4px 0 0; color: var(--tx3); } .tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } .tile { background: var(--bg1); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px; display: grid; gap: 6px; } .tile .k { color: var(--tx3); font-size: 12px; font-weight: 500; } .tile .v { font-size: 22px; font-weight: 700; letter-spacing: -.02em; } .tile .s { color: var(--tx3); font-size: 12px; } .panel { background: var(--bg1); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } .panel-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--line); } .panel-h h3 { margin: 0; font-size: 14px; font-weight: 650; } .panel .t th { background: var(--bg1); } .coin-cell { display: flex; align-items: center; gap: 10px; } .coin-cell .av { width: 30px; height: 30px; border-radius: 6px; } .coin-cell .ph { font-size: 12px; } .coin-cell b { font-weight: 600; } .trk { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 16px; align-items: start; } .trk-list { display: grid; } .tw { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--line); } .tw:hover { background: var(--bg2); } .tw .nmw { font-weight: 600; display: flex; align-items: center; gap: 6px; } .tw .acts { display: flex; gap: 2px; opacity: .55; transition: opacity .12s; } .tw:hover .acts { opacity: 1; } .add-form { display: grid; gap: 8px; padding: 14px; border-bottom: 1px solid var(--line); } .add-form .two { display: grid; grid-template-columns: 1fr 120px; gap: 8px; } .feed-row td { height: 40px; } /* ---------- firehose ---------- */ .live-wrap { max-width: 1400px; height: 100%; grid-template-rows: auto auto minmax(0, 1fr); padding-bottom: 24px; } .tape-panel { display: flex; flex-direction: column; min-height: 0; } .tape-tools { display: flex; align-items: center; gap: 8px; } .tape-scroll { flex: 1; overflow: auto; min-height: 0; } .min-sol { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px; border-radius: var(--r); background: var(--bg2); border: 1px solid var(--line); color: var(--tx3); font-size: 12px; font-weight: 550; } .min-sol input { width: 44px; border: 0; outline: 0; background: none; color: var(--tx); font-weight: 600; } .live-state { font-size: 12px; font-weight: 600; color: var(--warn); } .tape td { height: 36px; } .tape .coin-cell { gap: 8px; } .tape .coin-cell .av { width: 26px; height: 26px; border-radius: 5px; } .tape .coin-cell .ph { font-size: 10px; } .tape-name { font-size: 12px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tape tr.big td { background: rgba(255, 191, 87, .045); } .tape tr.big td:nth-child(4) { font-weight: 700; } @media (max-width: 1100px) { .pulse { grid-template-columns: 1fr; overflow: auto; } .col { height: 70vh; } .coin { grid-template-columns: 1fr; grid-template-rows: auto; overflow: auto; } .tiles { grid-template-columns: repeat(2, 1fr); } .trk { grid-template-columns: 1fr; } .search { width: 220px; } } ===== FILE: public/fonts/OFL.txt ===== sha256: 930853ee1daa68554d9e35c8a9175affb74f699fad9a5da6ee5ebe76379d9137 · bytes: 4368 · lines: 92 Copyright (c) 2023 Vercel, in collaboration with basement.studio This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION AND CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ===== FILE: public/img/mark.svg ===== sha256: 6e1afb1525f702acd542da43613464686c066471fb24d178414eeda743aac409 · bytes: 762 · lines: 16 ===== FILE: .env.example ===== sha256: 18928b104a3b082710039cdf62637cabf02bad55b5a0b8439ae9986ddfd9494d · bytes: 1004 · lines: 20 # Optional. Copy to .env and change what you need: OScope runs fine without this file. # Port of the page (if 3000 is taken and you did not set PORT, OScope picks the next free one) PORT=3000 # 127.0.0.1 = only this computer. 0.0.0.0 = also other devices on your network. HOST=127.0.0.1 # Live trade stream. Free, no key. Point it at your own firehose to share one connection. FEED_URL=wss://stream.pumpapi.io/ # OScope passes the stream on at ws://localhost:3000/firehose for your own tools. off = turn it off. FIREHOSE=on # Public IPFS gateways asked for coin pictures (comma separated, each ending in /ipfs/) IPFS_GATEWAYS=https://ipfs.filebase.io/ipfs/,https://4everland.io/ipfs/,https://pump.mypinata.cloud/ipfs/ # Where OScope keeps what it saw between runs DATA_DIR=data # Curve progress (%) at which a coin moves to Final Stretch STRETCH_AT=60 # How long a coin stays in New Pairs (minutes) NEW_MAX_AGE_MIN=60 # A line in the terminal every N seconds with the trade count (0 = off) LOG_EVERY=15 ===== FILE: .gitignore ===== sha256: c8406607266ae22fd29116a2df22231b723b39fe2c8faedabfd83352e98dad9a · bytes: 62 · lines: 7 node_modules/ data/ .env .env.* !.env.example *.log .DS_Store ===== FILE: LICENSE ===== sha256: 30c4934f73fd23f059d3dfba8957a21877e9cdd8cc572d53bb64cf4b9441f135 · bytes: 1076 · lines: 21 MIT License Copyright (c) 2026 OScope contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ===== END OF TEXT FILES ===== Binary files (pictures and fonts, not shown; check them against the manifest): 3cda0d770afbae013cfe9b7ec273e010aaebac0321192197879aa9f9b23a1092 docs/media/coin.jpg (179836 bytes) 8b6d72ab72ded05add3292e73ef283bf20b2ab5f454679b8b1ef477562b129dd docs/media/logo.png (69106 bytes) ff1f38a74800b6f88cc4b2ee4b4c78167d8f62b7da9897ed9adb89f93f78bc1e docs/media/pulse.jpg (309021 bytes) bbce1824c8e263cd7e212dde15525790026440d1c10e60733de9f82958bdada4 docs/media/trailer.webp (3258392 bytes) a369fcf5628ea2aa4e1b9e2ec6a5b3624e365bda588e1f0f2f12b564f728fbb8 public/fonts/Geist-Variable.woff2 (69652 bytes) fba8f577f38a2bbcbe818efa6348dd58f36303a10b8737c42fefad275be563ab public/fonts/GeistMono-Variable.woff2 (71368 bytes)