solana-risk-engine 0.4.1 → 0.4.3

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,268 @@
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 } 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
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
+ // Copies of the same name and ticker: one search per token, so only when asked (a single token, not a wallet)
217
+ const copiesPromise = pair && opts.copies ? copiesOf(client, mint, pair).catch(function (e) { if (isCancel(e)) throw e; return null; }) : Promise.resolve(null);
218
+ let info = null, conc = null;
219
+ try { info = await getMint(client, mint); } catch (e) { if (isCancel(e)) throw e; }
220
+ if (info && info.decimals === 0 && info.supply === 1) return null; // NFT
221
+ // Without a market concentration is not checked: saves half the requests
222
+ if (info && pair && (info.mintAuth || info.freezeAuth)) {
223
+ try { info.authKind = await getAuthKinds(client, info); } catch (e) { if (isCancel(e)) throw e; }
224
+ }
225
+ if (info && pair && opts.skipConcentration) conc = { skip: true };
226
+ else if (info && pair) {
227
+ // In tokens with millions of holders (BONK) getTokenLargestAccounts sometimes times out: a second try usually works
228
+ for (let tryC = 0; tryC < 2 && !conc; tryC++) {
229
+ try { conc = await getConc(client, mint, info.supply); }
230
+ catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(client.retryDelayMs === undefined ? 1500 : client.retryDelayMs); }
231
+ }
232
+ }
233
+ const rc = await rcPromise, launches = await launchPromise, copies = await copiesPromise;
234
+ const base = (pair && pair.baseToken) || {};
235
+ let lvl, sev = 0, reasons = [], lights = null, type = null, pts = 0, imp = null;
236
+ if (pair) {
237
+ type = tokenType(info, pair);
238
+ lights = buildLights(info, conc, pair, value, type, rc, null, launches);
239
+ imp = impostorOf(mint, pair);
240
+ const brand = imp ? null : brandOf(pair, type);
241
+ // copying a famous token, a real company or an older token with the same name and ticker
242
+ const front = [];
243
+ if (imp) front.push(sig("IMPOSTOR", "bad", { symbol: imp.sym, real: imp.real }));
244
+ if (brand) front.push(sig(brand.exact ? "BRAND_COPY" : "BRAND_NAME", brand.exact ? "bad" : "warn", { brand: brand.brand }));
245
+ if (copies && !imp) {
246
+ const strong = copies.origLiq >= 50000 && copies.origLiq >= 10 * Math.max(copies.liq, 1);
247
+ front.push(sig("COPYCAT", strong ? "bad" : "warn", copies));
248
+ }
249
+ if (front.length) {
250
+ const prev = lights[0], st = front.some(function (x) { return x.s === "bad"; }) ? "bad" : (prev.s === "bad" ? "bad" : "warn");
251
+ lights[0] = { k: "control", s: st, items: front.concat(prev.s !== "ok" ? prev.items : []) };
252
+ }
253
+ const lf = levelFrom(lights); lvl = lf.lvl; sev = lf.sev; reasons = reasonsFrom(lights);
254
+ // a copy of a famous token or a real company, or liquidity faked 100 times over, is high risk even
255
+ // when everything else looks normal: it is built to look like something it is not
256
+ if (imp || (brand && brand.exact) || fakeLiquidity(pair)) { if (lvl !== "HIGH") sev += 5; lvl = "HIGH"; }
257
+ } else {
258
+ const s = score(info);
259
+ pts = s.pts; reasons = s.why; lvl = pts >= 25 ? "SPAM" : "NO_MARKET";
260
+ }
261
+ return {
262
+ mint: mint, sym: base.symbol || short(mint), name: base.name || "", pts: pts, sev: sev,
263
+ lvl: lvl, type: type, lights: lights, reasons: reasons, impostor: imp, copies: copies, value: value, pair: pair,
264
+ rugcheck: rc ? { lpLockedPct: rc.lpLockedPct, score: rc.score_normalised !== undefined ? rc.score_normalised : rc.score } : null,
265
+ creator: launches,
266
+ img: (pair && pair.info && pair.info.imageUrl) || null
267
+ };
268
+ }