lovewebconnection 0.0.0-stage → 0.2.1

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.
Files changed (68) hide show
  1. package/.env.example +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1937 -2
  4. package/dist/alert-queue.d.ts +72 -0
  5. package/dist/alert-queue.js +155 -0
  6. package/dist/alert-store.d.ts +29 -0
  7. package/dist/alert-store.js +75 -0
  8. package/dist/balances.d.ts +221 -0
  9. package/dist/balances.js +1232 -0
  10. package/dist/cache.d.ts +37 -0
  11. package/dist/cache.js +128 -0
  12. package/dist/call.d.ts +46 -0
  13. package/dist/call.js +377 -0
  14. package/dist/chain.d.ts +97 -0
  15. package/dist/chain.js +419 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +1011 -0
  18. package/dist/compare-history.d.ts +82 -0
  19. package/dist/compare-history.js +340 -0
  20. package/dist/compare.d.ts +98 -0
  21. package/dist/compare.js +492 -0
  22. package/dist/contract.d.ts +107 -0
  23. package/dist/contract.js +162 -0
  24. package/dist/default-abi.d.ts +53 -0
  25. package/dist/default-abi.js +85 -0
  26. package/dist/history.d.ts +83 -0
  27. package/dist/history.js +422 -0
  28. package/dist/index.d.ts +54 -0
  29. package/dist/index.js +27 -0
  30. package/dist/market-page.d.ts +8 -0
  31. package/dist/market-page.js +554 -0
  32. package/dist/market-server.d.ts +46 -0
  33. package/dist/market-server.js +473 -0
  34. package/dist/market.d.ts +323 -0
  35. package/dist/market.js +835 -0
  36. package/dist/metadata-cache.d.ts +72 -0
  37. package/dist/metadata-cache.js +197 -0
  38. package/dist/metrics.d.ts +64 -0
  39. package/dist/metrics.js +138 -0
  40. package/dist/plot.d.ts +60 -0
  41. package/dist/plot.js +317 -0
  42. package/dist/presets-history.d.ts +372 -0
  43. package/dist/presets-history.js +1243 -0
  44. package/dist/presets-summary.d.ts +17 -0
  45. package/dist/presets-summary.js +55 -0
  46. package/dist/presets.d.ts +59 -0
  47. package/dist/presets.js +181 -0
  48. package/dist/prices.d.ts +63 -0
  49. package/dist/prices.js +154 -0
  50. package/dist/rotate.d.ts +19 -0
  51. package/dist/rotate.js +34 -0
  52. package/dist/scan.d.ts +81 -0
  53. package/dist/scan.js +501 -0
  54. package/dist/sessions.d.ts +104 -0
  55. package/dist/sessions.js +515 -0
  56. package/dist/signatures.d.ts +13 -0
  57. package/dist/signatures.js +43 -0
  58. package/dist/tokenfile.d.ts +50 -0
  59. package/dist/tokenfile.js +218 -0
  60. package/dist/views.d.ts +63 -0
  61. package/dist/views.js +415 -0
  62. package/dist/watch.d.ts +306 -0
  63. package/dist/watch.js +1121 -0
  64. package/dist/webhook.d.ts +17 -0
  65. package/dist/webhook.js +35 -0
  66. package/examples/erc20.abi.json +57 -0
  67. package/examples/xlayer.tokens.json +4 -0
  68. package/package.json +77 -4
@@ -0,0 +1,1232 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { decodeFunctionResult, encodeFunctionData, encodeFunctionResult, erc20Abi, formatUnits, getAddress, isAddress, } from "viem";
3
+ import { MULTICALL3_ADDRESS, multicall3BatchAbi, readTyped } from "./contract.js";
4
+ import { parseCacheAge } from "./metadata-cache.js";
5
+ export class BalancesArgError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "BalancesArgError";
9
+ }
10
+ }
11
+ function readJsonList(path, kind, keys, entryHint) {
12
+ let raw;
13
+ try {
14
+ raw = readFileSync(path, "utf8");
15
+ }
16
+ catch (error) {
17
+ const reason = error instanceof Error ? error.message : String(error);
18
+ throw new BalancesArgError(`cannot read ${kind} ${path}: ${reason}`);
19
+ }
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(raw);
23
+ }
24
+ catch {
25
+ throw new BalancesArgError(`${path} is not valid JSON.`);
26
+ }
27
+ const list = Array.isArray(parsed)
28
+ ? parsed
29
+ : keys.reduce((found, key) => found ?? parsed?.[key], undefined);
30
+ if (!Array.isArray(list)) {
31
+ throw new BalancesArgError(`${path} must be a JSON array of ${entryHint}, or an object with a "${keys[0]}" array.`);
32
+ }
33
+ return list;
34
+ }
35
+ /**
36
+ * Read a saved address list: a JSON array of addresses, or an object holding one
37
+ * of `keys` as that array (matching the tolerance of the ABI loader).
38
+ */
39
+ export function loadAddressList(path, label, keys) {
40
+ return readJsonList(path, label, keys, "addresses").map((entry, index) => {
41
+ if (typeof entry !== "string" || !isAddress(entry)) {
42
+ throw new BalancesArgError(`${path}: entry ${index + 1} is not an EVM address.`);
43
+ }
44
+ return getAddress(entry);
45
+ });
46
+ }
47
+ /** Parse an inline token spec: `0xAddress:Label` or `0xAddress=Label`. */
48
+ export function parseTokenSpec(raw) {
49
+ const candidate = raw.trim();
50
+ const separator = candidate.search(/[:=]/);
51
+ const address = (separator === -1 ? candidate : candidate.slice(0, separator)).trim();
52
+ const label = separator === -1 ? "" : candidate.slice(separator + 1).trim();
53
+ if (!isAddress(address)) {
54
+ throw new BalancesArgError(`"${address}" is not a valid token address.`);
55
+ }
56
+ if (label.length > 64) {
57
+ throw new BalancesArgError("token label longer than 64 characters.");
58
+ }
59
+ return { address: getAddress(address), label: label.length > 0 ? label : null };
60
+ }
61
+ /** Record the last label for a token, warning when a different one was present. */
62
+ export function rememberTokenLabel(entry, labels, warnings) {
63
+ if (!entry.label)
64
+ return;
65
+ const key = entry.address.toLowerCase();
66
+ const previous = labels.get(key);
67
+ if (previous !== undefined && previous !== entry.label) {
68
+ warnings.push(`token ${shortAddress(entry.address)} is listed with conflicting labels ${JSON.stringify(previous)} and ${JSON.stringify(entry.label)}; using ${JSON.stringify(entry.label)}.`);
69
+ }
70
+ labels.set(key, entry.label);
71
+ }
72
+ /**
73
+ * Merge ad-hoc and file labels. Inline `--token` labels win over the token
74
+ * file, and a differing file label is reported once rather than silently lost.
75
+ */
76
+ export function mergeTokenLabels(inlineLabels, fileLabels, warnings) {
77
+ const labels = new Map(fileLabels);
78
+ for (const [key, label] of inlineLabels) {
79
+ const fileLabel = fileLabels.get(key);
80
+ if (fileLabel !== undefined && fileLabel !== label) {
81
+ warnings.push(`token ${shortAddress(getAddress(key))} inline label ${JSON.stringify(label)} overrides file label ${JSON.stringify(fileLabel)}.`);
82
+ }
83
+ labels.set(key, label);
84
+ }
85
+ return labels;
86
+ }
87
+ /**
88
+ * Saved token list: a JSON array of addresses, or `{ address, label }` objects,
89
+ * or an object with a `tokens` array of either.
90
+ */
91
+ export function loadTokenList(path) {
92
+ return readJsonList(path, "token list", ["tokens"], "addresses (or {address, label} entries)").map((entry, index) => {
93
+ if (typeof entry === "string") {
94
+ if (!isAddress(entry)) {
95
+ throw new BalancesArgError(`${path}: entry ${index + 1} is not an EVM address.`);
96
+ }
97
+ return { address: getAddress(entry), label: null };
98
+ }
99
+ if (entry && typeof entry === "object") {
100
+ const record = entry;
101
+ if (typeof record.address !== "string" || !isAddress(record.address)) {
102
+ throw new BalancesArgError(`${path}: entry ${index + 1} is not an EVM address.`);
103
+ }
104
+ if (record.label !== undefined && record.label !== null && typeof record.label !== "string") {
105
+ throw new BalancesArgError(`${path}: entry ${index + 1} has a non-string label.`);
106
+ }
107
+ const label = typeof record.label === "string" ? record.label.trim() : "";
108
+ if (label.length > 64) {
109
+ throw new BalancesArgError(`${path}: entry ${index + 1} has a label longer than 64 characters.`);
110
+ }
111
+ return { address: getAddress(record.address), label: label.length > 0 ? label : null };
112
+ }
113
+ throw new BalancesArgError(`${path}: entry ${index + 1} is not an EVM address or {address, label} object.`);
114
+ });
115
+ }
116
+ function parseBatchSize(raw) {
117
+ if (!/^\d+$/.test(raw)) {
118
+ throw new BalancesArgError(`--batch-size must be a whole number, got "${raw}".`);
119
+ }
120
+ const value = Number(raw);
121
+ if (value < 1 || value > MAX_BATCH_SIZE) {
122
+ throw new BalancesArgError(`--batch-size must be between 1 and ${MAX_BATCH_SIZE}.`);
123
+ }
124
+ return value;
125
+ }
126
+ function parseMaxCacheAge(raw) {
127
+ const parsed = parseCacheAge(raw);
128
+ if (!parsed.ok)
129
+ throw new BalancesArgError(parsed.message);
130
+ return parsed.ms;
131
+ }
132
+ function parseStaleAfter(raw) {
133
+ const parsed = parseCacheAge(raw, "--stale-after");
134
+ if (!parsed.ok)
135
+ throw new BalancesArgError(parsed.message);
136
+ return parsed.ms;
137
+ }
138
+ function parseMaxSubCalls(raw) {
139
+ if (!/^\d+$/.test(raw)) {
140
+ throw new BalancesArgError(`--max-subcalls must be a whole number, got "${raw}".`);
141
+ }
142
+ const value = Number(raw);
143
+ if (value < 1 || value > MAX_SUB_CALLS) {
144
+ throw new BalancesArgError(`--max-subcalls must be between 1 and ${MAX_SUB_CALLS}.`);
145
+ }
146
+ return value;
147
+ }
148
+ export function parseBalanceArgs(argv) {
149
+ const positional = [];
150
+ const rawTokens = [];
151
+ const tokenFiles = [];
152
+ let batchSize = TOKENS_PER_BATCH;
153
+ let maxSubCalls = null;
154
+ let failOnRetry = false;
155
+ let verbose = false;
156
+ let maxCacheAgeMs = null;
157
+ let staleAfterMs = null;
158
+ let sawBatchSize = false;
159
+ for (let index = 0; index < argv.length; index += 1) {
160
+ const arg = argv[index] ?? "";
161
+ if (arg === "--token") {
162
+ const value = argv[index + 1];
163
+ if (!value)
164
+ throw new BalancesArgError("--token requires an address.");
165
+ rawTokens.push(value);
166
+ index += 1;
167
+ }
168
+ else if (arg.startsWith("--token=")) {
169
+ rawTokens.push(arg.slice("--token=".length));
170
+ }
171
+ else if (arg === "--tokens") {
172
+ const value = argv[index + 1];
173
+ if (!value)
174
+ throw new BalancesArgError("--tokens requires a comma-separated list.");
175
+ rawTokens.push(...value.split(","));
176
+ index += 1;
177
+ }
178
+ else if (arg.startsWith("--tokens=")) {
179
+ rawTokens.push(...arg.slice("--tokens=".length).split(","));
180
+ }
181
+ else if (arg === "--tokens-file") {
182
+ const value = argv[index + 1];
183
+ if (!value)
184
+ throw new BalancesArgError("--tokens-file requires a path.");
185
+ tokenFiles.push(value);
186
+ index += 1;
187
+ }
188
+ else if (arg.startsWith("--tokens-file=")) {
189
+ const value = arg.slice("--tokens-file=".length);
190
+ if (!value)
191
+ throw new BalancesArgError("--tokens-file requires a path.");
192
+ tokenFiles.push(value);
193
+ }
194
+ else if (arg === "--batch-size") {
195
+ const value = argv[index + 1];
196
+ if (!value)
197
+ throw new BalancesArgError("--batch-size requires a value.");
198
+ batchSize = parseBatchSize(value);
199
+ sawBatchSize = true;
200
+ index += 1;
201
+ }
202
+ else if (arg.startsWith("--batch-size=")) {
203
+ batchSize = parseBatchSize(arg.slice("--batch-size=".length));
204
+ sawBatchSize = true;
205
+ }
206
+ else if (arg === "--max-subcalls") {
207
+ const value = argv[index + 1];
208
+ if (!value)
209
+ throw new BalancesArgError("--max-subcalls requires a value.");
210
+ maxSubCalls = parseMaxSubCalls(value);
211
+ index += 1;
212
+ }
213
+ else if (arg.startsWith("--max-subcalls=")) {
214
+ maxSubCalls = parseMaxSubCalls(arg.slice("--max-subcalls=".length));
215
+ }
216
+ else if (arg === "--fail-on-retry") {
217
+ failOnRetry = true;
218
+ }
219
+ else if (arg === "--verbose") {
220
+ verbose = true;
221
+ }
222
+ else if (arg === "--max-cache-age") {
223
+ const value = argv[index + 1];
224
+ if (!value)
225
+ throw new BalancesArgError("--max-cache-age requires a duration.");
226
+ maxCacheAgeMs = parseMaxCacheAge(value);
227
+ index += 1;
228
+ }
229
+ else if (arg.startsWith("--max-cache-age=")) {
230
+ maxCacheAgeMs = parseMaxCacheAge(arg.slice("--max-cache-age=".length));
231
+ }
232
+ else if (arg === "--stale-after") {
233
+ const value = argv[index + 1];
234
+ if (!value)
235
+ throw new BalancesArgError("--stale-after requires a duration.");
236
+ staleAfterMs = parseStaleAfter(value);
237
+ index += 1;
238
+ }
239
+ else if (arg.startsWith("--stale-after=")) {
240
+ staleAfterMs = parseStaleAfter(arg.slice("--stale-after=".length));
241
+ }
242
+ else if (arg.startsWith("-")) {
243
+ // Other flags (e.g. --json) are handled by the caller.
244
+ continue;
245
+ }
246
+ else {
247
+ positional.push(arg);
248
+ }
249
+ }
250
+ const ownerRaw = positional[0];
251
+ if (!ownerRaw) {
252
+ throw new BalancesArgError("An owner address is required: lwc balances <0xaddress> [--token 0x…]");
253
+ }
254
+ if (positional.length > 1) {
255
+ throw new BalancesArgError(`Unexpected extra argument: ${positional[1]}.`);
256
+ }
257
+ if (!isAddress(ownerRaw)) {
258
+ throw new BalancesArgError(`"${ownerRaw}" is not a valid EVM address.`);
259
+ }
260
+ const tokens = [];
261
+ const warnings = [];
262
+ const inlineLabels = new Map();
263
+ const fileLabels = new Map();
264
+ for (const raw of rawTokens) {
265
+ const candidate = raw.trim();
266
+ if (!candidate)
267
+ continue;
268
+ const entry = parseTokenSpec(candidate);
269
+ tokens.push(entry.address);
270
+ rememberTokenLabel(entry, inlineLabels, warnings);
271
+ }
272
+ for (const file of tokenFiles) {
273
+ for (const entry of loadTokenList(file)) {
274
+ tokens.push(entry.address);
275
+ rememberTokenLabel(entry, fileLabels, warnings);
276
+ }
277
+ }
278
+ const labels = mergeTokenLabels(inlineLabels, fileLabels, warnings);
279
+ if (sawBatchSize && maxSubCalls !== null) {
280
+ throw new BalancesArgError("use either --batch-size or --max-subcalls, not both.");
281
+ }
282
+ return {
283
+ owner: getAddress(ownerRaw),
284
+ tokens: [...new Set(tokens)],
285
+ labels,
286
+ batchSize,
287
+ maxSubCalls,
288
+ failOnRetry,
289
+ verbose,
290
+ maxCacheAgeMs,
291
+ staleAfterMs,
292
+ warnings,
293
+ };
294
+ }
295
+ /** Render a base-unit value without trailing zeros. */
296
+ export function formatTokenAmount(value, decimals) {
297
+ const raw = formatUnits(value, decimals);
298
+ if (!raw.includes("."))
299
+ return raw;
300
+ return raw.replace(/0+$/, "").replace(/\.$/, "");
301
+ }
302
+ /** Compact age for cached metadata: `45s`, `12m`, `5h`, `3d`. */
303
+ /**
304
+ * Warn-only check for cached metadata older than `staleAfterMs`. Freshly read
305
+ * tokens and undated legacy entries carry no age, so they never warn.
306
+ */
307
+ export function staleMetadataWarnings(tokens, staleAfterMs) {
308
+ if (staleAfterMs === null)
309
+ return [];
310
+ return tokens.flatMap((token) => {
311
+ const age = token.cacheAgeMs;
312
+ if (age === null || age <= staleAfterMs)
313
+ return [];
314
+ const name = token.label
315
+ ? `${token.symbol} [${token.label}]`
316
+ : token.symbol || shortAddress(token.address);
317
+ return [
318
+ `${name} (${shortAddress(token.address)}) metadata is ${formatCacheAge(age)} old, stale after ${formatCacheAge(staleAfterMs)}`,
319
+ ];
320
+ });
321
+ }
322
+ export function formatCacheAge(ms) {
323
+ const seconds = Math.floor(Math.max(0, Number.isFinite(ms) ? ms : 0) / 1000);
324
+ if (seconds < 60)
325
+ return `${seconds}s`;
326
+ const minutes = Math.floor(seconds / 60);
327
+ if (minutes < 60)
328
+ return `${minutes}m`;
329
+ const hours = Math.floor(minutes / 60);
330
+ if (hours < 48)
331
+ return `${hours}h`;
332
+ return `${Math.floor(hours / 24)}d`;
333
+ }
334
+ export function shortAddress(address) {
335
+ return `${address.slice(0, 6)}…${address.slice(-4)}`;
336
+ }
337
+ /** One stderr line per chunk for `--verbose`, matching `scan`'s progress text. */
338
+ export function formatChunkProgress(chunk) {
339
+ const status = chunk.status === "batched"
340
+ ? "ok"
341
+ : chunk.status === "recovered"
342
+ ? "retried, recovered"
343
+ : chunk.status === "partial"
344
+ ? "retried, partial"
345
+ : "failed";
346
+ const individual = chunk.individual.length > 0 ? `, ${chunk.individual.length} individual` : "";
347
+ return ` chunk ${shortAddress(chunk.owner)} ${chunk.index}/${chunk.total} (${chunk.tokens} token${chunk.tokens === 1 ? "" : "s"}) ${status}${individual}`;
348
+ }
349
+ /** Highest standard EVM precompile (0x0a, point evaluation). */
350
+ const PRECOMPILE_MAX = 0x0a;
351
+ /**
352
+ * Precompiles live at 0x…01–0x…0a. They have no code, so they can never be
353
+ * ERC-20s, and some (notably 0x05 modexp) read a 4-byte selector as an enormous
354
+ * operand length and burn every unit of gas available — which reverts the whole
355
+ * aggregate3 batch with "out of gas". They are answered locally instead.
356
+ */
357
+ export function isPrecompileAddress(address) {
358
+ const trimmed = address.slice(2).toLowerCase().replace(/^0+/, "");
359
+ if (trimmed.length === 0 || trimmed.length > 2)
360
+ return false;
361
+ const value = Number.parseInt(trimmed, 16);
362
+ return value >= 1 && value <= PRECOMPILE_MAX;
363
+ }
364
+ function unreadableToken(token, issue = "no data returned for this address") {
365
+ return {
366
+ address: token,
367
+ symbol: shortAddress(token),
368
+ label: null,
369
+ decimals: null,
370
+ balance: null,
371
+ cacheAgeMs: null,
372
+ issue,
373
+ };
374
+ }
375
+ function decodeDecimals(result) {
376
+ if (!result?.success)
377
+ return null;
378
+ try {
379
+ const value = decodeFunctionResult({
380
+ abi: erc20Abi,
381
+ functionName: "decimals",
382
+ data: result.returnData,
383
+ });
384
+ return typeof value === "number" ? value : null;
385
+ }
386
+ catch {
387
+ return null;
388
+ }
389
+ }
390
+ function decodeSymbol(result) {
391
+ if (!result?.success)
392
+ return null;
393
+ try {
394
+ const value = decodeFunctionResult({
395
+ abi: erc20Abi,
396
+ functionName: "symbol",
397
+ data: result.returnData,
398
+ });
399
+ return typeof value === "string" ? value : null;
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ }
405
+ function decodeErc20Balance(result) {
406
+ if (!result?.success)
407
+ return null;
408
+ try {
409
+ const value = decodeFunctionResult({
410
+ abi: erc20Abi,
411
+ functionName: "balanceOf",
412
+ data: result.returnData,
413
+ });
414
+ return typeof value === "bigint" ? value : null;
415
+ }
416
+ catch {
417
+ return null;
418
+ }
419
+ }
420
+ function decodeTotalSupply(result) {
421
+ if (!result?.success)
422
+ return null;
423
+ try {
424
+ const value = decodeFunctionResult({
425
+ abi: erc20Abi,
426
+ functionName: "totalSupply",
427
+ data: result.returnData,
428
+ });
429
+ return typeof value === "bigint" ? value : null;
430
+ }
431
+ catch {
432
+ return null;
433
+ }
434
+ }
435
+ function decodeNativeBalance(result) {
436
+ if (!result?.success)
437
+ return null;
438
+ try {
439
+ const value = decodeFunctionResult({
440
+ abi: multicall3BatchAbi,
441
+ functionName: "getEthBalance",
442
+ data: result.returnData,
443
+ });
444
+ return typeof value === "bigint" ? value : null;
445
+ }
446
+ catch {
447
+ return null;
448
+ }
449
+ }
450
+ const EMPTY_DATA = "0x";
451
+ /** Decode a bytes32 word holding ASCII text, the pre-`string` symbol convention. */
452
+ function bytes32Text(data) {
453
+ if (data.length !== 66)
454
+ return null;
455
+ const body = data.slice(2);
456
+ const bytes = [];
457
+ for (let index = 0; index < body.length; index += 2) {
458
+ bytes.push(Number.parseInt(body.slice(index, index + 2), 16));
459
+ }
460
+ const terminator = bytes.indexOf(0);
461
+ const text = terminator === -1 ? bytes : bytes.slice(0, terminator);
462
+ if (text.length === 0 || text.some((byte) => byte < 0x20 || byte > 0x7e))
463
+ return null;
464
+ return String.fromCharCode(...text);
465
+ }
466
+ /**
467
+ * Classify one token from its raw sub-call results. A null field alone cannot
468
+ * say whether an address is a real ERC-20: an EOA answers every call with no
469
+ * data, a plain contract reverts, and a token can return a bytes32 symbol or a
470
+ * decimals value that is not a uint8 while still reporting a balance.
471
+ */
472
+ function inspectToken(decimalsResult, symbolResult, balanceResult, supplyResult) {
473
+ const decimals = decodeDecimals(decimalsResult);
474
+ const symbol = decodeSymbol(symbolResult);
475
+ const balance = decodeErc20Balance(balanceResult);
476
+ const totalSupply = decodeTotalSupply(supplyResult);
477
+ const results = [decimalsResult, symbolResult, balanceResult, supplyResult];
478
+ const allEmpty = results.every((result) => result?.success === true && result.returnData === EMPTY_DATA);
479
+ const answered = results.filter((result) => result?.success === true).length;
480
+ let issue = null;
481
+ if (allEmpty) {
482
+ issue = "no contract code (every call returned no data)";
483
+ }
484
+ else if (answered === 0) {
485
+ issue = "reverted on decimals, symbol, balanceOf and totalSupply (not an ERC-20)";
486
+ }
487
+ else {
488
+ const problems = [];
489
+ if (decimals === null) {
490
+ problems.push(decimalsResult?.success ? "decimals is not a uint8" : "decimals reverted");
491
+ }
492
+ else if (decimals > 36) {
493
+ problems.push(`implausible decimals (${decimals})`);
494
+ }
495
+ if (symbol === null) {
496
+ const text = symbolResult?.success ? bytes32Text(symbolResult.returnData) : null;
497
+ if (text)
498
+ problems.push(`symbol is bytes32 (${text}), not a string`);
499
+ else if (symbolResult?.success)
500
+ problems.push("symbol is neither a string nor bytes32 text");
501
+ else
502
+ problems.push("symbol reverted");
503
+ }
504
+ else if (symbol.trim().length === 0) {
505
+ problems.push("symbol is empty");
506
+ }
507
+ if (balance === null) {
508
+ problems.push(balanceResult?.success ? "balanceOf is not a uint256" : "balanceOf reverted");
509
+ }
510
+ if (totalSupply === null) {
511
+ problems.push(supplyResult?.success ? "totalSupply is not a uint256" : "totalSupply reverted");
512
+ }
513
+ else if (balance !== null && balance > totalSupply) {
514
+ problems.push(`balanceOf exceeds totalSupply (${balance} > ${totalSupply} base units)`);
515
+ }
516
+ if (problems.length > 0)
517
+ issue = problems.join("; ");
518
+ }
519
+ return { decimals, symbol, balance, issue };
520
+ }
521
+ async function attempt(fn) {
522
+ try {
523
+ return await fn();
524
+ }
525
+ catch {
526
+ return null;
527
+ }
528
+ }
529
+ async function readTokenIndividually(client, token, owner) {
530
+ const [decimals, symbol, balance, totalSupply] = await Promise.all([
531
+ attempt(() => client.readContract({ address: token, abi: erc20Abi, functionName: "decimals" })),
532
+ attempt(() => client.readContract({ address: token, abi: erc20Abi, functionName: "symbol" })),
533
+ attempt(() => client.readContract({
534
+ address: token,
535
+ abi: erc20Abi,
536
+ functionName: "balanceOf",
537
+ args: [owner],
538
+ })),
539
+ attempt(() => client.readContract({ address: token, abi: erc20Abi, functionName: "totalSupply" })),
540
+ ]);
541
+ const problems = [];
542
+ if (decimals === null)
543
+ problems.push("decimals failed");
544
+ if (symbol === null)
545
+ problems.push("symbol failed");
546
+ if (balance === null)
547
+ problems.push("balanceOf failed");
548
+ if (totalSupply === null) {
549
+ problems.push("totalSupply failed");
550
+ }
551
+ else if (typeof balance === "bigint" && balance > totalSupply) {
552
+ problems.push(`balanceOf exceeds totalSupply (${balance} > ${totalSupply} base units)`);
553
+ }
554
+ const result = {
555
+ address: token,
556
+ symbol: typeof symbol === "string" && symbol.trim().length > 0
557
+ ? symbol.trim()
558
+ : shortAddress(token),
559
+ label: null,
560
+ decimals: typeof decimals === "number" ? decimals : null,
561
+ balance: typeof balance === "bigint" ? balance : null,
562
+ cacheAgeMs: null,
563
+ issue: problems.length > 0 ? `individual reads: ${problems.join(", ")}` : null,
564
+ };
565
+ const metadata = typeof decimals === "number" && Number.isInteger(decimals) && decimals >= 0 && decimals <= 36 &&
566
+ typeof symbol === "string" && symbol.trim().length > 0
567
+ ? { symbol: symbol.trim(), decimals }
568
+ : null;
569
+ return { result, metadata };
570
+ }
571
+ /**
572
+ * One eth_call: native balance plus decimals/symbol/balanceOf/totalSupply for
573
+ * every token, through Multicall3 aggregate3. Returns null when the batch cannot
574
+ * be used at all, so the caller can fall back to individual reads.
575
+ */
576
+ /** Default tokens per Multicall3 request: 12 uncached tokens is 49 sub-calls,
577
+ * comfortably within the 50M eth_call gas cap that a gas-burning address can
578
+ * otherwise exhaust. `--batch-size` lowers it for stricter RPCs. */
579
+ export const TOKENS_PER_BATCH = 12;
580
+ /** Cached tokens need only balanceOf and totalSupply, so 24 still fits 49. */
581
+ export const CACHED_TOKENS_PER_BATCH = 24;
582
+ export const MAX_BATCH_SIZE = 100;
583
+ /** Upper bound for `--max-subcalls`: a fixed request budget, cache independent. */
584
+ export const MAX_SUB_CALLS = 1000;
585
+ /** Chunk by a per-item sub-call cost so a request budget, not a count, is fixed. */
586
+ function chunkByCost(items, maxSubCalls, cost) {
587
+ const chunks = [];
588
+ let current = [];
589
+ let used = 0;
590
+ for (const item of items) {
591
+ if (current.length > 0 && used + cost > maxSubCalls) {
592
+ chunks.push(current);
593
+ current = [];
594
+ used = 0;
595
+ }
596
+ current.push(item);
597
+ used += cost;
598
+ }
599
+ if (current.length > 0)
600
+ chunks.push(current);
601
+ return chunks;
602
+ }
603
+ function chunkTokens(items, size) {
604
+ if (!Number.isInteger(size) || size < 1) {
605
+ throw new RangeError(`batch size must be a positive integer, got ${size}.`);
606
+ }
607
+ const chunks = [];
608
+ for (let index = 0; index < items.length; index += size) {
609
+ chunks.push(items.slice(index, index + size));
610
+ }
611
+ return chunks;
612
+ }
613
+ /** Sub-calls one token needs: cached metadata skips decimals and symbol. */
614
+ function tokenSubCalls(token, cache) {
615
+ return cache?.get(token) === null ? 4 : cache ? 2 : 4;
616
+ }
617
+ /**
618
+ * Fill each request up to a fixed sub-call budget instead of a token count, so
619
+ * the cap does not grow when metadata is cached. The first request also carries
620
+ * the native balance and starts with one sub-call spent; a single token always
621
+ * gets a request of its own.
622
+ */
623
+ function chunkBySubCalls(tokens, maxSubCalls, cache) {
624
+ const chunks = [];
625
+ let current = [];
626
+ let used = 1;
627
+ for (const token of tokens) {
628
+ const cost = tokenSubCalls(token, cache);
629
+ if (current.length > 0 && used + cost > maxSubCalls) {
630
+ chunks.push(current);
631
+ current = [];
632
+ used = 0;
633
+ }
634
+ current.push(token);
635
+ used += cost;
636
+ }
637
+ if (current.length > 0)
638
+ chunks.push(current);
639
+ return chunks;
640
+ }
641
+ function batchErrorMessage(error) {
642
+ if (error instanceof Error) {
643
+ const short = error.shortMessage;
644
+ if (typeof short === "string" && short.length > 0)
645
+ return short;
646
+ return error.message;
647
+ }
648
+ return String(error);
649
+ }
650
+ export async function readOwnerBatch(client, owner, tokens, includeNative = true, cache) {
651
+ const calls = [
652
+ ...(includeNative
653
+ ? [
654
+ {
655
+ target: MULTICALL3_ADDRESS,
656
+ allowFailure: true,
657
+ callData: encodeFunctionData({
658
+ abi: multicall3BatchAbi,
659
+ functionName: "getEthBalance",
660
+ args: [owner],
661
+ }),
662
+ },
663
+ ]
664
+ : []),
665
+ ];
666
+ // Cached tokens contribute only their balanceOf and totalSupply sub-calls,
667
+ // which are the parts that actually change between reads.
668
+ const slots = [];
669
+ for (const token of tokens) {
670
+ const cached = cache?.get(token) ?? null;
671
+ let decimalsIndex = null;
672
+ let symbolIndex = null;
673
+ if (!cached) {
674
+ decimalsIndex = calls.length;
675
+ calls.push({
676
+ target: token,
677
+ allowFailure: true,
678
+ callData: encodeFunctionData({ abi: erc20Abi, functionName: "decimals" }),
679
+ });
680
+ symbolIndex = calls.length;
681
+ calls.push({
682
+ target: token,
683
+ allowFailure: true,
684
+ callData: encodeFunctionData({ abi: erc20Abi, functionName: "symbol" }),
685
+ });
686
+ }
687
+ const balanceIndex = calls.length;
688
+ calls.push({
689
+ target: token,
690
+ allowFailure: true,
691
+ callData: encodeFunctionData({
692
+ abi: erc20Abi,
693
+ functionName: "balanceOf",
694
+ args: [owner],
695
+ }),
696
+ });
697
+ const supplyIndex = calls.length;
698
+ calls.push({
699
+ target: token,
700
+ allowFailure: true,
701
+ callData: encodeFunctionData({ abi: erc20Abi, functionName: "totalSupply" }),
702
+ });
703
+ slots.push({ token, cached, decimalsIndex, symbolIndex, balanceIndex, supplyIndex });
704
+ }
705
+ let results;
706
+ try {
707
+ results = await readTyped(client, { address: MULTICALL3_ADDRESS, abi: multicall3BatchAbi }, "aggregate3", calls);
708
+ }
709
+ catch (error) {
710
+ return { ok: false, error: batchErrorMessage(error) };
711
+ }
712
+ const metadataFound = new Map();
713
+ const tokenBalances = slots.map((slot) => {
714
+ const decimalsResult = slot.cached
715
+ ? {
716
+ success: true,
717
+ returnData: encodeFunctionResult({
718
+ abi: erc20Abi,
719
+ functionName: "decimals",
720
+ result: slot.cached.decimals,
721
+ }),
722
+ }
723
+ : results[slot.decimalsIndex];
724
+ const symbolResult = slot.cached
725
+ ? {
726
+ success: true,
727
+ returnData: encodeFunctionResult({
728
+ abi: erc20Abi,
729
+ functionName: "symbol",
730
+ result: slot.cached.symbol,
731
+ }),
732
+ }
733
+ : results[slot.symbolIndex];
734
+ const reading = inspectToken(decimalsResult, symbolResult, results[slot.balanceIndex], results[slot.supplyIndex]);
735
+ if (reading.decimals !== null && reading.symbol !== null && reading.symbol.trim().length > 0) {
736
+ metadataFound.set(slot.token.toLowerCase(), {
737
+ symbol: reading.symbol.trim(),
738
+ decimals: reading.decimals,
739
+ });
740
+ }
741
+ return {
742
+ address: slot.token,
743
+ symbol: reading.symbol && reading.symbol.trim().length > 0
744
+ ? reading.symbol.trim()
745
+ : shortAddress(slot.token),
746
+ label: null,
747
+ decimals: reading.decimals,
748
+ balance: reading.balance,
749
+ cacheAgeMs: null,
750
+ issue: reading.issue,
751
+ };
752
+ });
753
+ return {
754
+ ok: true,
755
+ native: includeNative ? decodeNativeBalance(results[0]) : null,
756
+ tokens: tokenBalances,
757
+ metadata: metadataFound,
758
+ };
759
+ }
760
+ /**
761
+ * Read `decimals`/`symbol` once for every token missing from the cache. `scan`
762
+ * calls this before fanning out to owners so concurrent owners do not each
763
+ * re-read the same metadata.
764
+ */
765
+ export async function prefetchMetadata(client, tokens, cache, batchSize = TOKENS_PER_BATCH, maxSubCalls) {
766
+ const missing = tokens.filter((token) => !isPrecompileAddress(token) && cache.get(token) === null);
767
+ if (missing.length === 0)
768
+ return 0;
769
+ // Prefetch only reads missing metadata: decimals plus symbol, two per token.
770
+ const perBatch = maxSubCalls === undefined ? batchSize : Math.max(1, Math.floor(maxSubCalls / 2));
771
+ await Promise.all(chunkTokens(missing, perBatch).map(async (batch) => {
772
+ const calls = batch.flatMap((token) => [
773
+ {
774
+ target: token,
775
+ allowFailure: true,
776
+ callData: encodeFunctionData({ abi: erc20Abi, functionName: "decimals" }),
777
+ },
778
+ {
779
+ target: token,
780
+ allowFailure: true,
781
+ callData: encodeFunctionData({ abi: erc20Abi, functionName: "symbol" }),
782
+ },
783
+ ]);
784
+ try {
785
+ const results = await readTyped(client, { address: MULTICALL3_ADDRESS, abi: multicall3BatchAbi }, "aggregate3", calls);
786
+ batch.forEach((token, index) => {
787
+ const decimals = decodeDecimals(results[index * 2]);
788
+ const symbol = decodeSymbol(results[index * 2 + 1]);
789
+ if (decimals !== null && symbol !== null && symbol.trim().length > 0) {
790
+ cache.set(token, { symbol: symbol.trim(), decimals });
791
+ }
792
+ });
793
+ }
794
+ catch {
795
+ // Prefetch is an optimization; per-owner reads still fill the cache.
796
+ }
797
+ }));
798
+ return missing.length;
799
+ }
800
+ /** Count unique ERC-20 metadata lookups the cache can answer. */
801
+ export function countCacheStats(tokens, cache) {
802
+ if (!cache)
803
+ return { hits: 0, misses: 0 };
804
+ let hits = 0;
805
+ let misses = 0;
806
+ const seen = new Set();
807
+ for (const token of tokens) {
808
+ const key = token.toLowerCase();
809
+ if (seen.has(key) || isPrecompileAddress(token))
810
+ continue;
811
+ seen.add(key);
812
+ if (cache.get(token) === null)
813
+ misses += 1;
814
+ else
815
+ hits += 1;
816
+ }
817
+ return { hits, misses };
818
+ }
819
+ /**
820
+ * Classify a token from decimals, symbol and totalSupply alone, without an
821
+ * owner. Mirrors `inspectToken`, minus the balance checks.
822
+ */
823
+ function inspectTokenMetadata(decimalsResult, symbolResult, supplyResult) {
824
+ const decimals = decodeDecimals(decimalsResult);
825
+ const symbol = decodeSymbol(symbolResult);
826
+ const totalSupply = decodeTotalSupply(supplyResult);
827
+ const results = [decimalsResult, symbolResult, supplyResult];
828
+ const allEmpty = results.every((result) => result?.success === true && result.returnData === EMPTY_DATA);
829
+ const answered = results.filter((result) => result?.success === true).length;
830
+ let issue = null;
831
+ if (allEmpty) {
832
+ issue = "no contract code (every call returned no data)";
833
+ }
834
+ else if (answered === 0) {
835
+ issue = "reverted on decimals, symbol and totalSupply (not an ERC-20)";
836
+ }
837
+ else {
838
+ const problems = [];
839
+ if (decimals === null) {
840
+ problems.push(decimalsResult?.success ? "decimals is not a uint8" : "decimals reverted");
841
+ }
842
+ else if (decimals > 36) {
843
+ problems.push(`implausible decimals (${decimals})`);
844
+ }
845
+ if (symbol === null) {
846
+ const text = symbolResult?.success ? bytes32Text(symbolResult.returnData) : null;
847
+ if (text)
848
+ problems.push(`symbol is bytes32 (${text}), not a string`);
849
+ else if (symbolResult?.success)
850
+ problems.push("symbol is neither a string nor bytes32 text");
851
+ else
852
+ problems.push("symbol reverted");
853
+ }
854
+ else if (symbol.trim().length === 0) {
855
+ problems.push("symbol is empty");
856
+ }
857
+ if (totalSupply === null) {
858
+ problems.push(supplyResult?.success ? "totalSupply is not a uint256" : "totalSupply reverted");
859
+ }
860
+ if (problems.length > 0)
861
+ issue = problems.join("; ");
862
+ }
863
+ return {
864
+ decimals,
865
+ symbol: symbol !== null && symbol.trim().length > 0 ? symbol.trim() : null,
866
+ issue,
867
+ };
868
+ }
869
+ /** Wrap decoded individual reads back into sub-call results for the classifier. */
870
+ function decodedResults(values) {
871
+ const decimals = values.decimals === null
872
+ ? undefined
873
+ : {
874
+ success: true,
875
+ returnData: encodeFunctionResult({
876
+ abi: erc20Abi,
877
+ functionName: "decimals",
878
+ result: values.decimals,
879
+ }),
880
+ };
881
+ const symbol = values.symbol === null
882
+ ? undefined
883
+ : {
884
+ success: true,
885
+ returnData: encodeFunctionResult({
886
+ abi: erc20Abi,
887
+ functionName: "symbol",
888
+ result: values.symbol,
889
+ }),
890
+ };
891
+ const supply = values.supply === null
892
+ ? undefined
893
+ : {
894
+ success: true,
895
+ returnData: encodeFunctionResult({
896
+ abi: erc20Abi,
897
+ functionName: "totalSupply",
898
+ result: values.supply,
899
+ }),
900
+ };
901
+ return [decimals, symbol, supply];
902
+ }
903
+ /**
904
+ * Read decimals, symbol and totalSupply for each address and classify it, so
905
+ * `tokens` can warn about entries that are not readable ERC-20s before saving
906
+ * them. Nothing here throws for a bad address: reverts, empty answers and batch
907
+ * failures all become an `issue`. The report also carries the number of
908
+ * Multicall3 verification requests and the cap that shaped them.
909
+ */
910
+ export async function checkTokenAddressesReport(client, tokens, options = {}) {
911
+ const batchSize = options.batchSize ?? TOKENS_PER_BATCH;
912
+ const checks = new Map();
913
+ const record = (token, decimalsResult, symbolResult, supplyResult) => {
914
+ const reading = inspectTokenMetadata(decimalsResult, symbolResult, supplyResult);
915
+ checks.set(token.toLowerCase(), { address: token, ...reading });
916
+ };
917
+ const checkable = tokens.filter((token) => !isPrecompileAddress(token));
918
+ const batches = options.maxSubCalls === undefined
919
+ ? chunkTokens(checkable, batchSize)
920
+ : chunkByCost(checkable, options.maxSubCalls, 3);
921
+ await Promise.all(batches.map(async (batch) => {
922
+ const calls = batch.flatMap((token) => [
923
+ {
924
+ target: token,
925
+ allowFailure: true,
926
+ callData: encodeFunctionData({ abi: erc20Abi, functionName: "decimals" }),
927
+ },
928
+ {
929
+ target: token,
930
+ allowFailure: true,
931
+ callData: encodeFunctionData({ abi: erc20Abi, functionName: "symbol" }),
932
+ },
933
+ {
934
+ target: token,
935
+ allowFailure: true,
936
+ callData: encodeFunctionData({ abi: erc20Abi, functionName: "totalSupply" }),
937
+ },
938
+ ]);
939
+ try {
940
+ const results = await readTyped(client, { address: MULTICALL3_ADDRESS, abi: multicall3BatchAbi }, "aggregate3", calls);
941
+ batch.forEach((token, index) => {
942
+ record(token, results[index * 3], results[index * 3 + 1], results[index * 3 + 2]);
943
+ });
944
+ return;
945
+ }
946
+ catch {
947
+ // A failed aggregate (gas-heavy address, strict RPC) falls through to
948
+ // individual reads so one bad entry does not flag its whole batch.
949
+ }
950
+ await Promise.all(batch.map(async (token) => {
951
+ const [decimals, symbol, supply] = await Promise.all([
952
+ attempt(() => client.readContract({ address: token, abi: erc20Abi, functionName: "decimals" })),
953
+ attempt(() => client.readContract({ address: token, abi: erc20Abi, functionName: "symbol" })),
954
+ attempt(() => client.readContract({ address: token, abi: erc20Abi, functionName: "totalSupply" })),
955
+ ]);
956
+ const [decimalsResult, symbolResult, supplyResult] = decodedResults({
957
+ decimals: typeof decimals === "number" ? decimals : null,
958
+ symbol: typeof symbol === "string" ? symbol : null,
959
+ supply: typeof supply === "bigint" ? supply : null,
960
+ });
961
+ record(token, decimalsResult, symbolResult, supplyResult);
962
+ }));
963
+ }));
964
+ const classified = tokens.map((token) => checks.get(token.toLowerCase()) ?? {
965
+ address: token,
966
+ symbol: null,
967
+ decimals: null,
968
+ issue: isPrecompileAddress(token)
969
+ ? "precompile address: no code, cannot be an ERC-20"
970
+ : "no data returned for this address",
971
+ });
972
+ return {
973
+ checks: classified,
974
+ chunkCount: batches.length,
975
+ maxSubCalls: options.maxSubCalls ?? null,
976
+ };
977
+ }
978
+ /** The classified checks only, for callers that do not need the request count. */
979
+ export async function checkTokenAddresses(client, tokens, options = {}) {
980
+ return (await checkTokenAddressesReport(client, tokens, options)).checks;
981
+ }
982
+ export async function readBalances(client, config, owner, tokens, options = {}) {
983
+ if (options.cache && options.refreshCache) {
984
+ for (const token of tokens)
985
+ options.cache.invalidate(token);
986
+ }
987
+ if (options.cache && options.maxCacheAgeMs !== undefined) {
988
+ options.cache.invalidateStale(options.maxCacheAgeMs);
989
+ }
990
+ const cacheStats = countCacheStats(tokens, options.cache);
991
+ // Ages are captured before the read: tokens fetched in this run have no
992
+ // pre-existing entry, so they are not misreported as freshly cached.
993
+ const cacheAges = new Map();
994
+ if (options.cache) {
995
+ for (const token of tokens) {
996
+ const age = options.cache.ageMs(token);
997
+ if (age !== null)
998
+ cacheAges.set(token.toLowerCase(), age);
999
+ }
1000
+ }
1001
+ const blockNumberPromise = client.getBlockNumber();
1002
+ const batchable = tokens.filter((token) => !isPrecompileAddress(token));
1003
+ const batchSize = options.batchSize ??
1004
+ (options.cache && cacheStats.hits > 0 && cacheStats.misses === 0
1005
+ ? CACHED_TOKENS_PER_BATCH
1006
+ : TOKENS_PER_BATCH);
1007
+ const batches = options.maxSubCalls === undefined
1008
+ ? chunkTokens(batchable, batchSize)
1009
+ : chunkBySubCalls(batchable, options.maxSubCalls, options.cache);
1010
+ const chunkReports = batches.map((batch, index) => ({
1011
+ index: index + 1,
1012
+ total: batches.length,
1013
+ tokens: batch.length,
1014
+ status: "batched",
1015
+ error: null,
1016
+ individual: [],
1017
+ }));
1018
+ // Chunks go out together: a batch that reverts costs only its own tokens.
1019
+ const outcomes = await Promise.all(batches.map((batch, index) => readOwnerBatch(client, owner, batch, index === 0, options.cache)));
1020
+ const discovered = new Map();
1021
+ const byAddress = new Map();
1022
+ const warnings = [];
1023
+ let nativeBalance = null;
1024
+ const ingest = (outcome) => {
1025
+ for (const token of outcome.tokens) {
1026
+ byAddress.set(token.address.toLowerCase(), token);
1027
+ }
1028
+ for (const [address, metadata] of outcome.metadata) {
1029
+ discovered.set(address, metadata);
1030
+ }
1031
+ if (outcome.native !== null)
1032
+ nativeBalance = outcome.native;
1033
+ };
1034
+ const failed = [];
1035
+ outcomes.forEach((outcome, index) => {
1036
+ if (outcome.ok) {
1037
+ ingest(outcome);
1038
+ return;
1039
+ }
1040
+ failed.push({ index, error: outcome.error });
1041
+ const report = chunkReports[index];
1042
+ if (report) {
1043
+ report.status = "failed";
1044
+ report.error = outcome.error;
1045
+ }
1046
+ });
1047
+ // One retry per failed chunk, split in half: a gas-heavy aggregate often
1048
+ // succeeds once its sub-call count is halved. There is no second retry; any
1049
+ // tokens still missing after this go through individual reads below.
1050
+ const retries = [];
1051
+ for (const failure of failed) {
1052
+ const batch = batches[failure.index] ?? [];
1053
+ if (batch.length < 2)
1054
+ continue;
1055
+ const half = Math.ceil(batch.length / 2);
1056
+ retries.push({ batch: batch.slice(0, half), includeNative: failure.index === 0, chunkIndex: failure.index }, { batch: batch.slice(half), includeNative: false, chunkIndex: failure.index });
1057
+ }
1058
+ const retryOutcomes = await Promise.all(retries.map((retry) => readOwnerBatch(client, owner, retry.batch, retry.includeNative, options.cache)));
1059
+ const retryAttempts = new Map();
1060
+ const retrySuccesses = new Map();
1061
+ retryOutcomes.forEach((outcome, index) => {
1062
+ const retry = retries[index];
1063
+ if (!retry)
1064
+ return;
1065
+ retryAttempts.set(retry.chunkIndex, (retryAttempts.get(retry.chunkIndex) ?? 0) + 1);
1066
+ if (!outcome.ok)
1067
+ return;
1068
+ retrySuccesses.set(retry.chunkIndex, (retrySuccesses.get(retry.chunkIndex) ?? 0) + 1);
1069
+ ingest(outcome);
1070
+ });
1071
+ for (const report of chunkReports) {
1072
+ if (report.status !== "failed")
1073
+ continue;
1074
+ const attempts = retryAttempts.get(report.index - 1) ?? 0;
1075
+ const successes = retrySuccesses.get(report.index - 1) ?? 0;
1076
+ if (attempts > 0 && successes === attempts)
1077
+ report.status = "recovered";
1078
+ else if (successes > 0)
1079
+ report.status = "partial";
1080
+ }
1081
+ // Re-read only the tokens still missing after the retry; the rest keep their
1082
+ // batched results. Record exactly which chunk each fallback came from.
1083
+ const missing = batchable.filter((token) => !byAddress.has(token.toLowerCase()));
1084
+ const chunkOf = new Map();
1085
+ batches.forEach((batch, index) => {
1086
+ const report = chunkReports[index];
1087
+ if (!report)
1088
+ return;
1089
+ for (const token of batch)
1090
+ chunkOf.set(token.toLowerCase(), report);
1091
+ });
1092
+ for (const token of missing) {
1093
+ chunkOf.get(token.toLowerCase())?.individual.push(token);
1094
+ }
1095
+ // Warn once per original chunk that is still incomplete. A chunk fully
1096
+ // recovered by its retry is not reported as a failure at all.
1097
+ for (const failure of failed) {
1098
+ const report = chunkReports[failure.index];
1099
+ if (!report || report.individual.length === 0)
1100
+ continue;
1101
+ warnings.push(batches.length > 1
1102
+ ? `batch ${failure.index + 1}/${batches.length}: ${failure.error}`
1103
+ : failure.error);
1104
+ }
1105
+ for (const report of chunkReports) {
1106
+ options.onChunkProgress?.({ owner, ...report });
1107
+ }
1108
+ const recovered = await Promise.all(missing.map((token) => readTokenIndividually(client, token, owner)));
1109
+ for (const entry of recovered) {
1110
+ byAddress.set(entry.result.address.toLowerCase(), entry.result);
1111
+ if (entry.metadata)
1112
+ discovered.set(entry.result.address.toLowerCase(), entry.metadata);
1113
+ }
1114
+ if (nativeBalance === null) {
1115
+ nativeBalance = await client.getBalance({ address: owner });
1116
+ }
1117
+ const tokenBalances = tokens.map((token) => {
1118
+ const label = options.labels?.get(token.toLowerCase()) ?? null;
1119
+ const base = isPrecompileAddress(token)
1120
+ ? unreadableToken(token, "precompile address: no code, cannot be an ERC-20")
1121
+ : (byAddress.get(token.toLowerCase()) ?? unreadableToken(token));
1122
+ return { ...base, label, cacheAgeMs: cacheAges.get(token.toLowerCase()) ?? null };
1123
+ });
1124
+ if (options.cache) {
1125
+ for (const [address, metadata] of discovered)
1126
+ options.cache.set(address, metadata);
1127
+ }
1128
+ const batchWarning = warnings.length > 0 ? warnings.join("; ") : null;
1129
+ return {
1130
+ owner,
1131
+ chainId: config.chainId,
1132
+ chainName: config.name,
1133
+ native: {
1134
+ symbol: config.nativeCurrency.symbol,
1135
+ decimals: config.nativeCurrency.decimals,
1136
+ balance: nativeBalance,
1137
+ },
1138
+ tokens: tokenBalances,
1139
+ blockNumber: await blockNumberPromise,
1140
+ batchWarning,
1141
+ batchSize,
1142
+ chunkCount: chunkReports.length,
1143
+ chunks: chunkReports,
1144
+ recovery: {
1145
+ failedChunks: failed.length,
1146
+ retryRequests: retries.length,
1147
+ recoveredChunks: chunkReports.filter((report) => report.status === "recovered").length,
1148
+ },
1149
+ cache: cacheStats,
1150
+ };
1151
+ }
1152
+ function describeToken(token) {
1153
+ const tag = token.label ? ` [${token.label}]` : "";
1154
+ const base = token.balance === null
1155
+ ? `unreadable (${token.address})${tag}`
1156
+ : token.decimals === null
1157
+ ? `${token.balance} raw (${token.address})${tag}`
1158
+ : `${formatTokenAmount(token.balance, token.decimals)} ${token.symbol}${tag} (${token.address})`;
1159
+ const cached = token.cacheAgeMs == null ? "" : ` [cached ${formatCacheAge(token.cacheAgeMs)}]`;
1160
+ return token.issue ? `${base}${cached} — ${token.issue}` : `${base}${cached}`;
1161
+ }
1162
+ export function formatBalances(report) {
1163
+ const lines = [
1164
+ "lwc — balances",
1165
+ "",
1166
+ ` owner ${report.owner}`,
1167
+ ` chain ${report.chainName} (${report.chainId})`,
1168
+ ` block ${report.blockNumber}`,
1169
+ "",
1170
+ ` native ${formatTokenAmount(report.native.balance, report.native.decimals)} ${report.native.symbol}`,
1171
+ ];
1172
+ if (report.tokens.length === 0) {
1173
+ lines.push(" tokens none requested (pass --token 0x… to include ERC-20s)");
1174
+ }
1175
+ else {
1176
+ for (const token of report.tokens) {
1177
+ lines.push(` token ${describeToken(token)}`);
1178
+ }
1179
+ }
1180
+ const flagged = report.tokens.filter((token) => token.issue !== null);
1181
+ if (flagged.length > 0) {
1182
+ lines.push("");
1183
+ lines.push(` flagged ${flagged.length} of ${report.tokens.length} ${flagged.length === 1 ? "address does" : "addresses do"} not look like a working ERC-20`);
1184
+ }
1185
+ if (report.recovery.failedChunks > 0) {
1186
+ const failed = report.recovery.failedChunks;
1187
+ const retries = report.recovery.retryRequests;
1188
+ const recovered = report.recovery.recoveredChunks;
1189
+ lines.push("");
1190
+ lines.push(` recovery ${failed} failed chunk${failed === 1 ? "" : "s"}, ${retries} retry request${retries === 1 ? "" : "s"}, ${recovered} recovered`);
1191
+ }
1192
+ if (report.batchWarning) {
1193
+ lines.push("");
1194
+ lines.push(` batch unavailable, read individually: ${report.batchWarning}`);
1195
+ }
1196
+ return lines.join("\n");
1197
+ }
1198
+ export function toBalancesJson(report) {
1199
+ return {
1200
+ ok: true,
1201
+ chain: { id: report.chainId, name: report.chainName },
1202
+ blockNumber: report.blockNumber.toString(),
1203
+ owner: report.owner,
1204
+ batchWarning: report.batchWarning,
1205
+ batchSize: report.batchSize,
1206
+ chunkCount: report.chunkCount,
1207
+ recovery: {
1208
+ failedChunks: report.recovery.failedChunks,
1209
+ retryRequests: report.recovery.retryRequests,
1210
+ recoveredChunks: report.recovery.recoveredChunks,
1211
+ },
1212
+ cache: { hits: report.cache.hits, misses: report.cache.misses },
1213
+ native: {
1214
+ symbol: report.native.symbol,
1215
+ decimals: report.native.decimals,
1216
+ balance: report.native.balance.toString(),
1217
+ formatted: formatTokenAmount(report.native.balance, report.native.decimals),
1218
+ },
1219
+ tokens: report.tokens.map((token) => ({
1220
+ address: token.address,
1221
+ symbol: token.symbol,
1222
+ label: token.label,
1223
+ decimals: token.decimals,
1224
+ issue: token.issue,
1225
+ cacheAgeMs: token.cacheAgeMs,
1226
+ balance: token.balance === null ? null : token.balance.toString(),
1227
+ formatted: token.balance === null || token.decimals === null
1228
+ ? null
1229
+ : formatTokenAmount(token.balance, token.decimals),
1230
+ })),
1231
+ };
1232
+ }