solana-risk-engine 0.0.0-stage → 0.2.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/src/risk.js ADDED
@@ -0,0 +1,234 @@
1
+ import { EXT_POINTS, RANKS } from "./constants.js";
2
+ import { isCancel } from "./errors.js";
3
+ import { short, sleep } from "./util.js";
4
+ import { getMint, getAuthKinds, getConc } from "./tokens.js";
5
+ import { impostorOf } from "./markets.js";
6
+ import { rugcheckSummary } from "./rugcheck.js";
7
+
8
+ // Every finding is a "signal": {code, s, data}. `s` is ok | na | warn | bad, `data` holds the numbers
9
+ // behind it. Turning signals into sentences is i18n's job (describe / explainLight), never the engine's.
10
+ function sig(code, s, data) {
11
+ const o = { code: code, s: s };
12
+ if (data) o.data = data;
13
+ return o;
14
+ }
15
+
16
+ const DAY = 86400000;
17
+
18
+ /* ---------- old point score: only for tokens without a market, to spot spam ---------- */
19
+ export function score(info) {
20
+ let pts = 0;
21
+ const why = [];
22
+ if (!info) return { pts: 30, why: [sig("NO_INFO", "warn")] };
23
+ const ak = info.authKind || {};
24
+ if (info.mintAuth) {
25
+ if (ak[info.mintAuth] === "program") { pts += 10; why.push(sig("MINT_BY_PROGRAM", "warn")); }
26
+ else { pts += 25; why.push(sig("MINT_ACTIVE", "bad", { byWallet: ak[info.mintAuth] === "wallet" })); }
27
+ }
28
+ if (info.freezeAuth) {
29
+ if (ak[info.freezeAuth] === "program") { pts += 8; why.push(sig("FREEZE_BY_PROGRAM", "warn")); }
30
+ else { pts += 20; why.push(sig("FREEZE_ACTIVE", "warn", { byWallet: ak[info.freezeAuth] === "wallet" })); }
31
+ }
32
+ info.ext.forEach(function (e) { if (EXT_POINTS[e]) { pts += EXT_POINTS[e]; why.push(sig("EXTENSION", "warn", { ext: e })); } });
33
+ why.push(sig("NO_MARKET", "na"));
34
+ return { pts: pts, why: why };
35
+ }
36
+
37
+ /* ---------- token type ---------- */
38
+ export function tokenType(info, pair, now) {
39
+ if (!info) return null;
40
+ now = now || Date.now();
41
+ const ak = info.authKind || {};
42
+ const sym = ((pair && pair.baseToken && pair.baseToken.symbol) || "") + " " + ((pair && pair.baseToken && pair.baseToken.name) || "");
43
+ const price = Number((pair && pair.priceUsd) || 0);
44
+ const liq = (pair && pair.liquidity && pair.liquidity.usd) || 0;
45
+ const ageDays = (pair && pair.pairCreatedAt) ? (now - pair.pairCreatedAt) / DAY : 0;
46
+ // Being called "USD something" is not enough to be judged more leniently than a normal token:
47
+ // it needs a stable price, real liquidity and some time trading (otherwise a scammer only has to
48
+ // name the token "USDX" and keep the price near $1 for a while)
49
+ if (/USD|DOLLAR/i.test(sym) && price > 0.97 && price < 1.03 && liq >= 200000 && ageDays >= 30) return "STABLE";
50
+ if (info.ext.indexOf("permanentDelegate") >= 0) return "TOKENIZED";
51
+ const walletAuth = (info.mintAuth && ak[info.mintAuth] !== "program") || (info.freezeAuth && ak[info.freezeAuth] !== "program");
52
+ if (walletAuth) return "CREATOR";
53
+ if (info.mintAuth || info.freezeAuth) return "BRIDGED";
54
+ return "OPEN";
55
+ }
56
+
57
+ /* ---------- the four traffic lights ---------- */
58
+ function mkLight(k, items, okSignal) {
59
+ if (!items.length) return { k: k, s: "ok", items: [okSignal] };
60
+ let st = "ok";
61
+ items.forEach(function (x) { if (RANKS[x.s] > RANKS[st]) st = x.s; });
62
+ // na ranks like ok for "worst of", but an unchecked light must stay na so levelFrom can cap it
63
+ if (st === "ok" && items.some(function (x) { return x.s === "na"; })) st = "na";
64
+ return { k: k, s: st, items: items };
65
+ }
66
+
67
+ /**
68
+ * @param info mint info from getMint (+ authKind) or null if it could not be read
69
+ * @param conc concentration from getConc, {skip:true}, or null if the RPC did not answer
70
+ * @param pair best DexScreener pair (required: lights are only built for tokens with a market)
71
+ * @param value USD value the analysed wallet holds (0 when checking a token alone)
72
+ * @param type tokenType(info, pair)
73
+ * @param rc RugCheck summary or null
74
+ * @returns [control, concentration, market, dependency]
75
+ */
76
+ export function buildLights(info, conc, pair, value, type, rc, now) {
77
+ now = now || Date.now();
78
+ const ak = (info && info.authKind) || {};
79
+ let it;
80
+
81
+ // 1) creator control
82
+ it = [];
83
+ if (!info) it.push(sig("NO_INFO", "warn"));
84
+ else {
85
+ if (info.mintAuth && ak[info.mintAuth] !== "program") it.push(sig("CTL_MINT_ACTIVE", "bad"));
86
+ if (info.freezeAuth && ak[info.freezeAuth] !== "program") it.push(sig("CTL_FREEZE_ACTIVE", "warn"));
87
+ if (info.ext.indexOf("transferHook") >= 0) it.push(sig("CTL_TRANSFER_HOOK", "warn"));
88
+ if (info.ext.indexOf("transferFeeConfig") >= 0) it.push(sig("CTL_TRANSFER_FEE", "warn"));
89
+ if (info.ext.indexOf("nonTransferable") >= 0) it.push(sig("CTL_NON_TRANSFERABLE", "warn"));
90
+ if (info.ext.indexOf("defaultAccountState") >= 0) it.push(sig("CTL_DEFAULT_FROZEN", "warn"));
91
+ }
92
+ const control = mkLight("control", it, sig((info && (info.mintAuth || info.freezeAuth)) ? "CTL_ONLY_PROGRAMS" : "CTL_NONE", "ok"));
93
+
94
+ // 2) concentration in people (pools and programs do not count)
95
+ let concentration;
96
+ const soft = (type === "STABLE" || type === "TOKENIZED" || type === "BRIDGED");
97
+ if (conc && conc.skip) concentration = mkLight("concentration", [sig("CON_SKIPPED", "na")]);
98
+ else if (!conc) concentration = mkLight("concentration", [sig("CON_UNAVAILABLE", "na")]);
99
+ else if (conc.ok) {
100
+ let st = "ok";
101
+ if (conc.w1 > 30 || conc.w10 > 70) st = "bad"; else if (conc.w1 > 15 || conc.w10 > 50) st = "warn";
102
+ if (soft && st === "bad") st = "warn";
103
+ const data = { w1: conc.w1, w10: conc.w10, prog: conc.prog, wallets: conc.nW }; // prog >= 20 is worth mentioning
104
+ const code = !conc.nW ? "CON_ALL_POOLS" : (st === "ok" ? "CON_OK" : "CON_HIGH");
105
+ concentration = mkLight("concentration", [sig(code, st, data)]);
106
+ } else {
107
+ concentration = mkLight("concentration", [sig("CON_UNSPLIT", (conc.top1 > 30 || conc.top10 > 70) ? "warn" : "ok", { top1: conc.top1, top10: conc.top10 })]);
108
+ }
109
+
110
+ // 3) market and exit
111
+ const liq = (pair.liquidity && pair.liquidity.usd) || 0, vol = (pair.volume && pair.volume.h24) || 0;
112
+ it = [];
113
+ if (pair.liqRaw) it.push(sig("MKT_LIQ_INFLATED", "bad", { reported: pair.liqRaw, real: liq }));
114
+ else if (liq < 10000) it.push(sig("MKT_LIQ_VERY_LOW", "bad", { liq: liq }));
115
+ else if (liq < 50000) it.push(sig("MKT_LIQ_LOW", "warn", { liq: liq }));
116
+ if (vol < 1000) it.push(sig("MKT_VOLUME_LOW", "warn", { vol: vol }));
117
+ if (pair.pairCreatedAt) {
118
+ const d = (now - pair.pairCreatedAt) / DAY;
119
+ if (d < 7) it.push(sig("MKT_NEW", "warn", { days: Math.floor(d) }));
120
+ }
121
+ if (value > 0 && liq > 0 && value / liq >= 0.1) it.push(sig("MKT_POSITION_HEAVY", "warn", { pct: value / liq * 100 }));
122
+ // Dollars in the pool do not say who can take them out: if the LP tokens are neither locked nor
123
+ // burned, the creator can pull the liquidity at any time whatever the rest says. Only for markets
124
+ // under 90 days: that is when the creator alone provides liquidity and rug pulls are typical. In
125
+ // veteran tokens (JUP, WIF...) many market makers provide it and unlocked LP is not a signal: applying
126
+ // it there gave false positives (JUP scored worse than a new memecoin)
127
+ const young = !pair.pairCreatedAt || (now - pair.pairCreatedAt) / DAY < 90;
128
+ if (rc && !soft && young && liq > 0 && typeof rc.lpLockedPct === "number" && isFinite(rc.lpLockedPct)) {
129
+ if (rc.lpLockedPct < 20) it.push(sig("MKT_LP_UNLOCKED", "bad", { pct: rc.lpLockedPct }));
130
+ else if (rc.lpLockedPct < 60) it.push(sig("MKT_LP_PARTLY_LOCKED", "warn", { pct: rc.lpLockedPct }));
131
+ }
132
+ if (type === "BRIDGED" && liq < 50000 && it.length) it[0] = Object.assign({}, it[0], { data: Object.assign({}, it[0].data, { offSolana: true }) });
133
+ const market = mkLight("market", it, sig("MKT_OK", "ok", { liq: liq, vol: vol }));
134
+
135
+ // 4) dependency on an issuer or a bridge
136
+ it = [];
137
+ if (info) {
138
+ if ((info.mintAuth && ak[info.mintAuth] === "program") || (info.freezeAuth && ak[info.freezeAuth] === "program"))
139
+ it.push(sig("DEP_PROGRAM_AUTHORITY", "warn"));
140
+ if (info.ext.indexOf("permanentDelegate") >= 0) it.push(sig("DEP_PERMANENT_DELEGATE", "warn"));
141
+ }
142
+ if (type === "STABLE") it.push(sig("DEP_STABLE_ISSUER", "warn"));
143
+ const dependency = mkLight("dependency", it, sig("DEP_NONE", "ok"));
144
+
145
+ return [control, concentration, market, dependency];
146
+ }
147
+
148
+ export function levelFrom(lights) {
149
+ let b = 0, w = 0, na = 0;
150
+ lights.forEach(function (l) { if (l.s === "bad") b++; else if (l.s === "warn") w++; else if (l.s === "na") na++; });
151
+ let lvl = (b >= 2 || (b === 1 && w >= 1)) ? "HIGH" : ((b === 1 || w >= 2) ? "MEDIUM" : "LOW");
152
+ // What could not be checked does not count as "fine": with a check missing, at best it is medium risk
153
+ if (lvl === "LOW" && na > 0) lvl = "MEDIUM";
154
+ // A market under 7 days old has no track record: it is never "low risk", even with nothing else wrong.
155
+ // Other single warnings stay low: on established tokens they are normal for their type (a staking pool's
156
+ // program-controlled mint, a transfer hook...); measured on 62 real tokens, this rule moved 9 brand-new
157
+ // tokens from low to medium and no established one
158
+ if (lvl === "LOW" && lights.some(function (l) { return (l.items || []).some(function (x) { return x.code === "MKT_NEW"; }); })) lvl = "MEDIUM";
159
+ return { lvl: lvl, sev: 3 * b + w + (na && !b && !w ? 1 : 0) };
160
+ }
161
+
162
+ // "Medium" with no concrete signal at all: only because some check could not run (e.g. holders without
163
+ // your own RPC). Callers should say so explicitly so the scanner does not look like it marks everything the same
164
+ export function dataOnly(r) {
165
+ return r.lvl === "MEDIUM" && !!r.lights && !r.lights.some(function (l) { return l.s === "bad" || l.s === "warn"; }) && r.lights.some(function (l) { return l.s === "na"; });
166
+ }
167
+
168
+ // The signals worth showing as "why": everything not ok, in light order
169
+ export function reasonsFrom(lights) {
170
+ const out = [];
171
+ lights.forEach(function (l) { if (l.s !== "ok") l.items.forEach(function (x) { if (x.s !== "ok") out.push(Object.assign({ k: l.k }, x)); }); });
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * Full risk assessment of one token.
177
+ *
178
+ * @param client
179
+ * @param mint
180
+ * @param pair best DexScreener pair or null (no market)
181
+ * @param [opts.value] USD the analysed wallet holds of it (for the "heavy position" check)
182
+ * @param [opts.marketUnknown] the DexScreener batch for this mint failed: no data, not "no market"
183
+ * @param [opts.skipConcentration]
184
+ * @returns null for NFTs, otherwise {mint, sym, name, lvl, sev, pts, type, lights, reasons, impostor, value, pair, img}
185
+ */
186
+ export async function assess(client, mint, pair, opts) {
187
+ opts = opts || {};
188
+ const value = opts.value || 0;
189
+ if (opts.marketUnknown) {
190
+ return {
191
+ mint: mint, sym: short(mint), name: "", pts: 0, sev: 0, lvl: "NO_DATA",
192
+ reasons: [sig("MARKET_UNCHECKED", "na")], lights: null, type: null, impostor: null, value: value, pair: null, img: null
193
+ };
194
+ }
195
+ const rcPromise = pair ? rugcheckSummary(client, mint) : Promise.resolve(null); // in parallel with the RPC calls below
196
+ let info = null, conc = null;
197
+ try { info = await getMint(client, mint); } catch (e) { if (isCancel(e)) throw e; }
198
+ if (info && info.decimals === 0 && info.supply === 1) return null; // NFT
199
+ // Without a market concentration is not checked: saves half the requests
200
+ if (info && pair && (info.mintAuth || info.freezeAuth)) {
201
+ try { info.authKind = await getAuthKinds(client, info); } catch (e) { if (isCancel(e)) throw e; }
202
+ }
203
+ if (info && pair && opts.skipConcentration) conc = { skip: true };
204
+ else if (info && pair) {
205
+ // In tokens with millions of holders (BONK) getTokenLargestAccounts sometimes times out: a second try usually works
206
+ for (let tryC = 0; tryC < 2 && !conc; tryC++) {
207
+ try { conc = await getConc(client, mint, info.supply); }
208
+ catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(client.retryDelayMs === undefined ? 1500 : client.retryDelayMs); }
209
+ }
210
+ }
211
+ const rc = await rcPromise;
212
+ const base = (pair && pair.baseToken) || {};
213
+ let lvl, sev = 0, reasons = [], lights = null, type = null, pts = 0, imp = null;
214
+ if (pair) {
215
+ type = tokenType(info, pair);
216
+ lights = buildLights(info, conc, pair, value, type, rc);
217
+ imp = impostorOf(mint, pair);
218
+ if (imp) {
219
+ const prev = lights[0];
220
+ lights[0] = { k: "control", s: "bad", items: [sig("IMPOSTOR", "bad", { symbol: imp.sym, real: imp.real })].concat(prev.s !== "ok" ? prev.items : []) };
221
+ }
222
+ const lf = levelFrom(lights); lvl = lf.lvl; sev = lf.sev; reasons = reasonsFrom(lights);
223
+ if (imp) { lvl = "HIGH"; sev += 5; } // a copy is high risk even if everything else looks normal
224
+ } else {
225
+ const s = score(info);
226
+ pts = s.pts; reasons = s.why; lvl = pts >= 25 ? "SPAM" : "NO_MARKET";
227
+ }
228
+ return {
229
+ mint: mint, sym: base.symbol || short(mint), name: base.name || "", pts: pts, sev: sev,
230
+ lvl: lvl, type: type, lights: lights, reasons: reasons, impostor: imp, value: value, pair: pair,
231
+ rugcheck: rc ? { lpLockedPct: rc.lpLockedPct, score: rc.score_normalised !== undefined ? rc.score_normalised : rc.score } : null,
232
+ img: (pair && pair.info && pair.info.imageUrl) || null
233
+ };
234
+ }
@@ -0,0 +1,66 @@
1
+ import { RUGCHECK } from "./constants.js";
2
+ import { isCancel } from "./errors.js";
3
+ import { kindOf } from "./tokens.js";
4
+ import { pool } from "./util.js";
5
+
6
+ // Extra signal from RugCheck.xyz (public API, no key): mainly the % of liquidity locked or burned,
7
+ // which on-chain data does not show without decoding each DEX's binary layout (Raydium, Orca, Meteora
8
+ // and pump.fun all differ). It is an extra, never a requirement: on failure these return null and the
9
+ // rest of the analysis is exactly the same.
10
+ export function rugcheckSummary(client, mint) {
11
+ return client.softJson(RUGCHECK + mint + "/report/summary", 6000);
12
+ }
13
+
14
+ // Full report (~700 KB, so ask once per holder search, never per token of a wallet): who created the
15
+ // token, which other tokens that wallet created and whether RugCheck sees wallet networks linked by
16
+ // transfers (a wider coordination signal than a shared funder).
17
+ export function rugcheckReport(client, mint) {
18
+ return client.softJson(RUGCHECK + mint + "/report", 10000);
19
+ }
20
+
21
+ // The report only gives each network's size; this endpoint gives its wallets and holdings
22
+ export async function rugcheckGraph(client, mint) {
23
+ const g = await client.softJson(RUGCHECK + mint + "/insiders/graph", 8000);
24
+ if (!Array.isArray(g)) return null;
25
+ return g.map(function (n) {
26
+ const nodes = (n.nodes || []).filter(function (x) { return x && x.id && x.participant !== false; })
27
+ .map(function (x) { return { addr: x.id, hold: Number(x.holdings || 0) }; })
28
+ .sort(function (a, b) { return b.hold - a.hold; });
29
+ return { nodes: nodes, hold: nodes.reduce(function (s, x) { return s + x.hold; }, 0) };
30
+ }).filter(function (n) { return n.nodes.length > 1; }).sort(function (a, b) { return b.hold - a.hold; });
31
+ }
32
+
33
+ // The graph's "holdings" are not today's balance: a wallet that handed tokens out shows the whole
34
+ // network's total even with 0, and sometimes a network includes a pool. This reads on chain what each
35
+ // one holds today and whether it is a wallet or a program. Mutates the graph (adds now/kind/checked).
36
+ export async function netBalances(client, mint, graph) {
37
+ let addrs = [];
38
+ graph.forEach(function (n) { n.nodes.forEach(function (x) { if (addrs.indexOf(x.addr) < 0) addrs.push(x.addr); }); });
39
+ addrs = addrs.slice(0, client.ownRpc() ? 80 : 25); // without your own URL each balance goes through the ~1/s queue
40
+ const bal = {}, kind = {};
41
+ for (let i = 0; i < addrs.length; i += 100) {
42
+ const part = addrs.slice(i, i + 100);
43
+ try {
44
+ const oa = await client.rpc("getMultipleAccounts", [part, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
45
+ part.forEach(function (a, k) { kind[a] = kindOf(a, oa.value[k], true); });
46
+ } catch (e) { if (isCancel(e)) throw e; }
47
+ }
48
+ await pool(addrs, 4, async function (a) {
49
+ try {
50
+ const r = await client.rpc("getTokenAccountsByOwner", [a, { mint: mint }, { encoding: "jsonParsed" }]);
51
+ bal[a] = (r && r.value || []).reduce(function (s, c) { return s + Number(c.account.data.parsed.info.tokenAmount.amount || 0); }, 0);
52
+ } catch (e) { if (isCancel(e)) throw e; }
53
+ });
54
+ graph.forEach(function (n) {
55
+ n.checked = true; n.now = 0;
56
+ n.nodes.forEach(function (x) {
57
+ x.kind = kind[x.addr] || null;
58
+ x.now = bal[x.addr] === undefined ? null : bal[x.addr];
59
+ if (x.now === null) n.checked = false;
60
+ else if (x.kind !== "program") n.now += x.now; // what sits in a pool belongs to nobody in the network
61
+ });
62
+ n.nodes.sort(function (a, b) { return (b.now || 0) - (a.now || 0) || b.hold - a.hold; });
63
+ });
64
+ graph.sort(function (a, b) { return b.now - a.now || b.hold - a.hold; });
65
+ return graph;
66
+ }
package/src/tokens.js ADDED
@@ -0,0 +1,103 @@
1
+ import { TOKEN_PROGRAM, TOKEN_2022, SYSTEM_PROGRAM } from "./constants.js";
2
+ import { isCancel } from "./errors.js";
3
+ import { onCurve, b58bytes } from "./curve.js";
4
+ import { uniq } from "./util.js";
5
+
6
+ // SOL balance and every token the wallet holds (classic SPL + Token-2022), summed by mint
7
+ export async function getTokens(client, wallet) {
8
+ const res = await Promise.all([
9
+ client.rpc("getBalance", [wallet]),
10
+ client.rpc("getTokenAccountsByOwner", [wallet, { programId: TOKEN_PROGRAM }, { encoding: "jsonParsed" }]),
11
+ client.rpc("getTokenAccountsByOwner", [wallet, { programId: TOKEN_2022 }, { encoding: "jsonParsed" }])
12
+ ]);
13
+ const tokens = {};
14
+ [res[1], res[2]].forEach(function (r) {
15
+ r.value.forEach(function (c) {
16
+ const info = c.account.data.parsed.info;
17
+ const q = Number(info.tokenAmount.uiAmount || 0);
18
+ if (q > 0) tokens[info.mint] = (tokens[info.mint] || 0) + q;
19
+ });
20
+ });
21
+ return { sol: res[0].value / 1e9, tokens: tokens };
22
+ }
23
+
24
+ // A mint account belongs to a token program; a wallet belongs to the System Program
25
+ export async function isMint(client, addr) {
26
+ try {
27
+ const r = await client.rpc("getAccountInfo", [addr, { encoding: "jsonParsed" }]);
28
+ const v = r && r.value;
29
+ return !!(v && (v.owner === TOKEN_PROGRAM || v.owner === TOKEN_2022) && v.data && v.data.parsed && v.data.parsed.type === "mint");
30
+ } catch (e) { if (isCancel(e)) throw e; return false; } // if it cannot be known, treat it as a wallet
31
+ }
32
+
33
+ export async function getMint(client, mint) {
34
+ const r = await client.rpc("getAccountInfo", [mint, { encoding: "jsonParsed" }]);
35
+ const p = r && r.value && r.value.data && r.value.data.parsed && r.value.data.parsed.info;
36
+ if (!p) return null;
37
+ return {
38
+ mintAuth: p.mintAuthority || null, freezeAuth: p.freezeAuthority || null,
39
+ supply: Number(p.supply || 0), decimals: Number(p.decimals || 0),
40
+ ext: (p.extensions || []).map(function (e) { return e.extension; })
41
+ };
42
+ }
43
+
44
+ // A key off the ed25519 curve has no private key: only a program can sign for it (a PDA), whoever
45
+ // owns its account. PDAs that hold SOL (stake pool and AMM authorities) are owned by the System
46
+ // Program like any wallet, so the owner alone would count them as people.
47
+ export function kindOf(key, account, missingIsWallet) {
48
+ if (!onCurve(b58bytes(key))) return "program";
49
+ if (account) return account.owner === SYSTEM_PROGRAM ? "wallet" : "program";
50
+ return missingIsWallet ? "wallet" : "program";
51
+ }
52
+
53
+ // "wallet" or "program" for each address
54
+ export async function accountKinds(client, addrs, missingIsWallet) {
55
+ if (!addrs.length) return {};
56
+ const r = await client.rpc("getMultipleAccounts", [addrs, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
57
+ const out = {};
58
+ addrs.forEach(function (k, i) { out[k] = kindOf(k, r.value[i], missingIsWallet); });
59
+ return out;
60
+ }
61
+
62
+ // Who controls the mint and freeze authorities: a person's wallet or a program (bridge, protocol)
63
+ export async function getAuthKinds(client, info) {
64
+ const keys = [];
65
+ [info.mintAuth, info.freezeAuth].forEach(function (a) { if (a && keys.indexOf(a) < 0) keys.push(a); });
66
+ if (!keys.length) return {};
67
+ return accountKinds(client, keys, true); // no account but on the curve: an empty wallet
68
+ }
69
+
70
+ // Concentration of the supply. Each large account is traced to its owner: a pool or a program
71
+ // holding tokens is not a whale, so w1/w10 count only people's wallets.
72
+ export async function getConc(client, mint, supply) {
73
+ const r = await client.rpc("getTokenLargestAccounts", [mint]);
74
+ const v = (r && r.value) || [];
75
+ if (!v.length || supply <= 0) throw new Error("no holder data");
76
+ const amts = v.map(function (x) { return Number(x.amount); });
77
+ const out = {
78
+ ok: false,
79
+ top1: amts[0] / supply * 100,
80
+ top10: amts.slice(0, 10).reduce(function (s, x) { return s + x; }, 0) / supply * 100
81
+ };
82
+ const addrs = v.map(function (x) { return x.address; });
83
+ if (addrs.some(function (x) { return !x; })) return out;
84
+ try {
85
+ const ta = await client.rpc("getMultipleAccounts", [addrs, { encoding: "jsonParsed" }]);
86
+ const owners = ta.value.map(function (x) {
87
+ return x && x.data && x.data.parsed && x.data.parsed.info && x.data.parsed.info.owner || null;
88
+ });
89
+ if (owners.some(function (o) { return !o; })) return out;
90
+ const kind = await accountKinds(client, uniq(owners), false);
91
+ const agg = {};
92
+ owners.forEach(function (o, i) { agg[o] = (agg[o] || 0) + amts[i]; });
93
+ const ws = [];
94
+ let prog = 0;
95
+ Object.keys(agg).forEach(function (o) { if (kind[o] === "wallet") ws.push(agg[o]); else prog += agg[o]; });
96
+ ws.sort(function (x, y) { return y - x; });
97
+ out.ok = true; out.nW = ws.length;
98
+ out.w1 = ws.length ? ws[0] / supply * 100 : 0;
99
+ out.w10 = ws.slice(0, 10).reduce(function (s, x) { return s + x; }, 0) / supply * 100;
100
+ out.prog = prog / supply * 100;
101
+ } catch (e) { if (isCancel(e)) throw e; }
102
+ return out;
103
+ }
package/src/util.js ADDED
@@ -0,0 +1,29 @@
1
+ export function sleep(ms) {
2
+ return new Promise(function (r) { setTimeout(r, ms); });
3
+ }
4
+
5
+ export function short(a) {
6
+ return a.slice(0, 4) + "…" + a.slice(-4);
7
+ }
8
+
9
+ // Runs fn over items with at most n in flight
10
+ export async function pool(items, n, fn) {
11
+ let i = 0;
12
+ const workers = [];
13
+ for (let w = 0; w < Math.min(n, items.length); w++) {
14
+ workers.push((async function () {
15
+ while (i < items.length) { const k = i++; await fn(items[k], k); }
16
+ })());
17
+ }
18
+ await Promise.all(workers);
19
+ }
20
+
21
+ export function uniq(list) {
22
+ return list.filter(function (x, i) { return list.indexOf(x) === i; });
23
+ }
24
+
25
+ // Calls onProgress({step, ...data}, fraction) if the caller gave one; the engine never touches a UI
26
+ // itself. Steps are codes (see i18n describeStep) so each app words them in its own language.
27
+ export function progress(onProgress, step, frac, data) {
28
+ if (onProgress) onProgress(Object.assign({ step: step }, data), Math.max(0, Math.min(1, frac)));
29
+ }
package/src/wallet.js ADDED
@@ -0,0 +1,80 @@
1
+ import { ADDRESS_RE, KNOWN, LEVELS } from "./constants.js";
2
+ import { ScanError } from "./errors.js";
3
+ import { pool, progress } from "./util.js";
4
+ import { getTokens, isMint } from "./tokens.js";
5
+ import { getMarkets } from "./markets.js";
6
+ import { assess } from "./risk.js";
7
+
8
+ function checkAddress(addr) {
9
+ if (!ADDRESS_RE.test(addr || "")) throw new ScanError("INVALID_ADDRESS", "not a Solana address");
10
+ }
11
+
12
+ export function sortResults(list) {
13
+ return list.sort(function (a, b) {
14
+ return LEVELS.indexOf(a.lvl) - LEVELS.indexOf(b.lvl) || (b.sev || 0) - (a.sev || 0) || b.pts - a.pts || b.value - a.value;
15
+ });
16
+ }
17
+
18
+ /** Risk of a single token. Throws IS_NFT for NFTs. */
19
+ export async function analyzeToken(client, mint, opts) {
20
+ opts = opts || {};
21
+ checkAddress(mint);
22
+ progress(opts.onProgress, "markets", 0.15);
23
+ const mkr = await getMarkets(client, [mint]);
24
+ progress(opts.onProgress, "token", 0.45);
25
+ const r = await assess(client, mint, mkr.data[mint] || null, { marketUnknown: mkr.failed.length > 0, skipConcentration: opts.skipConcentration });
26
+ if (!r) throw new ScanError("IS_NFT", "that address is an NFT, not a token");
27
+ progress(opts.onProgress, "done", 1);
28
+ return r;
29
+ }
30
+
31
+ /**
32
+ * Risk of every token a wallet holds.
33
+ *
34
+ * @param [opts.minUsd=1] hide priced tokens worth less than this (dust)
35
+ * @param [opts.concurrency=3]
36
+ * @returns {wallet, sol, balances, total, hidden, results}
37
+ */
38
+ export async function analyzeWallet(client, wallet, opts) {
39
+ opts = opts || {};
40
+ checkAddress(wallet);
41
+ const minUsd = opts.minUsd === undefined ? 1 : opts.minUsd;
42
+ const onProgress = opts.onProgress;
43
+ progress(onProgress, "wallet", 0.02);
44
+ const w = await getTokens(client, wallet);
45
+ const mints = Object.keys(w.tokens);
46
+ const res = { wallet: wallet, sol: w.sol, balances: w.tokens, total: mints.length, hidden: 0, results: [] };
47
+ if (!mints.length) return res;
48
+ progress(onProgress, "markets", 0.08);
49
+ const mkr = await getMarkets(client, mints, function (d, n) { progress(onProgress, "markets", 0.08 + 0.12 * d / n, { done: d, total: n }); });
50
+ const mk = mkr.data, failedMk = {};
51
+ mkr.failed.forEach(function (m) { failedMk[m] = 1; });
52
+
53
+ const out = [], todo = [];
54
+ mints.forEach(function (m) {
55
+ const pair = mk[m], price = Number((pair && pair.priceUsd) || 0);
56
+ let value = w.tokens[m] * price;
57
+ if (pair && pair.liqRaw) value = Math.min(value, pair.liquidity.usd); // with inflated liquidity the price is fake too
58
+ if (KNOWN[m]) { out.push({ mint: m, sym: KNOWN[m], name: "", pts: 0, sev: 0, lvl: "LOW", known: true, type: null, lights: null, reasons: [{ code: "KNOWN_TOKEN", s: "ok" }], value: value, pair: pair, img: null }); return; }
59
+ if (price > 0 && value < minUsd) { res.hidden++; return; }
60
+ todo.push({ mint: m, pair: pair, value: value, unknown: !!failedMk[m] });
61
+ });
62
+
63
+ let done = 0;
64
+ await pool(todo, opts.concurrency || 3, async function (t) {
65
+ const r = await assess(client, t.mint, t.pair, { value: t.value, marketUnknown: t.unknown, skipConcentration: opts.skipConcentration });
66
+ if (r) out.push(r);
67
+ done++;
68
+ progress(onProgress, "tokens", 0.2 + 0.8 * done / Math.max(1, todo.length), { done: done, total: todo.length });
69
+ });
70
+ res.results = sortResults(out);
71
+ return res;
72
+ }
73
+
74
+ /** Detects whether the address is a token mint or a wallet and analyses it accordingly. */
75
+ export async function analyzeAddress(client, addr, opts) {
76
+ checkAddress(addr);
77
+ progress(opts && opts.onProgress, "address", 0.01);
78
+ if (await isMint(client, addr)) return { kind: "token", token: await analyzeToken(client, addr, opts) };
79
+ return { kind: "wallet", wallet: await analyzeWallet(client, addr, opts) };
80
+ }