solana-risk-engine 0.4.1 → 0.4.2

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
@@ -73,7 +73,7 @@ Methods (all accept `{ onProgress(step, fraction) }`):
73
73
  | `analyzeToken(mint, { skipCreator })` | `{ lvl, sev, type, lights, reasons, impostor, pair, creator, ... }` |
74
74
  | `analyzeWallet(wallet, { minUsd })` | `{ sol, balances, total, hidden, results: [token results] }` |
75
75
  | `analyzeAddress(addr)` | `{ kind: "token" \| "wallet", ... }`, detected automatically |
76
- | `analyzeHolders(mint)` | `{ rows, clusters, hotGroups, creator, ... }`. Best with your own RPC. |
76
+ | `analyzeHolders(mint)` | `{ rows, clusters, groups, hotGroups, creator, mode, ageDays, signals, ... }`. Young tokens (under 14 days) also trace busy wallets; a source over 350 tx/h counts as an exchange; a group over 10% (young) or 15% (mature) of the supply gives a `HOLD_GROUP` signal. Best with your own RPC. |
77
77
  | `estimatePnl(wallets)` | `{ pnl: { [wallet]: { realized, unreal, total, ret, st, open } } }`. Needs Helius. |
78
78
  | `takeSnapshots(wallets)` | Today's holdings. Store them yourself and pass them to `diffSnapshots(prev, next)`. |
79
79
  | `confluence(wallets, { pct, withPnl, pnlFilter })` | Tokens held in common, with risk. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solana-risk-engine",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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/holders.js CHANGED
@@ -89,6 +89,44 @@ export async function findFunders(client, wallets, onStep) {
89
89
  return out;
90
90
  }
91
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
+
92
130
  // An exchange pays the withdrawals of thousands of customers from the same wallet: if that is the
93
131
  // source, the "group" joins strangers. So sources that pass the same very-active test used to separate
94
132
  // bots are ignored (hot = markBusy result over the sources).
@@ -159,7 +197,14 @@ export async function creatorInfo(client, mint, onProgress) {
159
197
  * Whale finder: top holders, with bots/exchanges separated from people, people grouped by who funded
160
198
  * them, and the creator's history. Works best with your own RPC (it needs getTokenLargestAccounts).
161
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
+ *
162
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}
163
208
  */
164
209
  export async function analyzeHolders(client, mint, opts) {
165
210
  opts = opts || {};
@@ -169,18 +214,36 @@ export async function analyzeHolders(client, mint, opts) {
169
214
  const wal = b.uniq.filter(function (o) { return b.kind[o] === "wallet"; });
170
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 }); });
171
216
  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; });
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; });
173
228
  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 }); });
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 }); });
176
231
  clusters = clusterByFunder(funders);
177
232
  const srcs = uniq(Object.keys(clusters).map(function (a) { return clusters[a].funder; }));
178
233
  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 }); });
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; });
180
239
  hotGroups = srcs.filter(function (f) { return hot[f]; }).length;
181
240
  if (hotGroups) clusters = clusterByFunder(funders, hot);
182
241
  }
183
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 } });
184
247
  let creator = null;
185
248
  if (opts.creator !== false) {
186
249
  progress(onProgress, "creator", 0.9);
@@ -190,6 +253,7 @@ export async function analyzeHolders(client, mint, opts) {
190
253
  return {
191
254
  mint: mint, sym: (pair && pair.baseToken && pair.baseToken.symbol) || short(mint),
192
255
  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
256
+ mintAuth: !!b.info.mintAuth, freezeAuth: !!b.info.freezeAuth, clusters: clusters, hotGroups: hotGroups, creator: creator,
257
+ mode: mode, ageDays: ageDays, groups: groups, signals: signals
194
258
  };
195
259
  }
package/src/i18n/en.js CHANGED
@@ -37,6 +37,7 @@ export default {
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
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",
40
+ HOLD_GROUP: (d) => d.n + " holders got their first SOL from the same wallet and together hold " + pct(d.pct) + " of the supply: most likely one person or team",
40
41
  CTL_CREATOR_HISTORY: (d) => "Of the " + d.old + " tokens the creator launched before (over 24 h ago), " + d.dead + " stayed under $10,000 market cap (per RugCheck): their launches rarely take off",
41
42
  IMPOSTOR: (d) => "Impersonates " + d.symbol + ": the real " + d.symbol + " has another address (" + short(d.real) + "). It is a copy, almost certainly a scam",
42
43
 
package/src/i18n/es.js CHANGED
@@ -40,6 +40,7 @@ export default {
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
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",
43
+ HOLD_GROUP: (d) => d.n + " holders recibieron su primer SOL de la misma wallet y juntos tienen el " + pct(d.pct) + " del suministro: probablemente son la misma persona o equipo",
43
44
  CTL_CREATOR_HISTORY: (d) => "De los " + d.old + " tokens que el creador lanzó antes (hace más de 24 h), " + d.dead + " se quedaron por debajo de 10.000 $ de capitalización (según RugCheck): sus lanzamientos no suelen despegar",
44
45
  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",
45
46
 
package/src/index.js CHANGED
@@ -49,7 +49,8 @@ export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances, creatorLau
49
49
  export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess, SERIAL_WARN, SERIAL_BAD, HISTORY_MIN, HISTORY_WARN, HISTORY_BAD_MIN, HISTORY_BAD } from "./risk.js";
50
50
  export { recentLaunches } from "./launch.js";
51
51
  export { analyzeToken, analyzeWallet, analyzeAddress, sortResults } from "./wallet.js";
52
- export { holderBase, markBusy, findFunder, findFunders, clusterByFunder, holderRows, creatorInfo, analyzeHolders } from "./holders.js";
52
+ export { holderBase, markBusy, findFunder, findFunders, clusterByFunder, holderRows, creatorInfo, analyzeHolders,
53
+ perHour, sourceRates, groupList, EXCHANGE_PER_HOUR, YOUNG_DAYS, HOLD_GROUP_YOUNG, HOLD_GROUP_MATURE } from "./holders.js";
53
54
  export { fetchSwaps, solHistory, priceSeries, parseTrades, pnlFromTrades, pnlStatus, estimatePnl } from "./pnl.js";
54
55
  export { capSnap, chgDiff, chgAgg, takeSnapshots, diffSnapshots } from "./changes.js";
55
56
  export { confluence, commonHolders } from "./confluence.js";
package/src/tokens.js CHANGED
@@ -1,103 +1,103 @@
1
- import { TOKEN_PROGRAM, TOKEN_2022, SYSTEM_PROGRAM } from "./constants.js";
2
- import { isCancel } from "./errors.js";
3
- import { onCurve, b58bytes } from "./curve.js";
4
- import { uniq } from "./util.js";
5
-
6
- // SOL balance and every token the wallet holds (classic SPL + Token-2022), summed by mint
7
- export async function getTokens(client, wallet) {
8
- const res = await Promise.all([
9
- client.rpc("getBalance", [wallet]),
10
- client.rpc("getTokenAccountsByOwner", [wallet, { programId: TOKEN_PROGRAM }, { encoding: "jsonParsed" }]),
11
- client.rpc("getTokenAccountsByOwner", [wallet, { programId: TOKEN_2022 }, { encoding: "jsonParsed" }])
12
- ]);
13
- const tokens = {};
14
- [res[1], res[2]].forEach(function (r) {
15
- r.value.forEach(function (c) {
16
- const info = c.account.data.parsed.info;
17
- const q = Number(info.tokenAmount.uiAmount || 0);
18
- if (q > 0) tokens[info.mint] = (tokens[info.mint] || 0) + q;
19
- });
20
- });
21
- return { sol: res[0].value / 1e9, tokens: tokens };
22
- }
23
-
24
- // A mint account belongs to a token program; a wallet belongs to the System Program
25
- export async function isMint(client, addr) {
26
- try {
27
- const r = await client.rpc("getAccountInfo", [addr, { encoding: "jsonParsed" }]);
28
- const v = r && r.value;
29
- return !!(v && (v.owner === TOKEN_PROGRAM || v.owner === TOKEN_2022) && v.data && v.data.parsed && v.data.parsed.type === "mint");
30
- } catch (e) { if (isCancel(e)) throw e; return false; } // if it cannot be known, treat it as a wallet
31
- }
32
-
33
- export async function getMint(client, mint) {
34
- const r = await client.rpc("getAccountInfo", [mint, { encoding: "jsonParsed" }]);
35
- const p = r && r.value && r.value.data && r.value.data.parsed && r.value.data.parsed.info;
36
- if (!p) return null;
37
- return {
38
- mintAuth: p.mintAuthority || null, freezeAuth: p.freezeAuthority || null,
39
- supply: Number(p.supply || 0), decimals: Number(p.decimals || 0),
40
- ext: (p.extensions || []).map(function (e) { return e.extension; })
41
- };
42
- }
43
-
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) {
55
- if (!addrs.length) return {};
56
- const r = await client.rpc("getMultipleAccounts", [addrs, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
57
- const out = {};
58
- addrs.forEach(function (k, i) { out[k] = kindOf(k, r.value[i], missingIsWallet); });
59
- return out;
60
- }
61
-
62
- // Who controls the mint and freeze authorities: a person's wallet or a program (bridge, protocol)
63
- export async function getAuthKinds(client, info) {
64
- const keys = [];
65
- [info.mintAuth, info.freezeAuth].forEach(function (a) { if (a && keys.indexOf(a) < 0) keys.push(a); });
66
- if (!keys.length) return {};
67
- return accountKinds(client, keys, true); // no account but on the curve: an empty wallet
68
- }
69
-
70
- // Concentration of the supply. Each large account is traced to its owner: a pool or a program
71
- // holding tokens is not a whale, so w1/w10 count only people's wallets.
72
- export async function getConc(client, mint, supply) {
73
- const r = await client.rpc("getTokenLargestAccounts", [mint]);
74
- const v = (r && r.value) || [];
75
- if (!v.length || supply <= 0) throw new Error("no holder data");
76
- const amts = v.map(function (x) { return Number(x.amount); });
77
- const out = {
78
- ok: false,
79
- top1: amts[0] / supply * 100,
80
- top10: amts.slice(0, 10).reduce(function (s, x) { return s + x; }, 0) / supply * 100
81
- };
82
- const addrs = v.map(function (x) { return x.address; });
83
- if (addrs.some(function (x) { return !x; })) return out;
84
- try {
85
- const ta = await client.rpc("getMultipleAccounts", [addrs, { encoding: "jsonParsed" }]);
86
- const owners = ta.value.map(function (x) {
87
- return x && x.data && x.data.parsed && x.data.parsed.info && x.data.parsed.info.owner || null;
88
- });
89
- if (owners.some(function (o) { return !o; })) return out;
90
- const kind = await accountKinds(client, uniq(owners), false);
91
- const agg = {};
92
- owners.forEach(function (o, i) { agg[o] = (agg[o] || 0) + amts[i]; });
93
- const ws = [];
94
- let prog = 0;
95
- Object.keys(agg).forEach(function (o) { if (kind[o] === "wallet") ws.push(agg[o]); else prog += agg[o]; });
96
- ws.sort(function (x, y) { return y - x; });
97
- out.ok = true; out.nW = ws.length;
98
- out.w1 = ws.length ? ws[0] / supply * 100 : 0;
99
- out.w10 = ws.slice(0, 10).reduce(function (s, x) { return s + x; }, 0) / supply * 100;
100
- out.prog = prog / supply * 100;
101
- } catch (e) { if (isCancel(e)) throw e; }
102
- return out;
103
- }
1
+ import { TOKEN_PROGRAM, TOKEN_2022, SYSTEM_PROGRAM } from "./constants.js";
2
+ import { isCancel } from "./errors.js";
3
+ import { onCurve, b58bytes } from "./curve.js";
4
+ import { uniq } from "./util.js";
5
+
6
+ // SOL balance and every token the wallet holds (classic SPL + Token-2022), summed by mint
7
+ export async function getTokens(client, wallet) {
8
+ const res = await Promise.all([
9
+ client.rpc("getBalance", [wallet]),
10
+ client.rpc("getTokenAccountsByOwner", [wallet, { programId: TOKEN_PROGRAM }, { encoding: "jsonParsed" }]),
11
+ client.rpc("getTokenAccountsByOwner", [wallet, { programId: TOKEN_2022 }, { encoding: "jsonParsed" }])
12
+ ]);
13
+ const tokens = {};
14
+ [res[1], res[2]].forEach(function (r) {
15
+ r.value.forEach(function (c) {
16
+ const info = c.account.data.parsed.info;
17
+ const q = Number(info.tokenAmount.uiAmount || 0);
18
+ if (q > 0) tokens[info.mint] = (tokens[info.mint] || 0) + q;
19
+ });
20
+ });
21
+ return { sol: res[0].value / 1e9, tokens: tokens };
22
+ }
23
+
24
+ // A mint account belongs to a token program; a wallet belongs to the System Program
25
+ export async function isMint(client, addr) {
26
+ try {
27
+ const r = await client.rpc("getAccountInfo", [addr, { encoding: "jsonParsed" }]);
28
+ const v = r && r.value;
29
+ return !!(v && (v.owner === TOKEN_PROGRAM || v.owner === TOKEN_2022) && v.data && v.data.parsed && v.data.parsed.type === "mint");
30
+ } catch (e) { if (isCancel(e)) throw e; return false; } // if it cannot be known, treat it as a wallet
31
+ }
32
+
33
+ export async function getMint(client, mint) {
34
+ const r = await client.rpc("getAccountInfo", [mint, { encoding: "jsonParsed" }]);
35
+ const p = r && r.value && r.value.data && r.value.data.parsed && r.value.data.parsed.info;
36
+ if (!p) return null;
37
+ return {
38
+ mintAuth: p.mintAuthority || null, freezeAuth: p.freezeAuthority || null,
39
+ supply: Number(p.supply || 0), decimals: Number(p.decimals || 0),
40
+ ext: (p.extensions || []).map(function (e) { return e.extension; })
41
+ };
42
+ }
43
+
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) {
55
+ if (!addrs.length) return {};
56
+ const r = await client.rpc("getMultipleAccounts", [addrs, { encoding: "base64", dataSlice: { offset: 0, length: 0 } }]);
57
+ const out = {};
58
+ addrs.forEach(function (k, i) { out[k] = kindOf(k, r.value[i], missingIsWallet); });
59
+ return out;
60
+ }
61
+
62
+ // Who controls the mint and freeze authorities: a person's wallet or a program (bridge, protocol)
63
+ export async function getAuthKinds(client, info) {
64
+ const keys = [];
65
+ [info.mintAuth, info.freezeAuth].forEach(function (a) { if (a && keys.indexOf(a) < 0) keys.push(a); });
66
+ if (!keys.length) return {};
67
+ return accountKinds(client, keys, true); // no account but on the curve: an empty wallet
68
+ }
69
+
70
+ // Concentration of the supply. Each large account is traced to its owner: a pool or a program
71
+ // holding tokens is not a whale, so w1/w10 count only people's wallets.
72
+ export async function getConc(client, mint, supply) {
73
+ const r = await client.rpc("getTokenLargestAccounts", [mint]);
74
+ const v = (r && r.value) || [];
75
+ if (!v.length || supply <= 0) throw new Error("no holder data");
76
+ const amts = v.map(function (x) { return Number(x.amount); });
77
+ const out = {
78
+ ok: false,
79
+ top1: amts[0] / supply * 100,
80
+ top10: amts.slice(0, 10).reduce(function (s, x) { return s + x; }, 0) / supply * 100
81
+ };
82
+ const addrs = v.map(function (x) { return x.address; });
83
+ if (addrs.some(function (x) { return !x; })) return out;
84
+ try {
85
+ const ta = await client.rpc("getMultipleAccounts", [addrs, { encoding: "jsonParsed" }]);
86
+ const owners = ta.value.map(function (x) {
87
+ return x && x.data && x.data.parsed && x.data.parsed.info && x.data.parsed.info.owner || null;
88
+ });
89
+ if (owners.some(function (o) { return !o; })) return out;
90
+ const kind = await accountKinds(client, uniq(owners), false);
91
+ const agg = {};
92
+ owners.forEach(function (o, i) { agg[o] = (agg[o] || 0) + amts[i]; });
93
+ const ws = [];
94
+ let prog = 0;
95
+ Object.keys(agg).forEach(function (o) { if (kind[o] === "wallet") ws.push(agg[o]); else prog += agg[o]; });
96
+ ws.sort(function (x, y) { return y - x; });
97
+ out.ok = true; out.nW = ws.length;
98
+ out.w1 = ws.length ? ws[0] / supply * 100 : 0;
99
+ out.w10 = ws.slice(0, 10).reduce(function (s, x) { return s + x; }, 0) / supply * 100;
100
+ out.prog = prog / supply * 100;
101
+ } catch (e) { if (isCancel(e)) throw e; }
102
+ return out;
103
+ }