solana-risk-engine 0.1.0 → 0.3.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 +16 -6
- package/package.json +1 -1
- package/src/client.js +1 -1
- package/src/constants.js +7 -1
- package/src/i18n/en.js +1 -0
- package/src/i18n/es.js +1 -0
- package/src/index.js +5 -4
- package/src/launch.js +44 -0
- package/src/markets.js +6 -1
- package/src/risk.js +26 -7
- package/src/rugcheck.js +15 -3
- package/src/tokens.js +13 -8
- package/src/wallet.js +2 -2
package/README.md
CHANGED
|
@@ -24,11 +24,12 @@ 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. |
|
|
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
|
-
| **
|
|
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
|
+
| **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, and never while a token is on Pump.fun's bonding curve, where there is no LP to pull. |
|
|
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
|
+
| **Impostors** | Tokens using the symbol or name of a famous token (USDC, JUP, BONK, WIF, BTC, ETH...) with a different address. |
|
|
32
33
|
| **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. |
|
|
33
34
|
| **Bots and exchanges** | Wallets that made 200 transactions in under 2 days are marked `active` and are not counted as whales. |
|
|
34
35
|
| **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. |
|
|
@@ -37,7 +38,16 @@ for (const l of r.lights) // the four traffic lights
|
|
|
37
38
|
| **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
39
|
| **Change tracking** | Snapshots and diffs (new / bought more / sold part / sold all) per wallet and aggregated across many wallets. |
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
### How the level is decided
|
|
42
|
+
|
|
43
|
+
- Two red lights, or one red and one yellow, give **HIGH**.
|
|
44
|
+
- One red, or two yellows, give **MEDIUM**.
|
|
45
|
+
- Otherwise the level is **LOW**, with two exceptions that raise it to **MEDIUM**:
|
|
46
|
+
- **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.
|
|
47
|
+
- **The market is under 7 days old.** It has no track record yet.
|
|
48
|
+
- An impostor of a famous token is always **HIGH**.
|
|
49
|
+
|
|
50
|
+
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
51
|
|
|
42
52
|
## Install
|
|
43
53
|
|
|
@@ -60,7 +70,7 @@ Methods (all accept `{ onProgress(step, fraction) }`):
|
|
|
60
70
|
|
|
61
71
|
| Method | Returns |
|
|
62
72
|
|---|---|
|
|
63
|
-
| `analyzeToken(mint)` | `{ lvl, sev, type, lights, reasons, impostor, pair, ... }` |
|
|
73
|
+
| `analyzeToken(mint, { skipCreator })` | `{ lvl, sev, type, lights, reasons, impostor, pair, creator, ... }` |
|
|
64
74
|
| `analyzeWallet(wallet, { minUsd })` | `{ sol, balances, total, hidden, results: [token results] }` |
|
|
65
75
|
| `analyzeAddress(addr)` | `{ kind: "token" \| "wallet", ... }`, detected automatically |
|
|
66
76
|
| `analyzeHolders(mint)` | `{ rows, clusters, hotGroups, creator, ... }`. Best with your own RPC. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solana-risk-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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/constants.js
CHANGED
|
@@ -12,6 +12,9 @@ export const COINGECKO_SOL = "https://api.coingecko.com/api/v3/coins/solana/mark
|
|
|
12
12
|
export const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
13
13
|
export const TOKEN_2022 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
14
14
|
export const SYSTEM_PROGRAM = "11111111111111111111111111111111";
|
|
15
|
+
export const PUMP_PROGRAM = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";
|
|
16
|
+
// dexId of a token still on Pump.fun's bonding curve: DexScreener says "pumpfun", GeckoTerminal "pump-fun"
|
|
17
|
+
export const PUMP_CURVE_DEXES = ["pumpfun", "pump-fun"];
|
|
15
18
|
|
|
16
19
|
export const USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
17
20
|
export const USDT = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB";
|
|
@@ -34,7 +37,10 @@ export const FAMOUS = {
|
|
|
34
37
|
PUMP: "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn", JLP: "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4",
|
|
35
38
|
POPCAT: "7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr", HNT: "hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux",
|
|
36
39
|
RENDER: "rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof", ORCA: "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE",
|
|
37
|
-
DRIFT: "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7"
|
|
40
|
+
DRIFT: "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7",
|
|
41
|
+
// Bitcoin and Ether only exist on Solana bridged: a Pump.fun token called "BTC" is never the real one
|
|
42
|
+
BTC: "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh", WBTC: "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh",
|
|
43
|
+
ETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", WETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs"
|
|
38
44
|
};
|
|
39
45
|
|
|
40
46
|
// Token-2022 extensions that add risk, with the points they add to the no-market spam score
|
package/src/i18n/en.js
CHANGED
|
@@ -36,6 +36,7 @@ export default {
|
|
|
36
36
|
CTL_DEFAULT_FROZEN: () => "New accounts can be created frozen",
|
|
37
37
|
CTL_ONLY_PROGRAMS: () => "Only program-controlled authorities (see dependency)",
|
|
38
38
|
CTL_NONE: () => "No active authorities: nobody can mint more or freeze",
|
|
39
|
+
CTL_SERIAL_CREATOR: (d) => "The creator launched " + d.n + " other tokens in the last 24 h (per RugCheck): a serial launcher, who usually drops each token as soon as buyers arrive",
|
|
39
40
|
IMPOSTOR: (d) => "Impersonates " + d.symbol + ": the real " + d.symbol + " has another address (" + short(d.real) + "). It is a copy, almost certainly a scam",
|
|
40
41
|
|
|
41
42
|
CON_SKIPPED: () => "Holder concentration not checked",
|
package/src/i18n/es.js
CHANGED
|
@@ -39,6 +39,7 @@ export default {
|
|
|
39
39
|
CTL_DEFAULT_FROZEN: () => "Las cuentas nuevas pueden crearse congeladas",
|
|
40
40
|
CTL_ONLY_PROGRAMS: () => "Solo hay autoridades controladas por un programa (ver dependencia)",
|
|
41
41
|
CTL_NONE: () => "Sin autoridades activas: nadie puede crear más ni congelar",
|
|
42
|
+
CTL_SERIAL_CREATOR: (d) => "El creador ha lanzado " + d.n + " tokens más en las últimas 24 h (según RugCheck): es un lanzador en serie, que suele abandonar cada token en cuanto llegan compradores",
|
|
42
43
|
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",
|
|
43
44
|
|
|
44
45
|
CON_SKIPPED: () => "Sin comprobar la concentración de holders",
|
package/src/index.js
CHANGED
|
@@ -43,10 +43,11 @@ 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";
|
|
48
|
-
export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances } from "./rugcheck.js";
|
|
49
|
-
export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess } from "./risk.js";
|
|
46
|
+
export { getTokens, getMint, getConc, getAuthKinds, isMint, accountKinds, kindOf } from "./tokens.js";
|
|
47
|
+
export { getMarkets, realLiq, impostorOf, INFLATED_RATIO } from "./markets.js";
|
|
48
|
+
export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances, creatorLaunches } from "./rugcheck.js";
|
|
49
|
+
export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess, SERIAL_WARN, SERIAL_BAD } from "./risk.js";
|
|
50
|
+
export { recentLaunches } from "./launch.js";
|
|
50
51
|
export { analyzeToken, analyzeWallet, analyzeAddress, sortResults } from "./wallet.js";
|
|
51
52
|
export { holderBase, markBusy, findFunder, findFunders, clusterByFunder, holderRows, creatorInfo, analyzeHolders } from "./holders.js";
|
|
52
53
|
export { fetchSwaps, solHistory, priceSeries, parseTrades, pnlFromTrades, pnlStatus, estimatePnl } from "./pnl.js";
|
package/src/launch.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PUMP_PROGRAM } from "./constants.js";
|
|
2
|
+
import { isCancel } from "./errors.js";
|
|
3
|
+
import { pool } from "./util.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pump.fun tokens a wallet launched in the last 24 h, read on chain from its latest transactions.
|
|
7
|
+
*
|
|
8
|
+
* Launch farms often pay every launch from one wallet but name a fresh "creator" each time, so
|
|
9
|
+
* RugCheck (which follows the creator) sees one token per creator. The wallet that pays is the first
|
|
10
|
+
* signer of the creation transaction: this counts what else it created.
|
|
11
|
+
*
|
|
12
|
+
* @param [opts.limit=40] latest transactions to read (each one is a request)
|
|
13
|
+
* @param [opts.now]
|
|
14
|
+
* @returns {n, mints, scanned, more} more = every transaction read was a launch-day one, there may be more
|
|
15
|
+
*/
|
|
16
|
+
export async function recentLaunches(client, wallet, opts) {
|
|
17
|
+
opts = opts || {};
|
|
18
|
+
const limit = opts.limit || 40, now = opts.now || Date.now();
|
|
19
|
+
const sigs = (await client.rpc("getSignaturesForAddress", [wallet, { limit: limit }])) || [];
|
|
20
|
+
const day = sigs.filter(function (s) { return !s.err && s.blockTime && now / 1000 - s.blockTime < 86400; });
|
|
21
|
+
const mints = [];
|
|
22
|
+
await pool(day, 4, async function (s) {
|
|
23
|
+
const p = { encoding: "jsonParsed", maxSupportedTransactionVersion: 1, commitment: "confirmed" };
|
|
24
|
+
let tx = null;
|
|
25
|
+
try { tx = await client.rpc("getTransaction", [s.signature, p]); }
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (isCancel(e)) throw e;
|
|
28
|
+
// an older RPC that does not know version 1 transactions yet
|
|
29
|
+
if (!/version|invalid param/i.test(e.message || "")) return;
|
|
30
|
+
p.maxSupportedTransactionVersion = 0;
|
|
31
|
+
try { tx = await client.rpc("getTransaction", [s.signature, p]); } catch (e2) { if (isCancel(e2)) throw e2; return; }
|
|
32
|
+
}
|
|
33
|
+
const meta = tx && tx.meta, keys = tx && tx.transaction && tx.transaction.message && tx.transaction.message.accountKeys;
|
|
34
|
+
if (!meta || !keys) return;
|
|
35
|
+
const pump = keys.some(function (k) { return (k.pubkey || k) === PUMP_PROGRAM; });
|
|
36
|
+
const create = (meta.logMessages || []).some(function (l) { return /^Program log: Instruction: Create(V2)?$/.test(l); });
|
|
37
|
+
if (!pump || !create) return;
|
|
38
|
+
const pre = (meta.preTokenBalances || []).map(function (b) { return b.mint; });
|
|
39
|
+
(meta.postTokenBalances || []).forEach(function (b) {
|
|
40
|
+
if (pre.indexOf(b.mint) < 0 && mints.indexOf(b.mint) < 0) mints.push(b.mint);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
return { n: mints.length, mints: mints, scanned: day.length, more: sigs.length >= limit && day.length === sigs.length };
|
|
44
|
+
}
|
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
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { EXT_POINTS, RANKS } from "./constants.js";
|
|
1
|
+
import { EXT_POINTS, RANKS, PUMP_CURVE_DEXES } 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";
|
|
5
5
|
import { impostorOf } from "./markets.js";
|
|
6
|
-
import { rugcheckSummary } from "./rugcheck.js";
|
|
6
|
+
import { rugcheckSummary, creatorLaunches } from "./rugcheck.js";
|
|
7
7
|
|
|
8
8
|
// Every finding is a "signal": {code, s, data}. `s` is ok | na | warn | bad, `data` holds the numbers
|
|
9
9
|
// behind it. Turning signals into sentences is i18n's job (describe / explainLight), never the engine's.
|
|
@@ -14,6 +14,8 @@ function sig(code, s, data) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
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;
|
|
17
19
|
|
|
18
20
|
/* ---------- old point score: only for tokens without a market, to spot spam ---------- */
|
|
19
21
|
export function score(info) {
|
|
@@ -71,9 +73,10 @@ function mkLight(k, items, okSignal) {
|
|
|
71
73
|
* @param value USD value the analysed wallet holds (0 when checking a token alone)
|
|
72
74
|
* @param type tokenType(info, pair)
|
|
73
75
|
* @param rc RugCheck summary or null
|
|
76
|
+
* @param launches creatorLaunches result or null: other tokens the creator launched in the last 24 h
|
|
74
77
|
* @returns [control, concentration, market, dependency]
|
|
75
78
|
*/
|
|
76
|
-
export function buildLights(info, conc, pair, value, type, rc, now) {
|
|
79
|
+
export function buildLights(info, conc, pair, value, type, rc, now, launches) {
|
|
77
80
|
now = now || Date.now();
|
|
78
81
|
const ak = (info && info.authKind) || {};
|
|
79
82
|
let it;
|
|
@@ -89,6 +92,9 @@ export function buildLights(info, conc, pair, value, type, rc, now) {
|
|
|
89
92
|
if (info.ext.indexOf("nonTransferable") >= 0) it.push(sig("CTL_NON_TRANSFERABLE", "warn"));
|
|
90
93
|
if (info.ext.indexOf("defaultAccountState") >= 0) it.push(sig("CTL_DEFAULT_FROZEN", "warn"));
|
|
91
94
|
}
|
|
95
|
+
// Launch farms put out a token every few minutes and drop each one as soon as buyers arrive: in a
|
|
96
|
+
// 100-token Pump.fun sample one wallet launched 7 copies of "GMS" in 10 minutes
|
|
97
|
+
if (launches && launches.day >= SERIAL_WARN) it.push(sig("CTL_SERIAL_CREATOR", launches.day >= SERIAL_BAD ? "bad" : "warn", { n: launches.day, creator: launches.creator }));
|
|
92
98
|
const control = mkLight("control", it, sig((info && (info.mintAuth || info.freezeAuth)) ? "CTL_ONLY_PROGRAMS" : "CTL_NONE", "ok"));
|
|
93
99
|
|
|
94
100
|
// 2) concentration in people (pools and programs do not count)
|
|
@@ -125,7 +131,10 @@ export function buildLights(info, conc, pair, value, type, rc, now) {
|
|
|
125
131
|
// veteran tokens (JUP, WIF...) many market makers provide it and unlocked LP is not a signal: applying
|
|
126
132
|
// it there gave false positives (JUP scored worse than a new memecoin)
|
|
127
133
|
const young = !pair.pairCreatedAt || (now - pair.pairCreatedAt) / DAY < 90;
|
|
128
|
-
|
|
134
|
+
// On Pump.fun's bonding curve there is no LP at all: the curve program holds the liquidity and nobody
|
|
135
|
+
// can pull it (RugCheck reports 0% locked, which flagged 49 of 75 curve tokens in a 100-token sample)
|
|
136
|
+
const onCurve = PUMP_CURVE_DEXES.indexOf(pair.dexId) >= 0;
|
|
137
|
+
if (rc && !soft && young && !onCurve && liq > 0 && typeof rc.lpLockedPct === "number" && isFinite(rc.lpLockedPct)) {
|
|
129
138
|
if (rc.lpLockedPct < 20) it.push(sig("MKT_LP_UNLOCKED", "bad", { pct: rc.lpLockedPct }));
|
|
130
139
|
else if (rc.lpLockedPct < 60) it.push(sig("MKT_LP_PARTLY_LOCKED", "warn", { pct: rc.lpLockedPct }));
|
|
131
140
|
}
|
|
@@ -151,6 +160,11 @@ export function levelFrom(lights) {
|
|
|
151
160
|
let lvl = (b >= 2 || (b === 1 && w >= 1)) ? "HIGH" : ((b === 1 || w >= 2) ? "MEDIUM" : "LOW");
|
|
152
161
|
// What could not be checked does not count as "fine": with a check missing, at best it is medium risk
|
|
153
162
|
if (lvl === "LOW" && na > 0) lvl = "MEDIUM";
|
|
163
|
+
// A market under 7 days old has no track record: it is never "low risk", even with nothing else wrong.
|
|
164
|
+
// Other single warnings stay low: on established tokens they are normal for their type (a staking pool's
|
|
165
|
+
// program-controlled mint, a transfer hook...); measured on 62 real tokens, this rule moved 9 brand-new
|
|
166
|
+
// tokens from low to medium and no established one
|
|
167
|
+
if (lvl === "LOW" && lights.some(function (l) { return (l.items || []).some(function (x) { return x.code === "MKT_NEW"; }); })) lvl = "MEDIUM";
|
|
154
168
|
return { lvl: lvl, sev: 3 * b + w + (na && !b && !w ? 1 : 0) };
|
|
155
169
|
}
|
|
156
170
|
|
|
@@ -176,6 +190,7 @@ export function reasonsFrom(lights) {
|
|
|
176
190
|
* @param [opts.value] USD the analysed wallet holds of it (for the "heavy position" check)
|
|
177
191
|
* @param [opts.marketUnknown] the DexScreener batch for this mint failed: no data, not "no market"
|
|
178
192
|
* @param [opts.skipConcentration]
|
|
193
|
+
* @param [opts.skipCreator] do not ask RugCheck what else the creator launched (markets under 7 days only)
|
|
179
194
|
* @returns null for NFTs, otherwise {mint, sym, name, lvl, sev, pts, type, lights, reasons, impostor, value, pair, img}
|
|
180
195
|
*/
|
|
181
196
|
export async function assess(client, mint, pair, opts) {
|
|
@@ -188,6 +203,9 @@ export async function assess(client, mint, pair, opts) {
|
|
|
188
203
|
};
|
|
189
204
|
}
|
|
190
205
|
const rcPromise = pair ? rugcheckSummary(client, mint) : Promise.resolve(null); // in parallel with the RPC calls below
|
|
206
|
+
// Serial launching only matters while a token is new; RugCheck's full report is small (~10 KB) then
|
|
207
|
+
const fresh = pair && !opts.skipCreator && (!pair.pairCreatedAt || Date.now() - pair.pairCreatedAt < 7 * DAY);
|
|
208
|
+
const launchPromise = fresh ? creatorLaunches(client, mint) : Promise.resolve(null);
|
|
191
209
|
let info = null, conc = null;
|
|
192
210
|
try { info = await getMint(client, mint); } catch (e) { if (isCancel(e)) throw e; }
|
|
193
211
|
if (info && info.decimals === 0 && info.supply === 1) return null; // NFT
|
|
@@ -200,15 +218,15 @@ export async function assess(client, mint, pair, opts) {
|
|
|
200
218
|
// In tokens with millions of holders (BONK) getTokenLargestAccounts sometimes times out: a second try usually works
|
|
201
219
|
for (let tryC = 0; tryC < 2 && !conc; tryC++) {
|
|
202
220
|
try { conc = await getConc(client, mint, info.supply); }
|
|
203
|
-
catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(
|
|
221
|
+
catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(client.retryDelayMs === undefined ? 1500 : client.retryDelayMs); }
|
|
204
222
|
}
|
|
205
223
|
}
|
|
206
|
-
const rc = await rcPromise;
|
|
224
|
+
const rc = await rcPromise, launches = await launchPromise;
|
|
207
225
|
const base = (pair && pair.baseToken) || {};
|
|
208
226
|
let lvl, sev = 0, reasons = [], lights = null, type = null, pts = 0, imp = null;
|
|
209
227
|
if (pair) {
|
|
210
228
|
type = tokenType(info, pair);
|
|
211
|
-
lights = buildLights(info, conc, pair, value, type, rc);
|
|
229
|
+
lights = buildLights(info, conc, pair, value, type, rc, null, launches);
|
|
212
230
|
imp = impostorOf(mint, pair);
|
|
213
231
|
if (imp) {
|
|
214
232
|
const prev = lights[0];
|
|
@@ -224,6 +242,7 @@ export async function assess(client, mint, pair, opts) {
|
|
|
224
242
|
mint: mint, sym: base.symbol || short(mint), name: base.name || "", pts: pts, sev: sev,
|
|
225
243
|
lvl: lvl, type: type, lights: lights, reasons: reasons, impostor: imp, value: value, pair: pair,
|
|
226
244
|
rugcheck: rc ? { lpLockedPct: rc.lpLockedPct, score: rc.score_normalised !== undefined ? rc.score_normalised : rc.score } : null,
|
|
245
|
+
creator: launches,
|
|
227
246
|
img: (pair && pair.info && pair.info.imageUrl) || null
|
|
228
247
|
};
|
|
229
248
|
}
|
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,
|
|
@@ -18,6 +18,18 @@ export function rugcheckReport(client, mint) {
|
|
|
18
18
|
return client.softJson(RUGCHECK + mint + "/report", 10000);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// How many other tokens the creator launched in the last 24 h. Only for new tokens: their full report
|
|
22
|
+
// is ~10 KB (the 700 KB ones are old tokens with many markets and holders).
|
|
23
|
+
// @returns {creator, day, total} or null when RugCheck does not answer or does not know the creator
|
|
24
|
+
export async function creatorLaunches(client, mint, now) {
|
|
25
|
+
const rc = await rugcheckReport(client, mint);
|
|
26
|
+
if (!rc || !rc.creator) return null;
|
|
27
|
+
now = now || Date.now();
|
|
28
|
+
const others = (rc.creatorTokens || []).filter(function (t) { return t && t.mint !== mint; });
|
|
29
|
+
const day = others.filter(function (t) { const at = Date.parse(t.createdAt); return at && now - at < 86400000; }).length;
|
|
30
|
+
return { creator: rc.creator, day: day, total: others.length };
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
// The report only gives each network's size; this endpoint gives its wallets and holdings
|
|
22
34
|
export async function rugcheckGraph(client, mint) {
|
|
23
35
|
const g = await client.softJson(RUGCHECK + mint + "/insiders/graph", 8000);
|
|
@@ -42,7 +54,7 @@ export async function netBalances(client, mint, graph) {
|
|
|
42
54
|
const part = addrs.slice(i, i + 100);
|
|
43
55
|
try {
|
|
44
56
|
const oa = await client.rpc("getMultipleAccounts", [part, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
|
|
45
|
-
part.forEach(function (a, k) {
|
|
57
|
+
part.forEach(function (a, k) { kind[a] = kindOf(a, oa.value[k], true); });
|
|
46
58
|
} catch (e) { if (isCancel(e)) throw e; }
|
|
47
59
|
}
|
|
48
60
|
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
|
package/src/wallet.js
CHANGED
|
@@ -22,7 +22,7 @@ export async function analyzeToken(client, mint, opts) {
|
|
|
22
22
|
progress(opts.onProgress, "markets", 0.15);
|
|
23
23
|
const mkr = await getMarkets(client, [mint]);
|
|
24
24
|
progress(opts.onProgress, "token", 0.45);
|
|
25
|
-
const r = await assess(client, mint, mkr.data[mint] || null, { marketUnknown: mkr.failed.length > 0, skipConcentration: opts.skipConcentration });
|
|
25
|
+
const r = await assess(client, mint, mkr.data[mint] || null, { marketUnknown: mkr.failed.length > 0, skipConcentration: opts.skipConcentration, skipCreator: opts.skipCreator });
|
|
26
26
|
if (!r) throw new ScanError("IS_NFT", "that address is an NFT, not a token");
|
|
27
27
|
progress(opts.onProgress, "done", 1);
|
|
28
28
|
return r;
|
|
@@ -62,7 +62,7 @@ export async function analyzeWallet(client, wallet, opts) {
|
|
|
62
62
|
|
|
63
63
|
let done = 0;
|
|
64
64
|
await pool(todo, opts.concurrency || 3, async function (t) {
|
|
65
|
-
const r = await assess(client, t.mint, t.pair, { value: t.value, marketUnknown: t.unknown, skipConcentration: opts.skipConcentration });
|
|
65
|
+
const r = await assess(client, t.mint, t.pair, { value: t.value, marketUnknown: t.unknown, skipConcentration: opts.skipConcentration, skipCreator: opts.skipCreator });
|
|
66
66
|
if (r) out.push(r);
|
|
67
67
|
done++;
|
|
68
68
|
progress(onProgress, "tokens", 0.2 + 0.8 * done / Math.max(1, todo.length), { done: done, total: todo.length });
|