solana-risk-engine 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.es.md +30 -0
- package/README.md +133 -2
- package/bin/solana-risk.js +65 -0
- package/package.json +45 -4
- package/src/changes.js +105 -0
- package/src/client.js +143 -0
- package/src/confluence.js +120 -0
- package/src/constants.js +55 -0
- package/src/curve.js +34 -0
- package/src/errors.js +33 -0
- package/src/holders.js +189 -0
- package/src/i18n/en.js +106 -0
- package/src/i18n/es.js +109 -0
- package/src/i18n/index.js +77 -0
- package/src/index.js +55 -0
- package/src/markets.js +53 -0
- package/src/pnl.js +148 -0
- package/src/risk.js +229 -0
- package/src/rugcheck.js +66 -0
- package/src/tokens.js +98 -0
- package/src/util.js +29 -0
- package/src/wallet.js +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 panpontomate
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.es.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# solana-risk-engine (español)
|
|
2
|
+
|
|
3
|
+
Es el motor del Escáner de Wallets convertido en librería: análisis de riesgo explicable para tokens y wallets de Solana. No tiene dependencias, funciona en Node 18+ y en el navegador, y usa RPCs gratuitos si no se le da otro.
|
|
4
|
+
|
|
5
|
+
El README principal está en inglés ([README.md](README.md)) porque los grants y la mayoría de desarrolladores lo leen así. Las traducciones al español están en `src/i18n/es.js` y conservan los textos del escáner original.
|
|
6
|
+
|
|
7
|
+
## Uso rápido
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { createScanner, explainLight, lightLabel, levelLabel } from "solana-risk-engine";
|
|
11
|
+
|
|
12
|
+
const scanner = createScanner(); // o createScanner({ rpcUrl: "https://mainnet.helius-rpc.com/?api-key=..." })
|
|
13
|
+
const r = await scanner.analyzeToken("JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN");
|
|
14
|
+
|
|
15
|
+
console.log(levelLabel(r, "es")); // "Riesgo bajo"
|
|
16
|
+
for (const l of r.lights) console.log(lightLabel(l.k, "es"), explainLight(l, "es"));
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Línea de comandos
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npx solana-risk <token-o-wallet>
|
|
23
|
+
npx solana-risk holders <mint> --rpc "https://mainnet.helius-rpc.com/?api-key=..."
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Tests
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm test
|
|
30
|
+
```
|
package/README.md
CHANGED
|
@@ -1,3 +1,134 @@
|
|
|
1
|
-
#
|
|
1
|
+
# solana-risk-engine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Explainable risk analysis for Solana tokens and wallets. **Zero dependencies**, runs in **Node 18+ and the browser**, works with free public RPCs out of the box.
|
|
4
|
+
|
|
5
|
+
It doesn't stop at a single score. Every verdict comes with the **signals** behind it, as stable codes plus the numbers involved. Apps can show them in any language (Spanish and English are included).
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { createScanner, explainLight, lightLabel, levelLabel } from "solana-risk-engine";
|
|
9
|
+
|
|
10
|
+
const scanner = createScanner(); // free PublicNode + Vibe Station, or { rpcUrl }
|
|
11
|
+
const r = await scanner.analyzeToken("7xKX...gAsU");
|
|
12
|
+
|
|
13
|
+
console.log(r.lvl); // "HIGH"
|
|
14
|
+
console.log(r.type); // "CREATOR"
|
|
15
|
+
for (const l of r.lights) // the four traffic lights
|
|
16
|
+
console.log(l.s, lightLabel(l.k, "en"), "-", explainLight(l, "en"));
|
|
17
|
+
// bad Creator control - The creator can mint more tokens (mint authority active)
|
|
18
|
+
// bad Concentration - The largest individual wallet holds 50% and the top 10 hold 60% of the supply
|
|
19
|
+
// bad Market and exit - Very low liquidity ($5,000). Very new market (3 days)
|
|
20
|
+
// ok Dependency - Does not depend on an issuer or a bridge
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What it detects
|
|
24
|
+
|
|
25
|
+
| Check | How |
|
|
26
|
+
|---|---|
|
|
27
|
+
| **Creator control** | Mint/freeze authority still active, and whether it sits in a person's wallet or a program (bridge/protocol). For keys with no account, it checks whether the key is on the ed25519 curve. Risky Token-2022 extensions: permanent delegate, transfer hook, transfer fee, non-transferable, default-frozen accounts. |
|
|
28
|
+
| **Real concentration** | Largest accounts are traced to their owners. Pools and programs are separated from people, so `w1`/`w10` measure *people*, not AMM vaults. |
|
|
29
|
+
| **Inflated liquidity** | DexScreener values both sides of a pool at the pool's price. A pool with 4B of a token against 2 USDC shows "$4,000M liquidity". The engine recomputes it from the real side (USDC/USDT/SOL) only. |
|
|
30
|
+
| **Unlocked LP** | % of LP locked/burned via RugCheck. It only counts for markets under 90 days old, so veteran tokens don't get false positives. |
|
|
31
|
+
| **Impostors** | Tokens using the symbol or name of a famous token (USDC, JUP, BONK, WIF...) with a different address. |
|
|
32
|
+
| **Token type** | `STABLE`, `TOKENIZED`, `BRIDGED`, `CREATOR`, `OPEN`. Each type is judged by the rules that fit it: a bridged token normally has a program as mint authority, a memecoin does not. |
|
|
33
|
+
| **Bots and exchanges** | Wallets that made 200 transactions in under 2 days are marked `active` and are not counted as whales. |
|
|
34
|
+
| **Funder clusters** | The fee payer of each holder's *first* transaction reveals who created it. Holders sharing a funder are grouped, since that is often one team splitting the supply. Exchange hot wallets are excluded as sources. |
|
|
35
|
+
| **Creator history** | Other tokens by the same creator and their risk, rugged flag, and insider networks with today's on-chain balances (RugCheck). |
|
|
36
|
+
| **Wallet PnL** | FIFO realized and unrealized PnL from the last ~300 swaps, valued with historical SOL prices (Helius + CoinGecko). |
|
|
37
|
+
| **Confluence** | Tokens held in common by a group of wallets, each one risk-assessed. Also the reverse: people who are top holders of several tokens. |
|
|
38
|
+
| **Change tracking** | Snapshots and diffs (new / bought more / sold part / sold all) per wallet and aggregated across many wallets. |
|
|
39
|
+
|
|
40
|
+
Anything that could not be checked is marked `na`. It never counts as "fine": a token with a missing check can be **medium** at best, and `dataOnly(r)` tells you when that is the only reason.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm install solana-risk-engine
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
### `createScanner(options)`
|
|
51
|
+
|
|
52
|
+
| Option | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `rpcUrl` | Your own RPC (Helius, Triton, QuickNode...). Without it, PublicNode is used, with Solana Vibe Station (rate-limited queue) for the methods PublicNode blocks. |
|
|
55
|
+
| `heliusApiKey` | For PnL. Taken from `rpcUrl` automatically when that is a Helius URL. It is never sent anywhere else. |
|
|
56
|
+
| `signal` | An `AbortSignal`. Aborting rejects pending work with `ScanError` code `CANCELLED`. |
|
|
57
|
+
| `fetch` | Custom fetch (tests, proxies). |
|
|
58
|
+
|
|
59
|
+
Methods (all accept `{ onProgress(step, fraction) }`):
|
|
60
|
+
|
|
61
|
+
| Method | Returns |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `analyzeToken(mint)` | `{ lvl, sev, type, lights, reasons, impostor, pair, ... }` |
|
|
64
|
+
| `analyzeWallet(wallet, { minUsd })` | `{ sol, balances, total, hidden, results: [token results] }` |
|
|
65
|
+
| `analyzeAddress(addr)` | `{ kind: "token" \| "wallet", ... }`, detected automatically |
|
|
66
|
+
| `analyzeHolders(mint)` | `{ rows, clusters, hotGroups, creator, ... }`. Best with your own RPC. |
|
|
67
|
+
| `estimatePnl(wallets)` | `{ pnl: { [wallet]: { realized, unreal, total, ret, st, open } } }`. Needs Helius. |
|
|
68
|
+
| `takeSnapshots(wallets)` | Today's holdings. Store them yourself and pass them to `diffSnapshots(prev, next)`. |
|
|
69
|
+
| `confluence(wallets, { pct, withPnl, pnlFilter })` | Tokens held in common, with risk. |
|
|
70
|
+
| `commonHolders(mints, { min })` | Wallets that are top holders of several tokens. |
|
|
71
|
+
|
|
72
|
+
Every lower-level function is exported too (`buildLights`, `levelFrom`, `realLiq`, `clusterByFunder`, `chgDiff`, `pnlFromTrades`...). Most are pure functions that are easy to reuse and test.
|
|
73
|
+
|
|
74
|
+
### Results
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
{
|
|
78
|
+
lvl: "HIGH" | "MEDIUM" | "LOW" | "SPAM" | "NO_DATA" | "NO_MARKET",
|
|
79
|
+
type: "STABLE" | "TOKENIZED" | "BRIDGED" | "CREATOR" | "OPEN" | null,
|
|
80
|
+
lights: [ // null for tokens without a market
|
|
81
|
+
{ k: "control" | "concentration" | "market" | "dependency",
|
|
82
|
+
s: "ok" | "na" | "warn" | "bad",
|
|
83
|
+
items: [{ code: "MKT_LIQ_INFLATED", s: "bad", data: { reported: 4e9, real: 2 } }] }
|
|
84
|
+
],
|
|
85
|
+
reasons: [ ...every non-ok signal ],
|
|
86
|
+
sev: 6 // for sorting within a level
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Texts and languages
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import { describe, explainLight, levelLabel, typeInfo, describeStep, describeError, addLanguage } from "solana-risk-engine";
|
|
94
|
+
|
|
95
|
+
describe({ code: "MKT_LIQ_VERY_LOW", data: { liq: 3000 } }, "es"); // "Liquidez muy baja (3.000 $)"
|
|
96
|
+
describeError(err, "en", { ownRpc: true }); // friendly message for any ScanError
|
|
97
|
+
addLanguage("pt", { signals: { ... }, levels: { ... } }); // same shape as src/i18n/en.js
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Errors
|
|
101
|
+
|
|
102
|
+
The engine throws `ScanError` with a stable `code`: `CANCELLED`, `HTTP`, `NETWORK`, `RPC`, `DEX_UNREACHABLE`, `HELIUS_REQUIRED`, `SOL_PRICE`, `INVALID_ADDRESS`, `NOT_A_TOKEN`, `IS_NFT`, `NO_HOLDERS`, `TOO_FEW_WALLETS`, `TOO_FEW_TOKENS`.
|
|
103
|
+
|
|
104
|
+
Optional extras (RugCheck) never throw. If they fail, the analysis goes on without them.
|
|
105
|
+
|
|
106
|
+
## CLI
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
npx solana-risk <token-or-wallet> --lang en
|
|
110
|
+
npx solana-risk holders <mint> --rpc "https://mainnet.helius-rpc.com/?api-key=..."
|
|
111
|
+
npx solana-risk <mint> --json
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Data sources
|
|
115
|
+
|
|
116
|
+
- Solana RPC: [PublicNode](https://solana-rpc.publicnode.com), [Solana Vibe Station](https://solanavibestation.com), or yours
|
|
117
|
+
- [DexScreener](https://dexscreener.com): pairs, price, liquidity, volume
|
|
118
|
+
- [RugCheck](https://rugcheck.xyz): LP lock, creator, insider networks (optional)
|
|
119
|
+
- [Helius](https://helius.dev): parsed swap history for PnL (optional, your key)
|
|
120
|
+
- [CoinGecko](https://coingecko.com): SOL price history for PnL
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
npm test # node --test, no network needed: the network is mocked
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Disclaimer
|
|
129
|
+
|
|
130
|
+
This is a heuristic tool, not financial advice. A "low risk" result means the engine found none of the signals it looks for. It does not mean the token is safe.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Minimal command line scanner:
|
|
3
|
+
// node examples/cli.js <token-or-wallet> [--lang en] [--rpc https://...] [--json]
|
|
4
|
+
// node examples/cli.js holders <mint> --rpc https://mainnet.helius-rpc.com/?api-key=...
|
|
5
|
+
import {
|
|
6
|
+
createScanner, describe, explainLight, lightLabel, levelLabel, typeInfo, describeStep, describeError, format
|
|
7
|
+
} from "../src/index.js";
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
function opt(name) { const i = args.indexOf("--" + name); return i >= 0 ? args.splice(i, 2)[1] : undefined; }
|
|
11
|
+
function flag(name) { const i = args.indexOf("--" + name); if (i >= 0) { args.splice(i, 1); return true; } return false; }
|
|
12
|
+
|
|
13
|
+
const lang = opt("lang") || "es";
|
|
14
|
+
const rpcUrl = opt("rpc") || process.env.SOLANA_RPC_URL;
|
|
15
|
+
const asJson = flag("json");
|
|
16
|
+
const mode = args[0] === "holders" ? args.shift() : "address";
|
|
17
|
+
const addr = args[0];
|
|
18
|
+
|
|
19
|
+
if (!addr) {
|
|
20
|
+
console.error("usage: solana-risk <token-or-wallet> [--lang es|en] [--rpc URL] [--json]\n solana-risk holders <mint> [--rpc URL]");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ctrl = new AbortController();
|
|
25
|
+
process.on("SIGINT", () => ctrl.abort());
|
|
26
|
+
const scanner = createScanner({ rpcUrl: rpcUrl, signal: ctrl.signal });
|
|
27
|
+
const f = format(lang);
|
|
28
|
+
const onProgress = asJson ? undefined : (p, frac) => process.stderr.write("\r" + Math.round(frac * 100) + "% " + describeStep(p, lang) + "\x1b[K");
|
|
29
|
+
const ICON = { ok: "🟢", na: "⚪", warn: "🟡", bad: "🔴" };
|
|
30
|
+
|
|
31
|
+
function printToken(r) {
|
|
32
|
+
console.log(`\n${r.sym}${r.name ? " · " + r.name : ""} — ${levelLabel(r, lang)}`);
|
|
33
|
+
const t = typeInfo(r.type, lang);
|
|
34
|
+
if (t) console.log(`${t.label}: ${t.note}`);
|
|
35
|
+
if (r.lights) r.lights.forEach((l) => console.log(` ${ICON[l.s]} ${lightLabel(l.k, lang)}: ${explainLight(l, lang)}`));
|
|
36
|
+
else r.reasons.forEach((x) => console.log(` • ${describe(x, lang)}`));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (mode === "holders") {
|
|
41
|
+
const h = await scanner.analyzeHolders(addr, { onProgress });
|
|
42
|
+
if (onProgress) process.stderr.write("\r\x1b[K");
|
|
43
|
+
if (asJson) { console.log(JSON.stringify(h, null, 2)); process.exit(0); }
|
|
44
|
+
console.log(`\n${h.sym} · top holders`);
|
|
45
|
+
h.rows.slice(0, 20).forEach((r) => {
|
|
46
|
+
const c = h.clusters[r.owner];
|
|
47
|
+
console.log(` #${r.rank} ${f.short(r.owner)} ${r.pct.toFixed(2)}% ${f.usd(r.value)} ${r.kind}${c ? " group " + c.id + " (" + c.n + ")" : ""}`);
|
|
48
|
+
});
|
|
49
|
+
if (h.creator) console.log(`\ncreator ${f.short(h.creator.addr)} · other tokens: ${h.creator.others.length}${h.creator.rugged ? " · RUGGED" : ""}`);
|
|
50
|
+
} else {
|
|
51
|
+
const r = await scanner.analyzeAddress(addr, { onProgress });
|
|
52
|
+
if (onProgress) process.stderr.write("\r\x1b[K");
|
|
53
|
+
if (asJson) { console.log(JSON.stringify(r, null, 2)); process.exit(0); }
|
|
54
|
+
if (r.kind === "token") printToken(r.token);
|
|
55
|
+
else {
|
|
56
|
+
const w = r.wallet;
|
|
57
|
+
console.log(`\nwallet ${f.short(w.wallet)} · ${w.sol.toFixed(3)} SOL · ${w.total} tokens (${w.hidden} dust hidden)`);
|
|
58
|
+
w.results.forEach(printToken);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (onProgress) process.stderr.write("\r\x1b[K");
|
|
63
|
+
console.error(describeError(e, lang, { ownRpc: !!rpcUrl }));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solana-risk-engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Explainable risk analysis for Solana tokens and wallets: creator control, real holder concentration, inflated liquidity, impostor tokens, funder clusters and PnL. Zero dependencies, runs in Node and the browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./i18n": "./src/i18n/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"bin"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"solana-risk": "bin/solana-risk.js"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/*.test.js",
|
|
22
|
+
"example": "node bin/solana-risk.js"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"solana",
|
|
29
|
+
"risk",
|
|
30
|
+
"rug-pull",
|
|
31
|
+
"scam-detection",
|
|
32
|
+
"token",
|
|
33
|
+
"spl-token",
|
|
34
|
+
"token-2022",
|
|
35
|
+
"holders",
|
|
36
|
+
"dexscreener",
|
|
37
|
+
"rugcheck"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"author": "panpontomate",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/panpontomate/solana-risk-engine.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/panpontomate/solana-risk-engine#readme",
|
|
46
|
+
"bugs": "https://github.com/panpontomate/solana-risk-engine/issues"
|
|
47
|
+
}
|
package/src/changes.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { USDC, USDT, WSOL } from "./constants.js";
|
|
2
|
+
import { ScanError, isCancel } from "./errors.js";
|
|
3
|
+
import { pool, progress, short } from "./util.js";
|
|
4
|
+
import { getTokens } from "./tokens.js";
|
|
5
|
+
import { getMarkets } from "./markets.js";
|
|
6
|
+
|
|
7
|
+
// Stablecoins and SOL move all the time for normal reasons: they are not "what this wallet bought"
|
|
8
|
+
const SKIP = { [USDC]: 1, [USDT]: 1, [WSOL]: 1 };
|
|
9
|
+
|
|
10
|
+
// A snapshot is {mint: {q: amount, v: usd value, s: symbol}} with only priced tokens worth >= $1.
|
|
11
|
+
// A low cap made a token from the tail reappear as "new" on the next check just for having left
|
|
12
|
+
// the picture; 300 entries (a few KB) covers almost any real wallet.
|
|
13
|
+
export function capSnap(h, max) {
|
|
14
|
+
const ks = Object.keys(h).sort(function (a, b) { return h[b].v - h[a].v; });
|
|
15
|
+
const out = {};
|
|
16
|
+
ks.slice(0, max || 300).forEach(function (k) { out[k] = h[k]; });
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Changes between two snapshots of one wallet: new | more | less | sold, with the USD value involved
|
|
21
|
+
export function chgDiff(prev, now) {
|
|
22
|
+
const out = [];
|
|
23
|
+
Object.keys(now).forEach(function (m) {
|
|
24
|
+
const n = now[m], p = prev[m];
|
|
25
|
+
if (!p) { if (n.v >= 20) out.push({ k: "new", m: m, s: n.s, v: n.v }); return; }
|
|
26
|
+
if (n.q < p.q * 0.05) return; // treated as "sold"
|
|
27
|
+
const price = n.v / n.q;
|
|
28
|
+
if (n.q >= p.q * 1.25 && (n.q - p.q) * price >= 50) out.push({ k: "more", m: m, s: n.s, v: (n.q - p.q) * price });
|
|
29
|
+
else if (n.q <= p.q * 0.75 && (p.q - n.q) * price >= 50) out.push({ k: "less", m: m, s: n.s, v: (p.q - n.q) * price });
|
|
30
|
+
});
|
|
31
|
+
Object.keys(prev).forEach(function (m) {
|
|
32
|
+
const p = prev[m], n = now[m];
|
|
33
|
+
if (p.v >= 20 && (!n || n.q < p.q * 0.05)) out.push({ k: "sold", m: m, s: p.s, v: p.v });
|
|
34
|
+
});
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Across many wallets: which tokens most of them bought and which they sold.
|
|
39
|
+
// rows = [{ch: chgDiff(...)}]; each wallet counts once per token and side.
|
|
40
|
+
export function chgAgg(rows) {
|
|
41
|
+
const buy = {}, sell = {};
|
|
42
|
+
rows.forEach(function (r) {
|
|
43
|
+
const seenB = {}, seenS = {};
|
|
44
|
+
r.ch.forEach(function (c) {
|
|
45
|
+
const up = (c.k === "new" || c.k === "more"), M = up ? buy : sell, seen = up ? seenB : seenS;
|
|
46
|
+
const o = M[c.m] || (M[c.m] = { m: c.m, s: c.s, n: 0, v: 0 });
|
|
47
|
+
o.v += c.v;
|
|
48
|
+
if (!seen[c.m]) { o.n++; seen[c.m] = 1; }
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
function list(M) { return Object.keys(M).map(function (k) { return M[k]; }).sort(function (a, b) { return b.n - a.n || b.v - a.v; }); }
|
|
52
|
+
return { buy: list(buy), sell: list(sell) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Today's snapshot of each wallet. Store it wherever you like (browser, database) and pass the stored
|
|
57
|
+
* one back to diffSnapshots next time.
|
|
58
|
+
*
|
|
59
|
+
* @returns {ts, snapshots: {wallet: snapshot}, failed}
|
|
60
|
+
*/
|
|
61
|
+
export async function takeSnapshots(client, addrs, opts) {
|
|
62
|
+
opts = opts || {};
|
|
63
|
+
const onProgress = opts.onProgress;
|
|
64
|
+
const cur = {};
|
|
65
|
+
let failed = 0, d = 0;
|
|
66
|
+
await pool(addrs, 3, async function (a) {
|
|
67
|
+
try { cur[a] = (await getTokens(client, a)).tokens; } catch (e) { if (isCancel(e)) throw e; failed++; }
|
|
68
|
+
d++; progress(onProgress, "wallets", 0.05 + 0.4 * d / addrs.length, { done: d, total: addrs.length });
|
|
69
|
+
});
|
|
70
|
+
const ok = addrs.filter(function (a) { return cur[a]; });
|
|
71
|
+
if (!ok.length) throw new ScanError("TOO_FEW_WALLETS", "could not read any wallet", { n: 0, total: addrs.length });
|
|
72
|
+
const set = {};
|
|
73
|
+
ok.forEach(function (a) { Object.keys(cur[a]).forEach(function (m) { if (!SKIP[m]) set[m] = 1; }); });
|
|
74
|
+
progress(onProgress, "markets", 0.5);
|
|
75
|
+
const ms = Object.keys(set);
|
|
76
|
+
const mkr = ms.length ? await getMarkets(client, ms, function (dn, n) { progress(onProgress, "markets", 0.5 + 0.45 * dn / n, { done: dn, total: n }); }) : { data: {}, failed: [] };
|
|
77
|
+
// A partial failure reading prices cannot be treated as "sold everything": better to abort and retry
|
|
78
|
+
if (mkr.failed.length) throw new ScanError("DEX_UNREACHABLE", "some prices could not be read");
|
|
79
|
+
const mk = mkr.data, snapshots = {};
|
|
80
|
+
ok.forEach(function (a) {
|
|
81
|
+
const now = {};
|
|
82
|
+
Object.keys(cur[a]).forEach(function (m) {
|
|
83
|
+
if (SKIP[m]) return;
|
|
84
|
+
const pair = mk[m], price = Number((pair && pair.priceUsd) || 0), q = cur[a][m], v = q * price;
|
|
85
|
+
if (price > 0 && v >= 1) now[m] = { q: q, v: Math.round(v * 100) / 100, s: (pair.baseToken && pair.baseToken.symbol) || short(m) };
|
|
86
|
+
});
|
|
87
|
+
snapshots[a] = capSnap(now);
|
|
88
|
+
});
|
|
89
|
+
progress(onProgress, "done", 1);
|
|
90
|
+
return { ts: Date.now(), snapshots: snapshots, failed: failed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param prev {wallet: {ts, h: snapshot}} what you stored last time (missing wallets = first check)
|
|
95
|
+
* @param next result of takeSnapshots
|
|
96
|
+
* @returns {rows: [{a, first, since, ch}], agg: chgAgg(rows that had a previous snapshot)}
|
|
97
|
+
*/
|
|
98
|
+
export function diffSnapshots(prev, next) {
|
|
99
|
+
prev = prev || {};
|
|
100
|
+
const rows = Object.keys(next.snapshots).map(function (a) {
|
|
101
|
+
const p = prev[a];
|
|
102
|
+
return { a: a, first: !p, since: p ? p.ts : 0, ch: p ? chgDiff(p.h, next.snapshots[a]) : [] };
|
|
103
|
+
});
|
|
104
|
+
return { rows: rows, agg: chgAgg(rows.filter(function (r) { return !r.first; })) };
|
|
105
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { PUBLIC_RPC, VIBE_RPC } from "./constants.js";
|
|
2
|
+
import { ScanError, cancelled, isCancel } from "./errors.js";
|
|
3
|
+
import { sleep } from "./util.js";
|
|
4
|
+
|
|
5
|
+
// Methods PublicNode rejects with 403; if another one shows up it gets added at runtime
|
|
6
|
+
const PN_BLOCKED_DEFAULT = ["getTokenLargestAccounts", "getTokenAccountsByOwner", "getTokenSupply"];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates the network client every engine function uses.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} [opts]
|
|
12
|
+
* @param {string} [opts.rpcUrl] your own RPC URL (Helius, Triton...). Without it the free
|
|
13
|
+
* PublicNode + Solana Vibe Station combination is used.
|
|
14
|
+
* @param {string} [opts.heliusApiKey] Helius key for PnL. If omitted it is taken from rpcUrl when
|
|
15
|
+
* that URL is a Helius one (the key is never sent anywhere else).
|
|
16
|
+
* @param {AbortSignal} [opts.signal] aborts every pending request with a CANCELLED ScanError
|
|
17
|
+
* @param {Function} [opts.fetch] fetch implementation (defaults to globalThis.fetch)
|
|
18
|
+
* @param {number} [opts.retryDelayMs] first retry delay, doubled each time (default 1200)
|
|
19
|
+
* @param {number} [opts.vibeIntervalMs] spacing of the free Vibe Station queue (default 1100)
|
|
20
|
+
*/
|
|
21
|
+
export function createClient(opts) {
|
|
22
|
+
opts = opts || {};
|
|
23
|
+
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
24
|
+
if (!fetchImpl) throw new Error("no fetch available: pass opts.fetch or use Node 18+");
|
|
25
|
+
const rpcUrl = (opts.rpcUrl || "").trim() || PUBLIC_RPC;
|
|
26
|
+
const publicRpc = opts.publicRpc || PUBLIC_RPC;
|
|
27
|
+
const vibeRpc = opts.vibeRpc || VIBE_RPC;
|
|
28
|
+
const signal = opts.signal || null;
|
|
29
|
+
const retryDelay = opts.retryDelayMs === undefined ? 1200 : opts.retryDelayMs;
|
|
30
|
+
const vibeInterval = opts.vibeIntervalMs === undefined ? 1100 : opts.vibeIntervalMs;
|
|
31
|
+
const blocked = {};
|
|
32
|
+
PN_BLOCKED_DEFAULT.forEach(function (m) { blocked[m] = 1; });
|
|
33
|
+
let vibeNext = 0;
|
|
34
|
+
|
|
35
|
+
function checkAbort() {
|
|
36
|
+
if (signal && signal.aborted) throw cancelled();
|
|
37
|
+
}
|
|
38
|
+
function ownRpc() {
|
|
39
|
+
return rpcUrl !== publicRpc;
|
|
40
|
+
}
|
|
41
|
+
function heliusKey() {
|
|
42
|
+
if (opts.heliusApiKey) return opts.heliusApiKey;
|
|
43
|
+
// Only use the key if the configured RPC really is Helius: otherwise it is never sent to api.helius.xyz
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(rpcUrl);
|
|
46
|
+
if (!/(^|\.)helius-rpc\.com$|(^|\.)helius\.xyz$/i.test(u.hostname)) return null;
|
|
47
|
+
return u.searchParams.get("api-key") || null;
|
|
48
|
+
} catch (e) { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function rawFetch(url, init) {
|
|
52
|
+
checkAbort();
|
|
53
|
+
try {
|
|
54
|
+
return await fetchImpl(url, signal ? Object.assign({}, init, { signal: signal }) : init);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (signal && signal.aborted) throw cancelled();
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// JSON fetch with retries on 429, 5xx and network errors (doubling the wait each time)
|
|
62
|
+
async function jfetch(url, init, tries) {
|
|
63
|
+
let wait = retryDelay, last = null;
|
|
64
|
+
tries = tries || 4;
|
|
65
|
+
for (let i = 0; i < tries; i++) {
|
|
66
|
+
checkAbort();
|
|
67
|
+
try {
|
|
68
|
+
const r = await rawFetch(url, init);
|
|
69
|
+
if (r.status === 429 || r.status >= 500) {
|
|
70
|
+
last = new ScanError("HTTP", "HTTP " + r.status, { status: r.status });
|
|
71
|
+
await sleep(wait); wait *= 2; continue;
|
|
72
|
+
}
|
|
73
|
+
if (!r.ok) throw new ScanError("HTTP", "HTTP " + r.status, { status: r.status });
|
|
74
|
+
return await r.json();
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (isCancel(e) || (e.code === "HTTP" && e.data.status >= 400 && e.data.status < 500)) throw e;
|
|
77
|
+
last = e; await sleep(wait); wait *= 2;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (last && last.code) throw last;
|
|
81
|
+
throw new ScanError("NETWORK", (last && last.message) || "no response", { cause: last && last.message });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fetch that never throws (except on cancel): used for optional extras such as RugCheck,
|
|
85
|
+
// which must never block the main result if they fail, time out or are down
|
|
86
|
+
async function softJson(url, timeoutMs) {
|
|
87
|
+
checkAbort();
|
|
88
|
+
const ctrl = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
89
|
+
let onAbort = null;
|
|
90
|
+
if (ctrl && signal) { onAbort = function () { ctrl.abort(); }; signal.addEventListener("abort", onAbort); }
|
|
91
|
+
const to = ctrl ? setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000) : null;
|
|
92
|
+
try {
|
|
93
|
+
const r = await fetchImpl(url, ctrl ? { signal: ctrl.signal } : undefined);
|
|
94
|
+
if (!r.ok) return null;
|
|
95
|
+
return await r.json();
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return null;
|
|
98
|
+
} finally {
|
|
99
|
+
if (to) clearTimeout(to);
|
|
100
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
101
|
+
checkAbort();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function rpcAt(url, method, params, tries) {
|
|
106
|
+
const j = await jfetch(url, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: method, params: params })
|
|
110
|
+
}, tries);
|
|
111
|
+
if (j.error) throw new ScanError("RPC", j.error.message || "RPC error", { rpcCode: j.error.code });
|
|
112
|
+
return j.result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function vibeSlot() {
|
|
116
|
+
const now = Date.now(), at = Math.max(now, vibeNext);
|
|
117
|
+
vibeNext = at + vibeInterval;
|
|
118
|
+
if (at > now) await sleep(at - now);
|
|
119
|
+
checkAbort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function rpc(method, params) {
|
|
123
|
+
if (ownRpc()) return rpcAt(rpcUrl, method, params);
|
|
124
|
+
// PublicNode accepts at most 10 accounts per getMultipleAccounts: split and merge the result
|
|
125
|
+
if (method === "getMultipleAccounts" && params[0].length > 10) {
|
|
126
|
+
const parts = [];
|
|
127
|
+
for (let i = 0; i < params[0].length; i += 10) parts.push(rpc(method, [params[0].slice(i, i + 10)].concat(params.slice(1))));
|
|
128
|
+
const rs = await Promise.all(parts);
|
|
129
|
+
return { context: rs[0].context, value: [].concat.apply([], rs.map(function (r) { return r.value; })) };
|
|
130
|
+
}
|
|
131
|
+
if (!blocked[method]) {
|
|
132
|
+
try { return await rpcAt(publicRpc, method, params); }
|
|
133
|
+
catch (e) {
|
|
134
|
+
if (isCancel(e) || !(e.code === "HTTP" && (e.data.status === 401 || e.data.status === 403))) throw e;
|
|
135
|
+
blocked[method] = 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await vibeSlot();
|
|
139
|
+
return rpcAt(vibeRpc, method, params, 6); // more retries: its limit sometimes trips even when the queue is respected
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { rpc: rpc, jfetch: jfetch, softJson: softJson, ownRpc: ownRpc, heliusKey: heliusKey, checkAbort: checkAbort, rpcUrl: rpcUrl };
|
|
143
|
+
}
|