solana-risk-engine 0.1.0 → 0.2.0

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
@@ -24,9 +24,9 @@ for (const l of r.lights) // the four traffic lights
24
24
 
25
25
  | Check | How |
26
26
  |---|---|
27
- | **Creator control** | Mint/freeze authority still active, and whether it sits in a person's wallet or a program (bridge/protocol). For keys with no account, it checks whether the key is on the ed25519 curve. Risky Token-2022 extensions: permanent delegate, transfer hook, transfer fee, non-transferable, default-frozen accounts. |
27
+ | **Creator control** | Mint/freeze authority still active, and whether it sits in a person's wallet or a program (bridge, protocol, staking pool). A key off the ed25519 curve is always a program (PDA), even when the System Program owns its account. Risky Token-2022 extensions: permanent delegate, transfer hook, transfer fee, non-transferable, default-frozen accounts. |
28
28
  | **Real concentration** | Largest accounts are traced to their owners. Pools and programs are separated from people, so `w1`/`w10` measure *people*, not AMM vaults. |
29
- | **Inflated liquidity** | DexScreener values both sides of a pool at the pool's price. A pool with 4B of a token against 2 USDC shows "$4,000M liquidity". The engine recomputes it from the real side (USDC/USDT/SOL) only. |
29
+ | **Inflated liquidity** | DexScreener values both sides of a pool at the pool's price. A pool with 4B of a token against 2 USDC shows "$4,000M liquidity". The engine recomputes it from the real side (USDC/USDT/SOL). It only flags extreme gaps (real side under 5%), because concentrated-liquidity pools are lopsided by nature. |
30
30
  | **Unlocked LP** | % of LP locked/burned via RugCheck. It only counts for markets under 90 days old, so veteran tokens don't get false positives. |
31
31
  | **Impostors** | Tokens using the symbol or name of a famous token (USDC, JUP, BONK, WIF...) with a different address. |
32
32
  | **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. |
@@ -37,7 +37,16 @@ for (const l of r.lights) // the four traffic lights
37
37
  | **Confluence** | Tokens held in common by a group of wallets, each one risk-assessed. Also the reverse: people who are top holders of several tokens. |
38
38
  | **Change tracking** | Snapshots and diffs (new / bought more / sold part / sold all) per wallet and aggregated across many wallets. |
39
39
 
40
- Anything that could not be checked is marked `na`. It never counts as "fine": a token with a missing check can be **medium** at best, and `dataOnly(r)` tells you when that is the only reason.
40
+ ### How the level is decided
41
+
42
+ - Two red lights, or one red and one yellow, give **HIGH**.
43
+ - One red, or two yellows, give **MEDIUM**.
44
+ - Otherwise the level is **LOW**, with two exceptions that raise it to **MEDIUM**:
45
+ - **A check could not run.** Lights that could not be checked are marked `na`, and they never count as "fine". `dataOnly(r)` tells you when this is the only reason.
46
+ - **The market is under 7 days old.** It has no track record yet.
47
+ - An impostor of a famous token is always **HIGH**.
48
+
49
+ A single yellow on an established token stays LOW, because those warnings are normal for its type (a staking pool's program-controlled mint, for example). This was measured on 62 real tokens: the 7-day rule moved 9 brand-new tokens from low to medium and no established one.
41
50
 
42
51
  ## Install
43
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solana-risk-engine",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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/client.js CHANGED
@@ -139,5 +139,5 @@ export function createClient(opts) {
139
139
  return rpcAt(vibeRpc, method, params, 6); // more retries: its limit sometimes trips even when the queue is respected
140
140
  }
141
141
 
142
- return { rpc: rpc, jfetch: jfetch, softJson: softJson, ownRpc: ownRpc, heliusKey: heliusKey, checkAbort: checkAbort, rpcUrl: rpcUrl };
142
+ return { rpc: rpc, jfetch: jfetch, softJson: softJson, ownRpc: ownRpc, heliusKey: heliusKey, checkAbort: checkAbort, rpcUrl: rpcUrl, retryDelayMs: opts.retryDelayMs };
143
143
  }
package/src/index.js CHANGED
@@ -43,8 +43,8 @@ export { createClient };
43
43
  export { ScanError, isCancel } from "./errors.js";
44
44
  export * from "./constants.js";
45
45
  export { isOnCurve, onCurve, b58bytes } from "./curve.js";
46
- export { getTokens, getMint, getConc, getAuthKinds, isMint, accountKinds } from "./tokens.js";
47
- export { getMarkets, realLiq, impostorOf } from "./markets.js";
46
+ export { getTokens, getMint, getConc, getAuthKinds, isMint, accountKinds, kindOf } from "./tokens.js";
47
+ export { getMarkets, realLiq, impostorOf, INFLATED_RATIO } from "./markets.js";
48
48
  export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances } from "./rugcheck.js";
49
49
  export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess } from "./risk.js";
50
50
  export { analyzeToken, analyzeWallet, analyzeAddress, sortResults } from "./wallet.js";
package/src/markets.js CHANGED
@@ -5,14 +5,19 @@ import { sleep } from "./util.js";
5
5
  // DexScreener values both sides of a pool at the pool's own price. A scammer creates a pool with
6
6
  // 4 billion of their token against 2 real USDC and it shows "liquidity $4,000M". What you can actually
7
7
  // take out when selling is the real-token side (USDC, USDT or SOL): if that is much smaller, it wins.
8
+ // Concentrated-liquidity pools (Orca Whirlpools, Raydium CLMM, Meteora DLMM) are lopsided by nature,
9
+ // often 10-20% real side, so only an extreme gap counts: real side under 5% of the reported value.
10
+ // The scams this targets sit far below that (2 USDC against "$4,000M" is 0.00000005%).
8
11
  // Mutates the pair: keeps the reported figure in liqRaw and replaces liquidity.usd with the real one.
12
+ export const INFLATED_RATIO = 0.05;
13
+
9
14
  export function realLiq(p) {
10
15
  const l = p.liquidity, qa = p.quoteToken && p.quoteToken.address;
11
16
  if (!l || !l.usd || (qa !== USDC && qa !== USDT && qa !== WSOL)) return;
12
17
  const pn = Number(p.priceNative), pu = Number(p.priceUsd), q = Number(l.quote);
13
18
  if (!(pn > 0 && pu > 0 && q >= 0)) return;
14
19
  const real = 2 * q * pu / pn; // pu/pn = USD price of the real token
15
- if (real < 0.25 * l.usd) { p.liqRaw = l.usd; p.liquidity = Object.assign({}, l, { usd: real }); }
20
+ if (real < INFLATED_RATIO * l.usd) { p.liqRaw = l.usd; p.liquidity = Object.assign({}, l, { usd: real }); }
16
21
  }
17
22
 
18
23
  // A token using the symbol (or name) of a famous one but with another address is a copy
package/src/risk.js CHANGED
@@ -151,6 +151,11 @@ export function levelFrom(lights) {
151
151
  let lvl = (b >= 2 || (b === 1 && w >= 1)) ? "HIGH" : ((b === 1 || w >= 2) ? "MEDIUM" : "LOW");
152
152
  // What could not be checked does not count as "fine": with a check missing, at best it is medium risk
153
153
  if (lvl === "LOW" && na > 0) lvl = "MEDIUM";
154
+ // A market under 7 days old has no track record: it is never "low risk", even with nothing else wrong.
155
+ // Other single warnings stay low: on established tokens they are normal for their type (a staking pool's
156
+ // program-controlled mint, a transfer hook...); measured on 62 real tokens, this rule moved 9 brand-new
157
+ // tokens from low to medium and no established one
158
+ if (lvl === "LOW" && lights.some(function (l) { return (l.items || []).some(function (x) { return x.code === "MKT_NEW"; }); })) lvl = "MEDIUM";
154
159
  return { lvl: lvl, sev: 3 * b + w + (na && !b && !w ? 1 : 0) };
155
160
  }
156
161
 
@@ -200,7 +205,7 @@ export async function assess(client, mint, pair, opts) {
200
205
  // In tokens with millions of holders (BONK) getTokenLargestAccounts sometimes times out: a second try usually works
201
206
  for (let tryC = 0; tryC < 2 && !conc; tryC++) {
202
207
  try { conc = await getConc(client, mint, info.supply); }
203
- catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(opts.retryDelayMs === undefined ? 1500 : opts.retryDelayMs); }
208
+ catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(client.retryDelayMs === undefined ? 1500 : client.retryDelayMs); }
204
209
  }
205
210
  }
206
211
  const rc = await rcPromise;
package/src/rugcheck.js CHANGED
@@ -1,6 +1,6 @@
1
- import { RUGCHECK, SYSTEM_PROGRAM } from "./constants.js";
1
+ import { RUGCHECK } from "./constants.js";
2
2
  import { isCancel } from "./errors.js";
3
- import { onCurve, b58bytes } from "./curve.js";
3
+ import { kindOf } from "./tokens.js";
4
4
  import { pool } from "./util.js";
5
5
 
6
6
  // Extra signal from RugCheck.xyz (public API, no key): mainly the % of liquidity locked or burned,
@@ -42,7 +42,7 @@ export async function netBalances(client, mint, graph) {
42
42
  const part = addrs.slice(i, i + 100);
43
43
  try {
44
44
  const oa = await client.rpc("getMultipleAccounts", [part, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
45
- part.forEach(function (a, k) { const v = oa.value[k]; kind[a] = v ? (v.owner === SYSTEM_PROGRAM ? "wallet" : "program") : (onCurve(b58bytes(a)) ? "wallet" : "program"); });
45
+ part.forEach(function (a, k) { kind[a] = kindOf(a, oa.value[k], true); });
46
46
  } catch (e) { if (isCancel(e)) throw e; }
47
47
  }
48
48
  await pool(addrs, 4, async function (a) {
package/src/tokens.js CHANGED
@@ -41,16 +41,21 @@ export async function getMint(client, mint) {
41
41
  };
42
42
  }
43
43
 
44
- // "wallet" or "program" for each address, from who owns its account (or the curve when it has none)
45
- export async function accountKinds(client, addrs, curveFallback) {
44
+ // A key off the ed25519 curve has no private key: only a program can sign for it (a PDA), whoever
45
+ // owns its account. PDAs that hold SOL (stake pool and AMM authorities) are owned by the System
46
+ // Program like any wallet, so the owner alone would count them as people.
47
+ export function kindOf(key, account, missingIsWallet) {
48
+ if (!onCurve(b58bytes(key))) return "program";
49
+ if (account) return account.owner === SYSTEM_PROGRAM ? "wallet" : "program";
50
+ return missingIsWallet ? "wallet" : "program";
51
+ }
52
+
53
+ // "wallet" or "program" for each address
54
+ export async function accountKinds(client, addrs, missingIsWallet) {
46
55
  if (!addrs.length) return {};
47
56
  const r = await client.rpc("getMultipleAccounts", [addrs, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
48
57
  const out = {};
49
- addrs.forEach(function (k, i) {
50
- const v = r.value[i];
51
- if (v) out[k] = v.owner === SYSTEM_PROGRAM ? "wallet" : "program";
52
- else out[k] = curveFallback ? (onCurve(b58bytes(k)) ? "wallet" : "program") : "program";
53
- });
58
+ addrs.forEach(function (k, i) { out[k] = kindOf(k, r.value[i], missingIsWallet); });
54
59
  return out;
55
60
  }
56
61
 
@@ -59,7 +64,7 @@ export async function getAuthKinds(client, info) {
59
64
  const keys = [];
60
65
  [info.mintAuth, info.freezeAuth].forEach(function (a) { if (a && keys.indexOf(a) < 0) keys.push(a); });
61
66
  if (!keys.length) return {};
62
- return accountKinds(client, keys, true); // no account: an empty wallet is on the curve, a PDA is not
67
+ return accountKinds(client, keys, true); // no account but on the curve: an empty wallet
63
68
  }
64
69
 
65
70
  // Concentration of the supply. Each large account is traced to its owner: a pool or a program