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 +12 -3
- package/package.json +1 -1
- package/src/client.js +1 -1
- package/src/index.js +2 -2
- package/src/markets.js +6 -1
- package/src/risk.js +6 -1
- package/src/rugcheck.js +3 -3
- package/src/tokens.js +13 -8
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
|
|
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
|
-
|
|
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.
|
|
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 <
|
|
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(
|
|
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
|
|
1
|
+
import { RUGCHECK } from "./constants.js";
|
|
2
2
|
import { isCancel } from "./errors.js";
|
|
3
|
-
import {
|
|
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) {
|
|
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
|
-
//
|
|
45
|
-
|
|
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
|
|
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
|