solana-risk-engine 0.4.1 → 0.4.3

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,195 +1,260 @@
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
- // An exchange pays the withdrawals of thousands of customers from the same wallet: if that is the
93
- // source, the "group" joins strangers. So sources that pass the same very-active test used to separate
94
- // bots are ignored (hot = markBusy result over the sources).
95
- export function clusterByFunder(funders, hot) {
96
- const byFunder = {};
97
- Object.keys(funders).forEach(function (a) {
98
- const f = funders[a]; if (!f || (hot && hot[f])) return;
99
- (byFunder[f] = byFunder[f] || []).push(a);
100
- });
101
- const clusters = {}, LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
102
- let n = 0;
103
- Object.keys(byFunder).forEach(function (f) {
104
- const ws = byFunder[f];
105
- if (ws.length < 2) return; // a single wallet with that source is not a pattern
106
- const id = LETTERS[n] || ("G" + n); n++;
107
- ws.forEach(function (a) { clusters[a] = { id: id, n: ws.length, funder: f }; });
108
- });
109
- return clusters;
110
- }
111
-
112
- // One row per owner (several token accounts of the same owner are summed), sorted by % of supply
113
- export function holderRows(b, busy) {
114
- busy = busy || {};
115
- const agg = {};
116
- b.largest.forEach(function (a, i) {
117
- const o = b.owners[i] || a.address;
118
- if (!agg[o]) agg[o] = { owner: o, kind: b.kind[o] || "program", raw: 0, busy: busy[o] || 0 };
119
- agg[o].raw += Number(a.amount);
120
- });
121
- const price = Number((b.pair && b.pair.priceUsd) || 0);
122
- const rows = Object.keys(agg).map(function (k) {
123
- const r = agg[k];
124
- r.pct = b.info.supply > 0 ? r.raw / b.info.supply * 100 : 0;
125
- r.tokens = r.raw / Math.pow(10, b.info.decimals);
126
- r.value = r.tokens * price;
127
- return r;
128
- }).sort(function (a, c) { return c.pct - a.pct; });
129
- rows.forEach(function (r, i) { r.rank = i + 1; });
130
- return { rows: rows, price: price };
131
- }
132
-
133
- // Who created the token, what else they created (and how risky it is) and RugCheck's insider networks
134
- export async function creatorInfo(client, mint, onProgress) {
135
- try {
136
- const rcs = await Promise.all([rugcheckReport(client, mint), rugcheckGraph(client, mint)]), rc = rcs[0];
137
- if (!rc || !rc.creator) return null;
138
- const others = (rc.creatorTokens || []).filter(function (t) { return t.mint !== mint; })
139
- .sort(function (a, b) { return new Date(b.createdAt || 0) - new Date(a.createdAt || 0); });
140
- const toCheck = others.slice(0, 10), riskMap = {};
141
- if (toCheck.length) {
142
- progress(onProgress, "creatorTokens", 0.94);
143
- await pool(toCheck, 3, async function (t) { riskMap[t.mint] = await rugcheckSummary(client, t.mint); });
144
- }
145
- const creator = {
146
- addr: rc.creator, balance: rc.creatorBalance || 0, rugged: !!rc.rugged,
147
- launchpad: (rc.launchpad && rc.launchpad.name) || null, others: others, checked: toCheck.length,
148
- riskMap: riskMap, insiderNetworks: rc.insiderNetworks || [], graph: rcs[1], supply: (rc.token && rc.token.supply) || 0
149
- };
150
- if (creator.graph && creator.graph.length) {
151
- progress(onProgress, "networks", 0.97);
152
- await netBalances(client, mint, creator.graph);
153
- }
154
- return creator;
155
- } catch (e) { if (isCancel(e)) throw e; return null; } // like RugCheck in general: never blocks the main result
156
- }
157
-
158
- /**
159
- * Whale finder: top holders, with bots/exchanges separated from people, people grouped by who funded
160
- * them, and the creator's history. Works best with your own RPC (it needs getTokenLargestAccounts).
161
- *
162
- * @param [opts.creator=true] also look up the creator and insider networks on RugCheck
163
- */
164
- export async function analyzeHolders(client, mint, opts) {
165
- opts = opts || {};
166
- const onProgress = opts.onProgress;
167
- if (!ADDRESS_RE.test(mint || "")) throw new ScanError("INVALID_ADDRESS", "not a Solana address");
168
- const b = await holderBase(client, mint, onProgress);
169
- const wal = b.uniq.filter(function (o) { return b.kind[o] === "wallet"; });
170
- 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 }); });
171
- const hr = holderRows(b, busy), pair = b.pair;
172
- const people = hr.rows.filter(function (r) { return r.kind === "wallet"; }).map(function (r) { return r.owner; });
173
- let clusters = {}, hotGroups = 0;
174
- if (people.length >= 2) {
175
- 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 }); });
176
- clusters = clusterByFunder(funders);
177
- const srcs = uniq(Object.keys(clusters).map(function (a) { return clusters[a].funder; }));
178
- if (srcs.length) {
179
- 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 }); });
180
- hotGroups = srcs.filter(function (f) { return hot[f]; }).length;
181
- if (hotGroups) clusters = clusterByFunder(funders, hot);
182
- }
183
- }
184
- let creator = null;
185
- if (opts.creator !== false) {
186
- progress(onProgress, "creator", 0.9);
187
- creator = await creatorInfo(client, mint, onProgress);
188
- }
189
- progress(onProgress, "done", 1);
190
- return {
191
- mint: mint, sym: (pair && pair.baseToken && pair.baseToken.symbol) || short(mint),
192
- name: (pair && pair.baseToken && pair.baseToken.name) || "", price: hr.price, rows: hr.rows,
193
- mintAuth: !!b.info.mintAuth, freezeAuth: !!b.info.freezeAuth, clusters: clusters, hotGroups: hotGroups, creator: creator
194
- };
195
- }
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 hundreds or thousands of transactions an hour. In the 28/10 study of
93
+ // 200 tokens the sources of real groups did 0-50 an hour; sources between 163 and 347 an hour funded
94
+ // holders of several unrelated tokens (Fartcoin, USELESS...): small exchanges. Above this, infrastructure.
95
+ export const EXCHANGE_PER_HOUR = 100;
96
+ // Young tokens (under 14 days) are read for the team's split, mature ones for what stayed hidden
97
+ export const YOUNG_DAYS = 14;
98
+ // A group (holders sharing the source of their first SOL) over this % of the supply warns
99
+ export const HOLD_GROUP_YOUNG = 10, HOLD_GROUP_MATURE = 15;
100
+
101
+ // Transactions per hour over a page of signatures (newest first)
102
+ export function perHour(sigs) {
103
+ if (!sigs || sigs.length < 2) return 0;
104
+ const span = ((sigs[0].blockTime || 0) - (sigs[sigs.length - 1].blockTime || 0)) / 3600;
105
+ return span > 0 ? sigs.length / span : sigs.length * 3600;
106
+ }
107
+
108
+ // Rate of each source wallet (one request each)
109
+ export async function sourceRates(client, addrs, onStep) {
110
+ const out = {};
111
+ let done = 0;
112
+ await pool(addrs, 3, async function (a) {
113
+ try { out[a] = perHour(await client.rpc("getSignaturesForAddress", [a, { limit: 1000 }])); }
114
+ catch (e) { if (isCancel(e)) throw e; out[a] = null; }
115
+ done++; if (onStep) onStep(done, addrs.length);
116
+ });
117
+ return out;
118
+ }
119
+
120
+ // Groups by size of their share of the supply, biggest first: [{id, funder, members, pct}]
121
+ export function groupList(clusters, rows) {
122
+ const by = {};
123
+ rows.forEach(function (r) {
124
+ const c = clusters[r.owner]; if (!c) return;
125
+ const g = by[c.id] || (by[c.id] = { id: c.id, funder: c.funder, members: [], pct: 0 });
126
+ g.members.push(r.owner); g.pct += r.pct;
127
+ });
128
+ return Object.keys(by).map(function (k) { return by[k]; }).sort(function (a, b) { return b.pct - a.pct; });
129
+ }
130
+
131
+ // An exchange pays the withdrawals of thousands of customers from the same wallet: if that is the
132
+ // source, the "group" joins strangers. So sources that pass the same very-active test used to separate
133
+ // bots are ignored (hot = markBusy result over the sources).
134
+ export function clusterByFunder(funders, hot) {
135
+ const byFunder = {};
136
+ Object.keys(funders).forEach(function (a) {
137
+ const f = funders[a]; if (!f || (hot && hot[f])) return;
138
+ (byFunder[f] = byFunder[f] || []).push(a);
139
+ });
140
+ const clusters = {}, LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
141
+ let n = 0;
142
+ Object.keys(byFunder).forEach(function (f) {
143
+ const ws = byFunder[f];
144
+ if (ws.length < 2) return; // a single wallet with that source is not a pattern
145
+ const id = LETTERS[n] || ("G" + n); n++;
146
+ ws.forEach(function (a) { clusters[a] = { id: id, n: ws.length, funder: f }; });
147
+ });
148
+ return clusters;
149
+ }
150
+
151
+ // One row per owner (several token accounts of the same owner are summed), sorted by % of supply
152
+ export function holderRows(b, busy) {
153
+ busy = busy || {};
154
+ const agg = {};
155
+ b.largest.forEach(function (a, i) {
156
+ const o = b.owners[i] || a.address;
157
+ if (!agg[o]) agg[o] = { owner: o, kind: b.kind[o] || "program", raw: 0, busy: busy[o] || 0 };
158
+ agg[o].raw += Number(a.amount);
159
+ });
160
+ const price = Number((b.pair && b.pair.priceUsd) || 0);
161
+ const rows = Object.keys(agg).map(function (k) {
162
+ const r = agg[k];
163
+ r.pct = b.info.supply > 0 ? r.raw / b.info.supply * 100 : 0;
164
+ r.tokens = r.raw / Math.pow(10, b.info.decimals);
165
+ r.value = r.tokens * price;
166
+ return r;
167
+ }).sort(function (a, c) { return c.pct - a.pct; });
168
+ rows.forEach(function (r, i) { r.rank = i + 1; });
169
+ return { rows: rows, price: price };
170
+ }
171
+
172
+ // Who created the token, what else they created (and how risky it is) and RugCheck's insider networks
173
+ export async function creatorInfo(client, mint, onProgress) {
174
+ try {
175
+ const rcs = await Promise.all([rugcheckReport(client, mint), rugcheckGraph(client, mint)]), rc = rcs[0];
176
+ if (!rc || !rc.creator) return null;
177
+ const others = (rc.creatorTokens || []).filter(function (t) { return t.mint !== mint; })
178
+ .sort(function (a, b) { return new Date(b.createdAt || 0) - new Date(a.createdAt || 0); });
179
+ const toCheck = others.slice(0, 10), riskMap = {};
180
+ if (toCheck.length) {
181
+ progress(onProgress, "creatorTokens", 0.94);
182
+ await pool(toCheck, 3, async function (t) { riskMap[t.mint] = await rugcheckSummary(client, t.mint); });
183
+ }
184
+ const creator = {
185
+ addr: rc.creator, balance: rc.creatorBalance || 0, rugged: !!rc.rugged,
186
+ launchpad: (rc.launchpad && rc.launchpad.name) || null, others: others, checked: toCheck.length,
187
+ riskMap: riskMap, insiderNetworks: rc.insiderNetworks || [], graph: rcs[1], supply: (rc.token && rc.token.supply) || 0
188
+ };
189
+ if (creator.graph && creator.graph.length) {
190
+ progress(onProgress, "networks", 0.97);
191
+ await netBalances(client, mint, creator.graph);
192
+ }
193
+ return creator;
194
+ } catch (e) { if (isCancel(e)) throw e; return null; } // like RugCheck in general: never blocks the main result
195
+ }
196
+
197
+ /**
198
+ * Whale finder: top holders, with bots/exchanges separated from people, people grouped by who funded
199
+ * them, and the creator's history. Works best with your own RPC (it needs getTokenLargestAccounts).
200
+ *
201
+ * Young tokens (under 14 days: market age from DexScreener, else RugCheck's detectedAt) also trace the
202
+ * busy wallets: on day one most big holders trade a lot and the team's split hides among them. A group
203
+ * over 10% of the supply in a young token (bad) or 15% in a mature one (warn) is a HOLD_GROUP signal.
204
+ *
205
+ * @param [opts.creator=true] also look up the creator and insider networks on RugCheck
206
+ * @param [opts.createdAt] ms when its market started, if the caller knows it
207
+ * @param [opts.now]
208
+ * @returns {..., mode: "young"|"mature", ageDays, groups, signals}
209
+ */
210
+ export async function analyzeHolders(client, mint, opts) {
211
+ opts = opts || {};
212
+ const onProgress = opts.onProgress;
213
+ if (!ADDRESS_RE.test(mint || "")) throw new ScanError("INVALID_ADDRESS", "not a Solana address");
214
+ const b = await holderBase(client, mint, onProgress);
215
+ const wal = b.uniq.filter(function (o) { return b.kind[o] === "wallet"; });
216
+ 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
+ const hr = holderRows(b, busy), pair = b.pair;
218
+ const now = opts.now || Date.now();
219
+ let created = opts.createdAt || (pair && pair.pairCreatedAt) || null;
220
+ if (!created) {
221
+ const rc = await rugcheckReport(client, mint);
222
+ if (rc && rc.detectedAt) created = Date.parse(rc.detectedAt) || null;
223
+ }
224
+ const ageDays = created ? Math.max(0, (now - created) / 86400000) : null;
225
+ const mode = ageDays !== null && ageDays < YOUNG_DAYS ? "young" : "mature";
226
+ // who is traced: people always; in young tokens also the busy wallets
227
+ const traced = hr.rows.filter(function (r) { return r.kind === "wallet" || (mode === "young" && r.kind === "active"); })
228
+ .map(function (r) { return r.owner; });
229
+ let clusters = {}, hotGroups = 0;
230
+ if (traced.length >= 2) {
231
+ 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 }); });
232
+ clusters = clusterByFunder(funders);
233
+ const srcs = uniq(Object.keys(clusters).map(function (a) { return clusters[a].funder; }));
234
+ if (srcs.length) {
235
+ // a source is an exchange when it moves more than EXCHANGE_PER_HOUR transactions an hour (the bot
236
+ // test, 200 in 2 days, also caught distributors doing ~50 an hour and hid their groups)
237
+ 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 }); });
238
+ const hot = {};
239
+ srcs.forEach(function (f) { if (rates[f] > EXCHANGE_PER_HOUR) hot[f] = true; });
240
+ hotGroups = srcs.filter(function (f) { return hot[f]; }).length;
241
+ if (hotGroups) clusters = clusterByFunder(funders, hot);
242
+ }
243
+ }
244
+ const groups = groupList(clusters, hr.rows), signals = [];
245
+ const limit = mode === "young" ? HOLD_GROUP_YOUNG : HOLD_GROUP_MATURE;
246
+ if (groups[0] && groups[0].pct > limit)
247
+ 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 } });
248
+ let creator = null;
249
+ if (opts.creator !== false) {
250
+ progress(onProgress, "creator", 0.9);
251
+ creator = await creatorInfo(client, mint, onProgress);
252
+ }
253
+ progress(onProgress, "done", 1);
254
+ return {
255
+ mint: mint, sym: (pair && pair.baseToken && pair.baseToken.symbol) || short(mint),
256
+ name: (pair && pair.baseToken && pair.baseToken.name) || "", price: hr.price, rows: hr.rows,
257
+ mintAuth: !!b.info.mintAuth, freezeAuth: !!b.info.freezeAuth, clusters: clusters, hotGroups: hotGroups, creator: creator,
258
+ mode: mode, ageDays: ageDays, groups: groups, signals: signals
259
+ };
260
+ }