solana-risk-engine 0.4.2 → 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/src/holders.js CHANGED
@@ -1,259 +1,274 @@
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
- // version 1 is needed for recent transactions; an older RPC that does not know it yet gets 0
68
- let tx;
69
- try { tx = await client.rpc("getTransaction", [earliest, { maxSupportedTransactionVersion: 1, encoding: "jsonParsed" }]); }
70
- catch (e) {
71
- if (isCancel(e) || !/version|invalid param/i.test(e.message || "")) throw e;
72
- tx = await client.rpc("getTransaction", [earliest, { maxSupportedTransactionVersion: 0, encoding: "jsonParsed" }]);
73
- }
74
- const keys = tx && tx.transaction && tx.transaction.message && tx.transaction.message.accountKeys;
75
- if (!keys || !keys.length) return null;
76
- const payer = keys[0] && (keys[0].pubkey !== undefined ? keys[0].pubkey : keys[0]);
77
- if (!payer || payer === addr) return null; // it paid for itself: there is no outside source to look at
78
- return payer;
79
- } catch (e) { if (isCancel(e)) throw e; return null; }
80
- }
81
-
82
- export async function findFunders(client, wallets, onStep) {
83
- const out = {};
84
- let done = 0;
85
- await pool(wallets, 3, async function (a) {
86
- out[a] = await findFunder(client, a);
87
- done++; if (onStep) onStep(done, wallets.length);
88
- });
89
- return out;
90
- }
91
-
92
- // Exchanges and launch platforms move thousands of transactions an hour; the busiest farms and
93
- // distributors seen in the 28/10 study did 1-150. Above this, a wallet is infrastructure, not a person.
94
- export const EXCHANGE_PER_HOUR = 350;
95
- // Young tokens (under 14 days) are read for the team's split, mature ones for what stayed hidden
96
- export const YOUNG_DAYS = 14;
97
- // A group (holders sharing the source of their first SOL) over this % of the supply warns
98
- export const HOLD_GROUP_YOUNG = 10, HOLD_GROUP_MATURE = 15;
99
-
100
- // Transactions per hour over a page of signatures (newest first)
101
- export function perHour(sigs) {
102
- if (!sigs || sigs.length < 2) return 0;
103
- const span = ((sigs[0].blockTime || 0) - (sigs[sigs.length - 1].blockTime || 0)) / 3600;
104
- return span > 0 ? sigs.length / span : sigs.length * 3600;
105
- }
106
-
107
- // Rate of each source wallet (one request each)
108
- export async function sourceRates(client, addrs, onStep) {
109
- const out = {};
110
- let done = 0;
111
- await pool(addrs, 3, async function (a) {
112
- try { out[a] = perHour(await client.rpc("getSignaturesForAddress", [a, { limit: 1000 }])); }
113
- catch (e) { if (isCancel(e)) throw e; out[a] = null; }
114
- done++; if (onStep) onStep(done, addrs.length);
115
- });
116
- return out;
117
- }
118
-
119
- // Groups by size of their share of the supply, biggest first: [{id, funder, members, pct}]
120
- export function groupList(clusters, rows) {
121
- const by = {};
122
- rows.forEach(function (r) {
123
- const c = clusters[r.owner]; if (!c) return;
124
- const g = by[c.id] || (by[c.id] = { id: c.id, funder: c.funder, members: [], pct: 0 });
125
- g.members.push(r.owner); g.pct += r.pct;
126
- });
127
- return Object.keys(by).map(function (k) { return by[k]; }).sort(function (a, b) { return b.pct - a.pct; });
128
- }
129
-
130
- // An exchange pays the withdrawals of thousands of customers from the same wallet: if that is the
131
- // source, the "group" joins strangers. So sources that pass the same very-active test used to separate
132
- // bots are ignored (hot = markBusy result over the sources).
133
- export function clusterByFunder(funders, hot) {
134
- const byFunder = {};
135
- Object.keys(funders).forEach(function (a) {
136
- const f = funders[a]; if (!f || (hot && hot[f])) return;
137
- (byFunder[f] = byFunder[f] || []).push(a);
138
- });
139
- const clusters = {}, LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
140
- let n = 0;
141
- Object.keys(byFunder).forEach(function (f) {
142
- const ws = byFunder[f];
143
- if (ws.length < 2) return; // a single wallet with that source is not a pattern
144
- const id = LETTERS[n] || ("G" + n); n++;
145
- ws.forEach(function (a) { clusters[a] = { id: id, n: ws.length, funder: f }; });
146
- });
147
- return clusters;
148
- }
149
-
150
- // One row per owner (several token accounts of the same owner are summed), sorted by % of supply
151
- export function holderRows(b, busy) {
152
- busy = busy || {};
153
- const agg = {};
154
- b.largest.forEach(function (a, i) {
155
- const o = b.owners[i] || a.address;
156
- if (!agg[o]) agg[o] = { owner: o, kind: b.kind[o] || "program", raw: 0, busy: busy[o] || 0 };
157
- agg[o].raw += Number(a.amount);
158
- });
159
- const price = Number((b.pair && b.pair.priceUsd) || 0);
160
- const rows = Object.keys(agg).map(function (k) {
161
- const r = agg[k];
162
- r.pct = b.info.supply > 0 ? r.raw / b.info.supply * 100 : 0;
163
- r.tokens = r.raw / Math.pow(10, b.info.decimals);
164
- r.value = r.tokens * price;
165
- return r;
166
- }).sort(function (a, c) { return c.pct - a.pct; });
167
- rows.forEach(function (r, i) { r.rank = i + 1; });
168
- return { rows: rows, price: price };
169
- }
170
-
171
- // Who created the token, what else they created (and how risky it is) and RugCheck's insider networks
172
- export async function creatorInfo(client, mint, onProgress) {
173
- try {
174
- const rcs = await Promise.all([rugcheckReport(client, mint), rugcheckGraph(client, mint)]), rc = rcs[0];
175
- if (!rc || !rc.creator) return null;
176
- const others = (rc.creatorTokens || []).filter(function (t) { return t.mint !== mint; })
177
- .sort(function (a, b) { return new Date(b.createdAt || 0) - new Date(a.createdAt || 0); });
178
- const toCheck = others.slice(0, 10), riskMap = {};
179
- if (toCheck.length) {
180
- progress(onProgress, "creatorTokens", 0.94);
181
- await pool(toCheck, 3, async function (t) { riskMap[t.mint] = await rugcheckSummary(client, t.mint); });
182
- }
183
- const creator = {
184
- addr: rc.creator, balance: rc.creatorBalance || 0, rugged: !!rc.rugged,
185
- launchpad: (rc.launchpad && rc.launchpad.name) || null, others: others, checked: toCheck.length,
186
- riskMap: riskMap, insiderNetworks: rc.insiderNetworks || [], graph: rcs[1], supply: (rc.token && rc.token.supply) || 0
187
- };
188
- if (creator.graph && creator.graph.length) {
189
- progress(onProgress, "networks", 0.97);
190
- await netBalances(client, mint, creator.graph);
191
- }
192
- return creator;
193
- } catch (e) { if (isCancel(e)) throw e; return null; } // like RugCheck in general: never blocks the main result
194
- }
195
-
196
- /**
197
- * Whale finder: top holders, with bots/exchanges separated from people, people grouped by who funded
198
- * them, and the creator's history. Works best with your own RPC (it needs getTokenLargestAccounts).
199
- *
200
- * Young tokens (under 14 days: market age from DexScreener, else RugCheck's detectedAt) also trace the
201
- * busy wallets: on day one most big holders trade a lot and the team's split hides among them. A group
202
- * over 10% of the supply in a young token (bad) or 15% in a mature one (warn) is a HOLD_GROUP signal.
203
- *
204
- * @param [opts.creator=true] also look up the creator and insider networks on RugCheck
205
- * @param [opts.createdAt] ms when its market started, if the caller knows it
206
- * @param [opts.now]
207
- * @returns {..., mode: "young"|"mature", ageDays, groups, signals}
208
- */
209
- export async function analyzeHolders(client, mint, opts) {
210
- opts = opts || {};
211
- const onProgress = opts.onProgress;
212
- if (!ADDRESS_RE.test(mint || "")) throw new ScanError("INVALID_ADDRESS", "not a Solana address");
213
- const b = await holderBase(client, mint, onProgress);
214
- const wal = b.uniq.filter(function (o) { return b.kind[o] === "wallet"; });
215
- 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 }); });
216
- const hr = holderRows(b, busy), pair = b.pair;
217
- const now = opts.now || Date.now();
218
- let created = opts.createdAt || (pair && pair.pairCreatedAt) || null;
219
- if (!created) {
220
- const rc = await rugcheckReport(client, mint);
221
- if (rc && rc.detectedAt) created = Date.parse(rc.detectedAt) || null;
222
- }
223
- const ageDays = created ? Math.max(0, (now - created) / 86400000) : null;
224
- const mode = ageDays !== null && ageDays < YOUNG_DAYS ? "young" : "mature";
225
- // who is traced: people always; in young tokens also the busy wallets
226
- const traced = hr.rows.filter(function (r) { return r.kind === "wallet" || (mode === "young" && r.kind === "active"); })
227
- .map(function (r) { return r.owner; });
228
- let clusters = {}, hotGroups = 0;
229
- if (traced.length >= 2) {
230
- const funders = await findFunders(client, traced, function (c, n) { progress(onProgress, "funders", 0.8 + 0.08 * c / Math.max(1, n), { done: c, total: n }); });
231
- clusters = clusterByFunder(funders);
232
- const srcs = uniq(Object.keys(clusters).map(function (a) { return clusters[a].funder; }));
233
- if (srcs.length) {
234
- // a source is an exchange when it moves more than EXCHANGE_PER_HOUR transactions an hour (the bot
235
- // test, 200 in 2 days, also caught distributors doing ~50 an hour and hid their groups)
236
- const rates = await sourceRates(client, srcs, function (c, n) { progress(onProgress, "exchangeSources", 0.88 + 0.02 * c / Math.max(1, n), { done: c, total: n }); });
237
- const hot = {};
238
- srcs.forEach(function (f) { if (rates[f] > EXCHANGE_PER_HOUR) hot[f] = true; });
239
- hotGroups = srcs.filter(function (f) { return hot[f]; }).length;
240
- if (hotGroups) clusters = clusterByFunder(funders, hot);
241
- }
242
- }
243
- const groups = groupList(clusters, hr.rows), signals = [];
244
- const limit = mode === "young" ? HOLD_GROUP_YOUNG : HOLD_GROUP_MATURE;
245
- if (groups[0] && groups[0].pct > limit)
246
- signals.push({ code: "HOLD_GROUP", s: mode === "young" ? "bad" : "warn", data: { n: groups[0].members.length, pct: groups[0].pct, id: groups[0].id, funder: groups[0].funder } });
247
- let creator = null;
248
- if (opts.creator !== false) {
249
- progress(onProgress, "creator", 0.9);
250
- creator = await creatorInfo(client, mint, onProgress);
251
- }
252
- progress(onProgress, "done", 1);
253
- return {
254
- mint: mint, sym: (pair && pair.baseToken && pair.baseToken.symbol) || short(mint),
255
- name: (pair && pair.baseToken && pair.baseToken.name) || "", price: hr.price, rows: hr.rows,
256
- mintAuth: !!b.info.mintAuth, freezeAuth: !!b.info.freezeAuth, clusters: clusters, hotGroups: hotGroups, creator: creator,
257
- mode: mode, ageDays: ageDays, groups: groups, signals: signals
258
- };
259
- }
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
+ import { loadWalletBase, knownType } from "./base.js";
8
+
9
+ // Largest accounts of a token, each traced to its owner, and whether each owner is a wallet or a program
10
+ export async function holderBase(client, mint, onProgress) {
11
+ progress(onProgress, "token", 0.08);
12
+ const res = await Promise.all([
13
+ getMint(client, mint),
14
+ client.rpc("getTokenLargestAccounts", [mint]),
15
+ getMarkets(client, [mint]).catch(function (e) { if (isCancel(e)) throw e; return { data: {}, failed: [mint] }; })
16
+ ]);
17
+ const info = res[0], largest = (res[1] && res[1].value) || [], pair = res[2].data[mint] || null;
18
+ if (!info) throw new ScanError("NOT_A_TOKEN", "that address is not a token");
19
+ if (!largest.length) throw new ScanError("NO_HOLDERS", "the RPC returned no holder data");
20
+ progress(onProgress, "owners", 0.4);
21
+ const ta = await client.rpc("getMultipleAccounts", [largest.map(function (a) { return a.address; }), { encoding: "jsonParsed" }]);
22
+ const owners = ta.value.map(function (v) {
23
+ return v && v.data && v.data.parsed && v.data.parsed.info && v.data.parsed.info.owner || null;
24
+ });
25
+ const owned = uniq(owners.filter(Boolean));
26
+ progress(onProgress, "kinds", 0.7);
27
+ const kind = await accountKinds(client, owned, false);
28
+ return { mint: mint, info: info, pair: pair, largest: largest, owners: owners, uniq: owned, kind: kind };
29
+ }
30
+
31
+ // People's wallets rarely move hundreds of transactions in a couple of days; exchanges, bots and
32
+ // market makers do. Checked with the last 200 signatures. Marks them kind[o] = "active" (mutates kind)
33
+ // and returns {address: hours it took to do those 200}.
34
+ export async function markBusy(client, kind, wallets, onStep) {
35
+ const busy = {};
36
+ let chk = 0;
37
+ await pool(wallets, 3, async function (o) {
38
+ try {
39
+ const sg = await client.rpc("getSignaturesForAddress", [o, { limit: 200 }]);
40
+ if (sg && sg.length >= 200) {
41
+ const t1 = sg[0].blockTime, t0 = sg[sg.length - 1].blockTime;
42
+ if (t1 && t0 && (t1 - t0) < 2 * 86400) { kind[o] = "active"; busy[o] = Math.max(1, Math.round((t1 - t0) / 3600)); }
43
+ }
44
+ } catch (e) { if (isCancel(e)) throw e; }
45
+ chk++; if (onStep) onStep(chk, wallets.length);
46
+ });
47
+ return busy;
48
+ }
49
+
50
+ /* ---------- wallets funded from the same source (maybe one owner split into several accounts) ---------- */
51
+ // By Solana protocol, account 0 of any transaction is always the fee payer. Looking at who paid the
52
+ // very first transaction of each wallet shows who "created" it with the first SOL transfer. If several
53
+ // wallets that look like different holders share that source, they are most likely the same person or
54
+ // team splitting the supply to look decentralised. If the wallet is too active to find its first
55
+ // transaction reliably (more than 3000 signatures), it is left without data rather than guessing wrong.
56
+ export async function findFunder(client, addr) {
57
+ try {
58
+ let sigs = null, before = null, reachedEnd = false;
59
+ for (let i = 0; i < 3; i++) {
60
+ const page = await client.rpc("getSignaturesForAddress", [addr, before ? { limit: 1000, before: before } : { limit: 1000 }]);
61
+ if (!page || !page.length) { reachedEnd = true; break; }
62
+ sigs = page;
63
+ if (page.length < 1000) { reachedEnd = true; break; }
64
+ before = page[page.length - 1].signature;
65
+ }
66
+ if (!reachedEnd || !sigs || !sigs.length) return null;
67
+ const earliest = sigs[sigs.length - 1].signature;
68
+ // version 1 is needed for recent transactions; an older RPC that does not know it yet gets 0
69
+ let tx;
70
+ try { tx = await client.rpc("getTransaction", [earliest, { maxSupportedTransactionVersion: 1, encoding: "jsonParsed" }]); }
71
+ catch (e) {
72
+ if (isCancel(e) || !/version|invalid param/i.test(e.message || "")) throw e;
73
+ tx = await client.rpc("getTransaction", [earliest, { maxSupportedTransactionVersion: 0, encoding: "jsonParsed" }]);
74
+ }
75
+ const keys = tx && tx.transaction && tx.transaction.message && tx.transaction.message.accountKeys;
76
+ if (!keys || !keys.length) return null;
77
+ const payer = keys[0] && (keys[0].pubkey !== undefined ? keys[0].pubkey : keys[0]);
78
+ if (!payer || payer === addr) return null; // it paid for itself: there is no outside source to look at
79
+ return payer;
80
+ } catch (e) { if (isCancel(e)) throw e; return null; }
81
+ }
82
+
83
+ export async function findFunders(client, wallets, onStep) {
84
+ const out = {};
85
+ let done = 0;
86
+ await pool(wallets, 3, async function (a) {
87
+ out[a] = await findFunder(client, a);
88
+ done++; if (onStep) onStep(done, wallets.length);
89
+ });
90
+ return out;
91
+ }
92
+
93
+ // Exchanges and launch platforms move hundreds or thousands of transactions an hour. In the 28/10 study of
94
+ // 200 tokens the sources of real groups did 0-50 an hour; sources between 163 and 347 an hour funded
95
+ // holders of several unrelated tokens (Fartcoin, USELESS...): small exchanges. Above this, infrastructure.
96
+ export const EXCHANGE_PER_HOUR = 100;
97
+ // Young tokens (under 14 days) are read for the team's split, mature ones for what stayed hidden
98
+ export const YOUNG_DAYS = 14;
99
+ // A group (holders sharing the source of their first SOL) over this % of the supply warns
100
+ export const HOLD_GROUP_YOUNG = 10, HOLD_GROUP_MATURE = 15;
101
+
102
+ // Transactions per hour over a page of signatures (newest first)
103
+ export function perHour(sigs) {
104
+ if (!sigs || sigs.length < 2) return 0;
105
+ const span = ((sigs[0].blockTime || 0) - (sigs[sigs.length - 1].blockTime || 0)) / 3600;
106
+ return span > 0 ? sigs.length / span : sigs.length * 3600;
107
+ }
108
+
109
+ // Rate of each source wallet (one request each)
110
+ export async function sourceRates(client, addrs, onStep) {
111
+ const out = {};
112
+ let done = 0;
113
+ await pool(addrs, 3, async function (a) {
114
+ try { out[a] = perHour(await client.rpc("getSignaturesForAddress", [a, { limit: 1000 }])); }
115
+ catch (e) { if (isCancel(e)) throw e; out[a] = null; }
116
+ done++; if (onStep) onStep(done, addrs.length);
117
+ });
118
+ return out;
119
+ }
120
+
121
+ // Groups by size of their share of the supply, biggest first: [{id, funder, members, pct}]
122
+ export function groupList(clusters, rows) {
123
+ const by = {};
124
+ rows.forEach(function (r) {
125
+ const c = clusters[r.owner]; if (!c) return;
126
+ const g = by[c.id] || (by[c.id] = { id: c.id, funder: c.funder, members: [], pct: 0 });
127
+ g.members.push(r.owner); g.pct += r.pct;
128
+ });
129
+ return Object.keys(by).map(function (k) { return by[k]; }).sort(function (a, b) { return b.pct - a.pct; });
130
+ }
131
+
132
+ // An exchange pays the withdrawals of thousands of customers from the same wallet: if that is the
133
+ // source, the "group" joins strangers. So sources that pass the same very-active test used to separate
134
+ // bots are ignored (hot = markBusy result over the sources).
135
+ export function clusterByFunder(funders, hot) {
136
+ const byFunder = {};
137
+ Object.keys(funders).forEach(function (a) {
138
+ const f = funders[a]; if (!f || (hot && hot[f])) return;
139
+ (byFunder[f] = byFunder[f] || []).push(a);
140
+ });
141
+ const clusters = {}, LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
142
+ let n = 0;
143
+ Object.keys(byFunder).forEach(function (f) {
144
+ const ws = byFunder[f];
145
+ if (ws.length < 2) return; // a single wallet with that source is not a pattern
146
+ const id = LETTERS[n] || ("G" + n); n++;
147
+ ws.forEach(function (a) { clusters[a] = { id: id, n: ws.length, funder: f }; });
148
+ });
149
+ return clusters;
150
+ }
151
+
152
+ // One row per owner (several token accounts of the same owner are summed), sorted by % of supply
153
+ export function holderRows(b, busy) {
154
+ busy = busy || {};
155
+ const agg = {};
156
+ b.largest.forEach(function (a, i) {
157
+ const o = b.owners[i] || a.address;
158
+ if (!agg[o]) agg[o] = { owner: o, kind: b.kind[o] || "program", raw: 0, busy: busy[o] || 0 };
159
+ agg[o].raw += Number(a.amount);
160
+ });
161
+ const price = Number((b.pair && b.pair.priceUsd) || 0);
162
+ const rows = Object.keys(agg).map(function (k) {
163
+ const r = agg[k];
164
+ r.pct = b.info.supply > 0 ? r.raw / b.info.supply * 100 : 0;
165
+ r.tokens = r.raw / Math.pow(10, b.info.decimals);
166
+ r.value = r.tokens * price;
167
+ return r;
168
+ }).sort(function (a, c) { return c.pct - a.pct; });
169
+ rows.forEach(function (r, i) { r.rank = i + 1; });
170
+ return { rows: rows, price: price };
171
+ }
172
+
173
+ // Who created the token, what else they created (and how risky it is) and RugCheck's insider networks
174
+ export async function creatorInfo(client, mint, onProgress) {
175
+ try {
176
+ const rcs = await Promise.all([rugcheckReport(client, mint), rugcheckGraph(client, mint)]), rc = rcs[0];
177
+ if (!rc || !rc.creator) return null;
178
+ const others = (rc.creatorTokens || []).filter(function (t) { return t.mint !== mint; })
179
+ .sort(function (a, b) { return new Date(b.createdAt || 0) - new Date(a.createdAt || 0); });
180
+ const toCheck = others.slice(0, 10), riskMap = {};
181
+ if (toCheck.length) {
182
+ progress(onProgress, "creatorTokens", 0.94);
183
+ await pool(toCheck, 3, async function (t) { riskMap[t.mint] = await rugcheckSummary(client, t.mint); });
184
+ }
185
+ const creator = {
186
+ addr: rc.creator, balance: rc.creatorBalance || 0, rugged: !!rc.rugged,
187
+ launchpad: (rc.launchpad && rc.launchpad.name) || null, others: others, checked: toCheck.length,
188
+ riskMap: riskMap, insiderNetworks: rc.insiderNetworks || [], graph: rcs[1], supply: (rc.token && rc.token.supply) || 0
189
+ };
190
+ if (creator.graph && creator.graph.length) {
191
+ progress(onProgress, "networks", 0.97);
192
+ await netBalances(client, mint, creator.graph);
193
+ }
194
+ return creator;
195
+ } catch (e) { if (isCancel(e)) throw e; return null; } // like RugCheck in general: never blocks the main result
196
+ }
197
+
198
+ /**
199
+ * Whale finder: top holders, with bots/exchanges separated from people, people grouped by who funded
200
+ * them, and the creator's history. Works best with your own RPC (it needs getTokenLargestAccounts).
201
+ *
202
+ * Young tokens (under 14 days: market age from DexScreener, else RugCheck's detectedAt) also trace the
203
+ * busy wallets: on day one most big holders trade a lot and the team's split hides among them. A group
204
+ * over 10% of the supply in a young token (bad) or 15% in a mature one (warn) is a HOLD_GROUP signal.
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"
210
+ * @param [opts.createdAt] ms when its market started, if the caller knows it
211
+ * @param [opts.now]
212
+ * @returns {..., mode: "young"|"mature", ageDays, groups, signals}
213
+ */
214
+ export async function analyzeHolders(client, mint, opts) {
215
+ opts = opts || {};
216
+ const onProgress = opts.onProgress;
217
+ if (!ADDRESS_RE.test(mint || "")) throw new ScanError("INVALID_ADDRESS", "not a Solana address");
218
+ const b = await holderBase(client, mint, onProgress);
219
+ const wal = b.uniq.filter(function (o) { return b.kind[o] === "wallet"; });
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 }); });
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
+ });
229
+ const now = opts.now || Date.now();
230
+ let created = opts.createdAt || (pair && pair.pairCreatedAt) || null;
231
+ if (!created) {
232
+ const rc = await rugcheckReport(client, mint);
233
+ if (rc && rc.detectedAt) created = Date.parse(rc.detectedAt) || null;
234
+ }
235
+ const ageDays = created ? Math.max(0, (now - created) / 86400000) : null;
236
+ const mode = ageDays !== null && ageDays < YOUNG_DAYS ? "young" : "mature";
237
+ // who is traced: people always; in young tokens also the busy wallets
238
+ const traced = hr.rows.filter(function (r) { return r.kind === "wallet" || (mode === "young" && r.kind === "active"); })
239
+ .map(function (r) { return r.owner; });
240
+ let clusters = {}, hotGroups = 0;
241
+ if (traced.length >= 2) {
242
+ const funders = await findFunders(client, traced, function (c, n) { progress(onProgress, "funders", 0.8 + 0.08 * c / Math.max(1, n), { done: c, total: n }); });
243
+ clusters = clusterByFunder(funders);
244
+ const srcs = uniq(Object.keys(clusters).map(function (a) { return clusters[a].funder; }));
245
+ if (srcs.length) {
246
+ // a source is an exchange when it moves more than EXCHANGE_PER_HOUR transactions an hour (the bot
247
+ // test, 200 in 2 days, also caught distributors doing ~50 an hour and hid their groups)
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; });
251
+ hotGroups = srcs.filter(function (f) { return hot[f]; }).length;
252
+ if (hotGroups) clusters = clusterByFunder(funders, hot);
253
+ }
254
+ }
255
+ const groups = groupList(clusters, hr.rows), signals = [];
256
+ const limit = mode === "young" ? HOLD_GROUP_YOUNG : HOLD_GROUP_MATURE;
257
+ if (groups[0] && groups[0].pct > limit)
258
+ signals.push({ code: "HOLD_GROUP", s: mode === "young" ? "bad" : "warn", data: { n: groups[0].members.length, pct: groups[0].pct, id: groups[0].id, funder: groups[0].funder } });
259
+ let creator = null;
260
+ if (opts.creator !== false) {
261
+ progress(onProgress, "creator", 0.9);
262
+ creator = await creatorInfo(client, mint, onProgress);
263
+ }
264
+ progress(onProgress, "done", 1);
265
+ return {
266
+ mint: mint, sym: (pair && pair.baseToken && pair.baseToken.symbol) || short(mint),
267
+ name: (pair && pair.baseToken && pair.baseToken.name) || "", price: hr.price, rows: hr.rows,
268
+ mintAuth: !!b.info.mintAuth, freezeAuth: !!b.info.freezeAuth, clusters: clusters, hotGroups: hotGroups, creator: creator,
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; }, {}) : {}
273
+ };
274
+ }