solana-risk-engine 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,11 +24,12 @@ for (const l of r.lights) // the four traffic lights
24
24
 
25
25
  | Check | How |
26
26
  |---|---|
27
- | **Creator control** | Mint/freeze authority still active, and whether it sits in a person's wallet or a program (bridge/protocol). For keys with no account, it checks whether the key is on the ed25519 curve. Risky Token-2022 extensions: permanent delegate, transfer hook, transfer fee, non-transferable, default-frozen accounts. |
27
+ | **Creator control** | Mint/freeze authority still active, and whether it sits in a person's wallet or a program (bridge, protocol, staking pool). A key off the ed25519 curve is always a program (PDA), even when the System Program owns its account. Risky Token-2022 extensions: permanent delegate, transfer hook, transfer fee, non-transferable, default-frozen accounts. |
28
28
  | **Real concentration** | Largest accounts are traced to their owners. Pools and programs are separated from people, so `w1`/`w10` measure *people*, not AMM vaults. |
29
- | **Inflated liquidity** | DexScreener values both sides of a pool at the pool's price. A pool with 4B of a token against 2 USDC shows "$4,000M liquidity". The engine recomputes it from the real side (USDC/USDT/SOL) only. |
30
- | **Unlocked LP** | % of LP locked/burned via RugCheck. It only counts for markets under 90 days old, so veteran tokens don't get false positives. |
31
- | **Impostors** | Tokens using the symbol or name of a famous token (USDC, JUP, BONK, WIF...) with a different address. |
29
+ | **Inflated liquidity** | DexScreener values both sides of a pool at the pool's price. A pool with 4B of a token against 2 USDC shows "$4,000M liquidity". The engine recomputes it from the real side (USDC/USDT/SOL). It only flags extreme gaps (real side under 5%), because concentrated-liquidity pools are lopsided by nature. |
30
+ | **Unlocked LP** | % of LP locked/burned via RugCheck. It only counts for markets under 90 days old, so veteran tokens don't get false positives, and never while a token is on Pump.fun's bonding curve, where there is no LP to pull. |
31
+ | **Serial creators** | For markets under 7 days: how many other tokens the creator launched in the last 24 h (RugCheck). 2 or more is a warning, 5 or more a red flag. `recentLaunches(client, wallet)` counts the same on chain for the wallet that *paid* a launch, which catches farms that name a fresh creator each time. |
32
+ | **Impostors** | Tokens using the symbol or name of a famous token (USDC, JUP, BONK, WIF, BTC, ETH...) with a different address. |
32
33
  | **Token type** | `STABLE`, `TOKENIZED`, `BRIDGED`, `CREATOR`, `OPEN`. Each type is judged by the rules that fit it: a bridged token normally has a program as mint authority, a memecoin does not. |
33
34
  | **Bots and exchanges** | Wallets that made 200 transactions in under 2 days are marked `active` and are not counted as whales. |
34
35
  | **Funder clusters** | The fee payer of each holder's *first* transaction reveals who created it. Holders sharing a funder are grouped, since that is often one team splitting the supply. Exchange hot wallets are excluded as sources. |
@@ -37,7 +38,16 @@ for (const l of r.lights) // the four traffic lights
37
38
  | **Confluence** | Tokens held in common by a group of wallets, each one risk-assessed. Also the reverse: people who are top holders of several tokens. |
38
39
  | **Change tracking** | Snapshots and diffs (new / bought more / sold part / sold all) per wallet and aggregated across many wallets. |
39
40
 
40
- Anything that could not be checked is marked `na`. It never counts as "fine": a token with a missing check can be **medium** at best, and `dataOnly(r)` tells you when that is the only reason.
41
+ ### How the level is decided
42
+
43
+ - Two red lights, or one red and one yellow, give **HIGH**.
44
+ - One red, or two yellows, give **MEDIUM**.
45
+ - Otherwise the level is **LOW**, with two exceptions that raise it to **MEDIUM**:
46
+ - **A check could not run.** Lights that could not be checked are marked `na`, and they never count as "fine". `dataOnly(r)` tells you when this is the only reason.
47
+ - **The market is under 7 days old.** It has no track record yet.
48
+ - An impostor of a famous token is always **HIGH**.
49
+
50
+ A single yellow on an established token stays LOW, because those warnings are normal for its type (a staking pool's program-controlled mint, for example). This was measured on 62 real tokens: the 7-day rule moved 9 brand-new tokens from low to medium and no established one.
41
51
 
42
52
  ## Install
43
53
 
@@ -60,7 +70,7 @@ Methods (all accept `{ onProgress(step, fraction) }`):
60
70
 
61
71
  | Method | Returns |
62
72
  |---|---|
63
- | `analyzeToken(mint)` | `{ lvl, sev, type, lights, reasons, impostor, pair, ... }` |
73
+ | `analyzeToken(mint, { skipCreator })` | `{ lvl, sev, type, lights, reasons, impostor, pair, creator, ... }` |
64
74
  | `analyzeWallet(wallet, { minUsd })` | `{ sol, balances, total, hidden, results: [token results] }` |
65
75
  | `analyzeAddress(addr)` | `{ kind: "token" \| "wallet", ... }`, detected automatically |
66
76
  | `analyzeHolders(mint)` | `{ rows, clusters, hotGroups, creator, ... }`. Best with your own RPC. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solana-risk-engine",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Explainable risk analysis for Solana tokens and wallets: creator control, real holder concentration, inflated liquidity, impostor tokens, funder clusters and PnL. Zero dependencies, runs in Node and the browser.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/client.js CHANGED
@@ -139,5 +139,5 @@ export function createClient(opts) {
139
139
  return rpcAt(vibeRpc, method, params, 6); // more retries: its limit sometimes trips even when the queue is respected
140
140
  }
141
141
 
142
- return { rpc: rpc, jfetch: jfetch, softJson: softJson, ownRpc: ownRpc, heliusKey: heliusKey, checkAbort: checkAbort, rpcUrl: rpcUrl };
142
+ return { rpc: rpc, jfetch: jfetch, softJson: softJson, ownRpc: ownRpc, heliusKey: heliusKey, checkAbort: checkAbort, rpcUrl: rpcUrl, retryDelayMs: opts.retryDelayMs };
143
143
  }
package/src/constants.js CHANGED
@@ -12,6 +12,9 @@ export const COINGECKO_SOL = "https://api.coingecko.com/api/v3/coins/solana/mark
12
12
  export const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
13
13
  export const TOKEN_2022 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
14
14
  export const SYSTEM_PROGRAM = "11111111111111111111111111111111";
15
+ export const PUMP_PROGRAM = "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P";
16
+ // dexId of a token still on Pump.fun's bonding curve: DexScreener says "pumpfun", GeckoTerminal "pump-fun"
17
+ export const PUMP_CURVE_DEXES = ["pumpfun", "pump-fun"];
15
18
 
16
19
  export const USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
17
20
  export const USDT = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB";
@@ -34,7 +37,10 @@ export const FAMOUS = {
34
37
  PUMP: "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn", JLP: "27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4",
35
38
  POPCAT: "7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr", HNT: "hntyVP6YFm1Hg25TN9WGLqM12b8TQmcknKrdu1oxWux",
36
39
  RENDER: "rndrizKT3MK1iimdxRdWabcF7Zg7AR5T4nud4EkHBof", ORCA: "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE",
37
- DRIFT: "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7"
40
+ DRIFT: "DriFtupJYLTosbwoN8koMbEYSx54aFAVLddWsbksjwg7",
41
+ // Bitcoin and Ether only exist on Solana bridged: a Pump.fun token called "BTC" is never the real one
42
+ BTC: "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh", WBTC: "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh",
43
+ ETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs", WETH: "7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxs"
38
44
  };
39
45
 
40
46
  // Token-2022 extensions that add risk, with the points they add to the no-market spam score
package/src/i18n/en.js CHANGED
@@ -36,6 +36,7 @@ export default {
36
36
  CTL_DEFAULT_FROZEN: () => "New accounts can be created frozen",
37
37
  CTL_ONLY_PROGRAMS: () => "Only program-controlled authorities (see dependency)",
38
38
  CTL_NONE: () => "No active authorities: nobody can mint more or freeze",
39
+ CTL_SERIAL_CREATOR: (d) => "The creator launched " + d.n + " other tokens in the last 24 h (per RugCheck): a serial launcher, who usually drops each token as soon as buyers arrive",
39
40
  IMPOSTOR: (d) => "Impersonates " + d.symbol + ": the real " + d.symbol + " has another address (" + short(d.real) + "). It is a copy, almost certainly a scam",
40
41
 
41
42
  CON_SKIPPED: () => "Holder concentration not checked",
package/src/i18n/es.js CHANGED
@@ -39,6 +39,7 @@ export default {
39
39
  CTL_DEFAULT_FROZEN: () => "Las cuentas nuevas pueden crearse congeladas",
40
40
  CTL_ONLY_PROGRAMS: () => "Solo hay autoridades controladas por un programa (ver dependencia)",
41
41
  CTL_NONE: () => "Sin autoridades activas: nadie puede crear más ni congelar",
42
+ CTL_SERIAL_CREATOR: (d) => "El creador ha lanzado " + d.n + " tokens más en las últimas 24 h (según RugCheck): es un lanzador en serie, que suele abandonar cada token en cuanto llegan compradores",
42
43
  IMPOSTOR: (d) => "Se hace pasar por " + d.symbol + ": el " + d.symbol + " de verdad tiene otra dirección (" + short(d.real) + "). Es una copia, casi seguro una estafa",
43
44
 
44
45
  CON_SKIPPED: () => "Sin comprobar la concentración de holders",
package/src/index.js CHANGED
@@ -43,10 +43,11 @@ export { createClient };
43
43
  export { ScanError, isCancel } from "./errors.js";
44
44
  export * from "./constants.js";
45
45
  export { isOnCurve, onCurve, b58bytes } from "./curve.js";
46
- export { getTokens, getMint, getConc, getAuthKinds, isMint, accountKinds } from "./tokens.js";
47
- export { getMarkets, realLiq, impostorOf } from "./markets.js";
48
- export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances } from "./rugcheck.js";
49
- export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess } from "./risk.js";
46
+ export { getTokens, getMint, getConc, getAuthKinds, isMint, accountKinds, kindOf } from "./tokens.js";
47
+ export { getMarkets, realLiq, impostorOf, INFLATED_RATIO } from "./markets.js";
48
+ export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances, creatorLaunches } from "./rugcheck.js";
49
+ export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess, SERIAL_WARN, SERIAL_BAD } from "./risk.js";
50
+ export { recentLaunches } from "./launch.js";
50
51
  export { analyzeToken, analyzeWallet, analyzeAddress, sortResults } from "./wallet.js";
51
52
  export { holderBase, markBusy, findFunder, findFunders, clusterByFunder, holderRows, creatorInfo, analyzeHolders } from "./holders.js";
52
53
  export { fetchSwaps, solHistory, priceSeries, parseTrades, pnlFromTrades, pnlStatus, estimatePnl } from "./pnl.js";
package/src/launch.js ADDED
@@ -0,0 +1,44 @@
1
+ import { PUMP_PROGRAM } from "./constants.js";
2
+ import { isCancel } from "./errors.js";
3
+ import { pool } from "./util.js";
4
+
5
+ /**
6
+ * Pump.fun tokens a wallet launched in the last 24 h, read on chain from its latest transactions.
7
+ *
8
+ * Launch farms often pay every launch from one wallet but name a fresh "creator" each time, so
9
+ * RugCheck (which follows the creator) sees one token per creator. The wallet that pays is the first
10
+ * signer of the creation transaction: this counts what else it created.
11
+ *
12
+ * @param [opts.limit=40] latest transactions to read (each one is a request)
13
+ * @param [opts.now]
14
+ * @returns {n, mints, scanned, more} more = every transaction read was a launch-day one, there may be more
15
+ */
16
+ export async function recentLaunches(client, wallet, opts) {
17
+ opts = opts || {};
18
+ const limit = opts.limit || 40, now = opts.now || Date.now();
19
+ const sigs = (await client.rpc("getSignaturesForAddress", [wallet, { limit: limit }])) || [];
20
+ const day = sigs.filter(function (s) { return !s.err && s.blockTime && now / 1000 - s.blockTime < 86400; });
21
+ const mints = [];
22
+ await pool(day, 4, async function (s) {
23
+ const p = { encoding: "jsonParsed", maxSupportedTransactionVersion: 1, commitment: "confirmed" };
24
+ let tx = null;
25
+ try { tx = await client.rpc("getTransaction", [s.signature, p]); }
26
+ catch (e) {
27
+ if (isCancel(e)) throw e;
28
+ // an older RPC that does not know version 1 transactions yet
29
+ if (!/version|invalid param/i.test(e.message || "")) return;
30
+ p.maxSupportedTransactionVersion = 0;
31
+ try { tx = await client.rpc("getTransaction", [s.signature, p]); } catch (e2) { if (isCancel(e2)) throw e2; return; }
32
+ }
33
+ const meta = tx && tx.meta, keys = tx && tx.transaction && tx.transaction.message && tx.transaction.message.accountKeys;
34
+ if (!meta || !keys) return;
35
+ const pump = keys.some(function (k) { return (k.pubkey || k) === PUMP_PROGRAM; });
36
+ const create = (meta.logMessages || []).some(function (l) { return /^Program log: Instruction: Create(V2)?$/.test(l); });
37
+ if (!pump || !create) return;
38
+ const pre = (meta.preTokenBalances || []).map(function (b) { return b.mint; });
39
+ (meta.postTokenBalances || []).forEach(function (b) {
40
+ if (pre.indexOf(b.mint) < 0 && mints.indexOf(b.mint) < 0) mints.push(b.mint);
41
+ });
42
+ });
43
+ return { n: mints.length, mints: mints, scanned: day.length, more: sigs.length >= limit && day.length === sigs.length };
44
+ }
package/src/markets.js CHANGED
@@ -5,14 +5,19 @@ import { sleep } from "./util.js";
5
5
  // DexScreener values both sides of a pool at the pool's own price. A scammer creates a pool with
6
6
  // 4 billion of their token against 2 real USDC and it shows "liquidity $4,000M". What you can actually
7
7
  // take out when selling is the real-token side (USDC, USDT or SOL): if that is much smaller, it wins.
8
+ // Concentrated-liquidity pools (Orca Whirlpools, Raydium CLMM, Meteora DLMM) are lopsided by nature,
9
+ // often 10-20% real side, so only an extreme gap counts: real side under 5% of the reported value.
10
+ // The scams this targets sit far below that (2 USDC against "$4,000M" is 0.00000005%).
8
11
  // Mutates the pair: keeps the reported figure in liqRaw and replaces liquidity.usd with the real one.
12
+ export const INFLATED_RATIO = 0.05;
13
+
9
14
  export function realLiq(p) {
10
15
  const l = p.liquidity, qa = p.quoteToken && p.quoteToken.address;
11
16
  if (!l || !l.usd || (qa !== USDC && qa !== USDT && qa !== WSOL)) return;
12
17
  const pn = Number(p.priceNative), pu = Number(p.priceUsd), q = Number(l.quote);
13
18
  if (!(pn > 0 && pu > 0 && q >= 0)) return;
14
19
  const real = 2 * q * pu / pn; // pu/pn = USD price of the real token
15
- if (real < 0.25 * l.usd) { p.liqRaw = l.usd; p.liquidity = Object.assign({}, l, { usd: real }); }
20
+ if (real < INFLATED_RATIO * l.usd) { p.liqRaw = l.usd; p.liquidity = Object.assign({}, l, { usd: real }); }
16
21
  }
17
22
 
18
23
  // A token using the symbol (or name) of a famous one but with another address is a copy
package/src/risk.js CHANGED
@@ -1,9 +1,9 @@
1
- import { EXT_POINTS, RANKS } from "./constants.js";
1
+ import { EXT_POINTS, RANKS, PUMP_CURVE_DEXES } from "./constants.js";
2
2
  import { isCancel } from "./errors.js";
3
3
  import { short, sleep } from "./util.js";
4
4
  import { getMint, getAuthKinds, getConc } from "./tokens.js";
5
5
  import { impostorOf } from "./markets.js";
6
- import { rugcheckSummary } from "./rugcheck.js";
6
+ import { rugcheckSummary, creatorLaunches } from "./rugcheck.js";
7
7
 
8
8
  // Every finding is a "signal": {code, s, data}. `s` is ok | na | warn | bad, `data` holds the numbers
9
9
  // behind it. Turning signals into sentences is i18n's job (describe / explainLight), never the engine's.
@@ -14,6 +14,8 @@ function sig(code, s, data) {
14
14
  }
15
15
 
16
16
  const DAY = 86400000;
17
+ // Other tokens launched by the same creator in the last 24 h that make it a serial launcher
18
+ export const SERIAL_WARN = 2, SERIAL_BAD = 5;
17
19
 
18
20
  /* ---------- old point score: only for tokens without a market, to spot spam ---------- */
19
21
  export function score(info) {
@@ -71,9 +73,10 @@ function mkLight(k, items, okSignal) {
71
73
  * @param value USD value the analysed wallet holds (0 when checking a token alone)
72
74
  * @param type tokenType(info, pair)
73
75
  * @param rc RugCheck summary or null
76
+ * @param launches creatorLaunches result or null: other tokens the creator launched in the last 24 h
74
77
  * @returns [control, concentration, market, dependency]
75
78
  */
76
- export function buildLights(info, conc, pair, value, type, rc, now) {
79
+ export function buildLights(info, conc, pair, value, type, rc, now, launches) {
77
80
  now = now || Date.now();
78
81
  const ak = (info && info.authKind) || {};
79
82
  let it;
@@ -89,6 +92,9 @@ export function buildLights(info, conc, pair, value, type, rc, now) {
89
92
  if (info.ext.indexOf("nonTransferable") >= 0) it.push(sig("CTL_NON_TRANSFERABLE", "warn"));
90
93
  if (info.ext.indexOf("defaultAccountState") >= 0) it.push(sig("CTL_DEFAULT_FROZEN", "warn"));
91
94
  }
95
+ // Launch farms put out a token every few minutes and drop each one as soon as buyers arrive: in a
96
+ // 100-token Pump.fun sample one wallet launched 7 copies of "GMS" in 10 minutes
97
+ if (launches && launches.day >= SERIAL_WARN) it.push(sig("CTL_SERIAL_CREATOR", launches.day >= SERIAL_BAD ? "bad" : "warn", { n: launches.day, creator: launches.creator }));
92
98
  const control = mkLight("control", it, sig((info && (info.mintAuth || info.freezeAuth)) ? "CTL_ONLY_PROGRAMS" : "CTL_NONE", "ok"));
93
99
 
94
100
  // 2) concentration in people (pools and programs do not count)
@@ -125,7 +131,10 @@ export function buildLights(info, conc, pair, value, type, rc, now) {
125
131
  // veteran tokens (JUP, WIF...) many market makers provide it and unlocked LP is not a signal: applying
126
132
  // it there gave false positives (JUP scored worse than a new memecoin)
127
133
  const young = !pair.pairCreatedAt || (now - pair.pairCreatedAt) / DAY < 90;
128
- if (rc && !soft && young && liq > 0 && typeof rc.lpLockedPct === "number" && isFinite(rc.lpLockedPct)) {
134
+ // On Pump.fun's bonding curve there is no LP at all: the curve program holds the liquidity and nobody
135
+ // can pull it (RugCheck reports 0% locked, which flagged 49 of 75 curve tokens in a 100-token sample)
136
+ const onCurve = PUMP_CURVE_DEXES.indexOf(pair.dexId) >= 0;
137
+ if (rc && !soft && young && !onCurve && liq > 0 && typeof rc.lpLockedPct === "number" && isFinite(rc.lpLockedPct)) {
129
138
  if (rc.lpLockedPct < 20) it.push(sig("MKT_LP_UNLOCKED", "bad", { pct: rc.lpLockedPct }));
130
139
  else if (rc.lpLockedPct < 60) it.push(sig("MKT_LP_PARTLY_LOCKED", "warn", { pct: rc.lpLockedPct }));
131
140
  }
@@ -151,6 +160,11 @@ export function levelFrom(lights) {
151
160
  let lvl = (b >= 2 || (b === 1 && w >= 1)) ? "HIGH" : ((b === 1 || w >= 2) ? "MEDIUM" : "LOW");
152
161
  // What could not be checked does not count as "fine": with a check missing, at best it is medium risk
153
162
  if (lvl === "LOW" && na > 0) lvl = "MEDIUM";
163
+ // A market under 7 days old has no track record: it is never "low risk", even with nothing else wrong.
164
+ // Other single warnings stay low: on established tokens they are normal for their type (a staking pool's
165
+ // program-controlled mint, a transfer hook...); measured on 62 real tokens, this rule moved 9 brand-new
166
+ // tokens from low to medium and no established one
167
+ if (lvl === "LOW" && lights.some(function (l) { return (l.items || []).some(function (x) { return x.code === "MKT_NEW"; }); })) lvl = "MEDIUM";
154
168
  return { lvl: lvl, sev: 3 * b + w + (na && !b && !w ? 1 : 0) };
155
169
  }
156
170
 
@@ -176,6 +190,7 @@ export function reasonsFrom(lights) {
176
190
  * @param [opts.value] USD the analysed wallet holds of it (for the "heavy position" check)
177
191
  * @param [opts.marketUnknown] the DexScreener batch for this mint failed: no data, not "no market"
178
192
  * @param [opts.skipConcentration]
193
+ * @param [opts.skipCreator] do not ask RugCheck what else the creator launched (markets under 7 days only)
179
194
  * @returns null for NFTs, otherwise {mint, sym, name, lvl, sev, pts, type, lights, reasons, impostor, value, pair, img}
180
195
  */
181
196
  export async function assess(client, mint, pair, opts) {
@@ -188,6 +203,9 @@ export async function assess(client, mint, pair, opts) {
188
203
  };
189
204
  }
190
205
  const rcPromise = pair ? rugcheckSummary(client, mint) : Promise.resolve(null); // in parallel with the RPC calls below
206
+ // Serial launching only matters while a token is new; RugCheck's full report is small (~10 KB) then
207
+ const fresh = pair && !opts.skipCreator && (!pair.pairCreatedAt || Date.now() - pair.pairCreatedAt < 7 * DAY);
208
+ const launchPromise = fresh ? creatorLaunches(client, mint) : Promise.resolve(null);
191
209
  let info = null, conc = null;
192
210
  try { info = await getMint(client, mint); } catch (e) { if (isCancel(e)) throw e; }
193
211
  if (info && info.decimals === 0 && info.supply === 1) return null; // NFT
@@ -200,15 +218,15 @@ export async function assess(client, mint, pair, opts) {
200
218
  // In tokens with millions of holders (BONK) getTokenLargestAccounts sometimes times out: a second try usually works
201
219
  for (let tryC = 0; tryC < 2 && !conc; tryC++) {
202
220
  try { conc = await getConc(client, mint, info.supply); }
203
- catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(opts.retryDelayMs === undefined ? 1500 : opts.retryDelayMs); }
221
+ catch (e) { if (isCancel(e)) throw e; conc = null; if (!tryC) await sleep(client.retryDelayMs === undefined ? 1500 : client.retryDelayMs); }
204
222
  }
205
223
  }
206
- const rc = await rcPromise;
224
+ const rc = await rcPromise, launches = await launchPromise;
207
225
  const base = (pair && pair.baseToken) || {};
208
226
  let lvl, sev = 0, reasons = [], lights = null, type = null, pts = 0, imp = null;
209
227
  if (pair) {
210
228
  type = tokenType(info, pair);
211
- lights = buildLights(info, conc, pair, value, type, rc);
229
+ lights = buildLights(info, conc, pair, value, type, rc, null, launches);
212
230
  imp = impostorOf(mint, pair);
213
231
  if (imp) {
214
232
  const prev = lights[0];
@@ -224,6 +242,7 @@ export async function assess(client, mint, pair, opts) {
224
242
  mint: mint, sym: base.symbol || short(mint), name: base.name || "", pts: pts, sev: sev,
225
243
  lvl: lvl, type: type, lights: lights, reasons: reasons, impostor: imp, value: value, pair: pair,
226
244
  rugcheck: rc ? { lpLockedPct: rc.lpLockedPct, score: rc.score_normalised !== undefined ? rc.score_normalised : rc.score } : null,
245
+ creator: launches,
227
246
  img: (pair && pair.info && pair.info.imageUrl) || null
228
247
  };
229
248
  }
package/src/rugcheck.js CHANGED
@@ -1,6 +1,6 @@
1
- import { RUGCHECK, SYSTEM_PROGRAM } from "./constants.js";
1
+ import { RUGCHECK } from "./constants.js";
2
2
  import { isCancel } from "./errors.js";
3
- import { onCurve, b58bytes } from "./curve.js";
3
+ import { kindOf } from "./tokens.js";
4
4
  import { pool } from "./util.js";
5
5
 
6
6
  // Extra signal from RugCheck.xyz (public API, no key): mainly the % of liquidity locked or burned,
@@ -18,6 +18,18 @@ export function rugcheckReport(client, mint) {
18
18
  return client.softJson(RUGCHECK + mint + "/report", 10000);
19
19
  }
20
20
 
21
+ // How many other tokens the creator launched in the last 24 h. Only for new tokens: their full report
22
+ // is ~10 KB (the 700 KB ones are old tokens with many markets and holders).
23
+ // @returns {creator, day, total} or null when RugCheck does not answer or does not know the creator
24
+ export async function creatorLaunches(client, mint, now) {
25
+ const rc = await rugcheckReport(client, mint);
26
+ if (!rc || !rc.creator) return null;
27
+ now = now || Date.now();
28
+ const others = (rc.creatorTokens || []).filter(function (t) { return t && t.mint !== mint; });
29
+ const day = others.filter(function (t) { const at = Date.parse(t.createdAt); return at && now - at < 86400000; }).length;
30
+ return { creator: rc.creator, day: day, total: others.length };
31
+ }
32
+
21
33
  // The report only gives each network's size; this endpoint gives its wallets and holdings
22
34
  export async function rugcheckGraph(client, mint) {
23
35
  const g = await client.softJson(RUGCHECK + mint + "/insiders/graph", 8000);
@@ -42,7 +54,7 @@ export async function netBalances(client, mint, graph) {
42
54
  const part = addrs.slice(i, i + 100);
43
55
  try {
44
56
  const oa = await client.rpc("getMultipleAccounts", [part, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
45
- part.forEach(function (a, k) { const v = oa.value[k]; kind[a] = v ? (v.owner === SYSTEM_PROGRAM ? "wallet" : "program") : (onCurve(b58bytes(a)) ? "wallet" : "program"); });
57
+ part.forEach(function (a, k) { kind[a] = kindOf(a, oa.value[k], true); });
46
58
  } catch (e) { if (isCancel(e)) throw e; }
47
59
  }
48
60
  await pool(addrs, 4, async function (a) {
package/src/tokens.js CHANGED
@@ -41,16 +41,21 @@ export async function getMint(client, mint) {
41
41
  };
42
42
  }
43
43
 
44
- // "wallet" or "program" for each address, from who owns its account (or the curve when it has none)
45
- export async function accountKinds(client, addrs, curveFallback) {
44
+ // A key off the ed25519 curve has no private key: only a program can sign for it (a PDA), whoever
45
+ // owns its account. PDAs that hold SOL (stake pool and AMM authorities) are owned by the System
46
+ // Program like any wallet, so the owner alone would count them as people.
47
+ export function kindOf(key, account, missingIsWallet) {
48
+ if (!onCurve(b58bytes(key))) return "program";
49
+ if (account) return account.owner === SYSTEM_PROGRAM ? "wallet" : "program";
50
+ return missingIsWallet ? "wallet" : "program";
51
+ }
52
+
53
+ // "wallet" or "program" for each address
54
+ export async function accountKinds(client, addrs, missingIsWallet) {
46
55
  if (!addrs.length) return {};
47
56
  const r = await client.rpc("getMultipleAccounts", [addrs, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
48
57
  const out = {};
49
- addrs.forEach(function (k, i) {
50
- const v = r.value[i];
51
- if (v) out[k] = v.owner === SYSTEM_PROGRAM ? "wallet" : "program";
52
- else out[k] = curveFallback ? (onCurve(b58bytes(k)) ? "wallet" : "program") : "program";
53
- });
58
+ addrs.forEach(function (k, i) { out[k] = kindOf(k, r.value[i], missingIsWallet); });
54
59
  return out;
55
60
  }
56
61
 
@@ -59,7 +64,7 @@ export async function getAuthKinds(client, info) {
59
64
  const keys = [];
60
65
  [info.mintAuth, info.freezeAuth].forEach(function (a) { if (a && keys.indexOf(a) < 0) keys.push(a); });
61
66
  if (!keys.length) return {};
62
- return accountKinds(client, keys, true); // no account: an empty wallet is on the curve, a PDA is not
67
+ return accountKinds(client, keys, true); // no account but on the curve: an empty wallet
63
68
  }
64
69
 
65
70
  // Concentration of the supply. Each large account is traced to its owner: a pool or a program
package/src/wallet.js CHANGED
@@ -22,7 +22,7 @@ export async function analyzeToken(client, mint, opts) {
22
22
  progress(opts.onProgress, "markets", 0.15);
23
23
  const mkr = await getMarkets(client, [mint]);
24
24
  progress(opts.onProgress, "token", 0.45);
25
- const r = await assess(client, mint, mkr.data[mint] || null, { marketUnknown: mkr.failed.length > 0, skipConcentration: opts.skipConcentration });
25
+ const r = await assess(client, mint, mkr.data[mint] || null, { marketUnknown: mkr.failed.length > 0, skipConcentration: opts.skipConcentration, skipCreator: opts.skipCreator });
26
26
  if (!r) throw new ScanError("IS_NFT", "that address is an NFT, not a token");
27
27
  progress(opts.onProgress, "done", 1);
28
28
  return r;
@@ -62,7 +62,7 @@ export async function analyzeWallet(client, wallet, opts) {
62
62
 
63
63
  let done = 0;
64
64
  await pool(todo, opts.concurrency || 3, async function (t) {
65
- const r = await assess(client, t.mint, t.pair, { value: t.value, marketUnknown: t.unknown, skipConcentration: opts.skipConcentration });
65
+ const r = await assess(client, t.mint, t.pair, { value: t.value, marketUnknown: t.unknown, skipConcentration: opts.skipConcentration, skipCreator: opts.skipCreator });
66
66
  if (r) out.push(r);
67
67
  done++;
68
68
  progress(onProgress, "tokens", 0.2 + 0.8 * done / Math.max(1, todo.length), { done: done, total: todo.length });