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/README.md +144 -144
- package/package.json +47 -47
- package/src/client.js +157 -143
- package/src/constants.js +78 -62
- package/src/holders.js +260 -195
- package/src/i18n/en.js +112 -108
- package/src/i18n/es.js +115 -111
- package/src/index.js +57 -56
- package/src/markets.js +136 -87
- package/src/risk.js +268 -255
- package/src/tokens.js +103 -103
- package/src/wallet.js +94 -94
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
export
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
r.
|
|
127
|
-
|
|
128
|
-
}).sort(function (a,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
export
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (!
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|