solana-risk-engine 0.0.0-stage → 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/LICENSE +21 -0
- package/README.es.md +30 -0
- package/README.md +142 -2
- package/bin/solana-risk.js +65 -0
- package/package.json +45 -4
- package/src/changes.js +105 -0
- package/src/client.js +143 -0
- package/src/confluence.js +120 -0
- package/src/constants.js +55 -0
- package/src/curve.js +34 -0
- package/src/errors.js +33 -0
- package/src/holders.js +189 -0
- package/src/i18n/en.js +106 -0
- package/src/i18n/es.js +109 -0
- package/src/i18n/index.js +77 -0
- package/src/index.js +55 -0
- package/src/markets.js +58 -0
- package/src/pnl.js +148 -0
- package/src/risk.js +234 -0
- package/src/rugcheck.js +66 -0
- package/src/tokens.js +103 -0
- package/src/util.js +29 -0
- package/src/wallet.js +80 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ADDRESS_RE, KNOWN } from "./constants.js";
|
|
2
|
+
import { ScanError, isCancel } from "./errors.js";
|
|
3
|
+
import { pool, progress, short } from "./util.js";
|
|
4
|
+
import { getTokens } from "./tokens.js";
|
|
5
|
+
import { getMarkets } from "./markets.js";
|
|
6
|
+
import { assess } from "./risk.js";
|
|
7
|
+
import { estimatePnl } from "./pnl.js";
|
|
8
|
+
import { holderBase, holderRows, markBusy } from "./holders.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Confluence: tokens that several wallets hold in common, each with its risk.
|
|
12
|
+
*
|
|
13
|
+
* @param wallets at least 2 addresses
|
|
14
|
+
* @param [opts.pct=30] a token must be held by at least this % of the wallets (and never fewer than 2)
|
|
15
|
+
* @param [opts.withPnl=false] estimate each wallet's result first (needs Helius)
|
|
16
|
+
* @param [opts.pnlFilter="all"] keep only wallets whose result is win | lose | flat | unk (implies withPnl)
|
|
17
|
+
* @returns {ts, wallets, allWallets, failed, minN, noMarket, marketFailed, rows, pnl, notes}
|
|
18
|
+
*/
|
|
19
|
+
export async function confluence(client, wallets, opts) {
|
|
20
|
+
opts = opts || {};
|
|
21
|
+
const onProgress = opts.onProgress;
|
|
22
|
+
const pct = opts.pct === undefined ? 30 : opts.pct;
|
|
23
|
+
const filter = opts.pnlFilter || "all";
|
|
24
|
+
const withPnl = !!opts.withPnl || filter !== "all";
|
|
25
|
+
if (!wallets || wallets.length < 2) throw new ScanError("TOO_FEW_WALLETS", "add at least 2 wallets", { n: wallets ? wallets.length : 0 });
|
|
26
|
+
const data = {};
|
|
27
|
+
let failed = 0, d = 0;
|
|
28
|
+
await pool(wallets, 3, async function (a) {
|
|
29
|
+
try { data[a] = (await getTokens(client, a)).tokens; } catch (e) { if (isCancel(e)) throw e; failed++; }
|
|
30
|
+
d++; progress(onProgress, "wallets", 0.05 + 0.3 * d / wallets.length, { done: d, total: wallets.length });
|
|
31
|
+
});
|
|
32
|
+
let ok = wallets.filter(function (a) { return data[a]; });
|
|
33
|
+
if (ok.length < 2) throw new ScanError("TOO_FEW_WALLETS", "could not read at least 2 wallets", { n: ok.length, total: wallets.length });
|
|
34
|
+
const okAll = ok, notes = [];
|
|
35
|
+
let pnlSnap = null;
|
|
36
|
+
if (withPnl && !client.heliusKey()) {
|
|
37
|
+
if (filter !== "all") throw new ScanError("HELIUS_REQUIRED", "the result filter needs a Helius API key");
|
|
38
|
+
notes.push("PNL_NO_HELIUS");
|
|
39
|
+
} else if (withPnl) {
|
|
40
|
+
progress(onProgress, "pnl", 0.3);
|
|
41
|
+
const pr = await estimatePnl(client, ok, data, { onStep: function (dn, n) { progress(onProgress, "pnl", 0.3 + 0.1 * dn / n, { done: dn, total: n }); } });
|
|
42
|
+
pnlSnap = {};
|
|
43
|
+
ok.forEach(function (a) { if (pr.pnl[a]) pnlSnap[a] = pr.pnl[a]; });
|
|
44
|
+
if (pr.down) notes.push("PNL_PARTIAL");
|
|
45
|
+
if (pr.approx) notes.push("PNL_APPROX");
|
|
46
|
+
if (filter !== "all") {
|
|
47
|
+
ok = ok.filter(function (a) { return pr.pnl[a] && pr.pnl[a].st === filter; });
|
|
48
|
+
if (ok.length < 2) throw new ScanError("TOO_FEW_WALLETS", "only " + ok.length + " wallets pass the result filter", { n: ok.length, total: okAll.length, filter: filter });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const minN = Math.max(2, Math.ceil(pct / 100 * ok.length));
|
|
52
|
+
const count = {};
|
|
53
|
+
ok.forEach(function (a) { Object.keys(data[a]).forEach(function (m) { count[m] = (count[m] || 0) + 1; }); });
|
|
54
|
+
const cand = Object.keys(count).filter(function (m) { return count[m] >= minN; });
|
|
55
|
+
progress(onProgress, "markets", 0.4);
|
|
56
|
+
const mkr = cand.length ? await getMarkets(client, cand, function (dn, n) { progress(onProgress, "markets", 0.4 + 0.15 * dn / n, { done: dn, total: n }); }) : { data: {}, failed: [] };
|
|
57
|
+
const mk = mkr.data, rows = [];
|
|
58
|
+
let noMarket = 0;
|
|
59
|
+
cand.forEach(function (m) {
|
|
60
|
+
const pair = mk[m], price = Number((pair && pair.priceUsd) || 0);
|
|
61
|
+
if (!pair || !(price > 0)) { noMarket++; return; }
|
|
62
|
+
const hs = [];
|
|
63
|
+
ok.forEach(function (a) { const q = data[a][m]; if (q && q * price >= 1) hs.push({ a: a, v: q * price }); }); // dust (< $1) ignored
|
|
64
|
+
if (hs.length < minN) return;
|
|
65
|
+
hs.sort(function (x, y) { return y.v - x.v; });
|
|
66
|
+
rows.push({ mint: m, pair: pair, hs: hs, n: hs.length, total: hs.reduce(function (s, x) { return s + x.v; }, 0) });
|
|
67
|
+
});
|
|
68
|
+
let done = 0;
|
|
69
|
+
const todo = rows.filter(function (r) { return !KNOWN[r.mint]; });
|
|
70
|
+
await pool(todo, 3, async function (r) {
|
|
71
|
+
let a = null;
|
|
72
|
+
try { a = await assess(client, r.mint, r.pair); } catch (e) { if (isCancel(e)) throw e; }
|
|
73
|
+
r.a = a; done++;
|
|
74
|
+
progress(onProgress, "tokens", 0.55 + 0.45 * done / Math.max(1, todo.length), { done: done, total: todo.length });
|
|
75
|
+
});
|
|
76
|
+
const out = [];
|
|
77
|
+
rows.forEach(function (r) {
|
|
78
|
+
const a = r.a, liq = (r.pair.liquidity && r.pair.liquidity.usd) || 0, common = { n: r.n, total: r.total, liq: liq, pairAddress: r.pair.pairAddress || "", holders: r.hs };
|
|
79
|
+
if (KNOWN[r.mint]) out.push(Object.assign({ mint: r.mint, sym: KNOWN[r.mint], name: "", lvl: "LOW", sev: 0, type: null, lights: null, known: true, img: null }, common));
|
|
80
|
+
else if (a) out.push(Object.assign({ mint: r.mint, sym: a.sym, name: a.name, lvl: a.lvl, sev: a.sev, type: a.type, lights: a.lights, known: false, img: a.img }, common));
|
|
81
|
+
});
|
|
82
|
+
out.sort(function (x, y) { return y.n - x.n || y.total - x.total; });
|
|
83
|
+
progress(onProgress, "done", 1);
|
|
84
|
+
return { ts: Date.now(), wallets: ok.length, allWallets: okAll.length, failed: failed, minN: minN, pct: pct, noMarket: noMarket, marketFailed: mkr.failed.length, rows: out, pnl: pnlSnap, notes: notes };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The reverse: wallets (people, not bots or exchanges) that are top holders of several tokens at once.
|
|
89
|
+
*
|
|
90
|
+
* @param mints 2 to 6 token addresses
|
|
91
|
+
* @param [opts.min=2] in how many of the tokens a wallet must appear
|
|
92
|
+
* @returns {ts, tokens, min, rows: [{a, hits: [{m, s, pct}], total}], dropped}
|
|
93
|
+
*/
|
|
94
|
+
export async function commonHolders(client, mints, opts) {
|
|
95
|
+
opts = opts || {};
|
|
96
|
+
const onProgress = opts.onProgress;
|
|
97
|
+
mints = (mints || []).filter(function (x, i, l) { return ADDRESS_RE.test(x) && l.indexOf(x) === i; }).slice(0, 6);
|
|
98
|
+
if (mints.length < 2) throw new ScanError("TOO_FEW_TOKENS", "give at least 2 token addresses");
|
|
99
|
+
const map = {}, toks = [];
|
|
100
|
+
for (let i = 0; i < mints.length; i++) {
|
|
101
|
+
const b = await holderBase(client, mints[i], function (st, f) { progress(onProgress, "token", (i + f * 0.9) / mints.length * 0.8, { index: i + 1, total: mints.length, sub: st.step }); });
|
|
102
|
+
const hr = holderRows(b);
|
|
103
|
+
const sym = (b.pair && b.pair.baseToken && b.pair.baseToken.symbol) || short(mints[i]);
|
|
104
|
+
toks.push({ mint: mints[i], sym: sym });
|
|
105
|
+
hr.rows.forEach(function (r) {
|
|
106
|
+
if (r.kind !== "wallet") return;
|
|
107
|
+
(map[r.owner] = map[r.owner] || []).push({ m: mints[i], s: sym, pct: r.pct });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const min = Math.min(Number(opts.min) || 2, mints.length);
|
|
111
|
+
const cands = Object.keys(map).filter(function (a) { return map[a].length >= min; });
|
|
112
|
+
const kind = {};
|
|
113
|
+
cands.forEach(function (a) { kind[a] = "wallet"; });
|
|
114
|
+
await markBusy(client, kind, cands, function (c, n) { progress(onProgress, "bots", 0.8 + 0.2 * c / Math.max(1, n), { done: c, total: n }); });
|
|
115
|
+
const keep = cands.filter(function (a) { return kind[a] === "wallet"; });
|
|
116
|
+
const rows = keep.map(function (a) { return { a: a, hits: map[a], total: map[a].reduce(function (s, x) { return s + x.pct; }, 0) }; })
|
|
117
|
+
.sort(function (x, y) { return y.hits.length - x.hits.length || y.total - x.total; });
|
|
118
|
+
progress(onProgress, "done", 1);
|
|
119
|
+
return { ts: Date.now(), tokens: toks, min: min, rows: rows, dropped: cands.length - keep.length };
|
|
120
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// api.mainnet-beta.solana.com answers 403 to anything coming from a browser. PublicNode serves almost
|
|
2
|
+
// everything (accounts, signatures, transactions) quickly, but blocks token-by-owner and largest-holder
|
|
3
|
+
// queries. Those go to Solana Vibe Station, which accepts them without a key but only at ~1 per second,
|
|
4
|
+
// so they go through a queue (see client.js). With your own RPC URL everything goes to that URL.
|
|
5
|
+
export const PUBLIC_RPC = "https://solana-rpc.publicnode.com";
|
|
6
|
+
export const VIBE_RPC = "https://public.rpc.solanavibestation.com";
|
|
7
|
+
export const DEX = "https://api.dexscreener.com/tokens/v1/solana/";
|
|
8
|
+
export const RUGCHECK = "https://api.rugcheck.xyz/v1/tokens/";
|
|
9
|
+
export const HELIUS_API = "https://api.helius.xyz/v0/addresses/";
|
|
10
|
+
export const COINGECKO_SOL = "https://api.coingecko.com/api/v3/coins/solana/market_chart?vs_currency=usd&days=365";
|
|
11
|
+
|
|
12
|
+
export const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
13
|
+
export const TOKEN_2022 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
|
14
|
+
export const SYSTEM_PROGRAM = "11111111111111111111111111111111";
|
|
15
|
+
|
|
16
|
+
export const USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
17
|
+
export const USDT = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB";
|
|
18
|
+
export const WSOL = "So11111111111111111111111111111111111111112";
|
|
19
|
+
|
|
20
|
+
export const KNOWN = {
|
|
21
|
+
[WSOL]: "SOL (wrapped)",
|
|
22
|
+
[USDC]: "USDC",
|
|
23
|
+
[USDT]: "USDT"
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Symbols that scams usually copy, with their official address (checked on DexScreener)
|
|
27
|
+
export const FAMOUS = {
|
|
28
|
+
USDC, USDT, SOL: WSOL, WSOL,
|
|
29
|
+
JUP: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN", BONK: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
|
|
30
|
+
WIF: "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm", PYTH: "HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3",
|
|
31
|
+
JTO: "jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL", RAY: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
|
|
32
|
+
PYUSD: "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo", MSOL: "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So",
|
|
33
|
+
JITOSOL: "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn", TRUMP: "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN",
|
|
34
|
+
PUMP: "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn", JLP: "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4",
|
|
35
|
+
POPCAT: "7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr", HNT: "hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux",
|
|
36
|
+
RENDER: "rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof", ORCA: "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE",
|
|
37
|
+
DRIFT: "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7"
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Token-2022 extensions that add risk, with the points they add to the no-market spam score
|
|
41
|
+
export const EXT_POINTS = {
|
|
42
|
+
permanentDelegate: 25,
|
|
43
|
+
transferHook: 15,
|
|
44
|
+
transferFeeConfig: 10,
|
|
45
|
+
nonTransferable: 10,
|
|
46
|
+
defaultAccountState: 10
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Risk levels, from worst to best (also the default sort order of results)
|
|
50
|
+
export const LEVELS = ["HIGH", "MEDIUM", "LOW", "SPAM", "NO_DATA", "NO_MARKET"];
|
|
51
|
+
export const TYPES = ["STABLE", "TOKENIZED", "BRIDGED", "CREATOR", "OPEN"];
|
|
52
|
+
export const LIGHTS = ["control", "concentration", "market", "dependency"];
|
|
53
|
+
export const RANKS = { ok: 0, na: 0, warn: 1, bad: 2 };
|
|
54
|
+
|
|
55
|
+
export const ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
package/src/curve.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Is a key a normal wallet (a point on the ed25519 curve) or a PDA (off the curve)?
|
|
2
|
+
// Used when an account does not exist on chain: an empty wallet is on the curve, a PDA is not.
|
|
3
|
+
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
4
|
+
const P25519 = (1n << 255n) - 19n;
|
|
5
|
+
|
|
6
|
+
function fmod(a) { a %= P25519; return a < 0n ? a + P25519 : a; }
|
|
7
|
+
function fpow(b, e) { let r = 1n; b = fmod(b); while (e > 0n) { if (e & 1n) r = r * b % P25519; b = b * b % P25519; e >>= 1n; } return r; }
|
|
8
|
+
const D25519 = fmod(-121665n * fpow(121666n, P25519 - 2n));
|
|
9
|
+
|
|
10
|
+
// Decodes a base58 address into its 32 bytes, or null if it is not a valid 32-byte key
|
|
11
|
+
export function b58bytes(str) {
|
|
12
|
+
let n = 0n, i;
|
|
13
|
+
const out = [];
|
|
14
|
+
for (i = 0; i < str.length; i++) { const k = B58.indexOf(str[i]); if (k < 0) return null; n = n * 58n + BigInt(k); }
|
|
15
|
+
while (n > 0n) { out.unshift(Number(n & 255n)); n >>= 8n; }
|
|
16
|
+
for (i = 0; i < str.length && str[i] === "1"; i++) out.unshift(0);
|
|
17
|
+
return out.length === 32 ? out : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function onCurve(bytes) {
|
|
21
|
+
if (!bytes) return false;
|
|
22
|
+
let y = 0n;
|
|
23
|
+
for (let i = 31; i >= 0; i--) { let b = BigInt(bytes[i]); if (i === 31) b &= 127n; y = (y << 8n) | b; }
|
|
24
|
+
if (y >= P25519) return false;
|
|
25
|
+
const y2 = y * y % P25519, u = fmod(y2 - 1n), v = fmod(D25519 * y2 + 1n);
|
|
26
|
+
if (v === 0n) return false;
|
|
27
|
+
const x2 = u * fpow(v, P25519 - 2n) % P25519;
|
|
28
|
+
if (x2 === 0n) return true;
|
|
29
|
+
return fpow(x2, (P25519 - 1n) / 2n) === 1n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isOnCurve(address) {
|
|
33
|
+
return onCurve(b58bytes(address));
|
|
34
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Every error the engine throws on purpose is a ScanError with a stable `code`, so callers can
|
|
2
|
+
// show their own message (see i18n describeError) instead of parsing English text.
|
|
3
|
+
//
|
|
4
|
+
// Codes:
|
|
5
|
+
// CANCELLED the AbortSignal passed to the client fired
|
|
6
|
+
// HTTP a service answered with an HTTP error (data.status)
|
|
7
|
+
// NETWORK no answer after all retries
|
|
8
|
+
// RPC the RPC answered with a JSON-RPC error
|
|
9
|
+
// DEX_UNREACHABLE DexScreener could not be reached at all
|
|
10
|
+
// HELIUS_REQUIRED PnL needs a Helius API key
|
|
11
|
+
// SOL_PRICE no SOL price available to value trades
|
|
12
|
+
// INVALID_ADDRESS not a Solana address
|
|
13
|
+
// NOT_A_TOKEN the address is not a token mint
|
|
14
|
+
// IS_NFT the mint is an NFT (decimals 0, supply 1)
|
|
15
|
+
// NO_HOLDERS the RPC returned no holder data
|
|
16
|
+
// TOO_FEW_WALLETS fewer readable wallets than needed (data.n, data.total)
|
|
17
|
+
// TOO_FEW_TOKENS fewer valid mints than needed
|
|
18
|
+
export class ScanError extends Error {
|
|
19
|
+
constructor(code, message, data) {
|
|
20
|
+
super(message || code);
|
|
21
|
+
this.name = "ScanError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
if (data) this.data = data;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isCancel(e) {
|
|
28
|
+
return !!e && e.code === "CANCELLED";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function cancelled() {
|
|
32
|
+
return new ScanError("CANCELLED", "cancelled");
|
|
33
|
+
}
|
package/src/holders.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ADDRESS_RE } from "./constants.js";
|
|
2
|
+
import { ScanError, isCancel } from "./errors.js";
|
|
3
|
+
import { pool, progress, short, uniq } from "./util.js";
|
|
4
|
+
import { getMint, accountKinds } from "./tokens.js";
|
|
5
|
+
import { getMarkets } from "./markets.js";
|
|
6
|
+
import { rugcheckReport, rugcheckGraph, rugcheckSummary, netBalances } from "./rugcheck.js";
|
|
7
|
+
|
|
8
|
+
// Largest accounts of a token, each traced to its owner, and whether each owner is a wallet or a program
|
|
9
|
+
export async function holderBase(client, mint, onProgress) {
|
|
10
|
+
progress(onProgress, "token", 0.08);
|
|
11
|
+
const res = await Promise.all([
|
|
12
|
+
getMint(client, mint),
|
|
13
|
+
client.rpc("getTokenLargestAccounts", [mint]),
|
|
14
|
+
getMarkets(client, [mint]).catch(function (e) { if (isCancel(e)) throw e; return { data: {}, failed: [mint] }; })
|
|
15
|
+
]);
|
|
16
|
+
const info = res[0], largest = (res[1] && res[1].value) || [], pair = res[2].data[mint] || null;
|
|
17
|
+
if (!info) throw new ScanError("NOT_A_TOKEN", "that address is not a token");
|
|
18
|
+
if (!largest.length) throw new ScanError("NO_HOLDERS", "the RPC returned no holder data");
|
|
19
|
+
progress(onProgress, "owners", 0.4);
|
|
20
|
+
const ta = await client.rpc("getMultipleAccounts", [largest.map(function (a) { return a.address; }), { encoding: "jsonParsed" }]);
|
|
21
|
+
const owners = ta.value.map(function (v) {
|
|
22
|
+
return v && v.data && v.data.parsed && v.data.parsed.info && v.data.parsed.info.owner || null;
|
|
23
|
+
});
|
|
24
|
+
const owned = uniq(owners.filter(Boolean));
|
|
25
|
+
progress(onProgress, "kinds", 0.7);
|
|
26
|
+
const kind = await accountKinds(client, owned, false);
|
|
27
|
+
return { mint: mint, info: info, pair: pair, largest: largest, owners: owners, uniq: owned, kind: kind };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// People's wallets rarely move hundreds of transactions in a couple of days; exchanges, bots and
|
|
31
|
+
// market makers do. Checked with the last 200 signatures. Marks them kind[o] = "active" (mutates kind)
|
|
32
|
+
// and returns {address: hours it took to do those 200}.
|
|
33
|
+
export async function markBusy(client, kind, wallets, onStep) {
|
|
34
|
+
const busy = {};
|
|
35
|
+
let chk = 0;
|
|
36
|
+
await pool(wallets, 3, async function (o) {
|
|
37
|
+
try {
|
|
38
|
+
const sg = await client.rpc("getSignaturesForAddress", [o, { limit: 200 }]);
|
|
39
|
+
if (sg && sg.length >= 200) {
|
|
40
|
+
const t1 = sg[0].blockTime, t0 = sg[sg.length - 1].blockTime;
|
|
41
|
+
if (t1 && t0 && (t1 - t0) < 2 * 86400) { kind[o] = "active"; busy[o] = Math.max(1, Math.round((t1 - t0) / 3600)); }
|
|
42
|
+
}
|
|
43
|
+
} catch (e) { if (isCancel(e)) throw e; }
|
|
44
|
+
chk++; if (onStep) onStep(chk, wallets.length);
|
|
45
|
+
});
|
|
46
|
+
return busy;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ---------- wallets funded from the same source (maybe one owner split into several accounts) ---------- */
|
|
50
|
+
// By Solana protocol, account 0 of any transaction is always the fee payer. Looking at who paid the
|
|
51
|
+
// very first transaction of each wallet shows who "created" it with the first SOL transfer. If several
|
|
52
|
+
// wallets that look like different holders share that source, they are most likely the same person or
|
|
53
|
+
// team splitting the supply to look decentralised. If the wallet is too active to find its first
|
|
54
|
+
// transaction reliably (more than 3000 signatures), it is left without data rather than guessing wrong.
|
|
55
|
+
export async function findFunder(client, addr) {
|
|
56
|
+
try {
|
|
57
|
+
let sigs = null, before = null, reachedEnd = false;
|
|
58
|
+
for (let i = 0; i < 3; i++) {
|
|
59
|
+
const page = await client.rpc("getSignaturesForAddress", [addr, before ? { limit: 1000, before: before } : { limit: 1000 }]);
|
|
60
|
+
if (!page || !page.length) { reachedEnd = true; break; }
|
|
61
|
+
sigs = page;
|
|
62
|
+
if (page.length < 1000) { reachedEnd = true; break; }
|
|
63
|
+
before = page[page.length - 1].signature;
|
|
64
|
+
}
|
|
65
|
+
if (!reachedEnd || !sigs || !sigs.length) return null;
|
|
66
|
+
const earliest = sigs[sigs.length - 1].signature;
|
|
67
|
+
const tx = await client.rpc("getTransaction", [earliest, { maxSupportedTransactionVersion: 0, encoding: "jsonParsed" }]);
|
|
68
|
+
const keys = tx && tx.transaction && tx.transaction.message && tx.transaction.message.accountKeys;
|
|
69
|
+
if (!keys || !keys.length) return null;
|
|
70
|
+
const payer = keys[0] && (keys[0].pubkey !== undefined ? keys[0].pubkey : keys[0]);
|
|
71
|
+
if (!payer || payer === addr) return null; // it paid for itself: there is no outside source to look at
|
|
72
|
+
return payer;
|
|
73
|
+
} catch (e) { if (isCancel(e)) throw e; return null; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function findFunders(client, wallets, onStep) {
|
|
77
|
+
const out = {};
|
|
78
|
+
let done = 0;
|
|
79
|
+
await pool(wallets, 3, async function (a) {
|
|
80
|
+
out[a] = await findFunder(client, a);
|
|
81
|
+
done++; if (onStep) onStep(done, wallets.length);
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// An exchange pays the withdrawals of thousands of customers from the same wallet: if that is the
|
|
87
|
+
// source, the "group" joins strangers. So sources that pass the same very-active test used to separate
|
|
88
|
+
// bots are ignored (hot = markBusy result over the sources).
|
|
89
|
+
export function clusterByFunder(funders, hot) {
|
|
90
|
+
const byFunder = {};
|
|
91
|
+
Object.keys(funders).forEach(function (a) {
|
|
92
|
+
const f = funders[a]; if (!f || (hot && hot[f])) return;
|
|
93
|
+
(byFunder[f] = byFunder[f] || []).push(a);
|
|
94
|
+
});
|
|
95
|
+
const clusters = {}, LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
96
|
+
let n = 0;
|
|
97
|
+
Object.keys(byFunder).forEach(function (f) {
|
|
98
|
+
const ws = byFunder[f];
|
|
99
|
+
if (ws.length < 2) return; // a single wallet with that source is not a pattern
|
|
100
|
+
const id = LETTERS[n] || ("G" + n); n++;
|
|
101
|
+
ws.forEach(function (a) { clusters[a] = { id: id, n: ws.length, funder: f }; });
|
|
102
|
+
});
|
|
103
|
+
return clusters;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One row per owner (several token accounts of the same owner are summed), sorted by % of supply
|
|
107
|
+
export function holderRows(b, busy) {
|
|
108
|
+
busy = busy || {};
|
|
109
|
+
const agg = {};
|
|
110
|
+
b.largest.forEach(function (a, i) {
|
|
111
|
+
const o = b.owners[i] || a.address;
|
|
112
|
+
if (!agg[o]) agg[o] = { owner: o, kind: b.kind[o] || "program", raw: 0, busy: busy[o] || 0 };
|
|
113
|
+
agg[o].raw += Number(a.amount);
|
|
114
|
+
});
|
|
115
|
+
const price = Number((b.pair && b.pair.priceUsd) || 0);
|
|
116
|
+
const rows = Object.keys(agg).map(function (k) {
|
|
117
|
+
const r = agg[k];
|
|
118
|
+
r.pct = b.info.supply > 0 ? r.raw / b.info.supply * 100 : 0;
|
|
119
|
+
r.tokens = r.raw / Math.pow(10, b.info.decimals);
|
|
120
|
+
r.value = r.tokens * price;
|
|
121
|
+
return r;
|
|
122
|
+
}).sort(function (a, c) { return c.pct - a.pct; });
|
|
123
|
+
rows.forEach(function (r, i) { r.rank = i + 1; });
|
|
124
|
+
return { rows: rows, price: price };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Who created the token, what else they created (and how risky it is) and RugCheck's insider networks
|
|
128
|
+
export async function creatorInfo(client, mint, onProgress) {
|
|
129
|
+
try {
|
|
130
|
+
const rcs = await Promise.all([rugcheckReport(client, mint), rugcheckGraph(client, mint)]), rc = rcs[0];
|
|
131
|
+
if (!rc || !rc.creator) return null;
|
|
132
|
+
const others = (rc.creatorTokens || []).filter(function (t) { return t.mint !== mint; })
|
|
133
|
+
.sort(function (a, b) { return new Date(b.createdAt || 0) - new Date(a.createdAt || 0); });
|
|
134
|
+
const toCheck = others.slice(0, 10), riskMap = {};
|
|
135
|
+
if (toCheck.length) {
|
|
136
|
+
progress(onProgress, "creatorTokens", 0.94);
|
|
137
|
+
await pool(toCheck, 3, async function (t) { riskMap[t.mint] = await rugcheckSummary(client, t.mint); });
|
|
138
|
+
}
|
|
139
|
+
const creator = {
|
|
140
|
+
addr: rc.creator, balance: rc.creatorBalance || 0, rugged: !!rc.rugged,
|
|
141
|
+
launchpad: (rc.launchpad && rc.launchpad.name) || null, others: others, checked: toCheck.length,
|
|
142
|
+
riskMap: riskMap, insiderNetworks: rc.insiderNetworks || [], graph: rcs[1], supply: (rc.token && rc.token.supply) || 0
|
|
143
|
+
};
|
|
144
|
+
if (creator.graph && creator.graph.length) {
|
|
145
|
+
progress(onProgress, "networks", 0.97);
|
|
146
|
+
await netBalances(client, mint, creator.graph);
|
|
147
|
+
}
|
|
148
|
+
return creator;
|
|
149
|
+
} catch (e) { if (isCancel(e)) throw e; return null; } // like RugCheck in general: never blocks the main result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Whale finder: top holders, with bots/exchanges separated from people, people grouped by who funded
|
|
154
|
+
* them, and the creator's history. Works best with your own RPC (it needs getTokenLargestAccounts).
|
|
155
|
+
*
|
|
156
|
+
* @param [opts.creator=true] also look up the creator and insider networks on RugCheck
|
|
157
|
+
*/
|
|
158
|
+
export async function analyzeHolders(client, mint, opts) {
|
|
159
|
+
opts = opts || {};
|
|
160
|
+
const onProgress = opts.onProgress;
|
|
161
|
+
if (!ADDRESS_RE.test(mint || "")) throw new ScanError("INVALID_ADDRESS", "not a Solana address");
|
|
162
|
+
const b = await holderBase(client, mint, onProgress);
|
|
163
|
+
const wal = b.uniq.filter(function (o) { return b.kind[o] === "wallet"; });
|
|
164
|
+
const busy = await markBusy(client, b.kind, wal, function (c, n) { progress(onProgress, "bots", 0.65 + 0.15 * c / Math.max(1, n), { done: c, total: n }); });
|
|
165
|
+
const hr = holderRows(b, busy), pair = b.pair;
|
|
166
|
+
const people = hr.rows.filter(function (r) { return r.kind === "wallet"; }).map(function (r) { return r.owner; });
|
|
167
|
+
let clusters = {}, hotGroups = 0;
|
|
168
|
+
if (people.length >= 2) {
|
|
169
|
+
const funders = await findFunders(client, people, function (c, n) { progress(onProgress, "funders", 0.8 + 0.08 * c / Math.max(1, n), { done: c, total: n }); });
|
|
170
|
+
clusters = clusterByFunder(funders);
|
|
171
|
+
const srcs = uniq(Object.keys(clusters).map(function (a) { return clusters[a].funder; }));
|
|
172
|
+
if (srcs.length) {
|
|
173
|
+
const hot = await markBusy(client, {}, srcs, function (c, n) { progress(onProgress, "exchangeSources", 0.88 + 0.02 * c / Math.max(1, n), { done: c, total: n }); });
|
|
174
|
+
hotGroups = srcs.filter(function (f) { return hot[f]; }).length;
|
|
175
|
+
if (hotGroups) clusters = clusterByFunder(funders, hot);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
let creator = null;
|
|
179
|
+
if (opts.creator !== false) {
|
|
180
|
+
progress(onProgress, "creator", 0.9);
|
|
181
|
+
creator = await creatorInfo(client, mint, onProgress);
|
|
182
|
+
}
|
|
183
|
+
progress(onProgress, "done", 1);
|
|
184
|
+
return {
|
|
185
|
+
mint: mint, sym: (pair && pair.baseToken && pair.baseToken.symbol) || short(mint),
|
|
186
|
+
name: (pair && pair.baseToken && pair.baseToken.name) || "", price: hr.price, rows: hr.rows,
|
|
187
|
+
mintAuth: !!b.info.mintAuth, freezeAuth: !!b.info.freezeAuth, clusters: clusters, hotGroups: hotGroups, creator: creator
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/i18n/en.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
function usd(n) {
|
|
2
|
+
if (!n) return "$0";
|
|
3
|
+
if (n < 0.01) return "<$0.01";
|
|
4
|
+
return "$" + n.toLocaleString("en-US", { minimumFractionDigits: n < 1000 ? 2 : 0, maximumFractionDigits: n < 1000 ? 2 : 0 });
|
|
5
|
+
}
|
|
6
|
+
function pct(n) { return Math.round(n) + "%"; }
|
|
7
|
+
function short(a) { return a.slice(0, 4) + "…" + a.slice(-4); }
|
|
8
|
+
function prog(d) { return d && d.prog >= 20 ? ". Pools and programs hold " + pct(d.prog) : ""; }
|
|
9
|
+
|
|
10
|
+
const EXT = {
|
|
11
|
+
permanentDelegate: "The issuer can move or burn your tokens (permanent delegate). Normal for tokenized stocks, but you have to trust the issuer",
|
|
12
|
+
transferHook: "Has a transfer hook: runs external code on every transfer",
|
|
13
|
+
transferFeeConfig: "Charges a fee on every transfer",
|
|
14
|
+
nonTransferable: "Cannot be transferred",
|
|
15
|
+
defaultAccountState: "New accounts can be created frozen"
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
fmt: { usd: usd, pct: pct, short: short },
|
|
20
|
+
signals: {
|
|
21
|
+
NO_INFO: () => "Could not read the token's information",
|
|
22
|
+
MINT_BY_PROGRAM: () => "A program controls the mint (usually a bridge or protocol): you have to trust that code",
|
|
23
|
+
MINT_ACTIVE: (d) => "The creator can still mint more tokens (mint authority active" + (d && d.byWallet ? ", held by a wallet" : "") + ")",
|
|
24
|
+
FREEZE_BY_PROGRAM: () => "A program controls freezing: it can freeze accounts according to its code",
|
|
25
|
+
FREEZE_ACTIVE: (d) => "The creator can freeze accounts (freeze authority active" + (d && d.byWallet ? ", held by a wallet" : "") + ")",
|
|
26
|
+
EXTENSION: (d) => EXT[d.ext] || ("Extension " + d.ext),
|
|
27
|
+
NO_MARKET: () => "No market on DexScreener (no price or liquidity)",
|
|
28
|
+
MARKET_UNCHECKED: () => "Could not check this token's market (network error reaching DexScreener): analyse it again to check",
|
|
29
|
+
KNOWN_TOKEN: () => "Well-known token",
|
|
30
|
+
|
|
31
|
+
CTL_MINT_ACTIVE: () => "The creator can mint more tokens (mint authority active)",
|
|
32
|
+
CTL_FREEZE_ACTIVE: () => "The creator can freeze accounts (freeze authority active)",
|
|
33
|
+
CTL_TRANSFER_HOOK: () => "Runs external code on every transfer",
|
|
34
|
+
CTL_TRANSFER_FEE: () => "Charges a fee on every transfer",
|
|
35
|
+
CTL_NON_TRANSFERABLE: () => "Cannot be transferred",
|
|
36
|
+
CTL_DEFAULT_FROZEN: () => "New accounts can be created frozen",
|
|
37
|
+
CTL_ONLY_PROGRAMS: () => "Only program-controlled authorities (see dependency)",
|
|
38
|
+
CTL_NONE: () => "No active authorities: nobody can mint more or freeze",
|
|
39
|
+
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
|
+
CON_SKIPPED: () => "Holder concentration not checked",
|
|
42
|
+
CON_UNAVAILABLE: () => "Could not check holder concentration (the service did not answer; try again in a while)",
|
|
43
|
+
CON_ALL_POOLS: (d) => "Almost all the supply is in pools or programs" + prog(d),
|
|
44
|
+
CON_OK: (d) => "No worrying concentration (largest individual wallet: " + pct(d.w1) + ")" + prog(d),
|
|
45
|
+
CON_HIGH: (d) => "The largest individual wallet holds " + pct(d.w1) + " and the top 10 hold " + pct(d.w10) + " of the supply" + prog(d),
|
|
46
|
+
CON_UNSPLIT: (d) => "Could not separate pools from people. The largest account holds " + pct(d.top1) + " of the supply",
|
|
47
|
+
|
|
48
|
+
MKT_LIQ_INFLATED: (d) => "Inflated liquidity: DexScreener says " + usd(d.reported) + ", but the pool only has about " + usd(d.real) + " in real tokens (USDC, USDT or SOL). That is the most you could get out when selling",
|
|
49
|
+
MKT_LIQ_VERY_LOW: (d) => "Very low liquidity (" + usd(d.liq) + ")",
|
|
50
|
+
MKT_LIQ_LOW: (d) => "Low liquidity (" + usd(d.liq) + ")",
|
|
51
|
+
MKT_VOLUME_LOW: (d) => "Almost no 24h volume (" + usd(d.vol) + ")",
|
|
52
|
+
MKT_NEW: (d) => "Very new market (" + (d.days < 1 ? "less than a day" : d.days + (d.days === 1 ? " day" : " days")) + ")",
|
|
53
|
+
MKT_POSITION_HEAVY: (d) => "This wallet's position equals " + pct(d.pct) + " of the liquidity: selling at once would move the price a lot",
|
|
54
|
+
MKT_LP_UNLOCKED: (d) => "Only " + pct(d.pct) + " of the liquidity is locked or burned (per RugCheck): the rest can be pulled at once",
|
|
55
|
+
MKT_LP_PARTLY_LOCKED: (d) => "Only " + pct(d.pct) + " of the liquidity is locked or burned (per RugCheck)",
|
|
56
|
+
MKT_OK: (d) => "Liquidity " + usd(d.liq) + " · 24h volume " + usd(d.vol),
|
|
57
|
+
|
|
58
|
+
DEP_PROGRAM_AUTHORITY: () => "A program controls the mint or freeze (bridge or protocol): you have to trust its code",
|
|
59
|
+
DEP_PERMANENT_DELEGATE: () => "The issuer can move or burn your tokens (permanent delegate): you trust the issuer",
|
|
60
|
+
DEP_STABLE_ISSUER: () => "Its value depends on whoever issues and backs it",
|
|
61
|
+
DEP_NONE: () => "Does not depend on an issuer or a bridge"
|
|
62
|
+
},
|
|
63
|
+
offSolana: " and its main market may be outside Solana",
|
|
64
|
+
levels: {
|
|
65
|
+
HIGH: "High risk", MEDIUM: "Medium risk", LOW: "Low risk", SPAM: "Likely spam",
|
|
66
|
+
NO_DATA: "Not checked", NO_MARKET: "No market", MEDIUM_DATA_ONLY: "Medium due to missing data"
|
|
67
|
+
},
|
|
68
|
+
types: {
|
|
69
|
+
STABLE: { label: "Stablecoin", note: "Judged by its liquidity and trust in the issuer; concentration matters less." },
|
|
70
|
+
TOKENIZED: { label: "Tokenized or regulated", note: "The issuer normally keeps control (permanent delegate). The real risk is trusting the issuer and having liquidity to exit." },
|
|
71
|
+
BRIDGED: { label: "Bridged or protocol", note: "A program normally controls the mint. The real risk is trusting that bridge and its Solana market being small, since the main one may be on another chain." },
|
|
72
|
+
CREATOR: { label: "Creator-controlled", note: "A wallet can mint more or freeze accounts: the typical scam pattern, so it is judged more strictly." },
|
|
73
|
+
OPEN: { label: "Open token", note: "No creator authorities, typical of memecoins and new projects. Concentration in people and liquidity matter most." }
|
|
74
|
+
},
|
|
75
|
+
lights: { control: "Creator control", concentration: "Concentration", market: "Market and exit", dependency: "Dependency" },
|
|
76
|
+
missing: { control: "who controls the token", concentration: "holder concentration", market: "the market", dependency: "issuer dependency" },
|
|
77
|
+
steps: {
|
|
78
|
+
address: "Checking the address…", wallet: "Reading the wallet…", markets: "Fetching prices and liquidity…",
|
|
79
|
+
tokens: "Reviewing tokens…", token: "Reading the token…", owners: "Finding who owns each account…",
|
|
80
|
+
kinds: "Telling wallets from pools and programs…", bots: "Separating people from bots and exchanges…",
|
|
81
|
+
funders: "Looking for wallets funded from the same source…", exchangeSources: "Checking whether each group's source is an exchange…",
|
|
82
|
+
creator: "Looking up who created the token (RugCheck)…", creatorTokens: "Reviewing the creator's other tokens…",
|
|
83
|
+
networks: "Checking what wallet networks hold today…", wallets: "Reading wallets…",
|
|
84
|
+
pnl: "Estimating each wallet's result…", done: "Done"
|
|
85
|
+
},
|
|
86
|
+
notes: {
|
|
87
|
+
PNL_NO_HELIUS: "Wallet results were not estimated because there is no Helius key.",
|
|
88
|
+
PNL_PARTIAL: "Some wallets' history could not be read.",
|
|
89
|
+
PNL_APPROX: "SOL price history was unavailable: today's price was used and results may be far off."
|
|
90
|
+
},
|
|
91
|
+
errors: {
|
|
92
|
+
CANCELLED: () => "Cancelled.",
|
|
93
|
+
INVALID_ADDRESS: () => "That does not look like a Solana address. They are 32 to 44 letters and digits and do not start with 0x.",
|
|
94
|
+
NOT_A_TOKEN: () => "That address is not a token.",
|
|
95
|
+
IS_NFT: () => "That address is an NFT, not a token. The scanner does not review NFTs.",
|
|
96
|
+
NO_HOLDERS: () => "The RPC returned no holder data.",
|
|
97
|
+
HELIUS_REQUIRED: () => "Estimating results needs a Helius API key (or a Helius RPC URL with the key).",
|
|
98
|
+
SOL_PRICE: () => "Could not get the SOL price to compute the result. Try again in a while.",
|
|
99
|
+
DEX_UNREACHABLE: () => "Could not connect to DexScreener. Check your internet connection and try again.",
|
|
100
|
+
TOO_FEW_WALLETS: (d) => d && d.filter ? "Only " + d.n + (d.n === 1 ? " wallet passes" : " wallets pass") + " the result filter (of " + d.total + " read)." : "At least 2 readable wallets are needed.",
|
|
101
|
+
TOO_FEW_TOKENS: () => "Give at least 2 Solana token addresses.",
|
|
102
|
+
NETWORK_PUBLIC: () => "The free Solana services did not answer (they get saturated sometimes). Wait a minute and try again, or use your own RPC URL (Helius has a free plan).",
|
|
103
|
+
NETWORK_OWN: () => "Could not connect to your RPC URL. Check it was copied correctly (with the key at the end).",
|
|
104
|
+
UNKNOWN: (d) => "Something failed: " + d.message + ". Try again or use another RPC."
|
|
105
|
+
}
|
|
106
|
+
};
|