solana-risk-engine 0.4.2 → 0.4.4

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 CHANGED
@@ -1,255 +1,293 @@
1
- import { EXT_POINTS, RANKS, PUMP_CURVE_DEXES } 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, creatorLaunches } 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
- // Other tokens launched by the same creator in the last 24 h that make it a serial launcher
18
- export const SERIAL_WARN = 2, SERIAL_BAD = 5;
19
- // Creator track record: at least HISTORY_MIN older tokens and this share of them never took off
20
- export const HISTORY_MIN = 3, HISTORY_WARN = 0.8, HISTORY_BAD_MIN = 10, HISTORY_BAD = 0.9;
21
-
22
- /* ---------- old point score: only for tokens without a market, to spot spam ---------- */
23
- export function score(info) {
24
- let pts = 0;
25
- const why = [];
26
- if (!info) return { pts: 30, why: [sig("NO_INFO", "warn")] };
27
- const ak = info.authKind || {};
28
- if (info.mintAuth) {
29
- if (ak[info.mintAuth] === "program") { pts += 10; why.push(sig("MINT_BY_PROGRAM", "warn")); }
30
- else { pts += 25; why.push(sig("MINT_ACTIVE", "bad", { byWallet: ak[info.mintAuth] === "wallet" })); }
31
- }
32
- if (info.freezeAuth) {
33
- if (ak[info.freezeAuth] === "program") { pts += 8; why.push(sig("FREEZE_BY_PROGRAM", "warn")); }
34
- else { pts += 20; why.push(sig("FREEZE_ACTIVE", "warn", { byWallet: ak[info.freezeAuth] === "wallet" })); }
35
- }
36
- info.ext.forEach(function (e) { if (EXT_POINTS[e]) { pts += EXT_POINTS[e]; why.push(sig("EXTENSION", "warn", { ext: e })); } });
37
- why.push(sig("NO_MARKET", "na"));
38
- return { pts: pts, why: why };
39
- }
40
-
41
- /* ---------- token type ---------- */
42
- export function tokenType(info, pair, now) {
43
- if (!info) return null;
44
- now = now || Date.now();
45
- const ak = info.authKind || {};
46
- const sym = ((pair && pair.baseToken && pair.baseToken.symbol) || "") + " " + ((pair && pair.baseToken && pair.baseToken.name) || "");
47
- const price = Number((pair && pair.priceUsd) || 0);
48
- const liq = (pair && pair.liquidity && pair.liquidity.usd) || 0;
49
- const ageDays = (pair && pair.pairCreatedAt) ? (now - pair.pairCreatedAt) / DAY : 0;
50
- // Being called "USD something" is not enough to be judged more leniently than a normal token:
51
- // it needs a stable price, real liquidity and some time trading (otherwise a scammer only has to
52
- // name the token "USDX" and keep the price near $1 for a while)
53
- if (/USD|DOLLAR/i.test(sym) && price > 0.97 && price < 1.03 && liq >= 200000 && ageDays >= 30) return "STABLE";
54
- if (info.ext.indexOf("permanentDelegate") >= 0) return "TOKENIZED";
55
- const walletAuth = (info.mintAuth && ak[info.mintAuth] !== "program") || (info.freezeAuth && ak[info.freezeAuth] !== "program");
56
- if (walletAuth) return "CREATOR";
57
- if (info.mintAuth || info.freezeAuth) return "BRIDGED";
58
- return "OPEN";
59
- }
60
-
61
- /* ---------- the four traffic lights ---------- */
62
- function mkLight(k, items, okSignal) {
63
- if (!items.length) return { k: k, s: "ok", items: [okSignal] };
64
- let st = "ok";
65
- items.forEach(function (x) { if (RANKS[x.s] > RANKS[st]) st = x.s; });
66
- // na ranks like ok for "worst of", but an unchecked light must stay na so levelFrom can cap it
67
- if (st === "ok" && items.some(function (x) { return x.s === "na"; })) st = "na";
68
- return { k: k, s: st, items: items };
69
- }
70
-
71
- /**
72
- * @param info mint info from getMint (+ authKind) or null if it could not be read
73
- * @param conc concentration from getConc, {skip:true}, or null if the RPC did not answer
74
- * @param pair best DexScreener pair (required: lights are only built for tokens with a market)
75
- * @param value USD value the analysed wallet holds (0 when checking a token alone)
76
- * @param type tokenType(info, pair)
77
- * @param rc RugCheck summary or null
78
- * @param launches creatorLaunches result or null: what else the creator launched (last 24 h, and how older ones did)
79
- * @returns [control, concentration, market, dependency]
80
- */
81
- export function buildLights(info, conc, pair, value, type, rc, now, launches) {
82
- now = now || Date.now();
83
- const ak = (info && info.authKind) || {};
84
- let it;
85
-
86
- // 1) creator control
87
- it = [];
88
- if (!info) it.push(sig("NO_INFO", "warn"));
89
- else {
90
- if (info.mintAuth && ak[info.mintAuth] !== "program") it.push(sig("CTL_MINT_ACTIVE", "bad"));
91
- if (info.freezeAuth && ak[info.freezeAuth] !== "program") it.push(sig("CTL_FREEZE_ACTIVE", "warn"));
92
- if (info.ext.indexOf("transferHook") >= 0) it.push(sig("CTL_TRANSFER_HOOK", "warn"));
93
- if (info.ext.indexOf("transferFeeConfig") >= 0) it.push(sig("CTL_TRANSFER_FEE", "warn"));
94
- if (info.ext.indexOf("nonTransferable") >= 0) it.push(sig("CTL_NON_TRANSFERABLE", "warn"));
95
- if (info.ext.indexOf("defaultAccountState") >= 0) it.push(sig("CTL_DEFAULT_FROZEN", "warn"));
96
- }
97
- // Launch farms put out a token every few minutes and drop each one as soon as buyers arrive: in a
98
- // 100-token Pump.fun sample one wallet launched 7 copies of "GMS" in 10 minutes
99
- if (launches && launches.day >= SERIAL_WARN) it.push(sig("CTL_SERIAL_CREATOR", launches.day >= SERIAL_BAD ? "bad" : "warn", { n: launches.day, creator: launches.creator }));
100
- // Older launches that went nowhere: farms that spread their tokens over days escape the 24 h count
101
- if (launches && launches.old >= HISTORY_MIN && launches.dead / launches.old >= HISTORY_WARN) {
102
- const bad = launches.old >= HISTORY_BAD_MIN && launches.dead / launches.old >= HISTORY_BAD;
103
- it.push(sig("CTL_CREATOR_HISTORY", bad ? "bad" : "warn", { old: launches.old, dead: launches.dead, best: launches.best, creator: launches.creator }));
104
- }
105
- const control = mkLight("control", it, sig((info && (info.mintAuth || info.freezeAuth)) ? "CTL_ONLY_PROGRAMS" : "CTL_NONE", "ok"));
106
-
107
- // 2) concentration in people (pools and programs do not count)
108
- let concentration;
109
- const soft = (type === "STABLE" || type === "TOKENIZED" || type === "BRIDGED");
110
- if (conc && conc.skip) concentration = mkLight("concentration", [sig("CON_SKIPPED", "na")]);
111
- else if (!conc) concentration = mkLight("concentration", [sig("CON_UNAVAILABLE", "na")]);
112
- else if (conc.ok) {
113
- let st = "ok";
114
- if (conc.w1 > 30 || conc.w10 > 70) st = "bad"; else if (conc.w1 > 15 || conc.w10 > 50) st = "warn";
115
- if (soft && st === "bad") st = "warn";
116
- const data = { w1: conc.w1, w10: conc.w10, prog: conc.prog, wallets: conc.nW }; // prog >= 20 is worth mentioning
117
- const code = !conc.nW ? "CON_ALL_POOLS" : (st === "ok" ? "CON_OK" : "CON_HIGH");
118
- concentration = mkLight("concentration", [sig(code, st, data)]);
119
- } else {
120
- concentration = mkLight("concentration", [sig("CON_UNSPLIT", (conc.top1 > 30 || conc.top10 > 70) ? "warn" : "ok", { top1: conc.top1, top10: conc.top10 })]);
121
- }
122
-
123
- // 3) market and exit
124
- const liq = (pair.liquidity && pair.liquidity.usd) || 0, vol = (pair.volume && pair.volume.h24) || 0;
125
- it = [];
126
- if (pair.liqRaw) it.push(sig("MKT_LIQ_INFLATED", "bad", { reported: pair.liqRaw, real: liq }));
127
- else if (liq < 10000) it.push(sig("MKT_LIQ_VERY_LOW", "bad", { liq: liq }));
128
- else if (liq < 50000) it.push(sig("MKT_LIQ_LOW", "warn", { liq: liq }));
129
- if (vol < 1000) it.push(sig("MKT_VOLUME_LOW", "warn", { vol: vol }));
130
- if (pair.pairCreatedAt) {
131
- const d = (now - pair.pairCreatedAt) / DAY;
132
- if (d < 7) it.push(sig("MKT_NEW", "warn", { days: Math.floor(d) }));
133
- }
134
- if (value > 0 && liq > 0 && value / liq >= 0.1) it.push(sig("MKT_POSITION_HEAVY", "warn", { pct: value / liq * 100 }));
135
- // Dollars in the pool do not say who can take them out: if the LP tokens are neither locked nor
136
- // burned, the creator can pull the liquidity at any time whatever the rest says. Only for markets
137
- // under 90 days: that is when the creator alone provides liquidity and rug pulls are typical. In
138
- // veteran tokens (JUP, WIF...) many market makers provide it and unlocked LP is not a signal: applying
139
- // it there gave false positives (JUP scored worse than a new memecoin)
140
- const young = !pair.pairCreatedAt || (now - pair.pairCreatedAt) / DAY < 90;
141
- // On Pump.fun's bonding curve there is no LP at all: the curve program holds the liquidity and nobody
142
- // can pull it (RugCheck reports 0% locked, which flagged 49 of 75 curve tokens in a 100-token sample)
143
- const onCurve = PUMP_CURVE_DEXES.indexOf(pair.dexId) >= 0;
144
- if (rc && !soft && young && !onCurve && liq > 0 && typeof rc.lpLockedPct === "number" && isFinite(rc.lpLockedPct)) {
145
- if (rc.lpLockedPct < 20) it.push(sig("MKT_LP_UNLOCKED", "bad", { pct: rc.lpLockedPct }));
146
- else if (rc.lpLockedPct < 60) it.push(sig("MKT_LP_PARTLY_LOCKED", "warn", { pct: rc.lpLockedPct }));
147
- }
148
- if (type === "BRIDGED" && liq < 50000 && it.length) it[0] = Object.assign({}, it[0], { data: Object.assign({}, it[0].data, { offSolana: true }) });
149
- const market = mkLight("market", it, sig("MKT_OK", "ok", { liq: liq, vol: vol }));
150
-
151
- // 4) dependency on an issuer or a bridge
152
- it = [];
153
- if (info) {
154
- if ((info.mintAuth && ak[info.mintAuth] === "program") || (info.freezeAuth && ak[info.freezeAuth] === "program"))
155
- it.push(sig("DEP_PROGRAM_AUTHORITY", "warn"));
156
- if (info.ext.indexOf("permanentDelegate") >= 0) it.push(sig("DEP_PERMANENT_DELEGATE", "warn"));
157
- }
158
- if (type === "STABLE") it.push(sig("DEP_STABLE_ISSUER", "warn"));
159
- const dependency = mkLight("dependency", it, sig("DEP_NONE", "ok"));
160
-
161
- return [control, concentration, market, dependency];
162
- }
163
-
164
- export function levelFrom(lights) {
165
- let b = 0, w = 0, na = 0;
166
- lights.forEach(function (l) { if (l.s === "bad") b++; else if (l.s === "warn") w++; else if (l.s === "na") na++; });
167
- let lvl = (b >= 2 || (b === 1 && w >= 1)) ? "HIGH" : ((b === 1 || w >= 2) ? "MEDIUM" : "LOW");
168
- // What could not be checked does not count as "fine": with a check missing, at best it is medium risk
169
- if (lvl === "LOW" && na > 0) lvl = "MEDIUM";
170
- // A market under 7 days old has no track record: it is never "low risk", even with nothing else wrong.
171
- // Other single warnings stay low: on established tokens they are normal for their type (a staking pool's
172
- // program-controlled mint, a transfer hook...); measured on 62 real tokens, this rule moved 9 brand-new
173
- // tokens from low to medium and no established one
174
- if (lvl === "LOW" && lights.some(function (l) { return (l.items || []).some(function (x) { return x.code === "MKT_NEW"; }); })) lvl = "MEDIUM";
175
- return { lvl: lvl, sev: 3 * b + w + (na && !b && !w ? 1 : 0) };
176
- }
177
-
178
- // "Medium" with no concrete signal at all: only because some check could not run (e.g. holders without
179
- // your own RPC). Callers should say so explicitly so the scanner does not look like it marks everything the same
180
- export function dataOnly(r) {
181
- 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"; });
182
- }
183
-
184
- // The signals worth showing as "why": everything not ok, in light order
185
- export function reasonsFrom(lights) {
186
- const out = [];
187
- 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)); }); });
188
- return out;
189
- }
190
-
191
- /**
192
- * Full risk assessment of one token.
193
- *
194
- * @param client
195
- * @param mint
196
- * @param pair best DexScreener pair or null (no market)
197
- * @param [opts.value] USD the analysed wallet holds of it (for the "heavy position" check)
198
- * @param [opts.marketUnknown] the DexScreener batch for this mint failed: no data, not "no market"
199
- * @param [opts.skipConcentration]
200
- * @param [opts.skipCreator] do not ask RugCheck what else the creator launched (markets under 7 days only)
201
- * @returns null for NFTs, otherwise {mint, sym, name, lvl, sev, pts, type, lights, reasons, impostor, value, pair, img}
202
- */
203
- export async function assess(client, mint, pair, opts) {
204
- opts = opts || {};
205
- const value = opts.value || 0;
206
- if (opts.marketUnknown) {
207
- return {
208
- mint: mint, sym: short(mint), name: "", pts: 0, sev: 0, lvl: "NO_DATA",
209
- reasons: [sig("MARKET_UNCHECKED", "na")], lights: null, type: null, impostor: null, value: value, pair: null, img: null
210
- };
211
- }
212
- const rcPromise = pair ? rugcheckSummary(client, mint) : Promise.resolve(null); // in parallel with the RPC calls below
213
- // Serial launching only matters while a token is new; RugCheck's full report is small (~10 KB) then
214
- const fresh = pair && !opts.skipCreator && (!pair.pairCreatedAt || Date.now() - pair.pairCreatedAt < 7 * DAY);
215
- const launchPromise = fresh ? creatorLaunches(client, mint) : Promise.resolve(null);
216
- let info = null, conc = null;
217
- try { info = await getMint(client, mint); } catch (e) { if (isCancel(e)) throw e; }
218
- if (info && info.decimals === 0 && info.supply === 1) return null; // NFT
219
- // Without a market concentration is not checked: saves half the requests
220
- if (info && pair && (info.mintAuth || info.freezeAuth)) {
221
- try { info.authKind = await getAuthKinds(client, info); } catch (e) { if (isCancel(e)) throw e; }
222
- }
223
- if (info && pair && opts.skipConcentration) conc = { skip: true };
224
- else if (info && pair) {
225
- // In tokens with millions of holders (BONK) getTokenLargestAccounts sometimes times out: a second try usually works
226
- for (let tryC = 0; tryC < 2 && !conc; tryC++) {
227
- try { conc = await getConc(client, mint, info.supply); }
228
- catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(client.retryDelayMs === undefined ? 1500 : client.retryDelayMs); }
229
- }
230
- }
231
- const rc = await rcPromise, launches = await launchPromise;
232
- const base = (pair && pair.baseToken) || {};
233
- let lvl, sev = 0, reasons = [], lights = null, type = null, pts = 0, imp = null;
234
- if (pair) {
235
- type = tokenType(info, pair);
236
- lights = buildLights(info, conc, pair, value, type, rc, null, launches);
237
- imp = impostorOf(mint, pair);
238
- if (imp) {
239
- const prev = lights[0];
240
- lights[0] = { k: "control", s: "bad", items: [sig("IMPOSTOR", "bad", { symbol: imp.sym, real: imp.real })].concat(prev.s !== "ok" ? prev.items : []) };
241
- }
242
- const lf = levelFrom(lights); lvl = lf.lvl; sev = lf.sev; reasons = reasonsFrom(lights);
243
- if (imp) { lvl = "HIGH"; sev += 5; } // a copy is high risk even if everything else looks normal
244
- } else {
245
- const s = score(info);
246
- pts = s.pts; reasons = s.why; lvl = pts >= 25 ? "SPAM" : "NO_MARKET";
247
- }
248
- return {
249
- mint: mint, sym: base.symbol || short(mint), name: base.name || "", pts: pts, sev: sev,
250
- lvl: lvl, type: type, lights: lights, reasons: reasons, impostor: imp, value: value, pair: pair,
251
- rugcheck: rc ? { lpLockedPct: rc.lpLockedPct, score: rc.score_normalised !== undefined ? rc.score_normalised : rc.score } : null,
252
- creator: launches,
253
- img: (pair && pair.info && pair.info.imageUrl) || null
254
- };
255
- }
1
+ import { EXT_POINTS, RANKS, PUMP_CURVE_DEXES, ISSUERS } 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, brandOf, copiesOf, fakeLiquidity } from "./markets.js";
6
+ import { rugcheckSummary, creatorLaunches } 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
+ // Other tokens launched by the same creator in the last 24 h that make it a serial launcher
18
+ export const SERIAL_WARN = 2, SERIAL_BAD = 5;
19
+ // Creator track record: at least HISTORY_MIN older tokens and this share of them never took off. Red needs
20
+ // 95%: 86% of all new tokens never reach $10k (149-token sample, 28/10), so one launch that took off out of
21
+ // ten is already better than chance and must not raise the verdict
22
+ export const HISTORY_MIN = 3, HISTORY_WARN = 0.8, HISTORY_BAD_MIN = 10, HISTORY_BAD = 0.95;
23
+
24
+ /* ---------- old point score: only for tokens without a market, to spot spam ---------- */
25
+ export function score(info) {
26
+ let pts = 0;
27
+ const why = [];
28
+ if (!info) return { pts: 30, why: [sig("NO_INFO", "warn")] };
29
+ const ak = info.authKind || {};
30
+ if (info.mintAuth) {
31
+ if (ak[info.mintAuth] === "program") { pts += 10; why.push(sig("MINT_BY_PROGRAM", "warn")); }
32
+ else { pts += 25; why.push(sig("MINT_ACTIVE", "bad", { byWallet: ak[info.mintAuth] === "wallet" })); }
33
+ }
34
+ if (info.freezeAuth) {
35
+ if (ak[info.freezeAuth] === "program") { pts += 8; why.push(sig("FREEZE_BY_PROGRAM", "warn")); }
36
+ else { pts += 20; why.push(sig("FREEZE_ACTIVE", "warn", { byWallet: ak[info.freezeAuth] === "wallet" })); }
37
+ }
38
+ info.ext.forEach(function (e) { if (EXT_POINTS[e]) { pts += EXT_POINTS[e]; why.push(sig("EXTENSION", "warn", { ext: e })); } });
39
+ why.push(sig("NO_MARKET", "na"));
40
+ return { pts: pts, why: why };
41
+ }
42
+
43
+ /* ---------- token type ---------- */
44
+ export function tokenType(info, pair, now) {
45
+ if (!info) return null;
46
+ now = now || Date.now();
47
+ const ak = info.authKind || {};
48
+ const sym = ((pair && pair.baseToken && pair.baseToken.symbol) || "") + " " + ((pair && pair.baseToken && pair.baseToken.name) || "");
49
+ const price = Number((pair && pair.priceUsd) || 0);
50
+ const liq = (pair && pair.liquidity && pair.liquidity.usd) || 0;
51
+ const ageDays = (pair && pair.pairCreatedAt) ? (now - pair.pairCreatedAt) / DAY : 0;
52
+ // Being called "USD something" is not enough to be judged more leniently than a normal token:
53
+ // it needs a stable price, real liquidity and some time trading (otherwise a scammer only has to
54
+ // name the token "USDX" and keep the price near $1 for a while)
55
+ if (/USD|DOLLAR/i.test(sym) && price > 0.97 && price < 1.03 && liq >= 200000 && ageDays >= 30) return "STABLE";
56
+ if (info.ext.indexOf("permanentDelegate") >= 0) return "TOKENIZED";
57
+ const walletAuth = (info.mintAuth && ak[info.mintAuth] !== "program") || (info.freezeAuth && ak[info.freezeAuth] !== "program");
58
+ if (walletAuth) return "CREATOR";
59
+ if (info.mintAuth || info.freezeAuth) return "BRIDGED";
60
+ return "OPEN";
61
+ }
62
+
63
+ /* ---------- known issuers ---------- */
64
+ // Name of the known issuer that holds every control key of this mint, or null. Needs the extension keys
65
+ // read by getMint (extKeys): without them a permanent delegate or a hook could belong to anyone
66
+ export function issuerOf(info) {
67
+ if (!info || !info.extKeys) return null;
68
+ const keys = [info.mintAuth, info.freezeAuth].concat(info.extKeys).filter(Boolean);
69
+ if (!keys.length) return null;
70
+ const found = ISSUERS.find(function (i) { return keys.every(function (k) { return i.keys.indexOf(k) >= 0; }); });
71
+ return found ? found.name : null;
72
+ }
73
+
74
+ // Extensions that only matter as they are set: a hook with no program and nobody able to add one does
75
+ // nothing, and new accounts are only born frozen when the default state says "frozen". Mint info without
76
+ // those details (older callers) keeps the old behaviour: the extension alone is enough
77
+ function hookActive(info) { return !info.hook || !!(info.hook.program || info.hook.auth); }
78
+ function bornFrozen(info) { return !info.defaultState || info.defaultState === "frozen"; }
79
+
80
+ /* ---------- the four traffic lights ---------- */
81
+ function mkLight(k, items, okSignal) {
82
+ if (!items.length) return { k: k, s: "ok", items: [okSignal] };
83
+ let st = "ok";
84
+ items.forEach(function (x) { if (RANKS[x.s] > RANKS[st]) st = x.s; });
85
+ // na ranks like ok for "worst of", but an unchecked light must stay na so levelFrom can cap it
86
+ if (st === "ok" && items.some(function (x) { return x.s === "na"; })) st = "na";
87
+ return { k: k, s: st, items: items };
88
+ }
89
+
90
+ /**
91
+ * @param info mint info from getMint (+ authKind) or null if it could not be read
92
+ * @param conc concentration from getConc, {skip:true}, or null if the RPC did not answer
93
+ * @param pair best DexScreener pair (required: lights are only built for tokens with a market)
94
+ * @param value USD value the analysed wallet holds (0 when checking a token alone)
95
+ * @param type tokenType(info, pair)
96
+ * @param rc RugCheck summary or null
97
+ * @param launches creatorLaunches result or null: what else the creator launched (last 24 h, and how older ones did)
98
+ * @returns [control, concentration, market, dependency]
99
+ */
100
+ export function buildLights(info, conc, pair, value, type, rc, now, launches) {
101
+ now = now || Date.now();
102
+ const ak = (info && info.authKind) || {};
103
+ const issuer = issuerOf(info);
104
+ let it;
105
+
106
+ // 1) creator control
107
+ it = [];
108
+ if (!info) it.push(sig("NO_INFO", "warn"));
109
+ else {
110
+ // a known issuer's mint, freeze, hook and pause are how the product works (see dependency)
111
+ if (!issuer) {
112
+ if (info.mintAuth && ak[info.mintAuth] !== "program") it.push(sig("CTL_MINT_ACTIVE", "bad"));
113
+ if (info.freezeAuth && ak[info.freezeAuth] !== "program") it.push(sig("CTL_FREEZE_ACTIVE", "warn"));
114
+ if (info.ext.indexOf("transferHook") >= 0 && hookActive(info)) it.push(sig("CTL_TRANSFER_HOOK", "warn"));
115
+ if (info.ext.indexOf("defaultAccountState") >= 0 && bornFrozen(info)) it.push(sig("CTL_DEFAULT_FROZEN", "warn"));
116
+ }
117
+ if (info.ext.indexOf("transferFeeConfig") >= 0) it.push(sig("CTL_TRANSFER_FEE", "warn"));
118
+ if (info.ext.indexOf("nonTransferable") >= 0) it.push(sig("CTL_NON_TRANSFERABLE", "warn"));
119
+ }
120
+ // Launch farms put out a token every few minutes and drop each one as soon as buyers arrive: in a
121
+ // 100-token Pump.fun sample one wallet launched 7 copies of "GMS" in 10 minutes
122
+ if (launches && launches.day >= SERIAL_WARN) it.push(sig("CTL_SERIAL_CREATOR", launches.day >= SERIAL_BAD ? "bad" : "warn", { n: launches.day, creator: launches.creator }));
123
+ // Older launches that went nowhere: farms that spread their tokens over days escape the 24 h count
124
+ if (launches && launches.old >= HISTORY_MIN && launches.dead / launches.old >= HISTORY_WARN) {
125
+ const bad = launches.old >= HISTORY_BAD_MIN && launches.dead / launches.old >= HISTORY_BAD;
126
+ it.push(sig("CTL_CREATOR_HISTORY", bad ? "bad" : "warn", { old: launches.old, dead: launches.dead, best: launches.best, creator: launches.creator }));
127
+ }
128
+ const control = mkLight("control", it, issuer ? sig("CTL_KNOWN_ISSUER", "ok", { issuer: issuer })
129
+ : sig((info && (info.mintAuth || info.freezeAuth)) ? "CTL_ONLY_PROGRAMS" : "CTL_NONE", "ok"));
130
+
131
+ // 2) concentration in people (pools and programs do not count)
132
+ let concentration;
133
+ const soft = (type === "STABLE" || type === "TOKENIZED" || type === "BRIDGED");
134
+ if (conc && conc.skip) concentration = mkLight("concentration", [sig("CON_SKIPPED", "na")]);
135
+ else if (!conc) concentration = mkLight("concentration", [sig("CON_UNAVAILABLE", "na")]);
136
+ else if (conc.ok) {
137
+ let st = "ok";
138
+ if (conc.w1 > 30 || conc.w10 > 70) st = "bad"; else if (conc.w1 > 15 || conc.w10 > 50) st = "warn";
139
+ if (soft && st === "bad") st = "warn";
140
+ const data = { w1: conc.w1, w10: conc.w10, prog: conc.prog, wallets: conc.nW }; // prog >= 20 is worth mentioning
141
+ const code = !conc.nW ? "CON_ALL_POOLS" : (st === "ok" ? "CON_OK" : "CON_HIGH");
142
+ concentration = mkLight("concentration", [sig(code, st, data)]);
143
+ } else {
144
+ concentration = mkLight("concentration", [sig("CON_UNSPLIT", (conc.top1 > 30 || conc.top10 > 70) ? "warn" : "ok", { top1: conc.top1, top10: conc.top10 })]);
145
+ }
146
+
147
+ // 3) market and exit
148
+ const liq = (pair.liquidity && pair.liquidity.usd) || 0, vol = (pair.volume && pair.volume.h24) || 0;
149
+ it = [];
150
+ if (pair.liqRaw) it.push(sig("MKT_LIQ_INFLATED", "bad", { reported: pair.liqRaw, real: liq }));
151
+ else if (liq < 10000) it.push(sig("MKT_LIQ_VERY_LOW", "bad", { liq: liq }));
152
+ else if (liq < 50000) it.push(sig("MKT_LIQ_LOW", "warn", { liq: liq }));
153
+ if (vol < 1000) it.push(sig("MKT_VOLUME_LOW", "warn", { vol: vol }));
154
+ if (pair.pairCreatedAt) {
155
+ const d = (now - pair.pairCreatedAt) / DAY;
156
+ if (d < 7) it.push(sig("MKT_NEW", "warn", { days: Math.floor(d) }));
157
+ }
158
+ if (value > 0 && liq > 0 && value / liq >= 0.1) it.push(sig("MKT_POSITION_HEAVY", "warn", { pct: value / liq * 100 }));
159
+ // Dollars in the pool do not say who can take them out: if the LP tokens are neither locked nor
160
+ // burned, the creator can pull the liquidity at any time whatever the rest says. Only for markets
161
+ // under 90 days: that is when the creator alone provides liquidity and rug pulls are typical. In
162
+ // veteran tokens (JUP, WIF...) many market makers provide it and unlocked LP is not a signal: applying
163
+ // it there gave false positives (JUP scored worse than a new memecoin)
164
+ const young = !pair.pairCreatedAt || (now - pair.pairCreatedAt) / DAY < 90;
165
+ // On Pump.fun's bonding curve there is no LP at all: the curve program holds the liquidity and nobody
166
+ // can pull it (RugCheck reports 0% locked, which flagged 49 of 75 curve tokens in a 100-token sample)
167
+ const onCurve = PUMP_CURVE_DEXES.indexOf(pair.dexId) >= 0;
168
+ if (rc && !soft && young && !onCurve && liq > 0 && typeof rc.lpLockedPct === "number" && isFinite(rc.lpLockedPct)) {
169
+ if (rc.lpLockedPct < 20) it.push(sig("MKT_LP_UNLOCKED", "bad", { pct: rc.lpLockedPct }));
170
+ else if (rc.lpLockedPct < 60) it.push(sig("MKT_LP_PARTLY_LOCKED", "warn", { pct: rc.lpLockedPct }));
171
+ }
172
+ if (type === "BRIDGED" && liq < 50000 && it.length) it[0] = Object.assign({}, it[0], { data: Object.assign({}, it[0].data, { offSolana: true }) });
173
+ const market = mkLight("market", it, sig("MKT_OK", "ok", { liq: liq, vol: vol }));
174
+
175
+ // 4) dependency on an issuer or a bridge
176
+ it = [];
177
+ if (issuer) it.push(sig("DEP_KNOWN_ISSUER", "warn", { issuer: issuer }));
178
+ else if (info) {
179
+ if ((info.mintAuth && ak[info.mintAuth] === "program") || (info.freezeAuth && ak[info.freezeAuth] === "program"))
180
+ it.push(sig("DEP_PROGRAM_AUTHORITY", "warn"));
181
+ if (info.ext.indexOf("permanentDelegate") >= 0) it.push(sig("DEP_PERMANENT_DELEGATE", "warn"));
182
+ }
183
+ if (type === "STABLE") it.push(sig("DEP_STABLE_ISSUER", "warn"));
184
+ const dependency = mkLight("dependency", it, sig("DEP_NONE", "ok"));
185
+
186
+ return [control, concentration, market, dependency];
187
+ }
188
+
189
+ export function levelFrom(lights) {
190
+ let b = 0, w = 0, na = 0;
191
+ lights.forEach(function (l) { if (l.s === "bad") b++; else if (l.s === "warn") w++; else if (l.s === "na") na++; });
192
+ let lvl = (b >= 2 || (b === 1 && w >= 1)) ? "HIGH" : ((b === 1 || w >= 2) ? "MEDIUM" : "LOW");
193
+ // What could not be checked does not count as "fine": with a check missing, at best it is medium risk
194
+ if (lvl === "LOW" && na > 0) lvl = "MEDIUM";
195
+ // A market under 7 days old has no track record: it is never "low risk", even with nothing else wrong.
196
+ // Other single warnings stay low: on established tokens they are normal for their type (a staking pool's
197
+ // program-controlled mint, a transfer hook...); measured on 62 real tokens, this rule moved 9 brand-new
198
+ // tokens from low to medium and no established one
199
+ if (lvl === "LOW" && lights.some(function (l) { return (l.items || []).some(function (x) { return x.code === "MKT_NEW"; }); })) lvl = "MEDIUM";
200
+ return { lvl: lvl, sev: 3 * b + w + (na && !b && !w ? 1 : 0) };
201
+ }
202
+
203
+ // "Medium" with no concrete signal at all: only because some check could not run (e.g. holders without
204
+ // your own RPC). Callers should say so explicitly so the scanner does not look like it marks everything the same
205
+ export function dataOnly(r) {
206
+ 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"; });
207
+ }
208
+
209
+ // The signals worth showing as "why": everything not ok, in light order
210
+ export function reasonsFrom(lights) {
211
+ const out = [];
212
+ 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)); }); });
213
+ return out;
214
+ }
215
+
216
+ /**
217
+ * Full risk assessment of one token.
218
+ *
219
+ * @param client
220
+ * @param mint
221
+ * @param pair best DexScreener pair or null (no market)
222
+ * @param [opts.value] USD the analysed wallet holds of it (for the "heavy position" check)
223
+ * @param [opts.marketUnknown] the DexScreener batch for this mint failed: no data, not "no market"
224
+ * @param [opts.skipConcentration]
225
+ * @param [opts.skipCreator] do not ask RugCheck what else the creator launched (markets under 7 days only)
226
+ * @returns null for NFTs, otherwise {mint, sym, name, lvl, sev, pts, type, lights, reasons, impostor, value, pair, img}
227
+ */
228
+ export async function assess(client, mint, pair, opts) {
229
+ opts = opts || {};
230
+ const value = opts.value || 0;
231
+ if (opts.marketUnknown) {
232
+ return {
233
+ mint: mint, sym: short(mint), name: "", pts: 0, sev: 0, lvl: "NO_DATA",
234
+ reasons: [sig("MARKET_UNCHECKED", "na")], lights: null, type: null, impostor: null, value: value, pair: null, img: null
235
+ };
236
+ }
237
+ const rcPromise = pair ? rugcheckSummary(client, mint) : Promise.resolve(null); // in parallel with the RPC calls below
238
+ // Serial launching only matters while a token is new; RugCheck's full report is small (~10 KB) then
239
+ const fresh = pair && !opts.skipCreator && (!pair.pairCreatedAt || Date.now() - pair.pairCreatedAt < 7 * DAY);
240
+ const launchPromise = fresh ? creatorLaunches(client, mint) : Promise.resolve(null);
241
+ // Copies of the same name and ticker: one search per token, so only when asked (a single token, not a wallet)
242
+ const copiesPromise = pair && opts.copies ? copiesOf(client, mint, pair).catch(function (e) { if (isCancel(e)) throw e; return null; }) : Promise.resolve(null);
243
+ let info = null, conc = null;
244
+ try { info = await getMint(client, mint); } catch (e) { if (isCancel(e)) throw e; }
245
+ if (info && info.decimals === 0 && info.supply === 1) return null; // NFT
246
+ // Without a market concentration is not checked: saves half the requests
247
+ if (info && pair && (info.mintAuth || info.freezeAuth)) {
248
+ try { info.authKind = await getAuthKinds(client, info); } catch (e) { if (isCancel(e)) throw e; }
249
+ }
250
+ if (info && pair && opts.skipConcentration) conc = { skip: true };
251
+ else if (info && pair) {
252
+ // In tokens with millions of holders (BONK) getTokenLargestAccounts sometimes times out: a second try usually works
253
+ for (let tryC = 0; tryC < 2 && !conc; tryC++) {
254
+ try { conc = await getConc(client, mint, info.supply); }
255
+ catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(client.retryDelayMs === undefined ? 1500 : client.retryDelayMs); }
256
+ }
257
+ }
258
+ const rc = await rcPromise, launches = await launchPromise, copies = await copiesPromise;
259
+ const base = (pair && pair.baseToken) || {};
260
+ let lvl, sev = 0, reasons = [], lights = null, type = null, pts = 0, imp = null;
261
+ if (pair) {
262
+ type = tokenType(info, pair);
263
+ lights = buildLights(info, conc, pair, value, type, rc, null, launches);
264
+ imp = impostorOf(mint, pair);
265
+ const brand = imp ? null : brandOf(pair, type);
266
+ // copying a famous token, a real company or an older token with the same name and ticker
267
+ const front = [];
268
+ if (imp) front.push(sig("IMPOSTOR", "bad", { symbol: imp.sym, real: imp.real }));
269
+ if (brand) front.push(sig(brand.exact ? "BRAND_COPY" : "BRAND_NAME", brand.exact ? "bad" : "warn", { brand: brand.brand }));
270
+ if (copies && !imp) {
271
+ const strong = copies.origLiq >= 50000 && copies.origLiq >= 10 * Math.max(copies.liq, 1);
272
+ front.push(sig("COPYCAT", strong ? "bad" : "warn", copies));
273
+ }
274
+ if (front.length) {
275
+ const prev = lights[0], st = front.some(function (x) { return x.s === "bad"; }) ? "bad" : (prev.s === "bad" ? "bad" : "warn");
276
+ lights[0] = { k: "control", s: st, items: front.concat(prev.s !== "ok" ? prev.items : []) };
277
+ }
278
+ const lf = levelFrom(lights); lvl = lf.lvl; sev = lf.sev; reasons = reasonsFrom(lights);
279
+ // a copy of a famous token or a real company, or liquidity faked 100 times over, is high risk even
280
+ // when everything else looks normal: it is built to look like something it is not
281
+ if (imp || (brand && brand.exact) || fakeLiquidity(pair)) { if (lvl !== "HIGH") sev += 5; lvl = "HIGH"; }
282
+ } else {
283
+ const s = score(info);
284
+ pts = s.pts; reasons = s.why; lvl = pts >= 25 ? "SPAM" : "NO_MARKET";
285
+ }
286
+ return {
287
+ mint: mint, sym: base.symbol || short(mint), name: base.name || "", pts: pts, sev: sev,
288
+ lvl: lvl, type: type, lights: lights, reasons: reasons, impostor: imp, copies: copies, value: value, pair: pair,
289
+ rugcheck: rc ? { lpLockedPct: rc.lpLockedPct, score: rc.score_normalised !== undefined ? rc.score_normalised : rc.score } : null,
290
+ creator: launches,
291
+ img: (pair && pair.info && pair.info.imageUrl) || null
292
+ };
293
+ }