solana-risk-engine 0.4.3 → 0.4.4
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 +2 -1
- package/package.json +47 -47
- package/src/base.js +28 -0
- package/src/constants.js +15 -0
- package/src/holders.js +18 -4
- package/src/i18n/en.js +2 -0
- package/src/i18n/es.js +2 -0
- package/src/index.js +5 -2
- package/src/risk.js +34 -9
- package/src/tokens.js +21 -1
package/README.md
CHANGED
|
@@ -73,7 +73,8 @@ Methods (all accept `{ onProgress(step, fraction) }`):
|
|
|
73
73
|
| `analyzeToken(mint, { skipCreator })` | `{ lvl, sev, type, lights, reasons, impostor, pair, creator, ... }` |
|
|
74
74
|
| `analyzeWallet(wallet, { minUsd })` | `{ sol, balances, total, hidden, results: [token results] }` |
|
|
75
75
|
| `analyzeAddress(addr)` | `{ kind: "token" \| "wallet", ... }`, detected automatically |
|
|
76
|
-
| `analyzeHolders(mint)` | `{ rows, clusters, groups, hotGroups, creator, mode, ageDays, signals, ... }`. Young tokens (under 14 days) also trace busy wallets; a source over 100 tx/h counts as an exchange; a group over 10% (young) or 15% (mature) of the supply gives a `HOLD_GROUP` signal. Best with your own RPC. |
|
|
76
|
+
| `analyzeHolders(mint)` | `{ rows, clusters, groups, hotGroups, creator, mode, ageDays, signals, ... }`. Young tokens (under 14 days) also trace busy wallets; a source over 100 tx/h counts as an exchange; a group over 10% (young) or 15% (mature) of the supply gives a `HOLD_GROUP` signal. With `{ base: true }` it also reads [SolFlag's wallet base](https://solflag.com/datos/base.json) (exchanges, launch farms, distributors and serial creators its collector has seen): known exchanges are recognised without measuring them, and rows and group sources get `known` / `knownSources`. Best with your own RPC. |
|
|
77
|
+
| `loadWalletBase([url])` | SolFlag's wallet base `{ wallets: { addr: { t, ph, n, roles } } }`, or `null` if it cannot be read. Kept 10 minutes. `knownType(base, addr)` gives a wallet's type. |
|
|
77
78
|
| `estimatePnl(wallets)` | `{ pnl: { [wallet]: { realized, unreal, total, ret, st, open } } }`. Needs Helius. |
|
|
78
79
|
| `takeSnapshots(wallets)` | Today's holdings. Store them yourself and pass them to `diffSnapshots(prev, next)`. |
|
|
79
80
|
| `confluence(wallets, { pct, withPnl, pnlFilter })` | Tokens held in common, with risk. |
|
package/package.json
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "solana-risk-engine",
|
|
3
|
-
"version": "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
|
-
"type": "module",
|
|
6
|
-
"main": "./src/index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./src/index.js",
|
|
9
|
-
"./i18n": "./src/i18n/index.js"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"src",
|
|
13
|
-
"README.md",
|
|
14
|
-
"LICENSE",
|
|
15
|
-
"bin"
|
|
16
|
-
],
|
|
17
|
-
"bin": {
|
|
18
|
-
"solana-risk": "bin/solana-risk.js"
|
|
19
|
-
},
|
|
20
|
-
"scripts": {
|
|
21
|
-
"test": "node --test test/*.test.js",
|
|
22
|
-
"example": "node bin/solana-risk.js"
|
|
23
|
-
},
|
|
24
|
-
"engines": {
|
|
25
|
-
"node": ">=18"
|
|
26
|
-
},
|
|
27
|
-
"keywords": [
|
|
28
|
-
"solana",
|
|
29
|
-
"risk",
|
|
30
|
-
"rug-pull",
|
|
31
|
-
"scam-detection",
|
|
32
|
-
"token",
|
|
33
|
-
"spl-token",
|
|
34
|
-
"token-2022",
|
|
35
|
-
"holders",
|
|
36
|
-
"dexscreener",
|
|
37
|
-
"rugcheck"
|
|
38
|
-
],
|
|
39
|
-
"license": "MIT",
|
|
40
|
-
"author": "panpontomate",
|
|
41
|
-
"repository": {
|
|
42
|
-
"type": "git",
|
|
43
|
-
"url": "git+https://github.com/panpontomate/solana-risk-engine.git"
|
|
44
|
-
},
|
|
45
|
-
"homepage": "https://github.com/panpontomate/solana-risk-engine#readme",
|
|
46
|
-
"bugs": "https://github.com/panpontomate/solana-risk-engine/issues"
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "solana-risk-engine",
|
|
3
|
+
"version": "0.4.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
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./i18n": "./src/i18n/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"bin"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"solana-risk": "bin/solana-risk.js"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/*.test.js",
|
|
22
|
+
"example": "node bin/solana-risk.js"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"solana",
|
|
29
|
+
"risk",
|
|
30
|
+
"rug-pull",
|
|
31
|
+
"scam-detection",
|
|
32
|
+
"token",
|
|
33
|
+
"spl-token",
|
|
34
|
+
"token-2022",
|
|
35
|
+
"holders",
|
|
36
|
+
"dexscreener",
|
|
37
|
+
"rugcheck"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"author": "panpontomate",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/panpontomate/solana-risk-engine.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/panpontomate/solana-risk-engine#readme",
|
|
46
|
+
"bugs": "https://github.com/panpontomate/solana-risk-engine/issues"
|
|
47
|
+
}
|
package/src/base.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { WALLET_BASE_URL } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
// SolFlag's wallet base: what its collector learns from every launch it checks (exchanges, launch farms,
|
|
4
|
+
// distributors, serial creators), refreshed every 30 minutes. A known exchange is recognised at once,
|
|
5
|
+
// without asking the RPC how many transactions it makes
|
|
6
|
+
|
|
7
|
+
export const WALLET_TYPES = ["exchange", "granja", "repartidor", "creador en serie", "sniper"];
|
|
8
|
+
const BASE_TTL = 600000;
|
|
9
|
+
const caches = new WeakMap();
|
|
10
|
+
|
|
11
|
+
// The base, or null when it cannot be read (it is an extra: nothing depends on it). Each client keeps it
|
|
12
|
+
// 10 minutes per URL
|
|
13
|
+
export async function loadWalletBase(client, url) {
|
|
14
|
+
url = url || WALLET_BASE_URL;
|
|
15
|
+
if (!caches.has(client)) caches.set(client, {});
|
|
16
|
+
const cache = caches.get(client), c = cache[url];
|
|
17
|
+
if (c && Date.now() - c.at < BASE_TTL) return c.data;
|
|
18
|
+
const j = await client.softJson(url, 8000);
|
|
19
|
+
const data = j && j.wallets && typeof j.wallets === "object" ? j : null;
|
|
20
|
+
cache[url] = { at: Date.now(), data: data };
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Type of a wallet in the base ("exchange", "granja"...) or null
|
|
25
|
+
export function knownType(base, addr) {
|
|
26
|
+
const w = base && base.wallets && base.wallets[addr];
|
|
27
|
+
return (w && WALLET_TYPES.indexOf(w.t) >= 0) ? w.t : null;
|
|
28
|
+
}
|
package/src/constants.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
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
|
+
// SolFlag's wallet base (see base.js)
|
|
9
|
+
export const WALLET_BASE_URL = "https://solflag.com/datos/base.json";
|
|
8
10
|
export const DEX_SEARCH = "https://api.dexscreener.com/latest/dex/search?q=";
|
|
9
11
|
export const RUGCHECK = "https://api.rugcheck.xyz/v1/tokens/";
|
|
10
12
|
export const GECKO = "https://api.geckoterminal.com/api/v2/networks/solana/";
|
|
@@ -60,6 +62,19 @@ export const BRANDS = {
|
|
|
60
62
|
adidas: "Adidas", gucci: "Gucci", louis_vuitton: "Louis Vuitton", rolex: "Rolex", spacex: "SpaceX", neuralink: "Neuralink"
|
|
61
63
|
};
|
|
62
64
|
|
|
65
|
+
// Known issuers of tokenized assets: every key that controls their tokens (mint, freeze, permanent delegate,
|
|
66
|
+
// hook, pause, scaling). A token is theirs only if ALL its control keys are in the list, so copying one key
|
|
67
|
+
// (anyone can hand the mint authority to someone else's wallet) is not enough. Their control is the point of
|
|
68
|
+
// the product: it is shown as dependency on the issuer, not as a creator who can rug
|
|
69
|
+
export const ISSUERS = [
|
|
70
|
+
{ name: "xStocks (Backed)", keys: [
|
|
71
|
+
"7pt9tkctJPK7PPNQJ77GKg8ZffSF6QxoMiCFYHxrtaCj", // mint
|
|
72
|
+
"JDq14BWvqCRFNu1krb12bcRpbGtJZ1FLEakMw6FdxJNs", // freeze and pause (program)
|
|
73
|
+
"5aMNNLQJwAEeoemTEMkv5NVjqKwvvefRYCQ5Z67HFvEq", // permanent delegate, transfer hook, confidential transfers
|
|
74
|
+
"S7vYFFWH6BjJyEsdrPQpqpYTqLTrPRK6KW3VwsJuRaS" // share-split multiplier (scaled UI amount)
|
|
75
|
+
] }
|
|
76
|
+
];
|
|
77
|
+
|
|
63
78
|
// Token-2022 extensions that add risk, with the points they add to the no-market spam score
|
|
64
79
|
export const EXT_POINTS = {
|
|
65
80
|
permanentDelegate: 25,
|
package/src/holders.js
CHANGED
|
@@ -4,6 +4,7 @@ import { pool, progress, short, uniq } from "./util.js";
|
|
|
4
4
|
import { getMint, accountKinds } from "./tokens.js";
|
|
5
5
|
import { getMarkets } from "./markets.js";
|
|
6
6
|
import { rugcheckReport, rugcheckGraph, rugcheckSummary, netBalances } from "./rugcheck.js";
|
|
7
|
+
import { loadWalletBase, knownType } from "./base.js";
|
|
7
8
|
|
|
8
9
|
// Largest accounts of a token, each traced to its owner, and whether each owner is a wallet or a program
|
|
9
10
|
export async function holderBase(client, mint, onProgress) {
|
|
@@ -203,6 +204,9 @@ export async function creatorInfo(client, mint, onProgress) {
|
|
|
203
204
|
* over 10% of the supply in a young token (bad) or 15% in a mature one (warn) is a HOLD_GROUP signal.
|
|
204
205
|
*
|
|
205
206
|
* @param [opts.creator=true] also look up the creator and insider networks on RugCheck
|
|
207
|
+
* @param [opts.base] SolFlag's wallet base: true to load it (solflag.com/datos/base.json), or the base
|
|
208
|
+
* itself. Known exchanges are taken as such without measuring them, and every row
|
|
209
|
+
* it knows gets known: "exchange" | "granja" | "repartidor" | "creador en serie" | "sniper"
|
|
206
210
|
* @param [opts.createdAt] ms when its market started, if the caller knows it
|
|
207
211
|
* @param [opts.now]
|
|
208
212
|
* @returns {..., mode: "young"|"mature", ageDays, groups, signals}
|
|
@@ -215,6 +219,13 @@ export async function analyzeHolders(client, mint, opts) {
|
|
|
215
219
|
const wal = b.uniq.filter(function (o) { return b.kind[o] === "wallet"; });
|
|
216
220
|
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 }); });
|
|
217
221
|
const hr = holderRows(b, busy), pair = b.pair;
|
|
222
|
+
const base = opts.base === true ? await loadWalletBase(client) : (opts.base || null);
|
|
223
|
+
hr.rows.forEach(function (r) {
|
|
224
|
+
const t = knownType(base, r.owner);
|
|
225
|
+
if (!t) return;
|
|
226
|
+
r.known = t;
|
|
227
|
+
if (t === "exchange" && r.kind === "wallet") r.kind = "active"; // quiet hours made it look like a person
|
|
228
|
+
});
|
|
218
229
|
const now = opts.now || Date.now();
|
|
219
230
|
let created = opts.createdAt || (pair && pair.pairCreatedAt) || null;
|
|
220
231
|
if (!created) {
|
|
@@ -234,9 +245,9 @@ export async function analyzeHolders(client, mint, opts) {
|
|
|
234
245
|
if (srcs.length) {
|
|
235
246
|
// a source is an exchange when it moves more than EXCHANGE_PER_HOUR transactions an hour (the bot
|
|
236
247
|
// test, 200 in 2 days, also caught distributors doing ~50 an hour and hid their groups)
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
248
|
+
const hot = {}, ask = srcs.filter(function (f) { if (knownType(base, f) === "exchange") { hot[f] = true; return false; } return true; });
|
|
249
|
+
const rates = ask.length ? await sourceRates(client, ask, function (c, n) { progress(onProgress, "exchangeSources", 0.88 + 0.02 * c / Math.max(1, n), { done: c, total: n }); }) : {};
|
|
250
|
+
ask.forEach(function (f) { if (rates[f] > EXCHANGE_PER_HOUR) hot[f] = true; });
|
|
240
251
|
hotGroups = srcs.filter(function (f) { return hot[f]; }).length;
|
|
241
252
|
if (hotGroups) clusters = clusterByFunder(funders, hot);
|
|
242
253
|
}
|
|
@@ -255,6 +266,9 @@ export async function analyzeHolders(client, mint, opts) {
|
|
|
255
266
|
mint: mint, sym: (pair && pair.baseToken && pair.baseToken.symbol) || short(mint),
|
|
256
267
|
name: (pair && pair.baseToken && pair.baseToken.name) || "", price: hr.price, rows: hr.rows,
|
|
257
268
|
mintAuth: !!b.info.mintAuth, freezeAuth: !!b.info.freezeAuth, clusters: clusters, hotGroups: hotGroups, creator: creator,
|
|
258
|
-
mode: mode, ageDays: ageDays, groups: groups, signals: signals
|
|
269
|
+
mode: mode, ageDays: ageDays, groups: groups, signals: signals,
|
|
270
|
+
// sources of groups the base knows (a known distributor or farm behind a group is worth saying)
|
|
271
|
+
knownSources: base ? uniq(groups.map(function (g) { return g.funder; })).filter(function (f) { return knownType(base, f); })
|
|
272
|
+
.reduce(function (o, f) { o[f] = knownType(base, f); return o; }, {}) : {}
|
|
259
273
|
};
|
|
260
274
|
}
|
package/src/i18n/en.js
CHANGED
|
@@ -35,6 +35,7 @@ export default {
|
|
|
35
35
|
CTL_NON_TRANSFERABLE: () => "Cannot be transferred",
|
|
36
36
|
CTL_DEFAULT_FROZEN: () => "New accounts can be created frozen",
|
|
37
37
|
CTL_ONLY_PROGRAMS: () => "Only program-controlled authorities (see dependency)",
|
|
38
|
+
CTL_KNOWN_ISSUER: (d) => "Controlled by its issuer, " + d.issuer + " (see dependency)",
|
|
38
39
|
CTL_NONE: () => "No active authorities: nobody can mint more or freeze",
|
|
39
40
|
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",
|
|
40
41
|
BRAND_COPY: (d) => "Uses the name of " + d.brand + ", a real company with no token on Solana: it is not official, almost certainly an imitation to draw buyers",
|
|
@@ -64,6 +65,7 @@ export default {
|
|
|
64
65
|
DEP_PROGRAM_AUTHORITY: () => "A program controls the mint or freeze (bridge or protocol): you have to trust its code",
|
|
65
66
|
DEP_PERMANENT_DELEGATE: () => "The issuer can move or burn your tokens (permanent delegate): you trust the issuer",
|
|
66
67
|
DEP_STABLE_ISSUER: () => "Its value depends on whoever issues and backs it",
|
|
68
|
+
DEP_KNOWN_ISSUER: (d) => "Issued by " + d.issuer + ": it can mint, freeze or move tokens, like any regulated issuer. You trust the issuer",
|
|
67
69
|
DEP_NONE: () => "Does not depend on an issuer or a bridge"
|
|
68
70
|
},
|
|
69
71
|
offSolana: " and its main market may be outside Solana",
|
package/src/i18n/es.js
CHANGED
|
@@ -38,6 +38,7 @@ export default {
|
|
|
38
38
|
CTL_NON_TRANSFERABLE: () => "No se puede transferir",
|
|
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
|
+
CTL_KNOWN_ISSUER: (d) => "Lo controla su emisor, " + d.issuer + " (ver dependencia)",
|
|
41
42
|
CTL_NONE: () => "Sin autoridades activas: nadie puede crear más ni congelar",
|
|
42
43
|
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",
|
|
43
44
|
BRAND_COPY: (d) => "Usa el nombre de " + d.brand + ", una empresa real que no tiene token en Solana: no es oficial, casi seguro una imitación para atraer compradores",
|
|
@@ -67,6 +68,7 @@ export default {
|
|
|
67
68
|
DEP_PROGRAM_AUTHORITY: () => "Un programa controla el mint o el freeze (puente o protocolo): hay que confiar en su código",
|
|
68
69
|
DEP_PERMANENT_DELEGATE: () => "El emisor puede mover o quemar tus tokens (delegado permanente): confías en el emisor",
|
|
69
70
|
DEP_STABLE_ISSUER: () => "Su valor depende de quien la emite y la respalda",
|
|
71
|
+
DEP_KNOWN_ISSUER: (d) => "Lo emite " + d.issuer + ": puede crear, congelar o mover tokens, como cualquier emisor regulado. Confías en el emisor",
|
|
70
72
|
DEP_NONE: () => "No depende de un emisor ni de un puente"
|
|
71
73
|
},
|
|
72
74
|
offSolana: " y su mercado principal puede estar fuera de Solana",
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { estimatePnl } from "./pnl.js";
|
|
|
5
5
|
import { takeSnapshots } from "./changes.js";
|
|
6
6
|
import { confluence, commonHolders } from "./confluence.js";
|
|
7
7
|
import { getTokens } from "./tokens.js";
|
|
8
|
+
import { loadWalletBase } from "./base.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The easy entry point: one object with every high-level analysis bound to one network client.
|
|
@@ -35,7 +36,8 @@ export function createScanner(opts) {
|
|
|
35
36
|
},
|
|
36
37
|
takeSnapshots: (wallets, o) => takeSnapshots(client, wallets, o),
|
|
37
38
|
confluence: (wallets, o) => confluence(client, wallets, o),
|
|
38
|
-
commonHolders: (mints, o) => commonHolders(client, mints, o)
|
|
39
|
+
commonHolders: (mints, o) => commonHolders(client, mints, o),
|
|
40
|
+
loadWalletBase: (url) => loadWalletBase(client, url)
|
|
39
41
|
};
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -46,7 +48,7 @@ export { isOnCurve, onCurve, b58bytes } from "./curve.js";
|
|
|
46
48
|
export { getTokens, getMint, getConc, getAuthKinds, isMint, accountKinds, kindOf } from "./tokens.js";
|
|
47
49
|
export { getMarkets, realLiq, impostorOf, INFLATED_RATIO, geckoLiquidity, geckoPair, brandOf, copiesOf, fakeLiquidity, FAKE_LIQ_RATIO } from "./markets.js";
|
|
48
50
|
export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances, creatorLaunches, CREATOR_DEAD_MCAP } from "./rugcheck.js";
|
|
49
|
-
export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess, SERIAL_WARN, SERIAL_BAD, HISTORY_MIN, HISTORY_WARN, HISTORY_BAD_MIN, HISTORY_BAD } from "./risk.js";
|
|
51
|
+
export { score, issuerOf, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess, SERIAL_WARN, SERIAL_BAD, HISTORY_MIN, HISTORY_WARN, HISTORY_BAD_MIN, HISTORY_BAD } from "./risk.js";
|
|
50
52
|
export { recentLaunches } from "./launch.js";
|
|
51
53
|
export { analyzeToken, analyzeWallet, analyzeAddress, sortResults } from "./wallet.js";
|
|
52
54
|
export { holderBase, markBusy, findFunder, findFunders, clusterByFunder, holderRows, creatorInfo, analyzeHolders,
|
|
@@ -54,4 +56,5 @@ export { holderBase, markBusy, findFunder, findFunders, clusterByFunder, holderR
|
|
|
54
56
|
export { fetchSwaps, solHistory, priceSeries, parseTrades, pnlFromTrades, pnlStatus, estimatePnl } from "./pnl.js";
|
|
55
57
|
export { capSnap, chgDiff, chgAgg, takeSnapshots, diffSnapshots } from "./changes.js";
|
|
56
58
|
export { confluence, commonHolders } from "./confluence.js";
|
|
59
|
+
export { loadWalletBase, knownType, WALLET_TYPES } from "./base.js";
|
|
57
60
|
export { describe, explainLight, lightLabel, levelLabel, missingText, typeInfo, describeStep, describeNote, describeError, format, addLanguage } from "./i18n/index.js";
|
package/src/risk.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EXT_POINTS, RANKS, PUMP_CURVE_DEXES } from "./constants.js";
|
|
1
|
+
import { EXT_POINTS, RANKS, PUMP_CURVE_DEXES, ISSUERS } 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";
|
|
@@ -16,8 +16,10 @@ function sig(code, s, data) {
|
|
|
16
16
|
const DAY = 86400000;
|
|
17
17
|
// Other tokens launched by the same creator in the last 24 h that make it a serial launcher
|
|
18
18
|
export const SERIAL_WARN = 2, SERIAL_BAD = 5;
|
|
19
|
-
// Creator track record: at least HISTORY_MIN older tokens and this share of them never took off
|
|
20
|
-
|
|
19
|
+
// Creator track record: at least HISTORY_MIN older tokens and this share of them never took off. Red needs
|
|
20
|
+
// 95%: 86% of all new tokens never reach $10k (149-token sample, 28/10), so one launch that took off out of
|
|
21
|
+
// ten is already better than chance and must not raise the verdict
|
|
22
|
+
export const HISTORY_MIN = 3, HISTORY_WARN = 0.8, HISTORY_BAD_MIN = 10, HISTORY_BAD = 0.95;
|
|
21
23
|
|
|
22
24
|
/* ---------- old point score: only for tokens without a market, to spot spam ---------- */
|
|
23
25
|
export function score(info) {
|
|
@@ -58,6 +60,23 @@ export function tokenType(info, pair, now) {
|
|
|
58
60
|
return "OPEN";
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
/* ---------- known issuers ---------- */
|
|
64
|
+
// Name of the known issuer that holds every control key of this mint, or null. Needs the extension keys
|
|
65
|
+
// read by getMint (extKeys): without them a permanent delegate or a hook could belong to anyone
|
|
66
|
+
export function issuerOf(info) {
|
|
67
|
+
if (!info || !info.extKeys) return null;
|
|
68
|
+
const keys = [info.mintAuth, info.freezeAuth].concat(info.extKeys).filter(Boolean);
|
|
69
|
+
if (!keys.length) return null;
|
|
70
|
+
const found = ISSUERS.find(function (i) { return keys.every(function (k) { return i.keys.indexOf(k) >= 0; }); });
|
|
71
|
+
return found ? found.name : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Extensions that only matter as they are set: a hook with no program and nobody able to add one does
|
|
75
|
+
// nothing, and new accounts are only born frozen when the default state says "frozen". Mint info without
|
|
76
|
+
// those details (older callers) keeps the old behaviour: the extension alone is enough
|
|
77
|
+
function hookActive(info) { return !info.hook || !!(info.hook.program || info.hook.auth); }
|
|
78
|
+
function bornFrozen(info) { return !info.defaultState || info.defaultState === "frozen"; }
|
|
79
|
+
|
|
61
80
|
/* ---------- the four traffic lights ---------- */
|
|
62
81
|
function mkLight(k, items, okSignal) {
|
|
63
82
|
if (!items.length) return { k: k, s: "ok", items: [okSignal] };
|
|
@@ -81,18 +100,22 @@ function mkLight(k, items, okSignal) {
|
|
|
81
100
|
export function buildLights(info, conc, pair, value, type, rc, now, launches) {
|
|
82
101
|
now = now || Date.now();
|
|
83
102
|
const ak = (info && info.authKind) || {};
|
|
103
|
+
const issuer = issuerOf(info);
|
|
84
104
|
let it;
|
|
85
105
|
|
|
86
106
|
// 1) creator control
|
|
87
107
|
it = [];
|
|
88
108
|
if (!info) it.push(sig("NO_INFO", "warn"));
|
|
89
109
|
else {
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
110
|
+
// a known issuer's mint, freeze, hook and pause are how the product works (see dependency)
|
|
111
|
+
if (!issuer) {
|
|
112
|
+
if (info.mintAuth && ak[info.mintAuth] !== "program") it.push(sig("CTL_MINT_ACTIVE", "bad"));
|
|
113
|
+
if (info.freezeAuth && ak[info.freezeAuth] !== "program") it.push(sig("CTL_FREEZE_ACTIVE", "warn"));
|
|
114
|
+
if (info.ext.indexOf("transferHook") >= 0 && hookActive(info)) it.push(sig("CTL_TRANSFER_HOOK", "warn"));
|
|
115
|
+
if (info.ext.indexOf("defaultAccountState") >= 0 && bornFrozen(info)) it.push(sig("CTL_DEFAULT_FROZEN", "warn"));
|
|
116
|
+
}
|
|
93
117
|
if (info.ext.indexOf("transferFeeConfig") >= 0) it.push(sig("CTL_TRANSFER_FEE", "warn"));
|
|
94
118
|
if (info.ext.indexOf("nonTransferable") >= 0) it.push(sig("CTL_NON_TRANSFERABLE", "warn"));
|
|
95
|
-
if (info.ext.indexOf("defaultAccountState") >= 0) it.push(sig("CTL_DEFAULT_FROZEN", "warn"));
|
|
96
119
|
}
|
|
97
120
|
// Launch farms put out a token every few minutes and drop each one as soon as buyers arrive: in a
|
|
98
121
|
// 100-token Pump.fun sample one wallet launched 7 copies of "GMS" in 10 minutes
|
|
@@ -102,7 +125,8 @@ export function buildLights(info, conc, pair, value, type, rc, now, launches) {
|
|
|
102
125
|
const bad = launches.old >= HISTORY_BAD_MIN && launches.dead / launches.old >= HISTORY_BAD;
|
|
103
126
|
it.push(sig("CTL_CREATOR_HISTORY", bad ? "bad" : "warn", { old: launches.old, dead: launches.dead, best: launches.best, creator: launches.creator }));
|
|
104
127
|
}
|
|
105
|
-
const control = mkLight("control", it,
|
|
128
|
+
const control = mkLight("control", it, issuer ? sig("CTL_KNOWN_ISSUER", "ok", { issuer: issuer })
|
|
129
|
+
: sig((info && (info.mintAuth || info.freezeAuth)) ? "CTL_ONLY_PROGRAMS" : "CTL_NONE", "ok"));
|
|
106
130
|
|
|
107
131
|
// 2) concentration in people (pools and programs do not count)
|
|
108
132
|
let concentration;
|
|
@@ -150,7 +174,8 @@ export function buildLights(info, conc, pair, value, type, rc, now, launches) {
|
|
|
150
174
|
|
|
151
175
|
// 4) dependency on an issuer or a bridge
|
|
152
176
|
it = [];
|
|
153
|
-
if (
|
|
177
|
+
if (issuer) it.push(sig("DEP_KNOWN_ISSUER", "warn", { issuer: issuer }));
|
|
178
|
+
else if (info) {
|
|
154
179
|
if ((info.mintAuth && ak[info.mintAuth] === "program") || (info.freezeAuth && ak[info.freezeAuth] === "program"))
|
|
155
180
|
it.push(sig("DEP_PROGRAM_AUTHORITY", "warn"));
|
|
156
181
|
if (info.ext.indexOf("permanentDelegate") >= 0) it.push(sig("DEP_PERMANENT_DELEGATE", "warn"));
|
package/src/tokens.js
CHANGED
|
@@ -34,13 +34,33 @@ export async function getMint(client, mint) {
|
|
|
34
34
|
const r = await client.rpc("getAccountInfo", [mint, { encoding: "jsonParsed" }]);
|
|
35
35
|
const p = r && r.value && r.value.data && r.value.data.parsed && r.value.data.parsed.info;
|
|
36
36
|
if (!p) return null;
|
|
37
|
+
const exts = p.extensions || [];
|
|
38
|
+
const state = function (name) { const e = exts.find(function (x) { return x.extension === name; }); return (e && e.state) || {}; };
|
|
39
|
+
const hook = state("transferHook");
|
|
37
40
|
return {
|
|
38
41
|
mintAuth: p.mintAuthority || null, freezeAuth: p.freezeAuthority || null,
|
|
39
42
|
supply: Number(p.supply || 0), decimals: Number(p.decimals || 0),
|
|
40
|
-
ext:
|
|
43
|
+
ext: exts.map(function (e) { return e.extension; }),
|
|
44
|
+
// what the extensions are set to: a transfer hook with no program and no authority does nothing, and
|
|
45
|
+
// "default account state" only matters when new accounts are born frozen
|
|
46
|
+
hook: { program: hook.programId || null, auth: hook.authority || null },
|
|
47
|
+
defaultState: state("defaultAccountState").accountState || null,
|
|
48
|
+
// every key that controls something in the extensions (delegate, hook, pause, metadata...)
|
|
49
|
+
extKeys: extKeys(exts)
|
|
41
50
|
};
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
function extKeys(exts) {
|
|
54
|
+
const out = [];
|
|
55
|
+
exts.forEach(function (e) {
|
|
56
|
+
Object.keys(e.state || {}).forEach(function (k) {
|
|
57
|
+
const v = e.state[k];
|
|
58
|
+
if (typeof v === "string" && /authority$|^delegate$|^programId$/i.test(k) && out.indexOf(v) < 0) out.push(v);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
// A key off the ed25519 curve has no private key: only a program can sign for it (a PDA), whoever
|
|
45
65
|
// owns its account. PDAs that hold SOL (stake pool and AMM authorities) are owned by the System
|
|
46
66
|
// Program like any wallet, so the owner alone would count them as people.
|