solana-risk-engine 0.3.0 → 0.3.2
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/package.json +1 -1
- package/src/constants.js +1 -0
- package/src/index.js +1 -1
- package/src/markets.js +30 -1
- package/src/rugcheck.js +16 -2
- package/src/wallet.js +17 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solana-risk-engine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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,6 +6,7 @@ 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
|
|
package/src/index.js
CHANGED
|
@@ -44,7 +44,7 @@ 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";
|
|
47
|
+
export { getMarkets, realLiq, impostorOf, INFLATED_RATIO, geckoLiquidity, geckoPair } from "./markets.js";
|
|
48
48
|
export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances, creatorLaunches } from "./rugcheck.js";
|
|
49
49
|
export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess, SERIAL_WARN, SERIAL_BAD } from "./risk.js";
|
|
50
50
|
export { recentLaunches } from "./launch.js";
|
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/rugcheck.js
CHANGED
|
@@ -7,8 +7,22 @@ import { pool } from "./util.js";
|
|
|
7
7
|
// which on-chain data does not show without decoding each DEX's binary layout (Raydium, Orca, Meteora
|
|
8
8
|
// and pump.fun all differ). It is an extra, never a requirement: on failure these return null and the
|
|
9
9
|
// rest of the analysis is exactly the same.
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// RugCheck sometimes answers lpLockedPct 0 for a pool that is ~100% locked (5 of 40 calls on a
|
|
11
|
+
// Raydium CPMM token, 2026-10-26; a watched token flipped high/medium every check). A low value is
|
|
12
|
+
// therefore asked again up to twice and the highest answer wins: a pool that really is unlocked
|
|
13
|
+
// reads low every time, so only the false alarm goes away.
|
|
14
|
+
export const LP_RECHECK_BELOW = 60;
|
|
15
|
+
export async function rugcheckSummary(client, mint) {
|
|
16
|
+
const url = RUGCHECK + mint + "/report/summary";
|
|
17
|
+
let best = await client.softJson(url, 6000);
|
|
18
|
+
for (let i = 0; i < 2 && lowLp(best); i++) {
|
|
19
|
+
const again = await client.softJson(url, 6000);
|
|
20
|
+
if (again && typeof again.lpLockedPct === "number" && again.lpLockedPct > best.lpLockedPct) best = again;
|
|
21
|
+
}
|
|
22
|
+
return best;
|
|
23
|
+
}
|
|
24
|
+
function lowLp(rc) {
|
|
25
|
+
return !!rc && typeof rc.lpLockedPct === "number" && rc.lpLockedPct < LP_RECHECK_BELOW;
|
|
12
26
|
}
|
|
13
27
|
|
|
14
28
|
// Full report (~700 KB, so ask once per holder search, never per token of a wallet): who created the
|
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,12 +15,20 @@ 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
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");
|
|
@@ -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) {
|