solana-risk-engine 0.4.7 → 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 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** | Tokenized shares from xStocks (recognised by their control keys) 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 Solana pools are often tiny because they trade on the exchange: that is a warning, not a red flag. |
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.7",
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
@@ -43,12 +43,13 @@ export default {
43
43
  COPYCAT: (d) => "There " + (d.n === 1 ? "is another token" : "are " + d.n + " more tokens") + " with the same name and ticker; the oldest one with a real market (" + short(d.original) + ", " + d.days + (d.days === 1 ? " day" : " days") + " old) is not this one: check you have the right address",
44
44
  HOLD_GROUP: (d) => d.n + " holders got their first SOL from the same wallet and together hold " + pct(d.pct) + " of the supply: most likely one person or team",
45
45
  CTL_CREATOR_HISTORY: (d) => "Of the " + d.old + " tokens the creator launched before (over 24 h ago), " + d.dead + " stayed under $10,000 market cap (per RugCheck): their launches rarely take off",
46
- IMPOSTOR: (d) => "Impersonates " + d.symbol + ": the real " + d.symbol + " has another address (" + short(d.real) + "). It is a copy, almost certainly a scam",
46
+ IMPOSTOR: (d) => "Not the original " + d.symbol + ". Make sure it is the token you want",
47
47
 
48
48
  CON_SKIPPED: () => "Holder concentration not checked",
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
@@ -46,12 +46,13 @@ export default {
46
46
  COPYCAT: (d) => "Hay " + (d.n === 1 ? "otro token" : d.n + " tokens más") + " con el mismo nombre y ticker; el más antiguo con mercado real (" + short(d.original) + ", de hace " + d.days + (d.days === 1 ? " día" : " días") + ") no es este: comprueba que tienes la dirección correcta",
47
47
  HOLD_GROUP: (d) => d.n + " holders recibieron su primer SOL de la misma wallet y juntos tienen el " + pct(d.pct) + " del suministro: probablemente son la misma persona o equipo",
48
48
  CTL_CREATOR_HISTORY: (d) => "De los " + d.old + " tokens que el creador lanzó antes (hace más de 24 h), " + d.dead + " se quedaron por debajo de 10.000 $ de capitalización (según RugCheck): sus lanzamientos no suelen despegar",
49
- IMPOSTOR: (d) => "Se hace pasar por " + d.symbol + ": el " + d.symbol + " de verdad tiene otra dirección (" + short(d.real) + "). Es una copia, casi seguro una estafa",
49
+ IMPOSTOR: (d) => "No es el " + d.symbol + " original. Comprueba que es el token que buscas",
50
50
 
51
51
  CON_SKIPPED: () => "Sin comprobar la concentración de holders",
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
- if (info.ext.indexOf("transferFeeConfig") >= 0) it.push(sig("CTL_TRANSFER_FEE", "warn"));
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
- const code = !conc.nW ? "CON_ALL_POOLS" : (st === "ok" ? "CON_OK" : "CON_HIGH");
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
- if (pair.liqRaw) it.push(sig("MKT_LIQ_INFLATED", "bad", { reported: pair.liqRaw, real: liq }));
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
- if (copies && !imp && !official) {
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) {