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.
- package/.env.example +38 -0
- package/LICENSE +21 -0
- package/README.md +1937 -2
- package/dist/alert-queue.d.ts +72 -0
- package/dist/alert-queue.js +155 -0
- package/dist/alert-store.d.ts +29 -0
- package/dist/alert-store.js +75 -0
- package/dist/balances.d.ts +221 -0
- package/dist/balances.js +1232 -0
- package/dist/cache.d.ts +37 -0
- package/dist/cache.js +128 -0
- package/dist/call.d.ts +46 -0
- package/dist/call.js +377 -0
- package/dist/chain.d.ts +97 -0
- package/dist/chain.js +419 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1011 -0
- package/dist/compare-history.d.ts +82 -0
- package/dist/compare-history.js +340 -0
- package/dist/compare.d.ts +98 -0
- package/dist/compare.js +492 -0
- package/dist/contract.d.ts +107 -0
- package/dist/contract.js +162 -0
- package/dist/default-abi.d.ts +53 -0
- package/dist/default-abi.js +85 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +422 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +27 -0
- package/dist/market-page.d.ts +8 -0
- package/dist/market-page.js +554 -0
- package/dist/market-server.d.ts +46 -0
- package/dist/market-server.js +473 -0
- package/dist/market.d.ts +323 -0
- package/dist/market.js +835 -0
- package/dist/metadata-cache.d.ts +72 -0
- package/dist/metadata-cache.js +197 -0
- package/dist/metrics.d.ts +64 -0
- package/dist/metrics.js +138 -0
- package/dist/plot.d.ts +60 -0
- package/dist/plot.js +317 -0
- package/dist/presets-history.d.ts +372 -0
- package/dist/presets-history.js +1243 -0
- package/dist/presets-summary.d.ts +17 -0
- package/dist/presets-summary.js +55 -0
- package/dist/presets.d.ts +59 -0
- package/dist/presets.js +181 -0
- package/dist/prices.d.ts +63 -0
- package/dist/prices.js +154 -0
- package/dist/rotate.d.ts +19 -0
- package/dist/rotate.js +34 -0
- package/dist/scan.d.ts +81 -0
- package/dist/scan.js +501 -0
- package/dist/sessions.d.ts +104 -0
- package/dist/sessions.js +515 -0
- package/dist/signatures.d.ts +13 -0
- package/dist/signatures.js +43 -0
- package/dist/tokenfile.d.ts +50 -0
- package/dist/tokenfile.js +218 -0
- package/dist/views.d.ts +63 -0
- package/dist/views.js +415 -0
- package/dist/watch.d.ts +306 -0
- package/dist/watch.js +1121 -0
- package/dist/webhook.d.ts +17 -0
- package/dist/webhook.js +35 -0
- package/examples/erc20.abi.json +57 -0
- package/examples/xlayer.tokens.json +4 -0
- package/package.json +77 -4
package/dist/scan.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { getAddress, isAddress } from "viem";
|
|
2
|
+
import { parseCacheAge } from "./metadata-cache.js";
|
|
3
|
+
import { countCacheStats, formatTokenAmount, loadAddressList, loadTokenList, MAX_SUB_CALLS, formatCacheAge, mergeTokenLabels, parseTokenSpec, prefetchMetadata, readBalances, rememberTokenLabel, shortAddress, TOKENS_PER_BATCH, } from "./balances.js";
|
|
4
|
+
export class ScanArgError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ScanArgError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** How many owners are queried at once. */
|
|
11
|
+
const SCAN_CONCURRENCY = 4;
|
|
12
|
+
const MAX_BATCH_SIZE = 100;
|
|
13
|
+
function parseBatchSize(raw) {
|
|
14
|
+
if (!/^\d+$/.test(raw)) {
|
|
15
|
+
throw new ScanArgError(`--batch-size must be a whole number, got "${raw}".`);
|
|
16
|
+
}
|
|
17
|
+
const value = Number(raw);
|
|
18
|
+
if (value < 1 || value > MAX_BATCH_SIZE) {
|
|
19
|
+
throw new ScanArgError(`--batch-size must be between 1 and ${MAX_BATCH_SIZE}.`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function parseMaxCacheAge(raw) {
|
|
24
|
+
const parsed = parseCacheAge(raw);
|
|
25
|
+
if (!parsed.ok)
|
|
26
|
+
throw new ScanArgError(parsed.message);
|
|
27
|
+
return parsed.ms;
|
|
28
|
+
}
|
|
29
|
+
function parseStaleAfter(raw) {
|
|
30
|
+
const parsed = parseCacheAge(raw, "--stale-after");
|
|
31
|
+
if (!parsed.ok)
|
|
32
|
+
throw new ScanArgError(parsed.message);
|
|
33
|
+
return parsed.ms;
|
|
34
|
+
}
|
|
35
|
+
function parseMaxSubCalls(raw) {
|
|
36
|
+
if (!/^\d+$/.test(raw)) {
|
|
37
|
+
throw new ScanArgError(`--max-subcalls must be a whole number, got "${raw}".`);
|
|
38
|
+
}
|
|
39
|
+
const value = Number(raw);
|
|
40
|
+
if (value < 1 || value > MAX_SUB_CALLS) {
|
|
41
|
+
throw new ScanArgError(`--max-subcalls must be between 1 and ${MAX_SUB_CALLS}.`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
export function parseScanArgs(argv) {
|
|
46
|
+
const owners = [];
|
|
47
|
+
const rawTokens = [];
|
|
48
|
+
const tokenFiles = [];
|
|
49
|
+
const ownerFiles = [];
|
|
50
|
+
let csvPath = null;
|
|
51
|
+
let batchSize = TOKENS_PER_BATCH;
|
|
52
|
+
let maxSubCalls = null;
|
|
53
|
+
let sawBatchSize = false;
|
|
54
|
+
let failOnRetry = false;
|
|
55
|
+
let quiet = false;
|
|
56
|
+
let maxCacheAgeMs = null;
|
|
57
|
+
let staleAfterMs = null;
|
|
58
|
+
const addOwner = (raw) => {
|
|
59
|
+
const candidate = raw.trim();
|
|
60
|
+
if (!candidate)
|
|
61
|
+
return;
|
|
62
|
+
if (!isAddress(candidate)) {
|
|
63
|
+
throw new ScanArgError(`"${candidate}" is not a valid owner address.`);
|
|
64
|
+
}
|
|
65
|
+
owners.push(getAddress(candidate));
|
|
66
|
+
};
|
|
67
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
68
|
+
const arg = argv[index] ?? "";
|
|
69
|
+
if (arg === "--owner") {
|
|
70
|
+
const value = argv[index + 1];
|
|
71
|
+
if (!value)
|
|
72
|
+
throw new ScanArgError("--owner requires an address.");
|
|
73
|
+
addOwner(value);
|
|
74
|
+
index += 1;
|
|
75
|
+
}
|
|
76
|
+
else if (arg.startsWith("--owner=")) {
|
|
77
|
+
addOwner(arg.slice("--owner=".length));
|
|
78
|
+
}
|
|
79
|
+
else if (arg === "--owners-file") {
|
|
80
|
+
const value = argv[index + 1];
|
|
81
|
+
if (!value)
|
|
82
|
+
throw new ScanArgError("--owners-file requires a path.");
|
|
83
|
+
ownerFiles.push(value);
|
|
84
|
+
index += 1;
|
|
85
|
+
}
|
|
86
|
+
else if (arg.startsWith("--owners-file=")) {
|
|
87
|
+
const value = arg.slice("--owners-file=".length);
|
|
88
|
+
if (!value)
|
|
89
|
+
throw new ScanArgError("--owners-file requires a path.");
|
|
90
|
+
ownerFiles.push(value);
|
|
91
|
+
}
|
|
92
|
+
else if (arg === "--token") {
|
|
93
|
+
const value = argv[index + 1];
|
|
94
|
+
if (!value)
|
|
95
|
+
throw new ScanArgError("--token requires an address.");
|
|
96
|
+
rawTokens.push(value);
|
|
97
|
+
index += 1;
|
|
98
|
+
}
|
|
99
|
+
else if (arg.startsWith("--token=")) {
|
|
100
|
+
rawTokens.push(arg.slice("--token=".length));
|
|
101
|
+
}
|
|
102
|
+
else if (arg === "--tokens") {
|
|
103
|
+
const value = argv[index + 1];
|
|
104
|
+
if (!value)
|
|
105
|
+
throw new ScanArgError("--tokens requires a comma-separated list.");
|
|
106
|
+
rawTokens.push(...value.split(","));
|
|
107
|
+
index += 1;
|
|
108
|
+
}
|
|
109
|
+
else if (arg.startsWith("--tokens=")) {
|
|
110
|
+
rawTokens.push(...arg.slice("--tokens=".length).split(","));
|
|
111
|
+
}
|
|
112
|
+
else if (arg === "--tokens-file") {
|
|
113
|
+
const value = argv[index + 1];
|
|
114
|
+
if (!value)
|
|
115
|
+
throw new ScanArgError("--tokens-file requires a path.");
|
|
116
|
+
tokenFiles.push(value);
|
|
117
|
+
index += 1;
|
|
118
|
+
}
|
|
119
|
+
else if (arg.startsWith("--tokens-file=")) {
|
|
120
|
+
const value = arg.slice("--tokens-file=".length);
|
|
121
|
+
if (!value)
|
|
122
|
+
throw new ScanArgError("--tokens-file requires a path.");
|
|
123
|
+
tokenFiles.push(value);
|
|
124
|
+
}
|
|
125
|
+
else if (arg === "--batch-size") {
|
|
126
|
+
const value = argv[index + 1];
|
|
127
|
+
if (!value)
|
|
128
|
+
throw new ScanArgError("--batch-size requires a value.");
|
|
129
|
+
batchSize = parseBatchSize(value);
|
|
130
|
+
sawBatchSize = true;
|
|
131
|
+
index += 1;
|
|
132
|
+
}
|
|
133
|
+
else if (arg.startsWith("--batch-size=")) {
|
|
134
|
+
batchSize = parseBatchSize(arg.slice("--batch-size=".length));
|
|
135
|
+
sawBatchSize = true;
|
|
136
|
+
}
|
|
137
|
+
else if (arg === "--max-subcalls") {
|
|
138
|
+
const value = argv[index + 1];
|
|
139
|
+
if (!value)
|
|
140
|
+
throw new ScanArgError("--max-subcalls requires a value.");
|
|
141
|
+
maxSubCalls = parseMaxSubCalls(value);
|
|
142
|
+
index += 1;
|
|
143
|
+
}
|
|
144
|
+
else if (arg.startsWith("--max-subcalls=")) {
|
|
145
|
+
maxSubCalls = parseMaxSubCalls(arg.slice("--max-subcalls=".length));
|
|
146
|
+
}
|
|
147
|
+
else if (arg === "--fail-on-retry") {
|
|
148
|
+
failOnRetry = true;
|
|
149
|
+
}
|
|
150
|
+
else if (arg === "--quiet") {
|
|
151
|
+
quiet = true;
|
|
152
|
+
}
|
|
153
|
+
else if (arg === "--max-cache-age") {
|
|
154
|
+
const value = argv[index + 1];
|
|
155
|
+
if (!value)
|
|
156
|
+
throw new ScanArgError("--max-cache-age requires a duration.");
|
|
157
|
+
maxCacheAgeMs = parseMaxCacheAge(value);
|
|
158
|
+
index += 1;
|
|
159
|
+
}
|
|
160
|
+
else if (arg.startsWith("--max-cache-age=")) {
|
|
161
|
+
maxCacheAgeMs = parseMaxCacheAge(arg.slice("--max-cache-age=".length));
|
|
162
|
+
}
|
|
163
|
+
else if (arg === "--stale-after") {
|
|
164
|
+
const value = argv[index + 1];
|
|
165
|
+
if (!value)
|
|
166
|
+
throw new ScanArgError("--stale-after requires a duration.");
|
|
167
|
+
staleAfterMs = parseStaleAfter(value);
|
|
168
|
+
index += 1;
|
|
169
|
+
}
|
|
170
|
+
else if (arg.startsWith("--stale-after=")) {
|
|
171
|
+
staleAfterMs = parseStaleAfter(arg.slice("--stale-after=".length));
|
|
172
|
+
}
|
|
173
|
+
else if (arg === "--csv") {
|
|
174
|
+
const value = argv[index + 1];
|
|
175
|
+
if (!value)
|
|
176
|
+
throw new ScanArgError("--csv requires a path, or - for stdout.");
|
|
177
|
+
csvPath = value;
|
|
178
|
+
index += 1;
|
|
179
|
+
}
|
|
180
|
+
else if (arg.startsWith("--csv=")) {
|
|
181
|
+
const value = arg.slice("--csv=".length);
|
|
182
|
+
if (!value)
|
|
183
|
+
throw new ScanArgError("--csv requires a path, or - for stdout.");
|
|
184
|
+
csvPath = value;
|
|
185
|
+
}
|
|
186
|
+
else if (arg.startsWith("-")) {
|
|
187
|
+
// Other flags (e.g. --json) are handled by the caller.
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
addOwner(arg);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const tokens = [];
|
|
195
|
+
const warnings = [];
|
|
196
|
+
const inlineLabels = new Map();
|
|
197
|
+
const fileLabels = new Map();
|
|
198
|
+
const loadList = (fn) => {
|
|
199
|
+
try {
|
|
200
|
+
return fn();
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
throw new ScanArgError(error instanceof Error ? error.message : String(error));
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
for (const raw of rawTokens) {
|
|
207
|
+
const candidate = raw.trim();
|
|
208
|
+
if (!candidate)
|
|
209
|
+
continue;
|
|
210
|
+
const entry = loadList(() => parseTokenSpec(candidate));
|
|
211
|
+
tokens.push(entry.address);
|
|
212
|
+
rememberTokenLabel(entry, inlineLabels, warnings);
|
|
213
|
+
}
|
|
214
|
+
for (const file of tokenFiles) {
|
|
215
|
+
for (const entry of loadList(() => loadTokenList(file))) {
|
|
216
|
+
tokens.push(entry.address);
|
|
217
|
+
rememberTokenLabel(entry, fileLabels, warnings);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const labels = mergeTokenLabels(inlineLabels, fileLabels, warnings);
|
|
221
|
+
for (const file of ownerFiles) {
|
|
222
|
+
owners.push(...loadList(() => loadAddressList(file, "owner list", ["owners", "addresses"])));
|
|
223
|
+
}
|
|
224
|
+
const uniqueOwners = [...new Set(owners)];
|
|
225
|
+
if (uniqueOwners.length === 0) {
|
|
226
|
+
throw new ScanArgError("at least one owner is required: lwc scan 0xaddress… --tokens-file ./tokens.json --csv out.csv");
|
|
227
|
+
}
|
|
228
|
+
if (sawBatchSize && maxSubCalls !== null) {
|
|
229
|
+
throw new ScanArgError("use either --batch-size or --max-subcalls, not both.");
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
owners: uniqueOwners,
|
|
233
|
+
tokens: [...new Set(tokens)],
|
|
234
|
+
labels,
|
|
235
|
+
csvPath,
|
|
236
|
+
batchSize,
|
|
237
|
+
maxSubCalls,
|
|
238
|
+
failOnRetry,
|
|
239
|
+
quiet,
|
|
240
|
+
maxCacheAgeMs,
|
|
241
|
+
staleAfterMs,
|
|
242
|
+
warnings,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async function mapLimit(items, limit, fn) {
|
|
246
|
+
const results = new Array(items.length);
|
|
247
|
+
let next = 0;
|
|
248
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
249
|
+
while (next < items.length) {
|
|
250
|
+
const index = next;
|
|
251
|
+
next += 1;
|
|
252
|
+
results[index] = await fn(items[index]);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
await Promise.all(workers);
|
|
256
|
+
return results;
|
|
257
|
+
}
|
|
258
|
+
/** Prefer the first owner that resolved a token's metadata for the column header. */
|
|
259
|
+
function mergeTokens(perOwner) {
|
|
260
|
+
const first = perOwner[0] ?? [];
|
|
261
|
+
return first.map((token, index) => {
|
|
262
|
+
for (const list of perOwner) {
|
|
263
|
+
const candidate = list[index];
|
|
264
|
+
if (candidate && candidate.decimals !== null)
|
|
265
|
+
return candidate;
|
|
266
|
+
}
|
|
267
|
+
return token;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
export async function scanBalances(client, config, owners, tokens, options = {}) {
|
|
271
|
+
if (options.cache && options.refreshCache) {
|
|
272
|
+
for (const token of tokens)
|
|
273
|
+
options.cache.invalidate(token);
|
|
274
|
+
}
|
|
275
|
+
if (options.cache && options.maxCacheAgeMs !== undefined) {
|
|
276
|
+
options.cache.invalidateStale(options.maxCacheAgeMs);
|
|
277
|
+
}
|
|
278
|
+
const cacheStats = countCacheStats(tokens, options.cache);
|
|
279
|
+
if (options.cache) {
|
|
280
|
+
await prefetchMetadata(client, tokens, options.cache, options.batchSize, options.maxSubCalls);
|
|
281
|
+
}
|
|
282
|
+
// A scan refreshes metadata once above; owners must reuse the fresh entries.
|
|
283
|
+
const ownerOptions = { ...options, refreshCache: false };
|
|
284
|
+
const reports = await mapLimit(owners, SCAN_CONCURRENCY, (owner) => readBalances(client, config, owner, tokens, ownerOptions));
|
|
285
|
+
const warnings = reports
|
|
286
|
+
.filter((report) => report.batchWarning !== null)
|
|
287
|
+
.map((report) => `${shortAddress(report.owner)}: ${report.batchWarning}`);
|
|
288
|
+
const chunks = reports.flatMap((report) => report.chunks.map((chunk) => ({
|
|
289
|
+
owner: report.owner,
|
|
290
|
+
index: chunk.index,
|
|
291
|
+
total: chunk.total,
|
|
292
|
+
tokens: chunk.tokens,
|
|
293
|
+
status: chunk.status,
|
|
294
|
+
error: chunk.error,
|
|
295
|
+
individual: chunk.individual,
|
|
296
|
+
})));
|
|
297
|
+
const fallbacks = [];
|
|
298
|
+
for (const report of reports) {
|
|
299
|
+
const tokenByAddress = new Map(report.tokens.map((token) => [token.address.toLowerCase(), token]));
|
|
300
|
+
for (const chunk of report.chunks) {
|
|
301
|
+
for (const address of chunk.individual) {
|
|
302
|
+
const token = tokenByAddress.get(address.toLowerCase());
|
|
303
|
+
fallbacks.push({
|
|
304
|
+
owner: report.owner,
|
|
305
|
+
token: address,
|
|
306
|
+
symbol: token?.symbol ?? shortAddress(address),
|
|
307
|
+
label: token?.label ?? null,
|
|
308
|
+
chunk: chunk.index,
|
|
309
|
+
chunks: chunk.total,
|
|
310
|
+
error: chunk.error,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const recovery = reports.reduce((sum, report) => ({
|
|
316
|
+
failedChunks: sum.failedChunks + report.recovery.failedChunks,
|
|
317
|
+
retryRequests: sum.retryRequests + report.recovery.retryRequests,
|
|
318
|
+
recoveredChunks: sum.recoveredChunks + report.recovery.recoveredChunks,
|
|
319
|
+
}), { failedChunks: 0, retryRequests: 0, recoveredChunks: 0 });
|
|
320
|
+
return {
|
|
321
|
+
chainId: config.chainId,
|
|
322
|
+
chainName: config.name,
|
|
323
|
+
blockNumber: reports[0]?.blockNumber ?? 0n,
|
|
324
|
+
nativeSymbol: config.nativeCurrency.symbol,
|
|
325
|
+
nativeDecimals: config.nativeCurrency.decimals,
|
|
326
|
+
owners,
|
|
327
|
+
tokens: mergeTokens(reports.map((report) => report.tokens)),
|
|
328
|
+
rows: reports.map((report) => ({
|
|
329
|
+
owner: report.owner,
|
|
330
|
+
native: report.native.balance,
|
|
331
|
+
balances: report.tokens.map((token) => token.balance),
|
|
332
|
+
})),
|
|
333
|
+
warnings,
|
|
334
|
+
batchSize: reports[0]?.batchSize ?? options.batchSize ?? TOKENS_PER_BATCH,
|
|
335
|
+
chunkCount: chunks.length,
|
|
336
|
+
chunks,
|
|
337
|
+
fallbacks,
|
|
338
|
+
recovery,
|
|
339
|
+
cache: cacheStats,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function columnHeading(token) {
|
|
343
|
+
if (token.decimals === null)
|
|
344
|
+
return shortAddress(token.address);
|
|
345
|
+
return `${token.symbol} (${shortAddress(token.address)})`;
|
|
346
|
+
}
|
|
347
|
+
function columnLabel(token) {
|
|
348
|
+
const tag = token.label ? ` [${token.label}]` : "";
|
|
349
|
+
if (token.decimals === null)
|
|
350
|
+
return `${shortAddress(token.address)}${tag}`;
|
|
351
|
+
return `${token.symbol}${tag} (${shortAddress(token.address)})`;
|
|
352
|
+
}
|
|
353
|
+
function labelCell(tokens) {
|
|
354
|
+
return tokens
|
|
355
|
+
.filter((token) => token.label !== null)
|
|
356
|
+
.map((token) => `${columnHeading(token)}: ${token.label}`)
|
|
357
|
+
.join(" | ");
|
|
358
|
+
}
|
|
359
|
+
function flagCell(tokens) {
|
|
360
|
+
return tokens
|
|
361
|
+
.filter((token) => token.issue !== null)
|
|
362
|
+
.map((token) => `${columnLabel(token)}: ${token.issue}`)
|
|
363
|
+
.join(" | ");
|
|
364
|
+
}
|
|
365
|
+
export function csvCell(value) {
|
|
366
|
+
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
367
|
+
}
|
|
368
|
+
export function scanCsv(report) {
|
|
369
|
+
const header = [
|
|
370
|
+
"owner",
|
|
371
|
+
`native (${report.nativeSymbol})`,
|
|
372
|
+
...report.tokens.map(columnHeading),
|
|
373
|
+
"labels",
|
|
374
|
+
"flag",
|
|
375
|
+
];
|
|
376
|
+
const labels = labelCell(report.tokens);
|
|
377
|
+
const flags = flagCell(report.tokens);
|
|
378
|
+
const lines = [header.map(csvCell).join(",")];
|
|
379
|
+
for (const row of report.rows) {
|
|
380
|
+
const cells = [
|
|
381
|
+
row.owner,
|
|
382
|
+
formatTokenAmount(row.native, report.nativeDecimals),
|
|
383
|
+
];
|
|
384
|
+
report.tokens.forEach((token, index) => {
|
|
385
|
+
const balance = row.balances[index] ?? null;
|
|
386
|
+
cells.push(balance === null || token.decimals === null
|
|
387
|
+
? ""
|
|
388
|
+
: formatTokenAmount(balance, token.decimals));
|
|
389
|
+
});
|
|
390
|
+
cells.push(labels, flags);
|
|
391
|
+
lines.push(cells.map(csvCell).join(","));
|
|
392
|
+
}
|
|
393
|
+
return `${lines.join("\n")}\n`;
|
|
394
|
+
}
|
|
395
|
+
export function formatScan(report) {
|
|
396
|
+
const lines = [
|
|
397
|
+
"lwc — scan",
|
|
398
|
+
"",
|
|
399
|
+
` chain ${report.chainName} (${report.chainId})`,
|
|
400
|
+
` block ${report.blockNumber}`,
|
|
401
|
+
` owners ${report.owners.length}`,
|
|
402
|
+
` tokens ${report.tokens.length}`,
|
|
403
|
+
];
|
|
404
|
+
if (report.chunks.length > 0) {
|
|
405
|
+
const count = (status) => report.chunks.filter((chunk) => chunk.status === status).length;
|
|
406
|
+
const recovered = count("recovered");
|
|
407
|
+
const partial = count("partial");
|
|
408
|
+
const failed = count("failed");
|
|
409
|
+
if (recovered + partial + failed > 0) {
|
|
410
|
+
lines.push(` chunks ${report.chunks.length} (${recovered} recovered, ${partial} partial, ${failed} failed)`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (report.recovery.failedChunks > 0) {
|
|
414
|
+
const failed = report.recovery.failedChunks;
|
|
415
|
+
const retries = report.recovery.retryRequests;
|
|
416
|
+
const recovered = report.recovery.recoveredChunks;
|
|
417
|
+
lines.push(` recovery ${failed} failed chunk${failed === 1 ? "" : "s"}, ${retries} retry request${retries === 1 ? "" : "s"}, ${recovered} recovered`);
|
|
418
|
+
}
|
|
419
|
+
for (const row of report.rows) {
|
|
420
|
+
lines.push("");
|
|
421
|
+
lines.push(` owner ${row.owner}`);
|
|
422
|
+
lines.push(` native ${formatTokenAmount(row.native, report.nativeDecimals)} ${report.nativeSymbol}`);
|
|
423
|
+
report.tokens.forEach((token, index) => {
|
|
424
|
+
const balance = row.balances[index] ?? null;
|
|
425
|
+
const value = balance === null || token.decimals === null
|
|
426
|
+
? "unreadable"
|
|
427
|
+
: formatTokenAmount(balance, token.decimals);
|
|
428
|
+
const cached = token.cacheAgeMs == null ? "" : ` [cached ${formatCacheAge(token.cacheAgeMs)}]`;
|
|
429
|
+
lines.push(` ${columnLabel(token)} ${value}${cached}`);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
for (const warning of report.warnings) {
|
|
433
|
+
lines.push("");
|
|
434
|
+
lines.push(` warning ${warning}`);
|
|
435
|
+
}
|
|
436
|
+
if (report.fallbacks.length > 0) {
|
|
437
|
+
const count = report.fallbacks.length;
|
|
438
|
+
lines.push("");
|
|
439
|
+
lines.push(` fallbacks ${count} token${count === 1 ? "" : "s"} read individually`);
|
|
440
|
+
for (const fallback of report.fallbacks) {
|
|
441
|
+
const tag = fallback.label ? ` [${fallback.label}]` : "";
|
|
442
|
+
lines.push(` ${shortAddress(fallback.owner)} chunk ${fallback.chunk}/${fallback.chunks} ${fallback.token} ${fallback.symbol}${tag}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
for (const token of report.tokens) {
|
|
446
|
+
if (token.issue) {
|
|
447
|
+
lines.push("");
|
|
448
|
+
lines.push(` flag token ${shortAddress(token.address)}: ${token.issue}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return lines.join("\n");
|
|
452
|
+
}
|
|
453
|
+
export function toScanJson(report) {
|
|
454
|
+
return {
|
|
455
|
+
ok: true,
|
|
456
|
+
chain: { id: report.chainId, name: report.chainName },
|
|
457
|
+
blockNumber: report.blockNumber.toString(),
|
|
458
|
+
batchSize: report.batchSize,
|
|
459
|
+
chunkCount: report.chunkCount,
|
|
460
|
+
cache: { hits: report.cache.hits, misses: report.cache.misses },
|
|
461
|
+
recovery: {
|
|
462
|
+
failedChunks: report.recovery.failedChunks,
|
|
463
|
+
retryRequests: report.recovery.retryRequests,
|
|
464
|
+
recoveredChunks: report.recovery.recoveredChunks,
|
|
465
|
+
},
|
|
466
|
+
native: { symbol: report.nativeSymbol, decimals: report.nativeDecimals },
|
|
467
|
+
owners: report.owners,
|
|
468
|
+
tokens: report.tokens.map((token) => ({
|
|
469
|
+
address: token.address,
|
|
470
|
+
symbol: token.symbol,
|
|
471
|
+
label: token.label,
|
|
472
|
+
decimals: token.decimals,
|
|
473
|
+
issue: token.issue,
|
|
474
|
+
cacheAgeMs: token.cacheAgeMs,
|
|
475
|
+
})),
|
|
476
|
+
rows: report.rows.map((row) => ({
|
|
477
|
+
owner: row.owner,
|
|
478
|
+
native: row.native.toString(),
|
|
479
|
+
balances: row.balances.map((balance) => (balance === null ? null : balance.toString())),
|
|
480
|
+
})),
|
|
481
|
+
warnings: report.warnings,
|
|
482
|
+
chunks: report.chunks.map((chunk) => ({
|
|
483
|
+
owner: chunk.owner,
|
|
484
|
+
chunk: chunk.index,
|
|
485
|
+
total: chunk.total,
|
|
486
|
+
tokens: chunk.tokens,
|
|
487
|
+
status: chunk.status,
|
|
488
|
+
error: chunk.error,
|
|
489
|
+
individual: chunk.individual,
|
|
490
|
+
})),
|
|
491
|
+
fallbacks: report.fallbacks.map((fallback) => ({
|
|
492
|
+
owner: fallback.owner,
|
|
493
|
+
token: fallback.token,
|
|
494
|
+
symbol: fallback.symbol,
|
|
495
|
+
label: fallback.label,
|
|
496
|
+
chunk: fallback.chunk,
|
|
497
|
+
chunks: fallback.chunks,
|
|
498
|
+
error: fallback.error,
|
|
499
|
+
})),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export declare class SessionsArgError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export interface SessionsArgs {
|
|
5
|
+
path: string;
|
|
6
|
+
/** Only sessions that started at or after this epoch-ms time; null for no limit. */
|
|
7
|
+
since: number | null;
|
|
8
|
+
/** Only sessions whose id starts with one of these prefixes; empty for all. */
|
|
9
|
+
sessionPrefixes: string[];
|
|
10
|
+
/** Where to write the per-session CSV, "-" for stdout, or null for the table. */
|
|
11
|
+
csvPath: string | null;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseSessionsArgs(argv: string[]): SessionsArgs;
|
|
14
|
+
export declare function readWatchLog(path: string): string;
|
|
15
|
+
export interface SessionLatencyStats {
|
|
16
|
+
count: number;
|
|
17
|
+
p50: number;
|
|
18
|
+
p95: number;
|
|
19
|
+
max: number;
|
|
20
|
+
}
|
|
21
|
+
export interface SessionBlockTimeStats {
|
|
22
|
+
count: number;
|
|
23
|
+
medianSeconds: number;
|
|
24
|
+
minSeconds: number;
|
|
25
|
+
maxSeconds: number;
|
|
26
|
+
}
|
|
27
|
+
export type SessionState = "complete" | "aborted" | "partial";
|
|
28
|
+
export interface SessionSummary {
|
|
29
|
+
id: string;
|
|
30
|
+
chain: {
|
|
31
|
+
id: number;
|
|
32
|
+
name: string;
|
|
33
|
+
} | null;
|
|
34
|
+
/** Sanitised RPC endpoint from the `start` line; null in older logs. */
|
|
35
|
+
endpoint: string | null;
|
|
36
|
+
state: SessionState;
|
|
37
|
+
/** Epoch milliseconds of the `start` line; null when the log is missing it. */
|
|
38
|
+
startedAt: number | null;
|
|
39
|
+
endedAt: number | null;
|
|
40
|
+
durationMs: number;
|
|
41
|
+
samples: number;
|
|
42
|
+
latency: SessionLatencyStats;
|
|
43
|
+
blockTime: SessionBlockTimeStats;
|
|
44
|
+
stalls: number;
|
|
45
|
+
gasSurges: number;
|
|
46
|
+
alerts: number;
|
|
47
|
+
errors: number;
|
|
48
|
+
chainDrift: boolean;
|
|
49
|
+
firstBlock: bigint | null;
|
|
50
|
+
lastBlock: bigint | null;
|
|
51
|
+
advancedBy: bigint;
|
|
52
|
+
}
|
|
53
|
+
export interface EndpointSummary {
|
|
54
|
+
/** RPC host from the session's `start` line; null in older logs. */
|
|
55
|
+
endpoint: string | null;
|
|
56
|
+
sessions: number;
|
|
57
|
+
samples: number;
|
|
58
|
+
/** Percentiles pooled from every poll, so busy sessions weigh more. */
|
|
59
|
+
latency: SessionLatencyStats;
|
|
60
|
+
stalls: number;
|
|
61
|
+
gasSurges: number;
|
|
62
|
+
errors: number;
|
|
63
|
+
/** Stalls per poll across these sessions; 0 when there were no polls. */
|
|
64
|
+
stallRate: number;
|
|
65
|
+
}
|
|
66
|
+
export interface SessionsReport {
|
|
67
|
+
path: string;
|
|
68
|
+
sessions: SessionSummary[];
|
|
69
|
+
/** Sessions pooled by the RPC host that served them. */
|
|
70
|
+
endpoints: EndpointSummary[];
|
|
71
|
+
/** Lines that were not valid JSON objects, or had no session id. */
|
|
72
|
+
skippedLines: number;
|
|
73
|
+
}
|
|
74
|
+
export interface SessionsFilter {
|
|
75
|
+
/** Drop sessions that started before this epoch-ms time. */
|
|
76
|
+
since?: number | null;
|
|
77
|
+
/** Keep only sessions whose id starts with one of these prefixes. */
|
|
78
|
+
sessionPrefixes?: string[];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Group a watch NDJSON log by session id and summarise each run. Latency
|
|
82
|
+
* percentiles are computed from every poll in the session rather than the
|
|
83
|
+
* watch run's rolling window, so two sessions with different `--window`
|
|
84
|
+
* values are still comparable. A session without a `summary` line (killed, or
|
|
85
|
+
* still running) is kept and marked partial. `filter` narrows a long log before
|
|
86
|
+
* the comparison: prefixes drop non-matching lines, and `since` drops completed
|
|
87
|
+
* sessions that started too early.
|
|
88
|
+
*/
|
|
89
|
+
export declare function summarizeWatchLog(text: string, filter?: SessionsFilter): {
|
|
90
|
+
sessions: SessionSummary[];
|
|
91
|
+
endpoints: EndpointSummary[];
|
|
92
|
+
skippedLines: number;
|
|
93
|
+
};
|
|
94
|
+
export declare function formatSessions(report: SessionsReport): string;
|
|
95
|
+
export declare const SESSIONS_CSV_HEADER: readonly ["session", "started_at", "ended_at", "state", "chain", "chain_id", "endpoint", "duration_ms", "samples", "latency_p50_ms", "latency_p95_ms", "latency_max_ms", "block_time_median_s", "block_time_min_s", "block_time_max_s", "stalls", "gas_surges", "alerts", "errors", "chain_drift", "first_block", "last_block", "advanced_by"];
|
|
96
|
+
/**
|
|
97
|
+
* One row per session: identity, chain, RPC endpoint, and the same latency,
|
|
98
|
+
* block-time, and counter fields as the table. Missing measurements are empty
|
|
99
|
+
* cells rather than zeroes, so a spreadsheet can tell `no polls` from `0 ms`.
|
|
100
|
+
*/
|
|
101
|
+
export declare function sessionsCsv(report: Pick<SessionsReport, "sessions">): string;
|
|
102
|
+
/** Write the CSV, creating parent directories; an existing file is replaced. */
|
|
103
|
+
export declare function saveSessionsCsv(path: string, csv: string): void;
|
|
104
|
+
export declare function toSessionsJson(report: SessionsReport): Record<string, unknown>;
|