solana-risk-engine 0.2.0 → 0.3.1
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 +4 -3
- package/package.json +1 -1
- package/src/constants.js +8 -1
- package/src/i18n/en.js +1 -0
- package/src/i18n/es.js +1 -0
- package/src/index.js +4 -3
- package/src/launch.js +44 -0
- package/src/markets.js +30 -1
- package/src/risk.js +20 -6
- package/src/rugcheck.js +12 -0
- package/src/wallet.js +19 -5
package/README.md
CHANGED
|
@@ -27,8 +27,9 @@ for (const l of r.lights) // the four traffic lights
|
|
|
27
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
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. |
|
|
31
|
-
| **
|
|
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. |
|
|
@@ -69,7 +70,7 @@ Methods (all accept `{ onProgress(step, fraction) }`):
|
|
|
69
70
|
|
|
70
71
|
| Method | Returns |
|
|
71
72
|
|---|---|
|
|
72
|
-
| `analyzeToken(mint)` | `{ lvl, sev, type, lights, reasons, impostor, pair, ... }` |
|
|
73
|
+
| `analyzeToken(mint, { skipCreator })` | `{ lvl, sev, type, lights, reasons, impostor, pair, creator, ... }` |
|
|
73
74
|
| `analyzeWallet(wallet, { minUsd })` | `{ sol, balances, total, hidden, results: [token results] }` |
|
|
74
75
|
| `analyzeAddress(addr)` | `{ kind: "token" \| "wallet", ... }`, detected automatically |
|
|
75
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.1",
|
|
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
|
@@ -6,12 +6,16 @@ export const PUBLIC_RPC = "https://solana-rpc.publicnode.com";
|
|
|
6
6
|
export const VIBE_RPC = "https://public.rpc.solanavibestation.com";
|
|
7
7
|
export const DEX = "https://api.dexscreener.com/tokens/v1/solana/";
|
|
8
8
|
export const RUGCHECK = "https://api.rugcheck.xyz/v1/tokens/";
|
|
9
|
+
export const GECKO = "https://api.geckoterminal.com/api/v2/networks/solana/";
|
|
9
10
|
export const HELIUS_API = "https://api.helius.xyz/v0/addresses/";
|
|
10
11
|
export const COINGECKO_SOL = "https://api.coingecko.com/api/v3/coins/solana/market_chart?vs_currency=usd&days=365";
|
|
11
12
|
|
|
12
13
|
export const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
13
14
|
export const TOKEN_2022 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
14
15
|
export const SYSTEM_PROGRAM = "11111111111111111111111111111111";
|
|
16
|
+
export const PUMP_PROGRAM = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";
|
|
17
|
+
// dexId of a token still on Pump.fun's bonding curve: DexScreener says "pumpfun", GeckoTerminal "pump-fun"
|
|
18
|
+
export const PUMP_CURVE_DEXES = ["pumpfun", "pump-fun"];
|
|
15
19
|
|
|
16
20
|
export const USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
17
21
|
export const USDT = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB";
|
|
@@ -34,7 +38,10 @@ export const FAMOUS = {
|
|
|
34
38
|
PUMP: "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn", JLP: "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4",
|
|
35
39
|
POPCAT: "7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr", HNT: "hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux",
|
|
36
40
|
RENDER: "rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof", ORCA: "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE",
|
|
37
|
-
DRIFT: "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7"
|
|
41
|
+
DRIFT: "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7",
|
|
42
|
+
// Bitcoin and Ether only exist on Solana bridged: a Pump.fun token called "BTC" is never the real one
|
|
43
|
+
BTC: "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh", WBTC: "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh",
|
|
44
|
+
ETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", WETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs"
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
// 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
|
@@ -44,9 +44,10 @@ export { ScanError, isCancel } from "./errors.js";
|
|
|
44
44
|
export * from "./constants.js";
|
|
45
45
|
export { isOnCurve, onCurve, b58bytes } from "./curve.js";
|
|
46
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 } from "./rugcheck.js";
|
|
49
|
-
export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess } from "./risk.js";
|
|
47
|
+
export { getMarkets, realLiq, impostorOf, INFLATED_RATIO, geckoLiquidity, geckoPair } 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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEX, USDC, USDT, WSOL, FAMOUS } from "./constants.js";
|
|
1
|
+
import { DEX, GECKO, USDC, USDT, WSOL, FAMOUS } from "./constants.js";
|
|
2
2
|
import { ScanError, isCancel } from "./errors.js";
|
|
3
3
|
import { sleep } from "./util.js";
|
|
4
4
|
|
|
@@ -56,3 +56,32 @@ export async function getMarkets(client, mints, onStep) {
|
|
|
56
56
|
}
|
|
57
57
|
return { data: best, failed: failed };
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/* ---------- GeckoTerminal: fills two DexScreener gaps on Pump.fun ---------- */
|
|
61
|
+
// Both are optional extras: on any failure the market stays exactly as DexScreener gave it.
|
|
62
|
+
|
|
63
|
+
// DexScreener reports $0 liquidity for tokens still on Pump.fun's bonding curve. GeckoTerminal has the
|
|
64
|
+
// real figure for the same pool (its pool address is DexScreener's pairAddress). Mutates the pair.
|
|
65
|
+
export async function geckoLiquidity(client, pair) {
|
|
66
|
+
if (!pair || (pair.liquidity && pair.liquidity.usd) || !pair.pairAddress) return pair;
|
|
67
|
+
const j = await client.softJson(GECKO + "pools/" + pair.pairAddress, 6000);
|
|
68
|
+
const v = Number(j && j.data && j.data.attributes && j.data.attributes.reserve_in_usd) || 0;
|
|
69
|
+
if (v > 0) pair.liquidity = Object.assign({}, pair.liquidity, { usd: v });
|
|
70
|
+
return pair;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A token seconds old is not on DexScreener yet but is on GeckoTerminal: its most liquid pool, shaped
|
|
74
|
+
// like a DexScreener pair (what the engine reads: price, liquidity, volume, age, symbol), or null
|
|
75
|
+
export async function geckoPair(client, mint) {
|
|
76
|
+
const j = await client.softJson(GECKO + "tokens/" + mint + "/pools?page=1&include=base_token", 6000);
|
|
77
|
+
const p = j && j.data && j.data[0];
|
|
78
|
+
if (!p || !p.attributes) return null;
|
|
79
|
+
const a = p.attributes, rel = p.relationships || {}, t = ((j.included || [])[0] || {}).attributes || {};
|
|
80
|
+
return {
|
|
81
|
+
pairAddress: a.address, dexId: (rel.dex && rel.dex.data && rel.dex.data.id) || "",
|
|
82
|
+
baseToken: { address: mint, symbol: t.symbol, name: t.name },
|
|
83
|
+
priceUsd: String(a.base_token_price_usd || 0), liquidity: { usd: Number(a.reserve_in_usd) || 0 },
|
|
84
|
+
volume: { h24: Number(a.volume_usd && a.volume_usd.h24) || 0 }, pairCreatedAt: Date.parse(a.pool_created_at) || 0,
|
|
85
|
+
info: { imageUrl: t.image_url && !/missing/.test(t.image_url) ? t.image_url : null }, source: "geckoterminal"
|
|
86
|
+
};
|
|
87
|
+
}
|
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
|
}
|
|
@@ -181,6 +190,7 @@ export function reasonsFrom(lights) {
|
|
|
181
190
|
* @param [opts.value] USD the analysed wallet holds of it (for the "heavy position" check)
|
|
182
191
|
* @param [opts.marketUnknown] the DexScreener batch for this mint failed: no data, not "no market"
|
|
183
192
|
* @param [opts.skipConcentration]
|
|
193
|
+
* @param [opts.skipCreator] do not ask RugCheck what else the creator launched (markets under 7 days only)
|
|
184
194
|
* @returns null for NFTs, otherwise {mint, sym, name, lvl, sev, pts, type, lights, reasons, impostor, value, pair, img}
|
|
185
195
|
*/
|
|
186
196
|
export async function assess(client, mint, pair, opts) {
|
|
@@ -193,6 +203,9 @@ export async function assess(client, mint, pair, opts) {
|
|
|
193
203
|
};
|
|
194
204
|
}
|
|
195
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);
|
|
196
209
|
let info = null, conc = null;
|
|
197
210
|
try { info = await getMint(client, mint); } catch (e) { if (isCancel(e)) throw e; }
|
|
198
211
|
if (info && info.decimals === 0 && info.supply === 1) return null; // NFT
|
|
@@ -208,12 +221,12 @@ export async function assess(client, mint, pair, opts) {
|
|
|
208
221
|
catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(client.retryDelayMs === undefined ? 1500 : client.retryDelayMs); }
|
|
209
222
|
}
|
|
210
223
|
}
|
|
211
|
-
const rc = await rcPromise;
|
|
224
|
+
const rc = await rcPromise, launches = await launchPromise;
|
|
212
225
|
const base = (pair && pair.baseToken) || {};
|
|
213
226
|
let lvl, sev = 0, reasons = [], lights = null, type = null, pts = 0, imp = null;
|
|
214
227
|
if (pair) {
|
|
215
228
|
type = tokenType(info, pair);
|
|
216
|
-
lights = buildLights(info, conc, pair, value, type, rc);
|
|
229
|
+
lights = buildLights(info, conc, pair, value, type, rc, null, launches);
|
|
217
230
|
imp = impostorOf(mint, pair);
|
|
218
231
|
if (imp) {
|
|
219
232
|
const prev = lights[0];
|
|
@@ -229,6 +242,7 @@ export async function assess(client, mint, pair, opts) {
|
|
|
229
242
|
mint: mint, sym: base.symbol || short(mint), name: base.name || "", pts: pts, sev: sev,
|
|
230
243
|
lvl: lvl, type: type, lights: lights, reasons: reasons, impostor: imp, value: value, pair: pair,
|
|
231
244
|
rugcheck: rc ? { lpLockedPct: rc.lpLockedPct, score: rc.score_normalised !== undefined ? rc.score_normalised : rc.score } : null,
|
|
245
|
+
creator: launches,
|
|
232
246
|
img: (pair && pair.info && pair.info.imageUrl) || null
|
|
233
247
|
};
|
|
234
248
|
}
|
package/src/rugcheck.js
CHANGED
|
@@ -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);
|
package/src/wallet.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { ADDRESS_RE, KNOWN, LEVELS } from "./constants.js";
|
|
1
|
+
import { ADDRESS_RE, KNOWN, LEVELS, PUMP_CURVE_DEXES } from "./constants.js";
|
|
2
2
|
import { ScanError } from "./errors.js";
|
|
3
3
|
import { pool, progress } from "./util.js";
|
|
4
4
|
import { getTokens, isMint } from "./tokens.js";
|
|
5
|
-
import { getMarkets } from "./markets.js";
|
|
5
|
+
import { getMarkets, geckoLiquidity, geckoPair } from "./markets.js";
|
|
6
6
|
import { assess } from "./risk.js";
|
|
7
7
|
|
|
8
8
|
function checkAddress(addr) {
|
|
@@ -15,14 +15,22 @@ export function sortResults(list) {
|
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Risk of a single token. Throws IS_NFT for NFTs.
|
|
20
|
+
* @param [opts.noGecko] do not fill DexScreener's Pump.fun gaps from GeckoTerminal (also for analyzeWallet)
|
|
21
|
+
*/
|
|
19
22
|
export async function analyzeToken(client, mint, opts) {
|
|
20
23
|
opts = opts || {};
|
|
21
24
|
checkAddress(mint);
|
|
22
25
|
progress(opts.onProgress, "markets", 0.15);
|
|
23
26
|
const mkr = await getMarkets(client, [mint]);
|
|
27
|
+
// Pump.fun gaps in DexScreener: not listed yet (seconds old) or $0 liquidity on the bonding curve
|
|
28
|
+
if (!opts.noGecko) {
|
|
29
|
+
if (!mkr.data[mint] && !mkr.failed.length) { const gp = await geckoPair(client, mint); if (gp) mkr.data[mint] = gp; }
|
|
30
|
+
await geckoLiquidity(client, mkr.data[mint]);
|
|
31
|
+
}
|
|
24
32
|
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 });
|
|
33
|
+
const r = await assess(client, mint, mkr.data[mint] || null, { marketUnknown: mkr.failed.length > 0, skipConcentration: opts.skipConcentration, skipCreator: opts.skipCreator });
|
|
26
34
|
if (!r) throw new ScanError("IS_NFT", "that address is an NFT, not a token");
|
|
27
35
|
progress(opts.onProgress, "done", 1);
|
|
28
36
|
return r;
|
|
@@ -49,6 +57,12 @@ export async function analyzeWallet(client, wallet, opts) {
|
|
|
49
57
|
const mkr = await getMarkets(client, mints, function (d, n) { progress(onProgress, "markets", 0.08 + 0.12 * d / n, { done: d, total: n }); });
|
|
50
58
|
const mk = mkr.data, failedMk = {};
|
|
51
59
|
mkr.failed.forEach(function (m) { failedMk[m] = 1; });
|
|
60
|
+
// Bonding-curve tokens show $0 liquidity on DexScreener; ask GeckoTerminal for a few (its free API
|
|
61
|
+
// allows ~30 requests a minute, and wallets full of airdropped spam would use them up)
|
|
62
|
+
if (!opts.noGecko) {
|
|
63
|
+
const curve = mints.filter(function (m) { const p = mk[m]; return p && PUMP_CURVE_DEXES.indexOf(p.dexId) >= 0 && !(p.liquidity && p.liquidity.usd); }).slice(0, 10);
|
|
64
|
+
await pool(curve, 2, async function (m) { await geckoLiquidity(client, mk[m]); });
|
|
65
|
+
}
|
|
52
66
|
|
|
53
67
|
const out = [], todo = [];
|
|
54
68
|
mints.forEach(function (m) {
|
|
@@ -62,7 +76,7 @@ export async function analyzeWallet(client, wallet, opts) {
|
|
|
62
76
|
|
|
63
77
|
let done = 0;
|
|
64
78
|
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 });
|
|
79
|
+
const r = await assess(client, t.mint, t.pair, { value: t.value, marketUnknown: t.unknown, skipConcentration: opts.skipConcentration, skipCreator: opts.skipCreator });
|
|
66
80
|
if (r) out.push(r);
|
|
67
81
|
done++;
|
|
68
82
|
progress(onProgress, "tokens", 0.2 + 0.8 * done / Math.max(1, todo.length), { done: done, total: todo.length });
|