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/LICENSE +21 -0
- package/README.es.md +30 -0
- package/README.md +142 -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 +58 -0
- package/src/pnl.js +148 -0
- package/src/risk.js +234 -0
- package/src/rugcheck.js +66 -0
- package/src/tokens.js +103 -0
- package/src/util.js +29 -0
- package/src/wallet.js +80 -0
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
|
+
}
|
package/src/rugcheck.js
ADDED
|
@@ -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
|
+
}
|