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,492 @@
1
+ import { buildClient, publicEndpoint } from "./chain.js";
2
+ import { parseLogSize } from "./watch.js";
3
+ export class CompareArgError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "CompareArgError";
7
+ }
8
+ }
9
+ const MIN_SAMPLES = 1;
10
+ const MAX_SAMPLES = 100;
11
+ const MIN_INTERVAL_SECONDS = 1;
12
+ const MAX_INTERVAL_SECONDS = 60;
13
+ function parseInteger(raw, flag, min, max) {
14
+ if (!/^\d+$/.test(raw)) {
15
+ throw new CompareArgError(`${flag} must be a whole number, got "${raw}".`);
16
+ }
17
+ const value = Number(raw);
18
+ if (value < min || value > max) {
19
+ throw new CompareArgError(`${flag} must be between ${min} and ${max}.`);
20
+ }
21
+ return value;
22
+ }
23
+ function parseEndpointSpec(raw) {
24
+ let label = null;
25
+ let urlValue = raw;
26
+ // `label=https://…` names a provider. A URL that merely contains `=` in its
27
+ // query is left alone because the part after the first `=` is not a URL.
28
+ const separator = raw.indexOf("=");
29
+ if (separator > 0) {
30
+ const candidate = raw.slice(0, separator);
31
+ const rest = raw.slice(separator + 1);
32
+ if (/^https?:\/\//i.test(rest)) {
33
+ if (!/^[A-Za-z0-9._-]+$/.test(candidate)) {
34
+ throw new CompareArgError(`endpoint label "${candidate}" must use letters, digits, dot, dash, or underscore.`);
35
+ }
36
+ label = candidate;
37
+ urlValue = rest;
38
+ }
39
+ }
40
+ let parsed;
41
+ try {
42
+ parsed = new URL(urlValue);
43
+ }
44
+ catch {
45
+ throw new CompareArgError(`"${urlValue}" is not a valid URL.`);
46
+ }
47
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
48
+ throw new CompareArgError(`${urlValue} must be an http(s) endpoint.`);
49
+ }
50
+ return { url: urlValue, label, authHeader: null };
51
+ }
52
+ const ENV_AUTH_PREFIX = "env:";
53
+ /**
54
+ * Environment variable that supplies a labeled endpoint's header: the label is
55
+ * uppercased and any dot, dash, or underscore becomes an underscore, so `drpc`
56
+ * reads LWC_RPC_AUTH_DRPC and `my-node` reads LWC_RPC_AUTH_MY_NODE.
57
+ */
58
+ export function endpointAuthEnvName(label) {
59
+ return `LWC_RPC_AUTH_${label.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
60
+ }
61
+ /**
62
+ * Resolve `env:NAME` to the named environment variable so the secret stays out
63
+ * of argv. Any other value is a literal header and is returned unchanged.
64
+ */
65
+ function resolveEndpointAuth(raw, env) {
66
+ if (!raw.startsWith(ENV_AUTH_PREFIX))
67
+ return raw;
68
+ const name = raw.slice(ENV_AUTH_PREFIX.length).trim();
69
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
70
+ throw new CompareArgError(`--endpoint-auth env: expects an environment variable name, got "${name}".`);
71
+ }
72
+ const value = env[name]?.trim();
73
+ if (!value) {
74
+ throw new CompareArgError(`--endpoint-auth names environment variable ${name}, but it is unset or empty.`);
75
+ }
76
+ return value;
77
+ }
78
+ export function parseCompareArgs(argv, env = process.env) {
79
+ const rawEndpoints = [];
80
+ const rawAuth = [];
81
+ let samples = 5;
82
+ let intervalMs = 1000;
83
+ let logPath = null;
84
+ let logMaxBytes = 0;
85
+ for (let index = 0; index < argv.length; index += 1) {
86
+ const arg = argv[index] ?? "";
87
+ const readValue = (flag) => {
88
+ const value = argv[index + 1];
89
+ if (!value)
90
+ throw new CompareArgError(`${flag} requires a value.`);
91
+ index += 1;
92
+ return value;
93
+ };
94
+ if (arg === "--samples") {
95
+ samples = parseInteger(readValue("--samples"), "--samples", MIN_SAMPLES, MAX_SAMPLES);
96
+ }
97
+ else if (arg === "--interval") {
98
+ intervalMs =
99
+ parseInteger(readValue("--interval"), "--interval", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS) * 1000;
100
+ }
101
+ else if (arg === "--endpoint-auth" || arg.startsWith("--endpoint-auth=")) {
102
+ const value = arg.startsWith("--endpoint-auth=")
103
+ ? arg.slice("--endpoint-auth=".length)
104
+ : readValue("--endpoint-auth");
105
+ if (!value)
106
+ throw new CompareArgError("--endpoint-auth requires a value.");
107
+ rawAuth.push(value);
108
+ }
109
+ else if (arg === "--log" || arg.startsWith("--log=")) {
110
+ const value = arg.startsWith("--log=")
111
+ ? arg.slice("--log=".length)
112
+ : readValue("--log");
113
+ if (!value)
114
+ throw new CompareArgError("--log requires a file path.");
115
+ logPath = value;
116
+ }
117
+ else if (arg === "--log-max-size" || arg.startsWith("--log-max-size=")) {
118
+ const value = arg.startsWith("--log-max-size=")
119
+ ? arg.slice("--log-max-size=".length)
120
+ : readValue("--log-max-size");
121
+ if (!value)
122
+ throw new CompareArgError("--log-max-size requires a size, e.g. 10mb.");
123
+ try {
124
+ logMaxBytes = parseLogSize(value);
125
+ }
126
+ catch (error) {
127
+ throw new CompareArgError(error instanceof Error ? error.message : String(error));
128
+ }
129
+ }
130
+ else if (arg.startsWith("-")) {
131
+ // Other flags (e.g. --json) are handled by the caller.
132
+ continue;
133
+ }
134
+ else {
135
+ rawEndpoints.push(arg);
136
+ }
137
+ }
138
+ if (rawEndpoints.length < 2) {
139
+ throw new CompareArgError("two RPC URLs are required: lwc compare <urlA> <urlB> [--samples 5] [--interval 1]");
140
+ }
141
+ if (rawEndpoints.length > 8) {
142
+ throw new CompareArgError("compare accepts at most 8 endpoints.");
143
+ }
144
+ const endpoints = rawEndpoints.map(parseEndpointSpec);
145
+ if (new Set(endpoints.map((endpoint) => endpoint.url)).size !== endpoints.length) {
146
+ throw new CompareArgError("the same endpoint was given twice; comparison needs two distinct URLs.");
147
+ }
148
+ const labels = endpoints
149
+ .map((endpoint) => endpoint.label)
150
+ .filter((label) => label !== null);
151
+ if (new Set(labels).size !== labels.length) {
152
+ throw new CompareArgError("endpoint labels must be unique.");
153
+ }
154
+ for (const entry of rawAuth) {
155
+ const separator = entry.indexOf("=");
156
+ if (separator <= 0) {
157
+ throw new CompareArgError('--endpoint-auth must look like "label=Bearer <token>" or "https://…=Bearer <token>".');
158
+ }
159
+ const key = entry.slice(0, separator).trim();
160
+ const rawValue = entry.slice(separator + 1).trim();
161
+ if (!key || !rawValue) {
162
+ throw new CompareArgError("--endpoint-auth needs both an endpoint and a header value.");
163
+ }
164
+ const value = resolveEndpointAuth(rawValue, env);
165
+ if (/[\r\n]/.test(value)) {
166
+ throw new CompareArgError("--endpoint-auth must be a single line.");
167
+ }
168
+ const target = endpoints.find((endpoint) => endpoint.label === key || endpoint.url === key);
169
+ if (!target) {
170
+ throw new CompareArgError(`--endpoint-auth names "${key}", which matches no endpoint. Use a label or a full URL.`);
171
+ }
172
+ if (target.authHeader !== null) {
173
+ throw new CompareArgError(`--endpoint-auth was given twice for "${key}".`);
174
+ }
175
+ target.authHeader = value;
176
+ }
177
+ // Labeled endpoints with no explicit --endpoint-auth pick up their own key,
178
+ // so one .env can hold keys for several providers without long argv lists.
179
+ // An explicit flag and LWC_RPC_AUTH both take precedence over this.
180
+ for (const target of endpoints) {
181
+ if (target.label === null || target.authHeader !== null)
182
+ continue;
183
+ const name = endpointAuthEnvName(target.label);
184
+ const value = env[name]?.trim();
185
+ if (!value)
186
+ continue;
187
+ if (/[\r\n]/.test(value)) {
188
+ throw new CompareArgError(`${name} must be a single line.`);
189
+ }
190
+ target.authHeader = value;
191
+ }
192
+ if (logMaxBytes > 0 && logPath === null) {
193
+ throw new CompareArgError("--log-max-size needs --log <path> so it has a file to rotate.");
194
+ }
195
+ return { endpoints, samples, intervalMs, logPath, logMaxBytes };
196
+ }
197
+ /** Nearest-rank percentile, matching the watch metrics. */
198
+ function pick(sorted, percentile) {
199
+ const rank = Math.ceil((percentile / 100) * sorted.length);
200
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))] ?? 0;
201
+ }
202
+ export function summarizeLatency(values) {
203
+ if (values.length === 0)
204
+ return { p50: 0, p95: 0, min: 0, max: 0, mean: 0 };
205
+ const sorted = [...values].sort((a, b) => a - b);
206
+ const total = values.reduce((sum, value) => sum + value, 0);
207
+ return {
208
+ p50: pick(sorted, 50),
209
+ p95: pick(sorted, 95),
210
+ min: sorted[0] ?? 0,
211
+ max: sorted[sorted.length - 1] ?? 0,
212
+ mean: Math.round(total / values.length),
213
+ };
214
+ }
215
+ function describe(error) {
216
+ if (error instanceof Error) {
217
+ const short = error.shortMessage;
218
+ if (typeof short === "string" && short.length > 0)
219
+ return short;
220
+ return error.message;
221
+ }
222
+ return String(error);
223
+ }
224
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
225
+ /**
226
+ * Probe every endpoint in the same round, in parallel, so the latency and head
227
+ * numbers describe the same wall-clock window on each RPC.
228
+ */
229
+ export async function compareEndpoints(config, args, deps = {}) {
230
+ const clientFor = deps.clientFor ?? ((target) => buildClient(target));
231
+ const now = deps.now ?? (() => Date.now());
232
+ const sleep = deps.sleep ?? defaultSleep;
233
+ const count = args.endpoints.length;
234
+ const clients = args.endpoints.map((endpoint) => clientFor({
235
+ ...config,
236
+ rpcUrl: endpoint.url,
237
+ // A per-endpoint header overrides LWC_RPC_AUTH; null inherits it.
238
+ authHeader: endpoint.authHeader ?? config.authHeader,
239
+ }));
240
+ const chainIds = new Array(count).fill(null);
241
+ const setupErrors = new Array(count).fill(null);
242
+ const latencies = args.endpoints.map(() => []);
243
+ const heads = args.endpoints.map(() => []);
244
+ const errors = new Array(count).fill(0);
245
+ const roundsBehind = new Array(count).fill(0);
246
+ const lastError = new Array(count).fill(null);
247
+ const headAges = new Array(count).fill(null);
248
+ const observations = args.endpoints.map(() => new Map());
249
+ let baselineHead = null;
250
+ await Promise.all(clients.map(async (client, index) => {
251
+ try {
252
+ chainIds[index] = await client.getChainId();
253
+ }
254
+ catch (error) {
255
+ setupErrors[index] = describe(error);
256
+ }
257
+ }));
258
+ for (let round = 0; round < args.samples; round += 1) {
259
+ const results = await Promise.all(clients.map(async (client, index) => {
260
+ const started = now();
261
+ try {
262
+ // viem caches eth_blockNumber for a few seconds by default; a benchmark
263
+ // must hit the network on every round or the latency reads as zero.
264
+ const blockNumber = await client.getBlockNumber({ cacheTime: 0 });
265
+ const observedAt = now();
266
+ latencies[index]?.push(Math.max(0, observedAt - started));
267
+ // Keep the first time this endpoint served each head; the earliest
268
+ // sighting across endpoints is the delivery baseline for that head.
269
+ const seen = observations[index];
270
+ if (seen && !seen.has(blockNumber))
271
+ seen.set(blockNumber, observedAt);
272
+ return { ok: true, blockNumber };
273
+ }
274
+ catch (error) {
275
+ errors[index] = (errors[index] ?? 0) + 1;
276
+ lastError[index] = describe(error);
277
+ return { ok: false };
278
+ }
279
+ }));
280
+ const freshest = results.reduce((best, result) => result.ok && (best === null || result.blockNumber > best) ? result.blockNumber : best, null);
281
+ // The head present in the first round is the baseline; anything above it
282
+ // was produced during the run and can be raced for first delivery.
283
+ if (round === 0)
284
+ baselineHead = freshest;
285
+ results.forEach((result, index) => {
286
+ if (!result.ok)
287
+ return;
288
+ heads[index]?.push(result.blockNumber);
289
+ if (freshest !== null && result.blockNumber < freshest) {
290
+ roundsBehind[index] = (roundsBehind[index] ?? 0) + 1;
291
+ }
292
+ });
293
+ if (round < args.samples - 1)
294
+ await sleep(args.intervalMs);
295
+ }
296
+ await Promise.all(clients.map(async (client, index) => {
297
+ try {
298
+ const block = await client.getBlock();
299
+ headAges[index] = Math.max(0, Math.round(now() / 1000 - Number(block.timestamp)));
300
+ }
301
+ catch {
302
+ // Age is a bonus metric; the head number already shows freshness.
303
+ }
304
+ }));
305
+ const finalHeads = heads.map((values) => values.length === 0 ? null : values.reduce((best, value) => (value > best ? value : best)));
306
+ const freshestHead = finalHeads.reduce((best, head) => (head !== null && (best === null || head > best) ? head : best), null);
307
+ const observedBlocks = new Set();
308
+ for (const seen of observations) {
309
+ for (const block of seen.keys())
310
+ observedBlocks.add(block);
311
+ }
312
+ const newHeads = [...observedBlocks]
313
+ .filter((block) => baselineHead === null || block > baselineHead)
314
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
315
+ const deliveries = args.endpoints.map(() => ({
316
+ wins: 0,
317
+ observed: 0,
318
+ misses: 0,
319
+ p50Ms: null,
320
+ p95Ms: null,
321
+ }));
322
+ const delayValues = args.endpoints.map(() => []);
323
+ for (const block of newHeads) {
324
+ const sightings = observations
325
+ .map((seen, index) => ({ index, at: seen.get(block) }))
326
+ .filter((sighting) => sighting.at !== undefined);
327
+ if (sightings.length === 0)
328
+ continue;
329
+ const earliest = Math.min(...sightings.map((sighting) => sighting.at));
330
+ for (const sighting of sightings) {
331
+ const delivery = deliveries[sighting.index];
332
+ delivery.observed += 1;
333
+ delayValues[sighting.index]?.push(sighting.at - earliest);
334
+ if (sighting.at === earliest)
335
+ delivery.wins += 1;
336
+ }
337
+ }
338
+ deliveries.forEach((delivery, index) => {
339
+ delivery.misses = newHeads.length - delivery.observed;
340
+ const delays = delayValues[index] ?? [];
341
+ if (delays.length > 0) {
342
+ const summary = summarizeLatency(delays);
343
+ delivery.p50Ms = summary.p50;
344
+ delivery.p95Ms = summary.p95;
345
+ }
346
+ });
347
+ const endpoints = args.endpoints.map((target, index) => {
348
+ const head = finalHeads[index] ?? null;
349
+ const chainId = chainIds[index] ?? null;
350
+ const chainMismatch = chainId !== null && chainId !== config.chainId;
351
+ const errorCount = errors[index] ?? 0;
352
+ const latency = summarizeLatency(latencies[index] ?? []);
353
+ return {
354
+ url: target.url,
355
+ label: target.label,
356
+ ok: errorCount === 0 && !chainMismatch && head !== null,
357
+ samples: args.samples,
358
+ errors: errorCount,
359
+ latency,
360
+ head,
361
+ headLag: head === null || freshestHead === null ? 0 : Number(freshestHead - head),
362
+ roundsBehind: roundsBehind[index] ?? 0,
363
+ headAgeSeconds: headAges[index] ?? null,
364
+ delivery: deliveries[index] ?? {
365
+ wins: 0,
366
+ observed: 0,
367
+ misses: 0,
368
+ p50Ms: null,
369
+ p95Ms: null,
370
+ },
371
+ chainId,
372
+ chainMismatch,
373
+ error: lastError[index] ?? setupErrors[index] ?? null,
374
+ };
375
+ });
376
+ const fastest = endpoints
377
+ .filter((endpoint) => endpoint.head !== null || endpoint.latency.max > 0)
378
+ .reduce((best, endpoint) => (best === null || endpoint.latency.p50 < best.latency.p50 ? endpoint : best), null);
379
+ const freshest = endpoints.find((endpoint) => endpoint.head !== null && endpoint.head === freshestHead);
380
+ return {
381
+ chainId: config.chainId,
382
+ chainName: config.name,
383
+ samples: args.samples,
384
+ intervalMs: args.intervalMs,
385
+ newBlocks: newHeads.length,
386
+ endpoints,
387
+ fastest: fastest?.url ?? null,
388
+ freshest: freshest?.url ?? null,
389
+ };
390
+ }
391
+ export function formatCompare(report) {
392
+ const nameOf = (endpoint) => endpoint.label ?? endpoint.url;
393
+ const urlWidth = Math.max(24, ...report.endpoints.map((endpoint) => nameOf(endpoint).length));
394
+ const lines = [
395
+ "lwc — compare",
396
+ "",
397
+ ` ${"chain".padEnd(14)}${report.chainName} (${report.chainId})`,
398
+ ` ${"samples".padEnd(14)}${report.samples} every ${report.intervalMs / 1000}s`,
399
+ ` ${"new blocks".padEnd(14)}${report.newBlocks}`,
400
+ "",
401
+ ` ${"endpoint".padEnd(urlWidth)} ${"p50".padStart(6)} ${"p95".padStart(6)} ${"head".padStart(10)} ${"lag".padStart(4)} ${"behind".padStart(7)} ${"wins".padStart(5)} ${"delay".padStart(6)} ${"age".padStart(5)} ${"err".padStart(4)}`,
402
+ ];
403
+ for (const endpoint of report.endpoints) {
404
+ const head = endpoint.head === null ? "n/a" : endpoint.head.toString();
405
+ const age = endpoint.headAgeSeconds === null ? "n/a" : `${endpoint.headAgeSeconds}s`;
406
+ const delay = endpoint.delivery.p50Ms === null ? "n/a" : `${endpoint.delivery.p50Ms}ms`;
407
+ lines.push(` ${nameOf(endpoint).padEnd(urlWidth)} ${`${endpoint.latency.p50}ms`.padStart(6)} ${`${endpoint.latency.p95}ms`.padStart(6)} ${head.padStart(10)} ${String(endpoint.headLag).padStart(4)} ${`${endpoint.roundsBehind}/${report.samples}`.padStart(7)} ${String(endpoint.delivery.wins).padStart(5)} ${delay.padStart(6)} ${age.padStart(5)} ${String(endpoint.errors).padStart(4)}`);
408
+ }
409
+ lines.push("");
410
+ const fastest = report.endpoints.find((endpoint) => endpoint.url === report.fastest);
411
+ if (fastest) {
412
+ lines.push(` ${"fastest".padEnd(14)}${nameOf(fastest)} (p50 ${fastest.latency.p50} ms)`);
413
+ }
414
+ const freshest = report.endpoints.find((endpoint) => endpoint.url === report.freshest);
415
+ if (freshest) {
416
+ lines.push(` ${"freshest".padEnd(14)}${nameOf(freshest)} (head ${freshest.head})`);
417
+ }
418
+ for (const endpoint of report.endpoints) {
419
+ if (endpoint.chainMismatch) {
420
+ lines.push(` warning ${nameOf(endpoint)} answered chain ${endpoint.chainId}, expected ${report.chainId}`);
421
+ }
422
+ }
423
+ for (const endpoint of report.endpoints) {
424
+ if (endpoint.errors > 0 && endpoint.error) {
425
+ lines.push(` warning ${nameOf(endpoint)}: ${endpoint.error} (${endpoint.errors} rounds)`);
426
+ }
427
+ }
428
+ return lines.join("\n");
429
+ }
430
+ function sanitizeMessage(message) {
431
+ return message.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => publicEndpoint(url));
432
+ }
433
+ /**
434
+ * One NDJSON record per comparison run. URLs are reduced to origin and path so
435
+ * a provider URL carrying an API key never reaches the log, and error text is
436
+ * scrubbed the same way.
437
+ */
438
+ export function toCompareLogRecord(report, session, at = Date.now()) {
439
+ return {
440
+ session,
441
+ event: "compare",
442
+ at: new Date(at).toISOString(),
443
+ chain: { id: report.chainId, name: report.chainName },
444
+ samples: report.samples,
445
+ intervalMs: report.intervalMs,
446
+ newBlocks: report.newBlocks,
447
+ fastest: report.fastest === null ? null : publicEndpoint(report.fastest),
448
+ freshest: report.freshest === null ? null : publicEndpoint(report.freshest),
449
+ endpoints: report.endpoints.map((endpoint) => ({
450
+ url: publicEndpoint(endpoint.url),
451
+ label: endpoint.label,
452
+ ok: endpoint.ok,
453
+ p50: endpoint.latency.p50,
454
+ p95: endpoint.latency.p95,
455
+ head: endpoint.head === null ? null : endpoint.head.toString(),
456
+ headLag: endpoint.headLag,
457
+ roundsBehind: endpoint.roundsBehind,
458
+ headAgeSeconds: endpoint.headAgeSeconds,
459
+ delivery: endpoint.delivery,
460
+ errors: endpoint.errors,
461
+ chainMismatch: endpoint.chainMismatch,
462
+ error: endpoint.error === null ? null : sanitizeMessage(endpoint.error),
463
+ })),
464
+ };
465
+ }
466
+ export function toCompareJson(report) {
467
+ return {
468
+ ok: report.endpoints.every((endpoint) => endpoint.ok),
469
+ chain: { id: report.chainId, name: report.chainName },
470
+ samples: report.samples,
471
+ intervalMs: report.intervalMs,
472
+ newBlocks: report.newBlocks,
473
+ fastest: report.fastest,
474
+ freshest: report.freshest,
475
+ endpoints: report.endpoints.map((endpoint) => ({
476
+ url: endpoint.url,
477
+ label: endpoint.label,
478
+ ok: endpoint.ok,
479
+ samples: endpoint.samples,
480
+ errors: endpoint.errors,
481
+ latency: endpoint.latency,
482
+ head: endpoint.head === null ? null : endpoint.head.toString(),
483
+ headLag: endpoint.headLag,
484
+ roundsBehind: endpoint.roundsBehind,
485
+ headAgeSeconds: endpoint.headAgeSeconds,
486
+ delivery: endpoint.delivery,
487
+ chainId: endpoint.chainId,
488
+ chainMismatch: endpoint.chainMismatch,
489
+ error: endpoint.error,
490
+ })),
491
+ };
492
+ }
@@ -0,0 +1,107 @@
1
+ import type { Abi, Address, ContractFunctionArgs, ContractFunctionName, ContractFunctionReturnType, PublicClient } from "viem";
2
+ import { type ChainConfig } from "./chain.ts";
3
+ /**
4
+ * ABI-typed read: functionName and args are checked against the ABI, and the
5
+ * return type is derived from the function's outputs.
6
+ */
7
+ export declare function readTyped<const TAbi extends Abi, TFunctionName extends ContractFunctionName<TAbi, "pure" | "view">>(client: PublicClient, contract: {
8
+ address: Address;
9
+ abi: TAbi;
10
+ }, functionName: TFunctionName, ...args: ContractFunctionArgs<TAbi, "pure" | "view", TFunctionName> & readonly unknown[]): Promise<ContractFunctionReturnType<TAbi, "pure" | "view", TFunctionName>>;
11
+ /** Bind a client and contract once, then call typed view functions directly. */
12
+ export declare function createReader<const TAbi extends Abi>(client: PublicClient, contract: {
13
+ address: Address;
14
+ abi: TAbi;
15
+ }): <TFunctionName extends ContractFunctionName<TAbi, "pure" | "view">>(functionName: TFunctionName, ...args: ContractFunctionArgs<TAbi, "pure" | "view", TFunctionName> & readonly unknown[]) => Promise<ContractFunctionReturnType<TAbi, "pure" | "view", TFunctionName>>;
16
+ export declare class ContractCallError extends Error {
17
+ readonly address: Address;
18
+ constructor(message: string, address: Address);
19
+ }
20
+ /** Canonical Multicall3 deployment, present on most EVM chains. */
21
+ export declare const MULTICALL3_ADDRESS: Address;
22
+ export declare const multicall3Abi: readonly [{
23
+ readonly type: "function";
24
+ readonly name: "getChainId";
25
+ readonly stateMutability: "view";
26
+ readonly inputs: readonly [];
27
+ readonly outputs: readonly [{
28
+ readonly name: "chainid";
29
+ readonly type: "uint256";
30
+ }];
31
+ }, {
32
+ readonly type: "function";
33
+ readonly name: "getBlockNumber";
34
+ readonly stateMutability: "view";
35
+ readonly inputs: readonly [];
36
+ readonly outputs: readonly [{
37
+ readonly name: "blockNumber";
38
+ readonly type: "uint256";
39
+ }];
40
+ }, {
41
+ readonly type: "function";
42
+ readonly name: "getCurrentBlockTimestamp";
43
+ readonly stateMutability: "view";
44
+ readonly inputs: readonly [];
45
+ readonly outputs: readonly [{
46
+ readonly name: "timestamp";
47
+ readonly type: "uint256";
48
+ }];
49
+ }];
50
+ /** Multicall3 batching surface: aggregate3 plus the native-balance helper. */
51
+ export declare const multicall3BatchAbi: readonly [{
52
+ readonly type: "function";
53
+ readonly name: "aggregate3";
54
+ readonly stateMutability: "view";
55
+ readonly inputs: readonly [{
56
+ readonly name: "calls";
57
+ readonly type: "tuple[]";
58
+ readonly components: readonly [{
59
+ readonly name: "target";
60
+ readonly type: "address";
61
+ }, {
62
+ readonly name: "allowFailure";
63
+ readonly type: "bool";
64
+ }, {
65
+ readonly name: "callData";
66
+ readonly type: "bytes";
67
+ }];
68
+ }];
69
+ readonly outputs: readonly [{
70
+ readonly name: "returnData";
71
+ readonly type: "tuple[]";
72
+ readonly components: readonly [{
73
+ readonly name: "success";
74
+ readonly type: "bool";
75
+ }, {
76
+ readonly name: "returnData";
77
+ readonly type: "bytes";
78
+ }];
79
+ }];
80
+ }, {
81
+ readonly type: "function";
82
+ readonly name: "getEthBalance";
83
+ readonly stateMutability: "view";
84
+ readonly inputs: readonly [{
85
+ readonly name: "addr";
86
+ readonly type: "address";
87
+ }];
88
+ readonly outputs: readonly [{
89
+ readonly name: "balance";
90
+ readonly type: "uint256";
91
+ }];
92
+ }];
93
+ export interface ExampleCallResult {
94
+ contract: Address;
95
+ chainId: bigint;
96
+ blockNumber: bigint;
97
+ blockTimestamp: bigint;
98
+ }
99
+ /**
100
+ * Example call against the configured chain: three view functions on Multicall3.
101
+ * These are self-verifying — getChainId() must agree with LWC_CHAIN_ID.
102
+ */
103
+ export declare function readExampleCall(client: PublicClient): Promise<ExampleCallResult>;
104
+ /** Cross-check the contract's answer against the configured chain id. */
105
+ export declare function assertContractChain(result: ExampleCallResult, config: ChainConfig): void;
106
+ export declare function formatExampleCall(result: ExampleCallResult, config: ChainConfig, latencyMs: number): string;
107
+ export declare function toExampleCallJson(result: ExampleCallResult, config: ChainConfig, latencyMs: number): Record<string, unknown>;