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