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,17 @@
1
+ import type { toPresetsJson } from "./presets.ts";
2
+ type PresetsJson = ReturnType<typeof toPresetsJson>;
3
+ export interface PresetsSummary {
4
+ /** Markdown for the CI run summary. */
5
+ markdown: string;
6
+ /** One line per failover, escaped and ready for a ::warning:: annotation. */
7
+ warnings: string[];
8
+ }
9
+ /** GitHub decodes annotation data through these escapes. */
10
+ export declare function escapeAnnotation(value: string): string;
11
+ /**
12
+ * Summarize a `lwc presets --json` report for a CI run. Failovers are called out
13
+ * by name rather than left in the `via` column, so a run that quietly fell back
14
+ * to a backup is visible in the run history.
15
+ */
16
+ export declare function summarizePresets(report: PresetsJson): PresetsSummary;
17
+ export {};
@@ -0,0 +1,55 @@
1
+ /** Table cells cannot hold pipes or newlines. */
2
+ function cell(value) {
3
+ return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
4
+ }
5
+ /** GitHub decodes annotation data through these escapes. */
6
+ export function escapeAnnotation(value) {
7
+ return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
8
+ }
9
+ function failoverLine(endpoint) {
10
+ return `${endpoint.preset}: primary ${endpoint.primaryHost} failed (${endpoint.primaryError ?? "unknown error"}); answered by ${endpoint.rpcHost}`;
11
+ }
12
+ /**
13
+ * Summarize a `lwc presets --json` report for a CI run. Failovers are called out
14
+ * by name rather than left in the `via` column, so a run that quietly fell back
15
+ * to a backup is visible in the run history.
16
+ */
17
+ export function summarizePresets(report) {
18
+ const failovers = report.endpoints.filter((endpoint) => endpoint.ok && endpoint.viaFallback);
19
+ const failures = report.endpoints.filter((endpoint) => !endpoint.ok);
20
+ const headline = failovers.length === 0
21
+ ? `**${report.healthy} of ${report.total} endpoints healthy** — every endpoint answered on its primary.`
22
+ : `**${report.healthy} of ${report.total} endpoints healthy**, ${failovers.length} served by a backup.`;
23
+ const rows = report.endpoints.map((endpoint) => {
24
+ const status = endpoint.ok ? "healthy" : "failed";
25
+ const latency = endpoint.ok && endpoint.latencyMs !== null ? `${endpoint.latencyMs} ms` : "—";
26
+ const block = endpoint.blockNumber ?? "—";
27
+ const via = endpoint.ok ? (endpoint.viaFallback ? "backup" : "primary") : "—";
28
+ return `| \`${endpoint.preset}\` | ${endpoint.chainId} | ${endpoint.nativeSymbol} | ${status} | ${latency} | ${block} | ${via} | \`${cell(endpoint.rpcHost)}\` |`;
29
+ });
30
+ const lines = [
31
+ "### Built-in preset health",
32
+ "",
33
+ headline,
34
+ "",
35
+ "| preset | chain | native | status | latency | block | via | endpoint |",
36
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
37
+ ...rows,
38
+ ];
39
+ if (failovers.length > 0) {
40
+ lines.push("", "#### Failovers", "");
41
+ for (const endpoint of failovers) {
42
+ lines.push(`- \`${endpoint.preset}\` — primary \`${endpoint.primaryHost}\` failed: ${cell(endpoint.primaryError ?? "unknown error")}; answered by \`${endpoint.rpcHost}\``);
43
+ }
44
+ }
45
+ if (failures.length > 0) {
46
+ lines.push("", "#### Failures", "");
47
+ for (const endpoint of failures) {
48
+ lines.push(`- \`${endpoint.preset}\` — ${cell(endpoint.error ?? "unknown error")}`);
49
+ }
50
+ }
51
+ return {
52
+ markdown: lines.join("\n"),
53
+ warnings: failovers.map((endpoint) => escapeAnnotation(failoverLine(endpoint))),
54
+ };
55
+ }
@@ -0,0 +1,59 @@
1
+ import type { PublicClient } from "viem";
2
+ import { type ChainConfig, type ChainPreset } from "./chain.ts";
3
+ export declare const DEFAULT_PROBE_TIMEOUT_MS = 10000;
4
+ export declare class PresetsArgError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ export interface PresetProbeResult {
8
+ preset: string;
9
+ chainId: number;
10
+ nativeSymbol: string;
11
+ /** Host that answered: the backup endpoint when the primary could not. */
12
+ rpcHost: string;
13
+ /** Host of the configured (primary) endpoint, for failover reporting. */
14
+ primaryHost: string;
15
+ /** True when the primary failed and the backup answered instead. */
16
+ viaFallback: boolean;
17
+ /** Why the primary was skipped, when viaFallback is true. */
18
+ primaryError: string | null;
19
+ ok: boolean;
20
+ latencyMs: number | null;
21
+ blockNumber: bigint | null;
22
+ error: string | null;
23
+ }
24
+ export interface PresetsReport {
25
+ total: number;
26
+ healthy: number;
27
+ failed: number;
28
+ endpoints: PresetProbeResult[];
29
+ }
30
+ export interface PresetProbeDeps {
31
+ /** Defaults to the real viem client; tests inject a stub. */
32
+ clientFor?: (config: ChainConfig) => PublicClient;
33
+ timeoutMs?: number;
34
+ /** Defaults to the built-in table; tests can narrow it. */
35
+ presets?: Record<string, ChainPreset>;
36
+ }
37
+ /**
38
+ * Probe every built-in preset in parallel and report each one separately, so a
39
+ * single dead endpoint cannot hide the others. A failed probe is data here, not
40
+ * an exception: the caller decides what an unhealthy endpoint means.
41
+ */
42
+ export declare function probePresets(deps?: PresetProbeDeps): Promise<PresetsReport>;
43
+ export declare function formatPresets(report: PresetsReport): string;
44
+ export declare function toPresetsJson(report: PresetsReport): {
45
+ total: number;
46
+ healthy: number;
47
+ failed: number;
48
+ endpoints: Array<Omit<PresetProbeResult, "blockNumber"> & {
49
+ blockNumber: string | null;
50
+ }>;
51
+ };
52
+ /**
53
+ * `lwc presets` takes --json, --timeout, and --history; --chain is meaningless
54
+ * here, since the command probes every preset.
55
+ */
56
+ export declare function parsePresetsArgs(argv: string[]): {
57
+ timeoutMs: number;
58
+ historyPath: string | null;
59
+ };
@@ -0,0 +1,181 @@
1
+ import { CHAIN_PRESETS, ChainMismatchError, buildClient, probeWithFallback, } from "./chain.js";
2
+ export const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
3
+ const MIN_PROBE_TIMEOUT_MS = 250;
4
+ const MAX_PROBE_TIMEOUT_MS = 120_000;
5
+ export class PresetsArgError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "PresetsArgError";
9
+ }
10
+ }
11
+ function describeFailure(error) {
12
+ if (error instanceof ChainMismatchError)
13
+ return `chain mismatch: ${error.message}`;
14
+ if (error instanceof Error)
15
+ return error.message;
16
+ return String(error);
17
+ }
18
+ function hostOf(rpcUrl) {
19
+ try {
20
+ return new URL(rpcUrl).host;
21
+ }
22
+ catch {
23
+ return "<invalid>";
24
+ }
25
+ }
26
+ /**
27
+ * Probe every built-in preset in parallel and report each one separately, so a
28
+ * single dead endpoint cannot hide the others. A failed probe is data here, not
29
+ * an exception: the caller decides what an unhealthy endpoint means.
30
+ */
31
+ export async function probePresets(deps = {}) {
32
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
33
+ const clientFor = deps.clientFor ?? buildClient;
34
+ const presets = deps.presets ?? CHAIN_PRESETS;
35
+ const names = Object.keys(presets).sort();
36
+ const endpoints = await Promise.all(names.map(async (name) => {
37
+ const preset = presets[name];
38
+ const config = {
39
+ chainId: preset.chainId,
40
+ name: preset.name,
41
+ rpcUrl: preset.rpcUrl,
42
+ fallbackRpcUrl: preset.fallbackRpcUrl,
43
+ nativeCurrency: {
44
+ name: preset.nativeSymbol,
45
+ symbol: preset.nativeSymbol,
46
+ decimals: 18,
47
+ },
48
+ authHeader: null,
49
+ timeoutMs,
50
+ preset: preset.name,
51
+ };
52
+ const base = {
53
+ preset: name,
54
+ chainId: preset.chainId,
55
+ nativeSymbol: preset.nativeSymbol,
56
+ primaryHost: hostOf(preset.rpcUrl),
57
+ };
58
+ try {
59
+ // The backup endpoint is part of the preset, so a dead primary still
60
+ // reports the chain as reachable and says which endpoint answered.
61
+ const outcome = await probeWithFallback(clientFor, config);
62
+ return {
63
+ ...base,
64
+ rpcHost: outcome.health.rpcHost,
65
+ viaFallback: outcome.usedFallback,
66
+ primaryError: outcome.primaryError,
67
+ ok: true,
68
+ latencyMs: outcome.health.latencyMs,
69
+ blockNumber: outcome.health.blockNumber,
70
+ error: null,
71
+ };
72
+ }
73
+ catch (error) {
74
+ return {
75
+ ...base,
76
+ rpcHost: hostOf(preset.rpcUrl),
77
+ viaFallback: false,
78
+ primaryError: null,
79
+ ok: false,
80
+ latencyMs: null,
81
+ blockNumber: null,
82
+ error: describeFailure(error),
83
+ };
84
+ }
85
+ }));
86
+ const healthy = endpoints.filter((endpoint) => endpoint.ok).length;
87
+ return {
88
+ total: endpoints.length,
89
+ healthy,
90
+ failed: endpoints.length - healthy,
91
+ endpoints,
92
+ };
93
+ }
94
+ export function formatPresets(report) {
95
+ const rows = report.endpoints.map((endpoint) => [
96
+ endpoint.preset,
97
+ String(endpoint.chainId),
98
+ endpoint.ok ? "healthy" : "failed",
99
+ endpoint.ok && endpoint.latencyMs !== null ? `${endpoint.latencyMs} ms` : "—",
100
+ endpoint.ok && endpoint.blockNumber !== null ? String(endpoint.blockNumber) : "—",
101
+ endpoint.ok ? (endpoint.viaFallback ? "backup" : "primary") : "—",
102
+ ]);
103
+ const headers = ["preset", "chain", "status", "latency", "block", "via"];
104
+ const widths = headers.map((header, column) => Math.max(header.length, ...rows.map((row) => row[column].length)));
105
+ const line = (cells) => ` ${cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()}`;
106
+ const lines = ["lwc — built-in presets", "", line(headers), ...rows.map(line)];
107
+ for (const endpoint of report.endpoints) {
108
+ if (!endpoint.ok) {
109
+ lines.push(` ${endpoint.preset}: ${endpoint.error ?? "unknown error"}`);
110
+ }
111
+ else if (endpoint.viaFallback) {
112
+ lines.push(` ${endpoint.preset}: primary failed (${endpoint.primaryError ?? "unknown error"}); answered by ${endpoint.rpcHost}`);
113
+ }
114
+ }
115
+ lines.push("", `${report.healthy} of ${report.total} endpoints healthy`);
116
+ return lines.join("\n");
117
+ }
118
+ export function toPresetsJson(report) {
119
+ return {
120
+ total: report.total,
121
+ healthy: report.healthy,
122
+ failed: report.failed,
123
+ endpoints: report.endpoints.map((endpoint) => ({
124
+ ...endpoint,
125
+ blockNumber: endpoint.blockNumber === null ? null : endpoint.blockNumber.toString(),
126
+ })),
127
+ };
128
+ }
129
+ function parseTimeout(raw) {
130
+ if (!/^\d+$/.test(raw)) {
131
+ throw new PresetsArgError(`--timeout must be whole milliseconds, got "${raw}".`);
132
+ }
133
+ const timeoutMs = Number(raw);
134
+ if (timeoutMs < MIN_PROBE_TIMEOUT_MS || timeoutMs > MAX_PROBE_TIMEOUT_MS) {
135
+ throw new PresetsArgError(`--timeout must be between ${MIN_PROBE_TIMEOUT_MS} and ${MAX_PROBE_TIMEOUT_MS} ms.`);
136
+ }
137
+ return timeoutMs;
138
+ }
139
+ /**
140
+ * `lwc presets` takes --json, --timeout, and --history; --chain is meaningless
141
+ * here, since the command probes every preset.
142
+ */
143
+ export function parsePresetsArgs(argv) {
144
+ let timeoutMs = DEFAULT_PROBE_TIMEOUT_MS;
145
+ let historyPath = null;
146
+ for (let index = 0; index < argv.length; index += 1) {
147
+ const arg = argv[index] ?? "";
148
+ if (arg === "--json")
149
+ continue;
150
+ if (arg === "--timeout") {
151
+ const value = argv[index + 1];
152
+ if (!value)
153
+ throw new PresetsArgError("--timeout requires a value.");
154
+ timeoutMs = parseTimeout(value);
155
+ index += 1;
156
+ continue;
157
+ }
158
+ if (arg.startsWith("--timeout=")) {
159
+ timeoutMs = parseTimeout(arg.slice("--timeout=".length));
160
+ continue;
161
+ }
162
+ if (arg === "--history") {
163
+ const value = argv[index + 1];
164
+ if (!value || value.startsWith("--")) {
165
+ throw new PresetsArgError("--history requires a file path.");
166
+ }
167
+ historyPath = value;
168
+ index += 1;
169
+ continue;
170
+ }
171
+ if (arg.startsWith("--history=")) {
172
+ const value = arg.slice("--history=".length);
173
+ if (!value)
174
+ throw new PresetsArgError("--history requires a file path.");
175
+ historyPath = value;
176
+ continue;
177
+ }
178
+ throw new PresetsArgError(`unexpected argument "${arg}".`);
179
+ }
180
+ return { timeoutMs, historyPath };
181
+ }
@@ -0,0 +1,63 @@
1
+ import type { Address } from "viem";
2
+ export interface PriceQuote {
3
+ price: number;
4
+ symbol: string | null;
5
+ /** Source timestamp in epoch seconds, when it provides one. */
6
+ timestamp: number | null;
7
+ }
8
+ export interface PriceBook {
9
+ /** Lowercased token address → quote. A token the feed cannot price is absent. */
10
+ tokens: Map<string, PriceQuote>;
11
+ native: PriceQuote | null;
12
+ /** Why the last refresh failed; null when it succeeded. */
13
+ error: string | null;
14
+ }
15
+ export interface PriceSourceOptions {
16
+ /** DefiLlama chain slug, e.g. "xlayer". */
17
+ chainSlug?: string;
18
+ /** Coin id used for the native balance. */
19
+ nativeId?: string;
20
+ baseUrl?: string;
21
+ /** Quotes older than this are refreshed. Default 60 s. */
22
+ ttlMs?: number;
23
+ timeoutMs?: number;
24
+ /** Injected for tests; defaults to Date.now. */
25
+ now?: () => number;
26
+ /** Injected for tests; defaults to the global fetch. */
27
+ fetchImpl?: typeof fetch;
28
+ }
29
+ export declare const DEFAULT_PRICE_BASE_URL = "https://coins.llama.fi";
30
+ export declare const DEFAULT_PRICE_TTL_MS = 60000;
31
+ /** OKB is the native currency of X Layer; DefiLlama prices it by CoinGecko id. */
32
+ export declare const DEFAULT_NATIVE_PRICE_ID = "coingecko:okb";
33
+ /** DefiLlama chain slug for a chain id; null when this kit has no feed for it. */
34
+ export declare function priceChainSlug(chainId: number): string | null;
35
+ /**
36
+ * USD value of a base-unit balance. Null when the balance, its decimals, or the
37
+ * price is missing, so an unpriced asset never counts as zero.
38
+ */
39
+ export declare function usdValue(balance: bigint | null, decimals: number | null, price: number | null): number | null;
40
+ /**
41
+ * Batched USD prices from DefiLlama's free coins API, cached for a TTL so a
42
+ * watch loop polling every few seconds makes at most one price request a minute.
43
+ * A refresh covers every requested token plus the native balance in one call.
44
+ * Failures never throw: the last known quotes are kept and `error` says why.
45
+ */
46
+ export declare class PriceSource {
47
+ private readonly chainSlug;
48
+ private readonly nativeId;
49
+ private readonly baseUrl;
50
+ private readonly ttlMs;
51
+ private readonly timeoutMs;
52
+ private readonly now;
53
+ private readonly fetchImpl;
54
+ private readonly tokenQuotes;
55
+ private nativeQuote;
56
+ private error;
57
+ constructor(options?: PriceSourceOptions);
58
+ prices(tokens: Address[]): Promise<PriceBook>;
59
+ private refresh;
60
+ /** Remember that these assets were checked, so a failure waits for the TTL. */
61
+ private markChecked;
62
+ private quoteOf;
63
+ }
package/dist/prices.js ADDED
@@ -0,0 +1,154 @@
1
+ export const DEFAULT_PRICE_BASE_URL = "https://coins.llama.fi";
2
+ export const DEFAULT_PRICE_TTL_MS = 60_000;
3
+ /** OKB is the native currency of X Layer; DefiLlama prices it by CoinGecko id. */
4
+ export const DEFAULT_NATIVE_PRICE_ID = "coingecko:okb";
5
+ const DEFAULT_TIMEOUT_MS = 5000;
6
+ /** DefiLlama chain slug for a chain id; null when this kit has no feed for it. */
7
+ export function priceChainSlug(chainId) {
8
+ return chainId === 196 ? "xlayer" : null;
9
+ }
10
+ /**
11
+ * USD value of a base-unit balance. Null when the balance, its decimals, or the
12
+ * price is missing, so an unpriced asset never counts as zero.
13
+ */
14
+ export function usdValue(balance, decimals, price) {
15
+ if (balance === null || decimals === null || price === null)
16
+ return null;
17
+ if (!Number.isFinite(price) || decimals < 0 || decimals > 36)
18
+ return null;
19
+ return (Number(balance) / 10 ** decimals) * price;
20
+ }
21
+ function objectOf(value) {
22
+ return value !== null && typeof value === "object" && !Array.isArray(value)
23
+ ? value
24
+ : null;
25
+ }
26
+ function numberField(value) {
27
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
28
+ }
29
+ function stringField(value) {
30
+ return typeof value === "string" ? value : null;
31
+ }
32
+ function describePriceError(error) {
33
+ if (error instanceof Error) {
34
+ const short = error.shortMessage;
35
+ if (typeof short === "string" && short.length > 0)
36
+ return short;
37
+ return error.message;
38
+ }
39
+ return String(error);
40
+ }
41
+ /**
42
+ * Batched USD prices from DefiLlama's free coins API, cached for a TTL so a
43
+ * watch loop polling every few seconds makes at most one price request a minute.
44
+ * A refresh covers every requested token plus the native balance in one call.
45
+ * Failures never throw: the last known quotes are kept and `error` says why.
46
+ */
47
+ export class PriceSource {
48
+ chainSlug;
49
+ nativeId;
50
+ baseUrl;
51
+ ttlMs;
52
+ timeoutMs;
53
+ now;
54
+ fetchImpl;
55
+ tokenQuotes = new Map();
56
+ nativeQuote = null;
57
+ error = null;
58
+ constructor(options = {}) {
59
+ this.chainSlug = options.chainSlug ?? "xlayer";
60
+ this.nativeId = options.nativeId ?? DEFAULT_NATIVE_PRICE_ID;
61
+ this.baseUrl = (options.baseUrl ?? DEFAULT_PRICE_BASE_URL).replace(/\/+$/, "");
62
+ this.ttlMs = options.ttlMs ?? DEFAULT_PRICE_TTL_MS;
63
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
64
+ this.now = options.now ?? (() => Date.now());
65
+ this.fetchImpl = options.fetchImpl ?? fetch;
66
+ }
67
+ async prices(tokens) {
68
+ const unique = [...new Map(tokens.map((token) => [token.toLowerCase(), token])).values()];
69
+ const now = this.now();
70
+ const stale = unique.some((token) => {
71
+ const cached = this.tokenQuotes.get(token.toLowerCase());
72
+ return cached === undefined || now - cached.at >= this.ttlMs;
73
+ });
74
+ const nativeStale = this.nativeQuote === null || now - this.nativeQuote.at >= this.ttlMs;
75
+ if (stale || nativeStale)
76
+ await this.refresh(unique);
77
+ const quotes = new Map();
78
+ for (const token of unique) {
79
+ const cached = this.tokenQuotes.get(token.toLowerCase());
80
+ if (cached?.quote)
81
+ quotes.set(token.toLowerCase(), cached.quote);
82
+ }
83
+ return {
84
+ tokens: quotes,
85
+ native: this.nativeQuote?.quote ?? null,
86
+ error: this.error,
87
+ };
88
+ }
89
+ async refresh(tokens) {
90
+ const keys = [
91
+ ...tokens.map((token) => `${this.chainSlug}:${token.toLowerCase()}`),
92
+ this.nativeId,
93
+ ];
94
+ const url = `${this.baseUrl}/prices/current/${keys.join(",")}`;
95
+ try {
96
+ const response = await this.fetchImpl(url, {
97
+ signal: AbortSignal.timeout(this.timeoutMs),
98
+ headers: { accept: "application/json" },
99
+ });
100
+ if (!response.ok) {
101
+ this.error = `HTTP ${response.status}`;
102
+ this.markChecked(tokens, this.now());
103
+ return;
104
+ }
105
+ const payload = (await response.json());
106
+ const coins = objectOf(payload.coins);
107
+ if (coins === null) {
108
+ this.error = "unexpected response from the price feed";
109
+ this.markChecked(tokens, this.now());
110
+ return;
111
+ }
112
+ // Keys come back in the feed's casing, so compare them lowercased.
113
+ const byKey = new Map();
114
+ for (const [key, value] of Object.entries(coins)) {
115
+ byKey.set(key.toLowerCase(), objectOf(value));
116
+ }
117
+ const at = this.now();
118
+ for (const token of tokens) {
119
+ const raw = byKey.get(`${this.chainSlug}:${token.toLowerCase()}`.toLowerCase()) ?? null;
120
+ const price = numberField(raw?.price);
121
+ this.tokenQuotes.set(token.toLowerCase(), {
122
+ quote: price === null ? null : this.quoteOf(price, raw),
123
+ at,
124
+ });
125
+ }
126
+ const nativeRaw = byKey.get(this.nativeId.toLowerCase()) ?? null;
127
+ const nativePrice = numberField(nativeRaw?.price);
128
+ this.nativeQuote = {
129
+ quote: nativePrice === null ? null : this.quoteOf(nativePrice, nativeRaw),
130
+ at,
131
+ };
132
+ this.error = null;
133
+ }
134
+ catch (error) {
135
+ this.error = describePriceError(error);
136
+ this.markChecked(tokens, this.now());
137
+ }
138
+ }
139
+ /** Remember that these assets were checked, so a failure waits for the TTL. */
140
+ markChecked(tokens, at) {
141
+ for (const token of tokens) {
142
+ const key = token.toLowerCase();
143
+ this.tokenQuotes.set(key, { quote: this.tokenQuotes.get(key)?.quote ?? null, at });
144
+ }
145
+ this.nativeQuote = { quote: this.nativeQuote?.quote ?? null, at };
146
+ }
147
+ quoteOf(price, raw) {
148
+ return {
149
+ price,
150
+ symbol: stringField(raw?.symbol),
151
+ timestamp: numberField(raw?.timestamp),
152
+ };
153
+ }
154
+ }
@@ -0,0 +1,19 @@
1
+ export interface RotatingLogOptions {
2
+ path: string;
3
+ /** Rotate before a write would push the file past this many bytes; 0 disables it. */
4
+ maxBytes: number;
5
+ }
6
+ /**
7
+ * Append-only NDJSON writer with one size-triggered backup. When the next line
8
+ * would exceed `maxBytes`, the active file is renamed to `<path>.1` (replacing
9
+ * the previous backup) and a fresh file starts, so the log never grows past the
10
+ * limit plus a single line. A JSON line is never split.
11
+ */
12
+ export declare class RotatingLog {
13
+ readonly path: string;
14
+ readonly maxBytes: number;
15
+ private bytes;
16
+ constructor(options: RotatingLogOptions);
17
+ append(text: string): void;
18
+ private rotate;
19
+ }
package/dist/rotate.js ADDED
@@ -0,0 +1,34 @@
1
+ import { appendFileSync, existsSync, renameSync, rmSync, statSync } from "node:fs";
2
+ /**
3
+ * Append-only NDJSON writer with one size-triggered backup. When the next line
4
+ * would exceed `maxBytes`, the active file is renamed to `<path>.1` (replacing
5
+ * the previous backup) and a fresh file starts, so the log never grows past the
6
+ * limit plus a single line. A JSON line is never split.
7
+ */
8
+ export class RotatingLog {
9
+ path;
10
+ maxBytes;
11
+ bytes;
12
+ constructor(options) {
13
+ this.path = options.path;
14
+ this.maxBytes = options.maxBytes;
15
+ this.bytes = existsSync(this.path) ? statSync(this.path).size : 0;
16
+ }
17
+ append(text) {
18
+ const line = text.endsWith("\n") ? text : `${text}\n`;
19
+ const size = Buffer.byteLength(line, "utf8");
20
+ // Rotating an empty file would just rename a zero-byte file, and a single
21
+ // line larger than the limit still has to be written somewhere.
22
+ if (this.maxBytes > 0 && this.bytes > 0 && this.bytes + size > this.maxBytes) {
23
+ this.rotate();
24
+ }
25
+ appendFileSync(this.path, line);
26
+ this.bytes += size;
27
+ }
28
+ rotate() {
29
+ const backup = `${this.path}.1`;
30
+ rmSync(backup, { force: true });
31
+ renameSync(this.path, backup);
32
+ this.bytes = 0;
33
+ }
34
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { type Address, type PublicClient } from "viem";
2
+ import type { ChainConfig } from "./chain.ts";
3
+ import { type BalancesOptions, type CacheStats, type ChunkStatus, type RecoverySummary, type TokenBalance } from "./balances.ts";
4
+ export declare class ScanArgError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ export interface ScanArgs {
8
+ owners: Address[];
9
+ tokens: Address[];
10
+ /** Lowercased token address → label from the token list file. */
11
+ labels: Map<string, string>;
12
+ /** `null` prints the table; "-" prints CSV; anything else is a file path. */
13
+ csvPath: string | null;
14
+ /** Tokens per Multicall3 request. */
15
+ batchSize: number;
16
+ /** Fixed sub-call cap per Multicall3 request, independent of cache state. */
17
+ maxSubCalls: number | null;
18
+ /** Exit non-zero when any chunk needed a retry or individual fallback. */
19
+ failOnRetry: boolean;
20
+ /** Suppress per-chunk progress on stderr; reports and JSON are unchanged. */
21
+ quiet: boolean;
22
+ /** Refresh cached metadata older than this many milliseconds. */
23
+ maxCacheAgeMs: number | null;
24
+ /** Warn when cached metadata is older than this many milliseconds; never refreshes. */
25
+ staleAfterMs: number | null;
26
+ /** Non-fatal label conflicts found while merging token sources. */
27
+ warnings: string[];
28
+ }
29
+ export interface ScanRow {
30
+ owner: Address;
31
+ native: bigint;
32
+ balances: (bigint | null)[];
33
+ }
34
+ export interface ScanChunkReport {
35
+ owner: Address;
36
+ index: number;
37
+ total: number;
38
+ tokens: number;
39
+ status: ChunkStatus;
40
+ error: string | null;
41
+ /** Tokens from this chunk read individually. */
42
+ individual: Address[];
43
+ }
44
+ export interface ScanFallback {
45
+ owner: Address;
46
+ token: Address;
47
+ symbol: string;
48
+ label: string | null;
49
+ /** 1-based chunk position. */
50
+ chunk: number;
51
+ chunks: number;
52
+ error: string | null;
53
+ }
54
+ export interface ScanReport {
55
+ chainId: number;
56
+ chainName: string;
57
+ blockNumber: bigint;
58
+ nativeSymbol: string;
59
+ nativeDecimals: number;
60
+ owners: Address[];
61
+ tokens: TokenBalance[];
62
+ /** Tokens per Multicall3 request selected for the owner reads. */
63
+ batchSize: number;
64
+ /** Total Multicall3 chunks attempted across owners. */
65
+ chunkCount: number;
66
+ rows: ScanRow[];
67
+ warnings: string[];
68
+ chunks: ScanChunkReport[];
69
+ /** Every token that had to be read individually, with its owner and chunk. */
70
+ fallbacks: ScanFallback[];
71
+ /** First-attempt failures and retry outcomes across owners. */
72
+ recovery: RecoverySummary;
73
+ /** Metadata cache hits and misses for this scan. */
74
+ cache: CacheStats;
75
+ }
76
+ export declare function parseScanArgs(argv: string[]): ScanArgs;
77
+ export declare function scanBalances(client: PublicClient, config: ChainConfig, owners: Address[], tokens: Address[], options?: BalancesOptions): Promise<ScanReport>;
78
+ export declare function csvCell(value: string): string;
79
+ export declare function scanCsv(report: ScanReport): string;
80
+ export declare function formatScan(report: ScanReport): string;
81
+ export declare function toScanJson(report: ScanReport): Record<string, unknown>;