solana-risk-engine 0.4.8 → 0.4.9
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 +1 -1
- package/package.json +1 -1
- package/src/constants.js +21 -0
- package/src/i18n/en.js +1 -0
- package/src/i18n/es.js +1 -0
- package/src/risk.js +15 -6
- package/src/tokens.js +7 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ for (const l of r.lights) // the four traffic lights
|
|
|
31
31
|
| **Serial creators** | For markets under 7 days: how many other tokens the creator launched in the last 24 h (RugCheck). 2 or more is a warning, 5 or more a red flag. `recentLaunches(client, wallet)` counts the same on chain for the wallet that *paid* a launch, which catches farms that name a fresh creator each time. |
|
|
32
32
|
| **Impostors** | Tokens using the symbol or name of a famous token (USDC, JUP, BONK, WIF, BTC, ETH...) with a different address. |
|
|
33
33
|
| **Real brands** | About 320 real companies with no token on Solana (Spotify, Nike, Zara, Ferrari...). A token called just that is a red flag and **HIGH**; the brand as one word of a longer name is a warning. Everyday words that are also brands (Shell, Target, Puma) are left out, and markets at least 30 days old with $100k+ liquidity are not checked. |
|
|
34
|
-
| **Known issuers and official tokens** |
|
|
34
|
+
| **Known issuers and official tokens** | Stablecoins from Paxos (PYUSD, USDG), Circle (USDC, EURC) and Tether (USDT), tokenized shares from xStocks (all recognised by their control keys, taken from mints the issuer lists on its own pages) and official tokens from exchanges and Socios.com (bnSOL, bbSOL, cbBTC, club fan tokens..., by mint address) are shown as dependency on the issuer. Their large holders (treasury, custodians, exchanges) are shown but do not count as concentration, a 0% transfer fee is not a warning, and a tiny Solana pool is a warning, not a red flag. |
|
|
35
35
|
| **Token type** | `STABLE`, `TOKENIZED`, `BRIDGED`, `CREATOR`, `OPEN`. Each type is judged by the rules that fit it: a bridged token normally has a program as mint authority, a memecoin does not. |
|
|
36
36
|
| **Bots and exchanges** | Wallets that made 200 transactions in under 2 days are marked `active` and are not counted as whales. |
|
|
37
37
|
| **Funder clusters** | The fee payer of each holder's *first* transaction reveals who created it. Holders sharing a funder are grouped, since that is often one team splitting the supply. Exchange hot wallets are excluded as sources. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solana-risk-engine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Explainable risk analysis for Solana tokens and wallets: creator control, real holder concentration, inflated liquidity, impostor tokens, funder clusters and PnL. Zero dependencies, runs in Node and the browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/constants.js
CHANGED
|
@@ -162,6 +162,27 @@ export const ISSUERS = [
|
|
|
162
162
|
"JDq14BWvqCRFNu1krb12bcRpbGtJZ1FLEakMw6FdxJNs", // freeze and pause (program)
|
|
163
163
|
"5aMNNLQJwAEeoemTEMkv5NVjqKwvvefRYCQ5Z67HFvEq", // permanent delegate, transfer hook, confidential transfers
|
|
164
164
|
"S7vYFFWH6BjJyEsdrPQpqpYTqLTrPRK6KW3VwsJuRaS" // share-split multiplier (scaled UI amount)
|
|
165
|
+
] },
|
|
166
|
+
// Each mint below is listed by its issuer (URL); the keys are the ones those mints carry on-chain (1/11/2026).
|
|
167
|
+
// An issuer is only added with an official page for its mints: FDUSD was left out for lack of one.
|
|
168
|
+
{ name: "Paxos", keys: [
|
|
169
|
+
// PYUSD 2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo https://docs.paxos.com/guides/stablecoin/pyusd/mainnet
|
|
170
|
+
// USDG 2u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWH https://docs.paxos.com/guides/stablecoin/usdg/mainnet
|
|
171
|
+
"AxJkHRY3puoofyj7Pn512M1SGBgWroXMkEdNPQypkzbu", // PYUSD mint
|
|
172
|
+
"2e99QgqSDtiJSi5omGRVeZJuqNFRZdpy2YgG3mBd1AB4", // USDG mint
|
|
173
|
+
"2apBGMsS6ti9RyF5TwQTDswXBWskiJP2LD4cUEDqYJjk" // freeze, permanent delegate, hook, fee (both)
|
|
174
|
+
] },
|
|
175
|
+
{ name: "Circle", keys: [
|
|
176
|
+
// USDC EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v, EURC HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr
|
|
177
|
+
// https://developers.circle.com/stablecoins/usdc-contract-addresses , .../eurc-contract-addresses
|
|
178
|
+
"BJE5MMbqXjVwjAF7oxwPYXnTXDyspzZyt4vwenNw5ruG", // USDC mint
|
|
179
|
+
"7dGbd2QZcCKcTndnHcTL8q7SMVXAkp688NTQYwrRCrar", // USDC freeze
|
|
180
|
+
"Hy9168u7b2Toujh8SJKtKK8DWbyaBbXPzxm337R4o4XY", // EURC mint
|
|
181
|
+
"6FF2CPcL6fat16QVJH6W27DeBcX7TSqbwY3a5wtyVJLC" // EURC freeze
|
|
182
|
+
] },
|
|
183
|
+
{ name: "Tether", keys: [
|
|
184
|
+
// USDT Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB (FAMOUS.USDT)
|
|
185
|
+
"Q6XprfkF8RQQKoQVG33xT88H7wi8Uk1B1CC7YAs69Gi" // USDT mint and freeze
|
|
165
186
|
] }
|
|
166
187
|
];
|
|
167
188
|
|
package/src/i18n/en.js
CHANGED
|
@@ -49,6 +49,7 @@ export default {
|
|
|
49
49
|
CON_UNAVAILABLE: () => "Could not check holder concentration (the service did not answer; try again in a while)",
|
|
50
50
|
CON_ALL_POOLS: (d) => "Almost all the supply is in pools or programs" + prog(d),
|
|
51
51
|
CON_OK: (d) => "No worrying concentration (largest individual wallet: " + pct(d.w1) + ")" + prog(d),
|
|
52
|
+
CON_ISSUER: (d) => "The largest wallet holds " + pct(d.w1) + " and the top 10 hold " + pct(d.w10) + ": with a verified issuer these are usually its treasury, custodians, exchanges or protocols, so it does not count as a risk" + prog(d),
|
|
52
53
|
CON_HIGH: (d) => "The largest individual wallet holds " + pct(d.w1) + " and the top 10 hold " + pct(d.w10) + " of the supply" + prog(d),
|
|
53
54
|
CON_UNSPLIT: (d) => "Could not separate pools from people. The largest account holds " + pct(d.top1) + " of the supply",
|
|
54
55
|
|
package/src/i18n/es.js
CHANGED
|
@@ -52,6 +52,7 @@ export default {
|
|
|
52
52
|
CON_UNAVAILABLE: () => "No se pudo comprobar la concentración de holders (el servicio no respondió; prueba otra vez en un rato)",
|
|
53
53
|
CON_ALL_POOLS: (d) => "Casi todo el supply está en pools o programas" + prog(d),
|
|
54
54
|
CON_OK: (d) => "Sin concentración preocupante (mayor wallet individual: " + pct(d.w1) + ")" + prog(d),
|
|
55
|
+
CON_ISSUER: (d) => "La mayor wallet tiene el " + pct(d.w1) + " y las 10 mayores el " + pct(d.w10) + ": con un emisor verificado suelen ser su tesorería, custodios, exchanges o protocolos, no cuenta como riesgo" + prog(d),
|
|
55
56
|
CON_HIGH: (d) => "La mayor wallet individual tiene el " + pct(d.w1) + " y las 10 mayores el " + pct(d.w10) + " del supply" + prog(d),
|
|
56
57
|
CON_UNSPLIT: (d) => "No se pudo separar pools de personas. La mayor cuenta tiene el " + pct(d.top1) + " del supply",
|
|
57
58
|
|
package/src/risk.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EXT_POINTS, RANKS, PUMP_CURVE_DEXES, ISSUERS, OFFICIAL_TOKENS } from "./constants.js";
|
|
1
|
+
import { EXT_POINTS, RANKS, PUMP_CURVE_DEXES, ISSUERS, OFFICIAL_TOKENS, FAMOUS } from "./constants.js";
|
|
2
2
|
import { isCancel } from "./errors.js";
|
|
3
3
|
import { short, sleep } from "./util.js";
|
|
4
4
|
import { getMint, getAuthKinds, getConc } from "./tokens.js";
|
|
@@ -115,7 +115,8 @@ export function buildLights(info, conc, pair, value, type, rc, now, launches) {
|
|
|
115
115
|
if (info.ext.indexOf("transferHook") >= 0 && hookActive(info)) it.push(sig("CTL_TRANSFER_HOOK", "warn"));
|
|
116
116
|
if (info.ext.indexOf("defaultAccountState") >= 0 && bornFrozen(info)) it.push(sig("CTL_DEFAULT_FROZEN", "warn"));
|
|
117
117
|
}
|
|
118
|
-
|
|
118
|
+
// PYUSD and USDG carry the fee extension at 0%: under a known issuer that is how the product is built
|
|
119
|
+
if (info.ext.indexOf("transferFeeConfig") >= 0 && !(issuer && info.feeBps === 0)) it.push(sig("CTL_TRANSFER_FEE", "warn"));
|
|
119
120
|
if (info.ext.indexOf("nonTransferable") >= 0) it.push(sig("CTL_NON_TRANSFERABLE", "warn"));
|
|
120
121
|
}
|
|
121
122
|
// Launch farms put out a token every few minutes and drop each one as soon as buyers arrive: in a
|
|
@@ -139,7 +140,10 @@ export function buildLights(info, conc, pair, value, type, rc, now, launches) {
|
|
|
139
140
|
if (conc.w1 > 30 || conc.w10 > 70) st = "bad"; else if (conc.w1 > 15 || conc.w10 > 50) st = "warn";
|
|
140
141
|
if (soft && st === "bad") st = "warn";
|
|
141
142
|
const data = { w1: conc.w1, w10: conc.w10, prog: conc.prog, wallets: conc.nW }; // prog >= 20 is worth mentioning
|
|
142
|
-
|
|
143
|
+
let code = !conc.nW ? "CON_ALL_POOLS" : (st === "ok" ? "CON_OK" : "CON_HIGH");
|
|
144
|
+
// under a verified issuer the big holders are its treasury, custodians, exchanges and lending
|
|
145
|
+
// protocols, not someone about to dump: shown, not counted (PYUSD 56% in one wallet, 1/11)
|
|
146
|
+
if (issuer && code === "CON_HIGH") { code = "CON_ISSUER"; st = "ok"; }
|
|
143
147
|
concentration = mkLight("concentration", [sig(code, st, data)]);
|
|
144
148
|
} else {
|
|
145
149
|
concentration = mkLight("concentration", [sig("CON_UNSPLIT", (conc.top1 > 30 || conc.top10 > 70) ? "warn" : "ok", { top1: conc.top1, top10: conc.top10 })]);
|
|
@@ -148,7 +152,9 @@ export function buildLights(info, conc, pair, value, type, rc, now, launches) {
|
|
|
148
152
|
// 3) market and exit
|
|
149
153
|
const liq = (pair.liquidity && pair.liquidity.usd) || 0, vol = (pair.volume && pair.volume.h24) || 0;
|
|
150
154
|
it = [];
|
|
151
|
-
|
|
155
|
+
// a verified issuer's token with little real liquidity on Solana trades elsewhere: not a trap
|
|
156
|
+
if (pair.liqRaw && issuer) it.push(sig(liq < 10000 ? "MKT_LIQ_VERY_LOW" : "MKT_LIQ_LOW", "warn", { liq: liq, offSolana: true }));
|
|
157
|
+
else if (pair.liqRaw) it.push(sig("MKT_LIQ_INFLATED", "bad", { reported: pair.liqRaw, real: liq }));
|
|
152
158
|
else if (liq < 10000) it.push(sig("MKT_LIQ_VERY_LOW", "bad", { liq: liq }));
|
|
153
159
|
else if (liq < 50000) it.push(sig("MKT_LIQ_LOW", "warn", { liq: liq }));
|
|
154
160
|
if (vol < 1000) it.push(sig("MKT_VOLUME_LOW", "warn", { vol: vol }));
|
|
@@ -274,7 +280,10 @@ export async function assess(client, mint, pair, opts) {
|
|
|
274
280
|
const front = [];
|
|
275
281
|
if (imp) front.push(sig("IMPOSTOR", "bad", { symbol: imp.sym, real: imp.real }));
|
|
276
282
|
if (brand) front.push(sig(brand.exact ? "BRAND_COPY" : "BRAND_NAME", brand.exact ? "bad" : "warn", { brand: brand.brand }));
|
|
277
|
-
|
|
283
|
+
// the real USDC/USDT is rarely the base token of a DexScreener pair, so the search found an older
|
|
284
|
+
// "USDC" and called the real one the copy (USDC and USDT showed HIGH, 1/11)
|
|
285
|
+
const canon = Object.keys(FAMOUS).some(function (k) { return FAMOUS[k] === mint; }) || !!issuerOf(info, mint);
|
|
286
|
+
if (copies && !imp && !official && !canon) {
|
|
278
287
|
const strong = copies.origLiq >= 50000 && copies.origLiq >= 10 * Math.max(copies.liq, 1);
|
|
279
288
|
front.push(sig("COPYCAT", strong ? "bad" : "warn", copies));
|
|
280
289
|
}
|
|
@@ -285,7 +294,7 @@ export async function assess(client, mint, pair, opts) {
|
|
|
285
294
|
const lf = levelFrom(lights); lvl = lf.lvl; sev = lf.sev; reasons = reasonsFrom(lights);
|
|
286
295
|
// a copy of a famous token or a real company, or liquidity faked 100 times over, is high risk even
|
|
287
296
|
// 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"; }
|
|
297
|
+
if (imp || (brand && brand.exact) || (fakeLiquidity(pair) && !issuerOf(info, mint))) { if (lvl !== "HIGH") sev += 5; lvl = "HIGH"; }
|
|
289
298
|
} else {
|
|
290
299
|
const s = score(info);
|
|
291
300
|
pts = s.pts; reasons = s.why; lvl = pts >= 25 ? "SPAM" : "NO_MARKET";
|
package/src/tokens.js
CHANGED
|
@@ -45,11 +45,18 @@ export async function getMint(client, mint) {
|
|
|
45
45
|
// "default account state" only matters when new accounts are born frozen
|
|
46
46
|
hook: { program: hook.programId || null, auth: hook.authority || null },
|
|
47
47
|
defaultState: state("defaultAccountState").accountState || null,
|
|
48
|
+
// transfer fee in basis points (the higher of the current and the scheduled one); null when not set
|
|
49
|
+
feeBps: feeBps(state("transferFeeConfig")),
|
|
48
50
|
// every key that controls something in the extensions (delegate, hook, pause, metadata...)
|
|
49
51
|
extKeys: extKeys(exts)
|
|
50
52
|
};
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
function feeBps(s) {
|
|
56
|
+
const a = s.newerTransferFee && s.newerTransferFee.transferFeeBasisPoints, b = s.olderTransferFee && s.olderTransferFee.transferFeeBasisPoints;
|
|
57
|
+
return (typeof a === "number" || typeof b === "number") ? Math.max(Number(a) || 0, Number(b) || 0) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
function extKeys(exts) {
|
|
54
61
|
const out = [];
|
|
55
62
|
exts.forEach(function (e) {
|